Compare commits

..

1 Commits

Author SHA1 Message Date
renovate-bot 570f0a83b8 Update renovate/renovate Docker tag to v44.206.2
CI / full-gate (push) Successful in 3m33s
2026-11-03 04:19:16 +00:00
134 changed files with 1743 additions and 3857 deletions
+1 -1
View File
@@ -19,4 +19,4 @@ jobs:
run: | run: |
docker run --rm -v "$PWD:/repo" -w /repo \ docker run --rm -v "$PWD:/repo" -w /repo \
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \ -e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
node:26.13.0-alpine3.24 node registry-cleanup/cleanup.ts node:24.22.2-alpine3.24 node registry-cleanup/cleanup.ts
+6 -7
View File
@@ -22,7 +22,7 @@ jobs:
GIT_TAG: ${{ github.ref_name }} GIT_TAG: ${{ github.ref_name }}
run: | run: |
set -euo pipefail set -euo pipefail
docker run --rm -v "$PWD:/repo" -w /repo node:26.13.0-alpine3.24 \ docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
- name: Promote the commit-hash image to semver + latest - name: Promote the commit-hash image to semver + latest
env: env:
@@ -57,7 +57,7 @@ jobs:
COMMIT=$(git rev-parse 'HEAD^{commit}') COMMIT=$(git rev-parse 'HEAD^{commit}')
VERSION=${GIT_TAG#v} VERSION=${GIT_TAG#v}
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \ [ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (docs/ci-cd.md)"; exit 1; } || { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; }
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
TAGS="$VERSION ${VERSION%.*} latest" TAGS="$VERSION ${VERSION%.*} latest"
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
@@ -69,9 +69,8 @@ jobs:
if: always() if: always()
run: | run: |
set -uo pipefail set -uo pipefail
# This is cleanup: it must not fail a release that published, nor skip the overview job # Cleanup, and the runner's Docker config is shared (AGENTS.md) — a lost race here must not
# that follows. The runner's Docker config is shared, so a logout can lose a race # fail a release that published, nor skip the overview job that follows.
# (docs/decisions.md → "CI docker logins share the runner host's Docker config").
docker logout gitea.larvit.se || true docker logout gitea.larvit.se || true
docker logout docker.io || true docker logout docker.io || true
@@ -100,9 +99,9 @@ jobs:
VERSION=${INPUT_VERSION:-${GIT_TAG#v}} VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
VERSION=${VERSION#v} VERSION=${VERSION#v}
# An empty dispatch input falls back to the branch name, so gate this like a tag. # 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:26.13.0-alpine3.24 \ docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
docker run --rm -v "$PWD:/repo" -w /repo \ docker run --rm -v "$PWD:/repo" -w /repo \
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \ -e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
node:26.13.0-alpine3.24 \ node:24.22.2-alpine3.24 \
node release-tooling/dockerhub-overview.ts "$VERSION" node release-tooling/dockerhub-overview.ts "$VERSION"
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
-e RENOVATE_PLATFORM=gitea \ -e RENOVATE_PLATFORM=gitea \
-e RENOVATE_REPOSITORIES=${{ github.repository }} \ -e RENOVATE_REPOSITORIES=${{ github.repository }} \
-e RENOVATE_TOKEN \ -e RENOVATE_TOKEN \
renovate/renovate:44.222.3 renovate/renovate:44.206.2
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the # 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 # last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
@@ -60,11 +60,11 @@ jobs:
if [ -z "$BUMPS" ]; then if [ -z "$BUMPS" ]; then
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0 echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
fi fi
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:26.13.0-alpine3.24 \ NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/next-version.ts "$LATEST" $BUMPS) node release-tooling/next-version.ts "$LATEST" $BUMPS)
# Read the constant off origin/main, not the checkout, which lags the merges this run made. # 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 \ git show origin/main:src/plugin-host/plugin.ts \
| docker run -i --rm -v "$PWD:/repo" -w /repo node:26.13.0-alpine3.24 \ | docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/contract-version.ts "$NEXT" - node release-tooling/contract-version.ts "$NEXT" -
echo "Releasing $LATEST -> $NEXT" echo "Releasing $LATEST -> $NEXT"
git tag "$NEXT" origin/main git tag "$NEXT" origin/main
+270 -54
View File
@@ -10,9 +10,8 @@ change, sweep the prose you touched — this file, `README.md`, the example READ
comments — and cut it back to what a competent reader could not infer: 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 - **Delete history.** Git holds it. No "this moved from X", "used to be Y", "was tried and
rejected", "(declined twice)", dated changelog entries (a decision's date in `docs/decisions.md` rejected", "(declined twice)", dated changelog entries, or the symptom that prompted a fix. Record
is not one), or the symptom that prompted a fix. Record the decision and the reason it the decision and the reason it *currently* turns on, nothing else.
*currently* turns on, nothing else.
- **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that - **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, re-explains a table above it, a file-map entry that expands the filename. The fix is deletion,
not trimming. not trimming.
@@ -68,72 +67,289 @@ branch, create a PR and merge it when the CI/CD turns green.
**fails loud at boot/discovery** rather than sandboxing at runtime. Runtime crash-isolation is a **fails loud at boot/discovery** rather than sandboxing at runtime. Runtime crash-isolation is a
deliberate **non-goal**. deliberate **non-goal**.
## Decisions ## Deliberate architectural deviations (don't re-flag)
Each title below is an entry in `docs/decisions.md`, with its reasoning and the premise it rests Intentional, reasoned choices — an architecture review should honor them, not re-raise them.
on; a review honours them. Revisit only if the stated reason stops holding.
### Structure & contracts ### Structure & contracts
- `src/` is grouped by concern, and the core ships no domain screens - **`src/` is grouped by concern**, not flat — `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`,
- Plugins and config import the host only through the two barrels with `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` at the root; tests are
- The Ory client types are contract-visible through the barrel co-located. Add a new module to the folder owning its concern. The core ships **no domain
- The barrel is a package, so a plugin folder may carry its own `package.json` screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
- Plugin storage hands over credentials, not a client - **Plugins and config import the host only through a barrel** — `@plainpages/plugin-api` →
- Plugin settings are declared, not discovered `plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts`,
- The trust boundary is the `web` process, not the plugin never a relative `../../src/*` path. These two barrels are the whole contract surface; don't "fix"
- `ory/postgres/init/init.sql` is the only home for the Ory databases' ACL either back to a relative path. Three consequences:
- `bootstrap.ts` stays under `src/auth/` - `@plainpages/plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their
- `BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major
- `config/` is a plain dir with no `package.json` of its own `apiVersion` bump, not a free refactor.
- A plugin `package.json` without `"type": "module"` is refused, not warned - **The barrel is a package, not a `#`-import, so a plugin folder may carry its own
- `examples/` mirrors the drop-in mount dirs `package.json`** and depend on npm packages (README → Plugin dependencies). The Dockerfile links
- `ctx.chrome` is lazily memoized, never built unconditionally it into `/node_modules`, above every plugin scope. Never let a copy reach a plugin's own
- A plugin-owned render always runs on that plugin's context `node_modules`: two instances of the barrel break `instanceof` across the boundary, which
- Email is delegated to Kratos `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.**
- **`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 ### Authorization
- The vocabulary is `User` → `Group` → `Permission`, and there is no `Role` - **Vocabulary: `User` → `Group` → `Permission`, and there is no `Role`.** Keto ships no namespaces —
- A permission name is always `<resource>:<action>`, enforced at discovery all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one operation ("read shifts");
- Permission names are authored in plugin code; only grants live in Keto a role is a *bundle*, which here is just a group with several grants (groups nest). Ory's own
- A permission's `<resource>` is global, not plugin-scoped "permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
- Declaring a permission stays optional - **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
- `ADMIN_PERMISSIONS` defaults to empty, and an unusable value is dropped with a warning word names *who someone is* (a role), and roles are groups here. **Enforced at discovery**
- `actionForMethod` is plugin-local and stays out of `@plainpages/plugin-api` (`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route/nav
- A gate is one of `public`, `session` or `permission`, named exactly once `permission` and every declared name), fail-loud like any other manifest rule — not only in the
- The reference plugin's two shift pages duplicate a view model and markup admin GUI, which an operator removes by not copying it in.
- A `:read`-only holder is never shown a write affordance - **Names are authored in plugin code; only grants live in Keto.** The host collects every installed
- `users:write` and `groups:write` each amount to full administrative access plugin's declarations into one catalog (`declaredPermissions` → `ctx.declaredPermissions`), and
- The CSRF token is unbound to the session that catalog *is* the list the admin screens offer. Hence **no Permissions admin screen**: nothing
- Plainpages says "user" everywhere, where Ory says "identity" 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 gate is one of three, named exactly once, and `session` is a first-class one.** A route or nav
node names exactly one of `public`, `session`, `permission` — discovery refuses none, two, and a
flag spelled anything but `true`, so a forgotten gate fails the boot rather than publishing a page.
`src/auth/gate.ts` is the one home of the rule the plugin router, the host's own route table and
the menu all read. Exactly-one-gate is a discovery-time rule on manifests, not a runtime
invariant: `allows({}, user)` stays open **by design**, because the central override's `groups`
builds header nodes that carry no gate. Making `allows` fail closed would hide every
operator-grouped section. `session` exists because a plugin whose data is
the visitor's own — their upstream account, their own tokens — has no distinction a permission could
name; the alternative, granting every newly registered user a permission, couples the identity
lifecycle to a Keto write that nothing retries when it fails. A page scoped to "mine" joins on
`ctx.user.id`, never the email — an address is user-changeable and can be reassigned to someone
who would then inherit the previous holder's rows.
- **The reference plugin's two shift pages duplicate a view model and markup on purpose.** An example
is read far more often than it is changed, and each page reads top to bottom on its own. **Valid
while `examples/plugins/scheduling` stays a teaching artifact rather than a maintained product.**
- **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. The group paths are
guarded too: unticking a permission on a group you are in, removing yourself (or a nested group you
are in) from its members, or deleting the group is refused when you would lose a permission you
don't hold directly.
- **`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.
- **The CSRF token is deliberately unbound to the session** (`src/auth/csrf.ts`; README → Security
model): the usual cost of a signed double-submit token, covered by `SameSite=Lax` and HSTS.
**Valid while the session cookie stays `SameSite=Lax` or stricter.**
- **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 ### i18n
- The locale lives in the URL, never in a cookie - **The locale lives in the URL, never in a cookie.** `?locale=sv-SE` → `Accept-Language` → `en-US`,
- The core building blocks carry the locale, so a plugin doesn't have to and when the URL asked for one the host carries it onto the links it renders. A cookie would make a
- `locale` and the i18n view locals are reserved by the host page's language invisible in its address and unshareable; the cost is that a plugin wraps its own
- Catalogs are checked at boot, not at render hrefs. Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a lone language from
- `locales/` at the repo root is a drop-in mount `Accept-Language` takes the first regional catalog for it.
- The language picker is on every page, POST-rendered ones included - **The core building blocks carry the locale; a plugin doesn't have to.** The shell, `pagination`,
- An unknown translation key renders as itself `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every href in
- `t()` returns raw text, and the view escapes it `localeHref`; nav and sign-in are wrapped in `chrome.ts`; the two GET forms carry it as a hidden
- RTL is out of scope until there is a real use case `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 — as is `assetHref`
(`src/http/static.ts`), which stays undocumented for plugins because calling it is not yet contract.
- **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 ### UI
- A dropdown is a `<button popovertarget>` and a `[popover]`, never a `<details>` - **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
- The document is the one scroller owns open/close — the only zero-JS way to dismiss by clicking outside — and the panel sits in the
- `ICON_NAMES` is a host-owned registry, not a plugin contract 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**: every engine maintains the first itself on a declarative
`popovertarget` invoker, so a hand-written one replaces a live state with a static lie, and the
second would promise `role="menu"` semantics these panels don't implement. **That guarantee is the
declarative attribute's alone** — open a panel from script and no engine applies it, so
"enhancing" one of these triggers is what would cost it its accessibility. `<details>` stays where
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
the two in step.
- **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s
`overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame:
they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` —
a label click focuses it, and a browser scrolls a focused element into view.
- **`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 ### Build, test & release
- Dependencies install to `/node_modules`, above `WORKDIR /app` - **Deps install to `/node_modules`, above `WORKDIR /app`** — Node resolves upward, so dev's `.:/app`
- A container whose output a human then edits or deletes runs as the invoking user bind mount has nothing to shadow. Not a volume at `/app/node_modules`: the daemon creates a mount
- Anything the browser logs fails the E2E test that provoked it destination as root whatever `--user` says, leaving a root-owned dir in the checkout. Nothing may
- The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium sit at that path now — it shadows `/node_modules` silently (`src/compose.test.ts` guards the compose
- The docs-only CI skip is `*.md` anywhere in the tree files, `.dockerignore` the image).
- CI docker logins share the runner host's Docker config - **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 tests read, and a README-only change skips them. Accepted:
the `apiVersion` samples `release-tooling/contract-version.test.ts` checks are illustrative, and
the copies that matter (`examples/`, `views/`, the template) are gated. A Hub page command the
README dropped (`release-tooling/dockerhub-overview.test.ts`) fails the next gated change. **Valid while no markdown file is rendered or executed.**
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's
web-image build races another run's container creation on the `<project>-web` tag. Accepted for a
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
## Docker only — no host tooling ## Docker only — no host tooling
@@ -218,7 +434,7 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
`{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed `%`-encoding). `{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 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 URL shape, ignores the router's params, and has to re-handle HEAD. Factor shared per-request setup
into a small `withX` wrapper — see `examples/plugins/auth-admin/`. into a small `withX` wrapper — see `examples/plugins/admin/`.
- **A function stays within 50 lines and complexity 20** (ESLint's defaults, measured by - **A function stays within 50 lines and complexity 20** (ESLint's defaults, measured by
`src/function-limits.test.ts`). Split one that outgrows them; never add it to the test's `src/function-limits.test.ts`). Split one that outgrows them; never add it to the test's
`grandfathered` list or raise a ceiling there. `grandfathered` list or raise a ceiling there.
+6 -112
View File
@@ -6,113 +6,6 @@ at boot. Entries start at 0.3.0.
## Unreleased ## Unreleased
### Plugin contract 0.5: set `apiVersion` to `"0.5.0"`
The host's contract version is now 0.5.0, so discovery refuses a plugin whose `apiVersion` is still
`0.4.x`. Change the literal in each plugin's manifest when you upgrade, and add `assetHref` to any
`RequestContext` your tests build by hand: `assetHref: (href) => href`.
### The core dashboard view is `dashboard`, not `index`
`views/index.ejs` is now `views/dashboard.ejs`. The `apiVersion` check can't see this: a plugin view
that still does `include("index")` fails when it renders, with the 500 page. Change it to
`include("dashboard")`.
### `KetoClient.transactTuples` applies inserts and deletes as one transaction
`transactTuples({ delete, insert })` sends both lists to Keto in one `PATCH /admin/relation-tuples`,
so all of them apply or none does. A `KetoClient` you build by hand, such as a test stub, must add the
method. The admin example saves a user's or group's permissions through it, so a Keto failure leaves
every grant as it was and answers with the error page.
### The admin example deletes a group's grants and members in one Keto transaction
A Keto failure while deleting a group now leaves the group as it was, with its grants and members,
and answers with the error page. It used to leave some grants gone and the members still listed.
### The admin example removes a deleted group from the groups it was nested in
Deleting a group now also removes it from every group it was a member of, in the same Keto
transaction as the rest. It used to stay listed there, so a group re-created with the same name was
back in every parent. A parent whose only member was the deleted group is left with no members, and
so no longer exists as a group.
### The admin example keeps a user's permissions section when it refuses a self-deactivate or self-delete
The refusal page used to drop the permissions section of the user form.
### The admin example lists a user's or group's grants that no installed plugin declares, with a Remove
A direct grant of a name no installed plugin declares, such as one left by an uninstalled plugin,
used to stay invisible and unchangeable, and reinstalling the plugin silently reactivated it. The
user and group pages now list these apart from the checkboxes, each with a Remove button (hidden from
a `:read`-only holder). The example's catalogs gain `admin.grants.remove`, `admin.grants.removed`,
`admin.grants.undeclaredHint` and `admin.grants.undeclaredLegend`, and its routes gain
`POST /auth-admin/users/:id/permissions/remove` and `POST /auth-admin/groups/:name/permissions/remove`.
### An operator can hide or rename a plugin's profile-menu entry
A `profileMenu` entry takes an optional `id`, and `config/menu.ts`'s `override.hide` and
`override.rename` apply to it as they do to a nav node. Nav nodes and profile-menu entries share one
set of ids, so the boot refuses an entry whose `id` another entry or nav node already uses.
### The admin example plugin is now `auth-admin`, and its screens live at `/auth-admin/*`
The Users, Groups, OAuth2-clients and Plugin-settings screens move from `/admin/*` to
`/auth-admin/*`, and nothing redirects the old paths, so update any proxy rule, bookmark, alert or
log query that matches `/admin/`. An installed `plugins/admin/` still declares `apiVersion` 0.4, so
the boot refuses it: delete it, copy `examples/plugins/auth-admin/` into `plugins/`, and move any
`locales/plugins/admin/` to `locales/plugins/auth-admin/`.
### The `request` log line renames `method`, `path`, `status` and `ms`, and gains the caller, site and route
Update dashboards and log queries: the `request` line and the request span now use OpenTelemetry's
HTTP names. `method`, `path` and `status` become `http.request.method`, `url.path` and
`http.response.status_code`. `ms` is gone: latency is the span's duration, so it needs
`OTLP_ENDPOINT`. New are `http.route` (when a route matched), `server.address` (the `Host`),
`user_agent.original` and `client.address`. A plugin's own lines for a request carry them too,
except the status.
`client.address` is the socket's peer. Set `TRUSTED_PROXIES` (comma-separated IPs or CIDR ranges) to
the proxies in front of the host and it follows `X-Forwarded-For`; a bad entry refuses the boot.
README → Observability says which ranges to list. The log now holds callers' IPs and user agents,
which are personal data: say so in your privacy policy.
### `buildPaginationModel` builds the pagination partial's model
Call `buildPaginationModel(page, hrefFor, { label?, pageSizes?, pageParam?, pageSizeParam? })` from
`@plainpages/plugin-api` in place of mapping `paginate()`'s result to `partials/pagination` by hand.
`hrefFor(n)` is your list's URL on page n, and the rows-per-page form carries the same list state as
the links. A list that renamed its params in `parseListQuery` passes the same names here.
### A plugin can add links to the profile menu
Declare `profileMenu: [{ href, label, icon?, …gate }]` in the manifest. Each entry names exactly one
gate like a nav node, and shows above Sign out to a signed-in visitor who passes it. Discovery
refuses an entry without an `href`, a `label`, or exactly one gate.
### A plugin can version its own `/public/` files
Wrap the href of a script, image or other file your own markup links in `assetHref("/public/<id>/…")` in a view,
or `ctx.assetHref(…)` in a handler. The href gets the file's content hash, and the browser caches the
file for a year instead of revalidating it every five minutes.
### Discovery warns when a gate names a permission no plugin declares
A route or nav node that gates on a permission no installed plugin declares now logs a boot warning
naming the plugin and the permission. The admin screens offer only declared permissions, so nobody can
be granted such a gate from them. Add the name to the plugin's `permissions` to silence the warning.
### Discovery refuses a plugin with the id `error`
`/error` is the page Kratos sends a failed self-service flow to, and a plugin named `error` would
shadow it. Discovery now refuses that id at boot, like the other reserved ids. Rename such a plugin's
folder before upgrading.
### The admin's "group already exists" refusal is translated
Creating a group whose name is taken shows the refusal in the page's language, not always in English.
### A malformed URL variable's boot error no longer prints the value ### A malformed URL variable's boot error no longer prints the value
When a URL variable such as `PLUGIN_DB_URL`, `PLUGIN_DB_ADMIN_URL` or `OTLP_ENDPOINT` can't be parsed, When a URL variable such as `PLUGIN_DB_URL`, `PLUGIN_DB_ADMIN_URL` or `OTLP_ENDPOINT` can't be parsed,
@@ -190,19 +83,20 @@ Two administrators editing one user's or group's permissions no longer overwrite
form carries a token for the grants it showed. A save that would change something, made after those form carries a token for the grants it showed. A save that would change something, made after those
grants changed, is refused with "These permissions changed while you had the page open", and the page grants changed, is refused with "These permissions changed while you had the page open", and the page
shows the current grants. A POST with no token is refused the same way, so a script posting to shows the current grants. A POST with no token is refused the same way, so a script posting to
`/auth-admin/users/:id/permissions` or `/auth-admin/groups/:name/permissions` must first read the token from the `/admin/users/:id/permissions` or `/admin/groups/:name/permissions` must first read the token from the
page's `baseline` field. page's `baseline` field.
### The admin example confirms a permission save and reports a partial one ### The admin example confirms a permission save and reports a partial one
A save now lands on the page with "Permissions saved." On a user, a changed grant reaches the user's A save now lands on the page with "Permissions saved." If the permission service fails midway, the
live sessions at once when the revocation denylist is on. page says the save stopped part-way instead of showing the generic error page. On a user, the
changes that landed still reach the user's live sessions at once when the revocation denylist is on.
### A read-only holder sees View, not Edit, on the admin example's users list ### A read-only holder sees View, not Edit, on the admin example's users list
A holder of `users:read` without `users:write` gets a "View" row action with an eye icon. The admin A holder of `users:read` without `users:write` gets a "View" row action with an eye icon. The admin
example's catalogs gain `admin.grants.saved`, `admin.grants.stale` and example's catalogs gain `admin.grants.partial`, `admin.grants.saved`, `admin.grants.stale` and
`admin.users.view`, so a translated catalog mounted under `locales/plugins/auth-admin/` must add them. `admin.users.view`, so a translated catalog mounted under `locales/plugins/admin/` must add them.
### Every page carries a smaller icon sprite ### Every page carries a smaller icon sprite
+1 -1
View File
@@ -1,5 +1,5 @@
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag. # Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
FROM node:26.13.0-alpine3.24 FROM node:24.22.2-alpine3.24
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules # 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). # instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
+159 -103
View File
@@ -28,17 +28,17 @@ docker compose up -d # http://localhost:3000, live-reloads on source chan
**`admin@plainpages.local` / `admin`**. **`admin@plainpages.local` / `admin`**.
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups **3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
/ OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/auth-admin/*`: / OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
```bash ```bash
cp -r examples/plugins/auth-admin plugins/auth-admin cp -r examples/plugins/admin plugins/admin
docker compose up -d docker compose up -d
``` ```
The bootstrap grants the seeded admin every permission the installed plugins declare, so the The bootstrap grants the seeded admin every permission the installed plugins declare, so the
**Admin** section now shows in the menu. Use `up -d`, not `restart web` — the seed runs in the **Admin** section now shows in the menu. Use `up -d`, not `restart web` — the seed runs in the
one-shot `bootstrap` service, and only `up` re-runs it. See one-shot `bootstrap` service, and only `up` re-runs it. See
[`examples/plugins/auth-admin/`](examples/plugins/auth-admin/). [`examples/plugins/admin/`](examples/plugins/admin/).
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new **4. Add your first plugin.** The clone is bind-mounted into the container, so a new
folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`: folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`:
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
import { definePlugin } from "@plainpages/plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "0.5.0", apiVersion: "0.4.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [ routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) }, { method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
@@ -126,6 +126,7 @@ docker compose up -d
- [end-to-end](#end-to-end-playwright) - [end-to-end](#end-to-end-playwright)
- [the full gate](#the-full-gate-one-command) - [the full gate](#the-full-gate-one-command)
- [CI/CD](#cicd) - [CI/CD](#cicd)
- [one-time setup](#one-time-ci-setup)
- [Production & deployment](#production--deployment) - [Production & deployment](#production--deployment)
- [Upgrading](#upgrading) - [Upgrading](#upgrading)
- [Observability](#observability) - [Observability](#observability)
@@ -200,11 +201,11 @@ public front with an authenticated area behind it.
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design - **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
system + app shell, the config-driven menu, sessions, and access control. No domain screens. system + app shell, the config-driven menu, sessions, and access control. No domain screens.
- **Opt-in auth-admin plugin:** the **users, groups, and OAuth2-clients** screens ship as - **Opt-in admin plugin:** the **users, groups, and OAuth2-clients** screens ship as
[`examples/plugins/auth-admin/`](examples/plugins/auth-admin/) — an ordinary plugin, reaching Ory through [`examples/plugins/admin/`](examples/plugins/admin/) — an ordinary plugin, reaching Ory through
the privileged [`ctx.system`](#system-capabilities-the-ctxsystem-surface) surface. the privileged [`ctx.system`](#system-capabilities-the-ctxsystem-surface) surface.
- **You add:** everything else domain-specific, as **plugins** — built from the same building - **You add:** everything else domain-specific, as **plugins** — built from the same building
blocks the auth-admin plugin uses. blocks the admin plugin uses.
Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar
services. The shape doesn't change as it grows: every plugin is the same self-contained folder, services. The shape doesn't change as it grows: every plugin is the same self-contained folder,
@@ -321,9 +322,9 @@ Against the reference plugins' actual routes:
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` | | `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` | | `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` | | `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
| `GET /auth-admin/users` | `users:read` | 403 | 403 | ✅ | → `/login` | | `GET /admin/users` | `users:read` | 403 | 403 | ✅ | → `/login` |
| `POST /auth-admin/users` | `users:write` | 403 | 403 | ✅ | → `/login` | | `POST /admin/users` | `users:write` | 403 | 403 | ✅ | → `/login` |
| `GET /auth-admin/groups` | `groups:read` | 403 | 403 | 403 | → `/login` | | `GET /admin/groups` | `groups:read` | 403 | 403 | 403 | → `/login` |
Bob reaches the shifts list with no direct grant — `support` → `staff` → `scheduling:read`, two hops Bob reaches the shifts list with no direct grant — `support` → `staff` → `scheduling:read`, two hops
resolved by Keto at his login. An anonymous visitor gets a **redirect** carrying `return_to`, not a resolved by Keto at his login. An anonymous visitor gets a **redirect** carrying `return_to`, not a
@@ -333,7 +334,7 @@ door they cannot open.
### Granting a permission ### Granting a permission
Write the tuple. The auth-admin plugin's **Users** and **Groups** screens do exactly this — each offers Write the tuple. The admin plugin's **Users** and **Groups** screens do exactly this — each offers
the declared permissions as a checkbox list — or use Keto's write API directly: the declared permissions as a checkbox list — or use Keto's write API directly:
```bash ```bash
@@ -347,8 +348,8 @@ curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application
**A permission's name is authored in plugin code; only its *grants* live in Keto.** A plugin **A permission's name is authored in plugin code; only its *grants* live in Keto.** A plugin
declares the permissions it gates on (`permissions:` in the manifest) and the host collects them declares the permissions it gates on (`permissions:` in the manifest) and the host collects them
into one catalog — `ctx.declaredPermissions` — which is the fixed list the admin screens offer. into one catalog — `ctx.declaredPermissions` — which is the fixed list the admin screens offer.
Nothing in a GUI invents a name. A grant of a name that no installed plugin declares still reaches Nothing in a GUI invents a name, and a tuple naming something no installed plugin declares gates
the user's JWT, so a plugin may gate on that name without declaring it. nothing.
A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant
revoke](#instant-revoke-the-optional-denylist) when you need it sooner. revoke](#instant-revoke-the-optional-denylist) when you need it sooner.
@@ -416,9 +417,9 @@ URL/path-safe (`isValidPluginId`: lowercase `a–z`, digits, dashes) and also na
`views/`, its `/public/<id>/` assets, and by convention its nav/permission names. `views/`, its `/public/<id>/` assets, and by convention its nav/permission names.
`RESERVED_PLUGIN_IDS` are refused at discovery — the gated `dashboard`, the Kratos auth flows `RESERVED_PLUGIN_IDS` are refused at discovery — the gated `dashboard`, the Kratos auth flows
(`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`) and their `error` page, the `oauth2` (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`), the `oauth2`
provider routes, and `public` — since plugin routes resolve first and a folder claiming one would provider routes, and `public` — since plugin routes resolve first and a folder claiming one would
silently shadow a built-in. silently shadow a built-in. `admin` is **not** reserved: the admin screens are themselves a plugin.
Installing a plugin is "drop the folder, restart"; removing one is "delete the folder, restart". Installing a plugin is "drop the folder, restart"; removing one is "delete the folder, restart".
@@ -436,7 +437,7 @@ import { definePlugin } from "@plainpages/plugin-api";
import { listThings, createThings } from "./handlers.ts"; import { listThings, createThings } from "./handlers.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "0.5.0", // semver string of the host contract this plugin was built against (see Versioning) apiVersion: "0.4.0", // semver string of the host contract this plugin was built against (see Versioning)
// Nav fragment, merged into the global menu and gate-filtered per user. // Nav fragment, merged into the global menu and gate-filtered per user.
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
@@ -467,7 +468,6 @@ folder-derived `id` to produce the loaded `Plugin`.
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). | | `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
| `profileMenu` | no | `ProfileMenuEntry[]`: `{ href, label, icon?, id?, …gate }` links added above Sign out in the profile dropdown, shown only to a signed-in visitor. Each names [exactly one gate](#public-pages--menu-items); `icon`, `id` and `label` follow the same rules as a `nav` node. |
| `routes` | no | See [Routes & handlers](#routes--handlers). | | `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). | | `hooks` | no | See [Hooks](#hooks). |
| `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). | | `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). |
@@ -521,10 +521,8 @@ export async function listThings(ctx: RequestContext) {
`include()` the core building-block partials and its own. To load the plugin's own CSS, pass its `include()` the core building-block partials and its own. To load the plugin's own CSS, pass its
`/public/<id>/x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`. `/public/<id>/x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`.
The host adds the file's content hash to every `/public/` href the shell renders, so browsers and The host adds the file's content hash to every `/public/` href the shell renders, so browsers and
any cache in front of Plainpages keep the file for a year. Get the same for a script or image your any cache in front of Plainpages keep the file for a year; a `/public/` URL your own markup writes
own markup links by wrapping its href: `<script src="<%= assetHref("/public/<id>/x.js") %>" defer>` is kept for five minutes, then revalidated.
in a view, `ctx.assetHref(href)` in a handler. An unwrapped `/public/` URL is kept for five
minutes, then revalidated.
- **Finer authorization than the route `permission`** uses the guards from `@plainpages/plugin-api`: - **Finer authorization than the route `permission`** uses the guards from `@plainpages/plugin-api`:
`requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and `requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and
`check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw `check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw
@@ -563,7 +561,7 @@ import { definePlugin } from "@plainpages/plugin-api";
import { landing, board } from "./pages.ts"; import { landing, board } from "./pages.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "0.5.0", apiVersion: "0.4.0",
home: landing, // owns "/" — the public front page home: landing, // owns "/" — the public front page
dashboard: board, // owns "/dashboard" — the post-login app home dashboard: board, // owns "/dashboard" — the post-login app home
}); });
@@ -595,7 +593,6 @@ interface RequestContext {
locale: string; // the locale being served, e.g. "sv-SE" locale: string; // the locale being served, e.g. "sv-SE"
locales: string[]; // every installed locale, sorted locales: string[]; // every installed locale, sorted
localeHref(href): string; // carry an explicitly chosen locale onto a link this page renders localeHref(href): string; // carry an explicitly chosen locale onto a link this page renders
assetHref(href): string; // version a /public/ href so it is cached for a year; any other href unchanged
query: URLSearchParams; // alias of url.searchParams query: URLSearchParams; // alias of url.searchParams
req: IncomingMessage; req: IncomingMessage;
res: ServerResponse; res: ServerResponse;
@@ -607,7 +604,7 @@ interface RequestContext {
} }
``` ```
**`ctx.chrome`** — `{ brand, csrfToken, nav, signInHref, theme, user }` (`user.profileLinks` holds the profile-dropdown links). Hand each field to the **`ctx.chrome`** — `{ brand, csrfToken, nav, signInHref, theme, user }`. Hand each field to the
matching `partials/shell` local and a `view` result renders the **native app shell**, exactly as matching `partials/shell` local and a `view` result renders the **native app shell**, exactly as
`examples/plugins/scheduling/views/overview.ejs` does; a field you omit falls back to its shell `examples/plugins/scheduling/views/overview.ejs` does; a field you omit falls back to its shell
default rather than erroring. `chrome.nav` is the whole global menu — every installed plugin's default rather than erroring. `chrome.nav` is the whole global menu — every installed plugin's
@@ -651,7 +648,7 @@ interface SystemCapabilities { // every field optional — present only
`ctx.system` is **`undefined` unless the host wired at least one** of these. A system plugin treats `ctx.system` is **`undefined` unless the host wired at least one** of these. A system plugin treats
every field as optional and **degrades when absent** — the host never fails a request over it. The every field as optional and **degrades when absent** — the host never fails a request over it. The
**auth-admin plugin** ([`examples/plugins/auth-admin/`](examples/plugins/auth-admin/)) is the reference consumer: **admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the reference consumer:
Users uses `kratosAdmin`, Groups and the permission pickers `keto`, OAuth2 clients `hydra`, and a Users uses `kratosAdmin`, Groups and the permission pickers `keto`, OAuth2 clients `hydra`, and a
deactivate/delete or user permission-change calls `revoke` so the change lands before the JWT TTL; deactivate/delete or user permission-change calls `revoke` so the change lands before the JWT TTL;
a missing capability renders a themed 503. a missing capability renders a themed 503.
@@ -731,15 +728,14 @@ The host detects collisions across all discovered plugins with `findConflicts` a
| --- | --- | --- | | --- | --- | --- |
| `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. | | `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. |
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. | | `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
| `nav-id` | error | A nav node or profile-menu entry `id` is used more than once — the central override targets ids, so they must be unique across both. | | `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
| `setting` | error | Two settings resolve to the same `PLUGIN_SETTING_` variable name; rename a key or a plugin folder ([Plugin settings](#plugin-settings)). |
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). | | `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. Also warned: a route, nav node or profile-menu entry gates on a permission no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's `permissions`. | | `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. |
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
rejects **per-manifest shape errors**: a non-array `nav`/`permissions`/`profileMenu`/`routes`, a non-function rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a `home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
route, nav node or profile-menu entry that does not name [exactly one gate](#public-pages--menu-items). route/nav node that does not name [exactly one gate](#public-pages--menu-items).
### Hooks ### Hooks
@@ -854,7 +850,7 @@ camel humps both becoming underscores — so `upstream` on the `scheduling` plug
```ts ```ts
export default definePlugin({ export default definePlugin({
apiVersion: "0.5.0", apiVersion: "0.4.0",
settings: [ settings: [
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" }, { key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
{ key: "pageSize", type: "number", default: 25 }, { key: "pageSize", type: "number", default: 25 },
@@ -891,7 +887,7 @@ installed plugin declares is only *reported* — it is usually a typo in the one
set, and naming it turns two unrelated-looking errors into one. Declaring settings without an set, and naming it turns two unrelated-looking errors into one. Declaring settings without an
`onBoot` warns too: they resolve, but nothing receives them. `onBoot` warns too: they resolve, but nothing receives them.
**Reading what a deployment is configured with.** The auth-admin plugin's **Plugin settings** screen **Reading what a deployment is configured with.** The admin plugin's **Plugin settings** screen
(`plugin-settings:read`) lists every installed plugin, its declared keys, the variable that sets (`plugin-settings:read`) lists every installed plugin, its declared keys, the variable that sets
each, and whether the value came from the environment or the declared default. each, and whether the value came from the environment or the declared default.
@@ -908,7 +904,7 @@ import { definePlugin } from "@plainpages/plugin-api";
let sql: ReturnType<typeof postgres>; let sql: ReturnType<typeof postgres>;
export default definePlugin({ export default definePlugin({
apiVersion: "0.5.0", apiVersion: "0.4.0",
storage: true, storage: true,
hooks: { hooks: {
onBoot: async (boot) => { onBoot: async (boot) => {
@@ -1003,9 +999,9 @@ The menu is **driven entirely by config** and assembled from two sources:
1. **Plugin fragments** — each plugin contributes its own `nav` (above). 1. **Plugin fragments** — each plugin contributes its own `nav` (above).
2. **A central override** — `config/menu.ts` (loaded by `src/ui/menu-config.ts`, validated at 2. **A central override** — `config/menu.ts` (loaded by `src/ui/menu-config.ts`, validated at
boot) — where the operator reorders, renames, groups or hides nav nodes and renames or hides boot) — where the operator reorders, renames, groups, or hides items (by node `id`), and
profile-menu entries, each by its `id`, and sets branding (app name, logo, default theme). The sets branding (app name, logo, default theme). The override always wins, applied before
override always wins, applied before the per-user filter. A clean clone needs no `config/menu.ts`; defaults apply. the per-user filter. A clean clone needs no `config/menu.ts`; defaults apply.
`config/` is an **empty drop-in mount point** (like `plugins/`): it ships empty, and you `config/` is an **empty drop-in mount point** (like `plugins/`): it ships empty, and you
supply `config/menu.ts` by copying the template ([`examples/config/menu.ts`](examples/config/menu.ts)) supply `config/menu.ts` by copying the template ([`examples/config/menu.ts`](examples/config/menu.ts))
@@ -1024,7 +1020,7 @@ whenever a child does. The rendered tree is **filtered per user** from the sessi
per-request authz call), so the menu only shows what that person can reach. per-request authz call), so the menu only shows what that person can reach.
Branding (name, logo, default theme) renders in the app shell. Branding (name, logo, default theme) renders in the app shell.
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPageChrome`) renders **One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders
in the same app shell on **every** page — dashboard, plugin pages, and the login / registration / in the same app shell on **every** page — dashboard, plugin pages, and the login / registration /
recovery / front pages — so it looks identical signed in or out and just shows fewer items to an recovery / front pages — so it looks identical signed in or out and just shows fewer items to an
anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a
@@ -1043,26 +1039,10 @@ fully styled and zero-JS:
- **Partials:** app shell, nav tree, filter bar, data table (sort / select / row actions), - **Partials:** app shell, nav tree, filter bar, data table (sort / select / row actions),
pagination, form fields, badges, menus, auth cards. pagination, form fields, badges, menus, auth cards.
- **Helpers:** `composeNav` (menu from config), `parseListQuery` - **Helpers:** `composeNav` (menu from config), `parseListQuery`
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), (`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the
`buildPaginationModel` (page math → the pagination partial, sketched below), and the
auth guards (`src/auth/guards.ts`): `requireSession`, `can(permission)` (coarse JWT-claim check, auth guards (`src/auth/guards.ts`): `requireSession`, `can(permission)` (coarse JWT-claim check,
zero I/O), `check(relation, object)` (the one live Keto call). zero I/O), `check(relation, object)` (the one live Keto call).
A list page's pagination, from URL to partial (`examples/plugins/auth-admin/admin-users.ts` is the full
page):
```ts
const query = parseListQuery(ctx.url); // { q, filters, sort, page, pageSize }
const page = paginate(rows.length, query.page, query.pageSize); // or an upstream's total
const visible = rows.slice(page.from - 1, page.to);
const pagination = buildPaginationModel(page, (n) => listHref({ ...query, page: n }), { pageSizes: [25, 50, 100] });
// view: <%- include("partials/pagination", pagination) %>
```
`listHref` is yours: the list's URL with all its state. The rows-per-page form's hidden inputs are
read off it, so the form carries the same filters, search and sort as the page links. A list that
renamed its params in `parseListQuery` passes the same `pageParam`/`pageSizeParam` here.
## Interactivity: zero-JS spine ## Interactivity: zero-JS spine
The core and all building blocks **work with zero JavaScript** — theme switching and filtering are The core and all building blocks **work with zero JavaScript** — theme switching and filtering are
@@ -1084,7 +1064,7 @@ stylesheet using a feature Chromium 87, Safari 15 or Firefox 115 lacks, unless a
around it names that feature. It checks every stylesheet in `public/`, `examples/` and `plugins/`, around it names that feature. It checks every stylesheet in `public/`, `examples/` and `plugins/`,
so mount your plugin in `plugins/<id>/` and run it. A few constructs fail even when guarded, such as so mount your plugin in `plugins/<id>/` and run it. A few constructs fail even when guarded, such as
`@layer`, nested rules and media range syntax, while a media feature passes unguarded, since a `@layer`, nested rules and media range syntax, while a media feature passes unguarded, since a
browser without it skips the query; `src/test-tooling/stylesheet-floor.ts` lists the rules. browser without it skips the query; `src/ui/stylesheet-floor.ts` lists the rules.
**A field whose options depend on another field is asked for in steps, one GET form each**, so every **A field whose options depend on another field is asked for in steps, one GET form each**, so every
choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made. choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made.
@@ -1137,7 +1117,7 @@ catalog for it. There is **no locale cookie**: the URL is the only place a choic
link is shareable and a page is what its address says it is. When the URL asked for a language, the link is shareable and a page is what its address says it is. When the URL asked for a language, the
host carries `?locale=` onto every link *it* renders and `ctx.localeHref(href)` does the same for a host carries `?locale=` onto every link *it* renders and `ctx.localeHref(href)` does the same for a
plugin's. The picker lists every installed locale and renders **on every page**. After a POST the plugin's. The picker lists every installed locale and renders **on every page**. After a POST the
current URL may answer no GET (`POST /auth-admin/users/:id/recovery` has no GET sibling), so the host current URL may answer no GET (`POST /admin/users/:id/recovery` has no GET sibling), so the host
points the picker at this path when it answers GET, else the page the form was submitted from, else points the picker at this path when it answers GET, else the page the form was submitted from, else
`/` — switching language there leaves that POST's own result behind. `/` — switching language there leaves that POST's own result behind.
@@ -1208,7 +1188,7 @@ Rules worth knowing:
edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through
to them. Keep your catalog for your domain words. to them. Keep your catalog for your domain words.
- **Reserved view locals:** `t`, `locale`, `locales`, `localeHref`, `localeParam`, `localeSwitch`, - **Reserved view locals:** `t`, `locale`, `locales`, `localeHref`, `localeParam`, `localeSwitch`,
`dir`, and `assetHref`. They are merged after your `data`, so a colliding key of yours is ignored rather than `dir`, and the host-internal `assetHref`. They are merged after your `data`, so a colliding key of yours is ignored rather than
breaking the shell. `locale` is likewise reserved in `parseListQuery`, never returned as a filter. breaking the shell. `locale` is likewise reserved in `parseListQuery`, never returned as a filter.
**Kratos writes the auth flow's own text** (field labels, validation errors), tagging each string **Kratos writes the auth flow's own text** (field labels, validation errors), tagging each string
@@ -1239,7 +1219,6 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
| `SERVICE_NAME` | `plainpages` | OTLP `service.name` on every log + span — brand it as your own deployment | | `SERVICE_NAME` | `plainpages` | OTLP `service.name` on every log + span — brand it as your own deployment |
| `OTLP_ENDPOINT` | _unset_ | OpenTelemetry Collector HTTP base URI; set ⇒ export logs + traces (unset ⇒ console only) | | `OTLP_ENDPOINT` | _unset_ | OpenTelemetry Collector HTTP base URI; set ⇒ export logs + traces (unset ⇒ console only) |
| `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` | | `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` |
| `TRUSTED_PROXIES` | _unset_ | comma-separated IPs/CIDR ranges of the reverse proxies in front of the host; only a request from one lets `X-Forwarded-For` name the caller in logs and spans ([Observability](#observability)) |
| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) | | `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) |
| `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | authorization check / write | | `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | authorization check / write |
| `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) | | `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) |
@@ -1292,9 +1271,9 @@ override sets `APP_URL=http://localhost:3000`, and `127.0.0.1` is canonicalised
A clean clone needs **none** of the above. What can't be auto-generated is **production-only**: A clean clone needs **none** of the above. What can't be auto-generated is **production-only**:
1. **Production secrets** — every value below ships as a committed dev throwaway and **must** be 1. **Production secrets** — every value below ships as a committed dev throwaway and **must** be
replaced before a deploy faces the internet. Of them, `REQUIRE_SECURE_SECRETS=true` checks only replaced before a deploy faces the internet. Only the first is enforced:
`CSRF_SECRET`, and the flag's [Configuration](#configuration) row lists what else it checks; the `REQUIRE_SECURE_SECRETS=true` refuses to boot on a missing or throwaway `CSRF_SECRET` and
rest fail silently, so treat the table as a checklist. **nothing else** — the rest fail silently, so treat this as a checklist.
| Secret | Where | Protects | | Secret | Where | Protects |
| --- | --- | --- | | --- | --- | --- |
@@ -1363,8 +1342,8 @@ be `metadata_public`, not `metadata_admin` — the session Kratos hands the toke
public metadata, and the user can already read these permissions in their own JWT. The projection is public metadata, and the user can already read these permissions in their own JWT. The projection is
a per-login cache, authoritative nowhere; a stale one self-heals on the next login. a per-login cache, authoritative nowhere; a stale one self-heals on the next login.
Login resolves both direct grants and group membership, so the JWT `permissions` match what Keto Login resolves both direct grants and group membership, so the JWT `permissions` match what the
says the user holds. Cost: **a handful of Keto reads + one identity refresh per admin **Effective access** view shows. Cost: **a handful of Keto reads + one identity refresh per
login**, never per request. JWKS is cached, so signature verification hits the network only on key login**, never per request. JWKS is cached, so signature verification hits the network only on key
rotation. rotation.
@@ -1425,7 +1404,7 @@ Nothing in the menu or first-party pages needs Hydra.
post-logout redirect. The first-party `POST /logout` still owns ending the Kratos session and our post-logout redirect. The first-party `POST /logout` still owns ending the Kratos session and our
JWT cookie. JWT cookie.
Clients are registered from the auth-admin plugin's **OAuth2 clients** screen (`/auth-admin/clients`): Hydra Clients are registered from the admin plugin's **OAuth2 clients** screen (`/admin/clients`): Hydra
shows the generated `client_secret` **once**, on the confirmation page. Confidential vs public shows the generated `client_secret` **once**, on the confirmation page. Confidential vs public
(PKCE) and the first-party auto-consent flag are set at registration; writes go only to Hydra. (PKCE) and the first-party auto-consent flag are set at registration; writes go only to Hydra.
@@ -1460,8 +1439,8 @@ unauthenticated Ory ports. Install only what you trust, and let the plugin's own
Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing
**every** committed dev secret ([what you must supply](#what-you-must-supply-the-only-manual-prep)). **every** committed dev secret ([what you must supply](#what-you-must-supply-the-only-manual-prep)).
`REQUIRE_SECURE_SECRETS` guards only the secrets its [Configuration](#configuration) row lists; `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres'
nothing fails loud if you ship Ory's, Postgres' or the demo admin's throwaways. or the demo admin's throwaways.
## Email ## Email
@@ -1584,7 +1563,109 @@ gate runs them serially, one stack up/down per suite.
## CI/CD ## CI/CD
[The pipeline, releases and CI setup](docs/ci-cd.md). Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
[`ci.sh`](#the-full-gate-one-command) — the exact gate you run locally:
| Workflow | Trigger | Does |
| --- | --- | --- |
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image |
| `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `latest` (plus `X` once major ≥ 1), sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger |
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) |
| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged |
`main` is not re-tested on push — its commits are meant to arrive already green from a
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
**Merge gate on `main`** (Gitea branch protection + repo merge settings, set via the API —
no repo files involved): direct pushes are blocked, changes land via PR only, the
`CI / full-gate (push)` status must be green (admins included), and the only merge style is
**fast-forward-only** — history stays linear and `main`'s head is the exact commit hash of
the merged branch, which is why the branch's push-triggered status carries over.
**Container images** — after a green gate, `ci.yml` builds the app image and pushes it as
`gitea.larvit.se/larvit/plainpages:<full commit hash>`. Because merges are fast-forward-only, the
image for any `main` commit already exists from that commit's branch gate — build once, promote by
re-tagging. The build runs inside the required gate, so a missing/expired token or a registry outage
blocks **all** merges until restored; use a non-expiring token or track its expiry. Hash tags
accumulate one image per gated push, so nightly `registry-cleanup.yml` prunes them
([`registry-cleanup/cleanup.ts`](registry-cleanup/cleanup.ts) defines what survives). Don't add a
pattern-based org cleanup rule for this package — its age/count heuristics can't see branch heads or
release tags and would delete images the workflow protects.
**Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs
`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `latest` and —
once the major reaches `1` — `1`; nothing is rebuilt, so the released image is byte-identical to the
gated one. While the major is `0` the bare-major tag is skipped, because a `0.x` minor is a contract
break and a moving `:0` would carry one. It fails loud if no
hash image exists — release tags must point at a commit that went through the gate. The same four
tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only.
The [contract check](#contract-versioning) guards the tag before anything is published, refusing one
whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to set.
**The Docker Hub overview** is published by a separate `publish-overview` job from
[`release-tooling/dockerhub-overview.md.tmpl`](release-tooling/dockerhub-overview.md.tmpl), with
`{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go
stale. The same job sets the page's short description. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the
quick start stays a topology CI has actually run.
It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
outage leaves the promotion green and the images untouched; and the page has its own door — run the
workflow manually with an `overview_version` input to republish it without cutting a release. That
input goes through the same contract check as a tag: a non-semver value, or one whose `major.minor`
disagrees with the tree being published, is refused. It uses
the same `DOCKERHUB_TOKEN` the image push uses, which is why that token needs the **delete** scope.
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is
read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift.
Tags go with `--prune`, so deleting one here deletes it there on the next run, and a tag *created* on
GitHub is swept away — cut releases on Gitea, never on the mirror. Branches other than `main` match
no refspec and are left alone.
**Dependency updates** — `renovate.yml` runs [Renovate](https://docs.renovatebot.com) nightly
(self-hosted, this repo only) against [`renovate.json`](renovate.json), bumping npm deps, Docker base
images, Gitea action versions, and the image tags pinned inside workflow `run:` steps (a custom regex
manager, so nothing pinned drifts unmanaged). Version-locked sets move together in one PR — the Ory
images, and the Playwright runner + its browser image — and every bump keeps the **exact semver pin**
exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once
`CI / full-gate (push)` is green; only a red gate needs a human.
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`) cuts **one**
`vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and
**skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a
trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a
`Release-Bump: <updateType>` trailer onto the updates that reach a running Plainpages — the rules in
[`renovate.json`](renovate.json) name them — and
[`release-tooling/next-version.ts`](release-tooling/next-version.ts) turns the highest one into the next
version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release
version, an update big enough to reach a **minor** stops the job rather than tagging: bump
`HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since
`nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump,
so the trailer is an allowlist: an update outside those rules carries none and rides the next patch
release instead of escalating it. It is **tag-only**: the tag hands off to
`release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by
the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here.
### One-time CI setup
| Actions var / secret | Value |
| --- | --- |
| `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. |
| `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a **read/write/delete** token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT reaches every repo under it, and delete is destructive). Delete is what publishing the overview needs; pushing images alone would not. |
| `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. |
| `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. |
| `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. |
Gitea rejects secret names prefixed `GITEA_`/`GITHUB_`. Each job fails loud until its secret exists.
The container package is **org-owned**, so it lists under `larvit/-/packages` — link it to the repo's
Packages tab once: `POST /api/v1/packages/larvit/container/plainpages/-/link/plainpages`.
**The runner** — register one
[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode labelled `docker-host`
(`labels: ["docker-host:host"]`) on a machine with Docker Engine + Compose, git, and Node +
github.com access. Runs must **never overlap** — the e2e stacks use fixed compose project names, the
devstack suite uses host networking, and the workflows share the Docker daemon's registry login — so
keep exactly one runner at capacity 1, with host ports 3000/4433 free.
## Production & deployment ## Production & deployment
@@ -1635,7 +1716,7 @@ tightened a manifest rule since. Discovery fails loud at boot rather than runnin
honour, naming the plugin and the rule: honour, naming the plugin and the rule:
```bash ```bash
rm -rf plugins/auth-admin && cp -r examples/plugins/auth-admin plugins/auth-admin rm -rf plugins/admin && cp -r examples/plugins/admin plugins/admin
docker compose up -d --build docker compose up -d --build
``` ```
@@ -1662,32 +1743,9 @@ Logging is **structured** and **OTLP-native**, on
handler (an `AsyncLocalStorage`), so logs and traces correlate. Three explicit toggles: handler (an `AsyncLocalStorage`), so logs and traces correlate. Three explicit toggles:
`LOG_LEVEL`, `LOG_FORMAT` (`text` dev / `json` prod) and `SERVICE_NAME`. `LOG_LEVEL`, `LOG_FORMAT` (`text` dev / `json` prod) and `SERVICE_NAME`.
Every request emits one access line, and its span carries the same fields, named by Every request emits one access line (`method`, `path` — the query is dropped, it can carry tokens —
OpenTelemetry's HTTP conventions: `status`, `ms`, `requestId`); login/logout, admin writes and missing-permission/CSRF rejections log
at `info`/`warn`, the catch-all 500 and Ory-unreachable re-mint at `error`/`warn`. An inbound W3C
- `http.request.method`, `url.path` (the query is dropped, it can carry tokens),
`http.response.status_code`, `server.address` (the `Host`), `user_agent.original`,
`client.address` and `requestId`.
- `http.route`: the matched pattern, such as `/auth-admin/users/:id`. It is absent when no route
matched (a 404, a 405, a `/public/` file, a redirect) and on a plugin's `onRequest` short-circuit.
- Latency is the span's duration, so it reaches you only with `OTLP_ENDPOINT` set. OTLP carries every
attribute as a string.
A plugin's own lines for a request carry them too, except the status, which is known only when
the response closes.
**These are personal data.** An IP and a user agent identify a caller, so a deployment's privacy
policy must say the host logs them.
`client.address` is the socket's peer. Behind a reverse proxy that is the proxy, so set
`TRUSTED_PROXIES` to its IPs or CIDR ranges: for a request from a listed peer, the address is the
nearest one in `X-Forwarded-For` that is not itself listed. Anything else in the header is
ignored. List only addresses a caller cannot send from. In Docker, `compose.yml` publishes the port
on every interface, so a range that includes the bridge gateway lets a direct caller write any
`client.address`.
Login/logout, admin writes and missing-permission/CSRF rejections log at `info`/`warn`, the
catch-all 500 and Ory-unreachable re-mint at `error`/`warn`. An inbound W3C
`traceparent` is **adopted**, continuing a trace started upstream. `traceparent` is **adopted**, continuing a trace started upstream.
**Distributed tracing covers every outbound call.** Because the request logger is ambient, all **Distributed tracing covers every outbound call.** Because the request logger is ambient, all
@@ -1772,24 +1830,22 @@ unnecessary here — the signature itself is already invalid.
src/ The app — strict tsc, no build step. *.test.ts sit beside their module. src/ The app — strict tsc, no build step. *.test.ts sit beside their module.
server.ts Entry point; config.ts env loader; logger.ts structured log + trace span + tracedFetch server.ts Entry point; config.ts env loader; logger.ts structured log + trace span + tracedFetch
*.test.ts Guards with no source counterpart (compose/kratos/keto/hydra/postgres/ci-gate/function-limits) *.test.ts Guards with no source counterpart (compose/kratos/keto/hydra/postgres/ci-gate/function-limits)
http/ Request pipeline: app.ts (createApp), builtin-route, context, body, cookie, http/ Request pipeline: app.ts (createApp), builtin-routes, context, body, cookie,
static, safe-url (safeUrl + localPath), security-headers, request-fields · request-tracking (span/log attributes) static, safe-url (safeUrl + localPath), security-headers
auth/ Identity + the session-JWT hot path: jwt · jwt-middleware · jwks · gen-jwks (CLI) · auth/ Identity + the session-JWT hot path: jwt · jwt-middleware · jwks · gen-jwks (CLI) ·
login · guards · csrf · denylist · flow-view · oauth-login · oauth-consent · login · guards · csrf · denylist · flow-view · oauth-login · oauth-consent ·
routes · bootstrap, and the Ory REST clients (kratos-public/admin, keto, hydra, routes · bootstrap, and the Ory REST clients (kratos-public/admin, keto, hydra,
link-header, fetch-timeout) fetch-timeout)
i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime · i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime ·
english · view-locals · locales/ (the core en-US + sv-SE catalogs) english · view-locals · locales/ (the core en-US + sv-SE catalogs)
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
(ctx.system) · discovery · conflicts · router · hooks · view-resolver · storage (the rules) · (ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) ·
storage-provisioning (the DDL; bootstrap-only, holds the driver) storage-provisioning (the DDL; bootstrap-only, holds the driver)
ui/ chrome (`ctx.chrome`: menu, brand, user) · nav (composeNav) · menu-config ui/ chrome (`ctx.chrome`: menu, brand, user) · nav (composeNav) · menu-config
(`#menu-config`) · icons (lucide sprite builder) · list-query · paginate (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
test-tooling/ Test-only: app-harness (starts `createApp` over fakes for the `src/http` tests) ·
stylesheet-floor (the browser-floor check)
plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules
views/ Core EJS in the one app shell: home, dashboard, auth, oauth-consent, error, 403/404/500/503, views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503,
and partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, and partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card,
alert, menu/popover, theme switch, language picker, icon sprite). Domain screens live alert, menu/popover, theme switch, language picker, icon sprite). Domain screens live
in plugins. in plugins.
@@ -1799,7 +1855,7 @@ locales/ Drop-in mount: extra or replacement catalogs. Ships empty.
plugins/ Drop-in mount: plugin folders, scanned at /app/plugins. Ships empty. plugins/ Drop-in mount: plugin folders, scanned at /app/plugins. Ships empty.
ory/ Ory config — kratos/ (identity schema, oidc claims mapper, tokenizer + dev JWKS), ory/ Ory config — kratos/ (identity schema, oidc claims mapper, tokenizer + dev JWKS),
keto/ (namespaces.keto.ts OPL), hydra/, postgres/init/ keto/ (namespaces.keto.ts OPL), hydra/, postgres/init/
examples/ Copy-in reference mirroring the mount dirs: plugins/scheduling/, plugins/auth-admin/, examples/ Copy-in reference mirroring the mount dirs: plugins/scheduling/, plugins/admin/,
config/menu.ts, and shifts-upstream/ (the dev mock backend) config/menu.ts, and shifts-upstream/ (the dev mock backend)
e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml; e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml;
proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow
@@ -1808,7 +1864,7 @@ release-tooling/ Everything the release runs: next-version (the bump math),
registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts) registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts)
CHANGELOG.md What changed per release, and how to upgrade a plugin across a minor CHANGELOG.md What changed per release, and how to upgrade a plugin across a minor
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
.gitea/workflows/ Gitea Actions — see docs/ci-cd.md .gitea/workflows/ Gitea Actions — see CI/CD
``` ```
## Extending the core ## Extending the core
+1 -1
View File
@@ -6,7 +6,7 @@
# bash ci.sh # bash ci.sh
# #
# Exits non-zero on the first failure. Each E2E suite OWNS a clean stack — never point two suites at # Exits non-zero on the first failure. Each E2E suite OWNS a clean stack — never point two suites at
# one backend (auth-refresh revokes the admin's sessions; full-flow writes users, groups and grants). # one backend (auth-refresh revokes the admin's sessions; full-flow writes users/groups/roles to Keto).
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
+2 -2
View File
@@ -49,7 +49,7 @@ services:
# backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service — # 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. # stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
shifts-upstream: shifts-upstream:
image: node:26.13.0-alpine3.24 image: node:24.22.2-alpine3.24
command: node /srv/server.ts command: node /srv/server.ts
restart: unless-stopped restart: unless-stopped
volumes: volumes:
@@ -58,7 +58,7 @@ services:
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025). # 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. # kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
mailpit: mailpit:
image: axllent/mailpit:v1.33.1 image: axllent/mailpit:v1.33.0
ports: ports:
- "8025:8025" - "8025:8025"
restart: unless-stopped restart: unless-stopped
+2 -4
View File
@@ -24,7 +24,6 @@ services:
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-} PLUGIN_DB_URL: ${PLUGIN_DB_URL:-}
REQUIRE_SECURE_SECRETS: "true" REQUIRE_SECURE_SECRETS: "true"
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-} # IPs/CIDRs of your reverse proxy; empty ⇒ X-Forwarded-For is ignored
# Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/ # 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 # 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. # declares `storage` opens its connection in onBoot, before the server listens.
@@ -165,9 +164,8 @@ services:
# Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README). # Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README).
# DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml. web implements the # DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml. web implements the
# login challenge at /oauth2/login and the consent challenge at /oauth2/consent. Dev permits the # login challenge at /oauth2/login (consent next). Dev permits the http issuer via --dev
# http issuer via --dev (compose.override.yml); prod sets an https issuer via env # (compose.override.yml); prod sets an https issuer via env (URLS_SELF_ISSUER).
# (URLS_SELF_ISSUER).
hydra-migrate: hydra-migrate:
image: oryd/hydra:v26.2.0 image: oryd/hydra:v26.2.0
depends_on: depends_on:
-105
View File
@@ -1,105 +0,0 @@
# CI/CD
Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
[`ci.sh`](../README.md#the-full-gate-one-command) — the exact gate you run locally:
| Workflow | Trigger | Does |
| --- | --- | --- |
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image |
| `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `latest` (plus `X` once major ≥ 1), sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger |
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) |
| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged |
`main` is not re-tested on push — its commits are meant to arrive already green from a
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
**Merge gate on `main`** (Gitea branch protection + repo merge settings, set via the API —
no repo files involved): direct pushes are blocked, changes land via PR only, the
`CI / full-gate (push)` status must be green (admins included), and the only merge style is
**fast-forward-only** — history stays linear and `main`'s head is the exact commit hash of
the merged branch, which is why the branch's push-triggered status carries over.
**Container images** — after a green gate, `ci.yml` builds the app image and pushes it as
`gitea.larvit.se/larvit/plainpages:<full commit hash>`. Because merges are fast-forward-only, the
image for any `main` commit already exists from that commit's branch gate — build once, promote by
re-tagging. The build runs inside the required gate, so a missing/expired token or a registry outage
blocks **all** merges until restored; use a non-expiring token or track its expiry. Hash tags
accumulate one image per gated push, so nightly `registry-cleanup.yml` prunes them
([`registry-cleanup/select-versions.ts`](../registry-cleanup/select-versions.ts) defines what survives). Don't add a
pattern-based org cleanup rule for this package — its age/count heuristics can't see branch heads or
release tags and would delete images the workflow protects.
**Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs
`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `latest` and —
once the major reaches `1` — `1`; nothing is rebuilt, so the released image is byte-identical to the
gated one. While the major is `0` the bare-major tag is skipped, because a `0.x` minor is a contract
break and a moving `:0` would carry one. It fails loud if no
hash image exists — release tags must point at a commit that went through the gate. The same four
tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only.
The [contract check](../README.md#contract-versioning) guards the tag before anything is published, refusing one
whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to set.
**The Docker Hub overview** is published by a separate `publish-overview` job from
[`release-tooling/dockerhub-overview.md.tmpl`](../release-tooling/dockerhub-overview.md.tmpl), with
`{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go
stale. The same job sets the page's short description. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the
quick start stays a topology CI has actually run.
It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
outage leaves the promotion green and the images untouched; and the page has its own door — run the
workflow manually with an `overview_version` input to republish it without cutting a release. That
input goes through the same contract check as a tag: a non-semver value, or one whose `major.minor`
disagrees with the tree being published, is refused. It uses
the same `DOCKERHUB_TOKEN` the image push uses, which is why that token needs the **delete** scope.
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is
read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift.
Tags go with `--prune`, so deleting one here deletes it there on the next run, and a tag *created* on
GitHub is swept away — cut releases on Gitea, never on the mirror. Branches other than `main` match
no refspec and are left alone.
**Dependency updates** — `renovate.yml` runs [Renovate](https://docs.renovatebot.com) nightly
(self-hosted, this repo only) against [`renovate.json`](../renovate.json), bumping npm deps, Docker base
images, Gitea action versions, and the image tags pinned inside workflow `run:` steps (a custom regex
manager, so nothing pinned drifts unmanaged). Version-locked sets move together in one PR — the Ory
images, and the Playwright runner + its browser image — and every bump keeps the **exact semver pin**
exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once
`CI / full-gate (push)` is green; only a red gate needs a human.
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`) cuts **one**
`vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and
**skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a
trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a
`Release-Bump: <updateType>` trailer onto the updates that reach a running Plainpages — the rules in
[`renovate.json`](../renovate.json) name them — and
[`release-tooling/next-version.ts`](../release-tooling/next-version.ts) turns the highest one into the next
version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release
version, an update big enough to reach a **minor** stops the job rather than tagging: bump
`HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since
`nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump,
so the trailer is an allowlist: an update outside those rules carries none and rides the next patch
release instead of escalating it. It is **tag-only**: the tag hands off to
`release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by
the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here.
### One-time CI setup
| Actions var / secret | Value |
| --- | --- |
| `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. |
| `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a **read/write/delete** token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT reaches every repo under it, and delete is destructive). Delete is what publishing the overview needs; pushing images alone would not. |
| `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. |
| `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. |
| `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. |
Gitea rejects secret names prefixed `GITEA_`/`GITHUB_`. Each job fails loud until its secret exists.
The container package is **org-owned**, so it lists under `larvit/-/packages` — link it to the repo's
Packages tab once: `POST /api/v1/packages/larvit/container/plainpages/-/link/plainpages`.
**The runner** — register one
[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode labelled `docker-host`
(`labels: ["docker-host:host"]`) on a machine with Docker Engine + Compose, git, and Node +
github.com access. Runs must **never overlap** — the e2e stacks use fixed compose project names, the
devstack suite uses host networking, and the workflows share the Docker daemon's registry login — so
keep exactly one runner at capacity 1, with host ports 3000/4433 free.
-310
View File
@@ -290,313 +290,3 @@ Inherited architect, would restructure first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: stage order lives only in line order, in a grandfathered complexity-43 function - Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: stage order lives only in line order, in a grandfathered complexity-43 function
- Shape, Navigation: `src/auth/routes.ts:275` `buildAuthRoutes`: 19 flat modules in `src/auth/`, and the next flow lands here - Shape, Navigation: `src/auth/routes.ts:275` `buildAuthRoutes`: 19 flat modules in `src/auth/`, and the next flow lands here
- Shape, Navigation: `src/ui/stylesheet-floor.ts:15` `FLOOR`: the 3am rule's home is a test-only module under `ui/` - Shape, Navigation: `src/ui/stylesheet-floor.ts:15` `FLOOR`: the 3am rule's home is a test-only module under `ui/`
## 2026-11-08T13:22:47Z, PR #214 at 84368ea, against c2389f8
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | same | better | better | better |
| Inherited architect | better | same | better | better | better |
Mid A, decided by:
- Navigation, Shape, Overall: `src/test-tooling/stylesheet-floor.ts:1` `floorViolations`: test-only modules no longer sit beside runtime code in `ui/` and `http/`, and `AGENTS.md:77` names the folder
- Self-sufficiency: `src/auth/login.ts:40` `readPermissions`: the comment now names `#granted`, which the code queries
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: grouping and the per-group decision are separate named units
- Self-sufficiency: `src/test-tooling/stylesheet-floor.ts:10` file header: says why a custom property's value is checked for functions and units
- Self-sufficiency: `src/plugin-host/plugin.ts:234` `undeclaredGates`: its comment says why an undeclared gate matters
Inherited architect, decided by:
- Self-sufficiency, Shape, Overall: `src/auth/login.ts:40` `readPermissions`: the comment now names the relation the code queries
- Self-sufficiency: `src/auth/bootstrap.ts:31` `permissionTuple`: the contradicting comment is gone
- Navigation: `src/test-tooling/stylesheet-floor.ts:1` `floorViolations`: the folder is named in AGENTS.md and the README file map
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: one loop split into `parenGroups`, `Group` and `keepGroup`
- Self-sufficiency: `src/auth/routes.ts:117` `codeFlowSubmit`: a comment says which `flowFailure` branch can fire there
## 2026-11-08T13:44:57Z, PR #215 at 3a3bd77, against 01f368d
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | better | better | same | better |
| Inherited architect | better | better | better | better | better |
Mid A, decided by:
- Locality, Shape: `src/ui/paginate.ts:68` `buildPaginationModel`: the rows form's hidden inputs are read off hrefFor, so form and links can't drift, where each page used to list them by hand
- Shape, Navigation: `examples/plugins/admin/admin-users.ts:140` `buildUsersListModel`: three near-identical listPagination copies become one call each to a named helper
- Locality: `src/http/app.ts:271` `shared`: ctx and contextFor spread one option object instead of spelling the same list twice
- Shape: `src/ui/paginate.ts:1` header: names both steps truthfully, and PaginationModel types the partial's input
- Navigation: `src/http/context.ts:28` `RequestContext.assetHref`: versioning a plugin's asset is a documented contract member, not a host-internal local
Inherited architect, decided by:
- Shape, Locality, Navigation: `src/ui/paginate.ts:68` `buildPaginationModel`: one typed function in the module owning the page math replaces three hand-written copies in the example plugins
- Locality: `examples/plugins/admin/admin-users.ts:188` `listPagination`: the older version built the hidden inputs field by field, apart from listHref; the round trip through new URL is a little clever, paid for by its comment
- Locality, Shape: `src/http/app.ts:271` `shared`: the two buildContext calls differ only in the translator and params
- Self-sufficiency: `src/http/context.ts:28` `RequestContext.assetHref`: the contract field says on the spot what it does and how it differs from the view local
- Self-sufficiency: `README.md:1050` pagination sketch: URL to partial in five lines, with the param-renaming rule
## 2026-11-08T14:10:47Z, PR #216 at bb134a3, against 6ca63c6
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | same | better | same | better |
| Inherited architect | same | better | better | same | better |
Mid A, decided by:
- Navigation, Shape: `src/http/request-tracking.ts:9` `trackRequest`: the per-request logger, span and access line now live in a named module instead of the tail of the 318-line `createApp`
- Navigation, Shape: `src/http/request-fields.ts:33` `clientAddress`: the caller-IP and X-Forwarded-For rule has its own pure function in a file named for it
Inherited architect, decided by:
- Locality, Shape: `src/http/request-tracking.ts:9` `trackRequest`: the exactly-once finalize and close listener are one named unit, changeable without reading `createApp`'s other state
- Locality, Shape: `src/http/request-fields.ts:33` `clientAddress`: pure function of peer, header and list, with the reasons for reading from the right beside it
## 2026-11-08T18:34:54Z, PR #220 at 1b53b01, against 14a07dd
Ruling: same
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | same | same | same | same | same |
| Inherited architect | same | same | worse | same | same |
Inherited architect, decided by:
- Shape: `views/partials/shell.ejs:74` `user.menu`: a third meaning of `menu` beside the sidebar switch at line 19 and the `.menu` popover class
- Shape: `src/plugin-host/plugin.ts:9` `import type { ProfileMenuEntry }`: a second contract type defined under `ui/`, with a type-only import cycle through `chrome.ts`
## 2026-11-08T19:50:52Z, PR #223 at f8a6119, against b7e9780
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | better | better | same | better |
| Inherited architect | better | better | better | same | better |
Mid A, decided by:
- Navigation, Locality, Shape: `src/http/app.ts:183` `handleRequest`: a 20-line list of named stages replaces a 170-line closure
- Locality: `src/http/app.ts:213` `Host`: 19 fields passed to every stage, broader than any stage needs
- Shape, Navigation: `src/ui/chrome.ts:29` `ShellUser.profileLinks`: the name says what the field holds
- Navigation, Shape: `src/plugin-host/conflicts.ts:15` `findConflicts`: collision rules have their own file
Inherited architect, decided by:
- Locality, Shape, Navigation: `src/http/app.ts:183` `handleRequest`: named stages replace one 165-line closure
- Locality: `src/http/app.ts:213` `Host`: a 19-field bag wider than any stage needs
- Shape: `src/ui/chrome.ts:29` `ShellUser.profileLinks`: no longer collides with `MenuConfig` and the `menu` partial
- Navigation: `.gitea/workflows/release.yml:60` error pointer: names `docs/ci-cd.md`, one hop from the failure
## 2026-11-08T21:24:33Z, PR #228 at ed48c5a
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7.5 | 6 | 7 | 7 | 6.5 |
| Mid A | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Maintainability senior | 7.5 | 6.5 | 7 | 6.5 | 7 |
| Inherited architect | 6 | 6 | 6.5 | 7 | 6.5 |
| Mean | 7.00 | 6.13 | 6.75 | 6.75 | 6.63 |
Junior A, hardest first:
- Locality, Overall: `src/http/app.ts:67` `createApp`: a closure bag whose order matters; `switchBase` uses `builtinRoutes` declared further down
- Locality, Overall: `src/http/app.ts:274` `prepareRequest`: builds the context, the factory and the memoized chrome together, and every page path must remember to set the CSRF cookie
- Locality, Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:285` `lostToSelf`: the self-lockout rule is spread over four call sites and no single place states it
- Shape, Overall: `examples/plugins/auth-admin/admin-grants.ts:99` `buildPermissionPicker`: checked, inherited, read-only and undeclared rows interact and need a truth table
- Self-sufficiency: `src/auth/routes.ts:45` `flowReturnTo`: locale and return-to inputs interact, and no table gives the result per input
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: four Kratos error branches that need Ory knowledge the repo doesn't hold
- Locality: `src/ui/chrome.ts:55` `buildPluginChrome`: the locale rule is put into effect in three places with no single owner
- Self-sufficiency: `src/plugin-host/router.ts:4` header: promises order-independent specificity, but ties fall to discovery order
Mid A, hardest first:
- Locality, Overall: `examples/plugins/auth-admin/admin-groups.ts:285` `lostToSelf`: needs Keto's transitive group resolution rebuilt by hand, and over-reports
- Shape, Locality: `examples/plugins/auth-admin/admin-groups.ts:435` `groupsDelete`: deletes grants one at a time, so it can stop part-way
- Shape, Overall: `examples/plugins/auth-admin/admin-grants.ts:99` `buildPermissionPicker`: four overlapping ideas; `checked` means direct or effective
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos status codes map to different outcomes and need outside knowledge
- Shape, Locality: `src/http/app.ts:183` `handleRequest`: the 19-field `Host` bag and the state threaded between stages
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: a hand-rolled parser for `@supports` conditions
- Self-sufficiency: `src/plugin-host/settings.ts:35` `ValueOfDecl`: type-level inference with a `const` generic
- Self-sufficiency: `src/auth/jwt-middleware.ts:75` `verifyToken`: a denylisted token is thrown as expired so it gets re-minted
- Self-sufficiency: `examples/plugins/auth-admin/plugin.ts:22` `on`: each write-intent GET states its `"write"` override in the route table and in the handler
Maintainability senior, hardest first:
- Locality, Overall: `examples/plugins/auth-admin/admin-groups.ts:285` `lostToSelf`: the self-lockout guards rebuild Keto's transitive membership and no sentence states the invariant
- Shape, Overall: `src/http/app.ts:67` `createApp`: a long closure factory that is safe to read but not safe to change
- Shape, Self-sufficiency: `examples/plugins/auth-admin/admin-grants.ts:99` `buildPermissionPicker`: direct, effective and undeclared grant sets with a staleness token on top
- Self-sufficiency: `src/plugin-host/settings.ts:35` `ValueOfDecl`: type-level inference whose comments say what it does, not how
- Self-sufficiency: `src/auth/routes.ts:45` `flowFailure`: a matrix of Kratos statuses and flow states
- Locality: `src/auth/jwks.ts:68` `cachingJwks`: subtle cache state, and the denylist reuses the `expired` flag
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: a hand-written parser for `@supports` conditions
- Locality: `src/auth/bootstrap.ts:86` `seedAdmin`: calls Kratos and Keto with raw fetch instead of the client modules
- Self-sufficiency: `examples/plugins/auth-admin/views/partials/group-detail-body.ejs:2` locals header: documents `identity` and `del`, omits `canWrite` and `permissions`
Inherited architect, would restructure first:
- Navigation, Overall: `src/auth/routes.ts:1` `src/auth`: 19 modules holding sessions, Ory clients, flows, the OAuth2 provider and one-shot CLIs
- Navigation: `src/auth/bootstrap.ts:1` `bootstrap.ts`: a one-shot process that also provisions plugin storage, placed under auth
- Navigation, Locality: `src/ui/chrome.ts:55` `buildPluginChrome`: builds the chrome for every page, the host's own included
- Navigation: `views/index.ejs:1` `index.ejs`: the built-in /dashboard, and `home.ejs` is "/", and neither filename says so
- Locality: `public/css/styles.css:326` `.btn-primary`: one visual fact spans the partial, the rule, the tokens and the floor policy in `src/test-tooling/`
- Shape, Locality: `examples/plugins/auth-admin/admin-groups.ts:113` `admin-groups.ts`: pure view models, Keto queries, lockout policy and handlers in one 463-line file
- Shape: `src/http/app.ts:213` `Host`: a 19-field bag that every cross-cutting feature edits
- Self-sufficiency: `src/plugin-host/system.ts:1` header: calls the built-in admin screens the reference consumer, but they are a drop-in plugin
## 2026-11-08T21:30:30Z, PR #228 rerun at 0727d15
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7.5 | 6 | 6.5 | 6.5 | 6.5 |
| Mid A | 7 | 6 | 6.5 | 7 | 6.5 |
| Maintainability senior | 7.5 | 6.5 | 7 | 7 | 7 |
| Inherited architect | 7 | 6.5 | 6 | 7.5 | 6.5 |
| Mean | 7.25 | 6.25 | 6.50 | 7.00 | 6.63 |
Junior A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:274` `prepareRequest`: a 19-field `Host` bag and a twice-memoized chrome threaded through one closure, and two return conventions (send and return null, or return a result)
- Locality, Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:285` `lostToSelf`: the self-lockout rule differs from the users side and nothing says whether that is deliberate
- Shape, Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:18` `LIST_FETCH_SIZE`: the comment says a Keto page but the call is Kratos `listIdentities`
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: branches on Kratos status and body text that need Ory knowledge
- Shape, Self-sufficiency: `src/auth/jwt-middleware.ts:75` `verifyToken`: a revoked token is reported as expired, with the effect in another file
- Locality: `src/i18n/load.ts:43` `loadI18n`: four catalog sources kept apart with a nested spread on one line
- Self-sufficiency: `src/server.ts:25` boot order: deleting `PLUGIN_DB_SECRET` is order-dependent and its reason sits in the decision log
- Self-sufficiency: `src/plugin-host/system.ts:1` header: calls the built-in admin screens the reference consumer
Mid A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:274` `prepareRequest`: the request order lives only in the sequence of a long closure, and `serveHome` skips `sendResult`
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: a hand-written, regex-heavy reader of `@supports` conditions
- Locality: `src/auth/jwt-middleware.ts:75` `verifyToken`: revocation reported as expired works only through `resolveUser` in another file
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos statuses that need outside knowledge
- Locality: `src/auth/jwks.ts:68` `cachingJwks`: four interacting behaviours in one closure
- Shape: `src/i18n/load.ts:43` `loadI18n`: merges four catalog sources, with the chain order described in two files
- Locality: `src/i18n/locale.ts:81` `chosenLocale`: infers an explicit choice from the output of a URL builder
- Shape, Self-sufficiency: `examples/plugins/scheduling/plugin.ts:37` header: says "Roles" and `<id>:<action>` where the project has neither
- Self-sufficiency: `compose.yml:166` comment: says consent comes next, but `src/auth/oauth-consent.ts` exists
Maintainability senior, hardest first:
- Shape, Overall: `src/test-tooling/stylesheet-floor.ts:122` `positive`: a regex-driven mini-parser whose header states the rules but not how the helpers carry them
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: five branches that assume Kratos' error vocabulary
- Locality: `src/http/app.ts:274` `prepareRequest`: where the language picker points after a POST is spread over four units in three files
- Shape: `src/i18n/load.ts:43` `loadI18n`: three catalog sets, each with its own baseline, and a nested spread
- Locality: `src/auth/jwt-middleware.ts:84` `verifyToken`: the denylist travels as `expired`, so `resolveSession` can't show it
- Shape: `src/ui/chrome.ts:55` `buildPluginChrome`: labels are translated twice and `markCurrent` mutates the nodes
- Locality, Shape: `examples/plugins/auth-admin/admin-users.ts:348` `usersPermissions`: the stale-page, self-revoke and diff guards interact across two files, and `pickerOrNothing`'s promise is broken on the self-delete paths
- Shape: `src/plugin-host/router.ts:4` header: promises order-independent specificity, but ties fall to discovery order
Inherited architect, would restructure first:
- Shape, Navigation, Overall: `src/auth/bootstrap.ts:1` `src/auth`: 19 modules across the session, Kratos flows, OAuth2 and a second process entrypoint
- Navigation, Self-sufficiency: `src/auth/bootstrap.ts:1` header: lists the JWKS, admin and Keto steps but not the plugin storage step, and the file reads the environment outside `config.ts`
- Shape, Navigation: `views/index.ejs:1` `index.ejs`: the dashboard, with `home.ejs` as the landing page, and neither name says so
- Shape: `src/ui/chrome.ts:55` `buildPluginChrome`: builds the chrome for every page, not only plugin pages
- Locality, Navigation: `src/test-tooling/stylesheet-floor.ts:10` header: the custom-property footgun sits in test tooling instead of beside the tokens at `public/css/styles.css:9`
- Locality: `src/auth/bootstrap.ts:170` `provisionPluginStorage`: plugin storage lives in three places, across `storage.ts`, `storage-provisioning.ts` and `bootstrap.ts`
- Locality, Shape: `src/http/app.ts:183` `handleRequest`: every new manifest field or slot adds a stage here and a field to `Host`
- Self-sufficiency: `src/plugin-host/system.ts:1` header: calls the built-in admin screens the reference consumer
## 2026-11-09T00:38:11Z, item-70 at 5a5ce54
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 6 | 7 | 6.5 | 6.5 |
| Mid A | 7.5 | 6.5 | 7 | 6.5 | 7 |
| Maintainability senior | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Inherited architect | 7 | 7 | 6 | 7.5 | 6.5 |
| Mean | 7.13 | 6.38 | 6.63 | 6.75 | 6.63 |
Junior A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:67` `createApp`: a 130-line closure whose 19-field `Host` bag is read by every stage, and `switchBase` reads `builtinRoutes` declared 44 lines later
- Locality: `src/http/app.ts:263` `prepareRequest`: a lazily memoized chrome, `shared` and `contextFor` in one function, with two context paths whose reason is implicit
- Self-sufficiency: `src/plugin-host/settings.ts:35` `ValueOfDecl`: nested conditional types that only the README's settings example explains
- Locality: `examples/plugins/auth-admin/admin-groups.ts:270` `lostToSelf`: the self-lockout guard has three entry points that each decide "is the actor in this group" differently
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos status meanings that the code and README do not explain
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
Mid A, hardest first:
- Locality, Overall: `examples/plugins/auth-admin/admin-groups.ts:273` `lostToSelf`: one guard spread over four functions and three call sites, and it over-refuses on purpose
- Locality: `src/i18n/load.ts:43` `loadI18n`: four catalog sources and their precedence, explained only in the README
- Locality, Navigation: `src/http/app.ts:118` `switchBase`: where the language picker points after a POST needs `app.ts` and two i18n files
- Locality: `src/ui/chrome.ts:86` `profileLinks`: re-implements `hide` and `rename` outside `composeNav`, so the override rules have two homes
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
- Self-sufficiency: `src/server.ts:23` trust-boundary comment: cites `docs/decisions.md` for its reason
Maintainability senior, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:67` `createApp`: 133 lines and complexity 26 (grandfathered), and `switchBase` reads a table declared later
- Locality: `src/http/app.ts:349` `answerBuiltinRoute`: the CSRF cookie and the response-hook context differ from `answerPluginRoute` without a comment saying so
- Locality: `examples/plugins/auth-admin/admin-groups.ts:273` `lostToSelf`: the guard assumes Keto's transitive model, and `removesSelf` checks one level of nesting
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: each branch rests on Ory behaviour that only a comment states
- Locality: `src/ui/chrome.ts:86` `profileLinks`: the menu override has two homes
- Locality: `src/i18n/runtime.ts:39` `chain`: a plugin that shadows a core key in English gives a Swedish visitor the English text
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
Inherited architect, would restructure first:
- Shape, Navigation, Overall: `public/css/auth.css:84` `.field`: the generic form building-block rules live in a file named for one feature
- Shape, Navigation: `src/auth/bootstrap.ts:1` `src/auth`: 21 modules across Ory clients, session crypto and flow handlers in one flat directory
- Shape: `src/http/builtin-route.ts:23` `BuiltinRoute`: a second route contract beside the plugin `Route`
- Locality: `src/http/app.ts:202` `Host`: 19 fields, and each new capability adds one here, in `prepareRequest` and in `context.ts`
- Locality: `views/partials/shell.ejs:61` profile popover: hand-copied from `menu.ejs` and kept in step by a comment
- Self-sufficiency: `views/dashboard.ejs:15` card: hard-codes `apiVersion: "0.5.0"`, which nothing ties to `HOST_API_VERSION`
## 2026-11-09T00:38:15Z, item-70 rerun at 5a5ce54
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 6 | 6.5 | 7 | 6.5 |
| Mid A | 7 | 6 | 7 | 6.5 | 6.5 |
| Maintainability senior | 7.5 | 6.5 | 7 | 7 | 7 |
| Inherited architect | 7 | 6.5 | 6 | 7.5 | 6.5 |
| Mean | 7.13 | 6.25 | 6.63 | 7.00 | 6.63 |
Junior A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:67` `createApp`: about 130 lines of closure setup with a forward reference from `switchBase` to `builtinRoutes`, and a 19-field `Host` bag
- Locality: `src/http/app.ts:263` `prepareRequest`: reserved locals exist only through the spread order of an object literal
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos status branches that cannot be checked from the code
- Shape: `src/plugin-host/settings.ts:43` `SettingsOf`: conditional types that took several rereads
- Shape: `src/i18n/load.ts:43` `loadI18n`: two baselines and two catalog sources, with a dense tuple line at `:78`
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
- Shape: `src/plugin-host/discovery.ts:26` `discoverPlugins`: a long loop of `continue` exits
Mid A, hardest first:
- Locality, Overall: `src/ui/chrome.ts:72` `currentMarkedNav`: labels are translated twice, marking must precede the locale, and `markCurrent` mutates nodes
- Locality, Overall: `src/http/app.ts:118` `switchBase`: a forward reference to `builtinRoutes` that only works because the closure runs later
- Locality: `src/plugin-host/plugin.ts:129` `RESERVED_PLUGIN_IDS`: kept in step with `buildAuthRoutes` by hand, and nothing says so
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:446` `groupsRemoveMember`: the Keto subject-set model is never explained in this file
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos statuses that the comments name but the code cannot confirm
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
Maintainability senior, hardest first:
- Locality, Overall: `src/http/app.ts:263` `prepareRequest`: the lazy `ctx.chrome` is memoized here and in `buildContext`, and only a comment guards it
- Locality: `src/ui/chrome.ts:160` `markCurrent`: mutates nodes, safe only because `composeNav` returns fresh objects
- Locality: `examples/plugins/auth-admin/admin-groups.ts:273` `lostToSelf`: over-approximates deliberately, spread over four helpers
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos status meanings that no readable document states
- Shape: `views/partials/filter-bar.ejs:33` include loops: pass `align: null, up: false, width: null` to stop EJS locals leaking, which reads as noise
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
Inherited architect, would restructure first:
- Shape, Navigation, Overall: `src/auth/bootstrap.ts:1` `src/auth`: 21 files over five concerns, with the lying names `jwt-middleware.ts` and `bootstrap.ts`
- Locality, Shape: `src/http/builtin-route.ts:23` `BuiltinRoute`: a second route shape, so `switchBase` at `src/http/app.ts:118` consults two tables
- Locality: `src/http/app.ts:202` `Host`: 19 fields, and a new request capability touches four files
- Shape, Self-sufficiency: `examples/plugins/auth-admin/admin-shared.ts:107` `FieldConfig`: the type for a core partial's option shape lives in an example plugin
- Locality: `src/auth/login.ts:48` `readPermissions`: a second copy of the Keto paging loop that nothing says the barrel boundary forces
-522
View File
@@ -1,522 +0,0 @@
# Decisions
"Goal N" is README → Goals. "Code health" is the goal every project shares: the code is easy to
understand and change safely.
## `src/` is grouped by concern, and the core ships no domain screens
2026-06-24, the maintainer. Code health and goal 3. Valid while each concern folder stays one a
reader can name.
The folders are `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`, and `test-tooling/` (modules only
tests import), with `server.ts`, `config.ts`, `logger.ts` and the topology-guard `*.test.ts` at the
root. Tests are co-located. Add a new module to the folder owning its concern. Even the admin GUI
is a drop-in plugin (`examples/plugins/auth-admin/`).
## Plugins and config import the host only through the two barrels
2026-08-17, the maintainer. Goal 7. Valid while plugin authors typecheck against a host tree rather
than a published package.
`@plainpages/plugin-api` → `plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, and
`#menu-config` → `src/ui/menu-config.ts`; never a relative `../../src/*` path. These two barrels are
the whole contract surface, so don't "fix" either back to a relative path.
## The Ory client types are contract-visible through the barrel
2026-07-02, the maintainer. Goal 7. Valid while plugins get Ory admin clients through `ctx.system`.
`@plainpages/plugin-api` re-exports `KratosAdmin`, `KetoClient`, `HydraAdmin`, their DTOs and their
error classes. Changing those shapes needs a major `apiVersion` bump, not a free refactor.
## The barrel is a package, so a plugin folder may carry its own `package.json`
2026-08-17, the maintainer. Goal 7. Valid while plugins may depend on npm packages (README → Plugin
dependencies).
The Dockerfile links the barrel into `/node_modules`, above every plugin scope. Never let a copy
reach a plugin's own `node_modules`: two instances of the barrel break `instanceof` across the
boundary. `plugin-api.test.ts` guards this by asserting both paths reach one module.
## Plugin storage hands over credentials, not a client
2026-08-18, the maintainer. Goals 3 and 7. Valid while the host stays stateless (AGENTS.md →
priority 2).
README → Plugin storage describes the contract. The host takes `postgres` only to run the
provisioning DDL, and `storage-provisioning.ts` is the only module importing it. `storage.ts`
beside it stays pure, so `web` never loads a driver. `src/postgres.test.ts` guards both halves,
because one value imported from the wrong module breaks this invisibly. The driver is never
re-exported through the barrel, so no driver shape enters the contract.
Three properties hold the design together, so don't trade one away in isolation:
- Passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than stored, which keeps the host
stateless. Whoever holds that secret holds every plugin database, so it ranks with the DB password
itself.
- The provisioning DSN reaches `bootstrap` only (`src/compose.test.ts` guards the split).
- Provisioning never drops anything, so uninstalling a plugin cannot destroy data; boot logs the
orphans instead.
Because the host's copy sits in the ambient `/node_modules`, a plugin can `import "postgres"`
without declaring it. That is incidental, not a packaging promise: a plugin must still depend on its
own driver.
## Plugin settings are declared, not discovered
2026-08-23, the maintainer. Goals 7 and 10. Valid while settings reach plugins through the
environment (README → Plugin settings).
`settings.ts` is pure and takes the env as an argument, so the whole matrix unit-tests without a
stack. Four rules carry the design:
- The prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`: a plugin id `db` with key `url` would
otherwise name the host's own `PLUGIN_DB_URL`.
- Keys are camelCase, so the `camelCase → SNAKE_CASE` mapping is total and no two keys collide.
`findConflicts` (`plugin-host/conflicts.ts`) catches the residual cross-plugin collision.
- `required` and `default` are mutually exclusive. That lets `SettingsOf` type a declared key as
present rather than `T | undefined`, so no plugin author casts.
- A secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings`, not even
as a mask or a length.
An author mistake is refused at discovery, a bad operator value refuses the boot, and a stray
`PLUGIN_SETTING_` variable only warns, as an orphan database does.
## The trust boundary is the `web` process, not the plugin
2026-08-19, the maintainer. Goal 7. Valid while plugins are operator-installed code, not third-party
uploads.
Per-plugin databases and roles bound accidents, not hostile plugins: `PLUGIN_DB_SECRET` is in
`web`'s environment during `onBoot`, and a plugin already holds `ctx.system`'s Ory admin clients. So
cross-plugin DB isolation is containment, and README says so. This follows AGENTS.md → priority 7:
crash-isolation is a non-goal.
`server.ts` still deletes the secret from `process.env` right after `loadConfig`, before discovery
imports any plugin module. That ordering is the whole point: move the line earlier if anything,
never later.
## `ory/postgres/init/init.sql` is the only home for the Ory databases' ACL
2026-08-19, the maintainer. Code health. Valid while Ory's databases are created by Postgres' first
init.
Don't re-assert the `REVOKE CONNECT` from `bootstrap`. `REVOKE` only warns when the caller doesn't
own the database, so under the least-privilege provisioning account the README recommends it would
report success while changing nothing. It also hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a
server with no `kratos`. The script runs only on first init, so a revoke added to it later never
reaches a volume that already exists: `docker compose down -v` is the dev remedy, and a deployed
install needs a migration.
## `bootstrap.ts` stays under `src/auth/`
2026-08-19, the maintainer. Code health. Valid until a third seeding concern lands.
It provisions plugin databases as well as seeding Ory, but it is the one-shot service's entrypoint,
not an auth module. Moving it to `src/bootstrap.ts` would edit `compose.yml`, five e2e compose files
and `src/compose.test.ts` for a rename.
## `BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook
2026-08-19, the maintainer. Goal 7. Valid while no plugin needs an orderly drain of its pools.
Removing any field from the context costs a major bump, so all six stay. Adding one costs only a
minor, so the context otherwise gains a field only when a plugin needs it. Pools handed to a plugin
are reaped on process exit.
## `config/` is a plain dir with no `package.json` of its own
2026-07-01, the maintainer. Goal 7. Valid while an operator's menu override needs no dependencies.
With a `package.json` there, `#menu-config` resolves against it instead and boot fails loud. If the
override ever needs dependencies, `config/` needs the package treatment `plugins/` has.
## A plugin `package.json` without `"type": "module"` is refused, not warned
2026-08-18, the maintainer. Goal 7. Valid while nothing is installed in the wild.
Refusing is the safe direction: refuse→warn relaxes freely, while warn→refuse breaks installed
plugins. Allowing it would cost only a warning and a re-parse per file, not a break: Node detects
module syntax, so even a `.js` helper loads. The cost of refusing is that an operator on a read-only
third-party mount cannot apply the remedy.
## `examples/` mirrors the drop-in mount dirs
2026-07-01, the maintainer. Goal 9. Valid while the examples are meant to be copied in unchanged.
`examples/plugins/<id>/` copies to `plugins/<id>/`, and `examples/config/menu.ts` to
`config/menu.ts`. Both mirrors are in `tsconfig.include` and resolve the host through the barrels,
so each typechecks in place and copies across unchanged. Never commit real plugins or config into
the root mount dirs: they ship empty.
## `ctx.chrome` is lazily memoized, never built unconditionally
2026-06-23, the maintainer. Goal 8. Valid while the public landing (`/`) renders without I/O.
Don't make it unconditional or move it into the base request context: it protects the I/O-free hot
path on the public, bot-hit landing.
## A plugin-owned render always runs on that plugin's context
2026-08-03, the maintainer. Goal 7. Valid while each plugin has its own translator.
The landing slots (`home`, `dashboard`) and an `onRequest` short-circuit build their context with
`contextFor(pluginId)`, exactly as a plugin route does. Otherwise `ctx.t` is the core translator and
the plugin's own keys render as bare keys on the pages it owns.
## Email is delegated to Kratos
2026-06-23, the maintainer. Goal 3. Valid while Kratos owns the recovery and verification flows.
`web` never touches SMTP. Mail is customized through Kratos' `courier.template_override_path`.
## The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
2026-08-03, the maintainer. Code health. Valid while Keto holds no namespace of its own.
Keto ships no namespaces: all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one
operation ("read shifts"). A role is a bundle, which here is just a group with several grants, and
groups nest. Ory's own "permission" (the `Resource` `permits`: view, edit, delete) is the separate
per-row tier.
## A permission name is always `<resource>:<action>`, enforced at discovery
2026-08-05, the maintainer. Goal 7. Valid while permissions are named by plugin authors.
Examples: `scheduling:read`, `users:write`. A bare word names who someone is (a role), and roles are
groups here. `shapeError` checks every route, nav and profile-menu `permission`, and every declared
name, with `isValidPermissionName` (`plugin-host/plugin.ts`). A bad name refuses the boot like any
other manifest rule. The check is not left to the admin GUI, which an operator removes by not
copying it in.
## Permission names are authored in plugin code; only grants live in Keto
2026-08-05, the maintainer. Goal 2. Valid while the admin screens offer only declared names.
The host collects every installed plugin's declarations into one catalog (`declaredPermissions` →
`ctx.declaredPermissions`), and that catalog is the list the admin screens offer. Hence there is no
Permissions admin screen: nothing in a GUI invents a name, and holding one is a property of a user
or group, edited as a checkbox list there. A Keto tuple naming something no installed plugin
declares is not offered as a checkbox, and is never revoked by an unrelated save: the picker only
speaks for what it showed. A plugin may still gate on such a name, so it can be live. The user's or
group's page lists such a direct grant separately, each with its own Remove button.
## A permission's `<resource>` is global, not plugin-scoped
2026-08-05, the maintainer. Goal 7. Valid while cross-plugin sharing of permissions is wanted.
Hence `oauth2-clients`, not `clients`. Users are the host's, and plugins share them. The cost is
that collision-freedom is a convention rather than structural; the alternative penalizes the
sharing case.
## Declaring a permission stays optional
2026-08-05, the maintainer. Goal 7. Valid while plugins may share a permission.
Mandatory declaration would let `findConflicts` see every overlap, but it would then warn on exactly
the legitimate sharing case. Shape is enforced; declaration is not.
## `ADMIN_PERMISSIONS` defaults to empty, and an unusable value is dropped with a warning
2026-08-05, the maintainer. Goal 10. Valid while `bootstrap` is the only writer of grants.
Fail-loud belongs at the manifest boundary, where a developer authored the mistake. `bootstrap`
gates `web`, so refusing operator env would take the whole stack down;
`e2e-tests/compose.auth.yml` seeds a bad value to prove the container survives one.
The seed is a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs
`docker compose up -d`, not `restart web`. `bootstrap`'s matching `./plugins` mount belongs in
`compose.override.yml` and nowhere else. In the base file it would desynchronise prod and collide
with the e2e stacks, which bind individual plugins inside `/app/plugins`: a nested mount into a
read-only parent is EROFS, and the container never starts.
## `actionForMethod` is plugin-local and stays out of `@plainpages/plugin-api`
2026-08-05, the maintainer. Code health. Valid while route tables name their permission per route.
Inside the auth-admin example it keeps the route table and the in-handler guard deriving from one
function, so 29 routes × 2 gate sites cannot drift. Generalised, it would make authorization a
function of the transport verb, and a route table must answer "what does this need?" on its own.
## A gate is one of `public`, `session` or `permission`, named exactly once
2026-09-02, the maintainer. Goal 7. Valid while routes and nav nodes are declared in plugin
manifests.
A route or nav node names exactly one gate. Discovery refuses none, two, and a flag spelled anything
but `true`, so a forgotten gate fails the boot rather than publishing a page. `src/auth/gate.ts` is
the one home of the rule that the plugin router, the host's own route table and the menu all read.
The rule binds manifests only; the comment on `allows` says why a node without a gate passes it.
`session` exists because a plugin whose data is the visitor's own (their upstream account, their own
tokens) has no distinction a permission could name. The alternative, granting every newly
registered user a permission, couples the identity lifecycle to a Keto write that nothing retries
when it fails. A page scoped to "mine" joins on `ctx.user.id`, never the email: an address is
user-changeable and can be reassigned to someone who would then inherit the previous holder's rows.
## The reference plugin's two shift pages duplicate a view model and markup
2026-09-02, the maintainer. Goal 9. Valid while `examples/plugins/scheduling` stays a teaching
artifact rather than a maintained product.
Each page then reads top to bottom on its own, and an example rarely changes, so the two copies cost
little.
## A `:read`-only holder is never shown a write affordance
2026-08-05, the maintainer. Goal 2. Valid while the admin GUI splits `:read` from `:write`.
The list and detail models carry `canWrite`, and the views drop create, save, delete, add and
remove. The permission picker still renders, disabled, because seeing who holds what is the point
of `:read`. A write-intent GET (a create form, a delete-confirm page) is the exception to
`actionForMethod` and gates on `:write`.
Three guards go with it:
- You cannot revoke your own direct grants, since undoing that self-lockout needs a `curl` against
Keto.
- A permission held through a group renders ticked but disabled, because unticked would state the
opposite of the truth.
- Unticking a permission on a group you are in, removing yourself (or a nested group you are in)
from its members, or deleting the group is refused when you would lose a permission you don't hold
directly. Undoing that self-lockout would need a `curl` against Keto too.
## `users:write` and `groups:write` each amount to full administrative access
2026-08-05, the maintainer. Goal 2. Valid while the admin GUI splits its write permissions by
resource.
`groups:write` adds you to any group, including one holding every permission; `users:write` mints a
recovery code for any account. The containment the split buys is real on the read half only:
`users:read` is a safe helpdesk grant. Don't let the per-resource naming imply otherwise in docs.
## The CSRF token is unbound to the session
2026-10-30, the maintainer. Goal 5. Valid while the session cookie stays `SameSite=Lax` or stricter.
Leaving the token unbound is the usual cost of a signed double-submit token (`src/auth/csrf.ts`;
README → Security model). `SameSite=Lax` and HSTS cover that cost.
## Plainpages says "user" everywhere, where Ory says "identity"
2026-08-05, the maintainer. Code health. Valid while Kratos' wire shape names the DTO `Identity`.
House style, not a renamed concept. The single exception is the `Identity` DTO in
`src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape: don't rename it.
## The locale lives in the URL, never in a cookie
2026-08-03, the maintainer. Goal 5. Valid while a page's language should show in its address.
The host resolves the locale from `?locale=sv-SE`, then `Accept-Language`, then `en-US`. When the
URL asked for a locale, the host carries it onto the links it renders. A cookie would make a page's
language invisible in its address and unshareable; the cost is that a plugin wraps its own hrefs.
Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a lone language from
`Accept-Language` takes the first regional catalog for it.
## The core building blocks carry the locale, so a plugin doesn't have to
2026-08-03, the maintainer. Goal 1. Valid while the locale lives in the URL.
The shell, `pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu`
wrap every href in `localeHref`. Nav and sign-in are wrapped in `chrome.ts`. The two GET forms carry
the locale as a hidden `locale` input, since a GET submit replaces the whole query string.
A form's `action` counts as a link: sign-out, consent and auth-card forms carry the locale too, or
picking a language and then saving anything drops back to `Accept-Language`. The obligation stays on
the building block, never on each call site. `ctx.localeHref` remains for hrefs a plugin's own
markup emits. The one round-trip that cannot carry the locale is the Kratos sign-in POST, to an
absolute off-site URL.
## `locale` and the i18n view locals are reserved by the host
2026-08-03, the maintainer. Goal 7. Valid while the locale lives in the URL.
`locale` is in `parseListQuery`'s reserved set, so a localized list page doesn't hand a plugin a
phantom `locale` filter. The view locals `t`, `locale`, `locales`, `localeHref`, `localeParam`,
`localeSwitch`, `dir` and `assetHref` are merged after a handler's `data`, so a collision loses the
key instead of breaking the shell.
## Catalogs are checked at boot, not at render
2026-08-03, the maintainer. Goal 7. Valid while catalogs are TypeScript modules loaded at boot.
Every locale is compared against its set's `en-US`: keys, string-vs-plural kind, and the plural
categories `Intl.PluralRules` requires. A mismatch stops startup. A plugin may ship fewer locales
than the host, with its strings falling back to `en-US` per key, but never one the host lacks.
## `locales/` at the repo root is a drop-in mount
2026-08-03, the maintainer. Goal 7. Valid while adding a language must not require forking the
image.
Like `plugins/` and `config/`: `locales/<tag>.ts` for the core, `locales/plugins/<id>/<tag>.ts` for
a plugin. A new tag adds a language; an existing one replaces that catalog wholesale. The shipped
`en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is checked
rather than trusted. One compared only against itself would boot green with the whole UI rendering
keys.
## The language picker is on every page, POST-rendered ones included
2026-08-04, the maintainer. Goal 2. Valid while the picker is expected on literally every page; if
that softens, hiding it after a POST is simpler.
A POST-rendered URL often answers no GET (`POST /auth-admin/users/:id/recovery`), so the host resolves
the picker's target (`app.ts` → `switchBase`): this path when it answers GET, else the same-origin
Referer, else `/`. The accepted cost: switching language there leaves that POST's own result behind.
## An unknown translation key renders as itself
2026-08-03, the maintainer. Goal 7. Valid while labels may be either a key or plain text.
That single rule lets a nav label, branding, or a menu `rename` be either a key or plain text,
without a second field or a migration. Don't "fix" it into a loud failure: a manifest with plain
labels must keep working.
## `t()` returns raw text, and the view escapes it
2026-08-03, the maintainer. Code health. Valid while views escape with `<%= %>`.
Messages go through `<%= %>` like any other value. One carrying markup uses `<%- %>`, and then its
`{{vars}}` are escaped at the call site. Don't move escaping into `t()`: every other value in a view
would become the odd one out.
## RTL is out of scope until there is a real use case
2026-08-04, the maintainer. Goal 6. Valid while no deployment needs an RTL language.
`textDirection` sets `<html dir>` because that is free and correct, but the stylesheet keeps
physical `left` and `right` properties. A genuine RTL locale needs those moved to logical ones
first.
## A dropdown is a `<button popovertarget>` and a `[popover]`, never a `<details>`
2026-08-05, the maintainer. Goals 4 and 6. Valid while the popover API is the only zero-JS way to
dismiss on an outside click.
The browser owns open and close, and the panel sits in the top layer, so a row kebab is not clipped
by `.table-wrap`'s `overflow`. Four rules hold it together:
- The panel carries `position-anchor: auto`: a bare `anchor()` resolves to nothing in all three
engines.
- The panel stays the trigger's next sibling inside the `.menu` wrapper, which the open-state style
and the old-browser fallback both read.
- The partial requires a caller-named `id` and fails loud without one, since that is the
`popovertarget` idref. Never generate one: nondeterministic HTML forecloses caching.
- Neither `aria-expanded` nor `aria-haspopup` is written. Every engine maintains the first itself on
a declarative `popovertarget` invoker, so a hand-written one replaces a live state with a static
lie. The second would promise `role="menu"` semantics these panels don't implement.
That `aria-expanded` guarantee is the declarative attribute's alone: open a panel from script and no
engine applies it, so "enhancing" one of these triggers would cost it its accessibility. `<details>`
stays where it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same
block for the profile menu, and its comment says why. Keep the two in step.
## The document is the one scroller
2026-09-09, the maintainer. Goal 4. Valid while AGENTS.md → priority 1 holds that a page is a
document.
`.app` is `min-height: 100dvh`. `.nav`'s `overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto`
are not leftovers of a bounded frame: they make the off-canvas panel usable with a long tree.
`#nav-toggle` is `position: fixed` because a label click focuses it, and a browser scrolls a focused
element into view.
## `ICON_NAMES` is a host-owned registry, not a plugin contract
2026-08-05, the maintainer. Goal 7. Valid while the `every icon <use> resolves` e2e test guards the
nav.
`src/ui/icons.ts` is deliberately not re-exported from `@plainpages/plugin-api`. The palette may
narrow when the last reference to an id goes, and a plugin needing one gets it re-registered in the
same change. The accepted cost: an unknown sprite id renders blank instead of failing loud.
## Dependencies install to `/node_modules`, above `WORKDIR /app`
2026-08-05, the maintainer. Code health. Valid while dev bind-mounts the checkout at `/app`.
Node resolves upward, so dev's `.:/app` bind mount has nothing to shadow. A volume at
`/app/node_modules` would not do: the daemon creates a mount destination as root whatever `--user`
says, leaving a root-owned dir in the checkout. Nothing may sit at that path, since it silently
shadows `/node_modules`; `src/compose.test.ts` guards the compose files, and `.dockerignore` the
image.
## A container whose output a human then edits or deletes runs as the invoking user
2026-08-05, the maintainer. Code health. Valid while a dev box may lack `sudo`.
That means `--user "$(id -u):$(id -g)"` for the E2E runner (artifacts) and a lockfile edit;
otherwise the output is root-owned. It is not universal: `bootstrap` writes `jwks.json` as root when
it is absent on first boot. The committed dev key makes that rare, and when it happens the rotation
runbook's host-side `>` needs the file re-owned first. That exception is valid while the dev key
ships committed.
Three consequences:
- `e2e-tests/artifacts/` is tracked (`.gitkeep`), since an absent bind-mount source is
daemon-created as root and that uid then cannot write it (README → Upgrading).
- The runner image sets `HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an
unwritable `/`.
- Rootless Docker wants the flag dropped, container root already being the invoking user.
Baking a `USER` into the image does not work: the image's `pwuser` is 1001, and no fixed uid matches
every host. `src/compose.test.ts` guards every documented command, and `src/ci-gate.test.ts` the
gate's own.
## Anything the browser logs fails the E2E test that provoked it
2026-08-05, the maintainer. Goal 4. Valid while pages ship no JavaScript of their own.
Every spec takes its `test` from `e2e-tests/console-guard.ts`, which fails a test on a console error
or warning, or an uncaught exception, on any page it opened. A zero-JS app has nothing to say in the
console, so the bar is zero rather than a curated tolerance list. The two exceptions are narrow: a
module-level allowance for the COOP header Chromium drops (the e2e stacks serve plain http over
container hostnames), and `allowConsole(re)` for a test whose own page provokes a message on
purpose.
`src/e2e-console-guard.test.ts` locks the wiring in the unit gate, since a spec importing `test`
straight from Playwright, or minting a page with a raw `newPage()` instead of `watchedPage()`, would
run unwatched and green. The accepted cost: a page outliving its test can log late and fail the
next one.
## The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium
2026-08-05, the maintainer. Goal 4. Valid while the Ory-backed specs share one backend.
`visual.spec.ts` and `language.spec.ts` are side-effect-free, so parallel runs don't collide, and a
console message only appears in the engine that renders the page (`ORY_FREE` in
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
so widening them means a stack per engine.
## The docs-only CI skip is `*.md` anywhere in the tree
2026-08-05, the maintainer. Code health. Valid while no markdown file is rendered or executed.
Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the
destination, so `git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a
source file that was gone. `src/ci-gate.test.ts` locks the flags as a text guard, since the test
image ships neither `git` nor `bash`.
Because a `.md` change skips the gate, the Docker Hub overview is
`release-tooling/dockerhub-overview.md.tmpl`. A release reads it and a unit test guards it, so under
a `.md` name a broken `{{VERSION}}` would merge with that guard skipped.
`README.md` is the one markdown file tests read, so a README-only change skips them. That leaves two
gaps:
- An `apiVersion` sample in the README can go stale unchecked by
`release-tooling/contract-version.test.ts`. Accepted, because the README's samples are
illustrative; the copies authors build from (`examples/`, `views/`, the template) are gated.
- A README-only change that drops a command the Hub page shows fails only the next gated change, in
`release-tooling/dockerhub-overview.test.ts`.
## CI docker logins share the runner host's Docker config
2026-07-04, the maintainer. Code health. Valid while releases keep a single-maintainer cadence; if
these races start costing re-runs, serialize the workflows with a `concurrency` group.
The act_runner is host-mode, so `docker login` and `logout` in the workflows mutate one shared
`~/.docker/config.json`. Concurrent jobs can race: one job's logout can 401 another's push, and the
recovery is a re-run. Tokens sit in that file between login and logout. In the same class,
concurrent runs share the workspace dir, so `ci.sh`'s web-image build races another run's container
creation on the `<project>-web` tag.
+1 -278
View File
@@ -103,7 +103,7 @@ Read most (opens and searches):
- `src/http/app.test.ts` 1 - `src/http/app.test.ts` 1
- `src/ui/chrome.test.ts` 1 - `src/ui/chrome.test.ts` 1
- `src/ui/chrome.ts` 1 - `src/ui/chrome.ts` 1
- `views/dashboard.ejs` 1 - `views/index.ejs` 1
- `views/partials/shell.ejs` 1 - `views/partials/shell.ejs` 1
## Reading cost, chunk e4d3bb0ac092, finished 2026-10-31T08:02:27Z ## Reading cost, chunk e4d3bb0ac092, finished 2026-10-31T08:02:27Z
@@ -304,280 +304,3 @@ Read most (opens and searches):
- `CHANGELOG.md` 2 - `CHANGELOG.md` 2
- `src/plugin-host/settings.ts` 2 - `src/plugin-host/settings.ts` 2
- `AGENTS.md` 1 - `AGENTS.md` 1
## Reading cost, chunk 1a31ef102e70, finished 2026-10-31T13:14:10Z
70498 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 4
- `src/auth/bootstrap.test.ts` 4
- `src/auth/bootstrap.ts` 4
- `todo.md` 4
- `CHANGELOG.md` 1
- `src/config.test.ts` 1
- `src/config.ts` 1
- `src/plugin-host/storage-provisioning.ts` 1
- `src/plugin-host/storage.ts` 1
## Reading cost, chunk 80e22d10c810, finished 2026-11-08T13:06:23Z
130001 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 6
- `todo.md` 4
- `examples/plugins/admin/admin-groups.ts` 2
- `src/auth/bootstrap.ts` 2
- `src/auth/login.ts` 2
- `src/http/app-admin.test.ts` 2
- `AGENTS.md` 1
- `CHANGELOG.md` 1
- `examples/plugins/admin/i18n/sv-SE.ts` 1
- `src/auth/routes.ts` 1
## Reading cost, chunk b94ae3839c24, finished 2026-11-08T13:27:14Z
87251 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 9
- `README.md` 7
- `src/plugin-host/plugin.test.ts` 6
- `src/plugin-host/plugin.ts` 5
- `src/test-tooling/stylesheet-floor.ts` 4
- `AGENTS.md` 3
- `src/test-tooling/app-harness.ts` 3
- `src/plugin-host/discovery.ts` 2
- `src/test-tooling/stylesheet-floor.test.ts` 2
- `.dockerignore` 1
## Reading cost, chunk dcd9bc01df44, finished 2026-11-08T13:55:26Z
212195 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 10
- `todo.md` 8
- `src/http/app.ts` 4
- `src/http/app.test.ts` 3
- `src/http/static.ts` 3
- `CHANGELOG.md` 2
- `examples/plugins/admin/admin-users.ts` 2
- `release-tooling/contract-version.test.ts` 2
- `examples/plugins/admin/admin-clients.ts` 1
- `examples/plugins/admin/admin-groups.ts` 1
## Reading cost, chunk a6f3d52327a4, finished 2026-11-08T14:17:22Z
50555 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 4
## Reading cost, chunk 177d8e8081a6, finished 2026-11-08T18:14:49Z
66196 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 5
- `examples/plugins/admin/plugin.ts` 1
## Reading cost, chunk dd4d721e79d4, finished 2026-11-08T18:52:37Z
90532 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 8
- `README.md` 7
- `src/plugin-host/plugin.ts` 6
- `src/plugin-host/plugin.test.ts` 4
- `src/ui/chrome.ts` 4
- `AGENTS.md` 3
- `examples/plugins/scheduling/plugin.ts` 3
- `src/plugin-host/plugin-api.ts` 3
- `src/ui/chrome.test.ts` 3
- `CHANGELOG.md` 2
## Reading cost, chunk f4e8ff50cb2a, finished 2026-11-08T19:37:35Z
73698 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app.ts` 7
- `src/function-limits.test.ts` 2
- `src/http/context.ts` 2
- `src/http/static.ts` 2
- `src/plugin-host/plugin.ts` 2
- `todo.md` 2
- `AGENTS.md` 1
- `src/http/builtin-route.ts` 1
- `src/plugin-host/hooks.ts` 1
## Reading cost reset, 2026-11-08T19:39:42Z
## Reading cost, chunk 385be0aa8aef, finished 2026-11-08T19:57:05Z
87493 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 19
- `src/plugin-host/plugin.ts` 14
- `src/plugin-host/plugin.test.ts` 9
- `todo.md` 9
- `AGENTS.md` 4
- `docs/ci-cd.md` 4
- `src/compose.test.ts` 2
- `src/ci-gate.test.ts` 1
- `src/function-limits.test.ts` 1
- `src/plugin-host/discovery.ts` 1
## Reading cost, chunk 7100f96a1d8d, finished 2026-11-08T20:19:54Z
96357 new input tokens before the first commit.
Read most (opens and searches):
- `AGENTS.md` 11
- `README.md` 4
- `docs/decisions.md` 4
- `.gitea/workflows/release.yml` 2
- `examples/plugins/admin/admin-shared.ts` 2
- `src/postgres.test.ts` 2
- `todo.md` 2
- `src/auth/gate.ts` 1
- `src/auth/jwt.ts` 1
- `src/server.ts` 1
## Reading cost, chunk ac2b7411e145, finished 2026-11-08T20:50:21Z
105677 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 15
- `src/http/app-admin.test.ts` 7
- `todo.md` 5
- `CHANGELOG.md` 3
- `ci.sh` 3
- `examples/README.md` 3
- `examples/plugins/auth-admin/admin-shared.test.ts` 3
- `src/plugin-host/conflicts.ts` 3
- `AGENTS.md` 2
- `compose.yml` 2
## Reading cost, chunk 0d9d4ffb47aa, finished 2026-11-08T20:58:38Z
48347 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app.ts` 4
- `src/http/app.test.ts` 2
- `todo.md` 2
- `src/plugin-host/plugin.ts` 1
## Reading cost, chunk 8d5c5ad2ab7c, finished 2026-11-08T21:39:34Z
145906 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app-admin.test.ts` 8
- `README.md` 6
- `CHANGELOG.md` 5
- `compose.yml` 5
- `docs/decisions.md` 5
- `examples/plugins/auth-admin/admin-grants.test.ts` 4
- `examples/plugins/auth-admin/admin-users.ts` 4
- `src/auth/keto-client.ts` 4
- `todo.md` 4
- `examples/plugins/auth-admin/README.md` 3
## Reading cost, chunk ef984469151f, finished 2026-11-08T22:01:41Z
38668 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 7
- `AGENTS.md` 1
- `README.md` 1
- `docs/decisions.md` 1
## Reading cost, chunk f0194a598d2a, finished 2026-11-08T23:10:24Z
81419 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app-admin.test.ts` 11
- `examples/plugins/auth-admin/admin-users.ts` 7
- `todo.md` 6
- `examples/plugins/auth-admin/admin-groups.test.ts` 4
- `README.md` 3
- `examples/plugins/auth-admin/admin-groups.ts` 3
- `CHANGELOG.md` 2
- `docs/reading-cost.md` 2
- `examples/plugins/auth-admin/admin-grants.ts` 2
- `examples/plugins/auth-admin/i18n/en-US.ts` 2
## Reading cost, chunk a2d8600ec28b, finished 2026-11-08T23:23:17Z
66451 new input tokens before the first commit.
Read most (opens and searches):
- `src/auth/bootstrap.ts` 4
- `src/auth/jwt-middleware.ts` 4
- `README.md` 3
- `ci.sh` 3
- `examples/plugins/scheduling/plugin.ts` 3
- `src/auth/jwt.ts` 3
- `src/ui/chrome.ts` 3
- `compose.yml` 2
- `examples/plugins/auth-admin/admin-groups.ts` 2
- `examples/plugins/auth-admin/views/partials/group-detail-body.ejs` 2
## Reading cost, chunk d95a4d7286d2, finished 2026-11-08T23:59:30Z
82203 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app.ts` 8
- `src/http/app-plugins.test.ts` 4
- `todo.md` 4
- `src/http/app.test.ts` 3
- `src/function-limits.test.ts` 2
- `src/test-tooling/app-harness.ts` 2
- `docs/decisions.md` 1
- `src/auth/routes.ts` 1
- `src/http/builtin-route.test.ts` 1
- `src/http/builtin-route.ts` 1
## Reading cost, chunk 1241ed10b1c1, finished 2026-11-09T00:14:18Z
69646 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 7
- `src/auth/bootstrap.test.ts` 4
- `src/auth/bootstrap.ts` 4
- `src/auth/keto-client.ts` 4
- `src/config.ts` 3
- `src/auth/kratos-admin.ts` 2
- `src/config.test.ts` 2
- `src/function-limits.test.ts` 2
- `AGENTS.md` 1
- `CHANGELOG.md` 1
+1 -1
View File
@@ -1,6 +1,6 @@
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/. # Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network. # Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
FROM mcr.microsoft.com/playwright:v1.65.0-noble FROM mcr.microsoft.com/playwright:v1.64.1-noble
WORKDIR /e2e-tests WORKDIR /e2e-tests
+9 -10
View File
@@ -6,7 +6,7 @@
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after # docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
services: services:
web: web:
# The base's full depends_on applies (Hydra included — the auth-admin plugin's OAuth2-clients # The base's full depends_on applies (Hydra included — the admin plugin's OAuth2-clients
# screen needs it); only the reference plugin's upstream is added. SSO is enabled here only # 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 # (clean clone stays password-only): the mock provider's whole array is the env-settable form
# Kratos offers, mapped through the committed claims jsonnet. # Kratos offers, mapped through the committed claims jsonnet.
@@ -24,18 +24,18 @@ services:
timeout: 4s timeout: 4s
retries: 30 retries: 30
# plugins/ is empty in the image; bind the example plugins in so the browser flow can open the # plugins/ is empty in the image; bind the example plugins in so the browser flow can open the
# gated /scheduling/shifts page and the /auth-admin/* screens (the admin screens ship as a drop-in # gated /scheduling/shifts page and the /admin/* screens (the admin screens ship as a drop-in
# plugin, mounted at /app/plugins/auth-admin, reaching the host's Ory clients via ctx.system). # plugin, mounted at /app/plugins/admin, reaching the host's Ory clients via ctx.system).
volumes: volumes:
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro - ./examples/plugins/scheduling:/app/plugins/scheduling:ro
- ./examples/plugins/auth-admin:/app/plugins/auth-admin:ro - ./examples/plugins/admin:/app/plugins/admin:ro
# bootstrap grants the demo admin every discovered plugin's permission names, so it needs the # bootstrap grants the demo admin every discovered plugin's permission names, so it needs the
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403. # example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
bootstrap: bootstrap:
volumes: volumes:
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro - ./examples/plugins/scheduling:/app/plugins/scheduling:ro
- ./examples/plugins/auth-admin:/app/plugins/auth-admin:ro - ./examples/plugins/admin:/app/plugins/admin:ro
# Browser-facing URLs (base_url, every ui_url, the after-login redirect) move to the gateway host. # Browser-facing URLs (base_url, every ui_url, the after-login redirect) move to the gateway host.
# `--dev`: the browser hits the gateway over http, but Kratos marks cookies Secure for a # `--dev`: the browser hits the gateway over http, but Kratos marks cookies Secure for a
@@ -53,7 +53,7 @@ services:
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows. # The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
shifts-upstream: shifts-upstream:
image: node:26.13.0-alpine3.24 image: node:24.22.2-alpine3.24
command: ["node", "/server.ts"] command: ["node", "/server.ts"]
volumes: volumes:
- ./examples/shifts-upstream/server.ts:/server.ts:ro - ./examples/shifts-upstream/server.ts:/server.ts:ro
@@ -66,7 +66,7 @@ services:
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos # 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. # verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
mock-oidc: mock-oidc:
image: node:26.13.0-alpine3.24 image: node:24.22.2-alpine3.24
command: ["node", "/mock-oidc.ts"] command: ["node", "/mock-oidc.ts"]
environment: environment:
ISSUER: http://mock-oidc:9000 ISSUER: http://mock-oidc:9000
@@ -81,7 +81,7 @@ services:
# Catches the courier's mail (kratos.yml → smtp://mailpit:1025), so a test can read an emailed code. # Catches the courier's mail (kratos.yml → smtp://mailpit:1025), so a test can read an emailed code.
mailpit: mailpit:
image: axllent/mailpit:v1.33.1 image: axllent/mailpit:v1.33.0
mem_limit: 128m mem_limit: 128m
memswap_limit: 128m memswap_limit: 128m
healthcheck: healthcheck:
@@ -92,7 +92,7 @@ services:
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts). # Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
proxy: proxy:
image: node:26.13.0-alpine3.24 image: node:24.22.2-alpine3.24
command: ["node", "/proxy.ts"] command: ["node", "/proxy.ts"]
depends_on: depends_on:
web: web:
@@ -122,7 +122,6 @@ services:
condition: service_healthy condition: service_healthy
environment: environment:
BASE_URL: http://proxy BASE_URL: http://proxy
KETO_WRITE_URL: http://keto:4467
KRATOS_ADMIN_URL: http://kratos:4434 KRATOS_ADMIN_URL: http://kratos:4434
MAILPIT_URL: http://mailpit:8025 MAILPIT_URL: http://mailpit:8025
volumes: volumes:
+3 -7
View File
@@ -12,14 +12,10 @@ import { expect, test as base, type BrowserContext, type Page } from "@playwrigh
const problems: string[] = []; const problems: string[] = [];
const allowed: RegExp[] = []; const allowed: RegExp[] = [];
// The messages the stack itself provokes: the runner reaches `web`/`proxy` by container name over // 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 // plain http, and only a `localhost` origin is trustworthy without TLS — so Chromium drops the COOP
// header the app sends, and WebKit reports the page not cross-origin isolated, on every page. Over // header the app sends and says so on every page. Over https, where a deployment serves, it applies.
// https, where a deployment serves, the header applies. const EXPECTED = [/^console\.error: The Cross-Origin-Opener-Policy header has been ignored/];
const EXPECTED = [
/^console\.error: The Cross-Origin-Opener-Policy header has been ignored/,
/^console\.warning: This document is not cross-origin isolated because the document is not a secure context/,
];
// Allow a message for the current test only, when the page under test provokes it on purpose. // Allow a message for the current test only, when the page under test provokes it on purpose.
export function allowConsole(...patterns: RegExp[]): void { export function allowConsole(...patterns: RegExp[]): void {
+28 -44
View File
@@ -15,7 +15,6 @@ const ADMIN_PASSWORD = "admin";
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434"; const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
const KETO_WRITE = process.env.KETO_WRITE_URL ?? "http://keto:4467";
const MAILPIT = process.env.MAILPIT_URL ?? "http://mailpit:8025"; const MAILPIT = process.env.MAILPIT_URL ?? "http://mailpit:8025";
// Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in. // Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in.
@@ -56,7 +55,7 @@ test.describe.serial("authenticated admin journey", () => {
// The list screens rebuild their query from the list state (sort/page/filter), so they are where a // 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. // 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 () => { test("a sorted, paged admin list keeps the visitor's language", async () => {
await page.goto("/auth-admin/users?locale=sv-SE"); await page.goto("/admin/users?locale=sv-SE");
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible();
@@ -81,7 +80,7 @@ test.describe.serial("authenticated admin journey", () => {
// A POST that re-renders a page: the write must keep the language, and the picker — which is on // 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. // 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 () => { test("a write keeps the visitor's language, and the picker still works on the POST-rendered page", async () => {
await page.goto("/auth-admin/users?locale=sv-SE"); await page.goto("/admin/users?locale=sv-SE");
await page.getByRole("link", { name: "Ny användare" }).click(); await page.getByRole("link", { name: "Ny användare" }).click();
await page.fill('input[name="email"]', `lang-${suffix}@plainpages.local`); await page.fill('input[name="email"]', `lang-${suffix}@plainpages.local`);
await page.getByRole("button", { name: "Skapa användare" }).click(); await page.getByRole("button", { name: "Skapa användare" }).click();
@@ -90,7 +89,7 @@ test.describe.serial("authenticated admin journey", () => {
// Open the new user's edit page the way the CRUD test does — the row's Edit link carries the id. // 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 row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` });
const editHref = await row.locator('a[href^="/auth-admin/users/"]').first().getAttribute("href"); const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
await page.goto(`${editHref}`); await page.goto(`${editHref}`);
await expect(page.locator('button[aria-label="Språk"]')).toHaveCount(1); await expect(page.locator('button[aria-label="Språk"]')).toHaveCount(1);
await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route
@@ -109,33 +108,33 @@ test.describe.serial("authenticated admin journey", () => {
// seeds exactly those), so both gated sections are present in the menu (collapsed by default → // seeds exactly those), so both gated sections are present in the menu (collapsed by default →
// assert they're in the DOM, not necessarily visible). // assert they're in the DOM, not necessarily visible).
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/auth-admin/users"]')).toHaveCount(1); await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1);
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1); await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
}); });
test("users CRUD: create a user, see it listed, then delete it via the confirm step", async () => { test("users CRUD: create a user, see it listed, then delete it via the confirm step", async () => {
const email = `e2e-${suffix}@plainpages.local`; const email = `e2e-${suffix}@plainpages.local`;
await page.goto("/auth-admin/users/new"); await page.goto("/admin/users/new");
await page.fill('input[name="email"]', email); await page.fill('input[name="email"]', email);
await page.fill('input[name="first"]', "E2E"); await page.fill('input[name="first"]', "E2E");
await page.fill('input[name="last"]', "User"); await page.fill('input[name="last"]', "User");
await page.locator('.form-card button[type="submit"]').click(); await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/); // PRG back to the list await expect(page).toHaveURL(/\/admin\/users(\?|$)/); // PRG back to the list
const row = page.locator("tr", { hasText: email }); const row = page.locator("tr", { hasText: email });
await expect(row).toBeVisible(); await expect(row).toBeVisible();
// Row actions sit behind the kebab popover: opening it reveals them, in the top layer, so the // 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. // scrolling table around the row cannot clip the panel.
await row.locator("button.kebab").click(); await row.locator("button.kebab").click();
await expect(row.locator('a[href^="/auth-admin/users/"]').first()).toBeVisible(); await expect(row.locator('a[href^="/admin/users/"]').first()).toBeVisible();
// Delete through the confirm interstitial (the row's Edit link carries the id). // Delete through the confirm interstitial (the row's Edit link carries the id).
const editHref = await row.locator('a[href^="/auth-admin/users/"]').first().getAttribute("href"); const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
await page.goto(`${editHref}/delete`); await page.goto(`${editHref}/delete`);
await page.getByRole("button", { name: "Delete user" }).click(); // the confirm form's danger button await page.getByRole("button", { name: "Delete user" }).click(); // the confirm form's danger button
await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/); await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
await expect(page.locator("tr", { hasText: email })).toHaveCount(0); await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
}); });
@@ -144,8 +143,8 @@ test.describe.serial("authenticated admin journey", () => {
test.setTimeout(90_000); test.setTimeout(90_000);
const email = `admin-recovery-${suffix}@plainpages.local`; const email = `admin-recovery-${suffix}@plainpages.local`;
await createIdentity(email); await createIdentity(email);
await page.goto("/auth-admin/users"); await page.goto("/admin/users");
const editHref = await page.locator("tr", { hasText: email }).locator('a[href^="/auth-admin/users/"]').first().getAttribute("href"); const editHref = await page.locator("tr", { hasText: email }).locator('a[href^="/admin/users/"]').first().getAttribute("href");
await page.goto(editHref!); await page.goto(editHref!);
await page.getByRole("button", { name: "Generate recovery code" }).click(); await page.getByRole("button", { name: "Generate recovery code" }).click();
const code = await page.locator(".recovery-code code").innerText(); const code = await page.locator(".recovery-code code").innerText();
@@ -163,27 +162,27 @@ test.describe.serial("authenticated admin journey", () => {
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form // 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. // enforces it); pick the first option (a user) from the required picker.
const group = `e2e-grp-${suffix}`; const group = `e2e-grp-${suffix}`;
await page.goto("/auth-admin/groups/new"); await page.goto("/admin/groups/new");
await page.fill('input[name="name"]', group); await page.fill('input[name="name"]', group);
await page.locator('select[name="member"]').selectOption({ index: 1 }); await page.locator('select[name="member"]').selectOption({ index: 1 });
await page.locator('.form-card button[type="submit"]').click(); await page.locator('.form-card button[type="submit"]').click();
await expect(page).toHaveURL(/\/auth-admin\/groups(\?|\/|$)/); await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
await expect(page.locator("main")).toContainText(group); await expect(page.locator("main")).toContainText(group);
// Permissions are declared in plugin code, so the group's detail page offers them as a fixed // 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. // checkbox list rather than a create form — there is no Permissions screen to visit.
await page.goto(`/auth-admin/groups/${group}`); await page.goto(`/admin/groups/${group}`);
allowConsole(/status of 409 .*\/auth-admin\/groups\/[^/]+\/permissions/); // the stale save below, refused on purpose allowConsole(/status of 409 .*\/admin\/groups\/[^/]+\/permissions/); // the stale save below, refused on purpose
// A second browser signed in as the same admin, opened before the save below, so its form carries the old grants. // A second browser signed in as the same admin, opened before the save below, so its form carries the old grants.
const staleTab = await watchedPage(await browser.newContext({ storageState: await page.context().storageState() })); const staleTab = await watchedPage(await browser.newContext({ storageState: await page.context().storageState() }));
await staleTab.goto(`/auth-admin/groups/${group}`); await staleTab.goto(`/admin/groups/${group}`);
const scheduling = page.locator('input[name="permission"][value="scheduling:read"]'); 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).toHaveCount(1); // declared by the reference plugin, so it's on offer
await expect(scheduling).not.toBeChecked(); await expect(scheduling).not.toBeChecked();
await scheduling.check(); await scheduling.check();
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click(); await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
await expect(page).toHaveURL(new RegExp(`/auth-admin/groups/${group}`)); await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`));
await expect(page.getByText("Permissions saved.")).toBeVisible(); await expect(page.getByText("Permissions saved.")).toBeVisible();
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked(); await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
@@ -194,26 +193,11 @@ test.describe.serial("authenticated admin journey", () => {
await expect(staleTab.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked(); await expect(staleTab.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
await expect(staleTab.locator('input[name="permission"][value="scheduling:write"]')).not.toBeChecked(); await expect(staleTab.locator('input[name="permission"][value="scheduling:write"]')).not.toBeChecked();
await staleTab.context().close(); await staleTab.context().close();
// No screen can grant a name no plugin declares, so write one to Keto directly.
const stray = "retired:write";
const put = await fetch(`${KETO_WRITE}/admin/relation-tuples`, {
body: JSON.stringify({ namespace: "Permission", object: stray, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } }),
headers: { "content-type": "application/json" },
method: "PUT",
});
expect(put.ok).toBe(true);
await page.goto(`/auth-admin/groups/${group}`);
await expect(page.getByRole("heading", { name: "Permissions no installed plugin declares" })).toBeVisible();
await page.getByRole("button", { name: `Remove ${stray}` }).click();
await expect(page.getByText("Permission removed.")).toBeVisible();
await expect(page.getByText(stray)).toHaveCount(0);
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked(); // the declared grant stays
}); });
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 () => { 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}`; const name = `e2e-client-${suffix}`;
await page.goto("/auth-admin/clients"); await page.goto("/admin/clients");
await page.getByRole("link", { name: "Register client" }).click(); await page.getByRole("link", { name: "Register client" }).click();
await page.fill('input[name="name"]', name); await page.fill('input[name="name"]', name);
await page.fill('textarea[name="redirectUris"]', "https://app.example.com/callback"); await page.fill('textarea[name="redirectUris"]', "https://app.example.com/callback");
@@ -226,17 +210,17 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("#csecret")).toHaveValue(/.+/); await expect(page.locator("#csecret")).toHaveValue(/.+/);
// Listed; the row header links to the plain detail, which never shows the secret again. // Listed; the row header links to the plain detail, which never shows the secret again.
await page.goto("/auth-admin/clients"); await page.goto("/admin/clients");
const row = page.locator("tr", { hasText: name }); const row = page.locator("tr", { hasText: name });
await expect(row).toBeVisible(); await expect(row).toBeVisible();
await row.getByRole("link", { name }).click(); await row.getByRole("link", { name }).click();
await expect(page).toHaveURL(new RegExp(`/auth-admin/clients/${clientId}`)); await expect(page).toHaveURL(new RegExp(`/admin/clients/${clientId}`));
await expect(page.locator("#csecret")).toHaveCount(0); await expect(page.locator("#csecret")).toHaveCount(0);
// Delete through the confirm interstitial (danger link on the detail → confirm form's button). // 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("link", { name: "Delete client" }).click();
await page.getByRole("button", { name: "Delete client" }).click(); await page.getByRole("button", { name: "Delete client" }).click();
await expect(page).toHaveURL(/\/auth-admin\/clients(\?|$)/); await expect(page).toHaveURL(/\/admin\/clients(\?|$)/);
await expect(page.locator("tr", { hasText: name })).toHaveCount(0); await expect(page.locator("tr", { hasText: name })).toHaveCount(0);
}); });
@@ -253,7 +237,7 @@ test.describe.serial("authenticated admin journey", () => {
}); });
test("plugin settings: the screen names the variable that sets each declared key", async () => { test("plugin settings: the screen names the variable that sets each declared key", async () => {
await page.goto("/auth-admin/plugin-settings"); await page.goto("/admin/plugin-settings");
await expect(page.locator("h1")).toHaveText("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. // 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" }); const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" });
@@ -266,14 +250,14 @@ test.describe.serial("authenticated admin journey", () => {
test("logout: signing out ends the session and returns to the login page", async () => { test("logout: signing out ends the session and returns to the login page", async () => {
await page.goto("/dashboard"); await page.goto("/dashboard");
await page.locator("button.profile").click(); // open the profile dropdown await page.locator("button.profile").click(); // open the profile dropdown
await expect(page.locator("#profile-menu .menu-item")).toHaveText(["My shifts", "Sign out"]); // Sign out is the only item in it — the menu offers nothing that goes nowhere.
await expect(page.locator('#profile-menu a.menu-item[href="/scheduling/mine"]')).toHaveCount(1); await expect(page.locator("#profile-menu .menu-item")).toHaveText(["Sign out"]);
await page.locator('form[action="/logout"] button[type="submit"]').click(); await page.locator('form[action="/logout"] button[type="submit"]').click();
await page.waitForURL(/\/login(\?|$)/); await page.waitForURL(/\/login(\?|$)/);
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav). // The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login(\?|$)/); await expect(page).toHaveURL(/\/login(\?|$)/);
await expect(page.locator('.sidebar a[href="/auth-admin/users"]')).toHaveCount(0); await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
}); });
}); });
@@ -281,13 +265,13 @@ test("return_to: a deep link while logged out returns to that page after login",
test.setTimeout(90_000); test.setTimeout(90_000);
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos // A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
// flow server-side, so it's consumed, not shown in the settled URL). // flow server-side, so it's consumed, not shown in the settled URL).
await page.goto("/auth-admin/users"); await page.goto("/admin/users");
await expect(page).toHaveURL(/\/login(\?|$)/); await expect(page).toHaveURL(/\/login(\?|$)/);
await page.fill('input[name="identifier"]', ADMIN_EMAIL); await page.fill('input[name="identifier"]', ADMIN_EMAIL);
await page.fill('input[name="password"]', ADMIN_PASSWORD); await page.fill('input[name="password"]', ADMIN_PASSWORD);
await page.locator('.auth-form button[type="submit"]').click(); await page.locator('.auth-form button[type="submit"]').click();
// Completion routes through /auth/complete (mints the JWT) and on to the requested page, not the dashboard. // Completion routes through /auth/complete (mints the JWT) and on to the requested page, not the dashboard.
await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/); await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
await expect(page.locator("h1")).toHaveText("Users"); await expect(page.locator("h1")).toHaveText("Users");
}); });
@@ -310,7 +294,7 @@ test("mocked SSO login: the provider button signs a user in via OIDC", async ({
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in. // Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL); 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 permissions, so the gated Admin section stays hidden.
await expect(page.locator('.sidebar a[href="/auth-admin/users"]')).toHaveCount(0); await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
}); });
// The newest one-time code Kratos' courier mailed to `to`, read from the mail catcher (compose.full.yml). // The newest one-time code Kratos' courier mailed to `to`, read from the mail catcher (compose.full.yml).
+20 -20
View File
@@ -6,19 +6,19 @@
"": { "": {
"name": "plainpages-e2e", "name": "plainpages-e2e",
"devDependencies": { "devDependencies": {
"@playwright/test": "1.65.0", "@playwright/test": "1.64.1",
"@types/node": "26.6.6", "@types/node": "24.19.2",
"typescript": "7.0.2" "typescript": "7.0.2"
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.65.0", "version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==", "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.65.0" "playwright": "1.64.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -28,13 +28,13 @@
} }
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "26.6.6", "version": "24.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~8.9.0" "undici-types": ">=7.24.0 <7.24.7"
} }
}, },
"node_modules/@typescript/typescript-aix-ppc64": { "node_modules/@typescript/typescript-aix-ppc64": {
@@ -378,13 +378,13 @@
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.65.0", "version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
"integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==", "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.65.0" "playwright-core": "1.64.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -394,9 +394,9 @@
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.65.0", "version": "1.64.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
"integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==", "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -442,9 +442,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "8.9.0", "version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
} }
+2 -2
View File
@@ -8,8 +8,8 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.65.0", "@playwright/test": "1.64.1",
"@types/node": "26.6.6", "@types/node": "24.19.2",
"typescript": "7.0.2" "typescript": "7.0.2"
} }
} }
+2 -4
View File
@@ -264,9 +264,7 @@ test("a repeat page view fetches no stylesheet again", async ({ page }) => {
await page.goto("/"); await page.goto("/");
const repeat = await stylesheets(); const repeat = await stylesheets();
// A cache hit completes in no set order, so the two views list the same files in any order. expect(repeat.map(({ name }) => name)).toEqual(first.map(({ name }) => name));
const names = (entries: { name: string }[]): string[] => entries.map(({ name }) => name).sort();
expect(names(repeat)).toEqual(names(first));
for (const { name, transferSize } of repeat) expect(transferSize, `${name} on the repeat view`).toBe(0); for (const { name, transferSize } of repeat) expect(transferSize, `${name} on the repeat view`).toBe(0);
}); });
@@ -312,7 +310,7 @@ test("the reference plugin: public Overview is open to all, My shifts takes any
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
await expect(page.locator('.sidebar nav a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough await expect(page.locator('.sidebar a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough
// No shifts upstream on this stack, so this also pins the degraded page: the reason, never a 500 // No shifts upstream on this stack, so this also pins the degraded page: the reason, never a 500
// and never a claim about what is assigned. // and never a claim about what is assigned.
+1 -1
View File
@@ -6,6 +6,6 @@ across (or bind-mount your own) and restart.
| Path | Copy into | Example of | | Path | Copy into | Example of |
| --- | --- | --- | | --- | --- | --- |
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). | | [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
| [`plugins/auth-admin/`](plugins/auth-admin/) | `plugins/auth-admin/` | The system-admin plugin: the Users / Groups / OAuth2-clients / Plugin-settings screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/auth-admin/README.md). | | [`plugins/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). |
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). | | [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `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 `PLUGIN_SETTING_SCHEDULING_UPSTREAM` at the real thing instead. |
+3 -5
View File
@@ -2,9 +2,8 @@
// cp examples/config/menu.ts config/menu.ts // cp examples/config/menu.ts config/menu.ts
// Absent config = built-in defaults. // Absent config = built-in defaults.
// //
// Brand the app, reorder/rename/group/hide nav nodes and rename/hide profile-menu entries (by their // Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — the
// `id`) across all plugins — the override always wins, applied before the per-user permission // override always wins, applied before the per-user permission filter. Every field is optional.
// filter. Every field is optional.
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system. // See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system.
import { defineMenu } from "#menu-config"; import { defineMenu } from "#menu-config";
@@ -17,8 +16,7 @@ export default defineMenu({
// theme: "auto", // default color theme: auto | light | dark // theme: "auto", // default color theme: auto | light | dark
}, },
// Operator override (rename → group → order → hide), keyed by nav-node id; `rename` and `hide` also // Operator override (rename → group → order → hide), keyed by node id.
// take a profile-menu entry's id, such as "scheduling:profile-mine".
override: { override: {
// rename: { people: "Staff" }, // node id → new label (or a catalog key) // rename: { people: "Staff" }, // node id → new label (or a catalog key)
// groups: [{ id: "admin", label: "Admin", children: ["users", "groups"] }], // groups: [{ id: "admin", label: "Admin", children: ["users", "groups"] }],
@@ -1,11 +1,11 @@
# auth-admin — the plugin that administers users, groups, permissions, OAuth2 clients and plugin settings # Admin — the system-administration plugin
The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped as a **drop-in 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/` example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/`
(its folder name `auth-admin` is its id and mount path, so the screens live at `/auth-admin/*`) and restart: (it keeps the id and mount path `admin`, so the screens live at `/admin/*`) and restart:
```bash ```bash
cp -r examples/plugins/auth-admin plugins/auth-admin cp -r examples/plugins/admin plugins/admin
docker compose up -d docker compose up -d
``` ```
@@ -35,18 +35,17 @@ screen degrades to a themed 503 rather than crashing. Everything else is an ordi
folder-discovered, gated per route by its screen's `<resource>:<action>` permission, rendering the folder-discovered, gated per route by its screen's `<resource>:<action>` permission, rendering the
core building blocks in `views/`. core building blocks in `views/`.
Each screen is its own resource — `users`, `groups`, `oauth2-clients`, `plugin-settings` — split Each screen is its own resource — `users`, `groups`, `oauth2-clients` — split into `:read` and
into `:read` and `:write` (`plugin-settings` has only `:read`), so a helpdesk account can be given `:write`, so a helpdesk account can be given `users:read` alone. Holding none of the six hides the
`users:read` alone. Holding none of the seven hides the Admin section entirely. Admin section entirely.
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a 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 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`). property of a user or a group, edited as a checkbox list on those two screens (`admin-grants.ts`).
A grant of a name no installed plugin declares is listed apart on the same page, with a Remove.
## Layout ## Layout
- `plugin.ts` — the manifest: the Admin nav fragment, the seven permissions the plugin declares, and - `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))` the route table — one thin handler per method+path, gated via `permissionName(resource, actionForMethod(method))`
so a GET needs `:read` and a POST `:write`. 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 - `admin-grants.ts` — the permission picker and the grant diff, shared by the Users and Groups
@@ -59,15 +59,15 @@ test("validateClientInput requires a name, ≥1 redirect URI, and absolute redir
test("buildClientsListModel filters by search, paginates; the name links to the detail page", () => { test("buildClientsListModel filters by search, paginates; the name links to the detail page", () => {
const clients = Array.from({ length: 30 }, (_, i) => ({ client_id: `id-${String(i).padStart(2, "0")}`, client_name: `app-${String(i).padStart(2, "0")}`, token_endpoint_auth_method: "client_secret_basic" })); const clients = Array.from({ length: 30 }, (_, i) => ({ client_id: `id-${String(i).padStart(2, "0")}`, client_name: `app-${String(i).padStart(2, "0")}`, token_endpoint_auth_method: "client_secret_basic" }));
const all = buildClientsListModel({ clients, url: "http://x/auth-admin/clients" }); const all = buildClientsListModel({ clients, url: "http://x/admin/clients" });
assert.equal(all.pagination.summary.total, 30); assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.title, "OAuth2 clients"); assert.equal(all.title, "OAuth2 clients");
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } }; const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "app-00"); assert.equal(first.rowHeader.text, "app-00");
assert.equal(first.rowHeader.href, "/auth-admin/clients/id-00"); assert.equal(first.rowHeader.href, "/admin/clients/id-00");
const one = buildClientsListModel({ clients, url: "http://x/auth-admin/clients?q=app-07" }); const one = buildClientsListModel({ clients, url: "http://x/admin/clients?q=app-07" });
assert.equal(one.pagination.summary.total, 1); assert.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]); assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
}); });
@@ -75,7 +75,7 @@ test("buildClientsListModel filters by search, paginates; the name links to the
test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => { test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => {
const m = buildClientFormModel({ csrfToken: "tok.sig" }); const m = buildClientFormModel({ csrfToken: "tok.sig" });
assert.equal(m.title, "Register client"); assert.equal(m.title, "Register client");
assert.equal(m.form.action, "/auth-admin/clients"); assert.equal(m.form.action, "/admin/clients");
assert.equal(m.form.submitLabel, "Register client"); assert.equal(m.form.submitLabel, "Register client");
assert.equal(m.form.csrfToken, "tok.sig"); assert.equal(m.form.csrfToken, "tok.sig");
assert.equal(m.form.nameField.required, true); assert.equal(m.form.nameField.required, true);
@@ -94,7 +94,7 @@ test("buildClientDetailModel: client info + delete action; the one-time secret +
const plain = buildClientDetailModel({ client }); const plain = buildClientDetailModel({ client });
assert.equal(plain.title, "Acme"); assert.equal(plain.title, "Acme");
assert.equal(plain.delete.action, "/auth-admin/clients/c1/delete"); assert.equal(plain.delete.action, "/admin/clients/c1/delete");
assert.equal(plain.created, false); assert.equal(plain.created, false);
assert.equal(plain.secret, undefined); assert.equal(plain.secret, undefined);
@@ -5,8 +5,9 @@
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin // 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. // per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
import { buildPaginationModel, can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api"; import { 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, type FieldConfig, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100]; const PAGE_SIZES = [25, 50, 100];
@@ -121,7 +122,7 @@ export function buildClientsListModel(opts: {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }], breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }],
canWrite: opts.canWrite !== false, canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, t), filterBar: listFilterBar(state, t),
pagination: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.clients.pagination"), pageSizes: PAGE_SIZES }), pagination: listPagination(state, page, t),
table: listTable(rows, t), table: listTable(rows, t),
title: t("admin.clients.title"), title: t("admin.clients.title"),
}; };
@@ -157,6 +158,22 @@ function listFilterBar(state: ListState, t: Translate) {
}; };
} }
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
return {
label: t("admin.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 },
summary: { from: page.from, to: page.to, total: page.total },
};
}
// ---- register form + detail view models ---- // ---- register form + detail view models ----
export function buildClientFormModel(opts: { export function buildClientFormModel(opts: {
@@ -256,13 +273,13 @@ const canWriteClients = (ctx: RequestContext): boolean => can(ctx, permissionNam
const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult => 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({ canWrite: canWriteClients(ctx), client: toClientView(client), csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-detail" });
// GET /auth-admin/clients — the list. // GET /admin/clients — the list.
export const clientsList = withClients(async ({ ctx, hydra }) => { export const clientsList = withClients(async ({ ctx, hydra }) => {
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE }); 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({ canWrite: canWriteClients(ctx), clients, csrfToken: ctx.chrome.csrfToken, t: ctx.t, url: ctx.url }) }, view: "clients" };
}); });
// POST /auth-admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never // POST /admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
// returns it again). A Hydra 4xx (bad redirect/scope) re-renders the form (400); a 5xx rethrows → 500. // 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 }) => { export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
const input = readClientInput((await guardedForm(ctx))!); const input = readClientInput((await guardedForm(ctx))!);
@@ -279,13 +296,13 @@ export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
return clientDetailResult(ctx, created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) }); return clientDetailResult(ctx, created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
}); });
// GET /auth-admin/clients/new — the register form. // 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, {})), "write");
// GET /auth-admin/clients/:id — the detail (read-only; the secret is shown only once, at creation). // GET /admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client))); export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client)));
// GET /auth-admin/clients/:id/delete — the deliberate confirm step. // GET /admin/clients/:id/delete — the deliberate confirm step.
export const clientsDeleteConfirm = withClient((deps, client, id) => { export const clientsDeleteConfirm = withClient((deps, client, id) => {
const base = detailHref(id); const base = detailHref(id);
const name = toClientView(client).name; const name = toClientView(client).name;
@@ -297,7 +314,7 @@ export const clientsDeleteConfirm = withClient((deps, client, id) => {
}) }, view: "confirm" }); }) }, view: "confirm" });
}, "write"); }, "write");
// POST /auth-admin/clients/:id/delete — perform it. // POST /admin/clients/:id/delete — perform it.
export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => { export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => {
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
await hydra.deleteClient(id); await hydra.deleteClient(id);
@@ -4,7 +4,7 @@ import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import type { PermissionDecl } from "@plainpages/plugin-api"; import type { PermissionDecl } from "@plainpages/plugin-api";
import type { KetoClient } from "@plainpages/plugin-api"; import type { KetoClient } from "@plainpages/plugin-api";
import { applyGrants, baselineToken, buildPermissionPicker, grantDiff, grantTuple, groupSubject, isStale, removeUndeclaredGrant, userSubject } from "./admin-grants.ts"; import { applyGrants, baselineToken, buildPermissionPicker, grantDiff, grantTuple, groupSubject, isStale, userSubject } from "./admin-grants.ts";
const declared: PermissionDecl[] = [ const declared: PermissionDecl[] = [
{ description: "View users", name: "users:read" }, { description: "View users", name: "users:read" },
@@ -36,8 +36,8 @@ test("grantDiff ignores anything the plugins don't declare, in both directions",
}); });
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => { test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
const picker = buildPermissionPicker({ action: "/auth-admin/users/u1/permissions", declared, direct: ["users:write"] }); const picker = buildPermissionPicker({ action: "/admin/users/u1/permissions", declared, direct: ["users:write"] });
assert.equal(picker.action, "/auth-admin/users/u1/permissions"); 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.name), ["users:read", "users:write", "groups:read"]);
assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]); assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]);
assert.equal(picker.choices[0]?.description, "View users"); assert.equal(picker.choices[0]?.description, "View users");
@@ -106,53 +106,24 @@ test("isStale refuses a missing or old token, but only for a save that would cha
assert.equal(isStale(old, declared, held, nothing), false); // a repeated save has nothing to refuse assert.equal(isStale(old, declared, held, nothing), false); // a repeated save has nothing to refuse
}); });
test("applyGrants sends the whole change as one Keto transaction, and a failure applies none of it", async () => { test("applyGrants applies writes then deletes, and on a Keto failure stops and reports what was applied", async () => {
const calls: { delete: string[]; insert: string[] }[] = []; const log: string[] = [];
const keto = (fail = false): KetoClient => ({ const keto = (failOn?: string): KetoClient => ({
check: async () => false, check: async () => false,
deleteTuple: async () => { throw new Error("unused"); }, deleteTuple: async (tp) => { if (tp.object === failOn) throw new Error("boom"); log.push(`delete ${tp.object}`); },
expand: async () => ({ type: "leaf" }), expand: async () => ({ type: "leaf" }),
listRelations: async () => ({ nextPageToken: null, tuples: [] }), listRelations: async () => ({ nextPageToken: null, tuples: [] }),
transactTuples: async (c) => { writeTuple: async (tp) => { if (tp.object === failOn) throw new Error("boom"); log.push(`write ${tp.object}`); },
if (fail) throw new Error("boom");
calls.push({ delete: c.delete.map((tp) => tp.object), insert: c.insert.map((tp) => tp.object) });
},
writeTuple: async () => { throw new Error("unused"); },
}); });
const diff = { grant: ["a:read", "b:read"], revoke: ["c:read"] }; const diff = { grant: ["a:read", "b:read"], revoke: ["c:read"] };
await applyGrants(keto(), userSubject("u1"), diff); const ok = await applyGrants(keto(), userSubject("u1"), diff);
assert.deepEqual(calls, [{ delete: ["c:read"], insert: ["a:read", "b:read"] }]); assert.deepEqual(ok, { applied: diff });
await assert.rejects(applyGrants(keto(true), userSubject("u1"), diff), /boom/); assert.deepEqual(log, ["write a:read", "write b:read", "delete c:read"]);
});
test("buildPermissionPicker lists direct grants no plugin declares, with a remove action only for a writer", () => { log.length = 0;
const direct = ["zed:read", "users:read", "old:write"]; const failed = await applyGrants(keto("b:read"), userSubject("u1"), diff);
const picker = buildPermissionPicker({ action: "/auth-admin/users/u1/permissions", declared, direct }); assert.deepEqual(failed.applied, { grant: ["a:read"], revoke: [] });
assert.deepEqual(picker.undeclared?.names, ["old:write", "zed:read"]); assert.ok(failed.error instanceof Error);
assert.equal(picker.undeclared?.action, "/auth-admin/users/u1/permissions/remove"); assert.deepEqual(log, ["write a:read"]); // nothing after the failure was attempted
assert.ok(picker.undeclared?.remove);
assert.equal(buildPermissionPicker({ action: "/x", declared, direct, readOnly: true }).undeclared?.remove, undefined);
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: ["users:read"] }).undeclared, undefined);
assert.equal(picker.baseline, buildPermissionPicker({ action: "/x", declared, direct: ["users:read"] }).baseline, "an undeclared grant doesn't make a form stale");
});
test("removeUndeclaredGrant deletes one held, undeclared grant and refuses anything else", async () => {
const subject = userSubject("u1");
const tuples = [grantTuple("old:write", subject), grantTuple("users:read", subject), grantTuple("old:write", userSubject("u2"))];
const deleted: string[] = [];
const keto: KetoClient = {
check: async () => false,
deleteTuple: async (tp) => { deleted.push(`${tp.object} ${tp.subject_id}`); },
expand: async () => ({ type: "leaf" }),
listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((tp) => tp.subject_id === q.subject_id) }),
transactTuples: async () => { throw new Error("unused"); },
writeTuple: async () => { throw new Error("unused"); },
};
assert.equal(await removeUndeclaredGrant(keto, subject, declared, "old:write"), "old:write");
assert.deepEqual(deleted, ["old:write user:u1"]);
assert.equal(await removeUndeclaredGrant(keto, subject, declared, "users:read"), undefined, "a declared name is the picker's to change");
assert.equal(await removeUndeclaredGrant(keto, subject, declared, "never:held"), undefined);
assert.equal(await removeUndeclaredGrant(keto, subject, declared, null), undefined);
assert.deepEqual(deleted, ["old:write user:u1"]);
}); });
@@ -3,27 +3,20 @@
// resolves a group's grant transitively at login. // resolves a group's grant transitively at login.
// //
// The set of permissions that *exist* is `ctx.declaredPermissions` — the host's catalog, built from // The set of permissions that *exist* is `ctx.declaredPermissions` — the host's catalog, built from
// what the installed plugins declare in code. Nothing here invents a name: a grant is a property of a // what the installed plugins declare in code. Nothing here invents a name, which is why the old
// user or a group, edited where they are. // Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api"; import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
import { pagedTuples } from "./admin-shared.ts";
const PERMISSION_NS = "Permission"; const PERMISSION_NS = "Permission";
const GRANTED = "granted"; const GRANTED = "granted";
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
export const REMOVE_FIELD = "remove"; // the hidden field naming the undeclared grant a Remove form drops
export const BASELINE_FIELD = "baseline"; // the hidden field carrying baselineToken export const BASELINE_FIELD = "baseline"; // the hidden field carrying baselineToken
export const SAVED_NOTICE = "permissions-saved"; // the `info-msg` a successful save redirects with export const SAVED_NOTICE = "permissions-saved"; // the `info-msg` a successful save redirects with
export const REMOVED_NOTICE = "permission-removed"; // the `info-msg` a Remove redirects with; its own words, since the checkboxes were not saved
// Only these exact values count, so a crafted link can't put words on the page. // Only that exact value counts, so a crafted link can't put words on the page.
export function grantsNotice(url: URL, t: Translate): string | undefined { export const isSavedNotice = (url: URL): boolean => url.searchParams.get("info-msg") === SAVED_NOTICE;
const msg = url.searchParams.get("info-msg");
if (msg === SAVED_NOTICE) return t("admin.grants.saved");
return msg === REMOVED_NOTICE ? t("admin.grants.removed") : undefined;
}
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet }; export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
@@ -37,8 +30,14 @@ export function grantTuple(permission: string, subject: GrantSubject): RelationT
// The permissions this subject holds *directly* — one Keto read filtered by the subject, not one per // 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. // 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[]> { export async function heldPermissions(keto: KetoClient, subject: GrantSubject): Promise<string[]> {
const tuples = await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED, ...subject }); const held = new Set<string>();
return [...new Set(tuples.map((tuple) => tuple.object))].sort(); 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 // Every declared permission the subject effectively holds — direct grants *plus* anything reached
@@ -74,18 +73,6 @@ export interface PermissionPicker {
pending: string | undefined; pending: string | undefined;
readOnly: boolean; // the viewer holds :read but not :write — show the state, offer no save readOnly: boolean; // the viewer holds :read but not :write — show the state, offer no save
submit: string; submit: string;
undeclared: UndeclaredGrants | undefined; // set when the subject holds a name no installed plugin declares
}
// Direct grants of names nobody declares (their plugin was uninstalled, or the name was written to
// Keto by hand). grantDiff never touches them, so they get their own list and a Remove per name.
export interface UndeclaredGrants {
action: string; // where each Remove posts
field: string;
hint: string;
legend: string;
names: string[];
remove: string | undefined; // the button label; unset for a reader, who is shown the names only
} }
// The checkbox list: every declared permission, ticked where this subject holds it. A fixed list // The checkbox list: every declared permission, ticked where this subject holds it. A fixed list
@@ -102,7 +89,6 @@ export function buildPermissionPicker(opts: {
}): PermissionPicker { }): PermissionPicker {
const t = opts.t ?? ((k: string) => k); const t = opts.t ?? ((k: string) => k);
const directSet = new Set(opts.direct); const directSet = new Set(opts.direct);
const undeclared = undeclaredHeld(opts.declared, opts.direct);
const effectiveSet = new Set(opts.effective ?? opts.direct); const effectiveSet = new Set(opts.effective ?? opts.direct);
const choices = opts.declared.map((decl) => ({ const choices = opts.declared.map((decl) => ({
checked: directSet.has(decl.name) || effectiveSet.has(decl.name), checked: directSet.has(decl.name) || effectiveSet.has(decl.name),
@@ -124,22 +110,9 @@ export function buildPermissionPicker(opts: {
pending: opts.transitive === true ? t("admin.grants.pending") : undefined, pending: opts.transitive === true ? t("admin.grants.pending") : undefined,
readOnly: opts.readOnly === true, readOnly: opts.readOnly === true,
submit: t("admin.grants.save"), submit: t("admin.grants.save"),
undeclared: undeclared.length === 0 ? undefined : {
action: `${opts.action}/remove`,
field: REMOVE_FIELD,
hint: t("admin.grants.undeclaredHint"),
legend: t("admin.grants.undeclaredLegend"),
names: undeclared,
remove: opts.readOnly === true ? undefined : t("admin.grants.remove"),
},
}; };
} }
const undeclaredHeld = (declared: readonly PermissionDecl[], held: readonly string[]): string[] => {
const offered = new Set(declared.map((d) => d.name));
return held.filter((name) => !offered.has(name)).sort();
};
// What a submitted set changes. Pure so the diff is testable without Keto: only declared names are // 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 // 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. // name (left over from an uninstalled plugin) is never silently revoked by an unrelated save.
@@ -153,16 +126,6 @@ export function grantDiff(declared: readonly PermissionDecl[], held: string[], w
}; };
} }
// Deletes one held grant of a name no plugin declares and returns the name. Deletes nothing for a
// declared name, which the picker owns (so a Remove can't take away this plugin's own gates and needs
// no self-lockout guard), or for one not held, as after a double click.
export async function removeUndeclaredGrant(keto: KetoClient, subject: GrantSubject, declared: readonly PermissionDecl[], name: string | null): Promise<string | undefined> {
if (name === null || declared.some((d) => d.name === name)) return undefined;
if (!(await heldPermissions(keto, subject)).includes(name)) return undefined;
await keto.deleteTuple(grantTuple(name, subject));
return name;
}
// A token for the direct grants a form was rendered from. Like grantDiff it covers only the declared // A token for the direct grants a form was rendered from. Like grantDiff it covers only the declared
// names the form offered. It is a digest so an empty set is still a token; it is a guard against a // names the form offered. It is a digest so an empty set is still a token; it is a guard against a
// stale page, not a secret, since anyone who can read the page can compute it. // stale page, not a secret, since anyone who can read the page can compute it.
@@ -179,5 +142,16 @@ export const isStale = (form: { get(name: string): string | null }, declared: re
export interface GrantChange { grant: string[]; revoke: string[] } export interface GrantChange { grant: string[]; revoke: string[] }
export const applyGrants = (keto: KetoClient, subject: GrantSubject, diff: GrantChange): Promise<void> => // KetoClient writes one tuple per call, so the writes and deletes run one by one and stop at the first
keto.transactTuples({ delete: diff.revoke.map((name) => grantTuple(name, subject)), insert: diff.grant.map((name) => grantTuple(name, subject)) }); // failure. What was applied by then is returned beside the error, so the caller can still revoke live
// tokens and tell the admin the save is only partly done.
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: GrantChange): Promise<{ applied: GrantChange; error?: unknown }> {
const applied: GrantChange = { grant: [], revoke: [] };
try {
for (const name of diff.grant) { await keto.writeTuple(grantTuple(name, subject)); applied.grant.push(name); }
for (const name of diff.revoke) { await keto.deleteTuple(grantTuple(name, subject)); applied.revoke.push(name); }
} catch (error) {
return { applied, error };
}
return { applied };
}
@@ -56,22 +56,22 @@ test("memberView resolves a user subject to its email (else the raw id) and a su
test("buildGroupsListModel filters by search, sorts, paginates; the name links to the detail page", () => { test("buildGroupsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
const groups = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `team-${String(i).padStart(2, "0")}` })); const groups = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `team-${String(i).padStart(2, "0")}` }));
const all = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups" }); const all = buildGroupsListModel({ groups, url: "http://x/admin/groups" });
assert.equal(all.pagination.summary.total, 30); assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.title, "Groups"); assert.equal(all.title, "Groups");
// The group name is the row header, linking to its detail page. // The group name is the row header, linking to its detail page.
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } }; const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "team-00"); assert.equal(first.rowHeader.text, "team-00");
assert.equal(first.rowHeader.href, "/auth-admin/groups/team-00"); assert.equal(first.rowHeader.href, "/admin/groups/team-00");
// Search narrows + shows a pill. // Search narrows + shows a pill.
const one = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups?q=team-07" }); const one = buildGroupsListModel({ groups, url: "http://x/admin/groups?q=team-07" });
assert.equal(one.pagination.summary.total, 1); assert.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]); assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
// Sort by members descending puts the biggest group first. // Sort by members descending puts the biggest group first.
const desc = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups?sort=-members" }); const desc = buildGroupsListModel({ groups, url: "http://x/admin/groups?sort=-members" });
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "team-29"); assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "team-29");
}); });
@@ -79,7 +79,7 @@ test("buildGroupFormModel: a create form with a required name field + member opt
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }]; const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options }); const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New group"); assert.equal(m.title, "New group");
assert.equal(m.form.action, "/auth-admin/groups"); assert.equal(m.form.action, "/admin/groups");
assert.equal(m.form.submitLabel, "Create group"); assert.equal(m.form.submitLabel, "Create group");
assert.equal(m.form.csrfToken, "tok.sig"); assert.equal(m.form.csrfToken, "tok.sig");
assert.equal(m.form.nameField.name, "name"); assert.equal(m.form.nameField.name, "name");
@@ -105,8 +105,8 @@ test("buildGroupDetailModel: members → rows, add-options exclude current membe
const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members }); const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members });
assert.equal(m.title, "eng"); assert.equal(m.title, "eng");
assert.equal(m.members.rows.length, 2); assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/auth-admin/groups/eng/members/delete"); assert.equal(m.members.action, "/admin/groups/eng/members/delete");
assert.equal(m.add.action, "/auth-admin/groups/eng/members"); assert.equal(m.add.action, "/admin/groups/eng/members");
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]); assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
assert.equal(m.delete.action, "/auth-admin/groups/eng/delete"); assert.equal(m.delete.action, "/admin/groups/eng/delete");
}); });
@@ -2,20 +2,20 @@
// A group is a Keto subject set `Group:<name>#members`; a member is a user or a nested group (see // A group is a Keto subject set `Group:<name>#members`; a member is a user or a nested group (see
// parseSubject). Writes go only to Keto (README "stateless"). Keto has no "create object" — a group // parseSubject). Writes go only to Keto (README "stateless"). Keto has no "create object" — a group
// exists exactly while it has ≥1 member, so create writes its first-member tuple and delete removes // exists exactly while it has ≥1 member, so create writes its first-member tuple and delete removes
// every tuple that makes it a group or puts it in another. Pure builders turn tuples + the request URL into view models; below them are thin // every member tuple. Pure builders turn tuples + the request URL into view models; below them are thin
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded, // per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
// each returning a RouteResult. // each returning a RouteResult.
import { buildPaginationModel, can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api"; 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, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, SAVED_NOTICE, userSubject } from "./admin-grants.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, type FieldConfig, guardedForm, notFound, pagedTuples, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
const GROUP_NS = "Group"; const GROUP_NS = "Group";
const MEMBERS = "members"; const MEMBERS = "members";
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100]; const PAGE_SIZES = [25, 50, 100];
// One Kratos `listIdentities` page of candidate users is fetched for the member pickers (mirrors // One Keto page of candidate users is fetched for the member pickers (mirrors admin-users).
// admin-users).
const LIST_FETCH_SIZE = 250; const LIST_FETCH_SIZE = 250;
const GROUP_NAME = /^[a-z0-9][a-z0-9_-]*$/; // URL-safe; doubles as the path segment const GROUP_NAME = /^[a-z0-9][a-z0-9_-]*$/; // URL-safe; doubles as the path segment
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // a Kratos identity id const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // a Kratos identity id
@@ -143,7 +143,7 @@ export function buildGroupsListModel(opts: {
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }], breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }],
canWrite: opts.canWrite !== false, canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, t), filterBar: listFilterBar(state, t),
pagination: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.groups.pagination"), pageSizes: PAGE_SIZES }), pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t), table: listTable(rows, state, sort, t),
title: t("admin.groups.title"), title: t("admin.groups.title"),
}; };
@@ -179,6 +179,23 @@ function listFilterBar(state: ListState, t: Translate) {
}; };
} }
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
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"),
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 },
summary: { from: page.from, to: page.to, total: page.total },
};
}
// ---- create form + detail view models ---- // ---- create form + detail view models ----
export function buildGroupFormModel(opts: { export function buildGroupFormModel(opts: {
@@ -242,6 +259,18 @@ export function buildGroupDetailModel(opts: {
// ---- request handler (imperative shell) ---- // ---- request handler (imperative shell) ----
// Drain every page of a relation-tuple query.
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
const out: RelationTuple[] = [];
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ ...query, ...(pageToken ? { pageToken } : {}) });
out.push(...page.tuples);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return out;
}
// Build the member-picker options (every user by email + every existing group) and the id→email map // Build the member-picker options (every user by email + every existing group) and the id→email map
// detail rows render with. One Kratos page + one Keto scan; ample for an admin tool. // detail rows render with. One Kratos page + one Keto scan; ample for an admin tool.
export async function memberCandidates(keto: KetoClient, kratosAdmin: KratosAdmin): Promise<{ emailById: Map<string, string>; options: MemberOption[] }> { export async function memberCandidates(keto: KetoClient, kratosAdmin: KratosAdmin): Promise<{ emailById: Map<string, string>; options: MemberOption[] }> {
@@ -319,13 +348,13 @@ const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values
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, t: deps.ctx.t, ...extra }) }, view: "group-form" };
}; };
// GET /auth-admin/groups — the list. // GET /admin/groups — the list.
export const groupsList = withGroups(async ({ ctx, keto }) => { export const groupsList = withGroups(async ({ ctx, keto }) => {
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS })); 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({ canWrite: can(ctx, permissionName("groups", "write")), csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" };
}); });
// POST /auth-admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple). // POST /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
export const groupsCreate = withGroups(async (deps) => { export const groupsCreate = withGroups(async (deps) => {
const { ctx, keto, user } = deps; const { ctx, keto, user } = deps;
const form = (await guardedForm(ctx))!; const form = (await guardedForm(ctx))!;
@@ -335,13 +364,13 @@ export const groupsCreate = withGroups(async (deps) => {
const reject = async (error: string): Promise<RouteResult> => ({ ...(await groupFormResult(deps, { error, values: { member, name } })), status: 400 }); 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 (!isValidGroupName(name)) return reject(ctx.t("admin.groups.validation.name"));
if (!tuple) return reject(ctx.t("admin.groups.validation.member")); if (!tuple) return reject(ctx.t("admin.groups.validation.member"));
if (await groupExists(keto, name)) return reject(ctx.t("admin.groups.error.duplicate")); if (await groupExists(keto, name)) return reject("A group with that name already exists.");
await keto.writeTuple(tuple); await keto.writeTuple(tuple);
ctx.log.info("admin: group created", { actor: user.id, group: name }); ctx.log.info("admin: group created", { actor: user.id, group: name });
return { redirect: detailHref(name) }; return { redirect: detailHref(name) };
}); });
// GET /auth-admin/groups/new — the create form. // GET /admin/groups/new — the create form.
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write"); export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
// The detail + membership page. `notice` and `error` ride the permission picker; `pageError` tops the // The detail + membership page. `notice` and `error` ride the permission picker; `pageError` tops the
@@ -367,11 +396,11 @@ async function groupDetailPage({ ctx, keto, kratosAdmin }: GroupsDeps, name: str
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t, ...(say.pageError ? { error: say.pageError } : {}) }) }, view: "group-detail" }; return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t, ...(say.pageError ? { error: say.pageError } : {}) }) }, view: "group-detail" };
} }
// GET /auth-admin/groups/:name — the detail + membership page. // GET /admin/groups/:name — the detail + membership page.
export const groupsDetail = withGroupName((deps, name) => export const groupsDetail = withGroupName((deps, name) =>
groupDetailPage(deps, name, { notice: grantsNotice(deps.ctx.url, deps.ctx.t) ?? "" })); groupDetailPage(deps, name, isSavedNotice(deps.ctx.url) ? { notice: deps.ctx.t("admin.grants.saved") } : {}));
// POST /auth-admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold // 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 — // 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. // the documented instant-revoke tradeoff for anything held through a group.
export const groupsPermissions = withGroupName(async (deps, name) => { export const groupsPermissions = withGroupName(async (deps, name) => {
@@ -383,22 +412,18 @@ export const groupsPermissions = withGroupName(async (deps, name) => {
if (isStale(form, ctx.declaredPermissions, held, diff)) return { ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.stale") })), status: 409 }; if (isStale(form, ctx.declaredPermissions, held, diff)) return { ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.stale") })), status: 409 };
const lost = diff.revoke.length > 0 && await isMember(keto, user.id, name) ? await lostToSelf(keto, user.id, diff.revoke) : []; const lost = diff.revoke.length > 0 && await isMember(keto, user.id, name) ? await lostToSelf(keto, user.id, diff.revoke) : [];
if (lost.length > 0) return refuseSelfLockout(deps, name, "save", lost); if (lost.length > 0) return refuseSelfLockout(deps, name, "save", lost);
await applyGrants(keto, subject, diff); const { applied, error } = await applyGrants(keto, subject, diff);
if (diff.grant.length > 0 || diff.revoke.length > 0) { if (applied.grant.length > 0 || applied.revoke.length > 0) {
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: diff.grant.join(","), group: name, revoked: diff.revoke.join(",") }); ctx.log.info("admin: group permissions changed", { actor: user.id, granted: applied.grant.join(","), group: name, revoked: applied.revoke.join(",") });
}
if (error) {
ctx.log.error("admin: group permissions only partly saved", { actor: user.id, error: String(error), group: name });
return partialSave(ctx, async () => ({ ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.partial") })), status: 502 }));
} }
return { redirect: `${detailHref(name)}?info-msg=${SAVED_NOTICE}` }; return { redirect: `${detailHref(name)}?info-msg=${SAVED_NOTICE}` };
}); });
// POST /auth-admin/groups/:name/permissions/remove — drop one grant no installed plugin declares. // POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
export const groupsPermissionsRemove = withGroupName(async ({ ctx, keto, user }, name) => {
const form = (await guardedForm(ctx))!;
const removed = await removeUndeclaredGrant(keto, groupSubject(name), ctx.declaredPermissions, form.get(REMOVE_FIELD));
if (removed) ctx.log.info("admin: undeclared group permission removed", { actor: user.id, group: name, removed });
return { redirect: `${detailHref(name)}?info-msg=${REMOVED_NOTICE}` };
});
// POST /auth-admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => { export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
const form = (await guardedForm(ctx))!; const form = (await guardedForm(ctx))!;
const tuple = memberTuple(name, (form.get("member") ?? "").trim()); const tuple = memberTuple(name, (form.get("member") ?? "").trim());
@@ -406,7 +431,7 @@ export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
return { redirect: detailHref(name) }; return { redirect: detailHref(name) };
}); });
// GET /auth-admin/groups/:name/delete — the deliberate confirm step. // GET /admin/groups/:name/delete — the deliberate confirm step.
export const groupsDeleteConfirm = withGroupName(async (deps, name) => { export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
const lost = await lostOnDelete(deps, name); const lost = await lostOnDelete(deps, name);
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost); if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
@@ -419,30 +444,24 @@ export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
}) }, view: "confirm" }; }) }, view: "confirm" };
}, "write"); }, "write");
// POST /auth-admin/groups/:name/delete — one Keto transaction removes the group's member tuples, // POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
// the tuples naming it as a member of a parent group, and the grants it holds, so a Keto failure
// leaves the group as it was.
export const groupsDelete = withGroupName(async (deps, name) => { export const groupsDelete = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps; const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
const lost = await lostOnDelete(deps, name); const lost = await lostOnDelete(deps, name);
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost); if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
// A Keto set exists only through its tuples, so leaving the grants or the parents' membership // Drop what the group *holds* before what it *contains*: a Keto set exists only through its
// tuples behind would resurrect every permission and parent membership the moment someone // tuples, so leaving the grants behind would resurrect every permission the moment someone
// re-created a group with the same name. A parent that had only this group as a member is left // re-created a group with the same name.
// with none and so ceases to exist, as when its last member is removed by hand; its own grants
// and parent memberships stay (todo.md).
const subject = groupSubject(name); const subject = groupSubject(name);
const held = await heldPermissions(keto, subject); const held = await heldPermissions(keto, subject);
const grants = held.map((permission) => grantTuple(permission, subject)); for (const permission of held) await keto.deleteTuple(grantTuple(permission, subject));
const members = await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS }); await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS });
const inParents = await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS, ...subject });
await keto.transactTuples({ delete: [...grants, ...members, ...inParents], insert: [] });
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, revoked: held.join(",") });
return { redirect: ADMIN_GROUPS_BASE }; return { redirect: ADMIN_GROUPS_BASE };
}); });
// POST /auth-admin/groups/:name/members/delete — remove one member. // POST /admin/groups/:name/members/delete — remove one member.
export const groupsRemoveMember = withGroupName(async (deps, name) => { export const groupsRemoveMember = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps; const { ctx, keto, user } = deps;
const form = (await guardedForm(ctx))!; const form = (await guardedForm(ctx))!;
@@ -68,7 +68,7 @@ function valueLabel(setting: SettingSummary, t: Translate): string {
return setting.value ?? t("admin.pluginSettings.unset"); return setting.value ?? t("admin.pluginSettings.unset");
} }
// GET /auth-admin/plugin-settings // GET /admin/plugin-settings
export const pluginSettingsList: RouteHandler = (ctx) => { export const pluginSettingsList: RouteHandler = (ctx) => {
requirePermission(ctx, "plugin-settings"); requirePermission(ctx, "plugin-settings");
return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" }; return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" };
@@ -6,8 +6,8 @@ import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { test } from "node:test"; import { test } from "node:test";
import { GuardError, isValidPermissionName, type KetoClient, type Log, type PageChrome, type RequestContext, type User } from "@plainpages/plugin-api"; 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, pagedTuples, permissionName, requirePermission } from "./admin-shared.ts"; import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts";
const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] }; const 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 writer: User = { email: "cy@x.io", id: "u3", permissions: ["users:read", "users:write"] };
@@ -15,11 +15,11 @@ const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:rea
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome; const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext { function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
const url = new URL("http://localhost/auth-admin/users"); const url = new URL("http://localhost/admin/users");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET"; req.method = opts.method ?? "GET";
return { return {
assetHref: (href) => href, chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {}, 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, query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
verifyCsrf: opts.verifyCsrf ?? (() => true), verifyCsrf: opts.verifyCsrf ?? (() => true),
}; };
@@ -36,7 +36,7 @@ test("ADMIN_NAV: an ungated Admin header whose screens each gate on their own re
assert.equal(ADMIN_NAV.permission, undefined); assert.equal(ADMIN_NAV.permission, undefined);
assert.equal(ADMIN_NAV.href, undefined); assert.equal(ADMIN_NAV.href, undefined);
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/auth-admin/users", "/auth-admin/groups", "/auth-admin/clients", "/auth-admin/plugin-settings"]); 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"]); 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 // 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. // the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
@@ -64,7 +64,7 @@ test("actionForMethod: read for GET/HEAD, write for every mutation", () => {
// ---- auth gates ---- // ---- auth gates ----
test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => { test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fauth-admin%2Fusers"); // bounce remembers the page assert.throws(() => requirePermission(fakeCtx({ user: 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.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader); 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. // The whole point of the split: users:read opens the list but not the create/delete POSTs.
@@ -100,23 +100,3 @@ test("buildConfirmModel wires the danger action, message, breadcrumbs and title
assert.equal(model.title, "Delete user"); assert.equal(model.title, "Delete user");
assert.deepEqual(model.breadcrumbs.at(-1), { label: "Delete" }); assert.deepEqual(model.breadcrumbs.at(-1), { label: "Delete" });
}); });
test("pagedTuples follows the page token to the last page and keeps the query on every page", async () => {
const queries: unknown[] = [];
const tuple = (object: string) => ({ namespace: "Group", object, relation: "members", subject_id: "user:a" });
const keto = {
listRelations: async (q: { pageToken?: string } = {}) => {
queries.push(q);
if (q.pageToken === "p2") return { nextPageToken: "p3", tuples: [tuple("b")] };
if (q.pageToken === "p3") return { nextPageToken: null, tuples: [tuple("c")] };
return { nextPageToken: "p2", tuples: [tuple("a")] };
},
} as unknown as KetoClient;
const all = await pagedTuples(keto, { namespace: "Group", relation: "members" });
assert.deepEqual(all.map((t) => t.object), ["a", "b", "c"]);
assert.deepEqual(queries, [
{ namespace: "Group", relation: "members" },
{ namespace: "Group", pageToken: "p2", relation: "members" },
{ namespace: "Group", pageToken: "p3", relation: "members" },
]);
});
@@ -1,17 +1,18 @@
// Shared plumbing for the admin example plugin's screens. // 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. // Everything imports the host only through the @plainpages/plugin-api barrel.
import { can, CSRF_FIELD, englishTranslator, GuardError, type KetoClient, type NavNode, readFormBody, type RelationQuery, type RelationTuple, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "@plainpages/plugin-api"; 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"; 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, // 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. // i.e. its unit tests. At runtime the handlers pass ctx.t instead.
export const ADMIN_EN: Translate = englishTranslator(enUS); export const ADMIN_EN: Translate = englishTranslator(enUS);
export const ADMIN_USERS_BASE = "/auth-admin/users"; export const ADMIN_USERS_BASE = "/admin/users";
export const ADMIN_GROUPS_BASE = "/auth-admin/groups"; export const ADMIN_GROUPS_BASE = "/admin/groups";
export const ADMIN_CLIENTS_BASE = "/auth-admin/clients"; export const ADMIN_CLIENTS_BASE = "/admin/clients";
export const ADMIN_PLUGIN_SETTINGS_BASE = "/auth-admin/plugin-settings"; export const ADMIN_PLUGIN_SETTINGS_BASE = "/admin/plugin-settings";
// One resource per screen — the `<resource>` half of every permission this plugin gates on. // 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. // `oauth2-clients` rather than `clients` because permission names are one global namespace.
@@ -28,9 +29,7 @@ export function permissionName(resource: AdminResource, action: AdminAction): st
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both // 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 // 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 // local: generalised, it would make authorization a function of the transport verb (AGENTS.md).
// (docs/decisions.md → "`actionForMethod` is plugin-local and stays out of
// `@plainpages/plugin-api`").
export function actionForMethod(method: string): AdminAction { export function actionForMethod(method: string): AdminAction {
const verb = method.toUpperCase(); const verb = method.toUpperCase();
return verb === "GET" || verb === "HEAD" ? "read" : "write"; return verb === "GET" || verb === "HEAD" ? "read" : "write";
@@ -84,6 +83,16 @@ 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: ctx.t("admin.unavailable.message", { what }), title: ctx.t("admin.unavailable.title") }, status: 503, view: "notice" };
} }
// The page a partial permission save reports on reads Keto again. If that read fails too, the report
// stands alone on the notice page, so the admin still hears that part of the save landed.
export async function partialSave(ctx: RequestContext, render: () => Promise<RouteResult>): Promise<RouteResult> {
try {
return await render();
} catch {
return { data: { chrome: ctx.chrome, message: ctx.t("admin.grants.partial"), title: ctx.t("admin.unavailable.title") }, status: 502, view: "notice" };
}
}
// Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields // Model for the shared destructive-confirm page (views/confirm.ejs). The view reads the shell fields
// (brand/csrf/theme/user/nav) from ctx.chrome; this carries only the page body + title/breadcrumbs. // (brand/csrf/theme/user/nav) from ctx.chrome; this carries only the page body + title/breadcrumbs.
export function buildConfirmModel(opts: { export function buildConfirmModel(opts: {
@@ -102,30 +111,3 @@ export function buildConfirmModel(opts: {
title: opts.title, title: opts.title,
}; };
} }
// The config one text input takes in the core `field` partial.
export interface FieldConfig {
autocomplete?: string;
hint?: string;
icon?: string;
id: string;
label: string;
name: string;
optional?: boolean;
readonly?: boolean;
required?: boolean;
type?: string;
value?: string;
}
// Drain every page of a relation-tuple query.
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
const out: RelationTuple[] = [];
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ ...query, ...(pageToken ? { pageToken } : {}) });
out.push(...page.tuples);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return out;
}
@@ -38,42 +38,42 @@ test("toUserView maps traits → name/initials/email/state; falls back to the em
test("buildUsersListModel filters by search + status, sorts, and paginates", () => { test("buildUsersListModel filters by search + status, sorts, and paginates", () => {
const people = Array.from({ length: 30 }, (_, i) => identity(i + 1, { state: i % 2 ? "inactive" : "active" })); const people = Array.from({ length: 30 }, (_, i) => identity(i + 1, { state: i % 2 ? "inactive" : "active" }));
const all = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users" }); const all = buildUsersListModel({ identities: people, url: "http://x/admin/users" });
assert.equal(all.pagination.summary.total, 30); assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.title, "Users"); assert.equal(all.title, "Users");
// Search narrows to one and shows a pill. // Search narrows to one and shows a pill.
const one = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?q=user7%40example.com" }); const one = buildUsersListModel({ identities: people, url: "http://x/admin/users?q=user7%40example.com" });
assert.equal(one.pagination.summary.total, 1); assert.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]); assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
// Status filter keeps only inactive identities. // Status filter keeps only inactive identities.
const inactive = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?status=inactive" }); const inactive = buildUsersListModel({ identities: people, url: "http://x/admin/users?status=inactive" });
assert.equal(inactive.pagination.summary.total, 15); assert.equal(inactive.pagination.summary.total, 15);
assert.deepEqual(inactive.filterBar.pills.map((p) => p.label), ["Status"]); assert.deepEqual(inactive.filterBar.pills.map((p) => p.label), ["Status"]);
// Sort by email descending reverses the order. // Sort by email descending reverses the order.
const emailOf = (m: ReturnType<typeof buildUsersListModel>, i: number) => (m.table.rows[i]!.cells[1]) as string; const emailOf = (m: ReturnType<typeof buildUsersListModel>, i: number) => (m.table.rows[i]!.cells[1]) as string;
const desc = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?sort=-email" }); const desc = buildUsersListModel({ identities: people, url: "http://x/admin/users?sort=-email" });
assert.ok(emailOf(desc, 0) > emailOf(desc, 1)); assert.ok(emailOf(desc, 0) > emailOf(desc, 1));
// Edit link points at the per-identity route. // Edit link points at the per-identity route.
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/auth-admin/users/${people[0]!.id}$`)); assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/admin/users/${people[0]!.id}$`));
assert.deepEqual([all.table.rows[0]!.actions![0]!.icon, all.table.rows[0]!.actions![0]!.label], ["i-edit", "Edit"]); assert.deepEqual([all.table.rows[0]!.actions![0]!.icon, all.table.rows[0]!.actions![0]!.label], ["i-edit", "Edit"]);
}); });
// The detail page of a `users:read` holder is read-only, so the row action must not promise an edit. // The detail page of a `users:read` holder is read-only, so the row action must not promise an edit.
test("buildUsersListModel: a read-only holder's row action says View, with the same target", () => { test("buildUsersListModel: a read-only holder's row action says View, with the same target", () => {
const people: Identity[] = [{ id: "u1", traits: { email: "a@x.io" } }]; const people: Identity[] = [{ id: "u1", traits: { email: "a@x.io" } }];
const action = buildUsersListModel({ canWrite: false, identities: people, url: "http://x/auth-admin/users" }).table.rows[0]!.actions![0]!; const action = buildUsersListModel({ canWrite: false, identities: people, url: "http://x/admin/users" }).table.rows[0]!.actions![0]!;
assert.deepEqual([action.icon, action.label, action.href], ["i-eye", "View", "/auth-admin/users/u1"]); assert.deepEqual([action.icon, action.label, action.href], ["i-eye", "View", "/admin/users/u1"]);
}); });
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => { test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
const m = buildUserFormModel({ csrfToken: "tok.sig" }); const m = buildUserFormModel({ csrfToken: "tok.sig" });
assert.equal(m.title, "New user"); assert.equal(m.title, "New user");
assert.equal(m.form.action, "/auth-admin/users"); assert.equal(m.form.action, "/admin/users");
assert.equal(m.form.submitLabel, "Create user"); assert.equal(m.form.submitLabel, "Create user");
assert.equal(m.form.csrfToken, "tok.sig"); assert.equal(m.form.csrfToken, "tok.sig");
const names = m.form.fields.map((f) => f.name); const names = m.form.fields.map((f) => f.name);
@@ -85,7 +85,7 @@ test("buildUserFormModel: create mode has an editable email + password, no edit
test("buildUserFormModel: edit mode prefills, locks email, and exposes state/delete/recovery actions", () => { test("buildUserFormModel: edit mode prefills, locks email, and exposes state/delete/recovery actions", () => {
const m = buildUserFormModel({ identity: identity(3) }); const m = buildUserFormModel({ identity: identity(3) });
assert.equal(m.title, "Edit user"); assert.equal(m.title, "Edit user");
assert.equal(m.form.action, `/auth-admin/users/${id(3)}`); assert.equal(m.form.action, `/admin/users/${id(3)}`);
assert.equal(m.form.submitLabel, "Save changes"); assert.equal(m.form.submitLabel, "Save changes");
const email = m.form.fields.find((f) => f.name === "email")!; const email = m.form.fields.find((f) => f.name === "email")!;
assert.equal(email.value, "user3@example.com"); assert.equal(email.value, "user3@example.com");
@@ -109,7 +109,7 @@ test("buildUserFormModel: the title and breadcrumb say New, Edit, or View for a
for (const [opts, title, crumb] of cases) { for (const [opts, title, crumb] of cases) {
const m = buildUserFormModel(opts); const m = buildUserFormModel(opts);
assert.equal(m.title, title); assert.equal(m.title, title);
assert.deepEqual(m.breadcrumbs, [{ href: "/auth-admin/users", label: "Users" }, { label: crumb }]); assert.deepEqual(m.breadcrumbs, [{ href: "/admin/users", label: "Users" }, { label: crumb }]);
} }
}); });
@@ -3,9 +3,9 @@
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over // into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
// a shared `withUser` gate. // a shared `withUser` gate.
import { buildPaginationModel, can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api"; import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, SAVED_NOTICE, userSubject } from "./admin-grants.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, type FieldConfig, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
@@ -137,7 +137,7 @@ export function buildUsersListModel(opts: {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }], breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }],
canWrite: opts.canWrite !== false, canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, all.length, t), filterBar: listFilterBar(state, all.length, t),
pagination: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.users.pagination"), pageSizes: PAGE_SIZES }), pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t, opts.canWrite !== false), table: listTable(rows, state, sort, t, opts.canWrite !== false),
title: t("admin.users.title"), title: t("admin.users.title"),
}; };
@@ -185,6 +185,38 @@ function listFilterBar(state: ListState, total: number, t: Translate) {
}; };
} }
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
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"),
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 },
summary: { from: page.from, to: page.to, total: page.total },
};
}
export interface FieldConfig {
autocomplete?: string;
hint?: string;
icon?: string;
id: string;
label: string;
name: string;
optional?: boolean;
readonly?: boolean;
required?: boolean;
type?: string;
value?: string;
}
export function buildUserFormModel(opts: { export function buildUserFormModel(opts: {
canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance
csrfToken?: string; csrfToken?: string;
@@ -274,13 +306,13 @@ function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) =>
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult => 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, t: ctx.t, ...extra }) }, view: "user-form" });
// GET /auth-admin/users — the filtered/sorted/paged list. // GET /admin/users — the filtered/sorted/paged list.
export const usersList = withUser(async ({ ctx, kratosAdmin }) => { export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE }); 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({ canWrite: canWriteUsers(ctx), csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" };
}); });
// POST /auth-admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input. // POST /admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => { export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
const input = readUserInput((await guardedForm(ctx))!); const input = readUserInput((await guardedForm(ctx))!);
try { try {
@@ -293,12 +325,12 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
return { redirect: ADMIN_USERS_BASE }; return { redirect: ADMIN_USERS_BASE };
}); });
// GET /auth-admin/users/new — the empty create form. // 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, {})), "write");
// GET /auth-admin/users/:id — the edit form, prefilled. // GET /admin/users/:id — the edit form, prefilled.
export const usersEditForm = withTarget(async (deps, identity, id) => { export const usersEditForm = withTarget(async (deps, identity, id) => {
const permissions = await userPermissionPicker(deps, id, undefined, grantsNotice(deps.ctx.url, deps.ctx.t)); const permissions = await userPermissionPicker(deps, id, undefined, isSavedNotice(deps.ctx.url) ? deps.ctx.t("admin.grants.saved") : undefined);
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) }); return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
}); });
@@ -328,7 +360,7 @@ async function userPermissionPicker(deps: UsersDeps, id: string, error?: string,
}; };
} }
// POST /auth-admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct* // 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 // 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. // revokes their live tokens so it lands now rather than at the next re-mint.
export const usersPermissions = withTarget(async (deps, identity, id) => { export const usersPermissions = withTarget(async (deps, identity, id) => {
@@ -350,45 +382,37 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") }); ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
return refuse(ctx.t("admin.grants.selfRevoke"), 400); return refuse(ctx.t("admin.grants.selfRevoke"), 400);
} }
await applyGrants(keto, subject, diff); const { applied, error } = await applyGrants(keto, subject, diff);
if (diff.grant.length > 0 || diff.revoke.length > 0) { if (applied.grant.length > 0 || applied.revoke.length > 0) {
revoke?.(id); revoke?.(id);
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: diff.grant.join(","), revoked: diff.revoke.join(","), target: id }); ctx.log.info("admin: user permissions changed", { actor: user.id, granted: applied.grant.join(","), revoked: applied.revoke.join(","), target: id });
}
if (error) {
ctx.log.error("admin: user permissions only partly saved", { actor: user.id, error: String(error), target: id });
return partialSave(ctx, () => refuse(ctx.t("admin.grants.partial"), 502));
} }
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${SAVED_NOTICE}` }; return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${SAVED_NOTICE}` };
}); });
// POST /auth-admin/users/:id/permissions/remove — drop one direct grant no installed plugin declares. // POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
export const usersPermissionsRemove = withTarget(async ({ ctx, keto, revoke, user }, _identity, id) => {
const form = (await guardedForm(ctx))!;
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
const removed = await removeUndeclaredGrant(keto, userSubject(id), ctx.declaredPermissions, form.get(REMOVE_FIELD));
if (removed) {
revoke?.(id);
ctx.log.info("admin: undeclared user permission removed", { actor: user.id, removed, target: id });
}
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${REMOVED_NOTICE}` };
});
// POST /auth-admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
export const usersUpdate = withTarget(async (deps, identity, id) => { export const usersUpdate = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin } = deps; const { ctx, kratosAdmin } = deps;
const input = readUserInput((await guardedForm(ctx))!); const input = readUserInput((await guardedForm(ctx))!);
try { try {
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input)); await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
} catch (err) { } catch (err) {
if (err instanceof KratosError) return refuseForm(deps, identity, id, ctx.t("admin.users.error.save")); // 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 };
throw err; throw err;
} }
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` }; return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
}); });
// POST /auth-admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live // POST /admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
// tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account. // tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account.
export const usersState = withTarget(async (deps, identity, id) => { export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
const { ctx, kratosAdmin, revoke, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST (no fields read) await guardedForm(ctx); // CSRF-verify the POST (no fields read)
if (id === user.id) return refuseForm(deps, identity, id, ctx.t("admin.users.error.selfDeactivate")); if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDeactivate"), identity }), status: 400 };
const nextState = identity.state === "inactive" ? "active" : "inactive"; const nextState = identity.state === "inactive" ? "active" : "inactive";
await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState)); await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState));
if (nextState === "inactive") revoke?.(id); if (nextState === "inactive") revoke?.(id);
@@ -396,9 +420,9 @@ export const usersState = withTarget(async (deps, identity, id) => {
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` }; return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
}); });
// GET /auth-admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete. // GET /admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
export const usersDeleteConfirm = withTarget((deps, identity, id) => { export const usersDeleteConfirm = withTarget((deps, identity, id) => {
if (id === deps.user.id) return refuseForm(deps, identity, id, deps.ctx.t("admin.users.error.selfDelete")); if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: deps.ctx.t("admin.users.error.selfDelete"), identity }), status: 400 });
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`; const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
const view = toUserView(identity); const view = toUserView(identity);
const tt = deps.ctx.t; const tt = deps.ctx.t;
@@ -409,18 +433,17 @@ export const usersDeleteConfirm = withTarget((deps, identity, id) => {
}) }, view: "confirm" }); }) }, view: "confirm" });
}, "write"); }, "write");
// POST /auth-admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete. // POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
export const usersDelete = withTarget(async (deps, identity, id) => { export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
const { ctx, kratosAdmin, revoke, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
if (id === user.id) return refuseForm(deps, identity, id, ctx.t("admin.users.error.selfDelete")); if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDelete"), identity }), status: 400 };
await kratosAdmin.deleteIdentity(id); await kratosAdmin.deleteIdentity(id);
revoke?.(id); revoke?.(id);
ctx.log.info("admin: user deleted", { actor: user.id, target: id }); ctx.log.info("admin: user deleted", { actor: user.id, target: id });
return { redirect: ADMIN_USERS_BASE }; return { redirect: ADMIN_USERS_BASE };
}); });
// POST /auth-admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page. // POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
export const usersRecovery = withTarget(async (deps, identity, id) => { export const usersRecovery = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin } = deps; const { ctx, kratosAdmin } = deps;
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
@@ -428,13 +451,7 @@ export const usersRecovery = withTarget(async (deps, identity, id) => {
return formResult(ctx, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) }); return formResult(ctx, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) });
}); });
// The 400 re-render of the user's form with `error` above it. `usersPermissions` refuses through its // The picker as a spreadable fragment, so a re-render never silently drops the section.
// own `refuse`, which puts the error in the permissions section instead.
async function refuseForm(deps: UsersDeps, identity: Identity, id: string, error: string): Promise<RouteResult> {
return { ...formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), error, identity, ...(await pickerOrNothing(deps, id)) }), status: 400 };
}
// The picker as a spreadable fragment, so every form re-render keeps the section.
async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> { async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> {
const permissions = await userPermissionPicker(deps, id); const permissions = await userPermissionPicker(deps, id);
return permissions ? { permissions } : {}; return permissions ? { permissions } : {};
@@ -53,15 +53,12 @@ const messages = {
"admin.grants.inherited": "Greyed-out permissions come from a group. Change them on that group.", "admin.grants.inherited": "Greyed-out permissions come from a group. Change them on that group.",
"admin.grants.legend": "Permissions", "admin.grants.legend": "Permissions",
"admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.", "admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.",
"admin.grants.partial": "Saving stopped part-way because the permission service failed, so only some changes were applied. Check the permissions and make any remaining changes again.",
"admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).", "admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).",
"admin.grants.remove": "Remove",
"admin.grants.removed": "Permission removed.",
"admin.grants.save": "Save permissions", "admin.grants.save": "Save permissions",
"admin.grants.saved": "Permissions saved.", "admin.grants.saved": "Permissions saved.",
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.", "admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.",
"admin.grants.stale": "These permissions changed while you had the page open, so nothing was saved. The list now shows the current state. Review it and save again.", "admin.grants.stale": "These permissions changed while you had the page open, so nothing was saved. The list now shows the current state. Review it and save again.",
"admin.grants.undeclaredHint": "These permissions are held here, but no installed plugin declares them, so the list above can't change them. A plugin may still check one without declaring it.",
"admin.grants.undeclaredLegend": "Permissions no installed plugin declares",
"admin.groups.actions": "Group actions", "admin.groups.actions": "Group actions",
"admin.groups.addMember": "Add a member", "admin.groups.addMember": "Add a member",
@@ -71,7 +68,6 @@ const messages = {
"admin.groups.create": "Create group", "admin.groups.create": "Create group",
"admin.groups.delete": "Delete group", "admin.groups.delete": "Delete group",
"admin.groups.deleteMessage": "Delete group {{name}}? This can't be undone.", "admin.groups.deleteMessage": "Delete group {{name}}? This can't be undone.",
"admin.groups.error.duplicate": "A group with that name already exists.",
"admin.groups.error.selfLockout": "This change would lock you out of {{permissions}}, which you hold through this group. Ask another administrator to make it.", "admin.groups.error.selfLockout": "This change would lock you out of {{permissions}}, which you hold through this group. Ask another administrator to make it.",
"admin.groups.field.name": "Group name", "admin.groups.field.name": "Group name",
"admin.groups.field.nameHint": "Lowercase letters, digits, dashes and underscores.", "admin.groups.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
@@ -53,15 +53,12 @@ const messages: AdminMessages = {
"admin.grants.inherited": "Gråmarkerade behörigheter kommer från en grupp. Ändra dem på gruppen.", "admin.grants.inherited": "Gråmarkerade behörigheter kommer från en grupp. Ändra dem på gruppen.",
"admin.grants.legend": "Behörigheter", "admin.grants.legend": "Behörigheter",
"admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.", "admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.",
"admin.grants.partial": "Sparandet avbröts halvvägs eftersom behörighetstjänsten gav fel, så bara en del av ändringarna genomfördes. Kontrollera behörigheterna och gör de återstående ändringarna igen.",
"admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).", "admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).",
"admin.grants.remove": "Ta bort",
"admin.grants.removed": "Behörigheten är borttagen.",
"admin.grants.save": "Spara behörigheter", "admin.grants.save": "Spara behörigheter",
"admin.grants.saved": "Behörigheterna är sparade.", "admin.grants.saved": "Behörigheterna är sparade.",
"admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.", "admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.",
"admin.grants.stale": "Behörigheterna ändrades medan du hade sidan öppen, så ingenting sparades. Listan visar nu aktuellt läge. Granska den och spara igen.", "admin.grants.stale": "Behörigheterna ändrades medan du hade sidan öppen, så ingenting sparades. Listan visar nu aktuellt läge. Granska den och spara igen.",
"admin.grants.undeclaredHint": "Behörigheterna innehas här, men ingen installerad plugin deklarerar dem, så listan ovan kan inte ändra dem. En plugin kan ändå kontrollera någon av dem utan att deklarera den.",
"admin.grants.undeclaredLegend": "Behörigheter som ingen installerad plugin deklarerar",
"admin.groups.actions": "Gruppåtgärder", "admin.groups.actions": "Gruppåtgärder",
"admin.groups.addMember": "Lägg till en medlem", "admin.groups.addMember": "Lägg till en medlem",
@@ -71,7 +68,6 @@ const messages: AdminMessages = {
"admin.groups.create": "Skapa grupp", "admin.groups.create": "Skapa grupp",
"admin.groups.delete": "Radera grupp", "admin.groups.delete": "Radera grupp",
"admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det går inte att ångra.", "admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det går inte att ångra.",
"admin.groups.error.duplicate": "Det finns redan en grupp med det namnet.",
"admin.groups.error.selfLockout": "Ändringen skulle låsa ute dig från {{permissions}}, som du har genom den här gruppen. Be en annan administratör att göra den.", "admin.groups.error.selfLockout": "Ändringen skulle låsa ute dig från {{permissions}}, som du har genom den här gruppen. Be en annan administratör att göra den.",
"admin.groups.field.name": "Gruppnamn", "admin.groups.field.name": "Gruppnamn",
"admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.", "admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
@@ -1,14 +1,14 @@
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy // Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
// this folder to plugins/auth-admin (then restart) to enable it — see README → Quick start. // 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 // 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. // hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api"; import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api";
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts"; import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsPermissionsRemove, groupsRemoveMember } from "./admin-groups.ts"; import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
import { pluginSettingsList } from "./admin-plugin-settings.ts"; import { pluginSettingsList } from "./admin-plugin-settings.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersPermissionsRemove, usersRecovery, usersState, usersUpdate } from "./admin-users.ts"; import { 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 { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
// One route factory per screen: a GET gates on `<resource>:read` and a POST on `<resource>:write`, // One route factory per screen: a GET gates on `<resource>:read` and a POST on `<resource>:write`,
@@ -28,7 +28,7 @@ const clients = on("oauth2-clients");
const pluginSettings = on("plugin-settings"); const pluginSettings = on("plugin-settings");
export default definePlugin({ export default definePlugin({
apiVersion: "0.5.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV], nav: [ADMIN_NAV],
@@ -54,7 +54,6 @@ export default definePlugin({
users("POST", "/users/:id/delete", usersDelete), users("POST", "/users/:id/delete", usersDelete),
users("POST", "/users/:id/recovery", usersRecovery), users("POST", "/users/:id/recovery", usersRecovery),
users("POST", "/users/:id/permissions", usersPermissions), users("POST", "/users/:id/permissions", usersPermissions),
users("POST", "/users/:id/permissions/remove", usersPermissionsRemove),
// Groups // Groups
groups("GET", "/groups", groupsList), groups("GET", "/groups", groupsList),
groups("POST", "/groups", groupsCreate), groups("POST", "/groups", groupsCreate),
@@ -65,7 +64,6 @@ export default definePlugin({
groups("POST", "/groups/:name/delete", groupsDelete), groups("POST", "/groups/:name/delete", groupsDelete),
groups("POST", "/groups/:name/members/delete", groupsRemoveMember), groups("POST", "/groups/:name/members/delete", groupsRemoveMember),
groups("POST", "/groups/:name/permissions", groupsPermissions), groups("POST", "/groups/:name/permissions", groupsPermissions),
groups("POST", "/groups/:name/permissions/remove", groupsPermissionsRemove),
// OAuth2 clients // OAuth2 clients
clients("GET", "/clients", clientsList), clients("GET", "/clients", clientsList),
clients("POST", "/clients", clientsCreate), clients("POST", "/clients", clientsCreate),
@@ -7,7 +7,7 @@
const table = include("partials/data-table", model.table); const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination); 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. // Only offer "Register client" to an oauth2-clients:write holder — a :read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>'; const actions = 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>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
@@ -7,7 +7,7 @@
const table = include("partials/data-table", model.table); const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination); 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. // Only offer "New group" to a groups:write holder — a groups:read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>'; const actions = 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>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
@@ -1,11 +1,9 @@
<%# <%#
Admin group membership body, captured into the shell content slot. Config: Admin group membership body, captured into the shell content slot. Config:
group { name } group { name }
members { action, rows: { kind:"group"|"user", label, subject }[] } action = remove-member endpoint members { action, rows: { kind:"group"|"identity", label, subject }[] } action = remove-member endpoint
add { action, options: {label,value}[] } action = add-member endpoint add { action, options: {label,value}[] } action = add-member endpoint
del { action } delete the whole group del { action } delete the whole group
canWrite? false (a `groups:read` holder) drops the add, remove and delete forms
permissions? PermissionPicker; when set, the group's permission picker is rendered
csrfToken, error? csrfToken, error?
%><% %><%
const group = locals.group; const group = locals.group;
@@ -7,8 +7,7 @@
there) and every row when `readOnly` (the viewer holds :read but not :write). Neither can be diffed 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. into an accidental revoke, because grantDiff compares against the direct grants only.
Locals: csrfToken, permissions ({ action, baseline, choices, empty, error, field, hint, inheritedNote, legend, notice, pending, readOnly, submit, undeclared }). Locals: csrfToken, permissions ({ action, baseline, choices, empty, error, field, hint, inheritedNote, legend, notice, pending, readOnly, submit }).
%> %>
<section class="form-card" aria-labelledby="permissions-h"> <section class="form-card" aria-labelledby="permissions-h">
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2> <h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
@@ -51,25 +50,5 @@
<% if (permissions.pending) { -%> <% if (permissions.pending) { -%>
<p class="cell-muted"><%= permissions.pending %></p> <p class="cell-muted"><%= permissions.pending %></p>
<% } -%> <% } -%>
<% } -%>
<% if (permissions.undeclared) { const u = permissions.undeclared; -%>
<h3 class="card-title" id="undeclared-h"><%= u.legend %></h3>
<p class="cell-muted"><%= u.hint %></p>
<ul class="plain-list" aria-labelledby="undeclared-h">
<% u.names.forEach((name) => { -%>
<li>
<% if (u.remove) { -%>
<form method="post" action="<%= localeHref(u.action + '#permissions-h') %>">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="<%= u.field %>" value="<%= name %>">
<span class="cell-muted"><%= name %></span>
<button class="btn btn-danger" type="submit"><%= u.remove %><span class="sr-only"> <%= name %></span></button>
</form>
<% } else { -%>
<span class="cell-muted"><%= name %></span>
<% } -%>
</li>
<% }) -%>
</ul>
<% } -%> <% } -%>
</section> </section>
@@ -7,7 +7,7 @@
const table = include("partials/data-table", model.table); const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination); 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. // Only offer "New user" to a users:write holder — a users:read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>'; const actions = 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>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
+1 -2
View File
@@ -10,8 +10,7 @@ What it demonstrates:
service and renders the rows with the core building blocks (`shifts.ejs` → app shell, filter-bar, service and renders the rows with the core building blocks (`shifts.ejs` → app shell, filter-bar,
data-table). Search round-trips the URL; zero-JS. (It fetches **all** rows for brevity — for a data-table). Search round-trips the URL; zero-JS. (It fetches **all** rows for brevity — for a
large list, parse `page`/`pageSize` from `parseListQuery`, forward them upstream as a `?limit`/ large list, parse `page`/`pageSize` from `parseListQuery`, forward them upstream as a `?limit`/
`?offset`, and render `pagination.ejs` with `paginate()` and `buildPaginationModel()`, as the admin example plugin `?offset`, and render `pagination.ejs` with `paginate()`, exactly as the admin example plugin does.)
does.)
- **A form that forwards a write upstream** — `GET /scheduling/shifts/new` renders the form, - **A form that forwards a write upstream** — `GET /scheduling/shifts/new` renders the form,
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream, `POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`, then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
+2 -5
View File
@@ -12,7 +12,7 @@ let upstreamUrl = "";
const upstream = createUpstream(() => upstreamUrl); const upstream = createUpstream(() => upstreamUrl);
export default definePlugin({ export default definePlugin({
apiVersion: "0.5.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "0.4.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 // 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. // settings. A malformed URL already failed the boot by then; the host validated the declared type.
@@ -34,15 +34,12 @@ export default definePlugin({
public: true, // the header gates nothing; each child names its own gate, and an empty header is dropped public: true, // the header gates nothing; each child names its own gate, and an empty header is dropped
}], }],
// Permissions this plugin declares: the admin picker offers them and bootstrap grants them to the demo admin. // Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
permissions: [ permissions: [
{ description: "View shifts", name: READ }, { description: "View shifts", name: READ },
{ description: "Create and edit shifts", name: WRITE }, { description: "Create and edit shifts", name: WRITE },
], ],
// Also offered in the profile dropdown above Sign out, since the page is the visitor's own.
profileMenu: [{ href: MINE_PATH, icon: "i-cal", id: "scheduling:profile-mine", label: "scheduling.nav.mine", session: true }],
routes: [ routes: [
{ handler: overview(), method: "GET", path: "/", public: true }, { handler: overview(), method: "GET", path: "/", public: true },
{ handler: myShifts(upstream), method: "GET", path: "/mine", session: true }, { handler: myShifts(upstream), method: "GET", path: "/mine", session: true },
+1 -1
View File
@@ -18,7 +18,7 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; us
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts"); 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; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return { return {
assetHref: (href) => href, chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {}, chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.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, query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
verifyCsrf: opts.verifyCsrf ?? (() => true), verifyCsrf: opts.verifyCsrf ?? (() => true),
}; };
+29 -29
View File
@@ -6,15 +6,15 @@
"": { "": {
"name": "plainpages", "name": "plainpages",
"dependencies": { "dependencies": {
"@larvit/log": "2.4.0", "@larvit/log": "2.3.0",
"ejs": "7.0.2", "ejs": "6.0.1",
"lucide-static": "1.70.0", "lucide-static": "1.66.0",
"postgres": "4.1.0" "postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
"@mdn/browser-compat-data": "8.1.8", "@mdn/browser-compat-data": "8.1.7",
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
"@types/node": "26.6.6", "@types/node": "24.19.2",
"acorn": "8.19.0", "acorn": "8.19.0",
"typescript": "7.0.2" "typescript": "7.0.2"
}, },
@@ -23,18 +23,18 @@
} }
}, },
"node_modules/@larvit/log": { "node_modules/@larvit/log": {
"version": "2.4.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@larvit/log/-/log-2.4.0.tgz", "resolved": "https://registry.npmjs.org/@larvit/log/-/log-2.3.0.tgz",
"integrity": "sha512-0BA1S0eSzQMOP527Vy2DL9Zb68TiCgyDACr0gDiI2b/1zfY6Axq9XhR/ZJnF9URIAhQwFPYs3W6lh/T9K8vqgQ==", "integrity": "sha512-gLRMDWrFnoMGdAr9NXMXRKOUeceU6Qd/CW0c6qDO+DLFT1xWrqO2ubsHyPqPzlZHVUO3w3Bk7kOWo6C2Ykn4ZQ==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@mdn/browser-compat-data": { "node_modules/@mdn/browser-compat-data": {
"version": "8.1.8", "version": "8.1.7",
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-8.1.8.tgz", "resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-8.1.7.tgz",
"integrity": "sha512-+RK7inkLeZ70kr9Mo+Y81JjZMpQIt0Sx40tHHU0hXGDSdf9YvlawH+4WlUpP3SE8rFLmjqGiTzL+qPPPnncWDw==", "integrity": "sha512-TJ+rX530bFoE8678PuH2gjCSzwpBcaLlcElRhnnxFdkLHHT1clUAxtjImPFZDAlWQ005px9xaJ4Nd+aQp/rylQ==",
"dev": true, "dev": true,
"license": "CC0-1.0" "license": "CC0-1.0"
}, },
@@ -46,13 +46,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "26.6.6", "version": "24.19.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==", "integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~8.9.0" "undici-types": ">=7.24.0 <7.24.7"
} }
}, },
"node_modules/@typescript/typescript-aix-ppc64": { "node_modules/@typescript/typescript-aix-ppc64": {
@@ -409,9 +409,9 @@
} }
}, },
"node_modules/ejs": { "node_modules/ejs": {
"version": "7.0.2", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-7.0.2.tgz", "resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
"integrity": "sha512-LcDSEP6Oclb9sTiSyG0MpG0RuKvLDRxCOOt+2sIk+kdk1rMkFU8SRnT0qaNt5zvU0LOV1YXXOvIoZL/sKwabvw==", "integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
"ejs": "bin/cli.js" "ejs": "bin/cli.js"
@@ -421,18 +421,18 @@
} }
}, },
"node_modules/lucide-static": { "node_modules/lucide-static": {
"version": "1.70.0", "version": "1.66.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.70.0.tgz", "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.66.0.tgz",
"integrity": "sha512-A9UgY98g94IuHQY7rl7/AFwHX7T1qNpzryim+Kw5CGhdV2STobtBY+ib/Z44ohyqXEJB88gvXFAeJ87uOOHKLw==", "integrity": "sha512-j8Bjf/f21PtlD8p0wx1lRxyOrs98WZ1QEImkWmfskuJ8mOCwTN7Dwc6EHUzJDSzh79aB+Bo7SWcoTCM2zEjnJg==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/postgres": { "node_modules/postgres": {
"version": "4.1.0", "version": "3.4.9",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-4.1.0.tgz", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
"integrity": "sha512-s+0WH67YAyLdBQUb6XczbziBhY+C9uiQsg9S8R5RHdZhk6sdyK6202E+T2tXpiInSEEjDLRSJ3OpYBe3PN6MsQ==", "integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
"license": "Unlicense", "license": "Unlicense",
"engines": { "engines": {
"node": ">=18" "node": ">=12"
}, },
"funding": { "funding": {
"type": "individual", "type": "individual",
@@ -475,9 +475,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "8.9.0", "version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
} }
+6 -6
View File
@@ -16,15 +16,15 @@
"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\" \"release-tooling/**/*.test.ts\""
}, },
"dependencies": { "dependencies": {
"@larvit/log": "2.4.0", "@larvit/log": "2.3.0",
"ejs": "7.0.2", "ejs": "6.0.1",
"lucide-static": "1.70.0", "lucide-static": "1.66.0",
"postgres": "4.1.0" "postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
"@mdn/browser-compat-data": "8.1.8", "@mdn/browser-compat-data": "8.1.7",
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
"@types/node": "26.6.6", "@types/node": "24.19.2",
"acorn": "8.19.0", "acorn": "8.19.0",
"typescript": "7.0.2" "typescript": "7.0.2"
} }
-5
View File
@@ -20,11 +20,6 @@
resolution is unreliable across engines. color-scheme is still resolution is unreliable across engines. color-scheme is still
set as a hint for native widgets (date picker, scrollbars, set as a hint for native widgets (date picker, scrollbars,
selects). selects).
Footgun: a token value an engine of the oldest browser floor can't parse (a
function or unit it lacks) is invalid at computed-value time, so every
declaration reading it through var() computes as `unset`; a fallback declared
before it doesn't help. The test in src/test-tooling/stylesheet-floor.ts
fails on such a value.
------------------------------------------------------------- */ ------------------------------------------------------------- */
:root { :root {
/* ---- raw LIGHT palette ---- */ /* ---- raw LIGHT palette ---- */
+3 -3
View File
@@ -12,7 +12,7 @@ test("readHostApiVersion pulls the constant out of the real source, and returns
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => { 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 // 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. // makes an accidental edit fail here rather than at release time.
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.5.0"); assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.4.0");
}); });
test("every author-facing apiVersion sample matches the shipped contract", () => { test("every author-facing apiVersion sample matches the shipped contract", () => {
@@ -23,10 +23,10 @@ test("every author-facing apiVersion sample matches the shipped contract", () =>
const [major, minor] = host.split("."); const [major, minor] = host.split(".");
for (const file of [ for (const file of [
"README.md", "README.md",
"examples/plugins/auth-admin/plugin.ts", "examples/plugins/admin/plugin.ts",
"examples/plugins/scheduling/plugin.ts", "examples/plugins/scheduling/plugin.ts",
"release-tooling/dockerhub-overview.md.tmpl", "release-tooling/dockerhub-overview.md.tmpl",
"views/dashboard.ejs", "views/index.ejs",
]) { ]) {
const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]); 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`); assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`);
+2 -2
View File
@@ -131,7 +131,7 @@ services:
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025 # Catches Kratos' recovery/verification emails — UI on http://localhost:8025
mailpit: mailpit:
image: axllent/mailpit:v1.33.1 image: axllent/mailpit:v1.33.0
ports: ports:
- "8025:8025" - "8025:8025"
restart: unless-stopped restart: unless-stopped
@@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
import { definePlugin } from "@plainpages/plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "0.5.0", apiVersion: "0.4.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [ routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) }, { method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
+2 -2
View File
@@ -56,7 +56,7 @@ async function main(): Promise<number> {
if (!version || !repo || !user || !token) { if (!version || !repo || !user || !token) {
return fail( return fail(
"usage: dockerhub-overview.ts <X.Y.Z>; needs DOCKERHUB_REPO, DOCKERHUB_USER and " + "usage: dockerhub-overview.ts <X.Y.Z>; needs DOCKERHUB_REPO, DOCKERHUB_USER and " +
"DOCKERHUB_TOKEN (docs/ci-cd.md)", "DOCKERHUB_TOKEN (README -> CI/CD)",
); );
} }
// The page is public, so never render a version that resolves to no image. // The page is public, so never render a version that resolves to no image.
@@ -94,7 +94,7 @@ async function main(): Promise<number> {
`Docker Hub description and overview PATCH failed: ${res.status} ${res.text}` + `Docker Hub description and overview PATCH failed: ${res.status} ${res.text}` +
(res.status === 403 (res.status === 403
? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " + ? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " +
"read/write/delete, which pushing images alone does not (docs/ci-cd.md)." "read/write/delete, which pushing images alone does not (README -> CI/CD)."
: ""), : ""),
); );
} }
+1 -1
View File
@@ -1,5 +1,5 @@
// Pure release-version math for the Renovate auto-release (see renovate.yml → auto-release job, // Pure release-version math for the Renovate auto-release (see renovate.yml → auto-release job,
// docs/ci-cd.md). Renovate stamps each commit with a `Release-Bump: <updateType>` trailer; the // README → CI/CD). Renovate stamps each commit with a `Release-Bump: <updateType>` trailer; the
// workflow feeds those values here to pick the next `vX.Y.Z` tag. Kept side-effect-free and unit // workflow feeds those values here to pick the next `vX.Y.Z` tag. Kept side-effect-free and unit
// tested (next-version.test.ts) — the git/tag/push side lives in the workflow shell. // tested (next-version.test.ts) — the git/tag/push side lives in the workflow shell.
+1 -1
View File
@@ -6,7 +6,7 @@
"automerge": true, "automerge": true,
"packageRules": [ "packageRules": [
{ {
"description": "The host's own runtime deps. Release-Bump is opt-in per surface (docs/ci-cd.md): updateType rates the dependency's own jump, not whether it reaches a running Plainpages", "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"], "matchDepTypes": ["dependencies"],
"matchFileNames": ["package.json"], "matchFileNames": ["package.json"],
"matchManagers": ["npm"], "matchManagers": ["npm"],
+19 -29
View File
@@ -7,8 +7,6 @@ import assert from "node:assert/strict";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, provisionPluginStorage, seedAdmin, seedPermissions, serverMismatch } from "./bootstrap.ts"; import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, provisionPluginStorage, seedAdmin, seedPermissions, serverMismatch } from "./bootstrap.ts";
import { createLogger } from "../logger.ts"; import { createLogger } from "../logger.ts";
import { createKetoClient } from "./keto-client.ts";
import { createKratosAdmin } from "./kratos-admin.ts";
import type { Plugin } from "../plugin-host/plugin.ts"; import type { Plugin } from "../plugin-host/plugin.ts";
import type { ProvisionOptions, ProvisionResult } from "../plugin-host/storage-provisioning.ts"; import type { ProvisionOptions, ProvisionResult } from "../plugin-host/storage-provisioning.ts";
@@ -77,8 +75,9 @@ test("seedAdmin on a fresh stack creates the identity and grants every permissio
const result = await seedAdmin({ const result = await seedAdmin({
email: "admin@plainpages.local", email: "admin@plainpages.local",
keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }), fetchImpl,
kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }), ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin", password: "admin",
permissions: ["admin", "scheduling:read"], permissions: ["admin", "scheduling:read"],
}); });
@@ -109,8 +108,9 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
const result = await seedAdmin({ const result = await seedAdmin({
email: "admin@plainpages.local", email: "admin@plainpages.local",
keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }), fetchImpl,
kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }), ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin", password: "admin",
permissions: ["admin"], permissions: ["admin"],
}); });
@@ -119,29 +119,19 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` }); assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` });
}); });
const clients = (fetchImpl: typeof fetch) => ({ test("seedAdmin fails loud on an unexpected Kratos error", async () => {
keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }), const fetchImpl = (async () => json(500, { error: "boom" })) as typeof fetch;
kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }), await assert.rejects(
}); seedAdmin({
email: "admin@plainpages.local",
test("seedAdmin fails loud on a Kratos error other than 409, without looking the admin up", async () => { fetchImpl,
const urls: string[] = []; ketoWriteUrl: "http://keto:4467",
const fetchImpl = (async (url) => { kratosAdminUrl: "http://kratos:4434",
urls.push(String(url)); password: "admin",
return json(500, { error: "boom" }); permissions: ["admin"],
}) as typeof fetch; }),
await assert.rejects(seedAdmin({ email: "admin@plainpages.local", password: "admin", permissions: ["admin"], ...clients(fetchImpl) }), /bootstrap: Kratos.*500/); /Kratos/,
assert.equal(urls.length, 1); );
});
test("seedAdmin fails loud when a 409 admin cannot be found", async () => {
const fetchImpl = (async (_url, init) => (init?.method === "POST" ? json(409, {}) : json(200, []))) as typeof fetch;
await assert.rejects(seedAdmin({ email: "admin@plainpages.local", password: "admin", permissions: [], ...clients(fetchImpl) }), /reported as existing but not found/);
});
test("seedAdmin fails loud when Keto refuses a grant, naming the permission", async () => {
const fetchImpl = (async (url) => (String(url).endsWith("/admin/identities") ? json(201, { id: "u1" }) : json(500, {}))) as typeof fetch;
await assert.rejects(seedAdmin({ email: "admin@plainpages.local", password: "admin", permissions: ["users:read"], ...clients(fetchImpl) }), /granting "users:read" in Keto/);
}); });
test("firstRunBanner prints the login URL, seeded creds, and a change-before-production warning", () => { test("firstRunBanner prints the login URL, seeded creds, and a change-before-production warning", () => {
+64 -41
View File
@@ -1,20 +1,19 @@
// The one-shot compose service (`node src/auth/bootstrap.ts`) that prepares a stack. It starts once // One-command bootstrap (the MVP bar). One-shot compose service: runs after
// Kratos and Keto are healthy, `web` waits for it, and it repeats safely on every `docker compose up`. // kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
// Steps, in the order `main` runs them: write the JWKS signing key if absent, create each storage // 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
// plugin's database and role, then seed the demo admin in Kratos and grant it in Keto every // 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
// discovered plugin's declared permissions (plus ADMIN_PERMISSIONS). Any unexpected upstream error // 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — every
// fails the run loudly. // 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).
// Then prints a first-run banner; fails loud on any unexpected upstream error.
import { existsSync, writeFileSync } from "node:fs"; import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { type BootstrapConfig, loadBootstrapConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts"; import { resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts";
import { discoverPlugins } from "../plugin-host/discovery.ts"; import { discoverPlugins } from "../plugin-host/discovery.ts";
import { declaredPermissions, isValidPermissionName, type Plugin } from "../plugin-host/plugin.ts"; import { declaredPermissions, isValidPermissionName, type Plugin } from "../plugin-host/plugin.ts";
import { provisionStorage } from "../plugin-host/storage-provisioning.ts"; import { provisionStorage } from "../plugin-host/storage-provisioning.ts";
import { storagePluginIds } from "../plugin-host/storage.ts"; import { storagePluginIds } from "../plugin-host/storage.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts"; import { generateJwks, type JwkSet } from "./gen-jwks.ts";
import { createKetoClient, type KetoClient } from "./keto-client.ts";
import { createKratosAdmin, type KratosAdmin } from "./kratos-admin.ts";
import { KratosError } from "./kratos-public.ts";
import { createLogger, runWithLog, tracedFetch, type Log } from "../logger.ts"; import { createLogger, runWithLog, tracedFetch, type Log } from "../logger.ts";
type Env = Record<string, string | undefined>; type Env = Record<string, string | undefined>;
@@ -29,6 +28,8 @@ 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) { export function permissionTuple(userId: string, permission: string) {
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` }; return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
} }
@@ -71,8 +72,9 @@ export function ensureJwks(path: string, hooks: JwksFsHooks = {}): boolean {
export interface SeedOptions { export interface SeedOptions {
email: string; email: string;
keto: KetoClient; fetchImpl?: typeof fetch;
kratos: KratosAdmin; ketoWriteUrl: string;
kratosAdminUrl: string;
password: string; password: string;
permissions: string[]; permissions: string[];
} }
@@ -84,29 +86,45 @@ export interface SeedResult {
} }
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> { export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
const { created, id } = await createOrFindAdmin(opts.kratos, opts.email, opts.password); const http = opts.fetchImpl ?? fetch;
// Create the identity. A 409 means it already exists (a re-run) — look up its id.
const res = await http(`${opts.kratosAdminUrl}/admin/identities`, {
body: JSON.stringify(identityPayload(opts.email, opts.password)),
headers: { "content-type": "application/json" },
method: "POST",
});
let created: boolean;
let id: string;
if (res.status === 201) {
id = ((await res.json()) as { id: string }).id;
created = true;
} else if (res.status === 409) {
id = await findIdentityId(http, opts.kratosAdminUrl, opts.email);
created = false;
} else {
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) { for (const permission of opts.permissions) {
try { const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
await opts.keto.writeTuple(permissionTuple(id, permission)); body: JSON.stringify(permissionTuple(id, permission)),
} catch (err) { headers: { "content-type": "application/json" },
throw new Error(`bootstrap: granting "${permission}" in Keto: ${(err as Error).message}`, { cause: err }); method: "PUT",
} });
if (!grant.ok) throw new Error(`bootstrap: Keto grant permission "${permission}" failed (${grant.status}): ${await grant.text()}`);
} }
return { created, id, permissions: opts.permissions }; return { created, id, permissions: opts.permissions };
} }
// A Kratos 409 means the admin exists (a re-run), so look its id up by login identifier. async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
async function createOrFindAdmin(kratos: KratosAdmin, email: string, password: string): Promise<{ created: boolean; id: string }> { const res = await http(`${adminUrl}/admin/identities?credentials_identifier=${encodeURIComponent(email)}`);
try { if (!res.ok) throw new Error(`bootstrap: Kratos lookup failed (${res.status}): ${await res.text()}`);
return { created: true, id: (await kratos.createIdentity(identityPayload(email, password))).id }; const found = ((await res.json()) as { id: string }[])[0];
} catch (err) { if (!found?.id) throw new Error(`bootstrap: ${email} reported as existing but not found`);
if (!(err instanceof KratosError) || err.status !== 409) throw new Error(`bootstrap: ${(err as Error).message}`, { cause: err }); return found.id;
}
const found = (await kratos.listIdentities({ credentialsIdentifier: email })).identities[0];
if (!found) throw new Error(`bootstrap: ${email} reported as existing but not found`);
return { created: false, id: found.id };
} }
// --- First-run banner ---------------------------------------------------------------- // --- First-run banner ----------------------------------------------------------------
@@ -129,19 +147,21 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
async function main() { async function main() {
const env = { ...process.env }; // snapshot: the storage credentials leave process.env before discovery const env = { ...process.env }; // snapshot: the storage credentials leave process.env before discovery
const config = loadBootstrapConfig(env); // first, so a bad setting fails before any step has run // Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME.
// Structured like the web app so prod logs stay uniform. const log = createLogger({
const log = createLogger({ format: config.logFormat, ...(config.serviceName ? { serviceName: config.serviceName } : {}) }); format: env["LOG_FORMAT"] === "json" ? "json" : "text",
// runWithLog makes `log` ambient so the Kratos/Keto clients' tracedFetch traces the seed calls. ...(env["SERVICE_NAME"] ? { serviceName: env["SERVICE_NAME"] } : {}),
});
// runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls.
await runWithLog(log, async () => { await runWithLog(log, async () => {
if (ensureJwks(config.jwksFile)) log.info("generated a JWKS signing key"); if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
// Discovery imports every plugin module — and its dependencies — into *this* process, which holds // 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. // 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_ADMIN_URL"];
delete process.env["PLUGIN_DB_SECRET"]; delete process.env["PLUGIN_DB_SECRET"];
const plugins = await discoverPlugins(); const plugins = await discoverPlugins();
await provisionPluginStorage(env, plugins, log); await provisionPluginStorage(env, plugins, log);
await seedAdminAndPermissions(config, plugins, log); await seedAdminAndPermissions(env, plugins, log);
}); });
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
} }
@@ -194,22 +214,25 @@ function safeHostPort(url: string): string | null {
// Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the // 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. // shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
async function seedAdminAndPermissions(config: BootstrapConfig, plugins: Plugin[], log: Log): Promise<void> { async function seedAdminAndPermissions(env: Env, plugins: Plugin[], log: Log): Promise<void> {
const declared = declaredPermissions(plugins).map((decl) => decl.name); const declared = declaredPermissions(plugins).map((decl) => decl.name);
const { ignored, permissions } = seedPermissions(config.adminPermissions, declared); const { ignored, permissions } = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
if (ignored.length > 0) { if (ignored.length > 0) {
log.warn("ignoring ADMIN_PERMISSIONS entries that are not <resource>:<action>", { ignored: ignored.join(", ") }); 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({ const result = await seedAdmin({
email: config.adminEmail, email,
keto: createKetoClient({ fetchImpl: tracedFetch, readUrl: config.ketoReadUrl, writeUrl: config.ketoWriteUrl }), fetchImpl: tracedFetch,
kratos: createKratosAdmin({ baseUrl: config.kratosAdminUrl, fetchImpl: tracedFetch }), ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
password: config.adminPassword, kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
password,
permissions, permissions,
}); });
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") }); 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. // The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
console.log(firstRunBanner({ appUrl: config.appUrl, email: config.adminEmail, password: config.adminPassword })); console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
} }
if (process.argv[1] === fileURLToPath(import.meta.url)) await main(); if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
+1 -5
View File
@@ -10,14 +10,10 @@ export interface Gate {
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
} }
// A gate naming none of the three allows everyone. Discovery refuses one on a plugin's route, nav
// node or profile-menu entry, so only the header nodes `config/menu.ts`'s `groups` builds get here
// without a gate, and failing closed would hide every section an operator grouped.
export function allows(gate: Gate, user: User | null): boolean { export function allows(gate: Gate, user: User | null): boolean {
if (gate.public === true) return true; if (gate.public === true) return true;
if (gate.session === true) return user !== null; if (gate.session === true) return user !== null;
if (gate.permission == null) return true; return gate.permission == null || (user?.permissions.includes(gate.permission) ?? false);
return user?.permissions.includes(gate.permission) ?? false;
} }
export function gatesSet(gate: Gate | null | undefined): string[] { export function gatesSet(gate: Gate | null | undefined): string[] {
+1 -2
View File
@@ -4,9 +4,8 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { createPrivateKey, sign } from "node:crypto"; import { createPrivateKey, sign, type JsonWebKey } from "node:crypto";
import { generateJwks, rotateJwks } from "./gen-jwks.ts"; import { generateJwks, rotateJwks } from "./gen-jwks.ts";
import type { JsonWebKey } from "./jwks.ts";
import { verifyJws } from "./jwt.ts"; import { verifyJws } from "./jwt.ts";
const b64url = (s: string) => Buffer.from(s).toString("base64url"); const b64url = (s: string) => Buffer.from(s).toString("base64url");
+7 -1
View File
@@ -3,7 +3,6 @@
// *through* us with. Built-in `fetch` only, no SDK dep (AGENTS.md); `fetchImpl`-injectable // *through* us with. Built-in `fetch` only, no SDK dep (AGENTS.md); `fetchImpl`-injectable
// like the kratos/keto clients. We authenticate the user (login) and grant scopes (consent); // like the kratos/keto clients. We authenticate the user (login) and grant scopes (consent);
// Hydra mints the tokens. // Hydra mints the tokens.
import { nextPageToken } from "./link-header.ts";
export interface OAuth2Client { export interface OAuth2Client {
client_id?: string; client_id?: string;
@@ -102,6 +101,13 @@ export interface HydraAdmin {
rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>; rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>;
} }
// Hydra paginates with a Link header; pull the page_token of rel="next" (the href is relative, so
// resolve against a throwaway base to read the query param). Mirrors kratos-admin's helper.
function nextPageToken(link: string | null): string | null {
const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1];
return href ? new URL(href, "http://hydra").searchParams.get("page_token") : null;
}
export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): HydraAdmin { export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): HydraAdmin {
const base = config.baseUrl.replace(/\/+$/, ""); const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch; const http = config.fetchImpl ?? fetch;
+2 -2
View File
@@ -1,9 +1,9 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { generateKeyPairSync } from "node:crypto"; import { generateKeyPairSync, type JsonWebKey } from "node:crypto";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { test } from "node:test"; import { test } from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { cachingJwks, createJwksProvider, loadJwks, staticJwks, type JsonWebKey } from "./jwks.ts"; import { cachingJwks, createJwksProvider, loadJwks, staticJwks } from "./jwks.ts";
const jwk = (kid: string): JsonWebKey => ({ ...(generateKeyPairSync("ec", { namedCurve: "P-256" }).publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid }); const jwk = (kid: string): JsonWebKey => ({ ...(generateKeyPairSync("ec", { namedCurve: "P-256" }).publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid });
const committed = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "ory/kratos/tokenizer/jwks.json"); const committed = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "ory/kratos/tokenizer/jwks.json");
+1 -5
View File
@@ -1,4 +1,4 @@
import type { webcrypto } from "node:crypto"; import type { JsonWebKey } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { currentLog } from "../logger.ts"; import { currentLog } from "../logger.ts";
@@ -8,10 +8,6 @@ import { currentLog } from "../logger.ts";
// (or re-reads a mounted file), caches for a TTL, and reloads once on a `kid` miss so a rotated- // (or re-reads a mounted file), caches for a TTL, and reloads once on a `kid` miss so a rotated-
// in key is picked up without a restart (README: zero-downtime rotation). `createJwksProvider` // in key is picked up without a restart (README: zero-downtime rotation). `createJwksProvider`
// picks the right one from the configured URL scheme and primes it at boot (fail loud). // picks the right one from the configured URL scheme and primes it at boot (fail loud).
// node:crypto exports the JWK type only under webcrypto since @types/node 26, and that type lacks
// the `kid` a JWKS selects a key by.
export type JsonWebKey = webcrypto.JsonWebKey & { kid?: string };
export interface JwksProvider { export interface JwksProvider {
getKey(kid: string | undefined): Promise<JsonWebKey | null>; getKey(kid: string | undefined): Promise<JsonWebKey | null>;
} }
+2 -2
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { generateKeyPairSync, sign, type KeyObject } from "node:crypto"; import { generateKeyPairSync, sign, type JsonWebKey, type KeyObject } from "node:crypto";
import { test } from "node:test"; import { test } from "node:test";
import { staticJwks, type JsonWebKey } from "./jwks.ts"; import { staticJwks } from "./jwks.ts";
import { authenticate, claimsToUser, resolveSession, verifyToken } from "./jwt-middleware.ts"; import { authenticate, claimsToUser, resolveSession, verifyToken } from "./jwt-middleware.ts";
import { SESSION_COOKIE } from "./login.ts"; import { SESSION_COOKIE } from "./login.ts";
+1 -2
View File
@@ -1,7 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { generateKeyPairSync, sign } from "node:crypto"; import { generateKeyPairSync, sign } from "node:crypto";
import type { KeyObject } from "node:crypto"; import type { JsonWebKey, KeyObject } from "node:crypto";
import type { JsonWebKey } from "./jwks.ts";
import { test } from "node:test"; import { test } from "node:test";
import { decodeJws, verifyJws } from "./jwt.ts"; import { decodeJws, verifyJws } from "./jwt.ts";
+3 -4
View File
@@ -1,11 +1,10 @@
import { createPublicKey, verify } from "node:crypto"; import { createPublicKey, verify } from "node:crypto";
import type { KeyObject } from "node:crypto"; import type { JsonWebKey, KeyObject } from "node:crypto";
import type { JsonWebKey } from "./jwks.ts";
// JWS signature verification with the Node stdlib — no `jose`/JWT dep: // JWS signature verification with the Node stdlib — no `jose`/JWT dep:
// `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures // `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures
// the Kratos tokenizer produces. Signature only: `jwt-middleware.ts` // the Kratos tokenizer produces (see AGENTS.md). Signature only — adds claim checks
// picks the key by `kid` and checks the claims. // (exp/iss/aud, clock skew), JWKS-by-`kid` fetch/cache/rotation, and `token` bounds.
// JOSE `alg` → Node verify parameters. ES* signatures are raw r‖s (IEEE P1363), not DER. // JOSE `alg` → Node verify parameters. ES* signatures are raw r‖s (IEEE P1363), not DER.
// Extend this map to widen support. Security invariant: never add `HS*`/`none` — this map // Extend this map to widen support. Security invariant: never add `HS*`/`none` — this map
-26
View File
@@ -104,29 +104,3 @@ test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 t
(e: unknown) => e instanceof KetoError && e.status === 404, (e: unknown) => e instanceof KetoError && e.status === 404,
); );
}); });
test("transactTuples PATCHes every insert and delete in one request (204 resolves; non-204 throws; empty sends nothing)", async () => {
const add = { namespace: "Permission", object: "a:read", relation: "granted", subject_id: USER };
const drop = { namespace: "Permission", object: "b:read", relation: "granted", subject_id: USER };
const { calls, fetchImpl } = recorder(() => res(204));
await keto(fetchImpl).transactTuples({ delete: [drop], insert: [add] });
assert.equal(calls.length, 1);
assert.equal(calls[0]!.method, "PATCH");
assert.equal(calls[0]!.url, `${WRITE}/admin/relation-tuples`);
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ action: "insert", relation_tuple: add }, { action: "delete", relation_tuple: drop }]);
await keto(fetchImpl).transactTuples({ delete: [], insert: [] });
assert.equal(calls.length, 1, "an empty change is not a request");
await assert.rejects(
keto((async () => res(400, { error: "bad" })) as typeof fetch).transactTuples({ delete: [], insert: [add] }),
(e: unknown) => e instanceof KetoError && e.status === 400,
);
});
test("trailing slashes on the read and write URLs are stripped before a path is appended", async () => {
const { calls, fetchImpl } = recorder((url) => (url.startsWith("http://r") ? res(200, {}) : res(204)));
const client = createKetoClient({ fetchImpl, readUrl: "http://r//", writeUrl: "http://w/" });
await client.listRelations();
await client.transactTuples({ delete: [], insert: [{ namespace: "Permission", object: "a:read", relation: "granted", subject_id: USER }] });
assert.match(calls[0]!.url, /^http:\/\/r\/relation-tuples\?/);
assert.equal(calls[1]!.url, "http://w/admin/relation-tuples");
});
+17 -38
View File
@@ -1,7 +1,7 @@
// Keto client: typed `fetch` wrappers over Ory Keto's relation-tuple APIs — // Keto client: typed `fetch` wrappers over Ory Keto's relation-tuple APIs —
// `check` a permission, `listRelations`/`expand` to inspect them (read API), `writeTuple`/ // `check` a permission, `listRelations`/`expand` to inspect them (read API), `writeTuple`/
// `deleteTuple`/`transactTuples` to grant/revoke them (write API). Built-in `fetch` only, no SDK dep // `deleteTuple` to grant/revoke them (write API). Built-in `fetch` only, no SDK dep (AGENTS.md);
// (AGENTS.md); `fetchImpl`-injectable like the kratos clients. Read/write split onto the two ports config.ts // `fetchImpl`-injectable like the kratos clients. Read/write split onto the two ports config.ts
// targets (ketoReadUrl 4466 / ketoWriteUrl 4467). // targets (ketoReadUrl 4466 / ketoWriteUrl 4467).
// A subject set: a relation on another object (e.g. Group:eng#members), resolved // A subject set: a relation on another object (e.g. Group:eng#members), resolved
@@ -32,7 +32,7 @@ export interface RelationList {
// Keto's expand tree: a node is a set operation (union/…) or a leaf. The resolved subject // Keto's expand tree: a node is a set operation (union/…) or a leaf. The resolved subject
// (subject_id xor subject_set) rides on `tuple`, not the node itself — verified against Keto // (subject_id xor subject_set) rides on `tuple`, not the node itself — verified against Keto
// v26.2.0. A `subject_set` node carries its members as `children`. // v26.2.0. A `subject_set` node carries its members as `children` ("effective access" view).
export interface ExpandTree { export interface ExpandTree {
children?: ExpandTree[]; children?: ExpandTree[];
tuple?: RelationTuple; tuple?: RelationTuple;
@@ -56,8 +56,6 @@ export interface KetoClient {
deleteTuple(tuple: RelationTuple): Promise<void>; deleteTuple(tuple: RelationTuple): Promise<void>;
expand(set: SubjectSet, opts?: { maxDepth?: number }): Promise<ExpandTree>; expand(set: SubjectSet, opts?: { maxDepth?: number }): Promise<ExpandTree>;
listRelations(query?: RelationQuery): Promise<RelationList>; listRelations(query?: RelationQuery): Promise<RelationList>;
// Inserts and deletes in one Keto transaction: all of them apply or none does.
transactTuples(changes: { delete: RelationTuple[]; insert: RelationTuple[] }): Promise<void>;
writeTuple(tuple: RelationTuple): Promise<void>; writeTuple(tuple: RelationTuple): Promise<void>;
} }
@@ -77,14 +75,16 @@ function tupleParams(t: Partial<RelationTuple>): URLSearchParams {
return p; return p;
} }
async function fail(action: string, res: Response): Promise<never> { export function createKetoClient(config: { fetchImpl?: typeof fetch; readUrl: string; writeUrl: string }): KetoClient {
throw new KetoError(`Keto ${action} failed (${res.status})`, res.status, await res.text()); const read = config.readUrl.replace(/\/+$/, "");
} const write = config.writeUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch;
const tuples = `${write}/admin/relation-tuples`;
type ReadApi = Pick<KetoClient, "check" | "expand" | "listRelations">; async function fail(action: string, res: Response): Promise<never> {
type WriteApi = Pick<KetoClient, "deleteTuple" | "transactTuples" | "writeTuple">; throw new KetoError(`Keto ${action} failed (${res.status})`, res.status, await res.text());
}
function readApi(http: typeof fetch, read: string): ReadApi {
return { return {
async check(tuple, opts = {}) { async check(tuple, opts = {}) {
const params = tupleParams(tuple); const params = tupleParams(tuple);
@@ -95,6 +95,11 @@ function readApi(http: typeof fetch, read: string): ReadApi {
return ((await res.json()) as { allowed?: boolean }).allowed === true; return ((await res.json()) as { allowed?: boolean }).allowed === true;
}, },
async deleteTuple(tuple) {
const res = await http(`${tuples}?${tupleParams(tuple)}`, { method: "DELETE" });
if (res.status !== 204) await fail("delete tuple", res);
},
async expand(set, opts = {}) { async expand(set, opts = {}) {
const params = tupleParams(set); const params = tupleParams(set);
if (opts.maxDepth !== undefined) params.set("max-depth", String(opts.maxDepth)); if (opts.maxDepth !== undefined) params.set("max-depth", String(opts.maxDepth));
@@ -112,37 +117,11 @@ function readApi(http: typeof fetch, read: string): ReadApi {
const body = (await res.json()) as { next_page_token?: string; relation_tuples?: RelationTuple[] }; const body = (await res.json()) as { next_page_token?: string; relation_tuples?: RelationTuple[] };
return { nextPageToken: body.next_page_token || null, tuples: body.relation_tuples ?? [] }; return { nextPageToken: body.next_page_token || null, tuples: body.relation_tuples ?? [] };
}, },
};
}
function writeApi(http: typeof fetch, write: string): WriteApi {
const tuples = `${write}/admin/relation-tuples`;
const json = { "content-type": "application/json" };
return {
async deleteTuple(tuple) {
const res = await http(`${tuples}?${tupleParams(tuple)}`, { method: "DELETE" });
if (res.status !== 204) await fail("delete tuple", res);
},
async transactTuples(changes) {
const patches = [
...changes.insert.map((relation_tuple) => ({ action: "insert", relation_tuple })),
...changes.delete.map((relation_tuple) => ({ action: "delete", relation_tuple })),
];
if (patches.length === 0) return;
const res = await http(tuples, { body: JSON.stringify(patches), headers: json, method: "PATCH" });
if (res.status !== 204) await fail("transact tuples", res);
},
// PUT is idempotent — re-asserting an existing tuple is a no-op grant. // PUT is idempotent — re-asserting an existing tuple is a no-op grant.
async writeTuple(tuple) { async writeTuple(tuple) {
const res = await http(tuples, { body: JSON.stringify(tuple), headers: json, method: "PUT" }); const res = await http(tuples, { body: JSON.stringify(tuple), headers: { "content-type": "application/json" }, method: "PUT" });
if (!res.ok) await fail("write tuple", res); if (!res.ok) await fail("write tuple", res);
}, },
}; };
} }
export function createKetoClient(config: { fetchImpl?: typeof fetch; readUrl: string; writeUrl: string }): KetoClient {
const http = config.fetchImpl ?? fetch;
return { ...readApi(http, config.readUrl.replace(/\/+$/, "")), ...writeApi(http, config.writeUrl.replace(/\/+$/, "")) };
}
+7 -1
View File
@@ -3,7 +3,6 @@
// completion projects Keto permissions into (README). Built-in `fetch` only, no SDK dep (AGENTS.md); // completion projects Keto permissions into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`). // `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
import { KratosError } from "./kratos-public.ts"; import { KratosError } from "./kratos-public.ts";
import { nextPageToken } from "./link-header.ts";
export interface Identity { export interface Identity {
id: string; id: string;
@@ -42,6 +41,13 @@ export interface KratosAdmin {
updateMetadataPublic(id: string, metadata: unknown): Promise<Identity>; updateMetadataPublic(id: string, metadata: unknown): Promise<Identity>;
} }
// Kratos paginates with a Link header; pull the page_token of rel="next" (the href is a
// relative path, so resolve it against a throwaway base just to read the query param).
function nextPageToken(link: string | null): string | null {
const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1];
return href ? new URL(href, "http://kratos").searchParams.get("page_token") : null;
}
export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosAdmin { export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosAdmin {
const base = config.baseUrl.replace(/\/+$/, ""); const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch; const http = config.fetchImpl ?? fetch;
-14
View File
@@ -1,14 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { nextPageToken } from "./link-header.ts";
test("nextPageToken reads the page_token of the rel=next entry among several", () => {
const link = `</admin/identities?page_size=2&page_token=NEXT>; rel="next",</admin/identities?page_size=2>; rel="first"`;
assert.equal(nextPageToken(link), "NEXT");
});
test("nextPageToken is null with no header, no next entry, or a next href without a page_token", () => {
assert.equal(nextPageToken(null), null);
assert.equal(nextPageToken(`</admin/clients?page_size=2>; rel="first"`), null);
assert.equal(nextPageToken(`</admin/clients?page_size=2>; rel="next"`), null);
});
-6
View File
@@ -1,6 +0,0 @@
// The `page_token` of a `Link` header's `rel="next"` entry (Kratos and Hydra paginate with it), or
// null when there is none. The href is relative, so it is resolved against a throwaway base.
export function nextPageToken(link: string | null): string | null {
const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1];
return href ? new URL(href, "http://link-header.invalid").searchParams.get("page_token") : null;
}
-1
View File
@@ -16,7 +16,6 @@ const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
deleteTuple: async () => {}, deleteTuple: async () => {},
expand: async () => ({ type: "leaf" }), expand: async () => ({ type: "leaf" }),
listRelations: async () => ({ nextPageToken: null, tuples: [] }), listRelations: async () => ({ nextPageToken: null, tuples: [] }),
transactTuples: async () => {},
writeTuple: async () => {}, writeTuple: async () => {},
...over, ...over,
}); });
+5 -5
View File
@@ -37,11 +37,11 @@ export interface CompletedLogin {
permissions: string[]; permissions: string[];
} }
// The coarse permissions a user holds — directly (`Permission:<name>#granted@user:<id>`) or transitively via a // The coarse permissions a user holds — directly (`Permission:<name>#members@user:<id>`) or transitively via a
// group granted the permission. Enumerates the defined permissions (the distinct objects in the Permission // group that is a member of the permission. Enumerates the defined permissions (the distinct objects in the Permission
// namespace) and asks Keto to check each grant, so a permission granted to a group reaches the JWT — // namespace) and asks Keto to resolve each membership, so a permission granted to a group reaches the JWT —
// matching the OPL model. At login/refresh only, never per request; permission count is small, so the // matching the OPL model and the admin "Effective access" view. At login/refresh only, never per
// per-permission checks are cheap and run in parallel. // request; permission count is small, so the per-permission checks are cheap and run in parallel.
export async function readPermissions(keto: KetoClient, userId: string): Promise<string[]> { export async function readPermissions(keto: KetoClient, userId: string): Promise<string[]> {
const subject_id = `user:${userId}`; const subject_id = `user:${userId}`;
const names = new Set<string>(); const names = new Set<string>();
+3 -4
View File
@@ -1,10 +1,10 @@
// The built-in auth/OAuth2 endpoints as named handlers on the host's internal route table // The built-in auth/OAuth2 endpoints as named handlers on the host's internal route table
// (src/http/builtin-route.ts): the themed Kratos self-service pages and their code submits, the Hydra OAuth2 // (src/http/builtin-routes.ts): the themed Kratos self-service pages and their code submits, the Hydra OAuth2
// login/consent/logout challenges, login completion, the first-party logout, and Kratos' // login/consent/logout challenges, login completion, the first-party logout, and Kratos'
// flow-error sink. buildAuthRoutes registers only what the wired clients support — a missing // flow-error sink. buildAuthRoutes registers only what the wired clients support — a missing
// client means the path simply doesn't exist (404). `view` results render the core views. // client means the path simply doesn't exist (404). `view` results render the core views.
import { readFormBody } from "../http/body.ts"; import { readFormBody } from "../http/body.ts";
import type { BuiltinRoute, RequestCsrf } from "../http/builtin-route.ts"; import type { BuiltinRoute, RequestCsrf } from "../http/builtin-routes.ts";
import type { RequestContext } from "../http/context.ts"; import type { RequestContext } from "../http/context.ts";
import { CSRF_FIELD } from "./csrf.ts"; import { CSRF_FIELD } from "./csrf.ts";
import { chosenLocale } from "../i18n/locale.ts"; import { chosenLocale } from "../i18n/locale.ts";
@@ -117,8 +117,7 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
// A code flow's POST (flow-view.ts → CODE_FLOWS). Trims the pasted one-time code, since Kratos // A code flow's POST (flow-view.ts → CODE_FLOWS). Trims the pasted one-time code, since Kratos
// refuses one with whitespace around it, then passes the form on to Kratos and sends the browser // refuses one with whitespace around it, then passes the form on to Kratos and sends the browser
// where Kratos redirects. Kratos' csrf_token field guards the POST, so the host's own CSRF token // where Kratos redirects. Kratos' csrf_token field guards the POST, so the host's own CSRF token
// isn't checked here. `submitFlow` throws only on a 5xx or an unreachable Kratos, so only // isn't checked here.
// `flowFailure`'s 503 branch can fire here.
function codeFlowSubmit(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] { function codeFlowSubmit(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] {
return async (ctx: RequestContext): Promise<RouteResult> => { return async (ctx: RequestContext): Promise<RouteResult> => {
const pathname = ctx.url.pathname; const pathname = ctx.url.pathname;
+1 -2
View File
@@ -110,8 +110,7 @@ test("a one-shot bootstrap seeds the stack before web starts", () => {
}); });
test("deps live above WORKDIR, so no mount creates a root-owned dir in the checkout", () => { test("deps live above WORKDIR, so no mount creates a root-owned dir in the checkout", () => {
// A volume at /app/node_modules would leave a root-owned dir in the checkout // A volume at /app/node_modules would leave a root-owned dir in the checkout (AGENTS.md).
// (docs/decisions.md → "Dependencies install to `/node_modules`, above `WORKDIR /app`").
const dockerfile = read("Dockerfile"); const dockerfile = read("Dockerfile");
// split() returns the whole file when the marker is missing, widening "before" to "anywhere". // split() returns the whole file when the marker is missing, widening "before" to "anywhere".
assert.ok(dockerfile.includes("WORKDIR /app"), "the app dir is /app"); assert.ok(dockerfile.includes("WORKDIR /app"), "the app dir is /app");
+1 -41
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { loadBootstrapConfig, loadConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "./config.ts"; import { loadConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "./config.ts";
// Explicit secure-secret enforcement (no environment sniffing): secrets are the only // Explicit secure-secret enforcement (no environment sniffing): secrets are the only
// thing a hardened deploy must supply. // thing a hardened deploy must supply.
@@ -63,39 +63,6 @@ test("the per-role connection ceiling defaults to 10 and rejects nonsense", () =
assert.throws(() => resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "ten" }), /positive integer/); assert.throws(() => resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "ten" }), /positive integer/);
}); });
test("the one-shot bootstrap config has dev defaults and demands no web secret, even when secure secrets are required", () => {
const c = loadBootstrapConfig({ REQUIRE_SECURE_SECRETS: "true" });
assert.deepEqual(c, {
adminEmail: "admin@plainpages.local",
adminPassword: "admin",
adminPermissions: undefined,
appUrl: "http://localhost:3000",
ketoReadUrl: "http://keto:4466",
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
jwksFile: "/etc/config/kratos/tokenizer/jwks.json",
logFormat: "text",
serviceName: undefined,
});
assert.equal(loadBootstrapConfig({ ADMIN_EMAIL: "", ADMIN_PASSWORD: "" }).adminEmail, "admin@plainpages.local");
const set = loadBootstrapConfig({
ADMIN_EMAIL: "a@b.c",
ADMIN_PASSWORD: "s3cret",
ADMIN_PERMISSIONS: "users:read",
APP_URL: "https://x.test",
JWKS_FILE: "/k.json",
KRATOS_ADMIN_URL: "http://k:1",
LOG_FORMAT: "json",
SERVICE_NAME: "boot",
});
assert.deepEqual(
[set.adminEmail, set.adminPassword, set.adminPermissions, set.appUrl, set.jwksFile, set.kratosAdminUrl, set.logFormat, set.serviceName],
["a@b.c", "s3cret", "users:read", "https://x.test", "/k.json", "http://k:1", "json", "boot"],
);
assert.throws(() => loadBootstrapConfig({ LOG_FORMAT: "JSON" }), /LOG_FORMAT/);
assert.throws(() => loadBootstrapConfig({ KETO_WRITE_URL: "not a url" }), /KETO_WRITE_URL/);
});
test("loads dev defaults when the environment is empty", () => { test("loads dev defaults when the environment is empty", () => {
const c = loadConfig({}); const c = loadConfig({});
assert.equal(c.port, 3000); assert.equal(c.port, 3000);
@@ -130,13 +97,6 @@ test("SERVICE_NAME is overridable so an implementer brands their own logs/traces
assert.equal(loadConfig({ SERVICE_NAME: "" }).serviceName, "plainpages"); // empty ⇒ default assert.equal(loadConfig({ SERVICE_NAME: "" }).serviceName, "plainpages"); // empty ⇒ default
}); });
test("TRUSTED_PROXIES: a comma-separated list of IPs/CIDRs, empty by default, a bad entry refuses the boot", () => {
assert.deepEqual(loadConfig({}).trustedProxies, []); // trust nobody's X-Forwarded-For unless told
assert.deepEqual(loadConfig({ TRUSTED_PROXIES: "" }).trustedProxies, []); // compose passes ${TRUSTED_PROXIES:-}
assert.deepEqual(loadConfig({ TRUSTED_PROXIES: "10.0.0.0/8, ::1" }).trustedProxies, ["10.0.0.0/8", "::1"]);
assert.throws(() => loadConfig({ TRUSTED_PROXIES: "10.0.0.1,proxy.internal" }), /TRUSTED_PROXIES/);
});
test("LOG_LEVEL/LOG_FORMAT/OTLP_PROTOCOL are validated enums; OTLP_ENDPOINT an optional URL", () => { test("LOG_LEVEL/LOG_FORMAT/OTLP_PROTOCOL are validated enums; OTLP_ENDPOINT an optional URL", () => {
assert.equal(loadConfig({ LOG_LEVEL: "debug" }).logLevel, "debug"); assert.equal(loadConfig({ LOG_LEVEL: "debug" }).logLevel, "debug");
assert.equal(loadConfig({ LOG_LEVEL: "none" }).logLevel, "none"); assert.equal(loadConfig({ LOG_LEVEL: "none" }).logLevel, "none");
+5 -49
View File
@@ -2,8 +2,6 @@
// secret, a bad URL or an out-of-range port throws here, never at request time. Every value has a // secret, a bad URL or an out-of-range port throws here, never at request time. Every value has a
// working dev default, so `docker compose up` runs with zero config. // working dev default, so `docker compose up` runs with zero config.
import { parseTrustedProxies } from "./http/request-fields.ts";
// Log verbosity, most→least severe; "none" silences everything (matches @larvit/log's levels). // Log verbosity, most→least severe; "none" silences everything (matches @larvit/log's levels).
export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const; export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const;
export type LogLevel = (typeof LOG_LEVELS)[number]; export type LogLevel = (typeof LOG_LEVELS)[number];
@@ -63,7 +61,6 @@ export interface Config {
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
secureCookies: boolean; secureCookies: boolean;
serviceName: string; // OTLP service.name — an implementer brands their own logs/traces serviceName: string; // OTLP service.name — an implementer brands their own logs/traces
trustedProxies: string[]; // IPs/CIDRs whose X-Forwarded-For names the caller; empty ⇒ the socket peer is the caller
} }
type Env = Record<string, string | undefined>; type Env = Record<string, string | undefined>;
@@ -152,49 +149,6 @@ function readPosInt(env: Env, key: string, devDefault: number): number {
return n; return n;
} }
function readTrustedProxies(env: Env): string[] {
const entries = (env["TRUSTED_PROXIES"] ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
parseTrustedProxies(entries); // throws on a bad entry; the app parses the list again
return entries;
}
function readKratosKetoUrls(env: Env) {
return {
ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"),
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
};
}
export interface BootstrapConfig {
adminEmail: string;
adminPassword: string;
adminPermissions: string | undefined; // comma-separated, validated where it is used
appUrl: string; // only printed in the first-run banner; unlike Config.appUrl, it has a default
ketoReadUrl: string;
ketoWriteUrl: string;
kratosAdminUrl: string;
jwksFile: string;
logFormat: Config["logFormat"];
serviceName: string | undefined;
}
// What the one-shot bootstrap service reads, apart from the plugin-storage variables, which
// `resolvePluginDb*` and `PLUGIN_DB_*` cover. Not `loadConfig`: that demands web's own secrets, which
// compose does not give the bootstrap service while it sets REQUIRE_SECURE_SECRETS there.
export function loadBootstrapConfig(env: Env): BootstrapConfig {
return {
adminEmail: env["ADMIN_EMAIL"] || "admin@plainpages.local",
adminPassword: env["ADMIN_PASSWORD"] || "admin",
adminPermissions: readOptional(env, "ADMIN_PERMISSIONS"),
appUrl: readOptionalUrl(env, "APP_URL") ?? "http://localhost:3000",
jwksFile: env["JWKS_FILE"] || "/etc/config/kratos/tokenizer/jwks.json",
logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"),
serviceName: readOptional(env, "SERVICE_NAME"),
...readKratosKetoUrls(env),
};
}
export function loadConfig(env: Env = process.env): Config { export function loadConfig(env: Env = process.env): Config {
const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false); const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false);
return { return {
@@ -218,9 +172,12 @@ export function loadConfig(env: Env = process.env): Config {
// exp/nbf leeway (s) for Kratos↔web clock drift; the auth E2E sets 0 to time tokens out fast. // exp/nbf leeway (s) for Kratos↔web clock drift; the auth E2E sets 0 to time tokens out fast.
jwtClockSkewSec: readNonNegInt(env, "JWT_CLOCK_SKEW_SEC", 60), jwtClockSkewSec: readNonNegInt(env, "JWT_CLOCK_SKEW_SEC", 60),
jwtIssuer: readOptional(env, "JWT_ISSUER"), jwtIssuer: readOptional(env, "JWT_ISSUER"),
...readKratosKetoUrls(env), ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"),
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"), kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
// Console-only unless OTLP_ENDPOINT names a collector. // observability. Console-only by default (clean clone). Setting OTLP_ENDPOINT to an
// OpenTelemetry Collector exports structured logs + per-request spans there (Loki/Tempo).
logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"), logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"),
logLevel: readEnum(env, "LOG_LEVEL", LOG_LEVELS, "info"), logLevel: readEnum(env, "LOG_LEVEL", LOG_LEVELS, "info"),
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5), oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
@@ -242,6 +199,5 @@ export function loadConfig(env: Env = process.env): Config {
// Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it. // Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it.
secureCookies: readBool(env, "SECURE_COOKIES", false), secureCookies: readBool(env, "SECURE_COOKIES", false),
serviceName: env["SERVICE_NAME"] || "plainpages", // OTLP service.name; empty ⇒ default serviceName: env["SERVICE_NAME"] || "plainpages", // OTLP service.name; empty ⇒ default
trustedProxies: readTrustedProxies(env),
}; };
} }
+5 -2
View File
@@ -13,14 +13,17 @@ const LIMITS = { lines: 50, complexity: 20 };
// Each function over a limit, at its measurement. A ceiling follows its function down, and a metric // Each function over a limit, at its measurement. A ceiling follows its function down, and a metric
// leaves the entry once it fits, so a function that shrank can't grow back. // leaves the entry once it fits, so a function that shrank can't grow back.
const grandfathered: Record<string, { lines?: number; complexity?: number }> = { const grandfathered: Record<string, { lines?: number; complexity?: number }> = {
"src/http/app.ts createApp": { lines: 133, complexity: 26 }, "src/http/app.ts createApp": { lines: 318, complexity: 28 },
"src/http/app.ts createApp > handleRequest": { lines: 167, complexity: 43 },
"src/auth/flow-view.ts buildFlowView": { complexity: 24 }, "src/auth/flow-view.ts buildFlowView": { complexity: 24 },
"src/auth/hydra-admin.ts createHydraAdmin": { lines: 87 }, "src/auth/hydra-admin.ts createHydraAdmin": { lines: 87 },
"src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 }, "src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 },
"src/auth/kratos-public.ts createKratosPublic": { lines: 60 }, "src/auth/kratos-public.ts createKratosPublic": { lines: 60 },
"src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 }, "src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 },
"src/plugin-host/discovery.ts shapeError": { complexity: 23 },
"src/config.ts loadConfig": { lines: 52 },
"release-tooling/dockerhub-overview.ts main": { lines: 57 }, "release-tooling/dockerhub-overview.ts main": { lines: 57 },
"examples/plugins/auth-admin/admin-users.ts buildUserFormModel": { complexity: 21 }, "examples/plugins/admin/admin-users.ts buildUserFormModel": { complexity: 21 },
}; };
interface AstNode { interface AstNode {
+165 -270
View File
@@ -11,18 +11,17 @@ import { staticJwks } from "../auth/jwks.ts";
import type { OAuth2Client } from "../auth/hydra-admin.ts"; import type { OAuth2Client } from "../auth/hydra-admin.ts";
import type { RelationTuple } from "../auth/keto-client.ts"; import type { RelationTuple } from "../auth/keto-client.ts";
import type { Identity } from "../auth/kratos-admin.ts"; import type { Identity } from "../auth/kratos-admin.ts";
import { KratosError } from "../auth/kratos-public.ts";
import { SESSION_COOKIE } from "../auth/login.ts"; import { SESSION_COOKIE } from "../auth/login.ts";
import type { Plugin } from "../plugin-host/plugin.ts"; import type { Plugin } from "../plugin-host/plugin.ts";
import adminManifest from "../../examples/plugins/auth-admin/plugin.ts"; import adminManifest from "../../examples/plugins/admin/plugin.ts";
import { createI18n } from "../i18n/runtime.ts"; import { createI18n } from "../i18n/runtime.ts";
import { loadI18n } from "../i18n/load.ts"; import { loadI18n } from "../i18n/load.ts";
import { ecJwk, fakeKeto, mintJwt, stubAdmin, stubHydra } from "../test-tooling/app-harness.ts"; import { ecJwk, fakeKeto, matchesTuple, mintJwt, stubAdmin, stubHydra } from "./app-harness.ts";
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on // The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
// ctx.system, views from examples/plugins — exactly as an operator would after copying it in. // ctx.system, views from examples/plugins — exactly as an operator would after copying it in.
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins"); const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
const adminPlugin: Plugin = { ...adminManifest, id: "auth-admin" }; const adminPlugin: Plugin = { ...adminManifest, id: "admin" };
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT + // Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field. // CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
@@ -73,22 +72,22 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
const denylist = createDenylist(); // a deactivate/delete should revoke the target's live tokens instantly const denylist = createDenylist(); // a deactivate/delete should revoke the target's live tokens instantly
const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin }); const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin });
await assertAdminGate(url, get, "/auth-admin/users"); await assertAdminGate(url, get, "/admin/users");
// Nav: the admin plugin's section composes into the one global menu, and each screen is filtered // Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
// by its own read permission — proving the drop-in nav fragment. A user holding only users:read // by its own read permission — proving the drop-in nav fragment. A user holding only users:read
// sees Users and nothing else; holding none of the three, composeNav drops the emptied header. // sees Users and nothing else; holding none of the three, composeNav drops the emptied header.
assert.match(await (await get("/dashboard")).text(), /href="\/auth-admin\/users"/); assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text(); const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
assert.match(usersOnlyNav, /href="\/auth-admin\/users"/); assert.match(usersOnlyNav, /href="\/admin\/users"/);
assert.doesNotMatch(usersOnlyNav, /href="\/auth-admin\/groups"/); assert.doesNotMatch(usersOnlyNav, /href="\/admin\/groups"/);
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/auth-admin\/users"/); assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
// The read/write split: users:read opens the list but is refused on every mutation, and the // The read/write split: users:read opens the list but is refused on every mutation, and the
// resources don't leak — a users holder is not a groups holder. // resources don't leak — a users holder is not a groups holder.
assert.equal((await get("/auth-admin/users", ["users:read"])).status, 200); assert.equal((await get("/admin/users", ["users:read"])).status, 200);
assert.equal((await get("/auth-admin/groups", ["users:read", "users:write"])).status, 403); assert.equal((await get("/admin/groups", ["users:read", "users:write"])).status, 403);
const readOnlyPost = await fetch(url + "/auth-admin/users", { const readOnlyPost = await fetch(url + "/admin/users", {
body: `_csrf=${token}&email=nope@example.com`, body: `_csrf=${token}&email=nope@example.com`,
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${SESSION_COOKIE}=${mintJwt({ email: "r@x", exp: Math.floor(Date.now() / 1000) + 600, permissions: ["users:read"], sub: "reader1" })}; ${CSRF_COOKIE}=${token}` }, headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${SESSION_COOKIE}=${mintJwt({ email: "r@x", exp: Math.floor(Date.now() / 1000) + 600, permissions: ["users:read"], sub: "reader1" })}; ${CSRF_COOKIE}=${token}` },
method: "POST", method: "POST",
@@ -98,44 +97,44 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false); assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false);
// List: the admin sees the rows + the "add" link; the status filter narrows server-side. // List: the admin sees the rows + the "add" link; the status filter narrows server-side.
const listHtml = await (await get("/auth-admin/users")).text(); const listHtml = await (await get("/admin/users")).text();
assert.match(listHtml, /ada@example\.com/); assert.match(listHtml, /ada@example\.com/);
assert.match(listHtml, /href="\/auth-admin\/users\/new"/); assert.match(listHtml, /href="\/admin\/users\/new"/);
assert.doesNotMatch(await (await get("/auth-admin/users?status=inactive")).text(), /ada@example\.com/); assert.doesNotMatch(await (await get("/admin/users?status=inactive")).text(), /ada@example\.com/);
// Create: the form renders; a valid post creates the identity and redirects to the list. // Create: the form renders; a valid post creates the identity and redirects to the list.
assert.match(await (await get("/auth-admin/users/new")).text(), /Create user/); assert.match(await (await get("/admin/users/new")).text(), /Create user/);
const created = await post("/auth-admin/users", `_csrf=${token}&email=grace%40example.com&first=Grace&last=Hopper&password=`); const created = await post("/admin/users", `_csrf=${token}&email=grace%40example.com&first=Grace&last=Hopper&password=`);
assert.equal(created.status, 303); assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/auth-admin/users"); assert.equal(created.headers.get("location"), "/admin/users");
assert.deepEqual(lastCreate?.traits, { email: "grace@example.com", name: { first: "Grace", last: "Hopper" } }); assert.deepEqual(lastCreate?.traits, { email: "grace@example.com", name: { first: "Grace", last: "Hopper" } });
// A create with no CSRF token is refused and creates nothing. // A create with no CSRF token is refused and creates nothing.
const before = store.length; const before = store.length;
assert.equal((await post("/auth-admin/users", "email=x%40y.z")).status, 403); assert.equal((await post("/admin/users", "email=x%40y.z")).status, 403);
assert.equal(store.length, before); assert.equal(store.length, before);
// CSRF also guards a POST to an *existing* target (the per-route :id handlers), not just the // CSRF also guards a POST to an *existing* target (the per-route :id handlers), not just the
// collection: a delete with no token is refused (403) and removes nothing. // collection: a delete with no token is refused (403) and removes nothing.
assert.equal((await post(`/auth-admin/users/${store[1]!.id}/delete`, "")).status, 403); assert.equal((await post(`/admin/users/${store[1]!.id}/delete`, "")).status, 403);
assert.ok(store.some((x) => x.id === store[1]!.id)); assert.ok(store.some((x) => x.id === store[1]!.id));
// Edit: email is read-only + prefilled; a post rewrites the name. // Edit: email is read-only + prefilled; a post rewrites the name.
const target = store[0]!; const target = store[0]!;
const editHtml = await (await get(`/auth-admin/users/${target.id}`)).text(); const editHtml = await (await get(`/admin/users/${target.id}`)).text();
assert.match(editHtml, /name="email"[^>]*readonly/); assert.match(editHtml, /name="email"[^>]*readonly/);
assert.match(editHtml, /value="ada@example\.com"/); assert.match(editHtml, /value="ada@example\.com"/);
const updated = await post(`/auth-admin/users/${target.id}`, `_csrf=${token}&first=Ada&last=King`); const updated = await post(`/admin/users/${target.id}`, `_csrf=${token}&first=Ada&last=King`);
assert.equal(updated.status, 303); assert.equal(updated.status, 303);
assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" }); assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" });
// Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once. // Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once.
await post(`/auth-admin/users/${target.id}/state`, `_csrf=${token}`); await post(`/admin/users/${target.id}/state`, `_csrf=${token}`);
assert.equal(target.state, "inactive"); assert.equal(target.state, "inactive");
assert.equal(denylist.isRevoked(target.id, 0), true); assert.equal(denylist.isRevoked(target.id, 0), true);
// Recovery: renders the edit page (200) showing the generated code, linked to the flow it was minted on. // Recovery: renders the edit page (200) showing the generated code, linked to the flow it was minted on.
const rec = await post(`/auth-admin/users/${target.id}/recovery`, `_csrf=${token}`); const rec = await post(`/admin/users/${target.id}/recovery`, `_csrf=${token}`);
assert.equal(rec.status, 200); assert.equal(rec.status, 200);
const recHtml = await rec.text(); const recHtml = await rec.text();
assert.match(recHtml, /Recovery code generated/); assert.match(recHtml, /Recovery code generated/);
@@ -143,22 +142,22 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
assert.match(recHtml, new RegExp(`<a href="http://kratos/self-service/recovery\\?flow=flow-${target.id}">`)); assert.match(recHtml, new RegExp(`<a href="http://kratos/self-service/recovery\\?flow=flow-${target.id}">`));
// Delete needs a deliberate confirm step (zero-JS): GET renders the interstitial, POST performs it. // Delete needs a deliberate confirm step (zero-JS): GET renders the interstitial, POST performs it.
const confirm = await (await get(`/auth-admin/users/${target.id}/delete`)).text(); const confirm = await (await get(`/admin/users/${target.id}/delete`)).text();
assert.match(confirm, /Cancel/); assert.match(confirm, /Cancel/);
assert.match(confirm, new RegExp(`action="/auth-admin/users/${target.id}/delete"`)); assert.match(confirm, new RegExp(`action="/admin/users/${target.id}/delete"`));
const del = await post(`/auth-admin/users/${target.id}/delete`, `_csrf=${token}`); const del = await post(`/admin/users/${target.id}/delete`, `_csrf=${token}`);
assert.equal(del.status, 303); assert.equal(del.status, 303);
assert.ok(!store.some((x) => x.id === target.id)); assert.ok(!store.some((x) => x.id === target.id));
// Self-protection: an admin can't delete or deactivate their own account (JWT sub = admin1). // Self-protection: an admin can't delete or deactivate their own account (JWT sub = admin1).
assert.equal((await post(`/auth-admin/users/admin1/delete`, `_csrf=${token}`)).status, 400); assert.equal((await post(`/admin/users/admin1/delete`, `_csrf=${token}`)).status, 400);
assert.ok(store.some((x) => x.id === "admin1")); assert.ok(store.some((x) => x.id === "admin1"));
assert.equal((await post(`/auth-admin/users/admin1/state`, `_csrf=${token}`)).status, 400); assert.equal((await post(`/admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
assert.equal(store.find((x) => x.id === "admin1")!.state, "active"); assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients. // Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients.
assert.equal((await get(`/auth-admin/users/${randomUUID()}`)).status, 404); assert.equal((await get(`/admin/users/${randomUUID()}`)).status, 404);
assert.equal((await get("/auth-admin/users/%ZZ")).status, 404); assert.equal((await get("/admin/users/%ZZ")).status, 404);
}); });
// Built-in Groups admin screen: gate + list/create/membership/delete over HTTP against a // Built-in Groups admin screen: gate + list/create/membership/delete over HTTP against a
@@ -175,62 +174,54 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin }); const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
await assertAdminGate(url, get, "/auth-admin/groups"); await assertAdminGate(url, get, "/admin/groups");
// List: the existing group shows + the "add" link. // List: the existing group shows + the "add" link.
const listHtml = await (await get("/auth-admin/groups")).text(); const listHtml = await (await get("/admin/groups")).text();
assert.match(listHtml, /href="\/auth-admin\/groups\/eng"/); assert.match(listHtml, /href="\/admin\/groups\/eng"/);
assert.match(listHtml, /href="\/auth-admin\/groups\/new"/); assert.match(listHtml, /href="\/admin\/groups\/new"/);
// Create: the form renders; a valid post writes the first-member tuple and redirects to the detail. // Create: the form renders; a valid post writes the first-member tuple and redirects to the detail.
assert.match(await (await get("/auth-admin/groups/new")).text(), /Create group/); assert.match(await (await get("/admin/groups/new")).text(), /Create group/);
const created = await post("/auth-admin/groups", `_csrf=${token}&name=design&member=user:${grace}`); const created = await post("/admin/groups", `_csrf=${token}&name=design&member=user:${grace}`);
assert.equal(created.status, 303); assert.equal(created.status, 303);
assert.equal(created.headers.get("location"), "/auth-admin/groups/design"); assert.equal(created.headers.get("location"), "/admin/groups/design");
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`)); assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`));
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written. // An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
const before = tuples.length; const before = tuples.length;
assert.equal((await post("/auth-admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400); assert.equal((await post("/admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400);
const duplicate = await post("/auth-admin/groups?locale=sv-SE", `_csrf=${token}&name=eng&member=user:${grace}`); assert.equal((await post("/admin/groups", `_csrf=${token}&name=eng&member=user:${grace}`)).status, 400); // already exists
assert.equal(duplicate.status, 400); assert.equal((await post("/admin/groups", `name=x&member=user:${grace}`)).status, 403);
assert.match(await duplicate.text(), /Det finns redan en grupp med det namnet\./);
assert.equal((await post("/auth-admin/groups", `name=x&member=user:${grace}`)).status, 403);
assert.equal(tuples.length, before); assert.equal(tuples.length, before);
// Detail: lists the current member by email. // Detail: lists the current member by email.
assert.match(await (await get("/auth-admin/groups/eng")).text(), /ada@example\.com/); assert.match(await (await get("/admin/groups/eng")).text(), /ada@example\.com/);
// Add a member, then remove it. // Add a member, then remove it.
await post("/auth-admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`); await post("/admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`);
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`)); assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
await post("/auth-admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`); await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`)); assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
// Give it a permission first, so the delete below has an orphan to avoid leaving behind. // Give it a permission first, so the delete below has an orphan to avoid leaving behind.
const permissionsBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text()); const permissionsBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${permissionsBaseline}&permission=users%3Aread`); await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${permissionsBaseline}&permission=users%3Aread`);
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng")); assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng"));
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list. // Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
assert.match(await (await get("/auth-admin/groups/eng/delete")).text(), /Cancel/); assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/);
const bystanders: RelationTuple[] = [ const del = await post("/admin/groups/eng/delete", `_csrf=${token}`);
{ namespace: "Group", object: "ops", relation: "members", subject_id: `user:${ada}` },
{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` },
];
tuples.push(...bystanders);
const del = await post("/auth-admin/groups/eng/delete", `_csrf=${token}`);
assert.equal(del.status, 303); assert.equal(del.status, 303);
assert.equal(del.headers.get("location"), "/auth-admin/groups"); assert.equal(del.headers.get("location"), "/admin/groups");
assert.ok(!tuples.some((tp) => tp.object === "eng")); assert.ok(!tuples.some((tp) => tp.object === "eng"));
// …and the permissions it held go with it. A Keto set exists only through its tuples, so an // …and the permissions it held go with it. A Keto set exists only through its tuples, so an
// orphaned grant would resurrect the moment someone re-created a group with the same name. // orphaned grant would resurrect the moment someone re-created a group with the same name.
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.subject_set?.object === "eng")); assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.subject_set?.object === "eng"));
assert.ok(bystanders.every((tp) => tuples.includes(tp)), "only the deleted group's own tuples go");
// An invalid group name in the path → 404; malformed %-encoding doesn't 500. // An invalid group name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/auth-admin/groups/Bad%20Name")).status, 404); assert.equal((await get("/admin/groups/Bad%20Name")).status, 404);
assert.equal((await get("/auth-admin/groups/%ZZ")).status, 404); assert.equal((await get("/admin/groups/%ZZ")).status, 404);
}); });
// Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions), // Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
@@ -245,7 +236,7 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin }); const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
// The user edit page renders one checkbox per declared permission, ticked where already held. // The user edit page renders one checkbox per declared permission, ticked where already held.
const edit = await (await get(`/auth-admin/users/${ada}`)).text(); const edit = await (await get(`/admin/users/${ada}`)).text();
for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) { for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) {
assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name); assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name);
} }
@@ -255,7 +246,7 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth. // Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
const baseline = baselineOf(edit); const baseline = baselineOf(edit);
const saved = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`); const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
assert.equal(saved.status, 303); assert.equal(saved.status, 303);
assert.deepEqual( assert.deepEqual(
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(), tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
@@ -265,55 +256,23 @@ test("admin permission grants: the picker offers the declared catalog, and a sav
// The redirect lands on a page that confirms the save, and shows the new state. // The redirect lands on a page that confirms the save, and shows the new state.
const landing = saved.headers.get("location") ?? ""; const landing = saved.headers.get("location") ?? "";
assert.match(landing, new RegExp(`^/auth-admin/users/${ada}\\?info-msg=permissions-saved$`)); assert.match(landing, new RegExp(`^/admin/users/${ada}\\?info-msg=permissions-saved$`));
const confirmed = await (await get(landing)).text(); const confirmed = await (await get(landing)).text();
assert.match(confirmed, /Permissions saved/); assert.match(confirmed, /Permissions saved/);
assert.match(confirmed, /value="users:write"[^>]*checked/); assert.match(confirmed, /value="users:write"[^>]*checked/);
// A crafted POST can't grant something no plugin declares. // A crafted POST can't grant something no plugin declares.
await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(confirmed)}&permission=users%3Awrite&permission=superuser%3Aall`); await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(confirmed)}&permission=users%3Awrite&permission=superuser%3Aall`);
assert.ok(!tuples.some((tp) => tp.object === "superuser:all")); assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
// The same picker on a group writes the group's subject_set, which Keto resolves transitively. // The same picker on a group writes the group's subject_set, which Keto resolves transitively.
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }); tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
const groupBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text()); const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
const groupSaved = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`); const groupSaved = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`);
assert.equal(groupSaved.headers.get("location"), "/auth-admin/groups/eng?info-msg=permissions-saved"); assert.equal(groupSaved.headers.get("location"), "/admin/groups/eng?info-msg=permissions-saved");
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng")); assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
}); });
// Either half of a change reaches the user's live sessions, not just a save that does both.
test("admin permission grants: a grant-only and a revoke-only user save each revoke live tokens", async (t) => {
const [grantee, revokee] = [randomUUID(), randomUUID()];
const identities: Identity[] = [{ id: grantee, traits: { email: "g@example.com" } }, { id: revokee, traits: { email: "r@example.com" } }];
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${revokee}` }];
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist();
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
const save = async (id: string, permissions: string) =>
post(`/auth-admin/users/${id}/permissions`, `_csrf=${token}&baseline=${baselineOf(await (await get(`/auth-admin/users/${id}`)).text())}${permissions}`);
await save(grantee, "&permission=users%3Aread");
assert.equal(denylist.isRevoked(grantee, 0), true);
await save(revokee, "");
assert.equal(denylist.isRevoked(revokee, 0), true);
assert.equal(tuples.filter((tp) => tp.subject_id === `user:${revokee}`).length, 0);
});
// Remove needs Keto, but not the revocation denylist.
test("admin permission grants: Remove answers 503 without Keto, and still deletes where no denylist is wired", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const noKeto = await adminHarness(t, { kratosAdmin });
assert.equal((await noKeto.post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${noKeto.token}&remove=old%3Awrite`)).status, 503);
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "old:write", relation: "granted", subject_id: `user:${ada}` }];
const noDenylist = await adminHarness(t, { keto: fakeKeto(tuples), kratosAdmin });
assert.equal((await noDenylist.post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${noDenylist.token}&remove=old%3Awrite`)).status, 303);
assert.equal(tuples.length, 0);
});
// Two admins open the same page; the second save would silently undo the first. // Two admins open the same page; the second save would silently undo the first.
test("admin permission grants: a save from a stale page is refused, and the page re-renders the current state", async (t) => { test("admin permission grants: a save from a stale page is refused, and the page re-renders the current state", async (t) => {
const ada = randomUUID(); const ada = randomUUID();
@@ -322,12 +281,12 @@ test("admin permission grants: a save from a stale page is refused, and the page
const keto = fakeKeto(tuples); const keto = fakeKeto(tuples);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin }); const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
const stale = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text()); const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text());
// Another admin grants users:write while this page is open. // Another admin grants users:write while this page is open.
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` }); tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
const before = JSON.stringify(tuples); const before = JSON.stringify(tuples);
const refused = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=groups%3Aread`); const refused = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=groups%3Aread`);
assert.equal(refused.status, 409); assert.equal(refused.status, 409);
const page = await refused.text(); const page = await refused.text();
assert.match(page, /changed while you had the page open/); assert.match(page, /changed while you had the page open/);
@@ -336,114 +295,48 @@ test("admin permission grants: a save from a stale page is refused, and the page
assert.notEqual(baselineOf(page), stale); assert.notEqual(baselineOf(page), stale);
// Saving from the re-rendered page goes through. // Saving from the re-rendered page goes through.
const retried = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(page)}&permission=users%3Aread&permission=users%3Awrite&permission=groups%3Aread`); const retried = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(page)}&permission=users%3Aread&permission=users%3Awrite&permission=groups%3Aread`);
assert.equal(retried.status, 303); assert.equal(retried.status, 303);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === `user:${ada}`)); assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === `user:${ada}`));
// A POST with no token at all (a crafted request, or a form rendered before this check existed) is stale too. // A POST with no token at all (a crafted request, or a form rendered before this check existed) is stale too.
assert.equal((await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Aread`)).status, 409); assert.equal((await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Aread`)).status, 409);
// Groups are checked the same way. // Groups are checked the same way.
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }); tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
const groupStale = baselineOf(await (await get("/auth-admin/groups/eng")).text()); const groupStale = baselineOf(await (await get("/admin/groups/eng")).text());
tuples.push({ namespace: "Permission", object: "groups:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } }); tuples.push({ namespace: "Permission", object: "groups:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
const groupRefused = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupStale}&permission=users%3Aread`); const groupRefused = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupStale}&permission=users%3Aread`);
assert.equal(groupRefused.status, 409); assert.equal(groupRefused.status, 409);
assert.match(await groupRefused.text(), /changed while you had the page open/); assert.match(await groupRefused.text(), /changed while you had the page open/);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the group keeps its grant"); assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the group keeps its grant");
}); });
test("admin permission grants: a Keto failure applies none of the changes and revokes no live token", async (t) => { // A Keto error midway leaves some changes applied; the save says so and still revokes the user's live tokens.
test("admin permission grants: a Keto failure midway reports a partial save and keeps what was applied", async (t) => {
const ada = randomUUID(); const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }]; const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }]; const tuples: RelationTuple[] = [];
const keto = fakeKeto(tuples, { transactTuples: async () => { throw new Error("keto unreachable"); } }); let writes = 0;
const keto = fakeKeto(tuples, {
writeTuple: async (tp) => {
if (++writes === 2) throw new Error("keto unreachable");
tuples.push(tp);
},
});
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist(); const denylist = createDenylist();
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin }); const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
const baseline = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text()); const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text());
const res = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Awrite`); const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread&permission=users%3Awrite`);
assert.equal(res.status, 500); assert.equal(res.status, 502);
assert.deepEqual(tuples.map((tp) => tp.object), ["users:read"]); const page = await res.text();
assert.equal(denylist.isRevoked(ada, 0), false); assert.match(page, /stopped part-way/);
assert.match(page, /value="groups:read"[^>]*checked/); // the first write landed, and the page shows what is stored
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }); assert.doesNotMatch(page, /value="users:write"[^>]*checked/);
const groupBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text()); assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
const groupRes = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`); assert.equal(denylist.isRevoked(ada, 0), true); // the half that was applied still revokes live tokens
assert.equal(groupRes.status, 500);
assert.equal(tuples.filter((tp) => tp.subject_set?.object === "eng").length, 0);
});
test("admin users: a refused self-deactivate or self-delete keeps the permissions section", async (t) => {
const identities: Identity[] = [{ id: "admin1", traits: { email: "admin@x" } }];
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto: fakeKeto(), kratosAdmin });
assert.notEqual(baselineOf(await (await get("/auth-admin/users/admin1")).text()), "", "the plain form carries the section");
const refusals: [Response, RegExp][] = [
[await post("/auth-admin/users/admin1/state", `_csrf=${token}`), /deactivate your own account/],
[await get("/auth-admin/users/admin1/delete"), /delete your own account/],
[await post("/auth-admin/users/admin1/delete", `_csrf=${token}`), /delete your own account/],
];
for (const [res, reason] of refusals) {
assert.equal(res.status, 400);
const html = await res.text();
assert.match(html, reason);
assert.notEqual(baselineOf(html), "");
}
});
test("admin users: a Kratos rejection of a save re-renders the form with its permissions, any other failure is a 500", async (t) => {
const identities: Identity[] = [{ id: "ada", traits: { email: "ada@example.com" } }];
let failure: Error = new KratosError("rejected", 400, "");
const kratosAdmin = stubAdmin({
getIdentity: async (id) => identities.find((i) => i.id === id) ?? null,
listIdentities: async () => ({ identities, nextPageToken: null }),
updateIdentity: async () => { throw failure; },
});
const { post, token } = await adminHarness(t, { keto: fakeKeto(), kratosAdmin });
const rejected = await post("/auth-admin/users/ada", `_csrf=${token}&first=Ada`);
assert.equal(rejected.status, 400);
assert.notEqual(baselineOf(await rejected.text()), "");
failure = new Error("kratos unreachable");
assert.equal((await post("/auth-admin/users/ada", `_csrf=${token}&first=Ada`)).status, 500);
});
test("admin group delete: a Keto failure leaves the group's grants and members in place", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` },
{ namespace: "Permission", object: "users:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } },
];
const keto = fakeKeto(tuples, { transactTuples: async () => { throw new Error("keto unreachable"); } });
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { post, token } = await adminHarness(t, { keto, kratosAdmin });
const res = await post("/auth-admin/groups/eng/delete", `_csrf=${token}`);
assert.equal(res.status, 500);
assert.equal(tuples.length, 2);
});
test("admin group delete: removes the group from every parent, so re-creating the name restores no nesting", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const member = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Group", object, relation: "members", ...subject } as RelationTuple);
const inEng = { subject_set: { namespace: "Group", object: "eng", relation: "members" } };
const tuples: RelationTuple[] = [
member("eng", { subject_id: `user:${ada}` }),
member("org", { subject_id: `user:${ada}` }),
member("org", inEng),
member("staff", inEng),
member("staff", { subject_id: "user:other" }),
];
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { post, token } = await adminHarness(t, { keto: fakeKeto(tuples), kratosAdmin });
assert.equal((await post("/auth-admin/groups/eng/delete", `_csrf=${token}`)).status, 303);
assert.deepEqual(tuples, [member("org", { subject_id: `user:${ada}` }), member("staff", { subject_id: "user:other" })]);
}); });
// A double-clicked Save posts twice from one page; the second post finds the first one's result. // A double-clicked Save posts twice from one page; the second post finds the first one's result.
@@ -455,62 +348,64 @@ test("admin permission grants: a save that changes nothing is not refused for a
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist(); const denylist = createDenylist();
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin }); const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
const stale = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text()); const stale = baselineOf(await (await get(`/admin/users/${ada}`)).text());
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` }); tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
const before = JSON.stringify(tuples); const before = JSON.stringify(tuples);
const repeated = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=users%3Awrite`); const repeated = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=users%3Awrite`);
assert.equal(repeated.status, 303); assert.equal(repeated.status, 303);
assert.equal(JSON.stringify(tuples), before); assert.equal(JSON.stringify(tuples), before);
assert.equal(denylist.isRevoked(ada, 0), false); assert.equal(denylist.isRevoked(ada, 0), false);
}); });
test("admin permission grants: a grant no plugin declares is listed with a Remove, which deletes it and revokes live tokens", async (t) => { // Keto answered the writes but not the re-read: the report must still reach the admin.
test("admin permission grants: a partial save is still reported when Keto cannot be read again", async (t) => {
const ada = randomUUID(); const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }]; const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const held = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Permission", object, relation: "granted", ...subject }); const tuples: RelationTuple[] = [];
const eng = { subject_set: { namespace: "Group", object: "eng", relation: "members" } }; let writes = 0;
const tuples: RelationTuple[] = [ let down = false;
held("users:read", { subject_id: `user:${ada}` }), held("old:write", { subject_id: `user:${ada}` }), let failureTakesKetoDown = true;
held("old:write", eng), held("old:write", { subject_id: "user:someone-else" }), const keto = fakeKeto(tuples, {
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }, listRelations: async (q = {}) => {
]; if (down) throw new Error("keto unreachable");
return { nextPageToken: null, tuples: tuples.filter((tp) => matchesTuple(tp, q)) };
},
writeTuple: async (tp) => {
if (++writes === 2) { down = failureTakesKetoDown; throw new Error("keto unreachable"); }
tuples.push(tp);
},
});
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist(); const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin }); const baseline = baselineOf(await (await get(`/admin/users/${ada}`)).text());
const page = await (await get(`/auth-admin/users/${ada}`)).text(); const res = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Aread`);
assert.match(page, /Permissions no installed plugin declares/); assert.equal(res.status, 502);
assert.match(page, new RegExp(`action="/auth-admin/users/${ada}/permissions/remove#permissions-h"`)); assert.match(await res.text(), /stopped part-way/);
assert.match(page, /name="remove" value="old:write"/); assert.deepEqual(tuples.map((tp) => tp.object), ["groups:read"]);
assert.doesNotMatch(page, /name="remove" value="users:read"/, "a declared name has no Remove");
const readerPage = await (await get(`/auth-admin/users/${ada}`, ["users:read"])).text();
assert.match(readerPage, /old:write/);
assert.doesNotMatch(readerPage, /permissions\/remove/);
const declared = await post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${token}&remove=users%3Aread`); // The same on a group, whose page needs Keto for its members as well.
assert.equal(declared.status, 303); down = false;
assert.equal(tuples.length, 5); writes = 0;
assert.equal(denylist.isRevoked(ada, 0), false); tuples.length = 0;
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
const groupBaseline = baselineOf(await (await get("/admin/groups/eng")).text());
const groupRes = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread&permission=users%3Aread`);
assert.equal(groupRes.status, 502);
assert.match(await groupRes.text(), /stopped part-way/);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the first write landed");
const removed = await post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${token}&remove=old%3Awrite`); // With Keto still answering, the group page itself reports it and shows what is stored.
assert.equal(removed.status, 303); failureTakesKetoDown = false;
assert.equal(removed.headers.get("location"), `/auth-admin/users/${ada}?info-msg=permission-removed`); down = false;
assert.match(await (await get(removed.headers.get("location")!)).text(), /Permission removed\./); writes = 0;
assert.equal(tuples.length, 4); tuples.length = 1;
assert.ok(!tuples.some((tp) => tp.object === "old:write" && tp.subject_id === `user:${ada}`)); const again = await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baselineOf(await (await get("/admin/groups/eng")).text())}&permission=groups%3Aread&permission=users%3Aread`);
assert.ok(tuples.some((tp) => tp.object === "old:write" && tp.subject_set?.object === "eng"), "the group's grant is untouched"); assert.equal(again.status, 502);
assert.equal(denylist.isRevoked(ada, 0), true); const page = await again.text();
assert.equal((await post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${token}&remove=old%3Awrite`)).status, 303, "a second click is harmless"); assert.match(page, /stopped part-way/);
assert.equal((await post(`/auth-admin/users/${ada}/permissions/remove`, "remove=old%3Awrite")).status, 403, "CSRF-guarded"); assert.match(page, /value="groups:read"[^>]*checked/);
const groupPage = await (await get("/auth-admin/groups/eng")).text();
assert.match(groupPage, /action="\/auth-admin\/groups\/eng\/permissions\/remove#permissions-h"/);
const groupRemoved = await post("/auth-admin/groups/eng/permissions/remove", `_csrf=${token}&remove=old%3Awrite`);
assert.equal(groupRemoved.status, 303);
assert.equal(groupRemoved.headers.get("location"), "/auth-admin/groups/eng?info-msg=permission-removed");
assert.ok(!tuples.some((tp) => tp.object === "old:write" && tp.subject_set?.object === "eng"));
assert.ok(tuples.some((tp) => tp.object === "old:write" && tp.subject_id === "user:someone-else"), "another subject's grant is untouched");
}); });
// Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke // Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
@@ -522,15 +417,15 @@ test("admin permission grants: you can't revoke your own permissions, but you ca
const keto = fakeKeto(tuples); const keto = fakeKeto(tuples);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin }); const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
const baseline = baselineOf(await (await get("/auth-admin/users/admin1")).text()); const baseline = baselineOf(await (await get("/admin/users/admin1")).text());
const refused = await post("/auth-admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}`); // every box cleared const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}`); // every box cleared
assert.equal(refused.status, 400); assert.equal(refused.status, 400);
assert.match(await refused.text(), /lock yourself out/); assert.match(await refused.text(), /lock yourself out/);
assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked"); assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
// Granting yourself more is not a lockout, so it goes through. // Granting yourself more is not a lockout, so it goes through.
const granted = await post("/auth-admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`); const granted = await post("/admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
assert.equal(granted.status, 303); assert.equal(granted.status, 303);
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1")); assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
}); });
@@ -567,32 +462,32 @@ test("admin groups: you can't take away permissions you hold only through a grou
const keto = fakeKeto(tuples, { check: resolvingCheck(tuples) }); const keto = fakeKeto(tuples, { check: resolvingCheck(tuples) });
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) }); const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin }, you); const { get, post, token } = await adminHarness(t, { keto, kratosAdmin }, you);
const baseline = baselineOf(await (await get("/auth-admin/groups/eng")).text()); const baseline = baselineOf(await (await get("/admin/groups/eng")).text());
// A refusal renders that group's own page and names what you would lose. // A refusal renders that group's own page and names what you would lose.
const refusedWithMessage = async (res: Response, group: string, lost: string) => { const refusedWithMessage = async (res: Response, group: string, lost: string) => {
assert.equal(res.status, 400); assert.equal(res.status, 400);
const html = await res.text(); const html = await res.text();
assert.match(html, new RegExp(`lock you out of ${lost}, which`)); assert.match(html, new RegExp(`lock you out of ${lost}, which`));
assert.match(html, new RegExp(`action="/auth-admin/groups/${group}/members"`)); assert.match(html, new RegExp(`action="/admin/groups/${group}/members"`));
}; };
// Unticking users:write on eng would take it from you: refused, nothing revoked. // Unticking users:write on eng would take it from you: refused, nothing revoked.
const before = JSON.stringify(tuples); const before = JSON.stringify(tuples);
await refusedWithMessage(await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=groups%3Awrite`), "eng", "users:write"); await refusedWithMessage(await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=groups%3Awrite`), "eng", "users:write");
// Leaving eng, removing eng from leads, or deleting eng (the confirm step too) are refused the same // Leaving eng, removing eng from leads, or deleting eng (the confirm step too) are refused the same
// way; leaving eng also loses what it gets from leads. // way; leaving eng also loses what it gets from leads.
await refusedWithMessage(await post("/auth-admin/groups/eng/members/delete", `_csrf=${token}&member=user:${you}`), "eng", "oauth2-clients:write, users:write"); await refusedWithMessage(await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${you}`), "eng", "oauth2-clients:write, users:write");
await refusedWithMessage(await post("/auth-admin/groups/leads/members/delete", `_csrf=${token}&member=group:eng`), "leads", "oauth2-clients:write"); await refusedWithMessage(await post("/admin/groups/leads/members/delete", `_csrf=${token}&member=group:eng`), "leads", "oauth2-clients:write");
await refusedWithMessage(await get("/auth-admin/groups/eng/delete"), "eng", "oauth2-clients:write, users:write"); await refusedWithMessage(await get("/admin/groups/eng/delete"), "eng", "oauth2-clients:write, users:write");
await refusedWithMessage(await post("/auth-admin/groups/eng/delete", `_csrf=${token}`), "eng", "oauth2-clients:write, users:write"); await refusedWithMessage(await post("/admin/groups/eng/delete", `_csrf=${token}`), "eng", "oauth2-clients:write, users:write");
assert.equal(JSON.stringify(tuples), before, "nothing changed"); assert.equal(JSON.stringify(tuples), before, "nothing changed");
// You hold groups:write directly too, so unticking it on eng costs you nothing. // You hold groups:write directly too, so unticking it on eng costs you nothing.
assert.equal((await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite`)).status, 303); assert.equal((await post("/admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite`)).status, 303);
// Removing someone else, leaving a group that confers nothing, and deleting a group you are not in all go through. // Removing someone else, leaving a group that confers nothing, and deleting a group you are not in all go through.
assert.equal((await post("/auth-admin/groups/eng/members/delete", `_csrf=${token}&member=user:${ada}`)).status, 303); assert.equal((await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${ada}`)).status, 303);
assert.equal((await post("/auth-admin/groups/social/members/delete", `_csrf=${token}&member=user:${you}`)).status, 303); assert.equal((await post("/admin/groups/social/members/delete", `_csrf=${token}&member=user:${you}`)).status, 303);
assert.equal((await post("/auth-admin/groups/design/delete", `_csrf=${token}`)).status, 303); assert.equal((await post("/admin/groups/design/delete", `_csrf=${token}`)).status, 303);
assert.ok(!tuples.some((tp) => tp.object === "design" || tp.subject_set?.object === "design")); assert.ok(!tuples.some((tp) => tp.object === "design" || tp.subject_set?.object === "design"));
}); });
@@ -610,43 +505,43 @@ test("admin screens render no write affordance for a read-only holder", async (t
const { get } = await adminHarness(t, { hydra, keto, kratosAdmin }); const { get } = await adminHarness(t, { hydra, keto, kratosAdmin });
const readOnly = ["users:read", "groups:read"]; const readOnly = ["users:read", "groups:read"];
const list = await (await get("/auth-admin/users", readOnly)).text(); const list = await (await get("/admin/users", readOnly)).text();
assert.doesNotMatch(list, /href="\/auth-admin\/users\/new"/); // no "New user" assert.doesNotMatch(list, /href="\/admin\/users\/new"/); // no "New user"
assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read
// (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.) // (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.)
const detail = await (await get(`/auth-admin/users/${ada}`, readOnly)).text(); const detail = await (await get(`/admin/users/${ada}`, readOnly)).text();
assert.doesNotMatch(detail, /Save changes/); assert.doesNotMatch(detail, /Save changes/);
assert.doesNotMatch(detail, /Generate recovery code/); assert.doesNotMatch(detail, /Generate recovery code/);
assert.doesNotMatch(detail, /Delete user/); assert.doesNotMatch(detail, /Delete user/);
assert.doesNotMatch(detail, /Save permissions/); assert.doesNotMatch(detail, /Save permissions/);
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
const group = await (await get("/auth-admin/groups/eng", readOnly)).text(); const group = await (await get("/admin/groups/eng", readOnly)).text();
assert.doesNotMatch(group, /Add a member/); assert.doesNotMatch(group, /Add a member/);
assert.doesNotMatch(group, /Delete group/); assert.doesNotMatch(group, /Delete group/);
assert.doesNotMatch(group, /Save permissions/); assert.doesNotMatch(group, /Save permissions/);
// The OAuth2-clients screen is held to the same rule. // The OAuth2-clients screen is held to the same rule.
const clientsRes = await get("/auth-admin/clients", ["oauth2-clients:read"]); const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503 assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
const clients = await clientsRes.text(); const clients = await clientsRes.text();
assert.match(clients, /Reporting/); // the list is there — that's what :read buys assert.match(clients, /Reporting/); // the list is there — that's what :read buys
assert.doesNotMatch(clients, /href="\/auth-admin\/clients\/new"/); assert.doesNotMatch(clients, /href="\/admin\/clients\/new"/);
// The detail page is where Delete lives, so check it too and not just the list. // The detail page is where Delete lives, so check it too and not just the list.
const clientDetail = await (await get("/auth-admin/clients/existing", ["oauth2-clients:read"])).text(); const clientDetail = await (await get("/admin/clients/existing", ["oauth2-clients:read"])).text();
assert.match(clientDetail, /Reporting/); assert.match(clientDetail, /Reporting/);
assert.doesNotMatch(clientDetail, /clients\/existing\/delete/); assert.doesNotMatch(clientDetail, /clients\/existing\/delete/);
// A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than // A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than
// rendering a form whose submit would 403. // rendering a form whose submit would 403.
for (const path of ["/auth-admin/users/new", "/auth-admin/groups/new", `/auth-admin/users/${ada}/delete`, "/auth-admin/groups/eng/delete"]) { for (const path of ["/admin/users/new", "/admin/groups/new", `/admin/users/${ada}/delete`, "/admin/groups/eng/delete"]) {
assert.equal((await get(path, readOnly)).status, 403, path); assert.equal((await get(path, readOnly)).status, 403, path);
} }
assert.equal((await get("/auth-admin/clients/new", ["oauth2-clients:read"])).status, 403); assert.equal((await get("/admin/clients/new", ["oauth2-clients:read"])).status, 403);
// A writer sees the affordances the reader didn't. // A writer sees the affordances the reader didn't.
const writable = await (await get(`/auth-admin/users/${ada}`, ["users:read", "users:write"])).text(); const writable = await (await get(`/admin/users/${ada}`, ["users:read", "users:write"])).text();
assert.match(writable, /Save changes/); assert.match(writable, /Save changes/);
assert.match(writable, /Save permissions/); assert.match(writable, /Save permissions/);
}); });
@@ -666,20 +561,20 @@ test("admin OAuth2 clients screen: gate, list, register (one-time secret), detai
}); });
const { get, post, token, url } = await adminHarness(t, { hydra }); const { get, post, token, url } = await adminHarness(t, { hydra });
await assertAdminGate(url, get, "/auth-admin/clients"); await assertAdminGate(url, get, "/admin/clients");
// List: the existing client shows + the "register" link. // List: the existing client shows + the "register" link.
const listHtml = await (await get("/auth-admin/clients")).text(); const listHtml = await (await get("/admin/clients")).text();
assert.match(listHtml, /href="\/auth-admin\/clients\/existing"/); assert.match(listHtml, /href="\/admin\/clients\/existing"/);
assert.match(listHtml, /href="\/auth-admin\/clients\/new"/); assert.match(listHtml, /href="\/admin\/clients\/new"/);
assert.match(listHtml, /Reporting/); assert.match(listHtml, /Reporting/);
// Register: the form renders (with confidential-vs-public guidance); a valid post creates the // Register: the form renders (with confidential-vs-public guidance); a valid post creates the
// client and shows the one-time secret + id. // client and shows the one-time secret + id.
const formHtml = await (await get("/auth-admin/clients/new")).text(); const formHtml = await (await get("/admin/clients/new")).text();
assert.match(formHtml, /Register client/); assert.match(formHtml, /Register client/);
assert.match(formHtml, /keep a secret/i); // guidance on the public-vs-confidential choice (apostrophes arrive escaped: t() text goes through <%= %>) assert.match(formHtml, /keep a secret/i); // guidance on the public-vs-confidential choice (apostrophes arrive escaped: t() text goes through <%= %>)
const created = await post("/auth-admin/clients", `_csrf=${token}&name=Grafana&redirectUris=${encodeURIComponent("https://graf/cb")}&scope=openid+offline_access`); const created = await post("/admin/clients", `_csrf=${token}&name=Grafana&redirectUris=${encodeURIComponent("https://graf/cb")}&scope=openid+offline_access`);
assert.equal(created.status, 200); // not a redirect — the secret is shown once assert.equal(created.status, 200); // not a redirect — the secret is shown once
const createdHtml = await created.text(); const createdHtml = await created.text();
assert.match(createdHtml, /Client registered/); assert.match(createdHtml, /Client registered/);
@@ -689,26 +584,26 @@ test("admin OAuth2 clients screen: gate, list, register (one-time secret), detai
// Invalid input (missing redirect URI) and a missing CSRF token are both refused, nothing created. // Invalid input (missing redirect URI) and a missing CSRF token are both refused, nothing created.
const before = store.length; const before = store.length;
assert.equal((await post("/auth-admin/clients", `_csrf=${token}&name=NoRedirect&redirectUris=`)).status, 400); assert.equal((await post("/admin/clients", `_csrf=${token}&name=NoRedirect&redirectUris=`)).status, 400);
assert.equal((await post("/auth-admin/clients", `name=x&redirectUris=${encodeURIComponent("https://x/cb")}`)).status, 403); assert.equal((await post("/admin/clients", `name=x&redirectUris=${encodeURIComponent("https://x/cb")}`)).status, 403);
assert.equal(store.length, before); assert.equal(store.length, before);
// Detail: read-only info, never the secret again, a delete control. // Detail: read-only info, never the secret again, a delete control.
const detail = await (await get("/auth-admin/clients/existing")).text(); const detail = await (await get("/admin/clients/existing")).text();
assert.match(detail, /reporting\.example\/cb/); assert.match(detail, /reporting\.example\/cb/);
assert.doesNotMatch(detail, /Client secret/i); // the secret is shown only once, at creation assert.doesNotMatch(detail, /Client secret/i); // the secret is shown only once, at creation
assert.match(detail, /delete and re-register/i); // no edit: the lifecycle guidance is surfaced assert.match(detail, /delete and re-register/i); // no edit: the lifecycle guidance is surfaced
assert.match(detail, /href="\/auth-admin\/clients\/existing\/delete"/); assert.match(detail, /href="\/admin\/clients\/existing\/delete"/);
// Delete: a confirm step (GET) then the POST removes the client, back to the list. // Delete: a confirm step (GET) then the POST removes the client, back to the list.
assert.match(await (await get("/auth-admin/clients/existing/delete")).text(), /Cancel/); assert.match(await (await get("/admin/clients/existing/delete")).text(), /Cancel/);
const del = await post("/auth-admin/clients/existing/delete", `_csrf=${token}`); const del = await post("/admin/clients/existing/delete", `_csrf=${token}`);
assert.equal(del.status, 303); assert.equal(del.status, 303);
assert.equal(del.headers.get("location"), "/auth-admin/clients"); assert.equal(del.headers.get("location"), "/admin/clients");
assert.ok(!store.some((c) => c.client_id === "existing")); assert.ok(!store.some((c) => c.client_id === "existing"));
// Unknown id → 404; malformed %-encoding doesn't 500. // Unknown id → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/auth-admin/clients/does-not-exist")).status, 404); assert.equal((await get("/admin/clients/does-not-exist")).status, 404);
assert.equal((await get("/auth-admin/clients/%ZZ")).status, 404); assert.equal((await get("/admin/clients/%ZZ")).status, 404);
}); });
+1 -1
View File
@@ -10,7 +10,7 @@ import { KratosError, type Flow, type FlowSubmission, type FlowType, type Kratos
import { SESSION_COOKIE } from "../auth/login.ts"; import { SESSION_COOKIE } from "../auth/login.ts";
import { createI18n } from "../i18n/runtime.ts"; import { createI18n } from "../i18n/runtime.ts";
import { loadI18n } from "../i18n/load.ts"; import { loadI18n } from "../i18n/load.ts";
import { ecJwk, fakeKeto, mintJwt, mockKratos, OAUTH_SUBJECT, stubAdmin, stubHydra, withWhoami } from "../test-tooling/app-harness.ts"; import { ecJwk, fakeKeto, mintJwt, mockKratos, OAUTH_SUBJECT, stubAdmin, stubHydra, withWhoami } from "./app-harness.ts";
// A re-rendered login flow: csrf hidden, themed fields, a submit, and a failed-attempt message. // A re-rendered login flow: csrf hidden, themed fields, a submit, and a failed-attempt message.
const node = (attrs: Record<string, unknown>, label?: string): UiNode => ({ attributes: attrs, group: "default", messages: [], meta: label ? { label: { id: 1, text: label, type: "info" } } : {}, type: "input" }); const node = (attrs: Record<string, unknown>, label?: string): UiNode => ({ attributes: attrs, group: "default", messages: [], meta: label ? { label: { id: 1, text: label, type: "info" } } : {}, type: "input" });
@@ -1,13 +1,12 @@
import { generateKeyPairSync, sign } from "node:crypto"; import { generateKeyPairSync, sign, type JsonWebKey } from "node:crypto";
import type { JsonWebKey } from "../auth/jwks.ts";
import type { AddressInfo } from "node:net"; import type { AddressInfo } from "node:net";
import type { TestContext } from "node:test"; import type { TestContext } from "node:test";
import { createApp } from "./app.ts";
import type { HydraAdmin } from "../auth/hydra-admin.ts"; import type { HydraAdmin } from "../auth/hydra-admin.ts";
import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts"; import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
import type { KratosAdmin } from "../auth/kratos-admin.ts"; import type { KratosAdmin } from "../auth/kratos-admin.ts";
import type { FlowType, KratosPublic } from "../auth/kratos-public.ts"; import type { FlowType, KratosPublic } from "../auth/kratos-public.ts";
import { SESSION_COOKIE } from "../auth/login.ts"; import { SESSION_COOKIE } from "../auth/login.ts";
import { createApp } from "../http/app.ts";
import type { Plugin } from "../plugin-host/plugin.ts"; import type { Plugin } from "../plugin-host/plugin.ts";
// A session JWT signed with a throwaway test key; `staticJwks([ecJwk])` is the matching verify side. // A session JWT signed with a throwaway test key; `staticJwks([ecJwk])` is the matching verify side.
@@ -58,19 +57,14 @@ export const matchesTuple = (t: RelationTuple, f: Partial<RelationTuple>): boole
(f.subject_id === undefined || t.subject_id === f.subject_id) && (f.subject_id === undefined || t.subject_id === f.subject_id) &&
(f.subject_set === undefined || sameSet(t.subject_set, f.subject_set)); (f.subject_set === undefined || sameSet(t.subject_set, f.subject_set));
// A stateful in-memory KetoClient over a tuple array (writes mutate it); used by login + the admin screens. // A stateful in-memory KetoClient over a tuple array (writes mutate it); used by login + the admin screens.
export const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}): KetoClient => { export const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}): KetoClient => ({
const remove = (f: Partial<RelationTuple>): void => { for (let i = tuples.length - 1; i >= 0; i--) if (matchesTuple(tuples[i]!, f)) tuples.splice(i, 1); }; check: async () => false,
const add = (tp: RelationTuple): void => { if (!tuples.some((t) => matchesTuple(t, tp) && sameSet(t.subject_set, tp.subject_set))) tuples.push(tp); }; deleteTuple: async (f) => { for (let i = tuples.length - 1; i >= 0; i--) if (matchesTuple(tuples[i]!, f)) tuples.splice(i, 1); },
return { expand: async () => ({ type: "leaf" }),
check: async () => false, listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((t) => matchesTuple(t, q)) }),
deleteTuple: async (f) => remove(f), writeTuple: async (tp) => { if (!tuples.some((t) => matchesTuple(t, tp) && sameSet(t.subject_set, tp.subject_set))) tuples.push(tp); },
expand: async () => ({ type: "leaf" }), ...over,
listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((t) => matchesTuple(t, q)) }), });
transactTuples: async (c) => { c.insert.forEach(add); c.delete.forEach(remove); },
writeTuple: async (tp) => add(tp),
...over,
};
};
export const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami }); export const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
// OAuth2 login challenge: another app logs in *through* us; Hydra hands the browser here. // OAuth2 login challenge: another app logs in *through* us; Hydra hands the browser here.
+3 -17
View File
@@ -11,7 +11,7 @@ import type { Plugin } from "../plugin-host/plugin.ts";
import { createI18n } from "../i18n/runtime.ts"; import { createI18n } from "../i18n/runtime.ts";
import type { MenuConfig } from "../ui/menu-config.ts"; import type { MenuConfig } from "../ui/menu-config.ts";
import { loadI18n } from "../i18n/load.ts"; import { loadI18n } from "../i18n/load.ts";
import { ecJwk, mintJwt } from "../test-tooling/app-harness.ts"; import { ecJwk, mintJwt } from "./app-harness.ts";
// ---- language (i18n) ---- // ---- language (i18n) ----
@@ -78,15 +78,12 @@ test("a plugin that owns a landing page, or short-circuits a hook, translates fr
mkdirSync(join(dir, "demo", "i18n"), { recursive: true }); mkdirSync(join(dir, "demo", "i18n"), { recursive: true });
t.after(() => rmSync(dir, { force: true, recursive: true })); t.after(() => rmSync(dir, { force: true, recursive: true }));
writeFileSync(join(dir, "demo", "i18n", "en-US.ts"), 'const m = { "demo.hello": "Hello from the plugin" };\nexport default m;\n'); writeFileSync(join(dir, "demo", "i18n", "en-US.ts"), 'const m = { "demo.hello": "Hello from the plugin" };\nexport default m;\n');
mkdirSync(join(dir, "demo", "views"), { recursive: true });
writeFileSync(join(dir, "demo", "views", "landing.ejs"), '<%= t("demo.hello") %>');
// Every plugin-owned render path: the public landing, the gated dashboard, and a hook short-circuit. // Every plugin-owned render path: the public landing, the gated dashboard, and a hook short-circuit.
// The landings return a view, so the host's choice of views and context shows in the output.
const demo: Plugin = { const demo: Plugin = {
apiVersion: "1.0.0", apiVersion: "1.0.0",
dashboard: () => ({ view: "landing" }), dashboard: (ctx) => ({ html: ctx.t("demo.hello") }),
home: () => ({ view: "landing" }), home: (ctx) => ({ html: ctx.t("demo.hello") }),
hooks: { onRequest: (ctx) => (ctx.url.pathname === "/hooked" ? { html: ctx.t("demo.hello") } : undefined) }, hooks: { onRequest: (ctx) => (ctx.url.pathname === "/hooked" ? { html: ctx.t("demo.hello") } : undefined) },
id: "demo", id: "demo",
}; };
@@ -103,17 +100,6 @@ test("a plugin that owns a landing page, or short-circuits a hook, translates fr
assert.equal(await (await fetch(`${url}/dashboard`, { headers: { cookie } })).text(), "Hello from the plugin"); assert.equal(await (await fetch(`${url}/dashboard`, { headers: { cookie } })).text(), "Hello from the plugin");
}); });
test("a redirect from a plugin-owned landing keeps the visitor's chosen locale", async (t) => {
const demo: Plugin = { apiVersion: "1.0.0", home: () => ({ redirect: "/demo/next" }), id: "demo" };
const app = createApp({ i18n: createI18n(await loadI18n()), plugins: [demo] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const res = await fetch(`${url}/?locale=sv-SE`, { redirect: "manual" });
assert.equal(res.status, 303);
assert.equal(res.headers.get("location"), "/demo/next?locale=sv-SE");
});
test("an error page renders without composing the menu — it exists for when the shell's data is what failed", async (t) => { test("an error page renders without composing the menu — it exists for when the shell's data is what failed", async (t) => {
// The chrome getter is lazy on purpose; a render that reads no chrome must not trigger it, or a // The chrome getter is lazy on purpose; a render that reads no chrome must not trigger it, or a
// broken menu takes the error pages down with it. // broken menu takes the error pages down with it.
+4 -93
View File
@@ -13,9 +13,8 @@ import { staticJwks } from "../auth/jwks.ts";
import type { Identity } from "../auth/kratos-admin.ts"; import type { Identity } from "../auth/kratos-admin.ts";
import { KratosError, type Session } from "../auth/kratos-public.ts"; import { KratosError, type Session } from "../auth/kratos-public.ts";
import { SESSION_COOKIE } from "../auth/login.ts"; import { SESSION_COOKIE } from "../auth/login.ts";
import { createLogger } from "../logger.ts";
import type { Plugin } from "../plugin-host/plugin.ts"; import type { Plugin } from "../plugin-host/plugin.ts";
import { ecJwk, fakeKeto, mintJwt, session, startApp, stubAdmin, withWhoami } from "../test-tooling/app-harness.ts"; import { ecJwk, fakeKeto, mintJwt, startApp, stubAdmin, withWhoami } from "./app-harness.ts";
// A test plugin exercising each RouteResult shape, a path param, and the permission gate. // A test plugin exercising each RouteResult shape, a path param, and the permission gate.
const demoPlugin: Plugin = { const demoPlugin: Plugin = {
@@ -225,48 +224,6 @@ test("session re-mint: an expired JWT backed by a live Kratos session is silentl
assert.equal(outage.headers.get("set-cookie"), null); assert.equal(outage.headers.get("set-cookie"), null);
}); });
test("session re-mint runs only for an expired token with all three Ory clients wired; the new cookie is Secure under SECURE_COOKIES", async (t) => {
const identity: Identity = { id: "u1", traits: { email: "a@b.c" } };
const nowSec = Math.floor(Date.now() / 1000);
const freshJwt = mintJwt({ email: "a@b.c", exp: nowSec + 600, permissions: ["demo:read"], sub: "u1" });
let whoamiCalls = 0;
const kratos = withWhoami(async (o) => { whoamiCalls++; return (o?.tokenizeAs ? { active: true, identity, tokenized: freshJwt } : { active: true, identity }) as Session; });
const keto = fakeKeto([{ namespace: "Permission", object: "demo:read", relation: "granted", subject_id: "user:u1" }], { check: async () => true });
const kratosAdmin = stubAdmin({});
const logged: string[] = [];
const log = createLogger({ format: "json", level: "info", stderr: (l) => logged.push(l), stdout: (l) => logged.push(l) });
const valid = `${SESSION_COOKIE}=${freshJwt}`;
const expired = `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, permissions: ["demo:read"], sub: "u1" })}`;
const get = async (options: Parameters<typeof createApp>[0], cookie: string): Promise<Response> => {
const app = createApp({ jwks: staticJwks([ecJwk]), log, plugins: [demoPlugin], ...options });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/demo/secret`, { headers: { cookie }, redirect: "manual" });
};
const sessionCookieOf = (res: Response): string | undefined => res.headers.getSetCookie().find((c) => c.startsWith(`${SESSION_COOKIE}=`));
// A valid token, or one that is invalid but not expired, never reaches Ory.
const ok = await get({ keto, kratos, kratosAdmin }, valid);
assert.equal(ok.status, 200);
assert.equal(sessionCookieOf(ok), undefined);
assert.equal((await get({ keto, kratos, kratosAdmin }, `${SESSION_COOKIE}=not-a-jwt`)).status, 303);
assert.equal(whoamiCalls, 0);
// An expired token with any one of the three clients missing stays anonymous, without trying.
for (const wired of [{ kratos, kratosAdmin }, { keto, kratosAdmin }, { keto, kratos }]) {
const res = await get(wired, expired);
assert.equal(res.status, 303, Object.keys(wired).join("+"));
assert.equal(sessionCookieOf(res), undefined);
}
assert.equal(whoamiCalls, 0);
assert.ok(!logged.some((l) => l.includes("session re-mint failed")), "no re-mint was attempted");
// All three wired on an https deployment: the re-minted cookie carries Secure.
const reminted = await get({ keto, kratos, kratosAdmin, secureCookies: true }, expired);
assert.equal(reminted.status, 200);
assert.match(sessionCookieOf(reminted) ?? "", /; Secure(;|$)/);
});
test("guards map to responses: requireSession → /login, a failed can/check → 403, success runs the handler", async (t) => { test("guards map to responses: requireSession → /login, a failed can/check → 403, success runs the handler", async (t) => {
const keto = { check: async (tuple: { object: string }) => tuple.object === "open" } as unknown as Parameters<typeof check>[0]; const keto = { check: async (tuple: { object: string }) => tuple.object === "open" } as unknown as Parameters<typeof check>[0];
const guarded: Plugin = { const guarded: Plugin = {
@@ -328,65 +285,19 @@ test("plugin hooks: onRequest can short-circuit a request and onResponse observe
onResponse: (c, r) => void seen.push(`${c.url.pathname}:${r && "html" in r ? r.html : "?"}`), onResponse: (c, r) => void seen.push(`${c.url.pathname}:${r && "html" in r ? r.html : "?"}`),
}, },
id: "hooked", id: "hooked",
routes: [ routes: [{ handler: () => ({ html: "handler ran" }), method: "GET", path: "/ok" }],
{ handler: () => ({ html: "handler ran" }), method: "GET", path: "/ok" },
{ handler: () => { seen.push("blocked handler ran"); return { html: "behind the hook" }; }, method: "GET", path: "/blocked" },
],
}; };
const url = await startApp(t, [hooked]); const url = await startApp(t, [hooked]);
// onRequest short-circuits before routing — the route behind it never runs; a fresh CSRF cookie // onRequest short-circuits before routing — handler never runs; a fresh CSRF cookie still rides the
// still rides the response so a form the hook renders has its matching double-submit cookie. // response so a form the hook renders has its matching double-submit cookie.
const blocked = await fetch(url + "/hooked/blocked"); const blocked = await fetch(url + "/hooked/blocked");
assert.equal(blocked.status, 403); assert.equal(blocked.status, 403);
assert.match(await blocked.text(), /blocked by hook/); assert.match(await blocked.text(), /blocked by hook/);
assert.match(blocked.headers.get("set-cookie") ?? "", /plainpages_csrf=/); assert.match(blocked.headers.get("set-cookie") ?? "", /plainpages_csrf=/);
// Again with that cookie, so no Set-Cookie is due once the response is sent.
await (await fetch(url + "/hooked/blocked", { headers: { cookie: blocked.headers.get("set-cookie")?.split(";")[0] ?? "" } })).text();
assert.ok(!seen.includes("blocked handler ran"));
// A normal route runs the handler; onResponse observed its result. // A normal route runs the handler; onResponse observed its result.
assert.match(await (await fetch(url + "/hooked/ok")).text(), /handler ran/); assert.match(await (await fetch(url + "/hooked/ok")).text(), /handler ran/);
assert.ok(seen.includes("/hooked/ok:handler ran")); assert.ok(seen.includes("/hooked/ok:handler ran"));
}); });
test("plugin hooks: onResponse observes the result of a plugin-owned `/` and `/dashboard`", async (t) => {
const seen: string[] = [];
const owner: Plugin = {
apiVersion: "1.0.0",
dashboard: () => ({ html: "owned dashboard" }),
home: () => ({ html: "owned home" }),
id: "owner",
};
const observer: Plugin = {
apiVersion: "1.0.0",
hooks: { onResponse: (c, r) => void seen.push(`${c.url.pathname}:${r && "html" in r ? r.html : "?"}`) },
id: "observer",
};
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [owner, observer] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
assert.match(await (await fetch(url + "/")).text(), /owned home/);
assert.match(await (await fetch(url + "/dashboard", { headers: { cookie: session() } })).text(), /owned dashboard/);
assert.deepEqual(seen, ["/:owned home", "/dashboard:owned dashboard"]);
});
test("plugin hooks: the request routes by the method and path it arrived with, whatever onRequest does to req", async (t) => {
const rewriter: Plugin = {
apiVersion: "1.0.0",
hooks: { onRequest: (c) => { c.req.method = "POST"; c.req.url = "/rewriter/other"; } },
id: "rewriter",
routes: [
{ handler: () => ({ html: "original route" }), method: "GET", path: "/original" },
{ handler: () => ({ html: "rewritten route" }), method: "POST", path: "/other" },
],
};
const url = await startApp(t, [rewriter]);
const res = await fetch(url + "/rewriter/original");
assert.equal(res.status, 200);
assert.match(await res.text(), /original route/);
});
-90
View File
@@ -1,90 +0,0 @@
import assert from "node:assert/strict";
import type { AddressInfo } from "node:net";
import { test, type TestContext } from "node:test";
import { createLogger } from "../logger.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import { createApp } from "./app.ts";
// The `request` line, parsed, once the server has logged it (it logs on the response's close).
async function requestLineOf(lines: string[]): Promise<Record<string, unknown>> {
for (let i = 0; i < 200; i++) {
const line = lines.map((l) => JSON.parse(l) as Record<string, unknown>).find((rec) => rec["message"] === "request" || rec["msg"] === "request");
if (line) return line;
await new Promise((r) => setTimeout(r, 10));
}
throw new Error("no request line was logged");
}
// `lines` is stdout (info); `problems` is stderr (warn and error).
async function serve(t: TestContext, trustedProxies: string[], plugins: Plugin[] = []): Promise<{ lines: string[]; problems: string[]; url: string }> {
const lines: string[] = [];
const problems: string[] = [];
const log = createLogger({ format: "json", level: "info", stderr: (l: string) => problems.push(l), stdout: (l: string) => lines.push(l) });
const app = createApp({ log, plugins, trustedProxies });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
return { lines, problems, url: `http://localhost:${(app.address() as AddressInfo).port}` };
}
test("the request line names the caller, the site and the matched route in OpenTelemetry's terms", async (t) => {
const { lines, url } = await serve(t, []);
await (await fetch(`${url}/?token=secret`, { headers: { "user-agent": "TV/1.0", "x-forwarded-for": "198.51.100.7" } })).text();
const line = await requestLineOf(lines);
assert.equal(line["http.request.method"], "GET");
assert.equal(line["url.path"], "/"); // never the query
assert.equal(line["http.response.status_code"], 200);
assert.equal(line["http.route"], "/");
assert.equal(line["server.address"], "localhost");
assert.equal(line["user_agent.original"], "TV/1.0");
assert.match(String(line["client.address"]), /^(127\.0\.0\.1|::1)$/); // the forged header is not believed
assert.equal(line["service.name"], "plainpages");
assert.ok(line["requestId"], "carries a requestId for log↔trace correlation");
for (const old of ["method", "path", "status", "ms"]) assert.equal(line[old], undefined, old);
});
test("http.route is the full pattern, plugin prefix included, so two plugins' routes never merge", async (t) => {
const plugin: Plugin = {
apiVersion: "1.0.0",
id: "demo",
routes: [{ handler: () => ({ json: {} }), method: "GET", path: "/items/:id", public: true }],
};
const { lines, url } = await serve(t, [], [plugin]);
await (await fetch(`${url}/demo/items/7`)).text();
const line = await requestLineOf(lines);
assert.equal(line["http.route"], "/demo/items/:id");
assert.equal(line["url.path"], "/demo/items/7");
});
test("a listed proxy lets X-Forwarded-For name the caller; an unmatched path carries no route", async (t) => {
const { lines, url } = await serve(t, ["127.0.0.0/8", "::1"]);
await (await fetch(`${url}/no-such-page`, { headers: { "x-forwarded-for": "198.51.100.7" } })).text();
const line = await requestLineOf(lines);
assert.equal(line["client.address"], "198.51.100.7");
assert.equal(line["http.response.status_code"], 404);
assert.equal(line["http.route"], undefined);
});
test("an answered request goes no further: a refused visitor never reaches the handler, and nothing logs a problem", async (t) => {
const ran: string[] = [];
const plugin: Plugin = {
apiVersion: "1.0.0",
id: "demo",
routes: [
{ handler: () => ({ html: "open" }), method: "GET", path: "/open", public: true },
{ handler: () => { ran.push("gated"); return { html: "gated" }; }, method: "GET", path: "/gated", permission: "demo:read" },
],
};
const { lines, problems, url } = await serve(t, [], [plugin]);
// A plugin route, a refused plugin route, a built-in route, a refused built-in route, an asset.
const paths = ["/demo/open", "/demo/gated", "/", "/dashboard", "/public/css/styles.css"];
for (const path of paths) await (await fetch(url + path, { redirect: "manual" })).text();
// A request that fell through to a later stage would log its error after its response closed.
while (lines.filter((l) => l.includes('"request"')).length < paths.length) await new Promise((r) => setTimeout(r, 10));
await new Promise((r) => setTimeout(r, 100));
assert.deepEqual(ran, []);
assert.deepEqual(problems, []);
});
+33 -78
View File
@@ -2,7 +2,7 @@ import assert from "node:assert/strict";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { cpSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { request as httpRequest } from "node:http"; import { request as httpRequest } from "node:http";
import { type AddressInfo, connect } from "node:net"; import type { AddressInfo } from "node:net";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { after, before, test } from "node:test"; import { after, before, test } from "node:test";
@@ -14,7 +14,7 @@ import { CSRF_COOKIE } from "../auth/csrf.ts";
import { staticJwks } from "../auth/jwks.ts"; import { staticJwks } from "../auth/jwks.ts";
import type { Plugin } from "../plugin-host/plugin.ts"; import type { Plugin } from "../plugin-host/plugin.ts";
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts"; import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
import { ecJwk, session, startApp } from "../test-tooling/app-harness.ts"; import { ecJwk, session, startApp } from "./app-harness.ts";
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views"); const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
@@ -115,6 +115,31 @@ test("renders branding from the menu config into the shell: logo + default theme
assert.match(html, /id="theme-dark"\s+checked/); // config default theme reaches the switch assert.match(html, /id="theme-dark"\s+checked/); // config default theme reaches the switch
}); });
test("emits a structured access-log line per request (the injected logger)", async (t) => {
const lines: string[] = [];
const app = createApp({ log: createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (m) => lines.push(m) }) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/?q=zz`); // the public "/" — no auth
assert.equal(res.status, 200);
await res.text(); // consume the body so the connection closes (the access line emits on close)
// The line is emitted on connection close (after the body is sent) — poll briefly for it.
let line: string | undefined;
for (let i = 0; i < 50 && !line; i++) {
line = lines.find((l) => l.includes('"msg":"request"'));
if (!line) await new Promise((r) => setTimeout(r, 10));
}
assert.ok(line, "an access line is logged for the request");
const rec = JSON.parse(line!);
assert.equal(rec.method, "GET");
assert.equal(rec.path, "/"); // pathname only — the ?q=… query is dropped (may carry tokens)
assert.equal(rec.status, 200);
assert.equal(rec["service.name"], "plainpages");
assert.equal(typeof rec.ms, "number");
assert.ok(rec.requestId, "carries a requestId for log↔trace correlation");
});
test("ctx.log: a handler logs in the request trace, and ctx.log.fetch continues the inbound trace", async (t) => { test("ctx.log: a handler logs in the request trace, and ctx.log.fetch continues the inbound trace", async (t) => {
const lines: string[] = []; const lines: string[] = [];
const upstream: { traceparent: string | undefined; url: string }[] = []; const upstream: { traceparent: string | undefined; url: string }[] = [];
@@ -169,19 +194,14 @@ test("ctx.log after a client abort doesn't throw: the request log is ended only
// deferred until the handler settles (regression for the abort race). // deferred until the handler settles (regression for the abort race).
let afterCloseOk = false; let afterCloseOk = false;
let afterCloseErr: string | undefined; let afterCloseErr: string | undefined;
const started = Promise.withResolvers<void>();
const closed = Promise.withResolvers<void>();
const logged = Promise.withResolvers<void>();
const plugin: Plugin = { const plugin: Plugin = {
apiVersion: "1.0.0", apiVersion: "1.0.0",
id: "slow", id: "slow",
routes: [{ routes: [{
handler: async (ctx) => { handler: async (ctx) => {
started.resolve(); await new Promise((r) => setTimeout(r, 120)); // outlasts the client abort below
await closed.promise;
try { ctx.log.info("after abort", {}); afterCloseOk = true; } // would throw if end() already ran try { ctx.log.info("after abort", {}); afterCloseOk = true; } // would throw if end() already ran
catch (e) { afterCloseErr = String(e); } catch (e) { afterCloseErr = String(e); }
logged.resolve();
return { json: { ok: true } }; return { json: { ok: true } };
}, },
method: "GET", method: "GET",
@@ -189,20 +209,16 @@ test("ctx.log after a client abort doesn't throw: the request log is ended only
}], }],
}; };
const app = createApp({ log: createLogger({ level: "none" }), plugins: [plugin] }); const app = createApp({ log: createLogger({ level: "none" }), plugins: [plugin] });
// Added after the host's request listener, so this "close" listener fires after the host's.
app.once("request", (_req, res) => res.on("close", () => closed.resolve()));
await new Promise<void>((r) => app.listen(0, r)); await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close()); t.after(() => app.close());
const base = `http://localhost:${(app.address() as AddressInfo).port}`; const base = `http://localhost:${(app.address() as AddressInfo).port}`;
// Abort the request mid-handler. // Abort the request mid-handler (well before the 120ms), forcing res "close" while it still runs.
const ac = new AbortController(); const ac = new AbortController();
const aborted = assert.rejects(fetch(base + "/slow/go", { signal: ac.signal })); // the client sees the abort setTimeout(() => ac.abort(), 20);
await started.promise; await assert.rejects(fetch(base + "/slow/go", { signal: ac.signal })); // the client sees the abort
ac.abort();
await aborted;
await logged.promise; await new Promise((r) => setTimeout(r, 200)); // let the handler finish post-abort
assert.equal(afterCloseErr, undefined, "ctx.log did not throw after the client disconnected"); assert.equal(afterCloseErr, undefined, "ctx.log did not throw after the client disconnected");
assert.ok(afterCloseOk, "the handler logged successfully after close"); assert.ok(afterCloseOk, "the handler logged successfully after close");
@@ -223,9 +239,6 @@ test("static serving: GET sends body + content-type, HEAD headers only, unsafe p
// Encoded traversal and a NUL byte are refused before touching the filesystem. // Encoded traversal and a NUL byte are refused before touching the filesystem.
assert.equal((await fetch(base + "/public/..%2f..%2fapp.ts")).status, 403); assert.equal((await fetch(base + "/public/..%2f..%2fapp.ts")).status, 403);
assert.equal((await fetch(base + "/public/%00")).status, 403); assert.equal((await fetch(base + "/public/%00")).status, 403);
// Only GET/HEAD read an asset; any other method is routed like a page, and nothing answers it.
assert.equal((await fetch(base + "/public/css/styles.css", { method: "POST" })).status, 404);
}); });
test("every response carries the security headers; HSTS follows SECURE_COOKIES", async (t) => { test("every response carries the security headers; HSTS follows SECURE_COOKIES", async (t) => {
@@ -247,20 +260,6 @@ test("every response carries the security headers; HSTS follows SECURE_COOKIES",
assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/); assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/);
}); });
test("the CSRF cookie is set only when the request carries no valid token, and is Secure under SECURE_COOKIES", async (t) => {
const first = await fetch(base + "/");
const csrf = first.headers.getSetCookie().find((c) => c.startsWith(`${CSRF_COOKIE}=`)) ?? "";
assert.ok(csrf, "a first visit mints the token");
assert.doesNotMatch(csrf, /; Secure/); // http dev
assert.deepEqual((await fetch(base + "/", { headers: { cookie: csrf.split(";")[0] ?? "" } })).headers.getSetCookie(), []);
const secure = createApp({ secureCookies: true });
await new Promise<void>((r) => secure.listen(0, r));
t.after(() => secure.close());
const res = await fetch(`http://localhost:${(secure.address() as AddressInfo).port}/`);
assert.match(res.headers.getSetCookie().find((c) => c.startsWith(`${CSRF_COOKIE}=`)) ?? "", /; Secure(;|$)/);
});
test("Cache-Control: pages and JSON are private, no-store unless the route sets its own", async (t) => { test("Cache-Control: pages and JSON are private, no-store unless the route sets its own", async (t) => {
const NO_STORE = "private, no-store"; const NO_STORE = "private, no-store";
const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? ""; const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? "";
@@ -331,18 +330,6 @@ function rawGet(port: number, path: string, host: string, method = "GET"): Promi
}); });
} }
// An HTTP/1.0 request with no Host header (Node refuses a Host-less HTTP/1.1 one with a 400);
// resolves to the raw response head.
function rawHttp10(port: number, path: string): Promise<string> {
return new Promise((resolve, reject) => {
let raw = "";
const socket = connect(port, "127.0.0.1", () => socket.write(`GET ${path} HTTP/1.0\r\n\r\n`));
socket.on("data", (c) => (raw += c));
socket.on("end", () => resolve(raw));
socket.on("error", reject);
});
}
test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the configured origin (path+query kept)", async (t) => { test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the configured origin (path+query kept)", async (t) => {
// Reach the app on any host and it sends you to APP_URL's, so the browser, the themed form and the // Reach the app on any host and it sends you to APP_URL's, so the browser, the themed form and the
// cross-origin Kratos POST share ONE cookie host. Same-host requests pass straight through. // cross-origin Kratos POST share ONE cookie host. Same-host requests pass straight through.
@@ -367,22 +354,6 @@ test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the confi
// A 308 must not replay a cross-host POST — non-GET/HEAD is left alone (not canonicalised). // A 308 must not replay a cross-host POST — non-GET/HEAD is left alone (not canonicalised).
const post = await rawGet(port, "/dashboard", `127.0.0.1:${port}`, "POST"); const post = await rawGet(port, "/dashboard", `127.0.0.1:${port}`, "POST");
assert.notEqual(post.status, 308); assert.notEqual(post.status, 308);
// HEAD is canonicalised like GET.
const head = await rawGet(port, "/dashboard", `127.0.0.1:${port}`, "HEAD");
assert.equal(head.status, 308);
assert.equal(head.location, "http://canonical.example:3000/dashboard");
// An HTTP/1.0 request may omit Host; with no host to compare it is served as-is.
assert.match(await rawHttp10(port, "/missing"), /^HTTP\/1\.[01] 404 /);
});
test("an APP_URL with no host (file:///srv) ⇒ no canonical redirect", async (t) => {
const app = createApp({ appUrl: "file:///srv" });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const port = (app.address() as AddressInfo).port;
assert.equal((await rawGet(port, "/missing", `127.0.0.1:${port}`)).status, 404);
}); });
test("no APP_URL configured ⇒ no canonical redirect (unit-test apps and host-agnostic deploys unaffected)", async () => { test("no APP_URL configured ⇒ no canonical redirect (unit-test apps and host-agnostic deploys unaffected)", async () => {
@@ -406,7 +377,7 @@ test("/error renders a themed sign-in error page (Kratos' flow error sink), not
test("renders the 500 HTML page when a handler throws", async () => { test("renders the 500 HTML page when a handler throws", async () => {
const dir = mkdtempSync(join(tmpdir(), "pp-views-")); const dir = mkdtempSync(join(tmpdir(), "pp-views-"));
cpSync(viewsDir, dir, { recursive: true }); // the real views: 500.ejs includes the language picker cpSync(viewsDir, dir, { recursive: true }); // the real views: 500.ejs includes the language picker
writeFileSync(join(dir, "dashboard.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws
const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir }); const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir });
try { try {
await new Promise<void>((resolve) => app.listen(0, resolve)); await new Promise<void>((resolve) => app.listen(0, resolve));
@@ -421,22 +392,6 @@ test("renders the 500 HTML page when a handler throws", async () => {
} }
}); });
test("a plugin versions the /public/ files its own markup links, through the assetHref view local or ctx.assetHref", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
mkdirSync(join(dir, "drag", "views"), { recursive: true });
mkdirSync(join(dir, "drag", "public"), { recursive: true });
writeFileSync(join(dir, "drag", "views", "page.ejs"), `<script src="<%= assetHref("/public/drag/board.js") %>"></script><img src="<%= logo %>" alt="">`);
writeFileSync(join(dir, "drag", "public", "board.js"), "void 0;");
writeFileSync(join(dir, "drag", "public", "logo.svg"), "<svg/>");
t.after(() => rmSync(dir, { force: true, recursive: true }));
const drag: Plugin = { apiVersion: "1.0.0", id: "drag", routes: [{ handler: (ctx) => ({ data: { logo: ctx.assetHref("/public/drag/logo.svg") }, view: "page" }), method: "GET", path: "/page", public: true }] };
const url = await startApp(t, [drag], dir);
const html = await (await fetch(url + "/drag/page")).text();
assert.match(html, /src="\/public\/drag\/board\.js\?v=[\w-]+"/);
assert.match(html, /src="\/public\/drag\/logo\.svg\?v=[\w-]+"/);
});
// Every /public/ reference in the page, as written in the HTML. // Every /public/ reference in the page, as written in the HTML.
const publicRefs = (html: string): string[] => [...html.matchAll(/(?:href|src)="(\/public\/[^"]*)"/g)].map((m) => m[1] ?? ""); const publicRefs = (html: string): string[] => [...html.matchAll(/(?:href|src)="(\/public\/[^"]*)"/g)].map((m) => m[1] ?? "");
+218 -251
View File
@@ -1,10 +1,10 @@
import { randomBytes } from "node:crypto"; import { randomBytes, randomUUID } from "node:crypto";
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import ejs from "ejs"; import ejs from "ejs";
import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type PluginOwned, type RequestCsrf } from "./builtin-route.ts"; import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type RequestCsrf } from "./builtin-routes.ts";
import { buildPageChrome, type PageChrome } from "../ui/chrome.ts"; import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type RequestContext, type User } from "./context.ts"; import { buildContext, type RequestContext, type User } from "./context.ts";
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts"; import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
import type { Denylist } from "../auth/denylist.ts"; import type { Denylist } from "../auth/denylist.ts";
@@ -21,20 +21,18 @@ import { resolveSession, type VerifyOptions } from "../auth/jwt-middleware.ts";
import type { KetoClient } from "../auth/keto-client.ts"; import type { KetoClient } from "../auth/keto-client.ts";
import type { KratosAdmin } from "../auth/kratos-admin.ts"; import type { KratosAdmin } from "../auth/kratos-admin.ts";
import type { KratosPublic } from "../auth/kratos-public.ts"; import type { KratosPublic } from "../auth/kratos-public.ts";
import { createLogger, type Log } from "../logger.ts"; import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts";
import { remintSession } from "../auth/login.ts"; import { remintSession } from "../auth/login.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import { declaredPermissions, fullPath, type PermissionDecl, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts"; import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
import type { PluginSettings } from "../plugin-host/settings.ts"; import type { PluginSettings } from "../plugin-host/settings.ts";
import type { SystemCapabilities } from "../plugin-host/system.ts"; import type { SystemCapabilities } from "../plugin-host/system.ts";
import { allows, type Gate } from "../auth/gate.ts"; import { allows, type Gate } from "../auth/gate.ts";
import { allowedMethods, matchRoute } from "../plugin-host/router.ts"; import { allowedMethods, matchRoute } from "../plugin-host/router.ts";
import { buildAuthRoutes } from "../auth/routes.ts"; import { buildAuthRoutes } from "../auth/routes.ts";
import { parseTrustedProxies } from "./request-fields.ts";
import { trackRequest } from "./request-tracking.ts";
import { securityHeaders } from "./security-headers.ts"; import { securityHeaders } from "./security-headers.ts";
import { localPath } from "./safe-url.ts"; import { localPath } from "./safe-url.ts";
import { type PublicAssets, publicAssets } from "./static.ts"; import { publicAssets } from "./static.ts";
import { renderPluginView } from "../plugin-host/view-resolver.ts"; import { renderPluginView } from "../plugin-host/view-resolver.ts";
const NO_STORE = "private, no-store"; const NO_STORE = "private, no-store";
@@ -60,7 +58,6 @@ export interface AppOptions {
publicDir?: string; publicDir?: string;
secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http) secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http)
settingsCatalog?: readonly PluginSettings[]; // resolved at boot (server.ts, needs the env); → ctx.declaredSettings settingsCatalog?: readonly PluginSettings[]; // resolved at boot (server.ts, needs the env); → ctx.declaredSettings
trustedProxies?: readonly string[]; // IPs/CIDRs whose X-Forwarded-For names the caller (config.trustedProxies); omitted ⇒ the socket peer
viewsDir?: string; viewsDir?: string;
} }
@@ -72,9 +69,10 @@ export function createApp(options: AppOptions = {}): Server {
const revoke = denylist ? (sub: string): void => denylist.revoke(sub) : undefined; const revoke = denylist ? (sub: string): void => denylist.revoke(sub) : undefined;
const cache = options.cache ?? false; const cache = options.cache ?? false;
const canonical = options.appUrl ? new URL(options.appUrl) : undefined; const canonical = options.appUrl ? new URL(options.appUrl) : undefined;
const canonicalHost = canonical?.host; // host[:port], default ports omitted — matches the Host header
const canonicalOrigin = canonical?.origin; // scheme + host[:port], no trailing slash
const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own const csrfSecret = options.csrfSecret ?? randomBytes(32).toString("hex"); // server passes config; tests pass their own
const secureCookies = options.secureCookies ?? false; const secureCookies = options.secureCookies ?? false;
const trustedProxies = parseTrustedProxies(options.trustedProxies);
const hydra = options.hydra; const hydra = options.hydra;
const i18n = options.i18n ?? ENGLISH_I18N; const i18n = options.i18n ?? ENGLISH_I18N;
const jwks = options.jwks; const jwks = options.jwks;
@@ -113,7 +111,7 @@ export function createApp(options: AppOptions = {}): Server {
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir }); const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
// Where the language picker points. Normally the page itself; after a POST that URL may answer no // Where the language picker points. Normally the page itself; after a POST that URL may answer no
// GET (POST /auth-admin/users/:id/recovery has no GET sibling), so fall back to the page the form was // GET (POST /admin/users/:id/delete has no GET sibling), so fall back to the page the form was
// submitted from, then to the front page — the picker is on every page, so every link must land. // submitted from, then to the front page — the picker is on every page, so every link must land.
const switchBase = (req: IncomingMessage, url: URL): string => { const switchBase = (req: IncomingMessage, url: URL): string => {
const method = (req.method ?? "GET").toUpperCase(); const method = (req.method ?? "GET").toUpperCase();
@@ -139,22 +137,38 @@ export function createApp(options: AppOptions = {}): Server {
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href }); const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href });
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href }); const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href });
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
res.end(html);
};
// The public landing "/", ungated. A plugin may own it via `home`; else the built-in intro page. // The public landing "/", ungated. A plugin may own it via `home`; else the built-in intro page.
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | PluginOwned> => { const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
csrf.setCookie(); csrf.setCookie();
if (!homePlugin) return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" }; if (homePlugin) {
const pluginCtx = contextFor(homePlugin.id); // The plugin owns this page, so it runs on its own context — its catalog first, then core.
return { ctx: pluginCtx, pluginId: homePlugin.id, result: (await homePlugin.home(pluginCtx)) ?? null }; const pluginCtx = contextFor(homePlugin.id);
const result = (await homePlugin.home(pluginCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, contextFor, result);
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, homePlugin.id), pluginCtx.localeHref);
return null;
}
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
}; };
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in // "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
// starter page. // starter page.
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | PluginOwned> => { const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent. // The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
csrf.setCookie(); csrf.setCookie();
if (!dashboardPlugin) return { data: { chrome: ctx.chrome }, view: "dashboard" }; if (dashboardPlugin) {
const pluginCtx = contextFor(dashboardPlugin.id); const pluginCtx = contextFor(dashboardPlugin.id); // as serveHome: the owner's own translator
return { ctx: pluginCtx, pluginId: dashboardPlugin.id, result: (await dashboardPlugin.dashboard(pluginCtx)) ?? null }; const result = (await dashboardPlugin.dashboard(pluginCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, contextFor, result);
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
return null;
}
return { data: { chrome: ctx.chrome }, view: "index" };
}; };
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/ // The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
@@ -165,10 +179,9 @@ export function createApp(options: AppOptions = {}): Server {
{ handler: serveDashboard, method: "GET", path: "/dashboard", session: true }, { handler: serveDashboard, method: "GET", path: "/dashboard", session: true },
]; ];
const host: Host = { anyRequestHooks, anyResponseHooks, assets, authOptions, builtinRoutes, csrfSecret, i18n, jwks, keto, kratos, kratosAdmin, menu, permissionCatalog, pluginViewsFor, plugins, secureCookies, settingsCatalog, system, viewsFor }; // The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every
// outbound fetch (the Ory clients via tracedFetch) and any deep module joins this request's trace
// A stage that returns a boolean returns true once it has answered the request, and false to // and correlation with no logger threaded through their signatures.
// pass the request to the next stage.
const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => { const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
// Error pages can render before this request has a context at all (a throw on the way to one), // Error pages can render before this request has a context at all (a throw on the way to one),
// so they start on the built-in English and switch to the visitor's locale once it is resolved. // so they start on the built-in English and switch to the visitor's locale once it is resolved.
@@ -176,241 +189,195 @@ export function createApp(options: AppOptions = {}): Server {
try { try {
const method = req.method ?? "GET"; const method = req.method ?? "GET";
const url = new URL(req.url ?? "/", "http://localhost"); const url = new URL(req.url ?? "/", "http://localhost");
const pathname = url.pathname;
// Set before any branch so every response — static/redirect/error included — inherits them // Set before any branch so every response — static/redirect/error included — inherits them
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override). // (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
for (const [name, value] of secHeaderEntries) res.setHeader(name, value); for (const [name, value] of secHeaderEntries) res.setHeader(name, value);
// Before the session is resolved: an asset needs no user, and verifying the JWT cookie that
// every asset request carries would be wasted work. // Before auth: assets don't need a verified user, and the JWT cookie rides every request.
if (await serveStatic(assets, method, url, req, res, reqLog)) return; if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
setPageCacheHeaders(res); await assets.serve(url, req, res, (err) => reqLog.error("static file error", { error: String(err) }));
if (redirectToCanonicalHost(canonical, method, req, res)) return; return;
const state = await prepareRequest(host, req, res, method, url, reqLog); }
renderPage = viewsFor(state.ctx);
if (await answerFromHook(host, state)) return; // A cache in front of us must key on the language. Set after the static branch: an asset is
if (await answerPluginRoute(host, state)) return; // the same bytes in every language, and a Vary there fragments its entry per raw header.
if (await answerBuiltinRoute(host, state)) return; res.setHeader("vary", "accept-language");
await answerUnrouted(host, state); // Fail closed: a cache in front of us must not keep a page that may carry a user's data. Not
// even the signed-out `/` is shared: it and the signed-in page have one URL, and a cache keyed
// on URL and Accept-Language would hand the signed-out one to a signed-in visitor. A route that
// is the same for everyone sets its own value in RouteResult.headers; the host doesn't police it.
res.setHeader("cache-control", NO_STORE);
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
// host-scoped Kratos CSRF cookie is lost and login dumps onto /error. GET/HEAD only: a 308
// must not replay a cross-host POST.
if (canonicalHost && (method === "GET" || method === "HEAD")) {
const host = req.headers.host;
if (host !== undefined && host !== canonicalHost) {
res.writeHead(308, { location: canonicalOrigin + (req.url ?? "/") }).end();
return;
}
}
// `explicit` (the URL asked for a locale) is what makes the choice travel: the chrome, this
// request's redirects and ctx.localeHref then carry ?locale onto the links they emit.
const { explicit, locale } = i18n.resolve({ acceptLanguage: req.headers["accept-language"], param: url.searchParams.get("locale") });
const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null);
const t = i18n.translator(locale);
// A lapsed token still backed by a live Kratos session is silently re-minted — "stay signed
// in". The only place the hot path touches Ory.
let user: User | null = null;
if (jwks) {
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
user = auth.user;
if (!user && auth.expired && keto && kratos && kratosAdmin) {
try {
const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie, { secure: secureCookies });
user = reminted.user;
res.appendHeader("set-cookie", reminted.setCookie);
} catch (err) {
// Ory unreachable — degrade to anonymous instead of 500ing every lapsed request. Leave
// the cookie alone: it can re-mint once Ory recovers.
reqLog.warn("session re-mint failed (Ory unreachable?)", { error: String(err) });
}
}
}
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
const csrfMint: RequestCsrf = {
setCookie: (): void => { if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); },
token: csrf.token,
};
const verifyCsrf = (submitted: string | null | undefined): boolean =>
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
// Chrome composes the whole menu, so it is memoized and resolved lazily — a json/redirect
// handler, or the public "/" with a standalone home, never pays for it.
let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, localeHref: carryLocale, menu, plugins, t, translatorFor: (id) => i18n.translator(locale, id), user }));
// A plugin's context gets the plugin's own translator — its catalog first, then core.
const i18nFor = (pluginId?: string) => ({
locale,
localeHref: carryLocale,
locales: i18n.available,
t: pluginId === undefined ? t : i18n.translator(locale, pluginId),
});
// Base context (no route params), for the built-in routes. Every plugin-owned render — a
// landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead.
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
renderPage = viewsFor(ctx);
// Plugin onRequest hooks run before routing and may short-circuit the request.
if (anyRequestHooks) {
const short = await runRequestHooks(plugins, contextFor);
if (short) {
// Like every other page-emitting path, so a form the hook renders has its matching cookie.
csrfMint.setCookie();
await sendResult(res, short.result, pluginViewsFor(short.ctx, short.plugin.id), carryLocale);
return;
}
}
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply lacks
// the permission gets the 403 page.
const refuse = async (gate: Gate, gateCtx: RequestContext): Promise<void> => {
if (!gateCtx.user) { res.writeHead(303, { location: carryLocale(loginRedirect(gateCtx)) }).end(); return; }
reqLog.warn("forbidden: missing permission", { path: pathname, required: gate.permission ?? "", sub: gateCtx.user.id });
sendHtml(res, 403, await renderPage("403", {}));
};
const match = matchRoute(plugins, method, pathname);
if (match) {
const routeCtx = contextFor(match.plugin.id, match.params);
if (!allows(match.route, routeCtx.user)) {
await refuse(match.route, routeCtx);
return;
}
csrfMint.setCookie();
const result = (await match.route.handler(routeCtx)) ?? null;
// The responding plugin observes its own route, params and all; the others get a plain
// context for their own id (never another plugin's params).
if (anyResponseHooks) await runResponseHooks(plugins, (id) => (id === match.plugin.id ? routeCtx : contextFor(id)), result);
await sendResult(res, result, pluginViewsFor(routeCtx, match.plugin.id), carryLocale);
return;
}
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
if (builtin) {
if (!allows(builtin, ctx.user)) { await refuse(builtin, ctx); return; }
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
return;
}
// Known path, wrong method → 405 with Allow; otherwise nothing here → 404.
const allow = allowedMethods(plugins, pathname);
if (allow.length) {
res.writeHead(405, { allow: allow.join(", "), "content-type": "text/plain; charset=utf-8" }).end("Method Not Allowed");
return;
}
sendHtml(res, 404, await renderPage("404", {}));
} catch (err) { } catch (err) {
await answerError(err, res, reqLog, renderPage); // A guard thrown anywhere in handling maps to a response (not a 500): a `location` ⇒ a
// redirect (requireSession → /login), otherwise the status renders the error page.
if (err instanceof GuardError) {
if (res.headersSent) return void res.end();
if (err.location) return void res.writeHead(303, { location: err.location }).end();
try {
return void sendHtml(res, err.status, await renderPage("403", {}));
} catch (renderErr) {
// Same last resort as the 500 branch below: a throw here would leave the socket open
// (this catch is the one that would have handled it), so end the response ourselves.
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
return void res.writeHead(err.status, { "content-type": "text/plain; charset=utf-8" }).end("Forbidden");
}
}
reqLog.error("unhandled request error", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) });
if (res.headersSent) return void res.end(); // a partial body is already on the wire
try {
// Render before writing: if the 500 page itself throws, headers stay unsent
// and we fall back to plain text below instead of a half-written response.
sendHtml(res, 500, await renderPage("500", {}));
} catch (renderErr) {
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Internal Server Error");
}
} }
}; };
return createServer((req, res) => trackRequest(log, trustedProxies, req, res, handleRequest)); return createServer((req, res) => {
} // "close" (not "finish") fires on both a completed response and a premature disconnect, so an
// aborted request is still logged and its span flushed.
// What the request stages read from createApp, all fixed at boot. const startMs = Date.now();
interface Host { const reqLog = requestLogger(log, {
anyRequestHooks: boolean; requestId: randomUUID(),
anyResponseHooks: boolean; ...(typeof req.headers.traceparent === "string" ? { traceparent: req.headers.traceparent } : {}),
assets: PublicAssets; });
authOptions: VerifyOptions; // end() must run exactly once, after BOTH the handler has unwound AND the response has closed.
builtinRoutes: BuiltinRoute[]; // Earlier would throw "already ended" from a still-running handler's ctx.log on a client abort,
csrfSecret: string; // or drop the access line on the happy path (the handler settles before close).
i18n: I18n; let settled = false;
jwks: JwksProvider | undefined; let closed = false;
keto: KetoClient | undefined; const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); };
kratos: KratosPublic | undefined; res.on("close", () => {
kratosAdmin: KratosAdmin | undefined; closed = true;
menu: MenuConfig; try {
permissionCatalog: PermissionDecl[]; // path only (no query — it may carry tokens); method/status are header-safe here.
pluginViewsFor: (ctx: RequestContext, id: string) => ViewRenderer; reqLog.info("request", { method: req.method ?? "GET", ms: Date.now() - startMs, path: (req.url ?? "/").split("?", 1)[0] ?? "/", status: res.statusCode });
plugins: Plugin[]; } catch { /* never let logging crash a served request */ }
secureCookies: boolean; finalize();
settingsCatalog: readonly PluginSettings[]; });
system: SystemCapabilities | undefined; // Make reqLog ambient for the whole handler so all outbound fetch is traced. The .catch logs a
viewsFor: (ctx: RequestContext) => ViewRenderer; // pathological escape via the app logger — not reqLog, which may be the thing that broke.
} void runWithLog(reqLog, () => handleRequest(req, res, reqLog))
.catch((err) => log.error("request handler escaped its try/catch", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) }))
// A request once its visitor is known: what every stage after prepareRequest reads. .finally(() => { settled = true; finalize(); });
interface RequestState { });
carryLocale: (href: string) => string;
contextFor: (pluginId: string, params?: Record<string, string>) => RequestContext;
csrf: RequestCsrf;
ctx: RequestContext; // the base context (no route params), for the built-in routes
// Read before the onRequest hooks run, so a hook that edits `req` doesn't reroute the request.
method: string;
pathname: string;
}
async function serveStatic(assets: PublicAssets, method: string, url: URL, req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<boolean> {
if (!url.pathname.startsWith("/public/") || (method !== "GET" && method !== "HEAD")) return false;
await assets.serve(url, req, res, (err) => reqLog.error("static file error", { error: String(err) }));
return true;
}
function setPageCacheHeaders(res: ServerResponse): void {
// A cache in front of us must key on the language. Set after serveStatic: an asset is
// the same bytes in every language, and a Vary there fragments its entry per raw header.
res.setHeader("vary", "accept-language");
// Fail closed: a cache in front of us must not keep a page that may carry a user's data. Not
// even the signed-out `/` is shared: it and the signed-in page have one URL, and a cache keyed
// on URL and Accept-Language would hand the signed-out one to a signed-in visitor. A route that
// is the same for everyone sets its own value in RouteResult.headers; the host doesn't police it.
res.setHeader("cache-control", NO_STORE);
}
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
// host-scoped Kratos CSRF cookie is lost and login dumps onto /error. GET/HEAD only: a 308
// must not replay a cross-host POST. `URL.host` omits a default port, as the Host header does.
function redirectToCanonicalHost(canonical: URL | undefined, method: string, req: IncomingMessage, res: ServerResponse): boolean {
if (!canonical?.host || (method !== "GET" && method !== "HEAD") || req.headers.host === undefined || req.headers.host === canonical.host) return false;
res.writeHead(308, { location: canonical.origin + (req.url ?? "/") }).end();
return true;
}
// Resolves the visitor's locale, user and CSRF token, and builds the contexts handlers run on.
async function prepareRequest(host: Host, req: IncomingMessage, res: ServerResponse, method: string, url: URL, reqLog: Log): Promise<RequestState> {
const { i18n } = host;
// `explicit` (the URL asked for a locale) is what makes the choice travel: the chrome, this
// request's redirects and ctx.localeHref then carry ?locale onto the links they emit.
const { explicit, locale } = i18n.resolve({ acceptLanguage: req.headers["accept-language"], param: url.searchParams.get("locale") });
const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null);
const t = i18n.translator(locale);
const user = await resolveUser(host, req, res, reqLog);
const csrf = requestCsrf(host, req, res);
const verifyCsrf = (submitted: string | null | undefined): boolean =>
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: host.csrfSecret, submitted });
// Chrome composes the whole menu, so it is memoized and resolved lazily — a json/redirect
// handler, or the public "/" with a standalone home, never pays for it.
let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPageChrome({
csrfToken: csrf.token, currentPath: url.pathname, localeHref: carryLocale, menu: host.menu, plugins: host.plugins, t,
translatorFor: (id) => i18n.translator(locale, id), user,
}));
const shared = { assetHref: host.assets.href, chrome, declaredPermissions: host.permissionCatalog, declaredSettings: host.settingsCatalog, locale, localeHref: carryLocale, locales: i18n.available, log: reqLog, user, verifyCsrf, ...(host.system ? { system: host.system } : {}) };
return {
carryLocale,
// The plugin's own translator: its catalog first, then core.
contextFor: (pluginId, params) => buildContext(req, res, { ...shared, t: i18n.translator(locale, pluginId), ...(params ? { params } : {}) }),
csrf,
ctx: buildContext(req, res, { ...shared, t }),
method,
pathname: url.pathname,
};
}
function requestCsrf(host: Host, req: IncomingMessage, res: ServerResponse): RequestCsrf {
const { fresh, token } = ensureCsrfToken(req.headers.cookie, host.csrfSecret);
return {
setCookie: (): void => { if (fresh) res.appendHeader("set-cookie", csrfCookie(token, { secure: host.secureCookies })); },
token,
};
}
// A lapsed token still backed by a live Kratos session is silently re-minted — "stay signed
// in". The only place the hot path touches Ory.
async function resolveUser(host: Host, req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<User | null> {
const { jwks, keto, kratos, kratosAdmin } = host;
if (!jwks) return null;
const auth = await resolveSession(req.headers.cookie, jwks, host.authOptions);
if (auth.user || !auth.expired || !keto || !kratos || !kratosAdmin) return auth.user;
try {
const reminted = await remintSession({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie, { secure: host.secureCookies });
res.appendHeader("set-cookie", reminted.setCookie);
return reminted.user;
} catch (err) {
// Ory unreachable — degrade to anonymous instead of 500ing every lapsed request. Leave
// the cookie alone: it can re-mint once Ory recovers.
reqLog.warn("session re-mint failed (Ory unreachable?)", { error: String(err) });
return null;
}
}
async function answerFromHook(host: Host, { carryLocale, contextFor, csrf, ctx }: RequestState): Promise<boolean> {
if (!host.anyRequestHooks) return false;
const short = await runRequestHooks(host.plugins, contextFor);
if (!short) return false;
// Like every other page-emitting path, so a form the hook renders has its matching cookie.
csrf.setCookie();
await sendResult(ctx.res, short.result, host.pluginViewsFor(short.ctx, short.plugin.id), carryLocale);
return true;
}
async function answerPluginRoute(host: Host, state: RequestState): Promise<boolean> {
const { carryLocale, contextFor, csrf, ctx } = state;
const match = matchRoute(host.plugins, state.method, state.pathname);
if (!match) return false;
ctx.log.context["http.route"] = fullPath(match.plugin.id, match.route.path);
const routeCtx = contextFor(match.plugin.id, match.params);
if (!allows(match.route, routeCtx.user)) {
await refuse(host, state, match.route, routeCtx);
return true;
}
csrf.setCookie();
const result = (await match.route.handler(routeCtx)) ?? null;
// The responding plugin observes its own route, params and all; the others get a plain
// context for their own id (never another plugin's params).
if (host.anyResponseHooks) await runResponseHooks(host.plugins, (id) => (id === match.plugin.id ? routeCtx : contextFor(id)), result);
await sendResult(ctx.res, result, host.pluginViewsFor(routeCtx, match.plugin.id), carryLocale);
return true;
}
async function answerBuiltinRoute(host: Host, state: RequestState): Promise<boolean> {
const { carryLocale, contextFor, csrf, ctx } = state;
const builtin = matchBuiltinRoute(host.builtinRoutes, state.method, state.pathname);
if (!builtin) return false;
ctx.log.context["http.route"] = builtin.path;
if (!allows(builtin, ctx.user)) {
await refuse(host, state, builtin, ctx);
return true;
}
const answer = await builtin.handler(ctx, csrf, contextFor);
if ("pluginId" in answer) {
if (host.anyResponseHooks) await runResponseHooks(host.plugins, contextFor, answer.result);
await sendResult(ctx.res, answer.result, host.pluginViewsFor(answer.ctx, answer.pluginId), carryLocale);
} else {
await sendResult(ctx.res, answer, host.viewsFor(ctx), carryLocale);
}
return true;
}
async function answerUnrouted(host: Host, { ctx, pathname }: RequestState): Promise<void> {
const allow = allowedMethods(host.plugins, pathname);
if (allow.length) {
ctx.res.writeHead(405, { allow: allow.join(", "), "content-type": "text/plain; charset=utf-8" }).end("Method Not Allowed");
return;
}
sendHtml(ctx.res, 404, await host.viewsFor(ctx)("404", {}));
}
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply lacks
// the permission gets the 403 page.
async function refuse(host: Host, { carryLocale, ctx, pathname }: RequestState, gate: Gate, gateCtx: RequestContext): Promise<void> {
if (!gateCtx.user) { ctx.res.writeHead(303, { location: carryLocale(loginRedirect(gateCtx)) }).end(); return; }
ctx.log.warn("forbidden: missing permission", { path: pathname, required: gate.permission ?? "", sub: gateCtx.user.id });
sendHtml(ctx.res, 403, await host.viewsFor(ctx)("403", {}));
}
async function answerError(err: unknown, res: ServerResponse, reqLog: Log, renderPage: ViewRenderer): Promise<void> {
// A GuardError maps to its redirect (requireSession → /login) or its status page, not a 500.
if (err instanceof GuardError) {
if (res.headersSent) return void res.end();
if (err.location) return void res.writeHead(303, { location: err.location }).end();
try {
return void sendHtml(res, err.status, await renderPage("403", {}));
} catch (renderErr) {
// answerError runs inside handleRequest's catch, so a throw here would escape and leave the
// socket open. End the response here, as the 500 branch below does.
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
return void res.writeHead(err.status, { "content-type": "text/plain; charset=utf-8" }).end("Forbidden");
}
}
reqLog.error("unhandled request error", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) });
if (res.headersSent) return void res.end(); // a partial body is already on the wire
try {
// Render before writing: if the 500 page itself throws, headers stay unsent
// and we fall back to plain text below instead of a half-written response.
sendHtml(res, 500, await renderPage("500", {}));
} catch (renderErr) {
reqLog.error("error page render failed", { error: renderErr instanceof Error ? (renderErr.stack ?? renderErr.message) : String(renderErr) });
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" }).end("Internal Server Error");
}
}
function sendHtml(res: ServerResponse, status: number, html: string): void {
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
res.end(html);
} }
// The Referer as a host-relative path, when it is one of ours — the page a form was submitted // The Referer as a host-relative path, when it is one of ours — the page a form was submitted

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