Compare commits

..

8 Commits

Author SHA1 Message Date
lilleman d626b3ae74 Keep the group-delete confirm in user terms, not tuple mechanics
CI / full-gate (push) Successful in 2m39s
2026-08-05 15:30:54 +02:00
lilleman 453d6dc065 Deleting a group revokes the permissions it granted
CI / full-gate (push) Successful in 2m39s
2026-08-05 15:26:25 +02:00
lilleman 151117680a Extend the read-only treatment to OAuth2 clients and write-intent GETs
CI / full-gate (push) Successful in 2m42s
2026-08-05 15:08:31 +02:00
lilleman 3491e6109f Model the read/write split in the UI: read-only views, self-revoke and inherited-grant guards
CI / full-gate (push) Successful in 2m39s
2026-08-05 14:47:51 +02:00
lilleman 0011182f16 Permissions are a fixed list from plugin code; grant them on Users and Groups
CI / full-gate (push) Successful in 2m38s
2026-08-05 14:29:15 +02:00
lilleman b6f4e5366a Enforce the permission-name rule at discovery, for every plugin
CI / full-gate (push) Failing after 1m53s
2026-08-05 13:00:23 +02:00
lilleman 004d349a34 Seed the demo admin from the mounted plugins, not the image's empty copy 2026-08-05 12:55:12 +02:00
lilleman 065d865d24 Permission names are <resource>:<action>, replacing the catch-all admin permission
CI / full-gate (push) Successful in 2m43s
2026-08-05 12:45:06 +02:00
170 changed files with 4164 additions and 9406 deletions
-5
View File
@@ -1,14 +1,9 @@
.git .git
# Load-bearing both ways: a stray copy would bake in at /app/node_modules and shadow /node_modules,
# and matching only the root one is what lets a baked plugin keep its own deps. Never `**/node_modules`.
node_modules node_modules
npm-debug.log npm-debug.log
*.log *.log
.DS_Store .DS_Store
# A plugin's .npmrc is where a private-registry token would sit — never in a shipped image.
plugins/**/.npmrc
e2e-tests/artifacts e2e-tests/artifacts
# Orchestration, not test code — keep them out of the runner image (COPY e2e-tests/ ./) # Orchestration, not test code — keep them out of the runner image (COPY e2e-tests/ ./)
e2e-tests/Dockerfile e2e-tests/Dockerfile
+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:24.22.2-alpine3.24 node registry-cleanup/cleanup.ts node:24.19.0-alpine3.24 node registry-cleanup/cleanup.ts
+7 -66
View File
@@ -1,29 +1,13 @@
name: Release name: Release
# The Hub overview names a released tag, so it is published here only, never on a push to main.
on: on:
push: push:
tags: ['v[0-9]+.[0-9]+.[0-9]+'] tags: ['v[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch:
inputs:
overview_version:
description: 'Released version to republish the overview for, without the leading v (e.g. 0.1.0)'
required: true
jobs: jobs:
retag-image: retag-image:
if: github.event_name == 'push'
runs-on: docker-host runs-on: docker-host
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
# Before anything is published: the contract version IS the release version, so a tag that
# disagrees would ship a host misreporting itself to every plugin's compatibility check.
- name: Refuse a tag that disagrees with HOST_API_VERSION
env:
GIT_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
- name: Promote the commit-hash image to semver + latest - name: Promote the commit-hash image to semver + latest
env: env:
GIT_TAG: ${{ github.ref_name }} GIT_TAG: ${{ github.ref_name }}
@@ -31,77 +15,34 @@ jobs:
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }} REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
REPO: gitea.larvit.se/${{ github.repository }} REPO: gitea.larvit.se/${{ github.repository }}
run: | run: |
set -euo pipefail
COMMIT=$(git rev-parse 'HEAD^{commit}') COMMIT=$(git rev-parse 'HEAD^{commit}')
VERSION=${GIT_TAG#v} VERSION=${GIT_TAG#v}
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
docker pull "$REPO:$COMMIT" \ docker pull "$REPO:$COMMIT" \
|| { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; } || { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; }
# No bare-major tag while major is 0: a 0.x minor is a contract break, so `:0` would move for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
# across one and abort boot for everything tracking it. `:0.1` only moves across patches.
TAGS="$VERSION ${VERSION%.*} latest"
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
for TAG in $TAGS; do
docker tag "$REPO:$COMMIT" "$REPO:$TAG" docker tag "$REPO:$COMMIT" "$REPO:$TAG"
docker push "$REPO:$TAG" docker push "$REPO:$TAG"
done done
- name: Sync the release tags to Docker Hub - name: Sync the release tags to Docker Hub
env: env:
DOCKERHUB_IMAGE: docker.io/${{ github.repository }} DOCKERHUB_REPO: docker.io/${{ github.repository }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }} DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
GIT_TAG: ${{ github.ref_name }} GIT_TAG: ${{ github.ref_name }}
REPO: gitea.larvit.se/${{ github.repository }} REPO: gitea.larvit.se/${{ github.repository }}
run: | run: |
set -euo pipefail
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 (README -> CI/CD)"; 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" for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG"
for TAG in $TAGS; do docker push "$DOCKERHUB_REPO:$TAG"
docker tag "$REPO:$COMMIT" "$DOCKERHUB_IMAGE:$TAG"
docker push "$DOCKERHUB_IMAGE:$TAG"
done done
- name: Log out of the registries - name: Log out of the registries
if: always() if: always()
run: | run: |
set -uo pipefail docker logout gitea.larvit.se
# Cleanup, and the runner's Docker config is shared (AGENTS.md) — a lost race here must not docker logout docker.io
# fail a release that published, nor skip the overview job that follows.
docker logout gitea.larvit.se || true
docker logout docker.io || true
publish-overview:
if: always() && (github.event_name == 'workflow_dispatch' || needs.retag-image.result == 'success')
needs: [retag-image]
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
if: github.event_name == 'push'
# Publish the named release's own tree, so the page never pairs one Plainpages tag with another
# release's sidecar pins. A version that was never released fails here.
- uses: actions/checkout@v7.0.1
if: github.event_name == 'workflow_dispatch'
with:
ref: refs/tags/v${{ inputs.overview_version }}
- name: Publish the Docker Hub overview
env:
DOCKERHUB_REPO: ${{ github.repository }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
GIT_TAG: ${{ github.ref_name }}
INPUT_VERSION: ${{ inputs.overview_version }}
run: |
set -euo pipefail
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
VERSION=${VERSION#v}
# An empty dispatch input falls back to the branch name, so gate this like a tag.
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
docker run --rm -v "$PWD:/repo" -w /repo \
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
node:24.22.2-alpine3.24 \
node release-tooling/dockerhub-overview.ts "$VERSION"
+8 -15
View File
@@ -21,25 +21,25 @@ 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.186.6 renovate/renovate:44.11.7
# 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
# trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit # trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit
# (a human owns that release), nothing new merged, or nothing that merged carried a `Release-Bump:` # (a human owns that release) or nothing new merged. ff-only merges keep the renovate commit's
# trailer — a release nobody can observe is noise. ff-only merges keep the renovate commit's
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer; # authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
# pre-1.0 shifts down (release-tooling/next-version.ts). Tag-only — release.yml promotes the # pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the
# already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't). # already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't).
# Off until the Actions variable AUTO_RELEASE is set to 'true': Plainpages is pre-announcement and
# deliberately carries no tags, so an automated bump would only invent a version nobody consumes.
auto-release: auto-release:
runs-on: docker-host runs-on: docker-host
needs: renovate needs: renovate
if: vars.AUTO_RELEASE == 'true'
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
with: with:
fetch-depth: 0 fetch-depth: 0
# A persisted token outranks the PAT in the push URL, and Gitea runs nothing for its pushes.
persist-credentials: false
- name: Tag a release for what Renovate merged - name: Tag a release for what Renovate merged
env: env:
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
@@ -57,15 +57,8 @@ jobs:
fi fi
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \ BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; }) --format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
if [ -z "$BUMPS" ]; then NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0 node auto-release/next-version.ts "$LATEST" $BUMPS)
fi
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/next-version.ts "$LATEST" $BUMPS)
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
git show origin/main:src/plugin-host/plugin.ts \
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
node release-tooling/contract-version.ts "$NEXT" -
echo "Releasing $LATEST -> $NEXT" echo "Releasing $LATEST -> $NEXT"
git tag "$NEXT" origin/main git tag "$NEXT" origin/main
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT" git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
+2 -4
View File
@@ -3,10 +3,8 @@
*.log *.log
node_modules node_modules
# Playwright E2E outputs (screenshots, html report, traces). The dir itself is tracked: an absent # Playwright E2E outputs (screenshots, html report, traces)
# bind-mount source is created by the daemon as root, which the unprivileged runner cannot write. e2e-tests/artifacts/
/e2e-tests/artifacts/*
!/e2e-tests/artifacts/.gitkeep
# plugins/ is a drop-in mount point, not committed code — keep it empty (see examples/plugins/ for the reference) # plugins/ is a drop-in mount point, not committed code — keep it empty (see examples/plugins/ for the reference)
/plugins/* /plugins/*
+364 -422
View File
@@ -3,353 +3,302 @@
Guidance for AI agents and contributors working in this repo. Read `README.md` for Guidance for AI agents and contributors working in this repo. Read `README.md` for
commands and layout. commands and layout.
## Prose discipline
Every word in this repo is read again on every future task, so prose is a recurring cost. On **any**
change, sweep the prose you touched — this file, `README.md`, the example READMEs, and code
comments — and cut it back to what a competent reader could not infer:
- **Delete history.** Git holds it. No "this moved from X", "used to be Y", "was tried and
rejected", "(declined twice)", dated changelog entries, or the symptom that prompted a fix. Record
the decision and the reason it *currently* turns on, nothing else.
- **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that
re-explains a table above it, a file-map entry that expands the filename. The fix is deletion,
not trimming.
- **Delete the self-evident** and anything already stated once elsewhere. **One home per fact** —
link to it instead of repeating it; the same sentence in five files is five chances to drift.
- **Give every accepted risk an expiry** ("valid while X"), and delete the entry once X stops
holding.
- **Keep** the surprising why, the footgun, the invariant, the external constraint, and the one-time
setup a reader cannot dig out of the code. Once a line has earned its place, make it short and
information-dense.
Trimming is not a separate task to schedule — do it in the same change, every time.
## How to work with tasks ## How to work with tasks
Use the file `todo.md`. Use the file `todo.md`.
For each todo item, interview the user extensively to deeply understand the scope and goal of For each todo item, interview the user extensively to deeply understand the scope and goal of each. When done, check the completed task in `todo.md`. Commit all changes and push to a new branch, create a PR and merge it when the CI/CD turns green.
each. When done, check the completed task in `todo.md`. Commit all changes and push to a new
branch, create a PR and merge it when the CI/CD turns green.
## Project priorities (do not erode) ## Project priorities (do not erode)
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable. 1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
**A page is a document**: it scrolls, and the chrome scrolls with it. Nothing may bound the 2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`,
viewport to hold content still — no `height: 100dvh` frame, no `overflow: hidden` on `body`, no `@larvit/log` — the last itself zero-dependency, for structured/OTLP logging).
`position: sticky` header. Each such box buys an app-like look with CSS the next reader has to Prefer the Node standard library; justify any new dependency; do not add
reverse-engineer, and is one more thing to undo before the content under it can be reached. frameworks. The app is
Overlays are not this: the skip link, the mobile off-canvas nav and its scrim sit *above* the **stateless — no database**. Auth/identity/OAuth are **Ory sidecar services**
document rather than holding it still, and have no other spelling — the document keeps scrolling (Kratos/Keto/Hydra, backed by Postgres), reached over their REST APIs with
behind the open nav, accepted rather than overlooked. Only the document scroller gets keyboard built-in `fetch` — no SDK dependency. New capabilities ship as **plugin
paging unconditionally and back/forward scroll restoration, and a page a box clips fails silently: folders** under `plugins/` that fetch their data from upstream services, not as
nothing in a test or a console says content is unreachable below the fold. core code. See `README.md` for the architecture.
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
Postgres database, which the host provisions but never reads or writes inside. Auth/identity/OAuth are
**Ory sidecar services** reached over their REST APIs with built-in `fetch` — no SDK. New
capabilities ship as **plugin folders** under `plugins/` that get their data from an upstream
service or their own database, not as core code.
3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`, 3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types; `exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer
limit nullable and multi-option types. exact types and limit nullable and multi option types when possible. KISS.
4. **Environment-agnostic** — no `NODE_ENV` branching. Every behaviour is an **explicit config 4. **Environment-agnostic** — the app never asks *which environment* it runs in; there is
toggle** read once in `src/config.ts`; compose files set them per deployment. no `NODE_ENV` (or equivalent) branching. Every behaviour is an **explicit config
5. **Semantic, accessible DOM** — the right element for the job (landmarks, one `<h1>` per page + toggle** (e.g. `CACHE_TEMPLATES`, `REQUIRE_SECURE_SECRETS`, a future "disable email"),
sane heading order, lists, `<table>` with row/column headers, `<fieldset>`/`<legend>`, `<button>` read once in `src/config.ts`. Compose files set the toggles per deployment.
vs `<a>`); ARIA only to fill real gaps. Classes/ids name *meaning*, not looks. 5. **Semantic, accessible DOM** — markup is a first-class concern. Use the right element
6. **Full, parallel E2E** — every user-facing flow has a Playwright test, shipped in the same change for the job (landmarks, one `<h1>` per page + sane heading order, lists, `<table>` with
as the surface. Tests stay independent and side-effect-free so the suite runs `fullyParallel`. row/column headers, `<fieldset>`/`<legend>`, `<button>` vs `<a>`); add ARIA only to fill
7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the only way to real gaps (`aria-current`, `aria-sort`, labels). Classes/ids name *meaning*, not looks.
add domain features. It optimises for being powerful, predictable and overloadable, and the host Prefer native semantics over `div` + ARIA. New views and partials keep this bar.
**fails loud at boot/discovery** rather than sandboxing at runtime. Runtime crash-isolation is a 6. **Full, parallel E2E** — every user-facing flow (each page, form, guard, plugin route)
deliberate **non-goal**. has a Playwright E2E test, and a new surface ships *with* its E2E in the same change.
Tests stay independent and side-effect-free so the suite runs `fullyParallel` — keep it
that way as it grows (never serialise on shared state); parallelism is what keeps it
fast. E2E runs in Docker against the live stack — see `README.md`.
7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the
only way to add domain features. It optimises for being **powerful, predictable, and
overloadable** (a plugin can take over as much of a page as it wants), and the host
**fails loud at boot/discovery** (bad manifest, version mismatch, or conflict stops
startup with a clear message) rather than sandboxing at runtime. Runtime crash-isolation
is a deliberate **non-goal** — diagnose at deploy time, not in production. Keep this
contract stable; see `README.md` → Building plugins.
## Deliberate architectural deviations (don't re-flag) ## Deliberate architectural deviations (don't re-flag)
Intentional, reasoned choices — an architecture review should honor them, not re-raise them. Intentional, reasoned choices — an architecture review should honor them, not re-raise
Revisit only if the stated reason stops holding. them. Revisit only if the stated reason stops holding.
### Structure & contracts - **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/`
(session-JWT hot path, guards, and the Ory REST clients), `i18n/` (locale resolution + the
- **`src/` is grouped by concern**, not flat — `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`, catalogs, `locales/` holding the data), `plugin-host/`
with `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` at the root; tests are (discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel + `system.ts`, the
co-located. Add a new module to the folder owning its concern. The core ships **no domain `ctx.system` capability surface), and `ui/` (design-system view-models + menu/chrome);
screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`). `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests
- **Plugins and config import the host only through a barrel** — `@plainpages/plugin-api` → are co-located (`foo.test.ts` beside `foo.ts`). Add a new module to the folder that owns its
`plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts`, concern rather than to the root; don't reintroduce a flat tree. The core ships **no domain
never a relative `../../src/*` path. These two barrels are the whole contract surface; don't "fix" screens** — even the admin GUI (users/groups/permissions) is a drop-in plugin (`examples/plugins/admin/`),
either back to a relative path. Three consequences: not `src/` code.
- `@plainpages/plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their - **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the
DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major base request context. It protects the I/O-free hot path on the public, bot-hit landing
`apiVersion` bump, not a free refactor. (`/`). (Declined twice.)
- **The barrel is a package, not a `#`-import, so a plugin folder may carry its own - **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web`
`package.json`** and depend on npm packages (README → Plugin dependencies). The Dockerfile links never touches SMTP. Customization is Kratos' built-in `courier.template_override_path`,
it into `/node_modules`, above every plugin scope. Never let a copy reach a plugin's own not app code — keeping `web` stateless and dependency-light (see [Email](README.md#email)).
`node_modules`: two instances of the barrel break `instanceof` across the boundary, which - **Plugins and config import the host only via package.json `imports`** — `#plugin-api`
`plugin-api.test.ts` guards by asserting both paths reach one module. → `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts` — never a
- **Plugin storage hands over credentials, not a client** (README → Plugin storage). The host takes relative `../../src/*` path. These two barrels are the whole author/operator contract
`postgres` to run the provisioning DDL, and `storage-provisioning.ts` is the only module importing surface; the `src/*` behind them may be refactored freely. Depth-independent and
it — `storage.ts` beside it stays pure so `web` never loads a driver (`src/postgres.test.ts` guards refactor-stable by design — don't "fix" a `#`-import back to a relative path.
both halves, because one value imported from the wrong module breaks it invisibly). It is never **One caveat:** `#plugin-api` re-exports the Ory client types for the `ctx.system` surface
re-exported through the barrel, so no driver shape enters the contract. Three properties hold the design together, so (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their DTOs and error classes). Those shapes are
don't trade one away in isolation: passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than therefore **contract-visible** — changing them is a plugin-API break needing a major
stored, which is what keeps the host stateless — whoever holds that secret holds every plugin `apiVersion` bump, not a free refactor. Keep the Ory clients stable, or bump the version.
database, so it ranks with the DB password itself; the provisioning DSN reaches `bootstrap` only - **A plugin/config folder must stay a plain folder — no `package.json` of its own.** Node
(`src/compose.test.ts` guards the split); and provisioning never drops anything, so uninstalling a resolves `#`-specifiers against the nearest parent `package.json`; a `package.json` inside
plugin cannot destroy data — boot logs the orphans instead. Because the host's copy sits in the the folder becomes its own scope and `#plugin-api`/`#menu-config` stop resolving. Accepted
ambient `/node_modules`, a plugin can `import "postgres"` without declaring it — incidental, not a cost of the `#`-import contract (fits the stateless, no-per-plugin-deps ethos). A plugin
packaging promise, and a plugin must still depend on its own driver. kept in its own repo typechecks against the barrel only when mounted under the host tree
- **Plugin settings are declared, not discovered** (README → Plugin settings). `settings.ts` is pure and (or by adding a local `imports` map / vendored stub).
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 - **`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 `plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirror folders are in
`tsconfig.include` and resolve the host through the barrels, so each typechecks in place *and* `tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks
copies across unchanged. Never commit real plugins/config into the root mount dirs — they ship empty. in place *and* copies across unchanged. Never commit real plugins/config into the root
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the base request mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise).
context. It protects the I/O-free hot path on the public, bot-hit landing (`/`). - **Authorization vocabulary: `User` → `Group` → `Permission`, and there is no `Role`.** Keto ships
- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`, no namespaces — all four in `ory/keto/namespaces.keto.ts` are ours. `Permission` follows RBAC,
`dashboard`) and an `onRequest` short-circuit build their context with `contextFor(pluginId)` where a permission is one operation ("read shifts") and a role is a *bundle* of them; a route
exactly as a plugin route does — otherwise `ctx.t` is the core translator and the plugin's own keys gates on one operation, so it gates on a permission, and a bundle is just a group with several
render as bare keys on the pages it owns. grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the
- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web` never separate per-row tier.
touches SMTP. Customization is Kratos' `courier.template_override_path`, not app code.
### Authorization
- **Vocabulary: `User` → `Group` → `Permission`, and there is no `Role`.** Keto ships no namespaces —
all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one operation ("read shifts");
a role is a *bundle*, which here is just a group with several grants (groups nest). Ory's own
"permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare - **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
word names *who someone is* (a role), and roles are groups here. **Enforced at discovery** word names *who someone is* — a role — and roles are groups here; the old catch-all `admin`
(`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route/nav permission was exactly that mistake, split into `users:`/`groups:`/`permissions:`/`oauth2-clients:`
`permission` and every declared name), fail-loud like any other manifest rule — not only in the × `read`/`write` 2026-08-05. **Enforced at discovery** (`isValidPermissionName` in
admin GUI, which an operator removes by not copying it in. `plugin-host/plugin.ts`, checked by `shapeError` over every route/nav `permission` and every
- **Names are authored in plugin code; only grants live in Keto.** The host collects every installed declared name), fail-loud like every other manifest rule — not only in the admin GUI, which an
plugin's declarations into one catalog (`declaredPermissions` → `ctx.declaredPermissions`), and operator removes by not copying the example in. Decisions around it:
that catalog *is* the list the admin screens offer. Hence **no Permissions admin screen**: nothing - **Names are authored in plugin code; only grants live in Keto.** The host collects every
in a GUI invents a name, and holding one is a property of a user or group, edited as a checkbox installed plugin's declarations into one catalog (`declaredPermissions` → `ctx.declaredPermissions`),
list there. A Keto tuple naming something no installed plugin declares gates nothing, is not and that catalog *is* the fixed list the admin screens offer. So there is **no Permissions admin
offered, and is never revoked by an unrelated save — the picker only speaks for what it showed. screen**: nothing in a GUI invents a name, and holding one is a property of a user or a group,
- `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`): users are edited as a checkbox list on those two screens. A tuple in Keto naming something no installed
the *host's*, and cross-plugin sharing is a goal. Cost: collision-freedom is a convention rather plugin declares gates nothing and is not offered — and a save never revokes it, since the picker
than structural. Accepted — the alternative penalizes the sharing case. only speaks for what it showed. Decided with the maintainer 2026-08-05, replacing the CRUD
- **Declaring a permission stays optional.** Mandatory declaration would let `findConflicts` see all Permissions screen.
overlaps, but would then warn on exactly that legitimate sharing case. Shape is enforced; - `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`). Deliberate
declaration is not. cross-plugin sharing is a goal, so the pre-2026-08-05 `<id>:<action>` guidance was wrong: users
- `ADMIN_PERMISSIONS` **defaults to empty**, and **an unusable value is dropped with a warning, are the *host's*, not the admin plugin's. Cost: collision-freedom became a convention rather than
never fatal** — fail-loud belongs at the manifest boundary where a developer authored the structural. Accepted — the alternative penalizes the sharing case.
mistake, whereas `bootstrap` gates `web`, so refusing operator env takes the whole stack down - **Declaring a permission stays optional.** Requiring every gated route to declare its permission
(`e2e-tests/compose.auth.yml` seeds a bad value to prove the container survives one). The seed is would make `findConflicts` see all overlaps, but would then warn on exactly the legitimate
a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs sharing case above. Shape is enforced; declaration is not.
`docker compose up -d`, not `restart web`. `bootstrap`'s matching `./plugins` mount belongs in - **There is no name-minting path in the GUI at all**, which is what makes the discovery check the
`compose.override.yml` and nowhere else: in the base file it would desynchronise prod and collide whole story: the only way a name comes into being is a plugin declaring it, and discovery refuses
with the e2e stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a a badly-shaped declaration at boot. An earlier revision of this branch enforced the rule in the
read-only parent is EROFS and the container never starts). Valid while bootstrap is the only Permissions screen's create form instead and needed a second guard for the assign form, which
writer of grants. could also mint one — deleting the screen removed both.
- **`actionForMethod` is plugin-local and must not migrate into `@plainpages/plugin-api`.** Inside the admin - `ADMIN_PERMISSIONS` **defaults to empty**: every permission is owned by the plugin that gates on
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes it, and a host-invented default would gate nothing. This makes the seed a function of what
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport `bootstrap` discovers, and a plugin dropped in after first boot therefore needs
verb — a route table must answer "what does this need?" on its own. `docker compose up -d` (which re-runs the one-shot), not `restart web`. The base file gives
- **A gate is one of three, named exactly once, and `session` is a first-class one.** A route or nav `bootstrap` and `web` the same baked `plugins/`; only `compose.override.yml`'s dev-only `.:/app`
node names exactly one of `public`, `session`, `permission` — discovery refuses none, two, and a makes `web` diverge onto the host tree, so the matching `./plugins` mount for `bootstrap` lives
flag spelled anything but `true`, so a forgotten gate fails the boot rather than publishing a page. **there and only there** — in the base file it would desynchronise prod and collide with the e2e
`src/auth/gate.ts` is the one home of the rule the plugin router, the host's own route table and stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a read-only
the menu all read. Exactly-one-gate is a discovery-time rule on manifests, not a runtime parent is EROFS and the container never starts). Valid while bootstrap is the only writer of
invariant: `allows({}, user)` stays open **by design**, because the central override's `groups` grants.
builds header nodes that carry no gate. Making `allows` fail closed would hide every - **`actionForMethod` is plugin-local and must not migrate into `#plugin-api`.** Inside the admin
operator-grouped section. `session` exists because a plugin whose data is example it buys one thing: the route table and the in-handler guard derive from one function, so
the visitor's own — their upstream account, their own tokens — has no distinction a permission could 29 routes × 2 gate sites cannot drift. As a general mechanism it would make authorization a
name; the alternative, granting every newly registered user a permission, couples the identity function of the transport verb, and a route table must answer "what does this need?" on its own.
lifecycle to a Keto write that nothing retries when it fails. A page scoped to "mine" joins on - **A `:read`-only holder must never be shown a write affordance.** The split created a real read-only
`ctx.user.id`, never the email — an address is user-changeable and can be reassigned to someone operator (a helpdesk account with `users:read`), and the host's 403 is the backstop, not the UX: the
who would then inherit the previous holder's rows. list/detail models carry `canWrite` and the views drop create/save/delete/add/remove, while the
- **The reference plugin's two shift pages duplicate a view model and markup on purpose.** An example permission picker still renders — disabled — because *seeing* who holds what is the point of `:read`.
is read far more often than it is changed, and each page reads top to bottom on its own. **Valid A *write-intent GET* — a create form or a delete-confirm page — is the exception to
while `examples/plugins/scheduling` stays a teaching artifact rather than a maintained product.** `actionForMethod`: it gates on `:write` (declared in the route table and passed to the handler's
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry guard, so the two still agree), because a page whose only purpose is to start a write should refuse
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders, a reader rather than render a form whose submit 403s.
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create Two grant-specific guards go with it, both restoring behaviour the deleted Permissions screen had:
form, a delete-confirm page) is the exception to `actionForMethod` and gates on `:write`. Two you cannot revoke your own **direct** grants on the Users screen (self-lockout would need a `curl`
grant-specific guards go with it: you cannot revoke your own **direct** grants (self-lockout would against Keto to undo, which the operator persona can't do — same shape as the self-deactivate/
need a `curl` against Keto to undo), and a permission held *through a group* renders self-delete guards), and a permission held *through a group* renders ticked-but-disabled rather than
ticked-but-disabled, because unticked stated the opposite of the truth. The group paths are unticked, because showing it unticked stated the opposite of the truth and unticking it wrote
guarded too: unticking a permission on a group you are in, removing yourself (or a nested group you nothing while looking like a successful revoke. **Known gap, same scope the deleted screen had:**
are in) from its members, or deleting the group is refused when you would lose a permission you the group paths are unguarded — unticking a permission on a group you belong to, removing yourself
don't hold directly. from it, or deleting it can all still strip your own effective access. The robust "last effective
- **`users:write` and `groups:write` are equivalent to full administrative access**: `groups:write` holder" check needs a reverse Keto query and is deferred. Raised by the architecture + product +
adds you to any group, including one holding every permission; `users:write` mints a recovery code stability reviews 2026-08-05.
for any account. The containment the split buys is real on the **read** half only (`users:read` is - **`users:write` and `groups:write` are equivalent to full administrative access**, and the split
a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs. does not change that: `groups:write` adds you to any group, including one holding every permission;
- **The CSRF token is deliberately unbound to the session** (`src/auth/csrf.ts`; README → Security `users:write` mints a recovery code for any account. The containment the split buys is real on the
model): the usual cost of a signed double-submit token, covered by `SameSite=Lax` and HSTS. **read** half only (`users:read` is a safe helpdesk grant). Don't let the per-resource naming imply
**Valid while the session cookie stays `SameSite=Lax` or stricter.** otherwise in docs. Raised by the architecture review 2026-08-05.
- **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed - **Plainpages says "user" everywhere; Ory's word for it is "identity".** Kratos calls the record
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors an identity, but Ory's own docs state it uses that term *interchangeably* with "users" and
Kratos' wire shape — don't rename it. "accounts" — so this is house style, not a renamed concept, and "user" is the word readers
already know (Nielsen's heuristic #2: match between the system and the real world). One note in
### i18n README → Auth records the mapping so nobody has to rediscover it. The single exception is the
`Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape and keeps Ory's
name — don't rename that one.
- **The locale lives in the URL, never in a cookie.** `?locale=sv-SE` → `Accept-Language` → `en-US`, - **The locale lives in the URL, never in a cookie.** `?locale=sv-SE` → `Accept-Language` → `en-US`,
and when the URL asked for one the host carries it onto the links it renders. A cookie would make a and when the URL asked for one the host carries it onto the links it renders (`ctx.localeHref`).
page's language invisible in its address and unshareable; the cost is that a plugin wraps its own A cookie would make a page's language invisible in its address and unshareable; the cost is that a
hrefs. Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a lone language from plugin must wrap its own hrefs. Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a
`Accept-Language` takes the first regional catalog for it. lone language from `Accept-Language` takes the first regional catalog for it. Decided 2026-08-03.
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell, `pagination`,
`filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every href in
`localeHref`; nav and sign-in are wrapped in `chrome.ts`; the two GET forms carry it as a hidden
`locale` input, since a GET submit replaces the whole query string. **A form's `action` counts as a
link** — sign-out, consent and auth-card forms carry it too, or picking a language and then saving
anything drops back to `Accept-Language`. The obligation stays on the building block, never on each
call site. `ctx.localeHref` remains for hrefs a plugin's own markup emits. The one round-trip that
cannot carry it is the Kratos sign-in POST (absolute off-site URL).
- **`locale` is a host-owned query param** — in `parseListQuery`'s reserved set, so a localized list
page doesn't hand a plugin a phantom `locale` filter. The i18n view locals (`t`, `locale`, `locales`,
`localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's
`data` so a collision loses the key instead of breaking the shell — 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` - **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 — keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` says that locale needs —
mismatch stops startup. A plugin may ship fewer locales than the host (its strings fall back to and a mismatch stops startup, same fail-loud contract as a bad manifest. A plugin may ship fewer
`en-US` per key), never one the host lacks. 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` - **The core building blocks carry the locale; a plugin doesn't have to.** The shell (breadcrumbs),
for the core, `locales/plugins/<id>/<tag>.ts` for a plugin; a new tag adds a language, an existing `pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every
one replaces that catalog wholesale. Adding a language must never require forking the image. The href they render in `localeHref`; the nav and the sign-in link are wrapped upstream in `chrome.ts`;
SHIPPED `en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is and the two GET forms
checked rather than trusted (one compared only against itself would boot green with the whole UI (filter bar, rows-per-page) carry it as a hidden `locale` input, since a GET submit replaces the
rendering keys). whole query string and no href wrapper can reach it. Putting the obligation on each call site was
- **The language picker is on every page, POST-rendered ones included.** A POST-rendered URL often tried first and missed five of eight sites inside one commit — including the admin screens.
answers no GET (`POST /admin/users/:id/recovery`), so the host resolves the picker's target `ctx.localeHref` remains for hrefs a plugin's own markup emits (the admin example's delete links).
(`app.ts` → `switchBase`): this path when it answers GET, else the same-origin Referer, else `/`. **A form's `action` counts as a link** — a POST replaces the URL as completely as a GET submit, so
Accepted cost: switching language there leaves that POST's own result behind. Valid while the picker the sign-out, consent and auth-card forms carry it too; without that, picking a language and then
is expected on literally every page — if that softens, hiding it after a POST is simpler. saving anything drops back to `Accept-Language`. The one round-trip that cannot carry it is the
- **An unknown translation key renders as itself.** That single rule lets a nav label, branding, or a Kratos sign-in POST, whose action is an absolute off-site URL.
menu `rename` be either a key or plain text without a second field or a migration. Don't "fix" it Decided 2026-08-03 after an architecture review; a second pass then found breadcrumbs still raw,
into a loud failure: a manifest with plain labels must keep working. so: when a link renders from the core chrome, it is the chrome's job to carry the locale.
- **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value; - **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set (`list-query.ts`),
one carrying markup uses `<%- %>`, and then its `{{vars}}` are escaped at the call site. Don't move so a localized list page doesn't hand a plugin a phantom `locale` filter; the i18n view locals (`t`, `locale`, `locales`, `localeHref`,
escaping into `t()` — every other value in a view would become the odd one out. `localeParam`, `localeSwitch`, `dir`) are likewise reserved names, merged after a handler's `data`
- **RTL is out of scope until there is a real use case.** `textDirection` sets `<html dir>` because so a collision loses the key instead of breaking the shell.
that is free and correct, but the stylesheet keeps physical `left`/`right` properties; a genuine RTL - **The language picker is on every page, POST-rendered ones included.** Maintainer's call
locale needs those moved to logical ones first. Valid while no deployment needs an RTL language. 2026-08-04, overriding an earlier decision to hide it there. The problem it was hiding is real: a
POST-rendered URL frequently answers no GET (`POST /admin/users/:id/recovery`), so a link back to
### UI it dead-ends on a 405. The host therefore resolves the picker's target (`app.ts` → `switchBase`):
this path when it answers GET, else the same-origin Referer, else `/`. Accepted cost: switching
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then language on such a page leaves that POST's own result behind (a re-rendered form's input, or a
owns open/close — the only zero-JS way to dismiss by clicking outside — and the panel sits in the one-time recovery code). Valid while the picker is expected on literally every page — if that ever
top layer, so a row kebab is not clipped by `.table-wrap`'s `overflow`. Four rules hold it softens, hiding it after a POST is the simpler answer.
together: the panel carries **`position-anchor: auto`** (a bare `anchor()` resolves to nothing in - **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
all three engines); it stays the trigger's **next sibling inside the `.menu` wrapper**, which the `dashboard`) and an `onRequest` short-circuit dispatch a plugin's handler, so they build the
open-state style and the old-browser fallback both read; the partial **requires a caller-named context with `contextFor(pluginId)` exactly as a plugin route does — otherwise `ctx.t` is the core
`id`** and fails loud without one, since that is the `popovertarget` idref (never generate one — translator and the plugin's own keys render as bare keys on the pages it owns. Found by review
nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor 2026-08-03 after all three paths shipped with the host's context.
`aria-haspopup` is written**: every engine maintains the first itself on a declarative - **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`** — `locales/<tag>.ts`
`popovertarget` invoker, so a hand-written one replaces a live state with a static lie, and the for the core and `locales/plugins/<id>/<tag>.ts` for an installed plugin, each adding a language or
second would promise `role="menu"` semantics these panels don't implement. **That guarantee is the replacing that tag's catalog wholesale. Adding a language must never require forking the image or a
declarative attribute's alone** — open a panel from script and no engine applies it, so vendored plugin folder. The SHIPPED `en-US` (core's, or the plugin's own) stays the parity baseline
"enhancing" one of these triggers is what would cost it its accessibility. `<details>` stays where even when the mount replaces it, so a mounted catalog is checked rather than trusted (one compared
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the only against itself would boot green with the whole UI rendering keys), and each half is reported
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep under the folder it actually lives in.
the two in step. - **RTL is out of scope until there is a real use case.** `textDirection` sets `<html dir>` from the
- **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s locale's script because that is free and correct, but the stylesheet keeps physical `left`/`right`
`overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame: properties — a genuine RTL locale needs those moved to logical ones first. Don't convert the CSS or
they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` — file findings about it on spec. Maintainer's call 2026-08-04; valid while no deployment needs an
a label click focuses it, and a browser scrolls a focused element into view. RTL language. A catalog there
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it for a new tag adds a language; one for a tag the image ships replaces that catalog wholesale, held
is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference to the same parity check. Adding a language must not require forking the image.
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an - **An unknown translation key renders as itself.** That single rule is what lets a nav label,
unknown sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test branding, or a menu `rename` be either a key or plain text without a second field or a migration.
catches anything reaching the nav). 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,
### Build, test & release so nothing is double-escaped; a message carrying markup uses `<%- %>`, and then its `{{vars}}` are
escaped at the call site (see `views/partials/pagination.ejs`). Don't move escaping into `t()` —
- **Deps install to `/node_modules`, above `WORKDIR /app`** — Node resolves upward, so dev's `.:/app` every other value in a view would then be the odd one out.
bind mount has nothing to shadow. Not a volume at `/app/node_modules`: the daemon creates a mount
destination as root whatever `--user` says, leaving a root-owned dir in the checkout. Nothing may
sit at that path now — it shadows `/node_modules` silently (`src/compose.test.ts` guards the compose
files, `.dockerignore` the image).
- **A container whose output a human then edits or deletes runs as `--user "$(id -u):$(id -g)"`** —
the E2E runner (artifacts) and a lockfile edit, or the output is root-owned and needs `sudo`, which
a dev box may not have. Not universal: `bootstrap` writes `jwks.json` as root when it is absent on
first boot; the committed dev key makes that rare, and when it happens the rotation runbook's
host-side `>` needs the file re-owned first (valid while the dev key ships committed). Three
consequences: `e2e-tests/artifacts/` is *tracked* (`.gitkeep`), since an absent bind-mount source is
daemon-created as root and that uid then cannot write it (README → Upgrading); the runner image sets
`HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an unwritable `/`; and
rootless Docker wants the flag *dropped*, container root already being the invoking user. Baking a
`USER` in instead does not work — the image's `pwuser` is 1001 and no fixed uid matches every host.
`src/compose.test.ts` guards every documented command, `src/ci-gate.test.ts` the gate's own.
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
`e2e-tests/console-guard.ts`, which fails a test on a console error/warning or uncaught exception on
any page it opened. A zero-JS app has nothing to say in the console, so the bar is *zero* rather than
a curated tolerance list; the two exceptions are narrow — a module-level allowance for the COOP header
Chromium drops (the e2e stacks serve plain http over container hostnames), and `allowConsole(re)` for
a test whose own page provokes a message on purpose. `src/e2e-console-guard.test.ts` locks the wiring
in the *unit* gate, since a spec importing `test` straight from Playwright — or minting a page with
a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. Accepted cost: a page
outliving its test can log late and fail the next one.
- **The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium.**
`visual.spec.ts` + `language.spec.ts` are side-effect-free, so parallel runs don't collide, and a
console message only appears in the engine that renders the page (`ORY_FREE` in
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
so widening them means a stack per engine.
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** Both git channels in
`ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the destination, so
`git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a source file that
was gone. `src/ci-gate.test.ts` locks the flags as a
*text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
`release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard
skipped. `README.md` is the one markdown 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 - **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 `docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`:
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that concurrent jobs can race (one job's logout can 401 another's push — recover by re-running),
file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's and tokens sit in that file between login and logout. Same class: concurrent runs share the
web-image build races another run's container creation on the `<project>-web` tag. Accepted for a workspace dir, so ci.sh's web-image build races another run's container creation on the
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites. `<project>-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow
`concurrency` group if it ever bites.
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** No test, build step or
workflow reads a markdown file (`README-dockerhub.md` is pasted into Docker Hub by hand), so a
nested `examples/plugins/admin/README.md` edit is as safe to skip as `README.md`, and narrowing it
would spend the full gate on one. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`:
rename detection names only the destination, so `git mv src/app.ts notes.md` otherwise read as docs
and skipped the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a
*text* guard — the test image (`node:24.19.0-alpine3.24`) ships neither `git` nor `bash`, so it
cannot exercise the function; behaviour was verified against a scratch repo across ten scenarios.
Revisit if a `.md` ever becomes load-bearing. Decided 2026-08-05.
- **Plainpages is pre-announcement: no tags, no releases.** The repo carried tags up to `v0.2.2` from
the `auto-release` job; all of them — and the semver container tags — were deleted 2026-08-05, and
the job is gated behind the `AUTO_RELEASE` Actions variable (unset ⇒ skipped, the fail-safe
direction on every unknown-`vars` path). A version only communicates to consumers, and there are
none; same reasoning that freezes `HOST_API_VERSION` at 1.0.0. Note the coupling:
`registry-cleanup` keeps a hash image only while its commit is a branch head *or* release-tagged,
so with zero tags only branch heads survive the nightly prune — a hand-cut tag must sit on `main`'s
tip. `mirror.yml` pushes tags with `--prune` so the deletions actually reach the public GitHub
mirror; that makes the runner's tag view load-bearing (hence `fetch-tags: true`) and means a tag
or Release created on GitHub is swept away, so releases are cut on Gitea only. Valid until the
maintainer says Plainpages is ready to show people.
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
owns open/close, which is the only zero-JS way to dismiss a menu by clicking outside it (the whole
point), and the panel sits in the top layer so a row kebab is no longer clipped by `.table-wrap`'s
`overflow`. Four rules hold it together, none of them cosmetic. The panel carries
**`position-anchor: auto`** — a bare `anchor()` resolves to nothing in Chromium, Firefox *and*
WebKit alike, which is why the popover test in `visual.spec.ts` runs in all three rather
than resting on a one-time manual measurement. The panel stays the trigger's **next sibling inside
the `.menu` wrapper**, because the open-state style and the old-browser fallback both read that
adjacency, and a two-element partial cannot be dropped into an arbitrary layout. The `menu` partial
**requires a caller-named `id`** and fails loud without one: it is the `popovertarget` idref, and
generated random ids were tried and rejected the same day — nondeterministic HTML forecloses the
still-open caching decision and names nothing a reader can use. And **neither `aria-expanded` nor
`aria-haspopup` is written**: a zero-JS invoker cannot keep the first truthful, and the second would
promise `role="menu"` keyboard semantics these panels do not implement. `<details>` stays where it
means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
profile menu because its trigger composes escaped user values and its one item is a CSRF POST form,
neither of which the partial's `Item` shapes cover — keep the two in step, or fold it in if
`todo.md`'s "does the profile dropdown still earn a dropdown" settles the other way. Decided 2026-08-05.
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract.** It is
deliberately not re-exported from `#plugin-api`, and README → Nav & permission gates already tells an
author that using a new icon means registering it there. So the palette may narrow when the last
reference to an id goes — `i-gear` left with the settings menu 2026-08-05 — and a plugin needing one
gets it re-registered in the same change. Accepted cost: an unknown sprite id renders a blank icon
instead of failing loud; the `every icon <use> resolves to a defined <symbol>` e2e test catches it for
anything reaching the nav. Removing an id is a core edit, so weigh it per icon rather than sweeping the
registry — a few ids are registered ahead of a caller (see `todo.md`).
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
`e2e-tests/console-guard.ts`, which watches every page a test opens: a console error or warning, or
an uncaught exception, fails that test. A zero-JS app has nothing to say in the console, so the bar
is *zero* rather than a curated list of tolerated noise — and the two exceptions are explicit and
narrow: one module-level allowance for the COOP header Chromium drops because the e2e stacks serve
plain http over container hostnames (a deployment serves https, where it applies), and
`allowConsole(re)` for a test whose own page provokes a message on purpose — the 404 spec, whose
navigation Chromium and WebKit log. Each record carries the message's origin URL, so that allowance
can name the page under test and still see a sub-resource of it 404. `src/e2e-console-guard.test.ts`
locks the wiring in the *unit* gate: a spec importing `test` straight from Playwright — or minting a
page with a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. The buffer
clears at teardown rather than setup so a `beforeAll` is watched too (full-flow runs a whole login in
one); the accepted cost is that a page outliving its test, as a serial describe's does, can log late
and fail the next test instead of its own. Verified by negative control in all three engines.
- **The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium.**
`visual.spec.ts` + `language.spec.ts` are side-effect-free, so three parallel runs don't collide,
and a console message only appears in the engine that renders the page — the reason the per-test
`@engines` tag is gone: the whole Ory-free suite is the engine matrix now (`ORY_FREE` in
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
where a second engine's run would race the first, so widening them means giving each engine its own
stack. Screenshots are written per project name for the same reason. Decided 2026-08-05.
## Docker only — no host tooling ## Docker only — no host tooling
@@ -359,7 +308,6 @@ Docker Compose. **Never run `node`, `npm`, or `tsc` on the host.**
```bash ```bash
docker compose up # dev server, live reload docker compose up # dev server, live reload
docker compose run --rm --no-deps web npm run typecheck # strict type check (--no-deps: skip Ory) docker compose run --rm --no-deps web npm run typecheck # strict type check (--no-deps: skip Ory)
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --rm --no-deps --build e2e npm run typecheck # the E2E specs
docker compose run --rm --no-deps web npm test # tests docker compose run --rm --no-deps web npm test # tests
docker compose -f compose.yml up --build -d # production docker compose -f compose.yml up --build -d # production
``` ```
@@ -368,108 +316,102 @@ docker compose -f compose.yml up --build -d # production
`README.md` serves two readers, in this order — preserve it when editing: `README.md` serves two readers, in this order — preserve it when editing:
1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets the 1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets
stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands the stack up (`docker compose up`, sign in) and a *minimal* plugin live. Nothing comes
copy-pasteable; deeper detail lives in its own section, linked. before Quick start — no philosophy, no rationale. Keep its commands copy-pasteable and the
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered example plugin as small as possible; deeper detail lives in its own section, linked.
by **what an adopter reaches for first**, not by architectural layering: Goals → Audience → Overview → Glossary → Users, groups 2. **Returning developer (rest).** A **Contents** ToC immediately after Quick start, then
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email → sections ordered by **what a developer adopting Plainpages reaches for, in priority
Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file order** — not by architectural layering. The value that sets the order: getting up and
map → Extending. Place a new section by how early an adopter needs it. **Users, groups & running **building plugins** comes first, then **configuring and securing** the system
permissions precedes Building plugins** because a manifest's `permission:` gate is unreadable (Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
without the model, and it is the one home for that model. deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
Users, groups & permissions → Building plugins → menu/blocks/interactivity →
Configuration → Auth → Email → Architecture → Testing → Production → Observability → the
JWT-rotation runbook → the Project-layout file map → Extending. When adding a section, place
it by this value (how early an adopter needs it), not by where it sits in the stack.
Keep the ToC in sync when you add/rename/remove an `H2`/`H3`. **Don't document internals** — how a **Users, groups & permissions precedes Building plugins** because a manifest's
script reaches a decision, what a function guards; a developer reads that off the code in seconds. `permission:` gate is unreadable without the model, and operators need it as much as plugin
The README earns its length on how to use and operate Plainpages, the external contracts, and authors. It is the one home for that model — the plugin and auth sections link to it rather
one-time setup. A file-map or table row gets a clause, not a paragraph. than restating it.
When editing: put content in the section it belongs to (don't prepend rationale above Quick
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
one home, linking to it rather than restating (credentials, env vars, rotation steps).
**Don't document internals here.** How a script reaches a decision, why one run behaved
differently from another, what a function guards — a developer doesn't need it day to day and
can read it off the code or a run's log in seconds. Prose like that only makes the README
longer and harder to consume, for humans and machines alike. It belongs in the code it
describes, or nowhere. The README earns its length on what you cannot dig out: how to use and
operate Plainpages, the external contracts, and one-time setup (secrets, accounts, tokens).
Same test before adding a row to a table or the file map — a clause, not a paragraph.
## Rules ## Rules
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable** - Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or decorators. Import (`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or
local modules with their `.ts` extension. decorators. Import local modules with their `.ts` extension.
- **No `.mjs`.** Write modules as `.ts` — even standalone scripts run in bare `node:24` containers. - **No `.mjs`.** Write modules as `.ts` (Prio 1) — even standalone scripts run in bare
If a file genuinely must be plain JavaScript, use `.js`; `"type": "module"` is set in both `node:24` containers (the e2e mock servers, `examples/shifts-upstream/server.ts`): Node
strips types and detects ESM from syntax, no package.json needed. If a file genuinely
must be plain JavaScript, use `.js` (Prio 2); `"type": "module"` is already set in both
`package.json`s, so `.js` is ESM. `package.json`s, so `.js` is ESM.
- **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit. - **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit.
- Before finishing a change, run the typechecks and tests above; all must pass. - Before finishing a change, run the typecheck and tests above; both must pass.
- Tests use the built-in `node --test` runner — no test framework dependency. - Tests use the built-in `node --test` runner — no test framework dependency.
- English everywhere. - English everywhere. Keep code comments short and information-dense. Self explained code
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never without any comment at all is the preferred solution.
ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images - Do not comment about history in the code or README. Like "This function included X before,
by tag. but it moved to Y".
- **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist*: its - Do not comment about the absence of things, if it is not very unexpected. Banned is things
rules name exactly what carries the trailer, so a dependency outside them never escalates the like "This function does not calculate pi, that is done in function Z".
release version and nothing fails to say so. A new manifest, compose file, custom manager or dep - Pin all dependencies and Docker images to exact, human-readable **semantic
type is a decision: can it reach a running Plainpages? If yes it needs a rule; if no, record nothing versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
and let it ride the next patch. exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
- **`HOST_API_VERSION` *is* the release version.** Its `major.minor` must equal the release tag's, and `node:24.16.0-alpine3.24`).
both release paths refuse a tag that disagrees (`release-tooling/contract-version.ts`). The patch - **`HOST_API_VERSION` is frozen at 1.0.0 until the first external install**, even for additive
digit may lag on purpose: `checkApiVersion` contract changes (i18n added four `RequestContext` fields and several barrel exports without a
ignores patch, and auto-release cuts patch releases with no commit to bump a constant in. So a minor bump). Valid while nothing is installed against it: with no third-party plugin in the wild,
dependency update big enough to force a **minor** is plugin-visible by definition — `auto-release` a version bump can only produce noise. The promotion trigger is the first external plugin — from
stops rather than tagging, and the fix is to bump `HOST_API_VERSION` to that `X.Y.0` in a PR, merge then on, follow the versioning table in README → Contract versioning as written. Decided 2026-08-03.
it, then tag. Never bump it to "catch up" with a patch release. **The contract surface **The frozen surface includes `views/partials/*.ejs`**, not just the manifest and the barrel: the
includes `views/partials/*.ejs`** — the view resolver makes every core partial an `include()` root view resolver makes every core partial an `include()` root for a plugin's views, so their option
for a plugin's views, so their option names and emitted markup are author-visible. Know the hole names and emitted markup are author-visible (under this freeze the popover change dropped the `menu`
that leaves: discovery fails loud on a bad `apiVersion`, but `include("menu", { open: true })` partial's `open?` and rewrote its markup). Know the hole that leaves — discovery fails loud on a bad
silently ignores a dropped option, so the partial vocabulary is a surface the version check cannot `apiVersion`, but `include("menu", { open: true })` silently ignores the option and a plugin styling
police for you. `.menu > summary` silently loses it. Promotion must cover the partial vocabulary too. Added 2026-08-05.
- **The contract surface also includes the packaging promises** (README → Plugin dependencies): the - A plugin's `apiVersion` is a **hand-written literal** semver — the host version the
barrel is ambient at `/node_modules` with nothing for a plugin to declare, `"type": "module"` is plugin was built against — bumped by hand on rebuild, **never** the host's
mandatory, and the host neither upgrades nor dedupes a plugin's dependencies. Same hole as the `HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the
partials — move the publish point, rename the package or start hoisting and every installed plugin host, so `checkApiVersion` can never fire and a breaking change slips through silently.
breaks with no version signal. Note the promise is deliberately *not* "your deps are yours alone": - **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler
build-time dedupe for baked images stays open, module-instance sharing stays unpromised. per `{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed
- **Publishing `@plainpages/plugin-api` to a registry is deferred, not rejected.** Today it is `%`-encoding — no manual path-slicing/decoding). Don't funnel many routes into one dispatcher
`private` and shaped as a shim — `index.ts` re-exports `../src/…`, so `npm pack` would ship a that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
broken tree. The trigger is the first plugin author outside this repo — the first who cannot has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
typecheck against a mounted host tree. Whoever does it must first make the artifact self-contained resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
(types-only `.d.ts`, or move the barrel into `plugin-api/`). - **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version it was built canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin
against — bumped by hand on rebuild, **never** the host's `HOST_API_VERSION` constant. Importing routing, 405/404 and error mapping. The pure parts are already extracted and separately tested; what
the constant makes every plugin always equal the host, so `checkApiVersion` can never fire. remains is orchestration. Planned split along those seams; don't grow it further without taking one
- **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler per out. Raised by the architecture review 2026-08-03, deliberately not done inside the i18n change.
`{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed `%`-encoding). - Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer
Don't funnel many routes into one dispatcher that re-parses `ctx.url.pathname`: it duplicates the agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
URL shape, ignores the router's params, and has to re-handle HEAD. Factor shared per-request setup
into a small `withX` wrapper — see `examples/plugins/admin/`.
- **A function stays within 50 lines and complexity 20** (ESLint's defaults, measured by
`src/function-limits.test.ts`). Split one that outgrows them; never add it to the test's
`grandfathered` list or raise a ceiling there.
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer agents.
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in - **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
`src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own `src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own
`i18n/`. Operator/developer-facing text — boot errors, log messages, guard messages — stays English. `i18n/`. Operator/developer-facing text — boot errors, log messages, guard messages — stays English.
A pure view-model builder takes an optional `t` defaulting to its own English, so a unit test reads A pure view-model builder takes an optional `t` defaulting to its own English, so a unit test reads
in words; handlers pass `ctx.t`. in words; handlers pass `ctx.t`.
- **One verb per action in the English UI: sign in, sign out, create account.** Not "log in", "log - **One verb per action in the English UI: sign in, sign out, create account.** Not "log in",
out" or "sign up", inflections included — a second spelling for one button reads as a second thing; "log out" or "sign up", inflections included — a second spelling for one button reads as a second
the noun ("a sign-in error") is unaffected. A plugin's catalog and every other locale follow the thing; the noun ("a sign-in error", "the sign-in identifier") is unaffected. A plugin's catalog and
same rule in their own language. An unmapped Kratos id renders Kratos' own wording — map the id when every other locale follow the same rule in their own language. An unmapped Kratos id renders
it matters. **Held by the author, never by a test:** slightly different wording is often the right Kratos' own wording — map the id when it matters. **Held by the author, never by a test:** as the
call, and a build-failing check takes that judgment away. UI grows, slightly different wording is often the right call, and a check that fails the build on
- Use well formed, standard compliant, rich URIs. **State lives in the URL or on the server, never in a word takes that judgment away. Maintainer's call 2026-08-05, dropping the guard that shipped
a cookie.** Prefer state in the URL over POSTing it, for example on list pages with filters and with the rule.
pagination. A message for the page a redirect lands on rides its query string — `info-msg`, - Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
`warn-msg`, `error-msg` — since a fragment never reaches the server. A cookie carries only what for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
must be bound to the browser: the session (`plainpages_jwt`) and the CSRF token (`plainpages_csrf`). and not "ids=x,y".
Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not `ids=x,y`.
## Comments
Default to **no comment**. Delete one that restates the adjacent code, repeats a convention used
elsewhere, justifies self-evident code, or records history. Write one only for what a competent
reader of *this* codebase could not infer: a surprising why, a footgun, an invariant, an external
constraint. See [Prose discipline](#prose-discipline).
## Scoring run
The comprehension panel's seats fill their prompts from these fields.
- **Language:** strict TypeScript on Node 24, with EJS views and plain CSS
- **Kind:** a server-rendered web GUI host with a plugin system
- **Domain:** web authentication, authorization and server-rendered admin GUIs
- **Domain docs:** the Ory Kratos, Keto and Hydra documentation
- **3am question:** a visitor on an old TV browser reports that the sign-in button is blank. Which file and rule?
-241
View File
@@ -1,241 +0,0 @@
# Changelog
The release version **is** the plugin contract version (`HOST_API_VERSION`), so a minor is a
contract break: a plugin's `apiVersion` must match the host's `major.minor` or discovery refuses it
at boot. Entries start at 0.3.0.
## Unreleased
### A malformed URL variable's boot error no longer prints the value
When a URL variable such as `PLUGIN_DB_URL`, `PLUGIN_DB_ADMIN_URL` or `OTLP_ENDPOINT` can't be parsed,
the boot error names the variable and not its value, so a DSN whose password breaks parsing doesn't
put the password in the log.
### A secret plugin setting's bad value stays out of the boot error
When a `secret` setting's value fails validation, the boot error no longer prints it, so a malformed
DSN doesn't put its password in the log.
### The admin's recovery-code notice links to the recovery flow the code belongs to
After an admin generates a recovery code for a user, the notice asks them to send the user both the
code and its link. The link opens the recovery flow the code belongs to, where the user enters it.
Before, the notice linked a new recovery flow at its email step, which doesn't accept the code.
### A verification or recovery code pasted with whitespace around it is accepted
The code field on the verification and recovery pages takes a code with spaces or tabs around it.
Pasting a code from an email often adds them, and before, the browser refused the code. Those two
forms now post to Plainpages, which trims the code and passes the form on to Kratos at
`KRATOS_PUBLIC_URL`. A visitor who picked a language now keeps it through both flows.
### A filter shows its selected and focused choice in browsers without `:has()`
On Chromium below 105, Firefox below 121 and Safari below 15.4, a filter's segments and chips now show
their native radio or checkbox, which marks the selected choice and takes the keyboard focus ring.
Before, they showed neither. Newer browsers look as before.
### The tests fail on a stylesheet feature the browser floor lacks
`npm test` checks every stylesheet in `public/`, `examples/` and `plugins/` against Chromium 87,
Safari 15 and Firefox 115: a feature one of them lacks fails it unless an `@supports` around it
names that feature. Mount a plugin in `plugins/<id>/` to check its stylesheets. Pages render as
before in every browser.
### Every release reaches Docker Hub, and the Hub page has a short description
Releases 0.4.1 to 0.4.20 were tagged in git but never published to Docker Hub or as semver tags in
the Gitea registry; they are now, and every later release is. The Hub page gains a one-line
description.
### The admin example stops an administrator locking themselves out through a group
On the Groups screen, unticking a permission on a group you are in, removing yourself (or a nested
group you are in) from its members, or deleting the group is refused when it would take away a
permission you don't hold directly. The page names the permissions, and another administrator can
make the change. The check errs towards refusing: it also refuses when you would keep the permission
through another group, or keep your membership another way.
### The admin example titles a read-only user page "View user"
A holder of `users:read` without `users:write` sees the user page titled "View user", with a "View"
breadcrumb, instead of "Edit user".
### Browsers cache stylesheets instead of downloading them on every page
Every `/public/` URL the host renders carries `?v=<hash of the file>` and is cached for a year as
immutable: the shell's stylesheets, favicon and logo, the `styles` a plugin passes to the shell, and
the stylesheets of the standalone error and consent pages. A changed file gets a new hash, so
browsers fetch it at once. A plugin names its stylesheets exactly as before. Any other `/public/` URL, such as a script
a plugin's own markup links, is cached for five minutes, then revalidated with `ETag` or
`Last-Modified`. A cache or CDN in front of Plainpages may now store `/public/` responses.
### A language chosen on a sign-in link no longer drops an absolute return target
`/login?return_to=<absolute URL>&locale=sv-SE` passed the visitor to the dashboard after sign-in and
dropped the OAuth2 login challenge in `return_to`. The target now reaches Kratos, and the chosen
language is lost across that round-trip instead.
### The admin example refuses a permission save made from an out-of-date page
Two administrators editing one user's or group's permissions no longer overwrite each other. The
form carries a token for the grants it showed. A save that would change something, made after those
grants changed, is refused with "These permissions changed while you had the page open", and the page
shows the current grants. A POST with no token is refused the same way, so a script posting to
`/admin/users/:id/permissions` or `/admin/groups/:name/permissions` must first read the token from the
page's `baseline` field.
### The admin example confirms a permission save and reports a partial one
A save now lands on the page with "Permissions saved." If the permission service fails midway, the
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 holder of `users:read` without `users:write` gets a "View" row action with an eye icon. The admin
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/admin/` must add them.
### Every page carries a smaller icon sprite
`i-chart`, `i-copy` and `i-download` are gone from the inlined icon sprite, so a plugin that
referenced one now renders a blank icon. Nothing in the host or the example plugins used them. Pick
another id from `views/partials/icons.ejs`, or inline your own SVG.
### The README defines its terms and states its intended scale
A new Glossary defines host, plugin, manifest, chrome, nav fragment, permission name, system plugin
and denylist. Plugin dependencies also states the scale Plainpages is sized for: a handful of plugins
per deployment.
### The mobile nav stays reachable on engines without `:has()`
At 860px and narrower, an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23,
and Firefox below 121) hid the sidebar off-canvas with no way to open it, so the pages the nav links
to could not be reached. Now the sidebar moves off-canvas, behind the hamburger, only where `:has()`
works. Elsewhere it stays in the flow above the content and the hamburger is hidden.
### The whole nav tree shows on engines without `:has()`
On an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23, and Firefox below 121)
every nested nav entry was hidden with nothing to open it. Now the tree folds only where `:has()`
works and stays fully open elsewhere, with its toggles hidden. The theme switch, which also needs
`:has()`, is hidden there.
### Button and badge text meets 4.5:1 contrast
The primary button and the current page button take their text colour from the new `--on-accent`
token: white in light mode, near-black in dark mode, where white on the accent was 2.7:1. The
light-mode success, warning and info badge text is darker. A plugin that overrides `--accent` or
`--l-accent` should set `--l-on-accent` and `--d-on-accent` to match.
### Unused auth stylesheet rules are gone
`public/css/auth.css` no longer holds the `.auth-view` screen switch, `.tpl-*` preview controls,
`.forgot-alert`, `.register-alert`, `.remember` and `.auth-foot`; no core view used them. A plugin
view that did must carry its own rules.
### Colours render on engines without `oklch()`
The theme palette in `public/css/styles.css` is hex, not `oklch()`. On an engine older than
Chromium 111 (Tizen 2024, webOS TV 24) `var(--accent)` was invalid, so the primary button was white
text on no background. A plugin that sets a colour custom property, such as `--accent` or `--l-accent`,
should use hex, `rgb()` or `hsl()` too.
### One horizontal inset across the content pane
The topbar, filter bar, pager, form page and empty-table message inset their content by `--pad-x`
(12px), as table cells do, instead of 16px. A plugin that overrides `--pad-x` now moves all of them
together.
### Pages are not kept by a shared cache
Every page and JSON response now carries `Cache-Control: private, no-store`, so a cache or reverse
proxy in front of Plainpages can't store a signed-in page and serve it to the next visitor. A route
whose page is the same for everyone can still set its own `cache-control` through
`RouteResult.headers`; assets are unchanged.
## 0.4.0
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
that relied on filling it scrolls the document instead.
### The document scrolls, and the chrome scrolls with it
`.app` was a `100dvh` box with `overflow: hidden`, so a page was only reachable below the fold if its
own wrapper was a flex child with `overflow-y: auto`. `.table-wrap` and `.shell-auth` were; nothing
else was, and a long page in `.form-page` clipped everything past the window in every engine.
Now the shell is `min-height: 100dvh` and nothing bounds the viewport. The sidebar and topbar scroll
with the page, and keyboard paging, back/forward scroll restoration and find-in-page work without a
page doing anything.
The sticky `thead` on `data-table` goes with it: a header only sticks to a scrollport that moves, and
there is no longer one. A plugin that wants a full-height pane owns that in its own stylesheet; the
shell offers no opt-out, per the simplicity priority in `AGENTS.md`.
### Upgrading a plugin
1. Set `apiVersion: "0.4.0"`.
2. A page that scrolled the whole window needs no change — it now scrolls the document.
3. A page holding a region that filled the content column (`flex: 1 1 auto; min-height: 0` with its
own `overflow`) no longer gets a bounded column to fill, so that region grows and the page scrolls.
Either let it, or give the region its own height in the plugin's stylesheet.
4. A `data-table` no longer scrolls its rows in a bounded region: the page scrolls, and the header
scrolls with it.
The sidebar stretches the whole document, so on a long page its footer — theme, language, profile and
**Sign out** — sits at the end of that page rather than the bottom of the screen.
## 0.3.0
**Breaking.** Set `apiVersion: "0.3.0"`, and name a gate on every route and nav node.
### A session is a gate of its own
`session: true` takes any signed-in user, with no grant to hold — for a page whose data is the
visitor's own (their upstream account, their own tokens), where there is no distinction a permission
could name. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
permission gate does.
Every route and nav node now names **exactly one** of `public: true`, `session: true` or
`permission: "<resource>:<action>"`, and a gate is spelled `true`:
- Naming **none** is refused. It used to mean public, so a forgotten gate published a page; it now
fails the boot instead.
- Naming **two** is refused, as before.
- Spelling one anything but `true` is refused — `public: false` and `session: "yes"` both set no gate
while reading as if they set one.
A section header gates nothing itself, so it takes `public: true` and lets each child decide; the
host still drops a header whose children all filtered out.
`Gate` is exported from `@plainpages/plugin-api`, and `Route` and `NavNode` extend it.
### Filter bars take a multi-select
The `filter-bar` partial gains a `multiselect` control — the same checkboxes on the same query
parameter as `chips`, but behind a button once the list is too long to lay on the bar. Config is
`{ name, legend?, note?, value?, options }`, and the panel says what a capped list left out.
### Fixed
- An identity carrying no email no longer yields a session at all. Login used to mint a JWT for one,
which every later request then rejected as anonymous — leaving the browser holding a dead cookie
and no way to tell why.
### Dependencies
- Node 24.20.0.
### Upgrading a plugin
1. Set `apiVersion: "0.3.0"`.
2. Give every route and nav node a gate. Anything that relied on omitting one was public — say
`public: true` outright.
A page that scopes rows to the signed-in visitor should join on `ctx.user.id`. An email address is
user-changeable and can be reassigned to someone else, who would then inherit the previous holder's
rows. The reference plugin's new `/scheduling/mine` page shows the shape.
+6 -12
View File
@@ -1,20 +1,14 @@
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag. # Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
FROM node:24.22.2-alpine3.24 FROM node:24.19.0-alpine3.24
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
# Dev deps kept so typecheck/test run in-image.
COPY package.json package-lock.json .npmrc /deps/
RUN cd /deps && npm ci && mv node_modules /node_modules && rm -rf /deps
# The barrel as a package, so a plugin folder can own a package.json. Linked because it re-exports /app.
RUN mkdir -p /node_modules/@plainpages && ln -s /app/plugin-api /node_modules/@plainpages/plugin-api
WORKDIR /app WORKDIR /app
# Reproducible install from the lockfile. Dev deps kept so typecheck/test run in-image.
COPY package.json package-lock.json .npmrc ./
RUN npm ci
COPY . . COPY . .
# The host uid running a lockfile edit has no home here, so npm's cache would land in unwritable /.
ENV npm_config_cache=/tmp/.npm
ENV PORT=3000 ENV PORT=3000
EXPOSE 3000 EXPOSE 3000
CMD ["node", "src/server.ts"] CMD ["node", "src/server.ts"]
@@ -2,15 +2,14 @@
A self-hostable foundation for server-rendered web apps — public or gated pages from a A self-hostable foundation for server-rendered web apps — public or gated pages from a
zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in. zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in.
Every domain feature is a drop-in plugin folder, with a Postgres database of its own if it wants Every domain feature is a drop-in plugin folder; the app is stateless, no build step.
one; the host itself is stateless, and there is no build step.
**Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>** **Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>**
([GitHub mirror](https://github.com/larvit/plainpages)) ([GitHub mirror](https://github.com/larvit/plainpages))
## Tags ## Tags
`X.Y.Z` · `X.Y` · `latest` — each is a release promoted from a CI-gated build. `X.Y.Z` · `X.Y` · `X` · `latest` — each is a release promoted from a CI-gated build.
Pin the exact `X.Y.Z` you deploy. Pin the exact `X.Y.Z` you deploy.
## Quick start ## Quick start
@@ -22,7 +21,7 @@ so there is nothing to clone. In an empty directory, save this as `compose.yml`:
```yaml ```yaml
services: services:
web: web:
image: larvit/plainpages:{{VERSION}} image: larvit/plainpages:0.0.2
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
@@ -41,7 +40,7 @@ services:
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user. # One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
bootstrap: bootstrap:
image: larvit/plainpages:{{VERSION}} image: larvit/plainpages:0.0.2
command: node src/auth/bootstrap.ts command: node src/auth/bootstrap.ts
depends_on: depends_on:
kratos: kratos:
@@ -54,7 +53,7 @@ services:
restart: "on-failure:5" restart: "on-failure:5"
postgres: postgres:
image: postgres:18.6-alpine3.23 image: postgres:18.4-alpine3.23
environment: environment:
POSTGRES_DB: ory POSTGRES_DB: ory
POSTGRES_PASSWORD: ory POSTGRES_PASSWORD: ory
@@ -131,7 +130,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.0 image: axllent/mailpit:v1.30.1
ports: ports:
- "8025:8025" - "8025:8025"
restart: unless-stopped restart: unless-stopped
@@ -143,7 +142,7 @@ volumes:
Extract the Ory config the image ships, then start: Extract the Ory config the image ships, then start:
```bash ```bash
docker run --rm larvit/plainpages:{{VERSION}} tar -cf - ory | tar -xf - docker run --rm larvit/plainpages:0.0.2 tar -cf - ory | tar -xf -
mkdir -p plugins mkdir -p plugins
docker compose up -d docker compose up -d
``` ```
@@ -163,7 +162,7 @@ Every behaviour is an explicit env toggle read at boot — no `NODE_ENV`. The co
| --- | --- | --- | | --- | --- | --- |
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | `admin@plainpages.local` / `admin` | the seeded first admin (bootstrap service) | | `ADMIN_EMAIL` / `ADMIN_PASSWORD` | `admin@plainpages.local` / `admin` | the seeded first admin (bootstrap service) |
| `APP_URL` | unset | canonical public URL; off-host visitors are redirected to it | | `APP_URL` | unset | canonical public URL; off-host visitors are redirected to it |
| `CACHE_TEMPLATES` | `false` | cache compiled templates and `/public/` asset versions (`true` in prod) | | `CACHE_TEMPLATES` | `false` | cache compiled templates (`true` in prod) |
| `CSRF_SECRET` | dev throwaway | signs the CSRF token — set a real one in prod | | `CSRF_SECRET` | dev throwaway | signs the CSRF token — set a real one in prod |
| `KRATOS_*` / `KETO_*` / `HYDRA_*` URLs | compose defaults | the Ory sidecar endpoints | | `KRATOS_*` / `KETO_*` / `HYDRA_*` URLs | compose defaults | the Ory sidecar endpoints |
| `LOG_FORMAT` / `LOG_LEVEL` | `text` / `info` | `json` for structured prod logs | | `LOG_FORMAT` / `LOG_LEVEL` | `text` / `info` | `json` for structured prod logs |
@@ -179,10 +178,10 @@ Everything domain-specific is a plugin folder — the compose above mounts `./pl
into the app. Create `plugins/hello/plugin.ts`: into the app. Create `plugins/hello/plugin.ts`:
```ts ```ts
import { definePlugin } from "@plainpages/plugin-api"; import { definePlugin } from "#plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "0.4.0", apiVersion: "1.0.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>" }) },
+1067 -1007
View File
File diff suppressed because it is too large Load Diff
@@ -35,8 +35,6 @@ test("nextVersion at/after 1.0.0: literal semver", () => {
assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0"); assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0");
assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0"); assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0");
assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4"); assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4");
// the whole chain: a major dependency bump releases a major host, once the 0.x shift-down is gone
assert.equal(nextVersion("v1.2.3", maxLevel(["patch", "major"])), "v2.0.0");
}); });
test("nextVersion rejects a tag that is not vX.Y.Z", () => { test("nextVersion rejects a tag that is not vX.Y.Z", () => {
@@ -36,7 +36,7 @@ export function nextVersion(latestTag: string, level: Bump): string {
return `v${major}.${minor}.${patch + 1}`; return `v${major}.${minor}.${patch + 1}`;
} }
// CLI: node release-tooling/next-version.ts <latestTag> [updateType...] → prints the next tag. // CLI: node auto-release/next-version.ts <latestTag> [updateType...] → prints the next tag.
if (process.argv[1]?.endsWith("/next-version.ts")) { if (process.argv[1]?.endsWith("/next-version.ts")) {
const [, , latestTag, ...updateTypes] = process.argv; const [, , latestTag, ...updateTypes] = process.argv;
process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes))); process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes)));
+2 -36
View File
@@ -53,11 +53,6 @@ docker compose build web
step "Typecheck" step "Typecheck"
docker compose run --rm --no-deps web npm run typecheck docker compose run --rm --no-deps web npm run typecheck
# The specs import @playwright/test, which only the runner image installs, so they typecheck there
# rather than in the web image. --no-deps skips web; the visual file just names the e2e service.
step "Typecheck e2e-tests"
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --rm --no-deps --build e2e npm run typecheck
step "Unit tests" step "Unit tests"
units=$(docker compose run --rm --no-deps web npm test 2>&1) || { echo "$units"; exit 1; } units=$(docker compose run --rm --no-deps web npm test 2>&1) || { echo "$units"; exit 1; }
echo "$units" | grep -E '^. (tests|pass|fail) ' || true echo "$units" | grep -E '^. (tests|pass|fail) ' || true
@@ -65,42 +60,13 @@ echo "$units" | grep -E '^. (tests|pass|fail) ' || true
count=$(echo "$units" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | head -1 || true) count=$(echo "$units" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | head -1 || true)
[ "${count:-0}" -ge 50 ] || { echo "only ${count:-0} unit tests ran — test glob broken?"; exit 1; } [ "${count:-0}" -ge 50 ] || { echo "only ${count:-0} unit tests ran — test glob broken?"; exit 1; }
# Plugin storage against a real Postgres. The step above runs --no-deps, so this suite's integration
# test skips there — and it is the only thing proving the DDL actually grants what it claims, rather
# than that the SQL text is the text we wrote. `node --test` counts a skip, so the floor won't catch it.
step "Plugin storage (real Postgres)"
# Own project name, like every E2E suite below: the default project is the DEV stack, so a bare
# `down -v` here would delete the operator's pgdata — Ory identities and every plugin database.
# --wait, because initdb on a cold volume outlasts the suite's connect timeout.
storage_rc=0
storage_proj=plainpages-storage
storage_files=(-p "$storage_proj" -f compose.yml) # no override merge, like the e2e suites below
storage_dsn="postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/ory"
storage_out=""
docker compose "${storage_files[@]}" up -d --wait postgres >/dev/null || storage_rc=$?
# `if`, not `&&`: a false `&&` returns non-zero, which under `set -e` would exit before teardown.
if [ "$storage_rc" -eq 0 ]; then
# --build like the e2e suites: this stack mounts no source, so without it the step would test
# whatever `web` image that project last baked.
storage_out=$(docker compose "${storage_files[@]}" run --build --rm --no-deps \
-e "PLUGIN_DB_ADMIN_URL=$storage_dsn" \
web node --test src/plugin-host/storage.test.ts 2>&1) || storage_rc=$?
fi
docker compose "${storage_files[@]}" down -v >/dev/null 2>&1 || true # also covers a failed `up`
echo "$storage_out" | grep -E '^. (tests|pass|fail|skipped) ' || true
[ "$storage_rc" -eq 0 ] || { echo "$storage_out"; echo "plugin storage integration tests failed (exit $storage_rc)"; exit "$storage_rc"; }
# A skip here exits 0 and proves nothing — the same trap the unit floor above guards against.
echo "$storage_out" | grep -qE '^. skipped 0$' || { echo "storage integration test skipped — PLUGIN_DB_ADMIN_URL not wired through"; exit 1; }
# Run one E2E suite against its OWN named stack, then always tear it down (even on failure). The # Run one E2E suite against its OWN named stack, then always tear it down (even on failure). The
# per-suite project name keeps a flaky teardown from leaking containers/volumes into the next suite. # per-suite project name keeps a flaky teardown from leaking containers/volumes into the next suite.
# --user: the runner writes screenshots + the report into the checkout, so they must belong to
# whoever ran the gate — root-owned output needs sudo to delete, and a dev box may have none.
e2e() { e2e() {
step "E2E: $1" step "E2E: $1"
local proj="plainpages-e2e-$(basename "$1" .yml | tr '.' '-')" # dots aren't valid in a compose project name local proj="plainpages-e2e-$(basename "$1" .yml | tr '.' '-')" # dots aren't valid in a compose project name
local rc=0 local rc=0
docker compose -p "$proj" -f compose.yml -f "$1" run --user "$(id -u):$(id -g)" --build --rm e2e || rc=$? docker compose -p "$proj" -f compose.yml -f "$1" run --build --rm e2e || rc=$?
docker compose -p "$proj" -f compose.yml -f "$1" down -v >/dev/null 2>&1 || true docker compose -p "$proj" -f compose.yml -f "$1" down -v >/dev/null 2>&1 || true
[ "$rc" -eq 0 ] || { echo "E2E suite $1 failed (exit $rc)"; exit "$rc"; } [ "$rc" -eq 0 ] || { echo "E2E suite $1 failed (exit $rc)"; exit "$rc"; }
} }
@@ -116,7 +82,7 @@ e2e e2e-tests/compose.full.yml # full browser flow: login (password + SSO),
step "E2E: e2e-tests/compose.devstack.yml (dev-stack login: localhost works + 127.0.0.1 canonicalised)" step "E2E: e2e-tests/compose.devstack.yml (dev-stack login: localhost works + 127.0.0.1 canonicalised)"
devstack_files=(-f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml) devstack_files=(-f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml)
rc=0 rc=0
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" run --user "$(id -u):$(id -g)" --build --rm e2e || rc=$? docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" run --build --rm e2e || rc=$?
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" down -v >/dev/null 2>&1 || true docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" down -v >/dev/null 2>&1 || true
[ "$rc" -eq 0 ] || { echo "E2E suite e2e-tests/compose.devstack.yml failed (exit $rc)"; exit "$rc"; } [ "$rc" -eq 0 ] || { echo "E2E suite e2e-tests/compose.devstack.yml failed (exit $rc)"; exit "$rc"; }
+12 -25
View File
@@ -1,9 +1,5 @@
# Development overrides, merged automatically by `docker compose up`. # Development overrides, merged automatically by `docker compose up`.
# Mounts the source for live editing and restarts on change via `node --watch`. # Mounts the source for live editing and restarts on change via `node --watch`.
# web connects with it and bootstrap provisions against it, so the two must agree — one home.
x-plugin-db-url: &plugin-db-url postgres://postgres:5432
services: services:
web: web:
command: node --watch src/server.ts command: node --watch src/server.ts
@@ -17,39 +13,30 @@ services:
CACHE_TEMPLATES: "false" CACHE_TEMPLATES: "false"
LOG_FORMAT: "text" # human-readable logs in dev (base sets json for prod log pipelines) LOG_FORMAT: "text" # human-readable logs in dev (base sets json for prod log pipelines)
LOG_LEVEL: "debug" # verbose by default while developing (base defaults to info) LOG_LEVEL: "debug" # verbose by default while developing (base defaults to info)
# Point plugin storage at the bundled Postgres, so a dropped-in plugin declaring `storage`
# works with no further config; the secret falls back to the dev throwaway (config.ts).
PLUGIN_DB_URL: *plugin-db-url
REQUIRE_SECURE_SECRETS: "false" REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent
PLUGIN_SETTING_SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/ SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/
volumes: volumes:
- .:/app - .:/app
- /app/node_modules
# Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise): # Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise):
# - ./config:/app/config:ro # your config/menu.ts — see examples/config/menu.ts for a template # - ./config:/app/config:ro # your config/menu.ts — see examples/config/menu.ts for a template
# Mirror web's source mount so bootstrap discovers the same plugins *and* runs the same code. Only # The seed grants what the installed plugins declare, so bootstrap must discover the same plugins
# dev needs saying: the base file gives both services the image's baked copy, and it is the # as web. Only dev needs saying: the base file gives both services the image's baked plugins/, and
# `.:/app` above — dev-only — that makes web diverge onto the host tree. Without the mirror, # it is the `.:/app` above — dev-only — that makes web diverge onto the host tree. Mirror it here
# bootstrap silently runs whatever `src/` was baked at image-build time, so an edit to # rather than in the base file, where it would instead desynchronise them (and collide with the
# bootstrap.ts appears to do nothing until someone remembers `--build`. # e2e stacks, which mount individual plugins *inside* this path).
# It belongs here and not in the base file, where it would desynchronise prod and collide with the
# e2e stacks, which bind individual plugins *inside* /app/plugins.
bootstrap: bootstrap:
# Provisions the plugin databases web connects to above, as the dev superuser.
environment:
PLUGIN_DB_ADMIN_URL: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/ory
PLUGIN_DB_URL: *plugin-db-url
REQUIRE_SECURE_SECRETS: "false" # dev derives from the throwaway, as web does
volumes: volumes:
- .:/app - ./plugins:/app/plugins:ro
# Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so # Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so
# the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this # the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this
# backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service — # backs it (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 SCHEDULING_UPSTREAM at the real backend instead.
shifts-upstream: shifts-upstream:
image: node:24.22.2-alpine3.24 image: node:24.19.0-alpine3.24
command: node /srv/server.ts command: node /srv/server.ts
restart: unless-stopped restart: unless-stopped
volumes: volumes:
@@ -58,7 +45,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.0 image: axllent/mailpit:v1.30.6
ports: ports:
- "8025:8025" - "8025:8025"
restart: unless-stopped restart: unless-stopped
+4 -22
View File
@@ -17,16 +17,10 @@ services:
CACHE_TEMPLATES: "true" CACHE_TEMPLATES: "true"
CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret} CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}
LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector
# Per-plugin Postgres storage. Explicit toggle: unset ⇒ off, and a plugin declaring `storage`
# refuses to boot rather than run without its data. The URL carries no credentials — each
# plugin's own password is derived from the secret (README → Plugin storage).
PLUGIN_DB_SECRET: ${PLUGIN_DB_SECRET:-}
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-}
REQUIRE_SECURE_SECRETS: "true" 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
# 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).
# declares `storage` opens its connection in onBoot, before the server listens.
depends_on: depends_on:
bootstrap: bootstrap:
condition: service_completed_successfully condition: service_completed_successfully
@@ -36,20 +30,17 @@ services:
condition: service_healthy condition: service_healthy
hydra: hydra:
condition: service_healthy condition: service_healthy
postgres:
condition: service_healthy
# verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL). # verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
# Read-only — bootstrap is the only writer. # Read-only — bootstrap is the only writer.
volumes: volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
restart: unless-stopped restart: unless-stopped
# The stack's storage: one database per Ory service (init/init.sql), plus one per plugin that # Ory's storage only (Kratos/Keto/Hydra) — the web app never connects here.
# declares `storage` — bootstrap creates those at boot, since only it holds superuser credentials. # init/init.sql creates one database per service. Dev defaults below; supply
# A plugin connects as its own role from inside web. Dev defaults below; supply
# POSTGRES_USER/PASSWORD via env in production. # POSTGRES_USER/PASSWORD via env in production.
postgres: postgres:
image: postgres:18.6-alpine3.23 image: postgres:18.4-alpine3.23
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-ory} POSTGRES_USER: ${POSTGRES_USER:-ory}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ory} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ory}
@@ -136,8 +127,6 @@ services:
condition: service_healthy condition: service_healthy
keto: keto:
condition: service_healthy condition: service_healthy
postgres:
condition: service_healthy
environment: environment:
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
@@ -148,13 +137,6 @@ services:
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
KETO_WRITE_URL: http://keto:4467 KETO_WRITE_URL: http://keto:4467
KRATOS_ADMIN_URL: http://kratos:4434 KRATOS_ADMIN_URL: http://kratos:4434
# The superuser DSN that creates each plugin's database and role lives ONLY here — never in
# web, so plugin code cannot read it out of its own environment. Unset ⇒ a plugin declaring
# `storage` fails the seed loudly. The secret must match web's; both derive the same passwords.
PLUGIN_DB_ADMIN_URL: ${PLUGIN_DB_ADMIN_URL:-}
PLUGIN_DB_SECRET: ${PLUGIN_DB_SECRET:-}
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-} # only to refuse a mismatch: what bootstrap creates, web connects to
REQUIRE_SECURE_SECRETS: "true" # refuse the throwaway secret here too, before any role is created
volumes: volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
command: node src/auth/bootstrap.ts command: node src/auth/bootstrap.ts
-292
View File
@@ -1,292 +0,0 @@
# Comprehension history
## 2026-10-31T00:16:02Z, PR #181 at 56ff987, against eed1050
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | same | better | same | better | better |
| Inherited architect | same | better | same | better | better |
Mid A, decided by:
- Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited
- Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a colour edit that breaks the browser floor fails a test instead of an old engine
Inherited architect, decided by:
- Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited
- Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a red gate explains the symptom in the file it lands on
- Self-sufficiency: `README.md:1027` "Two browser floors": the colour rule now sits beside the floor rule
## 2026-10-31T00:43:19Z, PR #182 at 19987ba, against 3e70768
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | better | better | better | better |
| Inherited architect | better | better | better | better | better |
Mid A, decided by:
- Navigation, Shape, Self-sufficiency, Overall: `public/css/auth.css:1` `auth.css`: about 120 lines of rules for markup no view renders are gone, so a cold reader no longer rules them out first
- Locality, Self-sufficiency, Overall: `public/css/styles.css:31` `--on-accent`: the text colour on the accent is one token where four hard-coded `#fff` stood
- Self-sufficiency, Navigation, Overall: `public/css/styles.css:287` `@supports not selector(:has(a))`: the nav comment, the partial's header and the rule all say the tree folds only where `:has()` works
- Locality: `public/css/styles.css:241` `@supports selector(:has(a))`: the nav-fold behaviour now sits in two blocks about 50 lines apart, the second order-dependent; the comment names it
Inherited architect, decided by:
- Navigation, Shape, Overall: `public/css/auth.css:1` `auth.css`: the false `:target` banner and the phantom state machine are gone, so every selector left names something a view emits
- Self-sufficiency, Overall: `views/partials/nav-tree.ejs:4` `nav-tree.ejs`: the header and `public/css/styles.css:194` now say the branch folds only where `:has()` works; the old text was untrue on old engines
- Locality, Overall: `public/css/styles.css:31` `--l-on-accent`: one token feeds `.btn-primary`, `.page-btn[aria-current]` and `.brand-mark`
- Locality: `public/css/styles.css:287` `@supports not selector(:has(a))`: its `.nav-disc` rule sits about 85 lines from the nav rules it overrides, and `stylesheet-floor.test.ts` guards the order for `.theme-switch` only
- Self-sufficiency: `src/ui/stylesheet-contrast.test.ts:29` `stylesheet-contrast.test.ts`: the darker badge colours now have a stated 4.5:1 reason someone can find
## 2026-10-31T01:09:38Z, PR #183 at 0f20b38, against 364b047
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | same | same | same | better | better |
| Inherited architect | same | better | same | better | better |
Mid A, decided by:
- Self-sufficiency, Overall: `public/css/styles.css:1` `App Shell Template header`: the false `light-dark()` bullet is gone, so no reader has to work out which of two comments is lying
- Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment say what an engine without `:has()` gets, without opening the CHANGELOG
Inherited architect, decided by:
- Self-sufficiency, Overall: `public/css/styles.css:5` `Architecture` header: the false `light-dark()` bullet is gone, so the header no longer contradicts the TOKENS comment
- Locality, Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment put the dependency of the open state on `:has()` where an editor would change it
## 2026-10-31T01:19:49Z, PR #184 at af157c9, against bb8e6bc
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | same | same | better | better |
| Inherited architect | better | better | same | better | better |
Mid A, decided by:
- Self-sufficiency, Navigation, Overall: `public/css/styles.css:12` `TOKENS` header comment: says all three theme rules need `:has()`, names the engines without it and points to the `@supports not selector(:has(a))` block, where the old text called the swaps "rock-solid"
- Navigation, Self-sufficiency, Overall: `README.md:214` `## Glossary`: defines host, plugin, manifest, chrome, nav fragment, permission name, system plugin and denylist and links each to its home section
- Self-sufficiency: `README.md:815` `Plugin dependencies`: the scale bullet states the plugin count the per-plugin dependency design assumes
Inherited architect, decided by:
- Self-sufficiency, Locality, Overall: `public/css/styles.css:12` `1. TOKENS` comment: an editor of the theme mapping learns of the distant `:has()` fallback from the comment, where the old text said the swap could not fail
- Navigation, Self-sufficiency, Overall: `README.md:214` `## Glossary`: a cold reader reaches "chrome" or "denylist" and their home sections in one hop
- Self-sufficiency: `README.md:815` `### Plugin dependencies`: the scale bullet gives the plugin count the dependency design assumes
## 2026-10-31T07:26:19Z, PR #187 at 10152d2, against b336dee
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | same | same | better | better | better |
| Inherited architect | same | same | better | better | better |
Mid A, decided by:
- Self-sufficiency, Overall: `e2e-tests/mock-oidc.ts:18` `idToken`: the helpers' signatures now say what `res`, `nonce` and the `codes` map hold
- Self-sufficiency: `e2e-tests/proxy.ts:14` `toKratos`: `(path: string): boolean` shows the routing predicate's contract
- Self-sufficiency: `e2e-tests/tsconfig.json:2` `extends`: the specs are held to the host's strict flags, and the Dockerfile comment explains the path link
- Shape, Overall: `src/ui/icons.ts:4` `ICON_NAMES`: the comment "the icons the UI actually references" is true again
Inherited architect, decided by:
- Self-sufficiency, Overall: `e2e-tests/mock-oidc.ts:18` `b64`: typed helpers replace a trace through the `createServer` callback
- Self-sufficiency: `e2e-tests/proxy.ts:14` `toKratos`: the predicate's contract is readable on its own line
- Self-sufficiency: `e2e-tests/Dockerfile:11` `COPY tsconfig.json`: the relative `extends` path works in the checkout and in the image, and a comment names the link
- Shape, Overall: `src/ui/icons.ts:4` `ICON_NAMES`: the registry matches its description, with no dead ids
## 2026-10-31T12:28:23Z, PR #200 at 3592a30, against a8a9744
Ruling: same
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | better | better | same | better |
| Inherited architect | worse | worse | better | same | same |
| Maintainability senior | same | same | better | same | same |
| Junior A | better | better | better | better | better |
Mid A, decided by:
- Navigation, Locality, Shape: `src/ui/chrome.ts:74` `shellUser`: the deleted `buildShellContext` and `buildDashboardModel` left two shell models side by side, and now every page reads one `chrome`
- Locality, Shape: `src/auth/routes.ts:58` `flowFailure`: a named unit shared by `flowPage` and `codeFlowSubmit`, where the old `flowPage` held the whole error ladder inline
- Shape: `src/auth/kratos-public.ts:117` `submitFlow`: takes a flow type and id instead of an action URL, and the Referer quirk is explained where it applies
- Locality: `src/http/static.ts:93` `versioned`: reads the module-level `hashes` map, so an asset href depends on what an earlier request hashed
- Navigation, Shape: `src/ui/stylesheet-floor.ts:30` `floorViolations`: a 249-line regex walker that only tests use sits in `src/ui/` beside runtime modules, though the README now points straight at it
Inherited architect, decided by:
- Navigation: `public/css/styles.css:25` `:root`: the old comment next to the palette answered the 3am question and the new text drops it
- Navigation: `src/ui/stylesheet-floor.ts:1` `floorViolations`: its header never says an unsupported value in a custom property blanks every `var()` reading it, so a cold reader walks the regex walker to find the cause
- Navigation, Shape: `src/ui/stylesheet-floor.ts:12` `import`: test-only tooling that pulls a devDependency sits beside runtime modules and is missing from the README file map
- Locality: `src/http/static.ts:64` `hashes`: a module-level map shared by every `publicAssets()` instance makes a fresh href depend on request history elsewhere
- Locality: `src/http/app.ts:188` `renderPage`: `assetHref` is appended last at three render sites, and only a comment keeps a fourth from emitting unversioned hrefs
- Shape: `src/ui/chrome.ts:74` `shellUser`: removes the second shell model that duplicated `PageChrome`
- Shape: `src/auth/routes.ts:58` `flowFailure`: one named unit shared by `flowPage` and `codeFlowSubmit`, each handler reads in a screen
Maintainability senior, decided by:
- Shape, Locality: `src/ui/chrome.ts:74` `shellUser`: the deleted `ShellModel` left two shapes for the same chrome, and now the shell has one input and one builder
- Shape, Locality: `src/auth/routes.ts:58` `flowFailure`: the old `flowPage` mixed about 60 lines of error mapping with `return_to` handling, and now `flowReturnTo` and `flowFailure` read on their own
- Locality: `src/http/static.ts:64` `hashes`: a hidden module-level cache filled by two readers, and `assetHref` is merged by hand at three render sites
- Shape, Navigation: `src/ui/stylesheet-floor.ts:119` `positive`: a 249-line regex walker that only a test uses sits among runtime view modules
Junior A, decided by:
- Navigation, Self-sufficiency: `src/ui/stylesheet-floor.ts:1` `floorViolations`: the README's "Two browser floors" points here and the header lists what is checked, where the base had a prose rule and a hand-written regex test
- Shape, Locality: `src/ui/chrome.ts:74` `shellUser`: one shell view model replaces `ShellModel` beside `PageChrome`, and its helpers sit beside their only caller
- Shape, Locality: `src/auth/routes.ts:58` `flowFailure`: named units replace about 60 inline lines in `flowPage`, and `codeFlowSubmit` reuses them
- Self-sufficiency: `src/auth/kratos-admin.ts:68` `createRecoveryCode`: the comment no longer claims Kratos mails the link
- Locality: `src/http/static.ts:64` `hashes`: a module-level mutable map backs asset versioning, hidden state the base didn't have
## 2026-10-31T12:30:19Z, PR #200 at 3592a30
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Mid A | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Maintainability senior | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Inherited architect | 7 | 5.5 | 5.5 | 7.5 | 6.5 |
| Mean | 7.00 | 5.88 | 6.25 | 6.75 | 6.50 |
Junior A, hardest first:
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a regex-built `@supports` evaluator whose header says what it enforces but not how it decides a guard names a feature
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines in one closure where each stage reads state an earlier stage set
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: its 4xx branches can't fire from `codeFlowSubmit` because `submitFlow` throws only on 5xx, and the call site doesn't say so
Mid A, hardest first:
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-written CSS walker with three regex passes per declaration, opaque at line level
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: a fixed order of about 15 stages that nothing in the code enforces
- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment names the tuple `#members` where the code uses `granted`
- Locality: `src/auth/routes.ts:121` `codeFlowSubmit`: one idea split across `flow-view.ts`, `routes.ts` and `kratos-public.ts`
Maintainability senior, hardest first:
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: a 167-line closure of complexity 43 whose order across closure state nothing names as a contract
- Self-sufficiency, Locality: `src/auth/routes.ts:58` `flowFailure`: on the submit path every 4xx branch is dead and nothing at the call site says which can fire
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: the one module whose correctness can't be checked by reading
- Self-sufficiency: `src/auth/bootstrap.ts:31` `permissionTuple`: the comment says `#members` while the code writes `granted`
Inherited architect, would restructure first:
- Shape, Navigation: `src/auth/flow-view.ts:152` `formAction`: the flow-to-route mapping has two homes, here and `routes.ts`, so a code flow's route and its form target are decided in different files
- Shape, Navigation: `src/ui/stylesheet-floor.ts:30` `floorViolations`: a CSS linter that never runs at runtime sits among the runtime view helpers
- Locality, Shape: `src/http/app.ts:185` `handleRequest`: a level of its own inside `createApp`
- Shape: `src/http/builtin-routes.ts:23` `matchBuiltinRoute`: holds no routes, because the table is inline in `app.ts:176` and `buildAuthRoutes`
## 2026-10-31T12:30:27Z, PR #200 rerun at 3592a30
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 5.5 | 6 | 6.5 | 6 |
| Mid A | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Maintainability senior | 7 | 5.5 | 6 | 6.5 | 6 |
| Inherited architect | 7 | 6 | 6 | 6.5 | 6.5 |
| Mean | 7.00 | 5.75 | 6.13 | 6.50 | 6.25 |
Junior A, hardest first:
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a regex-built CSS walker that stayed opaque without tracing it by hand
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 320 lines of interdependent locals, with a fixed stage order that only a top-to-bottom read shows
- Self-sufficiency, Shape: `src/auth/login.ts:40` `completeLogin`: the header says `#members` where the code uses `granted`
Mid A, hardest first:
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines of order-dependent stages, with `renderPage` reassigned partway
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: how it decides a guard names a feature stayed opaque
- Self-sufficiency, Shape: `src/auth/bootstrap.ts:31` `seedPermissions`: the comment says `#members` where the code uses `granted`
Maintainability senior, hardest first:
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 167 lines and complexity 43 in a fixed order nobody states
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-rolled regex CSS walker, partly opaque
- Locality, Shape: `src/plugin-host/plugin.ts:126` `RESERVED_PLUGIN_IDS`: a hand-kept mirror of the host's path segments that has already drifted, as it omits `error`
- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment says `#members` where the code uses `granted`
Inherited architect, would restructure first:
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: the most concerns and the highest complexity, already grandfathered, and every request-wide concern lands in it
- Shape, Navigation: `src/ui/stylesheet-floor.ts:15` `FLOOR`: the 3am rule's home is a test-only module under `ui/`
- Shape: `src/auth/routes.ts:275` `buildAuthRoutes`: the 19-module `src/auth/` mixes six concerns, and this file with `flow-view.ts` is where the next flow lands
## 2026-10-31T13:06:16Z, PR #202 at 8e73d16
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Mid A | 7 | 6 | 6 | 6.5 | 6.5 |
| Maintainability senior | 7 | 6 | 6 | 6.5 | 6.5 |
| Inherited architect | 6.5 | 6 | 6 | 6.5 | 6.5 |
| Mean | 6.88 | 6.00 | 6.13 | 6.50 | 6.50 |
Junior A, hardest first:
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines in a fixed order that only a top-to-bottom read shows
- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment says `#members` where the code uses `granted`
- Self-sufficiency: `src/plugin-host/settings.ts:35` `SettingsOf`: nested conditional types that need the charter's rule to decode
Mid A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: complexity 43 inside the 318-line `createApp` closure, and `switchBase` reads `builtinRoutes` declared 60 lines later
- Locality, Shape: `src/ui/stylesheet-floor.ts:119` `positive`: a regex CSS walker whose how stayed opaque
- Self-sufficiency: `src/auth/bootstrap.ts:31` `permissionTuple`: the comment says `#members` where the code uses `granted`
Maintainability senior, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: 167 lines and complexity 43 mixing twelve concerns whose order no type expresses
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-rolled regex CSS scanner traced by hand
- Self-sufficiency: `src/auth/gate.ts:13` `allows`: an empty gate is allowed, and the reason lives only in AGENTS.md
Inherited architect, would restructure first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: the largest hub, where every request-wide concern lands
- Shape, Navigation: `src/auth/routes.ts:275` `buildAuthRoutes`: the 19-module `src/auth/` mixes four concerns with no sub-folder
- Shape, Navigation: `src/ui/stylesheet-floor.ts:30` `floorViolations`: test tooling sits among the runtime UI modules
## 2026-10-31T13:06:21Z, PR #202 rerun at 8e73d16
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 5.5 | 6 | 6 | 6 |
| Mid A | 7 | 6 | 6 | 6 | 6.5 |
| Maintainability senior | 7 | 6 | 6 | 6 | 6.5 |
| Inherited architect | 7 | 5.5 | 6 | 7 | 6.5 |
| Mean | 7.00 | 5.75 | 6.00 | 6.25 | 6.38 |
Junior A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: closures over request state in a 318-line `createApp`, and it stayed opaque
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: the Kratos status semantics live outside the repo
- Self-sufficiency, Shape: `src/auth/login.ts:45` `readPermissions`: the comment says `#members` where the code uses `granted`
Mid A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: one pipeline whose steps mutate what later steps read
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: traced by hand
- Self-sufficiency, Shape: `src/auth/login.ts:45` `readPermissions`: the comment says `#members` where the code uses `granted`
Maintainability senior, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: an unstated, load-bearing step order in shared closure state
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-rolled CSS scanner built on lookbehind regexes
- Self-sufficiency, Shape: `src/auth/bootstrap.ts:31` `permissionTuple`: the comment is false on the security-relevant path
Inherited architect, would restructure first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: stage order lives only in line order, in a grandfathered complexity-43 function
- Shape, Navigation: `src/auth/routes.ts:275` `buildAuthRoutes`: 19 flat modules in `src/auth/`, and the next flow lands here
- Shape, Navigation: `src/ui/stylesheet-floor.ts:15` `FLOOR`: the 3am rule's home is a test-only module under `ui/`
-306
View File
@@ -1,306 +0,0 @@
# Reading cost
## Reading cost, chunk 57e864bce02a, finished 2026-10-30T23:59:00Z
100624 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app.test.ts` 9
- `README.md` 7
- `src/http/app.ts` 4
- `todo.md` 3
- `e2e-tests/playwright.config.ts` 2
- `AGENTS.md` 1
- `CHANGELOG.md` 1
- `ci.sh` 1
- `examples/plugins/scheduling/views/shifts.ejs` 1
- `src/auth/csrf.ts` 1
## Reading cost, chunk df5e30a4db5f, finished 2026-10-31T00:22:00Z
80251 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 5
- `README.md` 3
- `CHANGELOG.md` 2
- `public/css/styles.css` 2
- `AGENTS.md` 1
- `src/ui/stylesheet-floor.test.ts` 1
- `src/ui/theme-switch.test.ts` 1
## Reading cost, chunk 3bf0b1a97d77, finished 2026-10-31T00:48:14Z
167985 new input tokens before the first commit.
Read most (opens and searches):
- `public/css/styles.css` 17
- `todo.md` 7
- `README.md` 6
- `public/css/auth.css` 5
- `AGENTS.md` 1
- `CHANGELOG.md` 1
- `examples/plugins/scheduling/public/scheduling.css` 1
- `examples/plugins/scheduling/views/overview.ejs` 1
- `examples/plugins/scheduling/views/shifts.ejs` 1
- `src/http/app.test.ts` 1
## Reading cost, chunk 91d11dbe63f9, finished 2026-10-31T01:13:44Z
69010 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 7
- `README.md` 4
- `public/css/styles.css` 4
- `CHANGELOG.md` 1
- `src/ui/stylesheet-floor.test.ts` 1
## Reading cost, chunk 70e46c48db80, finished 2026-10-31T07:17:56Z
70843 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 18
- `todo.md` 6
- `public/css/styles.css` 5
- `CHANGELOG.md` 2
- `AGENTS.md` 1
- `src/auth/jwt-middleware.ts` 1
## Reading cost, chunk 9e8ffd0aa8dc, finished 2026-10-31T07:36:53Z
98975 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 9
- `ci.sh` 3
- `e2e-tests/visual.spec.ts` 3
- `todo.md` 3
- `CHANGELOG.md` 2
- `e2e-tests/compose.visual.yml` 2
- `e2e-tests/language.spec.ts` 2
- `e2e-tests/mock-oidc.ts` 2
- `src/ci-gate.test.ts` 2
- `.dockerignore` 1
## Reading cost, chunk 25b7840ff611, finished 2026-10-31T07:46:18Z
73781 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 5
- `README.md` 2
- `src/http/app.ts` 2
- `AGENTS.md` 1
- `src/http/app.test.ts` 1
- `src/ui/chrome.test.ts` 1
- `src/ui/chrome.ts` 1
- `views/index.ejs` 1
- `views/partials/shell.ejs` 1
## Reading cost, chunk e4d3bb0ac092, finished 2026-10-31T08:02:27Z
154733 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 10
- `README.md` 6
- `package.json` 3
- `.gitignore` 1
- `.npmrc` 1
- `AGENTS.md` 1
- `ci.sh` 1
- `public/css/styles.css` 1
- `renovate.json` 1
- `src/e2e-console-guard.test.ts` 1
## Reading cost, chunk 2f36d29bd746, finished 2026-10-31T08:19:09Z
85029 new input tokens before the first commit.
Read most (opens and searches):
- `e2e-tests/full-flow.spec.ts` 4
- `README.md` 3
- `src/auth/routes.test.ts` 3
- `todo.md` 3
- `src/auth/kratos-public.ts` 2
- `src/http/app.test.ts` 2
- `ci.sh` 1
- `compose.yml` 1
- `e2e-tests/auth-refresh.spec.ts` 1
- `src/auth/flow-view.ts` 1
## Reading cost, chunk 3373527a4f72, finished 2026-10-31T08:52:59Z
138664 new input tokens before the first commit.
Read most (opens and searches):
- `examples/plugins/admin/admin-users.ts` 8
- `README.md` 7
- `src/http/app.test.ts` 7
- `e2e-tests/full-flow.spec.ts` 3
- `examples/plugins/admin/admin-groups.ts` 3
- `examples/plugins/admin/admin-users.test.ts` 3
- `examples/plugins/admin/i18n/en-US.ts` 3
- `src/ui/icons.ts` 3
- `todo.md` 3
- `CHANGELOG.md` 2
## Reading cost, chunk 0118907cad57, finished 2026-10-31T09:32:49Z
116614 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app.test.ts` 11
- `README.md` 7
- `src/http/app.ts` 6
- `src/ui/shell.test.ts` 5
- `e2e-tests/visual.spec.ts` 4
- `src/http/static.ts` 4
- `todo.md` 3
- `src/auth/flow-view.ts` 2
- `views/partials/shell.ejs` 2
- `AGENTS.md` 1
## Reading cost reset, 2026-10-31T09:34:55Z
## Reading cost, chunk 55cc9f01b477, finished 2026-10-31T09:42:24Z
94244 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app.test.ts` 6
- `README.md` 4
- `src/http/app-plugins.test.ts` 4
- `todo.md` 3
- `AGENTS.md` 2
- `docs/reading-cost.md` 2
- `examples/plugins/admin/README.md` 2
- `ci.sh` 1
- `examples/plugins/admin/admin-clients.test.ts` 1
- `examples/plugins/admin/admin-grants.test.ts` 1
## Reading cost, chunk 4a8c9037acde, finished 2026-10-31T10:07:56Z
126616 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app-admin.test.ts` 7
- `examples/plugins/admin/admin-groups.ts` 4
- `README.md` 3
- `todo.md` 3
- `AGENTS.md` 2
- `examples/plugins/admin/README.md` 2
- `examples/plugins/admin/admin-grants.ts` 2
- `examples/plugins/admin/i18n/en-US.ts` 2
- `CHANGELOG.md` 1
- `examples/plugins/admin/admin-groups.test.ts` 1
## Reading cost, chunk c90b25a50479, finished 2026-10-31T10:40:23Z
90935 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 9
- `todo.md` 5
- `AGENTS.md` 3
- `CHANGELOG.md` 1
- `package.json` 1
- `registry-cleanup/cleanup.ts` 1
- `release-tooling/dockerhub-overview.md.tmpl` 1
- `release-tooling/dockerhub-overview.test.ts` 1
- `release-tooling/dockerhub-overview.ts` 1
- `src/ci-gate.test.ts` 1
## Reading cost, chunk 984f4d4c8a76, finished 2026-10-31T11:18:20Z
209334 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 6
- `todo.md` 6
- `src/ui/stylesheet-floor.test.ts` 4
- `src/ui/stylesheet-floor.ts` 4
- `CHANGELOG.md` 3
- `AGENTS.md` 2
- `examples/plugins/scheduling/public/scheduling.css` 2
- `package.json` 2
- `public/css/styles.css` 2
- `ci.sh` 1
## Reading cost, chunk de26287532f7, finished 2026-10-31T11:29:32Z
68410 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 5
- `public/css/styles.css` 3
- `src/ui/stylesheet-floor.test.ts` 3
- `CHANGELOG.md` 2
- `README.md` 2
- `AGENTS.md` 1
- `src/ui/theme-switch.test.ts` 1
## Reading cost, chunk 4a258a237c53, finished 2026-10-31T12:04:07Z
154777 new input tokens before the first commit.
Read most (opens and searches):
- `e2e-tests/full-flow.spec.ts` 9
- `todo.md` 6
- `README.md` 5
- `src/auth/flow-view.ts` 5
- `src/auth/flow-view.test.ts` 4
- `src/auth/routes.ts` 4
- `src/auth/kratos-public.test.ts` 3
- `src/i18n/locales/en-US.ts` 3
- `e2e-tests/compose.full.yml` 2
- `src/auth/kratos-admin.ts` 2
## Reading cost, chunk d8b41620d631, finished 2026-10-31T12:37:38Z
70583 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 5
- `AGENTS.md` 2
- `README.md` 2
- `examples/plugins/admin/README.md` 2
- `src/auth/kratos-admin.ts` 2
- `src/http/app-admin.test.ts` 2
- `CHANGELOG.md` 1
- `e2e-tests/full-flow.spec.ts` 1
- `examples/plugins/admin/admin-users.test.ts` 1
- `examples/plugins/admin/admin-users.ts` 1
## Reading cost, chunk f0a49c300aac, finished 2026-10-31T12:47:27Z
63526 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 4
- `src/plugin-host/settings.test.ts` 3
- `todo.md` 3
- `CHANGELOG.md` 2
- `src/plugin-host/settings.ts` 2
- `AGENTS.md` 1
+1 -7
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.64.1-noble FROM mcr.microsoft.com/playwright:v1.62.1-noble
WORKDIR /e2e-tests WORKDIR /e2e-tests
@@ -8,11 +8,5 @@ COPY e2e-tests/package.json e2e-tests/package-lock.json ./
RUN npm ci RUN npm ci
COPY e2e-tests/ ./ COPY e2e-tests/ ./
# e2e-tests/tsconfig.json extends it, so the specs are held to the host's strictness flags.
COPY tsconfig.json /tsconfig.json
# Runs as the invoking `--user` so artifacts land owned by them, not root — and an arbitrary uid has
# no passwd entry here, so its home would be the unwritable `/`. npm's cache follows HOME.
ENV HOME=/tmp
CMD ["npx", "playwright", "test"] CMD ["npx", "playwright", "test"]
View File
+2 -7
View File
@@ -2,7 +2,7 @@
# visual suite (e2e-tests/compose.visual.yml) covers the design system; this is its full-stack counterpart: # visual suite (e2e-tests/compose.visual.yml) covers the design system; this is its full-stack counterpart:
# real Postgres + Kratos + Keto + bootstrap + web, with a SHORT tokenizer TTL (ory/kratos/e2e.yml) # real Postgres + Kratos + Keto + bootstrap + web, with a SHORT tokenizer TTL (ory/kratos/e2e.yml)
# and zero clock skew, so the JWT lapses and re-mints within seconds instead of ~10m. # and zero clock skew, so the JWT lapses and re-mints within seconds instead of ~10m.
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml run --user "$(id -u):$(id -g)" --build --rm e2e # docker compose -f compose.yml -f e2e-tests/compose.auth.yml run --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml down -v # tear down after # docker compose -f compose.yml -f e2e-tests/compose.auth.yml down -v # tear down after
services: services:
web: web:
@@ -33,14 +33,9 @@ services:
# This stack mounts no plugins, so nothing declares a permission for the bootstrap to seed — and # This stack mounts no plugins, so nothing declares a permission for the bootstrap to seed — and
# the suite asserts that Keto's grants reach the JWT claim. Name one explicitly so there is # the suite asserts that Keto's grants reach the JWT claim. Name one explicitly so there is
# something to project. # something to project.
#
# `admin` rides along on purpose: it was this setting's default until 2026-08-05 and is not a legal
# `<resource>:<action>` name, so it is exactly the leftover an upgrading deployment carries. An
# earlier revision made that fatal, and bootstrap gates `web` — so if the boot ever refuses operator
# env again, `web` never turns healthy and this suite fails instead of CI going green over it.
bootstrap: bootstrap:
environment: environment:
ADMIN_PERMISSIONS: admin,users:read ADMIN_PERMISSIONS: users:read
# Shorten the session→JWT TTL and expose a network-resolvable base_url (ory/kratos/e2e.yml), # Shorten the session→JWT TTL and expose a network-resolvable base_url (ory/kratos/e2e.yml),
# merged after the base config. # merged after the base config.
+1 -1
View File
@@ -4,7 +4,7 @@
# runs against the *plain* `docker compose up` topology and drives the browser on the HOST network, so # runs against the *plain* `docker compose up` topology and drives the browser on the HOST network, so
# it sees http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host # it sees http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host
# browser does. Merge the dev override so the live stack is byte-for-byte `docker compose up`: # browser does. Merge the dev override so the live stack is byte-for-byte `docker compose up`:
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml run --user "$(id -u):$(id -g)" --build --rm e2e # docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml run --build --rm e2e
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml down -v # tear down # docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml down -v # tear down
services: services:
web: web:
+5 -19
View File
@@ -1,8 +1,8 @@
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO # Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
# login, emailed-code verification + recovery, menu filtering by permission, users/groups/OAuth2-clients CRUD + permission granting, a plugin page, logout. A # login, menu filtering by permission, users/groups/OAuth2-clients CRUD + permission granting, a plugin page, logout. A
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies # tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test. # round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --user "$(id -u):$(id -g)" --build --rm e2e # docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after # docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
services: services:
web: web:
@@ -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:24.22.2-alpine3.24 image: node:24.19.0-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:24.22.2-alpine3.24 image: node:24.19.0-alpine3.24
command: ["node", "/mock-oidc.ts"] command: ["node", "/mock-oidc.ts"]
environment: environment:
ISSUER: http://mock-oidc:9000 ISSUER: http://mock-oidc:9000
@@ -79,20 +79,9 @@ services:
timeout: 4s timeout: 4s
retries: 15 retries: 15
# Catches the courier's mail (kratos.yml → smtp://mailpit:1025), so a test can read an emailed code.
mailpit:
image: axllent/mailpit:v1.33.0
mem_limit: 128m
memswap_limit: 128m
healthcheck:
test: ["CMD", "/mailpit", "readyz"]
interval: 2s
timeout: 4s
retries: 15
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts). # Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
proxy: proxy:
image: node:24.22.2-alpine3.24 image: node:24.19.0-alpine3.24
command: ["node", "/proxy.ts"] command: ["node", "/proxy.ts"]
depends_on: depends_on:
web: web:
@@ -114,8 +103,6 @@ services:
dockerfile: e2e-tests/Dockerfile dockerfile: e2e-tests/Dockerfile
command: ["npx", "playwright", "test", "full-flow.spec.ts"] command: ["npx", "playwright", "test", "full-flow.spec.ts"]
depends_on: depends_on:
mailpit:
condition: service_healthy
mock-oidc: mock-oidc:
condition: service_healthy condition: service_healthy
proxy: proxy:
@@ -123,6 +110,5 @@ services:
environment: environment:
BASE_URL: http://proxy BASE_URL: http://proxy
KRATOS_ADMIN_URL: http://kratos:4434 KRATOS_ADMIN_URL: http://kratos:4434
MAILPIT_URL: http://mailpit:8025
volumes: volumes:
- ./e2e-tests/artifacts:/e2e-tests/artifacts - ./e2e-tests/artifacts:/e2e-tests/artifacts
+1 -1
View File
@@ -3,7 +3,7 @@
# it via the Kratos session and accepts. Runs against the real stack (Postgres + Kratos + Keto + # it via the Kratos session and accepts. Runs against the real stack (Postgres + Kratos + Keto +
# Hydra + bootstrap + web). The runner drives the flow over HTTP (fetch, manual cookies), so it # Hydra + bootstrap + web). The runner drives the flow over HTTP (fetch, manual cookies), so it
# reaches the Ory services by their compose-network names. # reaches the Ory services by their compose-network names.
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml run --user "$(id -u):$(id -g)" --build --rm e2e # docker compose -f compose.yml -f e2e-tests/compose.oauth.yml run --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v # tear down after # docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v # tear down after
services: services:
web: web:
+1 -1
View File
@@ -1,7 +1,7 @@
# Playwright E2E. Brings up the app + a Playwright runner and exercises the live pages (design # Playwright E2E. Brings up the app + a Playwright runner and exercises the live pages (design
# system, theme switch, mobile layout, CSRF, landing, 404, plugin gating, language switching) — # system, theme switch, mobile layout, CSRF, landing, 404, plugin gating, language switching) —
# Ory-free, so it's fast. # Ory-free, so it's fast.
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --build --rm e2e # docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v # tear down after # docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v # tear down after
# --build rebuilds the runner (the image bakes in e2e-tests/) so spec edits are picked up. # --build rebuilds the runner (the image bakes in e2e-tests/) so spec edits are picked up.
# Screenshots + HTML report land in ./e2e-tests/artifacts/ (git-ignored). # Screenshots + HTML report land in ./e2e-tests/artifacts/ (git-ignored).
+14 -7
View File
@@ -1,13 +1,20 @@
import { expect, test } from "./console-guard.ts"; import { expect, test } from "./console-guard.ts";
// The from-scratch dev experience the banner advertises: `docker compose up`, open the printed // Regression: the from-scratch dev experience the README/banner advertises must work. `docker compose
// login URL, sign in as the seeded admin, land on the dashboard. A host-scoped Kratos CSRF cookie // up`, open the printed login URL (http://localhost:3000), sign in as the seeded admin → you land on
// cannot cross `localhost`↔`127.0.0.1`, so a cross-host login POST loses it and Kratos redirects to // the dashboard, signed in. Originally this dumped the user on http://127.0.0.1:3000/error?id=…
// its error sink; APP_URL canonicalises every off-host visitor onto one cookie host instead. // ("Page not found"): the banner printed `localhost` but kratos.yml hard-coded `127.0.0.1`, and a
// host-scoped Kratos CSRF cookie can't cross `localhost`↔`127.0.0.1`, so the cross-host login POST
// lost it and Kratos redirected to its error sink.
// //
// The runner is on the host network against the plain `docker compose up` topology, so it sees // The fix makes APP_URL the single source for the public host: the web app canonicalises every
// http://localhost:3000 and http://127.0.0.1:4433 exactly as a host browser does. The proxied // off-host visitor onto it (so localhost / 127.0.0.1 / any alias funnel to one cookie host), Kratos'
// full-flow suite cannot catch this — it fronts web + Kratos on one origin. // browser URLs derive from it, and a real /error page replaces the 404.
//
// This is faithful to the user's environment: the runner uses the host network
// (e2e-tests/compose.devstack.yml) against the plain `docker compose up` topology, so it sees
// http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host browser
// does. The proxied full-flow suite can't catch this regression — it fronts web + Kratos on one origin.
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
const ADMIN_PASSWORD = "admin"; const ADMIN_PASSWORD = "admin";
+4 -118
View File
@@ -1,5 +1,5 @@
import type { Browser, Page } from "@playwright/test"; import type { Browser, Page } from "@playwright/test";
import { allowConsole, expect, test, watchedPage } from "./console-guard.ts"; import { expect, test, watchedPage } from "./console-guard.ts";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
// Full browser E2E: the real Playwright UI against the live stack via the same-origin // Full browser E2E: the real Playwright UI against the live stack via the same-origin
@@ -14,8 +14,6 @@ const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the
const ADMIN_PASSWORD = "admin"; 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 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.
async function loginPassword(page: Page): Promise<void> { async function loginPassword(page: Page): Promise<void> {
@@ -52,8 +50,8 @@ test.describe.serial("authenticated admin journey", () => {
}); });
test.afterAll(async () => { await page.context().close(); }); test.afterAll(async () => { await page.context().close(); });
// The list screens rebuild their query from the list state (sort/page/filter), so they are where a // The list screens rebuild their query from the list state (sort/page/filter), so they are where
// chosen language is most easily dropped; the core building blocks carry it through. // a chosen language used to get dropped — the core building blocks carry it now.
test("a sorted, paged admin list keeps the visitor's language", async () => { test("a sorted, paged admin list keeps the visitor's language", async () => {
await page.goto("/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");
@@ -138,27 +136,7 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("tr", { hasText: email })).toHaveCount(0); await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
}); });
// Kratos accepts an admin-minted code only in the flow it was minted on, which the notice's link opens. test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async () => {
test("recovery code: the notice's link opens the flow that accepts the code, and the code signs the user in", async () => {
test.setTimeout(90_000);
const email = `admin-recovery-${suffix}@plainpages.local`;
await createIdentity(email);
await page.goto("/admin/users");
const editHref = await page.locator("tr", { hasText: email }).locator('a[href^="/admin/users/"]').first().getAttribute("href");
await page.goto(editHref!);
await page.getByRole("button", { name: "Generate recovery code" }).click();
const code = await page.locator(".recovery-code code").innerText();
const link = await page.locator(".alert-body a").getAttribute("href");
const user = await watchedPage(await browser.newContext());
await user.goto(link!);
await user.fill('input[name="code"]', code);
await user.getByRole("button", { name: "Continue" }).click();
await expect(user).toHaveURL(/\/settings/);
await user.context().close();
});
test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async ({ browser }) => {
// 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}`;
@@ -172,10 +150,6 @@ test.describe.serial("authenticated admin journey", () => {
// 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(`/admin/groups/${group}`); await page.goto(`/admin/groups/${group}`);
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.
const staleTab = await watchedPage(await browser.newContext({ storageState: await page.context().storageState() }));
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();
@@ -183,16 +157,7 @@ test.describe.serial("authenticated admin journey", () => {
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(`/admin/groups/${group}`)); await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`));
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();
// The other tab no longer matches Keto, so its save is refused and shows the grant made above.
await staleTab.locator('input[name="permission"][value="scheduling:write"]').check();
await staleTab.locator('form:has(input[name="permission"]) button[type="submit"]').click();
await expect(staleTab.getByText("changed while you had the page open")).toBeVisible();
await expect(staleTab.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
await expect(staleTab.locator('input[name="permission"][value="scheduling:write"]')).not.toBeChecked();
await staleTab.context().close();
}); });
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 () => {
@@ -228,23 +193,6 @@ test.describe.serial("authenticated admin journey", () => {
await page.goto("/scheduling/shifts"); await page.goto("/scheduling/shifts");
await expect(page.locator("h1")).toHaveText("Shifts"); await expect(page.locator("h1")).toHaveText("Shifts");
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
// The admin owns none of the demo's rows, so an empty page is the no-leak assertion.
await page.goto("/scheduling/mine");
await expect(page.locator("h1")).toHaveText("My shifts");
await expect(page.getByText("No shifts are assigned to admin@plainpages.local")).toBeVisible();
await expect(page.locator("table")).not.toContainText("Morning — Front desk");
});
test("plugin settings: the screen names the variable that sets each declared key", async () => {
await page.goto("/admin/plugin-settings");
await expect(page.locator("h1")).toHaveText("Plugin settings");
// The reference plugin's one declared setting, and the variable an operator would set for it.
const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" });
await expect(scheduling).toContainText("upstream");
await expect(scheduling).toContainText("http://shifts-upstream:4000"); // resolved, and its source shown
// Every installed plugin gets a section, so "declares none" is distinguishable from "not installed".
await expect(page.locator("h2", { hasText: "admin" })).toHaveCount(1);
}); });
test("logout: signing out ends the session and returns to the login page", async () => { test("logout: signing out ends the session and returns to the login page", async () => {
@@ -275,17 +223,6 @@ test("return_to: a deep link while logged out returns to that page after login",
await expect(page.locator("h1")).toHaveText("Users"); await expect(page.locator("h1")).toHaveText("Users");
}); });
test("settings: an anonymous visit signs in first, then lands on the settings page", async ({ page }) => {
test.setTimeout(90_000);
await page.goto("/settings");
await expect(page).toHaveURL(/\/login(\?|$)/);
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
await page.fill('input[name="password"]', ADMIN_PASSWORD);
await page.locator('.auth-form button[type="submit"]').click();
await expect(page).toHaveURL(/\/settings(\?|$)/);
await expect(page.locator("h1")).toHaveText("Account settings");
});
test("mocked SSO login: the provider button signs a user in via OIDC", async ({ page }) => { test("mocked SSO login: the provider button signs a user in via OIDC", async ({ page }) => {
test.setTimeout(90_000); test.setTimeout(90_000);
await page.goto("/login"); await page.goto("/login");
@@ -296,54 +233,3 @@ test("mocked SSO login: the provider button signs a user in via OIDC", async ({
// 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="/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).
async function mailedCode(to: string): Promise<string> {
let code = "";
await expect.poll(async () => {
const search = (await (await fetch(`${MAILPIT}/api/v1/search?query=${encodeURIComponent(`to:"${to}"`)}`)).json()) as { messages: { ID: string }[] };
const id = search.messages[0]?.ID;
if (!id) return "";
const message = (await (await fetch(`${MAILPIT}/api/v1/message/${id}`)).json()) as { Text: string };
code = /\b(\d{6})\b/.exec(message.Text)?.[1] ?? "";
return code;
}, { timeout: 30_000 }).not.toBe("");
return code;
}
async function createIdentity(email: string): Promise<void> {
const created = await fetch(`${KRATOS_ADMIN}/admin/identities`, {
body: JSON.stringify({ schema_id: "default", traits: { email } }),
headers: { "content-type": "application/json" },
method: "POST",
});
expect(created.status).toBe(201);
}
// Fills the email step, then pastes the mailed code with whitespace around it, as a paste often brings.
async function enterMailedCode(page: Page, email: string): Promise<void> {
await page.fill('input[name="email"]', email);
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.locator('input[name="code"]')).toBeVisible();
await page.fill('input[name="code"]', ` ${await mailedCode(email)}\t `);
await page.getByRole("button", { name: "Continue" }).click();
}
test("verification: a code pasted with whitespace around it is accepted", async ({ page }) => {
test.setTimeout(90_000);
const email = `verify-${suffix}@plainpages.local`;
await createIdentity(email);
await page.goto("/verification");
await enterMailedCode(page, email);
await expect(page.getByText("You successfully verified your email address.")).toBeVisible();
});
test("recovery: a pasted code signs in to the settings page, in the chosen language", async ({ page }) => {
test.setTimeout(90_000);
const email = `recover-${suffix}@plainpages.local`;
await createIdentity(email);
await page.goto("/recovery?locale=en-US");
await enterMailedCode(page, email);
await expect(page).toHaveURL(/\/settings\?.*locale=en-US/);
await expect(page.locator("h1")).toHaveText("Account settings");
});
+5 -5
View File
@@ -2,7 +2,7 @@
// never leaves the compose network. Auto-approves /authorize (no provider login UI), then signs an // never leaves the compose network. Auto-approves /authorize (no provider login UI), then signs an
// RS256 id_token Kratos verifies against /jwks. stdlib only, in-memory, NOT app code. The single // RS256 id_token Kratos verifies against /jwks. stdlib only, in-memory, NOT app code. The single
// host (mock-oidc:9000) is reachable by both the browser (/authorize) and Kratos (token/jwks). // host (mock-oidc:9000) is reachable by both the browser (/authorize) and Kratos (token/jwks).
import { createServer, type ServerResponse } from "node:http"; import { createServer } from "node:http";
import { createSign, generateKeyPairSync, randomUUID } from "node:crypto"; import { createSign, generateKeyPairSync, randomUUID } from "node:crypto";
const ISSUER = process.env.ISSUER ?? "http://mock-oidc:9000"; const ISSUER = process.env.ISSUER ?? "http://mock-oidc:9000";
@@ -15,8 +15,8 @@ const KID = "mock-1";
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
const jwk = { ...publicKey.export({ format: "jwk" }), alg: "RS256", kid: KID, use: "sig" }; const jwk = { ...publicKey.export({ format: "jwk" }), alg: "RS256", kid: KID, use: "sig" };
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url"); const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
function idToken(nonce: string): string { function idToken(nonce) {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const header = b64({ alg: "RS256", kid: KID, typ: "JWT" }); const header = b64({ alg: "RS256", kid: KID, typ: "JWT" });
const payload = b64({ const payload = b64({
@@ -27,8 +27,8 @@ function idToken(nonce: string): string {
return `${header}.${payload}.${sig}`; return `${header}.${payload}.${sig}`;
} }
const codes = new Map<string, string>(); // single-use auth code → the nonce Kratos sent (echoed into the id_token) const codes = new Map(); // single-use auth code → the nonce Kratos sent (echoed into the id_token)
const json = (res: ServerResponse, body: unknown): void => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); }; const json = (res, body) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
createServer((req, res) => { createServer((req, res) => {
const url = new URL(req.url ?? "/", ISSUER); const url = new URL(req.url ?? "/", ISSUER);
+23 -397
View File
@@ -1,24 +1,24 @@
{ {
"name": "plainpages-e2e", "name": "plainpages-e2e",
"version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "plainpages-e2e", "name": "plainpages-e2e",
"version": "0.1.0",
"devDependencies": { "devDependencies": {
"@playwright/test": "1.64.1", "@playwright/test": "1.62.1"
"@types/node": "24.19.2",
"typescript": "7.0.2"
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.64.1", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.64.1" "playwright": "1.62.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -27,376 +27,44 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/@types/node": { "node_modules/fsevents": {
"version": "24.19.2", "version": "2.3.2",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true, "dev": true,
"hasInstallScript": true,
"license": "MIT", "license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true, "optional": true,
"os": [ "os": [
"darwin" "darwin"
], ],
"engines": { "engines": {
"node": ">=16.20.0" "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
} }
}, },
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.64.1", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.64.1" "playwright-core": "1.62.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.64.1", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
@@ -405,48 +73,6 @@
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc"
},
"engines": {
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"dev": true,
"license": "MIT"
} }
} }
} }
+3 -5
View File
@@ -1,15 +1,13 @@
{ {
"name": "plainpages-e2e", "name": "plainpages-e2e",
"version": "0.1.0",
"private": true, "private": true,
"description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).", "description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "playwright test", "test": "playwright test"
"typecheck": "tsc --noEmit"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.64.1", "@playwright/test": "1.62.1"
"@types/node": "24.19.2",
"typescript": "7.0.2"
} }
} }
-1
View File
@@ -10,7 +10,6 @@ export default defineConfig({
testDir: ".", testDir: ".",
outputDir: "artifacts/test-output", outputDir: "artifacts/test-output",
fullyParallel: true, fullyParallel: true,
workers: 4, // Playwright's default is half the cores, so a run's parallelism would depend on the machine
forbidOnly: true, forbidOnly: true,
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]], reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
use: { use: {
+1 -1
View File
@@ -11,7 +11,7 @@ const PORT = Number(process.env.PORT ?? 80);
// Kratos public owns these prefixes (self-service flows, sessions, its well-known/schemas); the // Kratos public owns these prefixes (self-service flows, sessions, its well-known/schemas); the
// browser hits them via the flow action + OIDC callbacks. Everything else is the web app. // browser hits them via the flow action + OIDC callbacks. Everything else is the web app.
const toKratos = (path: string): boolean => ["/self-service", "/sessions", "/.well-known/ory", "/schemas"].some((p) => path === p || path.startsWith(`${p}/`)); const toKratos = (path) => ["/self-service", "/sessions", "/.well-known/ory", "/schemas"].some((p) => path === p || path.startsWith(`${p}/`));
createServer((req, res) => { createServer((req, res) => {
const target = toKratos(req.url ?? "/") ? KRATOS : WEB; const target = toKratos(req.url ?? "/") ? KRATOS : WEB;
-5
View File
@@ -1,5 +0,0 @@
{
"extends": "../tsconfig.json",
"compilerOptions": { "lib": ["ESNext", "DOM"] },
"include": ["*.ts"]
}
+13 -159
View File
@@ -10,15 +10,6 @@ const SESSION_COOKIE = "plainpages_jwt"; // src/auth/login.ts — web verifies i
const shot = (page: Page, name: string): Promise<Buffer> => const shot = (page: Page, name: string): Promise<Buffer> =>
page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/${name}.png` }); page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/${name}.png` });
// Either side of the stylesheet's 860px and 560px breakpoints.
const VIEWPORTS = [
{ name: "desktop-xl", width: 1920, height: 1080 },
{ name: "laptop", width: 1280, height: 800 },
{ name: "tablet", width: 768, height: 1024 },
{ name: "phone", width: 390, height: 844 },
{ name: "phone-small", width: 320, height: 568 },
] as const;
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the // Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it // gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT. // with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
@@ -37,45 +28,6 @@ test.beforeEach(async ({ context }) => {
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]); await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
}); });
// A key press, not scrollIntoView (a script can scroll a box no reader can) and not the wheel
// (Firefox's synthetic event never reaches the document).
for (const [name, path, tail] of [
["the starter dashboard", "/dashboard", ".form-actions .btn"],
["the public landing", "/", ".landing-actions .btn"],
] as const) {
for (const { width } of VIEWPORTS) {
test(`${name} scrolls to its end at ${width}px wide`, async ({ page }) => {
await page.setViewportSize({ width, height: 200 });
await page.goto(path);
const overflows = await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight);
expect(overflows, "the page must overflow, or it proves nothing").toBe(true);
await page.keyboard.press("End");
await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 });
});
}
}
// Green only while #nav-toggle is position: fixed — a label tap focuses it, and focus scrolls into view.
test("closing the mobile drawer leaves the reader where the scrim found them", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 200 });
await page.goto("/dashboard");
await page.locator(".hamburger").click();
await expect(page.locator("#nav-toggle")).toBeChecked();
// Scripted, because a key press with focus on the toggle does not scroll in every engine — and
// what is under test is closing the drawer, not how the reader got down the page.
await page.evaluate(() => window.scrollTo(0, 120));
const at = await page.evaluate(() => window.scrollY);
expect(at, "the page must have somewhere to scroll behind the scrim").toBeGreaterThan(0);
// The exposed strip beside the 264px panel: the scrim spans the viewport, so its centre is under
// the drawer and a centre click lands on the panel instead.
await page.locator(".scrim").click({ position: { x: 340, y: 100 } });
await expect(page.locator("#nav-toggle")).not.toBeChecked();
expect(await page.evaluate(() => window.scrollY), "closing the drawer must not move the page").toBe(at);
});
test("captures the live pages for review", async ({ page }) => { test("captures the live pages for review", async ({ page }) => {
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page.locator(".sidebar")).toBeVisible(); await expect(page.locator(".sidebar")).toBeVisible();
@@ -86,20 +38,12 @@ test("captures the live pages for review", async ({ page }) => {
await page.goto("/dashboard"); await page.goto("/dashboard");
await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio
await shot(page, "live-03-dark"); await shot(page, "live-03-dark");
});
// The page is a document, so a column wider than the viewport can only show as sideways scroll. await page.setViewportSize({ width: 390, height: 844 });
for (const { name, width, height } of VIEWPORTS) { await page.goto("/dashboard");
for (const [path, label] of [["/dashboard", "dashboard"], ["/", "landing"], ["/scheduling", "plugin"]] as const) { await shot(page, "live-04-mobile");
test(`the ${label} page fits ${width}px wide, with no sideways scroll`, async ({ page }) => { await page.setViewportSize({ width: 1280, height: 800 });
await page.setViewportSize({ width, height }); });
await page.goto(path);
const [scrollWidth, clientWidth] = await page.evaluate(() => [document.documentElement.scrollWidth, document.documentElement.clientWidth] as const);
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
await shot(page, `live-${label}-${name}`);
});
}
}
test("every icon <use> resolves to a defined <symbol> (no broken graphics)", async ({ page }) => { test("every icon <use> resolves to a defined <symbol> (no broken graphics)", async ({ page }) => {
await page.goto("/dashboard"); await page.goto("/dashboard");
@@ -112,8 +56,9 @@ test("every icon <use> resolves to a defined <symbol> (no broken graphics)", asy
expect(missing).toEqual([]); expect(missing).toEqual([]);
}); });
// The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component and // (The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component
// exercised live by the full-flow E2E's admin Users list, so it has no Ory-free counterpart here. // (list-query/data-table/filter-bar) and exercised live with real data by the full-flow E2E's admin
// Users list. The mock-data dashboard that used to host it in this Ory-free suite is gone.)
test("theme switch flips the palette with no JavaScript", async ({ page }) => { test("theme switch flips the palette with no JavaScript", async ({ page }) => {
await page.goto("/dashboard"); await page.goto("/dashboard");
@@ -150,7 +95,6 @@ test("a popover menu sits on its trigger and closes on an outside click or Esc
await expect(panel).toBeVisible(); await expect(panel).toBeVisible();
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await expect(panel).toBeHidden(); await expect(panel).toBeHidden();
await expect(trigger).toBeFocused(); // the browser returns focus, so no trigger needs a tabindex
}); });
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => { test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
@@ -165,21 +109,6 @@ test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({
expect(offCanvas).toBe(true); expect(offCanvas).toBe(true);
}); });
test("the hamburger opens the drawer over the scrim, and a link in it navigates", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/dashboard");
await page.locator(".hamburger").click();
const sidebar = page.locator(".sidebar");
await expect(sidebar).toBeInViewport({ ratio: 1 });
// A click is refused while the scrim sits above the drawer, which a lower z-index would cause.
await sidebar.locator("summary").first().click(); // the dashboard is the current page, so its links are in a folded branch
const link = sidebar.locator(".nav-children a.nav-self").first();
const target = await link.getAttribute("href");
await link.click();
await expect(page).toHaveURL((url) => url.pathname === target?.split("?")[0]);
});
test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => { test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => {
await page.goto("/dashboard"); await page.goto("/dashboard");
// The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit). // The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit).
@@ -206,68 +135,6 @@ test("the public landing at / is ungated and links to sign in + register", async
await shot(page, "live-05-public-landing"); await shot(page, "live-05-public-landing");
}); });
// Measured on the rendered elements, so a theme block that drops --on-accent fails here.
const contrastOf = (el: Element): number => {
const luminance = (rgb: string): number => {
const [r = 0, g = 0, b = 0] = (rgb.match(/[\d.]+/g) ?? []).map((c) => {
const v = Number(c) / 255;
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
};
const style = getComputedStyle(el);
const [hi = 0, lo = 0] = [luminance(style.color), luminance(style.backgroundColor)].sort((x, y) => y - x);
return (hi + 0.05) / (lo + 0.05);
};
for (const [name, colorScheme, forceDark] of [["light", "light", false], ["dark", "dark", false], ["forced dark", "light", true]] as const) {
test(`text on the accent reaches 4.5:1 in ${name} mode`, async ({ page, context }) => {
await context.clearCookies();
await page.emulateMedia({ colorScheme });
await page.goto("/");
if (forceDark) await page.locator("#theme-dark").check({ force: true });
// No page of this stack has a second page of results, so add the current-page button.
await page.locator("#main-content").evaluate((el) => el.insertAdjacentHTML("beforeend", '<a class="page-btn" aria-current="page" href="#">2</a>'));
for (const selector of [".landing .btn-primary", ".brand-mark", ".page-btn"]) {
await expect.poll(() => page.locator(selector).first().evaluate(contrastOf), { message: selector }).toBeGreaterThanOrEqual(4.5); // polls past the colour transition
}
});
}
test("a nav branch is folded until its toggle opens it — no JavaScript", async ({ page }) => {
await page.goto("/dashboard");
const child = page.locator('.sidebar a[href="/scheduling"]');
await expect(child).toBeHidden();
await page.locator('.sidebar summary[aria-label="Toggle Scheduling"]').click();
await expect(child).toBeVisible();
});
test("the reference plugin's lead text is inset by --pad-x, like the bars around it", async ({ page }) => {
await page.goto("/scheduling");
const [inset, padX] = await page.locator(".scheduling-page > p").evaluate((p) => [
p.getBoundingClientRect().left - (p.parentElement as HTMLElement).getBoundingClientRect().left,
parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--pad-x")),
] as const);
expect(inset).toBe(padX);
});
// transferSize is 0 only for a response the browser's own cache served; a 304 still costs a round trip.
test("a repeat page view fetches no stylesheet again", async ({ page }) => {
const stylesheets = (): Promise<{ name: string; transferSize: number }[]> => page.evaluate(() =>
performance.getEntriesByType("resource")
.filter((entry): entry is PerformanceResourceTiming => entry instanceof PerformanceResourceTiming && entry.initiatorType === "link" && entry.name.includes(".css"))
.map(({ name, transferSize }) => ({ name, transferSize })));
await page.goto("/");
const first = await stylesheets();
expect(first.length, "the landing links styles.css and auth.css").toBeGreaterThanOrEqual(2);
for (const { name, transferSize } of first) expect(transferSize, `${name} on the first view`).toBeGreaterThan(0);
await page.goto("/");
const repeat = await stylesheets();
expect(repeat.map(({ name }) => name)).toEqual(first.map(({ name }) => name));
for (const { name, transferSize } of repeat) expect(transferSize, `${name} on the repeat view`).toBe(0);
});
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => { test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
allowConsole(/status of 404 .*\/no-such-page$/); // the navigation under test, which Chromium and WebKit log — not a sub-resource of it allowConsole(/status of 404 .*\/no-such-page$/); // the navigation under test, which Chromium and WebKit log — not a sub-resource of it
const res = await page.goto("/no-such-page"); const res = await page.goto("/no-such-page");
@@ -276,11 +143,11 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to
await expect(page.getByRole("link", { name: "Back home" })).toBeVisible(); await expect(page.getByRole("link", { name: "Back home" })).toBeVisible();
}); });
// The reference plugin (plugins/scheduling) ships discovered in the image, and shows all three // The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
// gates: the public Overview is reachable by anyone, My shifts takes any session, and the shifts // reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated,
// list needs a permission. The authenticated list/form flow is the full E2E (full-flow.spec). // so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
// Side-effect-free. // E2E (full-flow.spec). Side-effect-free.
test("the reference plugin: public Overview is open to all, My shifts takes any session, the gated Shifts redirects to /login", async ({ page, request }) => { test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => {
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these // `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
// probes are genuinely anonymous. // probes are genuinely anonymous.
// The public overview is reachable with no session (200), not bounced to sign in. // The public overview is reachable with no session (200), not bounced to sign in.
@@ -299,23 +166,10 @@ test("the reference plugin: public Overview is open to all, My shifts takes any
expect(res.status()).toBe(303); expect(res.status()).toBe(303);
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts"); expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
// A `session: true` route bounces an anonymous visitor the same way — no permission involved.
const mine = await request.get("/scheduling/mine", { maxRedirects: 0 });
expect(mine.status()).toBe(303);
expect(mine.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fmine");
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav, // The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
// but the gated Shifts leaf is filtered out. // but the gated Shifts leaf is filtered out.
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders await expect(page.locator('.sidebar a[href="/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 a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough
// No shifts upstream on this stack, so this also pins the degraded page: the reason, never a 500
// and never a claim about what is assigned.
await page.goto("/scheduling/mine");
await expect(page.getByRole("heading", { name: "My shifts" })).toBeVisible();
await expect(page.getByText("Couldn't reach the scheduling service")).toBeVisible();
await expect(page.getByText("No shifts are assigned to")).toHaveCount(0);
}); });
+2 -2
View File
@@ -5,7 +5,7 @@ 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 `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). | | [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / 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 `SCHEDULING_UPSTREAM` at the real thing instead. |
+6 -5
View File
@@ -1,10 +1,11 @@
// Reference config/menu.ts — copy into the empty config/ mount at the repo root: // Reference config/menu.ts — copy into the (empty) config/ mount at the repo root:
// cp examples/config/menu.ts config/menu.ts // cp examples/config/menu.ts config/menu.ts
// Absent config = built-in defaults. // config/ ships empty; mount your own or copy this in. Absent config = built-in defaults.
// //
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — the // Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins —
// override always wins, applied before the per-user permission filter. Every field is optional. // the override always wins, applied before the per-user permission filter. Every field is
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system. // optional; delete one to fall back to the default.
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README.md (The menu system).
import { defineMenu } from "#menu-config"; import { defineMenu } from "#menu-config";
+19 -18
View File
@@ -1,22 +1,22 @@
# Admin — the system-administration plugin # 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. These used to be
example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/` built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
(it keeps the id and mount path `admin`, so the screens live at `/admin/*`) and restart: until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
screens live at `/admin/*`) and restart:
```bash ```bash
cp -r examples/plugins/admin plugins/admin cp -r examples/plugins/admin plugins/admin
docker compose up -d docker compose up -d
``` ```
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so the
the section appears in the menu and the screens work immediately. An older copy already in section appears in the menu and the screens work immediately.
`plugins/` is yours — the host never updates it — so re-copy after a pull; a stale one stops the boot
with a message naming it ([README → Upgrading](../../../README.md#upgrading)).
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`), the nav Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`) — the nav
labels included. Each pure view-model builder takes an optional `t` defaulting to the plugin's own labels included, which are catalog keys in `admin-shared.ts`. Each pure view-model builder takes an
English, so a unit test reads in words rather than keys. optional `t`; the handlers pass `ctx.t`, and the default is the plugin's own English so a unit test
reads in words rather than keys. (README → [Languages](../../../README.md#languages-i18n).)
## What it demonstrates — a *system* plugin ## What it demonstrates — a *system* plugin
@@ -30,14 +30,15 @@ stack**, so they use the privileged **`ctx.system`** surface the host exposes to
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a - **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL. user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
`ctx.system` is populated only when the host wired those services. Where a capability is absent the `ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
screen degrades to a themed 503 rather than crashing. Everything else is an ordinary plugin: and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
folder-discovered, gated per route by its screen's `<resource>:<action>` permission, rendering the than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
core building blocks in `views/`. gated per route by its screen's `<resource>:<action>` permission, rendering the core building blocks
in `views/`.
Each screen is its own resource — `users`, `groups`, `oauth2-clients` — split into `:read` and Each screen is its own resource — `users`, `groups`, `oauth2-clients` — and each splits into `:read`
`:write`, so a helpdesk account can be given `users:read` alone. Holding none of the six hides the and `:write`, so a helpdesk account can be given `users:read` alone. The nav is filtered by the same
Admin section entirely. permissions: holding none of the three hides the Admin section entirely.
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a 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
@@ -62,4 +63,4 @@ property of a user or a group, edited as a checkbox list on those two screens (`
The three screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders The three screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders
unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in
`src/http/app-admin.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`. `src/http/app.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`.
+1 -1
View File
@@ -1,6 +1,6 @@
// Built-in OAuth2 clients admin screen: the pure view-model + Hydra-payload builders. A client // Built-in OAuth2 clients admin screen: the pure view-model + Hydra-payload builders. A client
// is an Ory Hydra OAuth2 client (apps that log in *through* us); writes go only to Hydra. The // is an Ory Hydra OAuth2 client (apps that log in *through* us); writes go only to Hydra. The
// HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in src/http/app-admin.test.ts. // HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in app.test.ts.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { import {
+1 -1
View File
@@ -5,7 +5,7 @@
// 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 { 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 "#plugin-api";
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, 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"; import type { FieldConfig } from "./admin-users.ts";
+6 -52
View File
@@ -1,10 +1,9 @@
// The pure half of permission granting: what a submitted checkbox set changes, and the picker the // The pure half of permission granting: what a submitted checkbox set changes, and the picker the
// two screens render from it. The Keto writes and the HTTP round trip are covered in src/http/app-admin.test.ts. // two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts.
import assert from "node:assert/strict"; import 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 "#plugin-api";
import type { KetoClient } from "@plainpages/plugin-api"; import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, 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" },
@@ -47,9 +46,9 @@ test("buildPermissionPicker ticks what is held and carries each declaration's de
assert.equal(picker.inheritedNote, undefined); // nothing is group-held here assert.equal(picker.inheritedNote, undefined); // nothing is group-held here
}); });
// An inherited permission rendered unticked would say "not held" about a grant that reaches the JWT, // The failure this prevents: a permission held through a group used to render unticked, so the page
// and unticking it writes nothing, reading as a successful revoke. So inherited rows are ticked, // said "not held" about a grant that reaches the JWT — and unticking it wrote nothing, which read as
// disabled, and never posted. // a successful revoke. Inherited rows are ticked, disabled, and never posted.
test("buildPermissionPicker distinguishes a direct grant from one inherited through a group", () => { test("buildPermissionPicker distinguishes a direct grant from one inherited through a group", () => {
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:write"], effective: ["users:read", "users:write"] }); const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:write"], effective: ["users:read", "users:write"] });
assert.deepEqual(picker.choices.map((c) => [c.name, c.checked, c.inherited]), [ assert.deepEqual(picker.choices.map((c) => [c.name, c.checked, c.inherited]), [
@@ -82,48 +81,3 @@ test("buildPermissionPicker says so when no plugin declares a permission, rather
assert.deepEqual(picker.choices, []); assert.deepEqual(picker.choices, []);
assert.ok(picker.empty); assert.ok(picker.empty);
}); });
test("baselineToken depends on the offered grants held, not on their order or on undeclared ones", () => {
const token = baselineToken(declared, ["users:read", "users:write"]);
assert.match(token, /^[0-9a-f]+$/);
assert.equal(baselineToken(declared, ["users:write", "users:read"]), token);
assert.equal(baselineToken(declared, ["users:read", "users:write", "legacy:thing"]), token); // undeclared: never shown, never saved
assert.notEqual(baselineToken(declared, ["users:read"]), token);
assert.notEqual(baselineToken(declared, []), token);
assert.notEqual(baselineToken(declared, []), ""); // an empty set is a state too, not an absent token
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: ["users:read", "users:write"] }).baseline, token);
});
test("isStale refuses a missing or old token, but only for a save that would change something", () => {
const held = ["users:read"];
const change = grantDiff(declared, held, ["users:read", "users:write"]);
const nothing = grantDiff(declared, held, ["users:read"]);
const current = new URLSearchParams({ baseline: baselineToken(declared, held) });
const old = new URLSearchParams({ baseline: baselineToken(declared, []) });
assert.equal(isStale(current, declared, held, change), false);
assert.equal(isStale(old, declared, held, change), true);
assert.equal(isStale(new URLSearchParams(), declared, held, change), true); // no token at all
assert.equal(isStale(old, declared, held, nothing), false); // a repeated save has nothing to refuse
});
test("applyGrants applies writes then deletes, and on a Keto failure stops and reports what was applied", async () => {
const log: string[] = [];
const keto = (failOn?: string): KetoClient => ({
check: async () => false,
deleteTuple: async (tp) => { if (tp.object === failOn) throw new Error("boom"); log.push(`delete ${tp.object}`); },
expand: async () => ({ type: "leaf" }),
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
writeTuple: async (tp) => { if (tp.object === failOn) throw new Error("boom"); log.push(`write ${tp.object}`); },
});
const diff = { grant: ["a:read", "b:read"], revoke: ["c:read"] };
const ok = await applyGrants(keto(), userSubject("u1"), diff);
assert.deepEqual(ok, { applied: diff });
assert.deepEqual(log, ["write a:read", "write b:read", "delete c:read"]);
log.length = 0;
const failed = await applyGrants(keto("b:read"), userSubject("u1"), diff);
assert.deepEqual(failed.applied, { grant: ["a:read"], revoke: [] });
assert.ok(failed.error instanceof Error);
assert.deepEqual(log, ["write a:read"]); // nothing after the failure was attempted
});
+5 -39
View File
@@ -6,17 +6,11 @@
// what the installed plugins declare in code. Nothing here invents a name, which is why the old // what the installed plugins declare in code. Nothing here invents a name, which is why the old
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are. // 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 type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "#plugin-api";
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
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 BASELINE_FIELD = "baseline"; // the hidden field carrying baselineToken
export const SAVED_NOTICE = "permissions-saved"; // the `info-msg` a successful save redirects with
// Only that exact value counts, so a crafted link can't put words on the page.
export const isSavedNotice = (url: URL): boolean => url.searchParams.get("info-msg") === SAVED_NOTICE;
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet }; export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
@@ -59,7 +53,6 @@ export interface PermissionChoice {
export interface PermissionPicker { export interface PermissionPicker {
action: string; action: string;
baseline: string; // baselineToken of the direct grants this form was rendered from
choices: PermissionChoice[]; choices: PermissionChoice[];
empty: string | undefined; // set when no plugin declares a permission — the picker has nothing to offer empty: string | undefined; // set when no plugin declares a permission — the picker has nothing to offer
error?: string; // a rejected save (e.g. the self-revoke guard), rendered above the list error?: string; // a rejected save (e.g. the self-revoke guard), rendered above the list
@@ -67,7 +60,6 @@ export interface PermissionPicker {
hint: string; hint: string;
inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row
legend: string; legend: string;
notice?: string; // a confirmation, rendered above the list
// Set for a group: its members hold these transitively, so a change reaches them at their next // Set for a group: its members hold these transitively, so a change reaches them at their next
// re-mint rather than at once. The user picker revokes live tokens, so it says nothing. // re-mint rather than at once. The user picker revokes live tokens, so it says nothing.
pending: string | undefined; pending: string | undefined;
@@ -98,7 +90,6 @@ export function buildPermissionPicker(opts: {
})); }));
return { return {
action: opts.action, action: opts.action,
baseline: baselineToken(opts.declared, opts.direct),
choices, choices,
empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined, empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined,
field: PERMISSIONS_FIELD, field: PERMISSIONS_FIELD,
@@ -116,7 +107,7 @@ export function buildPermissionPicker(opts: {
// 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.
export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): GrantChange { export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): { grant: string[]; revoke: string[] } {
const offered = new Set(declared.map((d) => d.name)); const offered = new Set(declared.map((d) => d.name));
const heldSet = new Set(held); const heldSet = new Set(held);
const wantedSet = new Set(wanted.filter((name) => offered.has(name))); const wantedSet = new Set(wanted.filter((name) => offered.has(name)));
@@ -126,32 +117,7 @@ export function grantDiff(declared: readonly PermissionDecl[], held: string[], w
}; };
} }
// A token for the direct grants a form was rendered from. Like grantDiff it covers only the declared export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: { grant: string[]; revoke: string[] }): Promise<void> {
// names the form offered. It is a digest so an empty set is still a token; it is a guard against a for (const name of diff.grant) await keto.writeTuple(grantTuple(name, subject));
// stale page, not a secret, since anyone who can read the page can compute it. for (const name of diff.revoke) await keto.deleteTuple(grantTuple(name, subject));
export function baselineToken(declared: readonly PermissionDecl[], held: string[]): string {
const offered = new Set(declared.map((d) => d.name));
return createHash("sha256").update(held.filter((name) => offered.has(name)).sort().join("\n")).digest("hex").slice(0, 16);
}
// True when the form's token is missing or no longer matches Keto *and* the save would change
// something. A save that changes nothing, such as a double-clicked Save whose first POST landed, is
// harmless, so it is not refused.
export const isStale = (form: { get(name: string): string | null }, declared: readonly PermissionDecl[], held: string[], diff: GrantChange): boolean =>
(diff.grant.length > 0 || diff.revoke.length > 0) && form.get(BASELINE_FIELD) !== baselineToken(declared, held);
export interface GrantChange { grant: string[]; revoke: string[] }
// KetoClient writes one tuple per call, so the writes and deletes run one by one and stop at the first
// 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 };
} }
+2 -2
View File
@@ -1,7 +1,7 @@
// Built-in Groups admin screen: the pure view-model + Keto-tuple builders. A group is a // Built-in Groups admin screen: the pure view-model + Keto-tuple builders. A group is a
// Keto subject set (Group:<name>#members); membership tuples carry users (subject_id) or nested // Keto subject set (Group:<name>#members); membership tuples carry users (subject_id) or nested
// groups (subject_set). The HTTP routing/gate/CSRF + live Keto/Kratos calls are exercised over // groups (subject_set). The HTTP routing/gate/CSRF + live Keto/Kratos calls are exercised over
// HTTP in src/http/app-admin.test.ts. // HTTP in app.test.ts.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { import {
@@ -14,7 +14,7 @@ import {
memberView, memberView,
parseSubject, parseSubject,
} from "./admin-groups.ts"; } from "./admin-groups.ts";
import type { RelationTuple } from "@plainpages/plugin-api"; import type { RelationTuple } from "#plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`; const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (group: string, n: number): RelationTuple => const userTuple = (group: string, n: number): RelationTuple =>
+28 -83
View File
@@ -6,9 +6,9 @@
// 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 { 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 { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "#plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
const GROUP_NS = "Group"; const GROUP_NS = "Group";
@@ -293,32 +293,6 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
return page.tuples.length > 0; return page.tuples.length > 0;
} }
const isMember = (keto: KetoClient, actor: string, group: string): Promise<boolean> =>
keto.check({ namespace: GROUP_NS, object: group, relation: MEMBERS, subject_id: `user:${actor}` });
// The self-lockout guard, the group-side twin of the Users screen's self-revoke one: of the permissions
// a change takes from a group the actor is in, those they don't hold directly. One they also hold
// through another group is included, since telling those apart needs a reverse Keto query.
async function lostToSelf(keto: KetoClient, actor: string, taken: readonly string[]): Promise<string[]> {
if (taken.length === 0) return [];
const direct = new Set(await heldPermissions(keto, userSubject(actor)));
return taken.filter((name) => !direct.has(name));
}
// Leaving a group takes everything it confers, including through its own parents.
async function lostOnLeaving({ ctx, keto, user }: GroupsDeps, group: string): Promise<string[]> {
return lostToSelf(keto, user.id, await effectivePermissions(keto, groupSubject(group), ctx.declaredPermissions));
}
async function lostOnDelete(deps: GroupsDeps, group: string): Promise<string[]> {
return (await isMember(deps.keto, deps.user.id, group)) ? lostOnLeaving(deps, group) : [];
}
async function refuseSelfLockout(deps: GroupsDeps, name: string, action: string, lost: string[]): Promise<RouteResult> {
deps.ctx.log.warn("admin: refused a group change that would lock the actor out", { action, actor: deps.user.id, group: name, lost: lost.join(",") });
return { ...(await groupDetailPage(deps, name, { pageError: deps.ctx.t("admin.groups.error.selfLockout", { permissions: lost.join(", ") }) })), status: 400 };
}
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate (`groups:read` on // Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate (`groups:read` on
// a GET, `groups:write` on a POST) + the Keto and Kratos capabilities (else a themed 503). Each route // a GET, `groups:write` on a POST) + the Keto and Kratos capabilities (else a themed 503). Each route
// below is a thin handler over these. // below is a thin handler over these.
@@ -373,54 +347,36 @@ export const groupsCreate = withGroups(async (deps) => {
// GET /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 // GET /admin/groups/:name — the detail + membership page.
// page, for any change refused because it would lock the actor out. export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => {
async function groupDetailPage({ ctx, keto, kratosAdmin }: GroupsDeps, name: string, say: { error?: string; notice?: string; pageError?: string } = {}): Promise<RouteResult> {
const { emailById, options } = await memberCandidates(keto, kratosAdmin); const { emailById, options } = await memberCandidates(keto, kratosAdmin);
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById)); const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
const subject = groupSubject(name); const subject = groupSubject(name);
const [direct, effective] = await Promise.all([heldPermissions(keto, subject), effectivePermissions(keto, subject, ctx.declaredPermissions)]); const [direct, effective] = await Promise.all([heldPermissions(keto, subject), effectivePermissions(keto, subject, ctx.declaredPermissions)]);
const permissions: PermissionPicker = { const permissions = buildPermissionPicker({
...buildPermissionPicker({ action: `${detailHref(name)}/permissions`,
action: `${detailHref(name)}/permissions`, declared: ctx.declaredPermissions,
declared: ctx.declaredPermissions, direct,
direct, effective, // a group nested in another group inherits its permissions too
effective, // a group nested in another group inherits its permissions too readOnly: !can(ctx, permissionName("groups", "write")),
readOnly: !can(ctx, permissionName("groups", "write")), t: ctx.t,
t: ctx.t, transitive: true, // members inherit, so a change here lands at their next re-mint, not at once
transitive: true, // members inherit, so a change here lands at their next re-mint, not at once });
}), return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t }) }, view: "group-detail" };
...(say.error ? { error: say.error } : {}), });
...(say.notice ? { notice: say.notice } : {}),
};
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t, ...(say.pageError ? { error: say.pageError } : {}) }) }, view: "group-detail" };
}
// GET /admin/groups/:name — the detail + membership page.
export const groupsDetail = withGroupName((deps, name) =>
groupDetailPage(deps, name, isSavedNotice(deps.ctx.url) ? { notice: deps.ctx.t("admin.grants.saved") } : {}));
// POST /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 ({ ctx, keto, user }, name) => {
const { ctx, keto, user } = deps;
const form = (await guardedForm(ctx))!; const form = (await guardedForm(ctx))!;
const subject = groupSubject(name); const subject = groupSubject(name);
const held = await heldPermissions(keto, subject); const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
const diff = grantDiff(ctx.declaredPermissions, held, form.getAll(PERMISSIONS_FIELD)); await applyGrants(keto, subject, diff);
if (isStale(form, ctx.declaredPermissions, held, diff)) return { ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.stale") })), status: 409 }; if (diff.grant.length > 0 || diff.revoke.length > 0) {
const lost = diff.revoke.length > 0 && await isMember(keto, user.id, name) ? await lostToSelf(keto, user.id, diff.revoke) : []; ctx.log.info("admin: group permissions changed", { actor: user.id, granted: diff.grant.join(","), group: name, revoked: diff.revoke.join(",") });
if (lost.length > 0) return refuseSelfLockout(deps, name, "save", lost);
const { applied, error } = await applyGrants(keto, subject, diff);
if (applied.grant.length > 0 || applied.revoke.length > 0) {
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: applied.grant.join(","), group: name, revoked: applied.revoke.join(",") });
} }
if (error) { return { redirect: detailHref(name) };
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}` };
}); });
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest). // POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
@@ -432,24 +388,19 @@ export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
}); });
// GET /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((deps, name) => {
const lost = await lostOnDelete(deps, name);
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
const base = detailHref(name); const base = detailHref(name);
const tt = deps.ctx.t; const tt = deps.ctx.t;
return { data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: tt("admin.groups.title") }, { href: base, label: name }, { label: tt("common.delete") }], breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: tt("admin.groups.title") }, { href: base, label: name }, { label: tt("common.delete") }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"), cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"),
message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"), message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"),
}) }, view: "confirm" }; }) }, view: "confirm" });
}, "write"); }, "write");
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist). // POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
export const groupsDelete = withGroupName(async (deps, name) => { export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => {
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);
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
// Drop what the group *holds* before what it *contains*: a Keto set exists only through its // Drop what the group *holds* before what it *contains*: a Keto set exists only through its
// tuples, so leaving the grants behind would resurrect every permission the moment someone // tuples, so leaving the grants behind would resurrect every permission the moment someone
// re-created a group with the same name. // re-created a group with the same name.
@@ -462,15 +413,9 @@ export const groupsDelete = withGroupName(async (deps, name) => {
}); });
// POST /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 ({ ctx, keto }, name) => {
const { ctx, keto, user } = deps;
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());
if (!tuple) return { redirect: detailHref(name) }; if (tuple) await keto.deleteTuple(tuple);
// Removing the actor, or a nested group they are in, can take them out of this group.
const removesSelf = tuple.subject_set ? await isMember(keto, user.id, tuple.subject_set.object) : tuple.subject_id === `user:${user.id}`;
const lost = removesSelf ? await lostOnLeaving(deps, name) : [];
if (lost.length > 0) return refuseSelfLockout(deps, name, "remove", lost);
await keto.deleteTuple(tuple);
return { redirect: detailHref(name) }; return { redirect: detailHref(name) };
}); });
@@ -1,55 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { PageChrome, PluginSettings } from "@plainpages/plugin-api";
import { buildPluginSettingsModel } from "./admin-plugin-settings.ts";
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
const CATALOG: readonly PluginSettings[] = [
{
pluginId: "scheduling",
settings: [
{ description: "Where shifts come from", envName: "PLUGIN_SETTING_SCHEDULING_UPSTREAM", key: "upstream", required: true, secret: false, source: "env", type: "url", value: "https://shifts.test" },
{ envName: "PLUGIN_SETTING_SCHEDULING_MODE", key: "mode", required: false, secret: false, source: "default", type: "enum", value: "strict", values: ["strict", "lenient"] },
{ envName: "PLUGIN_SETTING_SCHEDULING_NOTE", key: "note", required: false, secret: false, source: "unset", type: "string" },
],
},
{ pluginId: "quiet", settings: [] },
];
test("a row carries the variable to set and where the value came from", () => {
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
const rows = model.groups[0]?.table.rows ?? [];
assert.deepEqual(rows.map((r) => r.name), ["upstream", "mode", "note"]);
assert.deepEqual(rows[0]?.cells, [
{ rowHeader: { text: "upstream" } }, "Where shifts come from", "url", "Yes", "PLUGIN_SETTING_SCHEDULING_UPSTREAM", "Environment", "https://shifts.test",
]);
assert.equal(rows[1]?.cells[2], "enum (strict, lenient)"); // the choices are the useful half of the type
assert.equal(rows[2]?.cells[5], "Not set");
});
test("a plugin declaring nothing still gets a section, so it is visibly installed", () => {
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
assert.deepEqual(model.groups.map((g) => g.pluginId), ["scheduling", "quiet"]);
assert.deepEqual(model.groups[1]?.table.rows, []);
assert.match(model.groups[1]?.emptyText ?? "", /no settings/i);
});
test("a secret renders as set-or-not, never as a value, a mask or a length", () => {
const settings: readonly PluginSettings[] = [{
pluginId: "billing",
settings: [
{ envName: "PLUGIN_SETTING_BILLING_API_KEY", key: "apiKey", required: false, secret: true, source: "env", type: "string" },
{ envName: "PLUGIN_SETTING_BILLING_WEBHOOK_KEY", key: "webhookKey", required: false, secret: true, source: "unset", type: "string" },
],
}];
const rows = buildPluginSettingsModel({ chrome: CHROME, settings }).groups[0]?.table.rows ?? [];
assert.equal(rows[0]?.cells[6], "Secret — set");
assert.equal(rows[1]?.cells[6], "Secret — not set");
});
test("two tables on one page need distinct row-action id stems", () => {
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
const stems = model.groups.map((g) => g.table.actionsId);
assert.equal(new Set(stems).size, stems.length);
});
@@ -1,75 +0,0 @@
// Plugin settings admin screen: what each installed plugin declares it can be configured with, the
// variable that sets it, and how each key resolved. Read-only — the host reads settings from the
// environment at boot, so changing one is a deploy, not a form.
import { type PageChrome, type PluginSettings, type RouteHandler, type SettingSummary, type Translate } from "@plainpages/plugin-api";
import { ADMIN_EN, requirePermission } from "./admin-shared.ts";
interface SettingsGroup {
emptyText: string;
pluginId: string;
table: {
actionsId: string;
caption: string;
columns: { label: string }[];
rows: { cells: (string | { rowHeader: { text: string } })[]; name: string }[];
};
}
// One group per installed plugin, including those declaring nothing — an operator who cannot find
// their plugin here has not installed it, which is the other half of what this screen answers.
export function buildPluginSettingsModel(opts: { chrome: PageChrome; settings: readonly PluginSettings[]; t?: Translate }) {
const t = opts.t ?? ADMIN_EN;
return {
breadcrumbs: [{ label: t("admin.pluginSettings.title") }],
chrome: opts.chrome,
groups: opts.settings.map((plugin): SettingsGroup => ({
emptyText: t("admin.pluginSettings.none"),
pluginId: plugin.pluginId,
table: {
actionsId: `settings-${plugin.pluginId}`, // two tables share this page, so the stem must differ
caption: t("admin.pluginSettings.caption", { plugin: plugin.pluginId }),
columns: [
{ label: t("admin.pluginSettings.column.key") },
{ label: t("admin.pluginSettings.column.description") },
{ label: t("admin.pluginSettings.column.type") },
{ label: t("admin.pluginSettings.column.required") },
{ label: t("admin.pluginSettings.column.variable") },
{ label: t("admin.pluginSettings.column.source") },
{ label: t("admin.pluginSettings.column.value") },
],
rows: plugin.settings.map((setting) => ({
cells: [
{ rowHeader: { text: setting.key } },
setting.description ?? "",
typeLabel(setting),
t(setting.required ? "admin.pluginSettings.yes" : "admin.pluginSettings.no"),
setting.envName,
t(`admin.pluginSettings.source.${setting.source}`),
valueLabel(setting, t),
],
name: setting.key,
})),
},
})),
title: t("admin.pluginSettings.title"),
};
}
// An enum's choices are the useful half of its type — they are what the operator must pick from.
function typeLabel(setting: SettingSummary): string {
return setting.type === "enum" && setting.values ? `${setting.type} (${setting.values.join(", ")})` : setting.type;
}
// A secret never renders its value — not the value, not a mask of it, not its length. Whether it
// resolved and from where is what an operator needs, and the source column already says the rest.
function valueLabel(setting: SettingSummary, t: Translate): string {
if (setting.secret) return t(setting.source === "unset" ? "admin.pluginSettings.secretUnset" : "admin.pluginSettings.secretSet");
return setting.value ?? t("admin.pluginSettings.unset");
}
// GET /admin/plugin-settings
export const pluginSettingsList: RouteHandler = (ctx) => {
requirePermission(ctx, "plugin-settings");
return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" };
};
+10 -10
View File
@@ -1,12 +1,12 @@
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical // Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
// (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the // (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app-admin.test.ts. // contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
// Import only from the @plainpages/plugin-api barrel — the same contract boundary the plugin code uses. // Import only from the #plugin-api barrel — the same contract boundary the plugin code uses.
import assert from "node:assert/strict"; import 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 Log, type PageChrome, type RequestContext, type User } from "@plainpages/plugin-api"; import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts"; import { 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"] };
@@ -19,7 +19,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; 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 {
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {}, chrome: CHROME, declaredPermissions: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url, query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
verifyCsrf: opts.verifyCsrf ?? (() => true), verifyCsrf: opts.verifyCsrf ?? (() => true),
}; };
@@ -27,21 +27,21 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
// ---- nav fragment ---- // ---- nav fragment ----
test("ADMIN_NAV: an ungated Admin header whose screens each gate on their own read permission", () => { test("ADMIN_NAV: an ungated Admin header whose three screens each gate on their own read permission", () => {
assert.equal(ADMIN_NAV.id, "admin"); assert.equal(ADMIN_NAV.id, "admin");
// No gate on the header: a user may hold one screen's permission and not another's. composeNav // No gate on the header: a user may hold one screen's permission and not another's. composeNav
// drops a header left with no visible children, so holding none of them hides the section. // drops a header left with no visible children, so holding none of the three hides the section.
// Both halves matter — give the header an `href` and it survives the filter as a visible leaf, // Both halves matter — give the header an `href` and it survives the filter as a visible leaf,
// ungated, for anonymous visitors included. // ungated, for anonymous visitors included.
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), ["/admin/users", "/admin/groups", "/admin/clients", "/admin/plugin-settings"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read", "plugin-settings:read"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read"]);
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes // 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.
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients", "admin.nav.pluginSettings"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients", "Plugin settings"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients"]);
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined)); assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
}); });
+25 -30
View File
@@ -1,24 +1,25 @@
// Shared plumbing for the admin example plugin: the section nav fragment, the screen gate, the // Shared plumbing for the admin example plugin: the section nav fragment, the admin-only gate, the
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers. // CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers
// Everything imports the host only through the @plainpages/plugin-api barrel. // (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
// everything imports the host only through the #plugin-api barrel.
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "@plainpages/plugin-api"; import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "#plugin-api";
import enUS from "./i18n/en-US.ts"; 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 — the screens reuse core words like Cancel and
// i.e. its unit tests. At runtime the handlers pass ctx.t instead. // Search), for a view model built outside a request: its unit tests. At runtime the handlers pass
// ctx.t, which reads this catalog in the visitor's locale first, then the host's.
export const ADMIN_EN: Translate = englishTranslator(enUS); export const ADMIN_EN: Translate = englishTranslator(enUS);
export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_USERS_BASE = "/admin/users";
export const ADMIN_GROUPS_BASE = "/admin/groups"; export const ADMIN_GROUPS_BASE = "/admin/groups";
export const ADMIN_CLIENTS_BASE = "/admin/clients"; export const ADMIN_CLIENTS_BASE = "/admin/clients";
export const ADMIN_PLUGIN_SETTINGS_BASE = "/admin/plugin-settings";
// One resource per screen — the `<resource>` half of every permission this plugin gates on. // 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.
// There is no `permissions` resource: permissions are declared in plugin code, not created here, so // There is no `permissions` resource: permissions are declared in plugin code, not created here, so
// holding a grant is a property of a user or a group and is edited on those two screens. // holding a grant is a property of a user or a group and is edited on those two screens.
export type AdminResource = "groups" | "oauth2-clients" | "plugin-settings" | "users"; export type AdminResource = "groups" | "oauth2-clients" | "users";
export type AdminAction = "read" | "write"; export type AdminAction = "read" | "write";
@@ -27,35 +28,39 @@ export function permissionName(resource: AdminResource, action: AdminAction): st
return `${resource}:${action}`; return `${resource}:${action}`;
} }
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both // This plugin's mapping from method to action: every screen reads on GET/HEAD and mutates on POST.
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately // The manifest's route table and the in-handler guard both go through it rather than each spelling
// local: generalised, it would make authorization a function of the transport verb (AGENTS.md). // the permission out, so they cannot drift into gating on different names. Deliberately local — as
// a general mechanism it would make authorization a function of the transport verb, and a route
// table should answer "what does this need?" on its own (AGENTS.md).
export function actionForMethod(method: string): AdminAction { 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";
} }
// The plugin's nav fragment: the "Admin" header + its four screens, each gated on its own read // The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own
// permission. composeNav drops a header left with no visible children, so a user holding none of // read permission. The header carries no `permission` because a user may hold one screen's and not
// them never sees the section. The host current-marks the active item — no `current`/`open` here. // another's; composeNav drops a header left with no visible children, so a user holding none of the
// three never sees the section. The host current-marks the active item — no `current`/`open` here.
export const ADMIN_NAV: NavNode = { export const ADMIN_NAV: NavNode = {
children: [ children: [
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") }, { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") }, { href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") },
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") }, { href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") },
{ href: ADMIN_PLUGIN_SETTINGS_BASE, icon: "i-sliders", id: "plugin-settings", label: "admin.nav.pluginSettings", permission: permissionName("plugin-settings", "read") },
], ],
icon: "i-shield", icon: "i-shield",
id: "admin", id: "admin",
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
public: true, // the header gates nothing; every child needs a permission, and an empty header is dropped
}; };
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already // The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
// declares the same permission, so this is defence-in-depth and what a direct unit test relies on. // declares the same permission, so the host enforces it before the handler runs; this is
// `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create // defence-in-depth and what a direct unit test relies on. Returns the (non-null) user for the
// form or a delete-confirm page — which refuses a reader rather than rendering a form whose submit // handler to thread on. GuardError → /login or 403.
// would 403. The route table declares the same override, so the two cannot disagree. // `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create form
// or a delete-confirm page, whose only purpose is to start a write. Those refuse a reader honestly
// instead of rendering a form whose submit would 403; the route table declares the same override, so
// the two still cannot disagree.
export function requirePermission(ctx: RequestContext, resource: AdminResource, action?: AdminAction): User { export function requirePermission(ctx: RequestContext, resource: AdminResource, action?: AdminAction): User {
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept) const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
const permission = permissionName(resource, action ?? actionForMethod(ctx.req.method ?? "GET")); const permission = permissionName(resource, action ?? actionForMethod(ctx.req.method ?? "GET"));
@@ -83,16 +88,6 @@ 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: {
+2 -23
View File
@@ -1,8 +1,8 @@
// Users admin screen (example plugin): the pure view-model + Kratos-payload builders. The HTTP // Users admin screen (example plugin): the pure view-model + Kratos-payload builders. The HTTP
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app-admin.test.ts. // routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import type { Identity } from "@plainpages/plugin-api"; import type { Identity } from "#plugin-api";
import { import {
buildUserFormModel, buildUserFormModel,
buildUsersListModel, buildUsersListModel,
@@ -60,14 +60,6 @@ test("buildUsersListModel filters by search + status, sorts, and paginates", ()
// 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(`/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"]);
});
// The detail page of a `users:read` holder is read-only, so the row action must not promise an edit.
test("buildUsersListModel: a read-only holder's row action says View, with the same target", () => {
const people: Identity[] = [{ id: "u1", traits: { email: "a@x.io" } }];
const action = buildUsersListModel({ canWrite: false, identities: people, url: "http://x/admin/users" }).table.rows[0]!.actions![0]!;
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", () => {
@@ -100,19 +92,6 @@ test("buildUserFormModel: edit mode prefills, locks email, and exposes state/del
assert.equal(buildUserFormModel({ identity: identity(4, { state: "inactive" }) }).edit!.nextLabel, "Reactivate"); assert.equal(buildUserFormModel({ identity: identity(4, { state: "inactive" }) }).edit!.nextLabel, "Reactivate");
}); });
test("buildUserFormModel: the title and breadcrumb say New, Edit, or View for a users:read holder", () => {
const cases: [Parameters<typeof buildUserFormModel>[0], string, string][] = [
[{}, "New user", "New"],
[{ identity: identity(3) }, "Edit user", "Edit"],
[{ canWrite: false, identity: identity(3) }, "View user", "View"],
];
for (const [opts, title, crumb] of cases) {
const m = buildUserFormModel(opts);
assert.equal(m.title, title);
assert.deepEqual(m.breadcrumbs, [{ href: "/admin/users", label: "Users" }, { label: crumb }]);
}
});
test("createIdentityPayload: schema/state/traits, name only when given, password only when set", () => { test("createIdentityPayload: schema/state/traits, name only when given, password only when set", () => {
assert.deepEqual(createIdentityPayload({ email: "a@b.c", first: "Ada", last: "Lovelace", password: "" }), { assert.deepEqual(createIdentityPayload({ email: "a@b.c", first: "Ada", last: "Lovelace", password: "" }), {
schema_id: "default", schema_id: "default",
+22 -32
View File
@@ -1,11 +1,12 @@
// Users admin screen: list Kratos identities (filter/sort/paginate) + // Users admin screen: list Kratos identities (filter/sort/paginate) +
// create/edit/deactivate/delete/trigger-recovery. Pure builders turn identities + the request URL // create/edit/deactivate/delete/trigger-recovery. Writes go only to Kratos via the admin client
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over // (README "stateless"). Pure builders turn identities + the request URL into building-block view
// a shared `withUser` gate. // models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api"; import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, isSavedNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, SAVED_NOTICE, userSubject } from "./admin-grants.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, partialSave, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
@@ -138,12 +139,12 @@ export function buildUsersListModel(opts: {
canWrite: opts.canWrite !== false, canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, all.length, t), filterBar: listFilterBar(state, all.length, t),
pagination: listPagination(state, page, t), pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t, opts.canWrite !== false), table: listTable(rows, state, sort, t),
title: t("admin.users.title"), title: t("admin.users.title"),
}; };
} }
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate, canWrite: boolean) { function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
return { return {
actions: true, actions: true,
caption: t("admin.users.title"), caption: t("admin.users.title"),
@@ -153,7 +154,7 @@ function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "des
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true }; return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
}), }),
rows: rows.map((u) => ({ rows: rows.map((u) => ({
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: canWrite ? "i-edit" : "i-eye", label: t(canWrite ? "common.edit" : "admin.users.view") }], actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: t("common.edit") }],
cells: [ cells: [
{ user: { initials: u.initials, name: u.name } }, { user: { initials: u.initials, name: u.name } },
u.email, u.email,
@@ -243,9 +244,8 @@ export function buildUserFormModel(opts: {
if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" }); if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" });
const canWrite = opts.canWrite !== false; const canWrite = opts.canWrite !== false;
const mode = !editing ? "new" : canWrite ? "edit" : "view";
return { return {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: t({ edit: "common.edit", new: "common.new", view: "admin.users.view" }[mode]) }], breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: editing ? t("common.edit") : t("common.new") }],
canWrite, // the view drops every write affordance when false; the host already 403s the POSTs canWrite, // the view drops every write affordance when false; the host already 403s the POSTs
edit: editing ? { edit: editing ? {
deleteAction: `${idPath}/delete`, deleteAction: `${idPath}/delete`,
@@ -259,7 +259,7 @@ export function buildUserFormModel(opts: {
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") }, form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") },
permissions: editing ? opts.permissions : undefined, permissions: editing ? opts.permissions : undefined,
recovery: opts.recovery, recovery: opts.recovery,
title: t({ edit: "admin.users.edit", new: "admin.users.new", view: "admin.users.viewTitle" }[mode]), title: editing ? t("admin.users.edit") : t("admin.users.new"),
}; };
} }
@@ -330,7 +330,7 @@ export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx
// GET /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, isSavedNotice(deps.ctx.url) ? deps.ctx.t("admin.grants.saved") : undefined); const permissions = await userPermissionPicker(deps, id);
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) }); return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
}); });
@@ -339,7 +339,7 @@ const canWriteUsers = (ctx: RequestContext): boolean => can(ctx, permissionName(
// The checkbox list of declared permissions: ticked where this user holds one, and disabled where // The checkbox list of declared permissions: ticked where this user holds one, and disabled where
// the grant comes from a group (real, but removed on that group). Undefined when Keto isn't wired — // the grant comes from a group (real, but removed on that group). Undefined when Keto isn't wired —
// the rest of the edit page still works. // the rest of the edit page still works.
async function userPermissionPicker(deps: UsersDeps, id: string, error?: string, notice?: string): Promise<PermissionPicker | undefined> { async function userPermissionPicker(deps: UsersDeps, id: string, error?: string): Promise<PermissionPicker | undefined> {
if (!deps.keto) return undefined; if (!deps.keto) return undefined;
const subject = userSubject(id); const subject = userSubject(id);
const [direct, effective] = await Promise.all([ const [direct, effective] = await Promise.all([
@@ -356,7 +356,6 @@ async function userPermissionPicker(deps: UsersDeps, id: string, error?: string,
t: deps.ctx.t, t: deps.ctx.t,
}), }),
...(error ? { error } : {}), ...(error ? { error } : {}),
...(notice ? { notice } : {}),
}; };
} }
@@ -368,30 +367,21 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
const form = (await guardedForm(ctx))!; const form = (await guardedForm(ctx))!;
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto")); if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
const subject = userSubject(id); const subject = userSubject(id);
const held = await heldPermissions(keto, subject); const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
const refuse = async (error: string, status: number): Promise<RouteResult> => {
const permissions = await userPermissionPicker(deps, id, error);
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status };
};
const diff = grantDiff(ctx.declaredPermissions, held, form.getAll(PERMISSIONS_FIELD));
if (isStale(form, ctx.declaredPermissions, held, diff)) return refuse(ctx.t("admin.grants.stale"), 409);
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can // Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very // remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
// next request — leaving a `curl` against Keto as the only way back in. // next request. Recovery would be a curl against Keto — not something the operator persona can do.
if (id === user.id && diff.revoke.length > 0) { if (id === user.id && diff.revoke.length > 0) {
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") }); 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); const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke"));
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status: 400 };
} }
const { applied, error } = await applyGrants(keto, subject, diff); await applyGrants(keto, subject, diff);
if (applied.grant.length > 0 || applied.revoke.length > 0) { if (diff.grant.length > 0 || diff.revoke.length > 0) {
revoke?.(id); revoke?.(id);
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: applied.grant.join(","), revoked: applied.revoke.join(","), target: id }); ctx.log.info("admin: user permissions changed", { actor: user.id, granted: diff.grant.join(","), revoked: diff.revoke.join(","), target: id });
} }
if (error) { return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
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}` };
}); });
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400). // POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
+2 -28
View File
@@ -53,12 +53,9 @@ 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.save": "Save permissions", "admin.grants.save": "Save permissions",
"admin.grants.saved": "Permissions saved.",
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.", "admin.grants.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.groups.actions": "Group actions", "admin.groups.actions": "Group actions",
"admin.groups.addMember": "Add a member", "admin.groups.addMember": "Add a member",
@@ -68,7 +65,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.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.",
"admin.groups.filter": "Filter groups", "admin.groups.filter": "Filter groups",
@@ -87,29 +83,9 @@ const messages = {
"admin.nav.clients": "OAuth2 clients", "admin.nav.clients": "OAuth2 clients",
"admin.nav.groups": "Groups", "admin.nav.groups": "Groups",
"admin.nav.pluginSettings": "Plugin settings",
"admin.nav.section": "Admin", "admin.nav.section": "Admin",
"admin.nav.users": "Users", "admin.nav.users": "Users",
"admin.pluginSettings.caption": "Settings declared by {{plugin}}",
"admin.pluginSettings.column.description": "Description",
"admin.pluginSettings.column.key": "Key",
"admin.pluginSettings.column.required": "Required",
"admin.pluginSettings.column.source": "Source",
"admin.pluginSettings.column.type": "Type",
"admin.pluginSettings.column.value": "Value",
"admin.pluginSettings.column.variable": "Variable",
"admin.pluginSettings.no": "No",
"admin.pluginSettings.none": "This plugin declares no settings.",
"admin.pluginSettings.secretSet": "Secret — set",
"admin.pluginSettings.secretUnset": "Secret — not set",
"admin.pluginSettings.source.default": "Default",
"admin.pluginSettings.source.env": "Environment",
"admin.pluginSettings.source.unset": "Not set",
"admin.pluginSettings.title": "Plugin settings",
"admin.pluginSettings.unset": "—",
"admin.pluginSettings.yes": "Yes",
"admin.notFound.message": "That item doesn't exist.", "admin.notFound.message": "That item doesn't exist.",
"admin.notFound.title": "Not found", "admin.notFound.title": "Not found",
@@ -141,8 +117,8 @@ const messages = {
"admin.users.new": "New user", "admin.users.new": "New user",
"admin.users.pagination": "Users pagination", "admin.users.pagination": "Users pagination",
"admin.users.reactivate": "Reactivate", "admin.users.reactivate": "Reactivate",
"admin.users.recovery.body": "Send the user this link and code. They open the link and enter the code to set a new password (generate a fresh code if it has expired):", "admin.users.recovery.body": "Give it to the user — they enter it to set a new password (generate a fresh one if it has expired):",
"admin.users.recovery.link": "password-reset link", "admin.users.recovery.link": "the password-reset screen",
"admin.users.recovery.generate": "Generate recovery code", "admin.users.recovery.generate": "Generate recovery code",
"admin.users.recovery.title": "Recovery code generated", "admin.users.recovery.title": "Recovery code generated",
"admin.users.save": "Save changes", "admin.users.save": "Save changes",
@@ -153,8 +129,6 @@ const messages = {
"admin.users.status.inactive": "Inactive", "admin.users.status.inactive": "Inactive",
"admin.users.status.label": "Status", "admin.users.status.label": "Status",
"admin.users.title": "Users", "admin.users.title": "Users",
"admin.users.view": "View",
"admin.users.viewTitle": "View user",
}; };
export type AdminMessages = typeof messages; export type AdminMessages = typeof messages;
+2 -28
View File
@@ -53,12 +53,9 @@ 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.save": "Spara behörigheter", "admin.grants.save": "Spara behörigheter",
"admin.grants.saved": "Behörigheterna är sparade.",
"admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.", "admin.grants.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.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",
@@ -68,7 +65,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.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.",
"admin.groups.filter": "Filtrera grupper", "admin.groups.filter": "Filtrera grupper",
@@ -87,29 +83,9 @@ const messages: AdminMessages = {
"admin.nav.clients": "OAuth2-klienter", "admin.nav.clients": "OAuth2-klienter",
"admin.nav.groups": "Grupper", "admin.nav.groups": "Grupper",
"admin.nav.pluginSettings": "Tilläggsinställningar",
"admin.nav.section": "Administration", "admin.nav.section": "Administration",
"admin.nav.users": "Användare", "admin.nav.users": "Användare",
"admin.pluginSettings.caption": "Inställningar som {{plugin}} deklarerar",
"admin.pluginSettings.column.description": "Beskrivning",
"admin.pluginSettings.column.key": "Nyckel",
"admin.pluginSettings.column.required": "Obligatorisk",
"admin.pluginSettings.column.source": "Källa",
"admin.pluginSettings.column.type": "Typ",
"admin.pluginSettings.column.value": "Värde",
"admin.pluginSettings.column.variable": "Variabel",
"admin.pluginSettings.no": "Nej",
"admin.pluginSettings.none": "Det här tillägget deklarerar inga inställningar.",
"admin.pluginSettings.secretSet": "Hemlighet — satt",
"admin.pluginSettings.secretUnset": "Hemlighet — inte satt",
"admin.pluginSettings.source.default": "Standardvärde",
"admin.pluginSettings.source.env": "Miljövariabel",
"admin.pluginSettings.source.unset": "Inte satt",
"admin.pluginSettings.title": "Tilläggsinställningar",
"admin.pluginSettings.unset": "—",
"admin.pluginSettings.yes": "Ja",
"admin.notFound.message": "Objektet finns inte.", "admin.notFound.message": "Objektet finns inte.",
"admin.notFound.title": "Hittades inte", "admin.notFound.title": "Hittades inte",
@@ -141,8 +117,8 @@ const messages: AdminMessages = {
"admin.users.new": "Ny användare", "admin.users.new": "Ny användare",
"admin.users.pagination": "Sidnavigering för användare", "admin.users.pagination": "Sidnavigering för användare",
"admin.users.reactivate": "Aktivera igen", "admin.users.reactivate": "Aktivera igen",
"admin.users.recovery.body": "Skicka länken och koden till användaren. Användaren öppnar länken och anger koden för att sätta ett nytt lösenord (skapa en ny kod om den hunnit gå ut):", "admin.users.recovery.body": "Ge den till användaren — koden anges för att sätta ett nytt lösenord (skapa en ny om den hunnit gå ut):",
"admin.users.recovery.link": "länk för lösenordsåterställning", "admin.users.recovery.link": "sidan för lösenordsåterställning",
"admin.users.recovery.generate": "Skapa återställningskod", "admin.users.recovery.generate": "Skapa återställningskod",
"admin.users.recovery.title": "Återställningskod skapad", "admin.users.recovery.title": "Återställningskod skapad",
"admin.users.save": "Spara ändringar", "admin.users.save": "Spara ändringar",
@@ -153,8 +129,6 @@ const messages: AdminMessages = {
"admin.users.status.inactive": "Inaktiv", "admin.users.status.inactive": "Inaktiv",
"admin.users.status.label": "Status", "admin.users.status.label": "Status",
"admin.users.title": "Användare", "admin.users.title": "Användare",
"admin.users.view": "Visa",
"admin.users.viewTitle": "Visa användare",
}; };
export default messages; export default messages;
+5 -8
View File
@@ -3,7 +3,7 @@
// with nothing in the logs to explain it. Pin the two halves against each other here. // with nothing in the logs to explain it. Pin the two halves against each other here.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { isValidPermissionName } from "@plainpages/plugin-api"; import { isValidPermissionName } from "#plugin-api";
import manifest from "./plugin.ts"; import manifest from "./plugin.ts";
const routes = manifest.routes ?? []; const routes = manifest.routes ?? [];
@@ -35,20 +35,17 @@ test("every nav permission is one the manifest declares", () => {
} }
}; };
walk(manifest.nav); walk(manifest.nav);
assert.equal(navPermissions.length, 4); assert.equal(navPermissions.length, 3);
for (const name of navPermissions) assert.ok(declared.includes(name), `nav gates on undeclared ${name}`); for (const name of navPermissions) assert.ok(declared.includes(name), `nav gates on undeclared ${name}`);
}); });
test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => { test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => {
for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it
// Three CRUD screens × read/write, plus read-only plugin settings — a screen that never writes // Three screens × read/write. There is deliberately no `permissions:` pair: permissions are
// declares no `:write`, since a permission nothing gates on is one an operator can only mis-grant. // declared in plugin code, so holding one is edited on the user or group that holds it.
// There is deliberately no `permissions:` pair either: permissions are declared in plugin code, so
// holding one is edited on the user or group that holds it.
assert.deepEqual([...declared].sort(), [ assert.deepEqual([...declared].sort(), [
"groups:read", "groups:write", "groups:read", "groups:write",
"oauth2-clients:read", "oauth2-clients:write", "oauth2-clients:read", "oauth2-clients:write",
"plugin-settings:read",
"users:read", "users:write", "users:read", "users:write",
]); ]);
}); });
@@ -61,5 +58,5 @@ test("GET routes gate on read and mutations on write, so a reader can open a scr
const action = route.method === "GET" && !writeIntent(route.path) ? "read" : "write"; const action = route.method === "GET" && !writeIntent(route.path) ? "read" : "write";
assert.ok(route.permission?.endsWith(`:${action}`), `${route.method} ${route.path} → ${route.permission}`); assert.ok(route.permission?.endsWith(`:${action}`), `${route.method} ${route.path} → ${route.permission}`);
} }
assert.equal(routes.filter((r) => r.method === "GET" && writeIntent(r.path)).length, 6); // 2 per CRUD screen; plugin settings has none assert.equal(routes.filter((r) => r.method === "GET" && writeIntent(r.path)).length, 6); // 2 per screen
}); });
+8 -11
View File
@@ -1,13 +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.
// this folder to plugins/admin (then restart) to enable it — see README → Quick start. // These used to ship inside the core; they were extracted here so a fresh clone has no built-in admin
// GUI. Copy this folder to plugins/admin (then restart) to enable it — see README → Quick start.
// //
// It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke // It is a *system* plugin: its handlers reach the host's Ory admin clients (Kratos/Keto/Hydra) and the
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503. // instant-revoke hook via ctx.system, which the host populates when those services are wired (the dev
// stack wires all of them). Where a capability is absent the screen degrades to a themed 503.
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api"; import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts"; import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts"; import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
import { pluginSettingsList } from "./admin-plugin-settings.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts"; import { 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";
@@ -25,10 +26,9 @@ const on = (resource: AdminResource) => (method: HttpMethod, path: string, handl
const users = on("users"); const users = on("users");
const groups = on("groups"); const groups = on("groups");
const clients = on("oauth2-clients"); const clients = on("oauth2-clients");
const pluginSettings = on("plugin-settings");
export default definePlugin({ export default definePlugin({
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV], nav: [ADMIN_NAV],
@@ -39,7 +39,6 @@ export default definePlugin({
{ description: "Create and delete groups, and change their members and permissions", name: "groups:write" }, { description: "Create and delete groups, and change their members and permissions", name: "groups:write" },
{ description: "View OAuth2 clients", name: "oauth2-clients:read" }, { description: "View OAuth2 clients", name: "oauth2-clients:read" },
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" }, { description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
{ description: "View the settings each installed plugin declares, and how they resolved", name: "plugin-settings:read" },
], ],
routes: [ routes: [
@@ -71,7 +70,5 @@ export default definePlugin({
clients("GET", "/clients/:id", clientsDetail), clients("GET", "/clients/:id", clientsDetail),
clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"), clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"),
clients("POST", "/clients/:id/delete", clientsDelete), clients("POST", "/clients/:id/delete", clientsDelete),
// Plugin settings — read-only, so no :write route and no write-intent GET.
pluginSettings("GET", "/plugin-settings", pluginSettingsList),
], ],
}); });
@@ -7,16 +7,13 @@
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 }). Locals: csrfToken, permissions ({ action, choices, empty, error, field, hint, inheritedNote, legend, 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>
<% if (permissions.error) { -%> <% if (permissions.error) { -%>
<%- include("partials/alert", { text: permissions.error, tone: "neg" }) %> <%- include("partials/alert", { text: permissions.error, tone: "neg" }) %>
<% } -%> <% } -%>
<% if (permissions.notice) { -%>
<%- include("partials/alert", { text: permissions.notice, tone: "pos" }) %>
<% } -%>
<% if (permissions.empty) { -%> <% if (permissions.empty) { -%>
<p class="cell-muted"><%= permissions.empty %></p> <p class="cell-muted"><%= permissions.empty %></p>
<% } else { -%> <% } else { -%>
@@ -29,10 +26,8 @@
<% }) -%> <% }) -%>
</fieldset> </fieldset>
<% } else { -%> <% } else { -%>
<%# The fragment brings the card, and the notice in it, into view on a long page. %> <form method="post" action="<%= localeHref(permissions.action) %>">
<form method="post" action="<%= localeHref(permissions.action + '#permissions-h') %>">
<input type="hidden" name="_csrf" value="<%= csrfToken %>"> <input type="hidden" name="_csrf" value="<%= csrfToken %>">
<input type="hidden" name="baseline" value="<%= permissions.baseline %>">
<fieldset class="check-group"> <fieldset class="check-group">
<legend class="sr-only"><%= permissions.legend %></legend> <legend class="sr-only"><%= permissions.legend %></legend>
<% permissions.choices.forEach((c) => { -%> <% permissions.choices.forEach((c) => { -%>
@@ -2,7 +2,7 @@
Admin user create/edit form body, captured into the shell content slot. Config: Admin user create/edit form body, captured into the shell content slot. Config:
form { action, csrfToken, submitLabel, cancelHref, fields: field.ejs config[] } form { action, csrfToken, submitLabel, cancelHref, fields: field.ejs config[] }
edit? { nextLabel, stateAction, recoveryAction, deleteAction } (edit mode only) edit? { nextLabel, stateAction, recoveryAction, deleteAction } (edit mode only)
recovery? { code, link } shown after a recovery code is generated; link is the URL of the flow the code was minted on, the only flow that accepts the code recovery? { code? } shown after a recovery code is generated (recovery is code-based)
error? string shown when a write was rejected error? string shown when a write was rejected
%><% %><%
const form = locals.form; const form = locals.form;
@@ -14,7 +14,7 @@
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %> <%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%> <% } -%>
<% if (recovery) { -%> <% if (recovery) { -%>
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong><%= t("admin.users.recovery.title") %></strong><span><%= t("admin.users.recovery.body") %> <a href="<%= recovery.link %>"><%= t("admin.users.recovery.link") %></a></span><span class="recovery-code"><code><%= recovery.code %></code></span></div></div> <div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong><%= t("admin.users.recovery.title") %></strong><span><%= t("admin.users.recovery.body") %> <a href="<%= localeHref("/recovery") %>"><%= t("admin.users.recovery.link") %></a></span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
<% } -%> <% } -%>
<form class="form-card" method="post" action="<%= localeHref(form.action) %>"> <form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>"> <input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
@@ -1,24 +0,0 @@
<%#
Plugin settings admin list: one section per installed plugin, each a table of what it declares
and how each key resolved (admin-plugin-settings.ts). Read-only — no actions, no forms.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
let body = "";
for (const group of model.groups) {
// A plugin id is the folder name, which discovery constrains to [a-z0-9-] — no escaping needed.
body += '<h2 class="h2">' + group.pluginId + "</h2>";
body += group.table.rows.length === 0
? '<p class="muted">' + group.emptyText + "</p>"
: include("partials/data-table", group.table);
}
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+7 -18
View File
@@ -15,12 +15,8 @@ What it demonstrates:
`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`,
reusing the core `field` partial. reusing the core `field` partial.
- **All three route gates** — the Overview is `public` (anyone), "My shifts" is `session` (any - **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
signed-in visitor, showing only rows assigned to them), and "Shifts" is gated on `scheduling:read` / `scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
`scheduling:write`; a leaf whose gate a visitor fails is invisible in the menu.
- **Ownership joined on the identity id** — "My shifts" asks the upstream for `assigneeId=ctx.user.id`,
the opaque subject id, and renders the row's separate `assignee` display name. An email address is
user-changeable and can be reassigned to someone else, who would then inherit those rows.
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including - **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
the views carry the visitor's language onto their links with `localeHref()`. the views carry the visitor's language onto their links with `localeHref()`.
@@ -31,7 +27,7 @@ The plugin holds **no state** — data lives upstream (README → *Stateless*).
## Upstream ## Upstream
Set `PLUGIN_SETTING_SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory Set `SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box. mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box.
A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook). A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook).
@@ -42,14 +38,9 @@ Your backend must expose two routes; the plugin treats any non-2xx as a recovera
| Route | Request | Success | Response body | | Route | Request | Success | Response body |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `GET /shifts` | `Accept: application/json`, optional `?assigneeId=<id>` | `200` | JSON array of `{ id, title, assignee, assigneeId, start, end }` (all strings; missing fields coerce to `""`). With `assigneeId`, only that person's rows | | `GET /shifts` | `Accept: application/json` | `200` | JSON array of `{ id, title, assignee, start, end }` (all strings; missing fields coerce to `""`) |
| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) | | `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
`POST /shifts` carries the assignee as a **display name only**, so a shift created through this
plugin's form belongs to nobody and surfaces on no one's "My shifts" — don't go hunting for it
there. Resolving a name to an identity id needs a directory this demo has none of; a real backend
does that join at create time and stores the `assigneeId` alongside the name.
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
form re-renders. The plugin only validates that `title` and `assignee` are non-empty. form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
@@ -59,8 +50,6 @@ cosmetically) — normalise to your backend's format there if it matters.
## Granting access ## Granting access
A user sees the shift list once they hold the `scheduling:read` permission in Keto (and A user sees Scheduling once they hold the `scheduling:read` permission in Keto (and `scheduling:write`
`scheduling:write` to create). The one-command bootstrap grants both to the demo admin, so the seeded to create). The one-command bootstrap grants both to the demo admin, so the seeded
`admin@plainpages.local` can use it immediately. "My shifts" needs no grant at all — signing in is `admin@plainpages.local` can use it immediately.
its whole gate; it lists the rows this demo upstream holds against the signed-in visitor's id, and
the demo's seeded rows belong to three made-up people, so a freshly seeded admin sees it empty.
+1 -5
View File
@@ -2,7 +2,7 @@
// looked up here first and fall back to the host's, so a plugin owns its words without prefixing // looked up here first and fall back to the host's, so a plugin owns its words without prefixing
// them, and `shifts.count` shows the plural form (host: README → Translating). // them, and `shifts.count` shows the plural form (host: README → Translating).
import type { PluralMessage } from "@plainpages/plugin-api"; import type { PluralMessage } from "#plugin-api";
const messages = { const messages = {
"scheduling.field.assignee": "Assignee", "scheduling.field.assignee": "Assignee",
@@ -13,16 +13,12 @@ const messages = {
"scheduling.filter.searchLabel": "Search shifts", "scheduling.filter.searchLabel": "Search shifts",
"scheduling.filter.searchPlaceholder": "Search title or assignee…", "scheduling.filter.searchPlaceholder": "Search title or assignee…",
"scheduling.form.submit": "Create shift", "scheduling.form.submit": "Create shift",
"scheduling.mine.empty": "No shifts are assigned to {{email}}.",
"scheduling.mine.title": "My shifts",
"scheduling.nav.mine": "My shifts",
"scheduling.nav.overview": "Overview", "scheduling.nav.overview": "Overview",
"scheduling.nav.section": "Scheduling", "scheduling.nav.section": "Scheduling",
"scheduling.nav.shifts": "Shifts", "scheduling.nav.shifts": "Shifts",
"scheduling.new.title": "New shift", "scheduling.new.title": "New shift",
"scheduling.overview.lead": "scheduling.overview.lead":
"Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.", "Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.",
"scheduling.overview.mine": "See my shifts",
"scheduling.overview.signIn": "Sign in to view shifts", "scheduling.overview.signIn": "Sign in to view shifts",
"scheduling.overview.title": "Scheduling", "scheduling.overview.title": "Scheduling",
"scheduling.overview.view": "View shifts", "scheduling.overview.view": "View shifts",
@@ -9,16 +9,12 @@ const messages: SchedulingMessages = {
"scheduling.filter.searchLabel": "Sök pass", "scheduling.filter.searchLabel": "Sök pass",
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…", "scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
"scheduling.form.submit": "Skapa pass", "scheduling.form.submit": "Skapa pass",
"scheduling.mine.empty": "Inga pass är tilldelade {{email}}.",
"scheduling.mine.title": "Mina pass",
"scheduling.nav.mine": "Mina pass",
"scheduling.nav.overview": "Översikt", "scheduling.nav.overview": "Översikt",
"scheduling.nav.section": "Schemaläggning", "scheduling.nav.section": "Schemaläggning",
"scheduling.nav.shifts": "Pass", "scheduling.nav.shifts": "Pass",
"scheduling.new.title": "Nytt pass", "scheduling.new.title": "Nytt pass",
"scheduling.overview.lead": "scheduling.overview.lead":
"Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <code>scheduling:read</code>.", "Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <code>scheduling:read</code>.",
"scheduling.overview.mine": "Visa mina pass",
"scheduling.overview.signIn": "Logga in för att se passen", "scheduling.overview.signIn": "Logga in för att se passen",
"scheduling.overview.title": "Schemaläggning", "scheduling.overview.title": "Schemaläggning",
"scheduling.overview.view": "Visa pass", "scheduling.overview.view": "Visa pass",
+11 -24
View File
@@ -2,21 +2,20 @@
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this // data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins. // folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
import { definePlugin } from "@plainpages/plugin-api"; import { definePlugin } from "#plugin-api";
import { createShift, createUpstream, listShifts, MINE_PATH, myShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts"; import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is // The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
// stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it // stateless). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream).
// over — which is after this manifest is built, hence the getter. const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000";
let upstreamUrl = ""; const upstream = createUpstream(upstreamUrl);
const upstream = createUpstream(() => upstreamUrl);
export default definePlugin({ export default definePlugin({
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved // onBoot runs after discovery, before the server listens: validate the plugin's own config so a
// settings. A malformed URL already failed the boot by then; the host validated the declared type. // typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
hooks: { onBoot: ({ settings }) => { upstreamUrl = settings.upstream; } }, hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") },
// Merged into the global menu + filtered per user. Labels are keys in this plugin's own catalog // Merged into the global menu + filtered per user. Labels are keys in this plugin's own catalog
// (i18n/<locale>.ts) — a plain string works too, it just isn't translated. "Overview" is `public`, so the "Scheduling" // (i18n/<locale>.ts) — a plain string works too, it just isn't translated. "Overview" is `public`, so the "Scheduling"
@@ -25,13 +24,11 @@ export default definePlugin({
nav: [{ nav: [{
children: [ children: [
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true }, { href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true },
{ href: MINE_PATH, id: "scheduling:mine", label: "scheduling.nav.mine", session: true },
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ }, { href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ },
], ],
icon: "i-cal", icon: "i-cal",
id: "scheduling", id: "scheduling",
label: "scheduling.nav.section", label: "scheduling.nav.section",
public: true, // the header gates nothing; each child names its own gate, and an empty header is dropped
}], }],
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`. // Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
@@ -40,22 +37,12 @@ export default definePlugin({
{ description: "Create and edit shifts", name: WRITE }, { description: "Create and edit shifts", name: WRITE },
], ],
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public`
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
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: listShifts(upstream), method: "GET", path: "/shifts", permission: READ }, { handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE }, { handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE }, { handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
], ],
// Operator-supplied config: one PLUGIN_SETTING_SCHEDULING_UPSTREAM variable, validated as a URL at
// boot. The default points at the mock backend the dev compose runs (examples/shifts-upstream).
settings: [
{
default: "http://shifts-upstream:4000",
description: "Base URL of the backend this plugin reads shifts from and writes them to",
key: "upstream",
type: "url",
},
],
}); });
@@ -4,9 +4,3 @@
.scheduling-page .table-wrap { .scheduling-page .table-wrap {
margin-top: var(--space-3, 0.75rem); margin-top: var(--space-3, 0.75rem);
} }
/* Inset what sits directly in the pane by --pad-x, as the filter bar, table and pager already are. */
.scheduling-page > p,
.scheduling-page > .alert,
.scheduling-page > .btn {
margin-inline: var(--pad-x);
}
+31 -71
View File
@@ -2,31 +2,31 @@ 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 only from the @plainpages/plugin-api barrel — the same contract boundary shifts.ts uses (the host may // Import only from the #plugin-api barrel — the same contract boundary shifts.ts uses (the host may
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches. // refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult, type User } from "@plainpages/plugin-api"; import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
import { import {
buildFormModel, createShift, createUpstream, listShifts, myShifts, newShiftForm, overview, readInput, assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate, SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
} from "./shifts.ts"; } from "./shifts.ts";
const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } }; const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; user?: User; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext { function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
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 {
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: [], 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),
}; };
} }
const SHIFTS: Shift[] = [ const SHIFTS: Shift[] = [
{ assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "1", start: "08:00", title: "Morning desk" }, { assignee: "Avery Kline", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
{ assignee: "Blair Mora", assigneeId: "019bdc1a-4a83-7de2-8f05-6c93a71be4d8", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" }, { assignee: "Blair Mora", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" },
]; ];
const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over }); const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over });
@@ -35,25 +35,27 @@ const asView = (r: RouteResult | void) => {
return r as { data: Record<string, unknown>; status?: number; view: string }; return r as { data: Record<string, unknown>; status?: number; view: string };
}; };
// ---- the upstream URL as a declared setting ---- // ---- upstream config validation (the onBoot hook) ----
test("the manifest declares its upstream as a URL setting the host validates", async () => { test("assertHttpUrl accepts http(s) and fails loud on a malformed or non-http upstream URL", () => {
const manifest = (await import("./plugin.ts")).default; assert.doesNotThrow(() => assertHttpUrl("http://shifts-upstream:4000", "SCHEDULING_UPSTREAM"));
assert.deepEqual(manifest.settings?.map((s) => s.key), ["upstream"]); assert.doesNotThrow(() => assertHttpUrl("https://api.example.com/v1", "SCHEDULING_UPSTREAM"));
assert.equal(manifest.settings?.[0]?.type, "url"); // so a typo'd URL fails the boot, not every request assert.throws(() => assertHttpUrl("not a url", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*valid URL/); // unparseable
assert.equal(manifest.settings?.[0]?.default, "http://shifts-upstream:4000"); // the dev compose's mock assert.throws(() => assertHttpUrl("shifts-upstream:4000", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // missing // → parsed as a bogus scheme
assert.equal(typeof manifest.hooks?.onBoot, "function"); // without it the resolved value never arrives assert.throws(() => assertHttpUrl("ftp://host/x", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // wrong scheme
}); });
test("the client re-reads its base URL, so onBoot can bind it after the manifest is built", async () => { test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not just the helper)", async () => {
let baseUrl = "http://first:4000"; const prev = process.env["SCHEDULING_UPSTREAM"];
const seen: string[] = []; process.env["SCHEDULING_UPSTREAM"] = "nope://bad"; // read at import time below
const http = (async (url) => { seen.push(String(url)); return new Response("[]", { status: 200 }); }) as typeof fetch; try {
const upstream = createUpstream(() => baseUrl, http); const manifest = (await import("./plugin.ts")).default;
await upstream.list(); assert.equal(typeof manifest.hooks?.onBoot, "function");
baseUrl = "http://second:4000"; assert.throws(() => manifest.hooks!.onBoot!(), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud
await upstream.list(); } finally {
assert.deepEqual(seen, ["http://first:4000/shifts", "http://second:4000/shifts"]); if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"];
else process.env["SCHEDULING_UPSTREAM"] = prev;
}
}); });
// ---- upstream client (fetch injected) ---- // ---- upstream client (fetch injected) ----
@@ -63,23 +65,23 @@ test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", as
const http = (async (url, init) => { const http = (async (url, init) => {
seen = String(url); seen = String(url);
assert.equal((init?.headers as Record<string, string>).accept, "application/json"); assert.equal((init?.headers as Record<string, string>).accept, "application/json");
return new Response(JSON.stringify([{ assignee: "A", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 }); return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
}) as typeof fetch; }) as typeof fetch;
const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed const shifts = await createUpstream("http://up:4000/", http).list(); // trailing slash trimmed
assert.equal(seen, "http://up:4000/shifts"); assert.equal(seen, "http://up:4000/shifts");
assert.deepEqual(shifts, [{ assignee: "A", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", end: "2", id: "x", start: "1", title: "T" }]); assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]);
}); });
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => { test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
const http = (async () => new Response("nope", { status: 503 })) as typeof fetch; const http = (async () => new Response("nope", { status: 503 })) as typeof fetch;
await assert.rejects(createUpstream(() => "http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503); await assert.rejects(createUpstream("http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
}); });
test("createUpstream.create POSTs the input as JSON", async () => { test("createUpstream.create POSTs the input as JSON", async () => {
let body: unknown, method = ""; let body: unknown, method = "";
const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch; const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch;
const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" }; const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" };
await createUpstream(() => "http://up:4000", http).create(input); await createUpstream("http://up:4000", http).create(input);
assert.equal(method, "POST"); assert.equal(method, "POST");
assert.deepEqual(body, input); assert.deepEqual(body, input);
}); });
@@ -115,21 +117,14 @@ test("listShifts degrades to a recoverable error page when the upstream is down
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ---- // ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
test("overview renders a public page for anyone, and its CTA names the best gate the visitor passes", async () => { test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
const anon = asView(await overview()(fakeCtx())); // user null, no permissions const anon = asView(await overview()(fakeCtx())); // user null, no permissions
assert.equal(anon.view, "overview"); assert.equal(anon.view, "overview");
assert.equal(anon.data["chrome"], CHROME); assert.equal(anon.data["chrome"], CHROME);
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
assert.equal(anon.data["signedIn"], false);
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] }))); const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
// Signed in but ungranted: the page must not invite them to sign in again.
const member = asView(await overview()(fakeCtx({ user: { email: "m@example.test", id: "01a06091-baa3-7a1f-9c62-0e3ab6d2f5c1", permissions: [] } })));
assert.equal(member.data["canRead"], false);
assert.equal(member.data["signedIn"], true);
assert.equal(member.data["mineHref"], "/scheduling/mine");
}); });
// ---- create handler ---- // ---- create handler ----
@@ -178,38 +173,3 @@ test("buildFormModel marks title/assignee required and attaches field errors", (
assert.equal(title.error, "needed"); assert.equal(title.error, "needed");
assert.equal(fields.find((f) => f.name === "start")!.required, undefined); assert.equal(fields.find((f) => f.name === "start")!.required, undefined);
}); });
// ---- the session-gated page: the visitor's own rows ----
test("my shifts scopes the upstream read by the visitor's id, and names them in the empty state", async () => {
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
const mine: Shift = { assignee: "Blair Mora", assigneeId: user.id, end: "22:00", id: "3", start: "17:00", title: "Evening on-call" };
let asked: { assigneeId?: string } | undefined;
const upstream = fakeUpstream({ list: async (opts) => { asked = opts; return [mine]; } });
const r = asView(await myShifts(upstream)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
assert.equal(r.view, "mine");
assert.deepEqual(asked, { assigneeId: "01a06091-baa3-71f4-a068-4879972979ff" }); // the id, never the address
const table = r.data["table"] as { emptyText: string; rows: { name: string }[] };
assert.deepEqual(table.rows.map((row) => row.name), ["Evening on-call"]);
assert.match(table.emptyText, /Blair\.Mora@example\.test/); // an empty page still says whose it is
// `requireSession` narrows `ctx.user` from `User | null` to `User` — the one part of the route's
// `session: true` guarantee the contract cannot state in the handler's type.
await assert.rejects(async () => { await myShifts(fakeUpstream())(fakeCtx()); }, GuardError);
});
test("my shifts degrades to the reason alone when the upstream is down, claiming nothing about what is assigned", async () => {
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
const down = fakeUpstream({ list: async () => { throw new UpstreamError("down", 503); } });
const r = asView(await myShifts(down)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
assert.match(String(r.data["error"]), /scheduling service/i);
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); // mine.ejs drops the count + table while `error` is set
});
test("my shifts drops a row the upstream returned that is not the visitor's", async () => {
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
const theirs: Shift = { assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "9", start: "08:00", title: "Not mine" };
const r = asView(await myShifts(fakeUpstream({ list: async () => [theirs] }))(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); // a backend ignoring the scope must not leak through this page
});
+22 -54
View File
@@ -5,8 +5,8 @@
// Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as // Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as
// pure functions against a mock upstream with no network (README.md → Local dev & test story). // pure functions against a mock upstream with no network (README.md → Local dev & test story).
// One import from the host's @plainpages/plugin-api barrel — the stable author surface (see README.md → Building plugins). // One import from the host's #plugin-api barrel — the stable author surface (see README.md → Building plugins).
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, requireSession, type RouteHandler, type Translate, tracedFetch } from "@plainpages/plugin-api"; import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "#plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
// The plugin's own English (its catalog, then the host's), for a view model built outside a request: // The plugin's own English (its catalog, then the host's), for a view model built outside a request:
@@ -16,14 +16,12 @@ const EN: Translate = englishTranslator(enUS);
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
export const SHIFTS_PATH = "/scheduling/shifts"; export const SHIFTS_PATH = "/scheduling/shifts";
export const MINE_PATH = "/scheduling/mine"; // the visitor's own shifts — a session is the whole gate
export const READ = "scheduling:read"; // the permission gating the list + nav export const READ = "scheduling:read"; // the permission gating the list + nav
export const WRITE = "scheduling:write"; // the permission gating create export const WRITE = "scheduling:write"; // the permission gating create
export interface Shift { export interface Shift {
id: string; id: string;
assignee: string; // display name, rendered in the table assignee: string;
assigneeId: string; // who the shift belongs to — an opaque id, the same one `ctx.user.id` carries
end: string; end: string;
start: string; start: string;
title: string; title: string;
@@ -48,30 +46,37 @@ export class UpstreamError extends Error {
export interface ShiftsUpstream { export interface ShiftsUpstream {
create(input: ShiftInput): Promise<void>; create(input: ShiftInput): Promise<void>;
// `assigneeId` scopes the read at the source, which is where an ownership rule belongs (README → list(): Promise<Shift[]>;
// Three tiers of "may I?"); without it the caller would hold everyone's rows to render one page. }
list(opts?: { assigneeId?: string }): Promise<Shift[]>;
// Fail loud at boot (the plugin's onBoot hook) on a malformed/non-http upstream URL — a config
// typo surfaces at startup, not as a degraded page later. Reachability stays a runtime concern.
export function assertHttpUrl(value: string, name: string): void {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error(`${name} is not a valid URL: ${JSON.stringify(value)}`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${name} must be an http(s) URL: ${JSON.stringify(value)}`);
} }
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch` // REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
// defaults to the host's tracedFetch, so each upstream call joins the request's trace (a client // defaults to the host's tracedFetch, so each upstream call joins the request's trace (a client
// span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network. // span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network.
// `baseUrl` is read per call: the plugin's settings arrive on onBoot, after the manifest that binds export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
// these handlers has already been built. const base = baseUrl.replace(/\/+$/, "");
export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
const base = (): string => baseUrl().replace(/\/+$/, "");
return { return {
async create(input) { async create(input) {
const res = await fetchImpl(`${base()}/shifts`, { const res = await fetchImpl(`${base}/shifts`, {
body: JSON.stringify(input), body: JSON.stringify(input),
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
method: "POST", method: "POST",
}); });
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status); if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
}, },
async list(opts = {}) { async list() {
const query = opts.assigneeId == null ? "" : `?${new URLSearchParams({ assigneeId: opts.assigneeId })}`; const res = await fetchImpl(`${base}/shifts`, { headers: { accept: "application/json" } });
const res = await fetchImpl(`${base()}/shifts${query}`, { headers: { accept: "application/json" } });
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status); if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
const data: unknown = await res.json(); const data: unknown = await res.json();
return Array.isArray(data) ? data.map(toShift) : []; return Array.isArray(data) ? data.map(toShift) : [];
@@ -83,7 +88,7 @@ const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? ""
function toShift(raw: unknown): Shift { function toShift(raw: unknown): Shift {
const r = (raw ?? {}) as Record<string, unknown>; const r = (raw ?? {}) as Record<string, unknown>;
return { assignee: str(r["assignee"]), assigneeId: str(r["assigneeId"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) }; return { assignee: str(r["assignee"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) };
} }
// ---- view models (pure; the EJS views read these) ----------------------------------- // ---- view models (pure; the EJS views read these) -----------------------------------
@@ -191,41 +196,6 @@ export function newShiftForm(): RouteHandler {
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" }); return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" });
} }
export function myShifts(upstream: ShiftsUpstream): RouteHandler {
return async (ctx) => {
const user = requireSession(ctx);
let shifts: Shift[] = [];
let error: string | undefined;
try {
// Join on the id, never the email: an address is user-changeable and can be reassigned to
// someone else, which would hand them the previous holder's rows. The re-filter is
// defence-in-depth: a backend that ignores an unknown query param would answer with everyone.
shifts = (await upstream.list({ assigneeId: user.id })).filter((s) => s.assigneeId === user.id);
} catch (err) {
ctx.log.warn("scheduling upstream unreachable", { error: String(err) });
error = ctx.t("scheduling.upstream.list");
}
return { data: buildMineModel({ chrome: ctx.chrome, email: user.email, ...(error ? { error } : {}), shifts, t: ctx.t }), view: "mine" };
};
}
export function buildMineModel(opts: { chrome: PageChrome; email: string; error?: string; shifts: Shift[]; t?: Translate }) {
const t = opts.t ?? EN;
return {
breadcrumbs: [{ label: t("scheduling.mine.title") }],
chrome: opts.chrome,
count: t("scheduling.shifts.count", { count: opts.shifts.length }),
...(opts.error ? { error: opts.error } : {}),
table: {
caption: t("scheduling.mine.title"),
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
emptyText: t("scheduling.mine.empty", { email: opts.email }),
rows: opts.shifts.map((s) => ({ cells: [{ rowHeader: { text: s.title } }, s.start, s.end], name: s.title })),
},
title: t("scheduling.mine.title"),
};
}
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the // Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data // gate lets an anonymous visitor through and the menu option shows for everyone. The real data
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone // (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
@@ -236,9 +206,7 @@ export function overview(): RouteHandler {
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }], breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
canRead: can(ctx, READ), canRead: can(ctx, READ),
chrome: ctx.chrome, chrome: ctx.chrome,
mineHref: ctx.localeHref(MINE_PATH),
shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links
signedIn: ctx.user !== null,
signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`), signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`),
title: ctx.t("scheduling.overview.title"), title: ctx.t("scheduling.overview.title"),
}, },
@@ -1,19 +0,0 @@
<%#
Scheduling · the visitor's own shifts (reference plugin).
Data: chrome, title, breadcrumbs, count, table, error?
%><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
const tableHtml = include("partials/data-table", table);
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
-%>
<%- include("partials/shell", {
body: '<div class="scheduling-page">' + alertHtml + (locals.error ? '' : '<p class="shift-count">' + count + '</p>' + tableHtml) + '</div>',
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,
nav: navHtml,
styles: ["/public/scheduling/scheduling.css"],
theme: chrome.theme,
title,
user: chrome.user,
}) %>
@@ -3,16 +3,12 @@
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome. it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
Data: chrome, title, breadcrumbs, canRead, mineHref, shiftsHref, signedIn, signInHref Data: chrome, title, breadcrumbs, canRead, shiftsHref, signInHref
%><% %><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav }); const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
// One CTA per gate the visitor passes: the list needs the permission, "My shifts" only a session,
// and sign-in is offered to nobody who already has one.
const cta = canRead const cta = canRead
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>' ? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
: signedIn : '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
? '<a class="btn btn-primary" href="' + mineHref + '">' + t("scheduling.overview.mine") + '</a>'
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions: "", actions: "",
+1 -1
View File
@@ -2,7 +2,7 @@
Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream
service; this view renders them with the core building blocks inside the native app shell service; this view renders them with the core building blocks inside the native app shell
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table, (ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
alert) — see the README. Zero-JS: search round-trips the URL. alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL.
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error? Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
%><% %><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav }); const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
+7 -13
View File
@@ -1,9 +1,9 @@
// Dev-only mock upstream for the reference plugin (examples/plugins/scheduling) — a stand-in for the // Dev-only mock upstream for the reference plugin (examples/plugins/scheduling) — a stand-in for the
// customer's real backend, ready for when you copy the reference plugin into plugins/. NOT part // customer's real backend, ready for when you copy the reference plugin into plugins/. NOT part
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM // of the app: stdlib only, in-memory (state resets on restart), no auth. Point SCHEDULING_UPSTREAM
// at your real service in production. // at your real service in production.
// //
// GET /shifts → 200 [ { id, title, assigneeId, assignee, start, end }, … ] (?assigneeId=<id> → only theirs) // GET /shifts → 200 [ { id, title, assignee, start, end }, … ]
// POST /shifts → 201 { id, … } (body: { title, assignee, start, end }) // POST /shifts → 201 { id, … } (body: { title, assignee, start, end })
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
@@ -11,12 +11,10 @@ import { createServer } from "node:http";
const PORT = Number(process.env.PORT ?? 4000); const PORT = Number(process.env.PORT ?? 4000);
// `assigneeId` is the identity the rows are owned by — an opaque, stable subject id, which is what
// `ctx.user.id` carries. These are this demo's own people; a real backend joins on your IdP's ids.
const shifts = [ const shifts = [
{ id: randomUUID(), title: "Morning — Front desk", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", assignee: "Avery Kline", start: "2026-06-22 08:00", end: "2026-06-22 12:00" }, { id: randomUUID(), title: "Morning — Front desk", assignee: "Avery Kline", start: "2026-06-22 08:00", end: "2026-06-22 12:00" },
{ id: randomUUID(), title: "Afternoon — Support", assigneeId: "019bdc1a-4a83-7de2-8f05-6c93a71be4d8", assignee: "Blair Mora", start: "2026-06-22 12:00", end: "2026-06-22 17:00" }, { id: randomUUID(), title: "Afternoon — Support", assignee: "Blair Mora", start: "2026-06-22 12:00", end: "2026-06-22 17:00" },
{ id: randomUUID(), title: "Evening — On-call", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", assignee: "Casey Nguyen", start: "2026-06-22 17:00", end: "2026-06-22 22:00" }, { id: randomUUID(), title: "Evening — On-call", assignee: "Casey Nguyen", start: "2026-06-22 17:00", end: "2026-06-22 22:00" },
]; ];
const json = (res, status, body) => { const json = (res, status, body) => {
@@ -35,14 +33,10 @@ const readBody = (req) =>
createServer(async (req, res) => { createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost"); const url = new URL(req.url ?? "/", "http://localhost");
if (url.pathname === "/shifts" && req.method === "GET") { if (url.pathname === "/shifts" && req.method === "GET") return json(res, 200, shifts);
const assigneeId = url.searchParams.get("assigneeId");
if (assigneeId === null) return json(res, 200, shifts);
return json(res, 200, shifts.filter((s) => s.assigneeId === assigneeId));
}
if (url.pathname === "/shifts" && req.method === "POST") { if (url.pathname === "/shifts" && req.method === "POST") {
const b = await readBody(req); const b = await readBody(req);
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), assigneeId: "", end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") }; const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
shifts.push(shift); shifts.push(shift);
return json(res, 201, shift); return json(res, 201, shift);
} }
+1
View File
@@ -0,0 +1 @@
{"errors":null,"message":"not found","url":"https://gitea.larvit.se/api/swagger"}
+1 -9
View File
@@ -1,14 +1,6 @@
-- Runs once on first boot (docker-entrypoint-initdb.d), as the POSTGRES_USER. -- Runs once on first boot (docker-entrypoint-initdb.d), as the POSTGRES_USER.
-- One database per Ory service: each owns its schema and runs its own migrations, -- One database per Ory service: each owns its schema and runs its own migrations,
-- so they never collide. A plugin's database does not belong here: bootstrap provisions those on -- so they never collide. The web app never connects here (stateless — see README).
-- every boot, so one dropped in later is picked up too (README → Plugin storage).
CREATE DATABASE kratos; CREATE DATABASE kratos;
CREATE DATABASE keto; CREATE DATABASE keto;
CREATE DATABASE hydra; CREATE DATABASE hydra;
-- Postgres grants CONNECT to PUBLIC by default, so every plugin role could otherwise open the auth
-- plane's databases and read pg_catalog; table data stays protected either way. Ory connects as the
-- POSTGRES_USER, which owns these and keeps its access.
REVOKE CONNECT ON DATABASE kratos FROM PUBLIC;
REVOKE CONNECT ON DATABASE keto FROM PUBLIC;
REVOKE CONNECT ON DATABASE hydra FROM PUBLIC;
+14 -48
View File
@@ -1,21 +1,20 @@
{ {
"name": "plainpages", "name": "plainpages",
"version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "plainpages", "name": "plainpages",
"version": "0.1.0",
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "6.0.1", "ejs": "6.0.1",
"lucide-static": "1.66.0", "lucide-static": "1.28.0"
"postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
"@mdn/browser-compat-data": "8.1.7",
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
"@types/node": "24.19.2", "@types/node": "24.13.3",
"acorn": "8.19.0",
"typescript": "7.0.2" "typescript": "7.0.2"
}, },
"engines": { "engines": {
@@ -31,13 +30,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@mdn/browser-compat-data": {
"version": "8.1.7",
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-8.1.7.tgz",
"integrity": "sha512-TJ+rX530bFoE8678PuH2gjCSzwpBcaLlcElRhnnxFdkLHHT1clUAxtjImPFZDAlWQ005px9xaJ4Nd+aQp/rylQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/@types/ejs": { "node_modules/@types/ejs": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz", "resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz",
@@ -46,13 +38,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.19.2", "version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": ">=7.24.0 <7.24.7" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@typescript/typescript-aix-ppc64": { "node_modules/@typescript/typescript-aix-ppc64": {
@@ -395,19 +387,6 @@
"node": ">=16.20.0" "node": ">=16.20.0"
} }
}, },
"node_modules/acorn": {
"version": "8.19.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.19.0.tgz",
"integrity": "sha512-oJlA3XiRm7Cyk6qFD2Jn8ak9B7jSy0qy00ADO3+8dpT0LSjFihQYv4C02LFCSYJV3Q37xYwwRy5m+IpIiUzqWw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/ejs": { "node_modules/ejs": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz", "resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
@@ -421,24 +400,11 @@
} }
}, },
"node_modules/lucide-static": { "node_modules/lucide-static": {
"version": "1.66.0", "version": "1.28.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.66.0.tgz", "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.28.0.tgz",
"integrity": "sha512-j8Bjf/f21PtlD8p0wx1lRxyOrs98WZ1QEImkWmfskuJ8mOCwTN7Dwc6EHUzJDSzh79aB+Bo7SWcoTCM2zEjnJg==", "integrity": "sha512-dC3VJwRFsjEVX7Iaq4rY88pm7Fi2OmOb8P0WRzXsUMgbt7sCmFX8bLhaDBeNW6JdRjuele+jKqqFaam4yr+Ygg==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/postgres": {
"version": "3.4.9",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
"license": "Unlicense",
"engines": {
"node": ">=12"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/porsager"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
@@ -475,9 +441,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.24.6", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
} }
+6 -7
View File
@@ -1,31 +1,30 @@
{ {
"name": "plainpages", "name": "plainpages",
"version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24" "node": ">=24"
}, },
"imports": { "imports": {
"#menu-config": "./src/ui/menu-config.ts" "#menu-config": "./src/ui/menu-config.ts",
"#plugin-api": "./src/plugin-host/plugin-api.ts"
}, },
"scripts": { "scripts": {
"start": "node src/server.ts", "start": "node src/server.ts",
"dev": "node --watch src/server.ts", "dev": "node --watch src/server.ts",
"gen-jwks": "node src/auth/gen-jwks.ts", "gen-jwks": "node src/auth/gen-jwks.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"release-tooling/**/*.test.ts\"" "test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"auto-release/**/*.test.ts\""
}, },
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "6.0.1", "ejs": "6.0.1",
"lucide-static": "1.66.0", "lucide-static": "1.28.0"
"postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
"@mdn/browser-compat-data": "8.1.7",
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
"@types/node": "24.19.2", "@types/node": "24.13.3",
"acorn": "8.19.0",
"typescript": "7.0.2" "typescript": "7.0.2"
} }
} }
-2
View File
@@ -1,2 +0,0 @@
// Re-export rather than the surface itself: a package's `exports` target may not escape its folder.
export * from "../src/plugin-host/plugin-api.ts";
-6
View File
@@ -1,6 +0,0 @@
{
"name": "@plainpages/plugin-api",
"private": true,
"type": "module",
"exports": "./index.ts"
}
+121 -7
View File
@@ -1,11 +1,17 @@
/* =============================================================
Auth flow — login / register / forgot password
Built on the shared tokens in styles.css. Pure HTML/CSS:
- screens switch via :target (no JS)
- SSO is a config section toggled by a class/checkbox; providers
are a <ul> you extend by adding one <li class="sso-item">.
============================================================= */
.auth-stage { .auth-stage {
min-height: 100dvh;
display: grid; place-items: center; display: grid; place-items: center;
padding: 40px 20px 96px; padding: 40px 20px 96px;
background: var(--bg); background: var(--bg);
} }
@supports (min-height: 100dvh) {
.auth-stage { min-height: 100dvh; }
}
.auth { .auth {
width: 100%; max-width: 408px; width: 100%; max-width: 408px;
display: flex; flex-direction: column; gap: 22px; display: flex; flex-direction: column; gap: 22px;
@@ -28,6 +34,13 @@
.landing-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 4px; } .landing-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 4px; }
.landing-actions .btn { height: 40px; padding: 0 20px; font-size: var(--fz); } .landing-actions .btn { height: 40px; padding: 0 20px; font-size: var(--fz); }
/* ---- screen switching via :target (login is the default) ---- */
.auth-view { display: none; }
#login { display: block; }
#register:target, #forgot:target { display: block; }
body:has(#register:target) #login,
body:has(#forgot:target) #login { display: none; }
/* card */ /* card */
.auth-card { .auth-card {
background: var(--surface); background: var(--surface);
@@ -105,13 +118,13 @@
.input:focus { outline: none; border-color: var(--accent); .input:focus { outline: none; border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-bg); } box-shadow: 0 0 0 2px var(--accent-bg); }
/* gentle validation feedback only after the user has interacted */ /* gentle validation feedback only after the user has interacted */
@supports selector(:user-invalid) { .input:user-invalid { border-color: var(--neg); }
.input:user-invalid { border-color: var(--neg); } .input:user-invalid:focus { box-shadow: 0 0 0 2px var(--neg-bg); }
.input:user-invalid:focus { box-shadow: 0 0 0 2px var(--neg-bg); }
}
.field-hint { font-size: var(--fz-xs); color: var(--text-faint); } .field-hint { font-size: var(--fz-xs); color: var(--text-faint); }
.remember { margin-top: 2px; }
/* full-width primary button (extends .btn / .btn-primary from styles.css) */ /* full-width primary button (extends .btn / .btn-primary from styles.css) */
.btn-block { width: 100%; justify-content: center; height: 40px; font-size: var(--fz); } .btn-block { width: 100%; justify-content: center; height: 40px; font-size: var(--fz); }
@@ -125,6 +138,74 @@
.auth-aside a { color: var(--accent); } .auth-aside a { color: var(--accent); }
.auth-aside a:hover { text-decoration: underline; } .auth-aside a:hover { text-decoration: underline; }
/* legal footnote */
.auth-foot { margin: 0; text-align: center; font-size: var(--fz-xs); color: var(--text-faint); }
.auth-foot a { color: var(--text-muted); text-decoration: underline; }
/* =============================================================
Template preview controls (delete this block + its markup for
production). Drives theme + SSO on/off with zero JS.
============================================================= */
.tpl-controls {
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
z-index: 50;
display: flex; align-items: center; gap: 12px;
padding: 6px 8px 6px 14px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 99px; box-shadow: 0 6px 22px rgba(0,0,0,.12);
}
.tpl-label { font-size: var(--fz-xs); color: var(--text-faint);
text-transform: uppercase; letter-spacing: .05em; font-weight: 600; }
.tpl-sep { width: 1px; height: 20px; background: var(--border); }
.tpl-controls .theme-switch { width: 156px; }
/* on/off switch */
.tpl-toggle { display: inline-flex; align-items: center; gap: 8px;
font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer; }
.tpl-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
.tpl-track { width: 32px; height: 18px; border-radius: 99px;
background: var(--border-2); position: relative; transition: background .15s ease; }
.tpl-track::after { content: ""; position: absolute; top: 2px; left: 2px;
width: 14px; height: 14px; border-radius: 50%; background: #fff;
box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: transform .15s ease; }
.tpl-toggle:has(input:checked) .tpl-track { background: var(--accent); }
.tpl-toggle:has(input:checked) .tpl-track::after { transform: translateX(14px); }
.tpl-toggle:has(input:focus-visible) .tpl-track {
outline: 2px solid var(--focus); outline-offset: 2px; }
@media (max-width: 520px) {
.tpl-label { display: none; }
.tpl-controls { gap: 10px; padding: 6px 10px; }
}
/* =============================================================
Forgot-password feedback (Post/Redirect/Get, no JS).
The form POSTs to its own URL; on the redirect-GET the server
re-renders #forgot with a state class so the matching alert shows:
<section id="forgot" class="auth-view state-sent"> … </section>
(state-sent | state-error). No extra view is added.
The preview radios below demonstrate the same states live.
============================================================= */
.forgot-alert { display: none; }
#forgot.state-sent .forgot-alert.is-sent,
body:has(#fstate-sent:checked) #forgot .forgot-alert.is-sent { display: flex; }
#forgot.state-error .forgot-alert.is-error,
body:has(#fstate-error:checked) #forgot .forgot-alert.is-error { display: flex; }
/* the Forgot-state control only appears while the forgot view is open */
.tpl-forgot { display: none; }
body:has(#forgot:target) .tpl-forgot { display: flex; }
.tpl-controls .segmented { padding: 1px; }
.tpl-controls .segmented label { padding: 3px 8px; }
/* =============================================================
Field-level errors (e.g. "email already used"), server-driven.
The server re-renders the field's form with a state class and
adds aria-invalid="true" to the input:
<section id="register" class="auth-view state-email-taken"> … </section>
Reusable: drop a <p class="field-error"> under any field and gate
it on whatever state your server sets.
============================================================= */
.field-error { .field-error {
display: none; align-items: center; gap: 6px; margin: 0; display: none; align-items: center; gap: 6px; margin: 0;
font-size: var(--fz-xs); color: var(--neg); font-size: var(--fz-xs); color: var(--neg);
@@ -132,3 +213,36 @@
.field.has-error .field-error { display: flex; } /* server-rendered field shows its own error */ .field.has-error .field-error { display: flex; } /* server-rendered field shows its own error */
.field-error > .ico { flex: 0 0 auto; color: var(--neg); } .field-error > .ico { flex: 0 0 auto; color: var(--neg); }
.field-error a { color: var(--neg); font-weight: 600; text-decoration: underline; } .field-error a { color: var(--neg); font-weight: 600; text-decoration: underline; }
/* register form-level error banner (pairs with field-level errors) */
.register-alert { display: none; }
/* --- state A: email already taken — field-level error only --- */
#register.state-email-taken .err-email,
body:has(#rstate-taken:checked) #register .err-email { display: flex; }
#register.state-email-taken #reg-email,
body:has(#rstate-taken:checked) #register #reg-email { border-color: var(--neg); }
/* --- state B: multiple errors — banner + several field errors together --- */
#register.state-invalid .register-alert,
body:has(#rstate-combined:checked) #register .register-alert { display: flex; }
#register.state-invalid .err-email,
#register.state-invalid .err-password,
body:has(#rstate-combined:checked) #register .err-email,
body:has(#rstate-combined:checked) #register .err-password { display: flex; }
#register.state-invalid #reg-email,
#register.state-invalid #reg-password,
body:has(#rstate-combined:checked) #register #reg-email,
body:has(#rstate-combined:checked) #register #reg-password { border-color: var(--neg); }
/* red focus ring on any invalid register input */
#register.state-email-taken #reg-email:focus,
#register.state-invalid #reg-email:focus,
#register.state-invalid #reg-password:focus,
body:has(#rstate-taken:checked) #register #reg-email:focus,
body:has(#rstate-combined:checked) #register #reg-email:focus,
body:has(#rstate-combined:checked) #register #reg-password:focus { box-shadow: 0 0 0 2px var(--neg-bg); }
/* the Register-state control only appears while the register view is open */
.tpl-register { display: none; }
body:has(#register:target) .tpl-register { display: flex; }
+125 -188
View File
@@ -2,6 +2,8 @@
App Shell Template — pure HTML/CSS, no JavaScript App Shell Template — pure HTML/CSS, no JavaScript
------------------------------------------------------------- -------------------------------------------------------------
Architecture Architecture
- Design tokens via light-dark() so theme follows system by
default and can be forced Light / Dark via radio + :has().
- Everything is a reusable "block": .nav-item, .filter, - Everything is a reusable "block": .nav-item, .filter,
.chip, .badge, .btn, .menu, .table — combine freely. .chip, .badge, .btn, .menu, .table — combine freely.
============================================================= */ ============================================================= */
@@ -9,46 +11,43 @@
/* ---------- 1. TOKENS ---------------------------------------- /* ---------- 1. TOKENS ----------------------------------------
Two raw palettes (--l-* light, --d-* dark) are defined once. Two raw palettes (--l-* light, --d-* dark) are defined once.
A small mapping (--bg: var(--l-bg) ...) picks the active set. A small mapping (--bg: var(--l-bg) ...) picks the active set.
Theme resolution (no JS); all three rules below need :has(): Theme resolution (no JS):
• default + @media(dark) → follows the OS (Auto) • default + @media(dark) → follows the OS (Auto)
• html:has(#theme-light) → force light • html:has(#theme-light) → force light
• html:has(#theme-dark) → force dark • html:has(#theme-dark) → force dark
Engines without :has() (Chromium below 105, Firefox below 121,
Safari below 15.4) ignore all three and stay light; see the @supports not
selector(:has(a)) block further down.
We avoid light-dark()/color-scheme for app colors because their We avoid light-dark()/color-scheme for app colors because their
resolution is unreliable across engines. color-scheme is still resolution is unreliable across engines; :has() variable swaps
set as a hint for native widgets (date picker, scrollbars, are rock-solid. color-scheme is still set as a hint for native
selects). widgets (date picker, scrollbars, selects).
------------------------------------------------------------- */ ------------------------------------------------------------- */
:root { :root {
/* ---- raw LIGHT palette ---- */ /* ---- raw LIGHT palette ---- */
--l-bg:#f3f3f4; --l-surface:#ffffff; --l-surface-2:#f6f6f7; --l-surface-3:#fbfbfc; --l-bg:#f3f3f4; --l-surface:#ffffff; --l-surface-2:#f6f6f7; --l-surface-3:#fbfbfc;
--l-border:#e5e5e8; --l-border-2:#d6d6da; --l-border:#e5e5e8; --l-border-2:#d6d6da;
--l-text:#18181b; --l-text-muted:#6b6b73; --l-text-faint:#9a9aa2; --l-text:#18181b; --l-text-muted:#6b6b73; --l-text-faint:#9a9aa2;
--l-accent:#3669ac; --l-accent-bg:#e5f3ff; --l-accent:oklch(0.52 0.12 256); --l-accent-bg:oklch(0.96 0.03 256);
--l-accent-bd:#a7c7f2; --l-focus:#2a68b7; --l-on-accent:#ffffff; --l-accent-bd:oklch(0.82 0.07 256); --l-focus:oklch(0.52 0.14 256);
--l-pos:#1f7a3d; --l-pos-bg:#e1f5e4; --l-pos-bd:#a8d5b0; --l-pos:oklch(0.54 0.13 150); --l-pos-bg:oklch(0.95 0.03 150); --l-pos-bd:oklch(0.83 0.07 150);
--l-neg:#c53637; --l-neg-bg:#ffebe8; --l-neg-bd:#febab4; --l-neg:oklch(0.55 0.18 25); --l-neg-bg:oklch(0.96 0.03 25); --l-neg-bd:oklch(0.85 0.08 25);
--l-warn:#8f5d00; --l-warn-bg:#fef0d4; --l-warn-bd:#e3c78e; --l-warn:oklch(0.55 0.12 75); --l-warn-bg:oklch(0.96 0.04 85); --l-warn-bd:oklch(0.84 0.08 85);
--l-info:#1d6aa5;--l-info-bg:#e2f5ff;--l-info-bd:#a2cdf2; --l-info:oklch(0.54 0.12 245);--l-info-bg:oklch(0.96 0.03 245);--l-info-bd:oklch(0.83 0.07 245);
/* ---- raw DARK palette ---- */ /* ---- raw DARK palette ---- */
--d-bg:#0d0d0f; --d-surface:#161619; --d-surface-2:#1d1d22; --d-surface-3:#1a1a1e; --d-bg:#0d0d0f; --d-surface:#161619; --d-surface-2:#1d1d22; --d-surface-3:#1a1a1e;
--d-border:#2a2a31; --d-border-2:#3a3a43; --d-border:#2a2a31; --d-border-2:#3a3a43;
--d-text:#ededf0; --d-text-muted:#9b9ba6; --d-text-faint:#6a6a74; --d-text:#ededf0; --d-text-muted:#9b9ba6; --d-text-faint:#6a6a74;
--d-accent:#6ba0e8; --d-accent-bg:#1d3351; --d-accent:oklch(0.70 0.12 256); --d-accent-bg:oklch(0.32 0.06 256);
--d-accent-bd:#3f5f8a; --d-focus:#72adfb; --d-on-accent:#0d0d0f; --d-accent-bd:oklch(0.48 0.08 256); --d-focus:oklch(0.74 0.13 256);
--d-pos:#62c37a; --d-pos-bg:#1a3520; --d-pos-bd:#376040; --d-pos:oklch(0.74 0.14 150); --d-pos-bg:oklch(0.30 0.05 150); --d-pos-bd:oklch(0.45 0.07 150);
--d-neg:#ff7e76; --d-neg-bg:#4b1d1b; --d-neg-bd:#8a423d; --d-neg:oklch(0.74 0.16 25); --d-neg-bg:oklch(0.30 0.07 25); --d-neg-bd:oklch(0.47 0.10 25);
--d-warn:#e4b750; --d-warn-bg:#3e2d10; --d-warn-bd:#72592c; --d-warn:oklch(0.80 0.13 85); --d-warn-bg:oklch(0.31 0.05 80); --d-warn-bd:oklch(0.48 0.07 80);
--d-info:#65b2f1;--d-info-bg:#12334c;--d-info-bd:#305f85; --d-info:oklch(0.74 0.12 245);--d-info-bg:oklch(0.31 0.06 245);--d-info-bd:oklch(0.47 0.08 245);
/* ---- active mapping: default = LIGHT ---- */ /* ---- active mapping: default = LIGHT ---- */
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3); --bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
--border:var(--l-border); --border-2:var(--l-border-2); --border:var(--l-border); --border-2:var(--l-border-2);
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint); --text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --on-accent:var(--l-on-accent); --accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus);
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd); --pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd); --neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd); --warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
@@ -59,7 +58,7 @@
--radius: 5px; --radius: 5px;
--nav-w: 264px; --nav-w: 264px;
--row-h: 34px; --row-h: 34px;
--pad-x: 12px; /* side padding of the content pane's bars and form page, and of every table cell, so their text lines up */ --pad-x: 12px;
--fz: 13px; --fz: 13px;
--fz-sm: 12px; --fz-sm: 12px;
--fz-xs: 11px; --fz-xs: 11px;
@@ -69,51 +68,50 @@
line-height: 1.45; line-height: 1.45;
} }
@supports selector(:has(a)) { /* shared DARK mapping, applied to Auto-via-OS and forced-dark */
/* shared DARK mapping, applied to Auto-via-OS and forced-dark */ @media (prefers-color-scheme: dark) {
@media (prefers-color-scheme: dark) { :root:not(:has(#theme-light:checked)):not(:has(#theme-dark:checked)) {
:root:not(:has(#theme-light:checked)):not(:has(#theme-dark:checked)) {
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
--border:var(--d-border); --border-2:var(--d-border-2);
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --on-accent:var(--d-on-accent);
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
--info:var(--d-info); --info-bg:var(--d-info-bg); --info-bd:var(--d-info-bd);
color-scheme: dark;
}
}
/* forced DARK (overrides everything) */
html:has(#theme-dark:checked) {
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3); --bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
--border:var(--d-border); --border-2:var(--d-border-2); --border:var(--d-border); --border-2:var(--d-border-2);
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint); --text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --on-accent:var(--d-on-accent); --accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus);
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd); --pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd); --neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd); --warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
--info:var(--d-info); --info-bg:var(--d-info-bg); --info-bd:var(--d-info-bd); --info:var(--d-info); --info-bg:var(--d-info-bg); --info-bd:var(--d-info-bd);
color-scheme: dark; color-scheme: dark;
} }
}
/* forced LIGHT (overrides the OS-dark media query) */ /* forced DARK (overrides everything) */
html:has(#theme-light:checked) { html:has(#theme-dark:checked) {
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3); --bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
--border:var(--l-border); --border-2:var(--l-border-2); --border:var(--d-border); --border-2:var(--d-border-2);
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint); --text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --on-accent:var(--l-on-accent); --accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus);
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd); --pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd); --neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd); --warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
--info:var(--l-info); --info-bg:var(--l-info-bg); --info-bd:var(--l-info-bd); --info:var(--d-info); --info-bg:var(--d-info-bg); --info-bd:var(--d-info-bd);
color-scheme: light; color-scheme: dark;
} }
/* forced LIGHT (overrides the OS-dark media query) */
html:has(#theme-light:checked) {
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
--border:var(--l-border); --border-2:var(--l-border-2);
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus);
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
--info:var(--l-info); --info-bg:var(--l-info-bg); --info-bd:var(--l-info-bd);
color-scheme: light;
} }
/* ---------- 2. RESET ---------------------------------------- */ /* ---------- 2. RESET ---------------------------------------- */
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
html, body { height: 100%; }
body { margin: 0; background: var(--bg); color: var(--text); body { margin: 0; background: var(--bg); color: var(--text);
-webkit-font-smoothing: antialiased; } -webkit-font-smoothing: antialiased; }
button { font: inherit; color: inherit; } button { font: inherit; color: inherit; }
@@ -122,12 +120,10 @@ a { color: inherit; text-decoration: none; }
summary::-webkit-details-marker { display: none; } summary::-webkit-details-marker { display: none; }
summary { list-style: none; cursor: pointer; } summary { list-style: none; cursor: pointer; }
@supports selector(:focus-visible) { :focus-visible {
:focus-visible { outline: 2px solid var(--focus);
outline: 2px solid var(--focus); outline-offset: 1px;
outline-offset: 1px; border-radius: 3px;
border-radius: 3px;
}
} }
@media (prefers-reduced-motion: no-preference) { @media (prefers-reduced-motion: no-preference) {
.sidebar, .scrim, summary, .nav-item, .btn, .chip, .profile, .kebab { transition: .15s ease; } .sidebar, .scrim, summary, .nav-item, .btn, .chip, .profile, .kebab { transition: .15s ease; }
@@ -155,9 +151,8 @@ summary { list-style: none; cursor: pointer; }
.app { .app {
display: grid; display: grid;
grid-template-columns: var(--nav-w) minmax(0, 1fr); grid-template-columns: var(--nav-w) minmax(0, 1fr);
} height: 100dvh;
@supports (min-height: 100dvh) { overflow: hidden;
.app { min-height: 100dvh; }
} }
/* ---------- 4. SIDEBAR -------------------------------------- */ /* ---------- 4. SIDEBAR -------------------------------------- */
@@ -165,6 +160,7 @@ summary { list-style: none; cursor: pointer; }
grid-column: 1; grid-column: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
background: var(--surface); background: var(--surface);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
} }
@@ -176,9 +172,9 @@ summary { list-style: none; cursor: pointer; }
.brand-mark { .brand-mark {
width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto;
background: var(--accent); background: var(--accent);
display: grid; place-items: center; color: var(--on-accent); display: grid; place-items: center; color: #fff;
} }
.brand-mark .ico { stroke: var(--on-accent); } .brand-mark .ico { stroke: #fff; }
.brand-logo { width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; object-fit: contain; } .brand-logo { width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; object-fit: contain; }
.brand-name { font-weight: 650; letter-spacing: -.01em; } .brand-name { font-weight: 650; letter-spacing: -.01em; }
.brand-sub { color: var(--text-faint); font-size: var(--fz-xs); margin-left: auto; } .brand-sub { color: var(--text-faint); font-size: var(--fz-xs); margin-left: auto; }
@@ -196,10 +192,9 @@ summary { list-style: none; cursor: pointer; }
• CLICKABLE = label is <a href> → navigates on click. • CLICKABLE = label is <a href> → navigates on click.
STATIC = label is <span> → not a navigation target. STATIC = label is <span> → not a navigation target.
So "header" and "clickable" are independent: any combination works. So "header" and "clickable" are independent: any combination works.
Children sit in a sibling <ul class="nav-children">. Where :has() works, a Children sit in a sibling <ul class="nav-children">, revealed by
branch is folded unless THIS row's own <details open> (scoped with > so a node :has() on THIS row's own <details open> (scoped with > so a node
only ever controls its own subtree — infinite depth, no JS). Without :has() only ever controls its own subtree — infinite depth, no JS).
every branch stays open, since nothing could reopen one.
============================================================ */ ============================================================ */
.nav-row { display: flex; align-items: center; gap: 2px; } .nav-row { display: flex; align-items: center; gap: 2px; }
@@ -223,10 +218,7 @@ summary { list-style: none; cursor: pointer; }
min-height: 30px; padding: 4px 8px; min-height: 30px; padding: 4px 8px;
border-radius: var(--radius); border-radius: var(--radius);
color: var(--text-muted); font-size: var(--fz); color: var(--text-muted); font-size: var(--fz);
position: relative; position: relative; user-select: none;
}
@supports (user-select: none) {
.nav-self { user-select: none; }
} }
a.nav-self:hover { background: var(--surface-2); color: var(--text); } a.nav-self:hover { background: var(--surface-2); color: var(--text); }
.nav-self .ico { color: var(--text-faint); } .nav-self .ico { color: var(--text-faint); }
@@ -246,10 +238,8 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.nav-self[aria-current="page"] .ico { color: var(--accent); } .nav-self[aria-current="page"] .ico { color: var(--accent); }
/* children + infinite indentation with a guide line per level */ /* children + infinite indentation with a guide line per level */
@supports selector(:has(a)) { .nav-node > .nav-children { display: none; }
.nav-node > .nav-children { display: none; } .nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; }
.nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; }
}
.nav-children { padding-left: 22px; position: relative; } .nav-children { padding-left: 22px; position: relative; }
.nav-children::before { .nav-children::before {
content: ""; position: absolute; left: 10px; top: 2px; bottom: 2px; content: ""; position: absolute; left: 10px; top: 2px; bottom: 2px;
@@ -284,14 +274,12 @@ span.nav-self { cursor: default; } /* static / non-clickable */
} }
.theme-switch label:hover { color: var(--text); } .theme-switch label:hover { color: var(--text); }
.theme-switch input { position: absolute; opacity: 0; inset: 0; cursor: pointer; } .theme-switch input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
@supports selector(:has(:focus-visible)) { .theme-switch label:has(input:checked) {
.theme-switch label:has(input:checked) { background: var(--surface); color: var(--text);
background: var(--surface); color: var(--text); box-shadow: 0 1px 2px rgba(0,0,0,.06); font-weight: 550;
box-shadow: 0 1px 2px rgba(0,0,0,.06); font-weight: 550; }
} .theme-switch label:has(input:focus-visible) {
.theme-switch label:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px;
outline: 2px solid var(--focus); outline-offset: 1px;
}
} }
.footer-actions { display: flex; align-items: center; gap: 4px; } .footer-actions { display: flex; align-items: center; gap: 4px; }
@@ -324,15 +312,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
} }
.btn:hover { background: var(--surface-2); } .btn:hover { background: var(--surface-2); }
.btn-primary { background: var(--accent); border-color: var(--accent); .btn-primary { background: var(--accent); border-color: var(--accent);
color: var(--on-accent); font-weight: 550; } color: #fff; font-weight: 550; }
.btn-primary:hover { filter: brightness(1.06); } .btn-primary:hover { filter: brightness(1.06); }
.btn-ghost { background: transparent; border-color: transparent; } .btn-ghost { background: transparent; border-color: transparent; }
.btn-ghost:hover { background: var(--surface-2); } .btn-ghost:hover { background: var(--surface-2); }
.btn-menu::after {
content: ""; width: 7px; height: 7px; margin: -2px 1px 0 1px;
border-right: 1.5px solid var(--text-faint); border-bottom: 1.5px solid var(--text-faint);
transform: rotate(45deg);
}
.icon-btn { .icon-btn {
width: 30px; height: 30px; padding: 0; justify-content: center; width: 30px; height: 30px; padding: 0; justify-content: center;
color: var(--text-muted); color: var(--text-muted);
@@ -343,7 +326,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.content { .content {
grid-column: 2; grid-column: 2;
display: flex; flex-direction: column; display: flex; flex-direction: column;
min-width: 0; min-width: 0; min-height: 0;
background: var(--bg); background: var(--bg);
} }
@@ -351,7 +334,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.topbar { .topbar {
flex: 0 0 auto; height: 48px; flex: 0 0 auto; height: 48px;
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 0 var(--pad-x); border-bottom: 1px solid var(--border); padding: 0 16px; border-bottom: 1px solid var(--border);
background: var(--surface); background: var(--surface);
} }
.hamburger { display: none; } /* shown only on narrow */ .hamburger { display: none; } /* shown only on narrow */
@@ -367,7 +350,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.filters { .filters {
flex: 0 0 auto; background: var(--surface); flex: 0 0 auto; background: var(--surface);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
padding: 10px var(--pad-x); padding: 10px 16px;
display: flex; flex-direction: column; gap: 9px; display: flex; flex-direction: column; gap: 9px;
} }
.filter-row { .filter-row {
@@ -416,7 +399,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
position: relative; display: inline-flex; align-items: center; position: relative; display: inline-flex; align-items: center;
} }
.select select { .select select {
height: 30px; padding: 0 26px 0 9px; appearance: none; height: 30px; padding: 0 26px 0 9px;
background: var(--surface); color: var(--text); background: var(--surface); color: var(--text);
border: 1px solid var(--border-2); border-radius: var(--radius); border: 1px solid var(--border-2); border-radius: var(--radius);
font: inherit; font-size: var(--fz-sm); cursor: pointer; font: inherit; font-size: var(--fz-sm); cursor: pointer;
@@ -426,13 +409,8 @@ span.nav-self { cursor: default; } /* static / non-clickable */
width: 7px; height: 7px; border-right: 1.5px solid var(--text-faint); width: 7px; height: 7px; border-right: 1.5px solid var(--text-faint);
border-bottom: 1.5px solid var(--text-faint); transform: translateY(-2px) rotate(45deg); border-bottom: 1.5px solid var(--text-faint); transform: translateY(-2px) rotate(45deg);
} }
@supports (appearance: none) { .select select:focus-visible { border-color: var(--accent); outline: none;
.select select { appearance: none; } box-shadow: 0 0 0 2px var(--accent-bg); }
}
@supports selector(:focus-visible) {
.select select:focus-visible { border-color: var(--accent); outline: none;
box-shadow: 0 0 0 2px var(--accent-bg); }
}
/* segmented toggle (radios) */ /* segmented toggle (radios) */
.segmented { .segmented {
@@ -447,13 +425,11 @@ span.nav-self { cursor: default; } /* static / non-clickable */
} }
.segmented label:hover { color: var(--text); } .segmented label:hover { color: var(--text); }
.segmented input { position: absolute; opacity: 0; inset: 0; cursor: pointer; } .segmented input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
@supports selector(:has(:focus-visible)) { .segmented label:has(input:checked) {
.segmented label:has(input:checked) { background: var(--surface); color: var(--text); font-weight: 550;
background: var(--surface); color: var(--text); font-weight: 550; box-shadow: 0 1px 2px rgba(0,0,0,.06);
box-shadow: 0 1px 2px rgba(0,0,0,.06);
}
.segmented label:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
} }
.segmented label:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
.seg-count { font-size: var(--fz-xs); color: var(--text-faint); } .seg-count { font-size: var(--fz-xs); color: var(--text-faint); }
/* chips (multi-select checkboxes) */ /* chips (multi-select checkboxes) */
@@ -469,25 +445,12 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.chip input { position: absolute; opacity: 0; inset: 0; cursor: pointer; } .chip input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
.chip .chip-dot { width: 7px; height: 7px; border-radius: 50%; .chip .chip-dot { width: 7px; height: 7px; border-radius: 50%;
background: var(--border-2); } background: var(--border-2); }
@supports selector(:has(:focus-visible)) { .chip:has(input:checked) {
.chip:has(input:checked) { background: var(--accent-bg); border-color: var(--accent-bd);
background: var(--accent-bg); border-color: var(--accent-bd); color: var(--text); font-weight: 550;
color: var(--text); font-weight: 550;
}
.chip:has(input:checked) .chip-dot { background: var(--accent); }
.chip:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
}
/* Without :has() every nav branch stays open and no theme rule applies, so the disclosure toggles and
the theme switch are hidden. The filter inputs are shown instead: the native control is then the
only sign of a choice or of focus, and the dot goes, since it would stay grey beside a ticked box.
This block must come after the base rules, since @supports adds no specificity. */
@supports not selector(:has(a)) {
.nav-disc { visibility: hidden; } /* keeps its width, so the labels stay aligned */
.theme-switch { display: none; }
.segmented input, .chip input { position: static; opacity: 1; margin: 0; }
.chip .chip-dot { display: none; }
} }
.chip:has(input:checked) .chip-dot { background: var(--accent); }
.chip:has(input:focus-visible) { outline: 2px solid var(--focus); outline-offset: 1px; }
/* date range */ /* date range */
.daterange { .daterange {
@@ -508,10 +471,8 @@ span.nav-self { cursor: default; } /* static / non-clickable */
display: inline-flex; align-items: center; gap: 7px; display: inline-flex; align-items: center; gap: 7px;
font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer; font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer;
} }
.check input, .radio input { width: 15px; height: 15px; margin: 0; cursor: pointer; } .check input, .radio input { width: 15px; height: 15px; accent-color: var(--accent);
@supports (accent-color: auto) { margin: 0; cursor: pointer; }
.check input, .radio input { accent-color: var(--accent); }
}
.check:hover, .radio:hover { color: var(--text); } .check:hover, .radio:hover { color: var(--text); }
/* A stacked group of .check rows in a <fieldset> — the right element for related checkboxes, but the /* A stacked group of .check rows in a <fieldset> — the right element for related checkboxes, but the
UA gives it a groove border, so reset it like .filter-field and .menu-field do. A disabled row is UA gives it a groove border, so reset it like .filter-field and .menu-field do. A disabled row is
@@ -519,9 +480,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.check-group { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; } .check-group { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.check-group .check { align-items: baseline; } .check-group .check { align-items: baseline; }
.check-group .check input:disabled { cursor: default; } .check-group .check input:disabled { cursor: default; }
@supports selector(:has(a)) { .check-group .check:has(input:disabled) { opacity: .7; cursor: default; }
.check-group .check:has(input:disabled) { opacity: .7; cursor: default; }
}
.check-group .check .cell-muted { margin-left: auto; padding-left: 12px; font-size: var(--fz-xs); } .check-group .check .cell-muted { margin-left: auto; padding-left: 12px; font-size: var(--fz-xs); }
/* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover] /* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover]
@@ -532,24 +491,16 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.menu { display: inline-flex; } .menu { display: inline-flex; }
.menu-pop { .menu-pop {
position: absolute; inset: auto; margin: 6px 0 0; position: absolute; inset: auto; margin: 6px 0 0;
min-width: 210px; max-width: min(320px, 92vw); padding: 6px; position-anchor: auto;
max-height: 60vh; overflow-y: auto; position-try-fallbacks: flip-block, flip-inline;
top: anchor(bottom); right: anchor(right);
min-width: 210px; padding: 6px;
background: var(--surface); color: var(--text); background: var(--surface); color: var(--text);
border: 1px solid var(--border-2); border-radius: var(--radius); border: 1px solid var(--border-2); border-radius: var(--radius);
box-shadow: 0 8px 28px rgba(0,0,0,.16); box-shadow: 0 8px 28px rgba(0,0,0,.16);
} }
.menu-pop.up { margin: 0 0 6px; } .menu-pop.left { right: auto; left: anchor(left); }
@supports (overflow-wrap: anywhere) { .menu-pop.up { top: auto; bottom: anchor(top); margin: 0 0 6px; }
.menu-pop { overflow-wrap: anywhere; }
}
@supports (position-anchor: auto) and (top: anchor(bottom)) {
.menu-pop { position-anchor: auto; top: anchor(bottom); right: anchor(right); }
.menu-pop.left { right: auto; left: anchor(left); }
.menu-pop.up { top: auto; bottom: anchor(top); }
}
@supports (position-try-fallbacks: flip-block, flip-inline) {
.menu-pop { position-try-fallbacks: flip-block, flip-inline; }
}
/* A browser too old for the Popover API leaves the trigger inert, so let each panel flow under its /* A browser too old for the Popover API leaves the trigger inert, so let each panel flow under its
trigger rather than stranding Sign out and the language picker behind a button that does nothing. trigger rather than stranding Sign out and the language picker behind a button that does nothing.
The wrapper turns block so the pair stacks instead of sitting side by side, and min-width goes — The wrapper turns block so the pair stacks instead of sitting side by side, and min-width goes —
@@ -580,10 +531,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.menu-check { display: flex; align-items: center; gap: 9px; width: 100%; .menu-check { display: flex; align-items: center; gap: 9px; width: 100%;
padding: 6px 8px; border-radius: 4px; font-size: var(--fz-sm); cursor: pointer; } padding: 6px 8px; border-radius: 4px; font-size: var(--fz-sm); cursor: pointer; }
.menu-check:hover { background: var(--surface-2); } .menu-check:hover { background: var(--surface-2); }
.menu-check input { width: 15px; height: 15px; margin: 0; } .menu-check input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; }
@supports (accent-color: auto) {
.menu-check input { accent-color: var(--accent); }
}
/* active filter pills */ /* active filter pills */
.active-pills { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; } .active-pills { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
@@ -605,12 +553,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.pill-clear:hover { text-decoration: underline; } .pill-clear:hover { text-decoration: underline; }
/* ---------- 9. TABLE --------------------------------------- */ /* ---------- 9. TABLE --------------------------------------- */
.table-wrap { overflow-x: auto; } .table-wrap { flex: 1 1 auto; min-height: 0; overflow: auto; }
table.table { table.table {
width: 100%; border-collapse: separate; border-spacing: 0; width: 100%; border-collapse: separate; border-spacing: 0;
font-size: var(--fz); font-variant-numeric: tabular-nums; font-size: var(--fz); font-variant-numeric: tabular-nums;
} }
.table thead th { .table thead th {
position: sticky; top: 0; z-index: 10;
background: var(--surface-3); background: var(--surface-3);
border-bottom: 1px solid var(--border-2); border-bottom: 1px solid var(--border-2);
color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs); color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs);
@@ -625,19 +574,15 @@ table.table {
} }
.table tbody th { font-weight: inherit; text-align: left; } /* row-header cell, not bold/centered */ .table tbody th { font-weight: inherit; text-align: left; } /* row-header cell, not bold/centered */
.table tbody tr:hover td, .table tbody tr:hover th { background: var(--surface-2); } .table tbody tr:hover td, .table tbody tr:hover th { background: var(--surface-2); }
@supports selector(:has(a)) { .table tbody tr:has(.row-select:checked) td,
.table tbody tr:has(.row-select:checked) td, .table tbody tr:has(.row-select:checked) th { background: var(--accent-bg); }
.table tbody tr:has(.row-select:checked) th { background: var(--accent-bg); }
}
.col-check { width: 36px; text-align: center; padding: 0; } .col-check { width: 36px; text-align: center; padding: 0; }
.col-check input { width: 15px; height: 15px; margin: 0; vertical-align: middle; } .col-check input { accent-color: var(--accent); width: 15px; height: 15px;
@supports (accent-color: auto) { margin: 0; vertical-align: middle; }
.col-check input { accent-color: var(--accent); }
}
.col-num { text-align: right; } .col-num { text-align: right; }
.cell-strong { font-weight: 550; } .cell-strong { font-weight: 550; }
.cell-muted { color: var(--text-muted); } .cell-muted { color: var(--text-muted); }
.table-empty { text-align: center; color: var(--text-muted); padding: 28px var(--pad-x); } .table-empty { text-align: center; color: var(--text-muted); padding: 28px 12px; }
.cell-mono { font-variant-numeric: tabular-nums; .cell-mono { font-variant-numeric: tabular-nums;
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); } font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); }
@@ -695,15 +640,13 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
.kebab:hover { background: var(--surface-2); color: var(--text); } .kebab:hover { background: var(--surface-2); color: var(--text); }
/* Held open — a trigger's panel is its next sibling. Its own rule: :has() is non-forgiving, so an /* Held open — a trigger's panel is its next sibling. Its own rule: :has() is non-forgiving, so an
engine that doesn't know :popover-open would drop the hover style along with it. */ engine that doesn't know :popover-open would drop the hover style along with it. */
@supports selector(.kebab:has(+ .menu-pop:popover-open)) { .kebab:has(+ .menu-pop:popover-open) { background: var(--surface-2); color: var(--text); }
.kebab:has(+ .menu-pop:popover-open) { background: var(--surface-2); color: var(--text); }
}
/* ---------- 10. PAGINATION --------------------------------- */ /* ---------- 10. PAGINATION --------------------------------- */
.pager { .pager {
flex: 0 0 auto; flex: 0 0 auto;
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 12px;
padding: 8px var(--pad-x); border-top: 1px solid var(--border); padding: 8px 16px; border-top: 1px solid var(--border);
background: var(--surface); font-size: var(--fz-sm); color: var(--text-muted); background: var(--surface); font-size: var(--fz-sm); color: var(--text-muted);
} }
.pager .spacer { flex: 1 1 auto; } .pager .spacer { flex: 1 1 auto; }
@@ -718,34 +661,29 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
} }
.page-btn:hover { background: var(--surface-2); color: var(--text); } .page-btn:hover { background: var(--surface-2); color: var(--text); }
.page-btn[aria-current="page"] { background: var(--accent); border-color: var(--accent); .page-btn[aria-current="page"] { background: var(--accent); border-color: var(--accent);
color: var(--on-accent); font-weight: 600; } color: #fff; font-weight: 600; }
.page-btn:disabled { opacity: .4; cursor: not-allowed; } .page-btn:disabled { opacity: .4; cursor: not-allowed; }
/* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */ /* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */
.scrim { display: none; } .scrim { display: none; }
@media (max-width: 860px) { @media (max-width: 860px) {
.app { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr; } .app { grid-template-columns: minmax(0, 1fr); }
.content { grid-column: 1; grid-row: 2; } .sidebar {
.search { min-width: 150px; flex: 1 1 auto; } position: fixed; inset: 0 auto 0 0; width: min(86vw, var(--nav-w));
z-index: 60; transform: translateX(-100%);
/* Off-canvas only where :has() works: without it nothing can read #nav-toggle, so the sidebar box-shadow: 2px 0 24px rgba(0,0,0,.18);
stays in the flow above the content and the hamburger stays hidden. */
@supports selector(:has(a)) {
.sidebar {
position: fixed; inset: 0 auto 0 0; width: min(86vw, var(--nav-w));
z-index: 60; transform: translateX(-100%);
box-shadow: 2px 0 24px rgba(0,0,0,.18);
}
.hamburger { display: inline-flex; }
/* checkbox-driven open state */
body:has(#nav-toggle:checked) .sidebar { transform: translateX(0); }
body:has(#nav-toggle:checked) .scrim {
display: block; position: fixed; inset: 0; z-index: 50;
background: rgba(0,0,0,.42);
}
} }
.content { grid-column: 1; }
.hamburger { display: inline-flex; }
/* checkbox-driven open state */
body:has(#nav-toggle:checked) .sidebar { transform: translateX(0); }
body:has(#nav-toggle:checked) .scrim {
display: block; position: fixed; inset: 0; z-index: 50;
background: rgba(0,0,0,.42);
}
.search { min-width: 150px; flex: 1 1 auto; }
} }
@media (max-width: 560px) { @media (max-width: 560px) {
.crumbs, .page-sub { display: none; } .crumbs, .page-sub { display: none; }
@@ -753,10 +691,10 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
} }
/* the nav-toggle checkbox itself is visually hidden but focusable */ /* the nav-toggle checkbox itself is visually hidden but focusable */
#nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; } #nav-toggle { position: absolute; opacity: 0; pointer-events: none; }
/* admin forms: create/edit user, account actions */ /* admin forms: create/edit user, account actions */
.form-page { padding: 16px var(--pad-x); display: flex; flex-direction: column; gap: 14px; max-width: 560px; } .form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
.form-card { .form-card {
display: flex; flex-direction: column; gap: 14px; display: flex; flex-direction: column; gap: 14px;
padding: 18px; background: var(--surface); padding: 18px; background: var(--surface);
@@ -778,6 +716,5 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
/* Chromeless shell: a page may drop the sidebar for a focused single column. */ /* Chromeless shell: a page may drop the sidebar for a focused single column. */
.app-bare { grid-template-columns: minmax(0, 1fr); } .app-bare { grid-template-columns: minmax(0, 1fr); }
.app-bare .content { grid-column: 1; } .app-bare .content { grid-column: 1; }
/* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */ /* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */
.shell-auth { flex: 1 1 auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; } .shell-auth { flex: 1 1 auto; overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
-10
View File
@@ -1,10 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
test("auto-release's checkout doesn't persist credentials, so the tag push authenticates as renovate-bot", () => {
const workflow = readFileSync(".gitea/workflows/renovate.yml", "utf8");
const job = workflow.slice(workflow.indexOf("\n auto-release:"));
const checkout = job.slice(job.indexOf("actions/checkout@"), job.indexOf("\n - name:"));
assert.match(checkout, /^\s+persist-credentials: false$/m);
});
-58
View File
@@ -1,58 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { checkTagMatchesContract, readHostApiVersion } from "./contract-version.ts";
test("readHostApiVersion pulls the constant out of the real source, and returns null when absent", () => {
const real = readFileSync("src/plugin-host/plugin.ts", "utf8");
assert.match(readHostApiVersion(real) ?? "", /^\d+\.\d+\.\d+$/);
assert.equal(readHostApiVersion('export const SOMETHING_ELSE = "1.0.0";'), null);
});
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
// makes an accidental edit fail here rather than at release time.
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.4.0");
});
test("every author-facing apiVersion sample matches the shipped contract", () => {
// A plugin author copies these; a stale one produces a boot-aborting refuse on first run. The
// examples deliberately write a literal rather than importing the constant (AGENTS.md), so this
// is the only thing keeping the copies honest.
const host = readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")) ?? "";
const [major, minor] = host.split(".");
for (const file of [
"README.md",
"examples/plugins/admin/plugin.ts",
"examples/plugins/scheduling/plugin.ts",
"release-tooling/dockerhub-overview.md.tmpl",
"views/index.ejs",
]) {
const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]);
assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`);
for (const sample of found) {
const [sMajor, sMinor] = (sample ?? "").split(".");
assert.equal(`${sMajor}.${sMinor}`, `${major}.${minor}`, `${file} samples apiVersion ${sample}, host is ${host}`);
}
}
});
test("checkTagMatchesContract: major.minor must agree, patch may lag", () => {
assert.equal(checkTagMatchesContract("v0.1.0", "0.1.0").ok, true);
assert.equal(checkTagMatchesContract("v0.1.7", "0.1.0").ok, true); // auto-release cut patches
assert.equal(checkTagMatchesContract("0.1.0", "0.1.0").ok, true); // bare tag, no v
assert.equal(checkTagMatchesContract("v0.2.0", "0.1.0").ok, false); // plugin-visible, needs a bump
assert.equal(checkTagMatchesContract("v1.0.0", "0.1.0").ok, false);
});
test("checkTagMatchesContract names what to fix rather than just failing", () => {
const res = checkTagMatchesContract("v0.2.0", "0.1.0");
assert.equal(res.ok, false);
assert.match(res.ok === false ? res.error : "", /HOST_API_VERSION to 0\.2\.0/);
});
test("checkTagMatchesContract rejects junk on either side without throwing", () => {
assert.equal(checkTagMatchesContract("v0.1.0", null).ok, false); // constant not found
assert.equal(checkTagMatchesContract("nope", "0.1.0").ok, false);
assert.equal(checkTagMatchesContract("v0.1.0", "1.0").ok, false);
});
-49
View File
@@ -1,49 +0,0 @@
import { readFileSync } from "node:fs";
export type ContractCheck = { ok: true } | { ok: false; error: string };
const SEMVER = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
export function readHostApiVersion(source: string): string | null {
return /^export const HOST_API_VERSION = "([^"]+)";/m.exec(source)?.[1] ?? null;
}
// Patch is deliberately not compared: checkApiVersion ignores it, and auto-release cuts patch
// releases with no commit to bump the constant in.
export function checkTagMatchesContract(tag: string, hostApiVersion: string | null): ContractCheck {
if (hostApiVersion === null) {
return { error: "HOST_API_VERSION not found", ok: false };
}
const t = SEMVER.exec(tag);
if (!t) return { error: `tag must be vX.Y.Z, got ${JSON.stringify(tag)}`, ok: false };
const h = SEMVER.exec(hostApiVersion);
if (!h) return { error: `HOST_API_VERSION must be X.Y.Z, got ${JSON.stringify(hostApiVersion)}`, ok: false };
if (t[1] === h[1] && t[2] === h[2]) return { ok: true };
return {
error:
`${tag} does not match HOST_API_VERSION ${hostApiVersion} — the contract version IS the release ` +
`version. Set HOST_API_VERSION to ${t[1]}.${t[2]}.0 in src/plugin-host/plugin.ts, merge that, ` +
"then tag.",
ok: false,
};
}
// CLI: node release-tooling/contract-version.ts <tag> <path/to/plugin.ts | -> → exits 1 on
// mismatch. `-` reads the source on stdin, so a caller checking a ref other than its checkout
// (`git show origin/main:… | …`) needs no scratch file in the workspace.
if (process.argv[1]?.endsWith("/contract-version.ts")) {
const [, , tag, pluginPath = "src/plugin-host/plugin.ts"] = process.argv;
let source = "";
try {
source = readFileSync(pluginPath === "-" ? 0 : pluginPath, "utf8");
} catch (err) {
process.stderr.write(`${pluginPath}: ${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
}
const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source));
if (!result.ok) {
process.stderr.write(`${pluginPath}: ${result.error}\n`);
process.exit(1);
}
process.stdout.write(`${tag} matches HOST_API_VERSION\n`);
}
@@ -1,73 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { DESCRIPTION, hubPatch, jwtFrom, leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts";
const TEMPLATE = "release-tooling/dockerhub-overview.md.tmpl";
const template = () => readFileSync(TEMPLATE, "utf8");
test("renderOverview substitutes every occurrence, not just the first", () => {
const out = renderOverview("pull a:{{VERSION}} then b:{{VERSION}}", "1.2.3");
assert.equal(out, "pull a:1.2.3 then b:1.2.3");
});
test("leftoverPlaceholders catches a typo'd placeholder, deduped, and passes clean text", () => {
assert.deepEqual(leftoverPlaceholders("a {{VERISON}} b {{VERISON}}"), ["{{VERISON}}"]);
assert.deepEqual(leftoverPlaceholders(renderOverview("x {{VERSION}}", "0.1.0")), []);
});
test("the real template renders clean, and the release owns its own image tag", () => {
const rendered = renderOverview(template(), "9.9.9");
assert.deepEqual(leftoverPlaceholders(rendered), []);
assert.match(rendered, /larvit\/plainpages:9\.9\.9/); // the placeholder actually reaches the examples
assert.doesNotMatch(rendered, /larvit\/plainpages:\d+\.\d+\.\d+(?<!9\.9\.9)/);
});
test("the quick start's sidecars are pinned to the same versions this repo runs", () => {
// The page is published automatically, so a drifted pin here ships a topology CI never tested.
const pins = (source: string) =>
new Map([...source.matchAll(/image: ([^:\s]+):(v?\d\S*)/g)].map((m) => [m[1] ?? "", m[2] ?? ""]));
const ours = new Map([
...pins(readFileSync("compose.override.yml", "utf8")),
...pins(readFileSync("compose.yml", "utf8")), // production wins: the template is the prod quick start
]);
const published = pins(template());
assert.ok(published.size > 0, "the template should pin sidecars");
for (const [image, tag] of published) {
assert.equal(tag, ours.get(image), `${TEMPLATE} pins ${image}:${tag}, this repo runs ${ours.get(image)}`);
}
});
test("jwtFrom accepts only a non-empty string token, never throwing on a hostile body", () => {
assert.equal(jwtFrom({ token: "abc" }), "abc");
assert.equal(jwtFrom(null), null); // valid JSON, and the shape a proxy can return
assert.equal(jwtFrom("<html>rate limited</html>"), null);
assert.equal(jwtFrom({}), null);
assert.equal(jwtFrom({ token: "" }), null);
assert.equal(jwtFrom({ token: 42 }), null);
});
test("the short description opens a README sentence and fits Docker Hub's 100 characters", () => {
const readme = readFileSync("README.md", "utf8").replaceAll("**", "");
assert.ok(DESCRIPTION.trim() !== "" && DESCRIPTION.length <= 100, `${DESCRIPTION.length} characters`);
const escaped = DESCRIPTION.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
assert.match(readme, new RegExp(`(?:\\n\\n|\\.\\s)${escaped}(?=[\\s.:,])`));
});
test("every command the overview shows, the README shows too", () => {
const commands = (source: string) =>
[...source.matchAll(/```bash\n(.*?)```/gs)].flatMap((block) =>
(block[1] ?? "")
.split("\n")
.map((line) => (line.split(" #")[0] ?? "").trim().replace(/larvit\/plainpages:\S+/g, "larvit/plainpages:X.Y.Z"))
.filter((line) => line !== ""),
);
const shown = new Set(commands(readFileSync("README.md", "utf8")));
const published = commands(template());
assert.ok(published.length > 0, `${TEMPLATE} should show commands`);
for (const command of published) assert.ok(shown.has(command), `${TEMPLATE} shows ${command}, which README.md doesn't`);
});
test("the PATCH carries the short description beside the overview", () => {
assert.deepEqual(hubPatch("# page"), { description: DESCRIPTION, full_description: "# page" });
});
-107
View File
@@ -1,107 +0,0 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
const HUB = "https://hub.docker.com/v2";
const TIMEOUT_MS = 30_000;
const VERSION = /^\d+\.\d+\.\d+$/;
export const DESCRIPTION = "A self-hostable foundation for server-rendered web applications";
export function hubPatch(overview: string): { description: string; full_description: string } {
return { description: DESCRIPTION, full_description: overview };
}
export function renderOverview(source: string, version: string): string {
return source.replaceAll("{{VERSION}}", version);
}
// A typo'd placeholder would publish literal braces to a public page, so fail the release instead.
export function leftoverPlaceholders(rendered: string): string[] {
return [...new Set(rendered.match(/\{\{[^}]*\}\}/g) ?? [])];
}
export function jwtFrom(body: unknown): string | null {
if (typeof body !== "object" || body === null || !("token" in body)) return null;
return typeof body.token === "string" && body.token !== "" ? body.token : null;
}
type Fetched = { error: string } | { json: unknown; ok: boolean; status: number; text: string };
// fetch and its body readers throw; this is the one edge that converts that into a value.
async function post(url: string, init: RequestInit): Promise<Fetched> {
try {
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
const text = await res.text();
let json: unknown = null;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return { json, ok: res.ok, status: res.status, text };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
async function main(): Promise<number> {
const fail = (message: string): number => {
process.stderr.write(`${message}\n`);
return 1;
};
const [, , version] = process.argv;
const repo = process.env["DOCKERHUB_REPO"];
const user = process.env["DOCKERHUB_USER"];
const token = process.env["DOCKERHUB_TOKEN"];
if (!version || !repo || !user || !token) {
return fail(
"usage: dockerhub-overview.ts <X.Y.Z>; needs DOCKERHUB_REPO, DOCKERHUB_USER and " +
"DOCKERHUB_TOKEN (README -> CI/CD)",
);
}
// The page is public, so never render a version that resolves to no image.
if (!VERSION.test(version)) return fail(`version must be X.Y.Z, got ${JSON.stringify(version)}`);
const templatePath = join(import.meta.dirname, "dockerhub-overview.md.tmpl");
let template = "";
try {
template = readFileSync(templatePath, "utf8");
} catch (err) {
return fail(`${templatePath}: ${err instanceof Error ? err.message : String(err)}`);
}
const body = renderOverview(template, version);
const leftover = leftoverPlaceholders(body);
if (leftover.length > 0) return fail(`${templatePath} has unrendered placeholders: ${leftover.join(", ")}`);
const login = await post(`${HUB}/users/login`, {
body: JSON.stringify({ password: token, username: user }),
headers: { "content-type": "application/json" },
method: "POST",
});
if ("error" in login) return fail(`Docker Hub login unreachable: ${login.error}`);
if (!login.ok) return fail(`Docker Hub login failed: ${login.status} ${login.text}`);
const jwt = jwtFrom(login.json);
if (!jwt) return fail("Docker Hub login returned no token");
const res = await post(`${HUB}/repositories/${repo}/`, {
body: JSON.stringify(hubPatch(body)),
headers: { authorization: `Bearer ${jwt}`, "content-type": "application/json" },
method: "PATCH",
});
if ("error" in res) return fail(`Docker Hub unreachable: ${res.error}`);
if (!res.ok) {
return fail(
`Docker Hub description and overview PATCH failed: ${res.status} ${res.text}` +
(res.status === 403
? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " +
"read/write/delete, which pushing images alone does not (README -> CI/CD)."
: ""),
);
}
process.stdout.write(`Docker Hub description and overview updated for ${repo} at ${version}\n`);
return 0;
}
if (process.argv[1]?.endsWith("/dockerhub-overview.ts")) {
process.exitCode = await main();
}
+3 -42
View File
@@ -1,41 +1,9 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"], "extends": ["config:recommended"],
"description": "ignorePaths overrides config:recommended's :ignoreModulesAndTests, which ignores **/examples/** — an example plugin's dependencies get update PRs like any other manifest here",
"ignorePaths": ["**/node_modules/**"],
"automerge": true, "automerge": true,
"commitBody": "Release-Bump: {{{updateType}}}",
"packageRules": [ "packageRules": [
{
"description": "The host's own runtime deps. Release-Bump is opt-in per surface (README → CI/CD): updateType rates the dependency's own jump, not whether it reaches a running Plainpages",
"matchDepTypes": ["dependencies"],
"matchFileNames": ["package.json"],
"matchManagers": ["npm"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "The shipped image's base — e2e-tests/Dockerfile is test-only",
"matchFileNames": ["Dockerfile"],
"matchManagers": ["dockerfile"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "The production topology — compose.override.yml is dev, e2e-tests/compose.*.yml are test",
"matchFileNames": ["compose.yml"],
"matchManagers": ["docker-compose"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "The production sidecars, wherever they are pinned — compose.yml and the published quick start move in one branch, so the trailer must not depend on which upgrade sorts first. mailpit is dev-only and stays out",
"matchDatasources": ["docker"],
"matchPackageNames": ["oryd/hydra", "oryd/keto", "oryd/kratos", "postgres"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "node is pinned to one version across Dockerfile, dev, E2E and CI, so Renovate moves them in a single branch whose commitBody would otherwise depend on upgrade order — the Dockerfile copy ships, so any node bump is a product change",
"matchDatasources": ["docker"],
"matchPackageNames": ["node"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{ {
"description": "Ory services share one release train - update kratos, keto and hydra together", "description": "Ory services share one release train - update kratos, keto and hydra together",
"matchDatasources": ["docker"], "matchDatasources": ["docker"],
@@ -59,15 +27,8 @@
}, },
{ {
"customType": "regex", "customType": "regex",
"description": "The published quick start ships a compose file, so its sidecars move with the repo's own pins. The version group starts at a digit, which skips the {{VERSION}} placeholder the release renders", "description": "Pin the node image workflow run-steps invoke (registry-cleanup, auto-release)",
"managerFilePatterns": ["release-tooling/dockerhub-overview.md.tmpl"], "managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/renovate.yml"],
"matchStrings": ["image: (?<depName>[^:\\s]+):(?<currentValue>v?\\d[^\\s]*)"],
"datasourceTemplate": "docker"
},
{
"customType": "regex",
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, renovate auto-release, release)",
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/release.yml", ".gitea/workflows/renovate.yml"],
"matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"], "matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"],
"depNameTemplate": "node", "depNameTemplate": "node",
"datasourceTemplate": "docker" "datasourceTemplate": "docker"
+11 -85
View File
@@ -5,10 +5,7 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; 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, seedAdmin, seedPermissions } from "./bootstrap.ts";
import { createLogger } from "../logger.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import type { ProvisionOptions, ProvisionResult } from "../plugin-host/storage-provisioning.ts";
const json = (status: number, body?: unknown) => const json = (status: number, body?: unknown) =>
new Response(body === undefined ? null : JSON.stringify(body), { new Response(body === undefined ? null : JSON.stringify(body), {
@@ -36,30 +33,19 @@ test("permissionTuple grants a permission to user:<id> in the Permission namespa
test("seedPermissions unions ADMIN_PERMISSIONS (empty by default) with the discovered plugins' declared permissions", () => { test("seedPermissions unions ADMIN_PERMISSIONS (empty by default) with the discovered plugins' declared permissions", () => {
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two names → the demo admin // Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two names → the demo admin
// holds exactly what the installed plugins gate on, derived from discovery, not hardcoded here. // holds exactly what the installed plugins gate on, derived from discovery, not hardcoded here.
const names = (env: string | undefined, declared: string[]): string[] => seedPermissions(env, declared).permissions; assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["scheduling:read", "scheduling:write"]);
assert.deepEqual(names(undefined, ["scheduling:read", "scheduling:write"]), ["scheduling:read", "scheduling:write"]);
// No plugins → nothing to grant. A host-invented base would be a permission that gates nothing. // No plugins → nothing to grant. A host-invented base would be a permission that gates nothing.
assert.deepEqual(names(undefined, []), []); assert.deepEqual(seedPermissions(undefined, []), []);
assert.deepEqual(names("ops:read, ops:write ", ["inventory:read"]), ["ops:read", "ops:write", "inventory:read"]); // env trimmed + extended assert.deepEqual(seedPermissions("ops:read, ops:write ", ["inventory:read"]), ["ops:read", "ops:write", "inventory:read"]); // env trimmed + extended
assert.deepEqual(names("scheduling:read", ["scheduling:read"]), ["scheduling:read"]); // dedup, no double grant assert.deepEqual(seedPermissions("scheduling:read", ["scheduling:read"]), ["scheduling:read"]); // dedup, no double grant
assert.deepEqual(names(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides) assert.deepEqual(seedPermissions(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
}); });
// Bootstrap gates `web`, so it must never refuse to start over operator env — a leftover test("seedPermissions refuses an ADMIN_PERMISSIONS name that isn't <resource>:<action>", () => {
// ADMIN_PERMISSIONS would otherwise brick the whole stack. Drop what it can't use, report it, seed // The operator's env is the one remaining hand-typed path; a manifest's names were checked at
// the rest. // discovery. `admin` would otherwise write a tuple that gates nothing, with no error anywhere.
test("seedPermissions drops an ADMIN_PERMISSIONS name that isn't <resource>:<action>, and never throws", () => { assert.throws(() => seedPermissions("admin", []), /ADMIN_PERMISSIONS.*<resource>:<action>.*admin/s);
const legacy = seedPermissions("admin", ["users:read"]); assert.throws(() => seedPermissions("users:read,Bad Name", []), /Bad Name/);
assert.deepEqual(legacy, { ignored: ["admin"], permissions: ["users:read"] });
const mixed = seedPermissions("admin, ops:read ,Bad Name", ["users:read"]);
assert.deepEqual(mixed, { ignored: ["admin", "Bad Name"], permissions: ["ops:read", "users:read"] });
// Whatever an operator puts there, the boot survives it — that is the property, not the parsing.
for (const value of ["admin", "Bad Name", ":", "::", "a".repeat(200), ",,,", "ADMIN", "1"]) {
assert.doesNotThrow(() => seedPermissions(value, ["users:read"]), value);
assert.deepEqual(seedPermissions(value, ["users:read"]).permissions.includes("users:read"), true, value);
}
}); });
test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => { test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => {
@@ -154,63 +140,3 @@ test("ensureJwks generates a key only when the file is absent", () => {
assert.equal(ensureJwks(path, { exists: () => true, write }), false); assert.equal(ensureJwks(path, { exists: () => true, write }), false);
assert.equal(writes.length, 1); // present → nothing written assert.equal(writes.length, 1); // present → nothing written
}); });
// --- Plugin storage provisioning -----------------------------------------------------
// The provisioner is injected, so the branch decisions are testable without a Postgres.
const SILENT = createLogger({ level: "none" });
const storagePlugin = (id: string): Plugin => ({ apiVersion: "1.0.0", id, storage: true });
const EMPTY: ProvisionResult = { orphans: [], provisioned: [] };
function recordingProvisioner(result: ProvisionResult = EMPTY) {
const calls: ProvisionOptions[] = [];
return { calls, provision: async (options: ProvisionOptions) => { calls.push(options); return result; } };
}
test("provisioning is skipped entirely when nothing declares storage and none is configured", async () => {
const { calls, provision } = recordingProvisioner();
await provisionPluginStorage({}, [{ apiVersion: "1.0.0", id: "plain" }], SILENT, provision);
assert.deepEqual(calls, []); // no connection attempted, so an unconfigured stack still boots
});
// Uninstalling the last storage plugin is exactly when a left-behind database needs naming.
test("provisioning still runs with nothing to provision, so orphans are reported", async () => {
const { calls, provision } = recordingProvisioner({ orphans: ["plugin_gone"], provisioned: [] });
await provisionPluginStorage({ PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db:5432/ory" }, [], SILENT, provision);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0]?.pluginIds, []);
});
test("a plugin declaring storage without a provisioning DSN fails loud, naming the plugin", async () => {
const { calls, provision } = recordingProvisioner();
await assert.rejects(
provisionPluginStorage({}, [storagePlugin("things")], SILENT, provision),
/PLUGIN_DB_ADMIN_URL.*things/s,
);
assert.deepEqual(calls, []);
});
test("the connection limit and derived secret reach the provisioner", async () => {
const { calls, provision } = recordingProvisioner();
const env = { PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db:5432/ory", PLUGIN_DB_CONNECTION_LIMIT: "25", PLUGIN_DB_SECRET: "real" };
await provisionPluginStorage(env, [storagePlugin("things")], SILENT, provision);
assert.equal(calls[0]?.connectionLimit, 25);
assert.equal(calls[0]?.secret, "real");
assert.deepEqual(calls[0]?.pluginIds, ["things"]);
});
// bootstrap creates the role on one server; web tells the plugin to connect to another. Left
// unsaid it surfaces inside a plugin as "password authentication failed", naming neither. Warned
// rather than refused: web reaching a pooler bootstrap cannot provision through is legitimate.
test("a storage URL mismatch is reported, and provisioning still runs", async () => {
const { calls, provision } = recordingProvisioner();
const env = { PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db-a:5432/ory", PLUGIN_DB_URL: "postgres://db-b:5432" };
await provisionPluginStorage(env, [storagePlugin("things")], SILENT, provision);
assert.equal(calls.length, 1);
});
test("the same server spelled with an implicit port still agrees", () => {
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", "postgres://db"), null); // 5432 is the default
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", undefined), null); // web's own boot error to raise
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", "postgres://db:6543"), "db:5432 vs db:6543");
});
+33 -95
View File
@@ -8,15 +8,10 @@
// Then prints a first-run banner; fails loud on any unexpected upstream error. // 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 { 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 } from "../plugin-host/plugin.ts";
import { provisionStorage } from "../plugin-host/storage-provisioning.ts";
import { storagePluginIds } from "../plugin-host/storage.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts"; import { generateJwks, type JwkSet } from "./gen-jwks.ts";
import { createLogger, runWithLog, tracedFetch, type Log } from "../logger.ts"; import { createLogger, runWithLog, tracedFetch } from "../logger.ts";
type Env = Record<string, string | undefined>;
// --- Pure payload builders (the Kratos/Keto request contracts) ----------------------- // --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
@@ -34,20 +29,21 @@ 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}` };
} }
// ADMIN_PERMISSIONS (empty by default) unioned with every discovered plugin's declared names, so // The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, empty by default)
// the host names no plugin yet a dropped-in one is seeded out of the box. // unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
// // coarse permission — granted as a Keto `Permission:<name>#granted` tuple). So the host names no plugin, yet a
// dropped-in plugin's permissions are seeded out of the box. Deduped, order-stable, blanks dropped.
// The base is empty because permissions are `<resource>:<action>` and every one of them is owned by
// the plugin that gates on it — a host-invented default would gate nothing.
// ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the // ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the
// same `<resource>:<action>` rule as a manifest — but *dropped with a warning*, never fatal: // same `<resource>:<action>` rule discovery applies to a manifest — fail loud rather than write a
// fail-loud belongs at the manifest boundary where a developer authored the mistake, whereas this // tuple that gates nothing. A declared name has already passed that check at discovery.
// is operator env and bootstrap gates `web`, so the whole stack must not refuse to start over a export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): string[] {
// stale variable. The name it would have written gates nothing anyway.
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): { ignored: string[]; permissions: string[] } {
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean); const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
const configured = clean((adminPermissionsEnv ?? "").split(",")); const configured = clean((adminPermissionsEnv ?? "").split(","));
const ignored = configured.filter((name) => !isValidPermissionName(name)); const bad = configured.filter((name) => !isValidPermissionName(name));
const valid = configured.filter((name) => isValidPermissionName(name)); if (bad.length > 0) throw new Error(`bootstrap: ADMIN_PERMISSIONS must be <resource>:<action> names, e.g. "things:read"; got ${bad.join(", ")}`);
return { ignored, permissions: [...new Set([...valid, ...clean(declaredNames)])] }; return [...new Set([...configured, ...clean(declaredNames)])];
} }
// --- JWKS safety net ----------------------------------------------------------------- // --- JWKS safety net -----------------------------------------------------------------
@@ -146,7 +142,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
// --- CLI (the bootstrap container entrypoint) ---------------------------------------- // --- CLI (the bootstrap container entrypoint) ----------------------------------------
async function main() { async function main() {
const env = { ...process.env }; // snapshot: the storage credentials leave process.env before discovery const env = process.env;
// Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME. // Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME.
const log = createLogger({ const log = createLogger({
format: env["LOG_FORMAT"] === "json" ? "json" : "text", format: env["LOG_FORMAT"] === "json" ? "json" : "text",
@@ -155,84 +151,26 @@ async function main() {
// runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls. // runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls.
await runWithLog(log, async () => { await runWithLog(log, async () => {
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) 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
// the credential that may CREATE DATABASE/ROLE. Same move as server.ts, on the stronger secret. // Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
delete process.env["PLUGIN_DB_ADMIN_URL"]; // shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
delete process.env["PLUGIN_DB_SECRET"]; const declared = declaredPermissions(await discoverPlugins()).map((decl) => decl.name);
const plugins = await discoverPlugins(); const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
await provisionPluginStorage(env, plugins, log); const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
await seedAdminAndPermissions(env, plugins, log); const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({
email,
fetchImpl: tracedFetch,
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
password,
permissions,
});
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
}); });
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
} }
// A database and login role for each plugin that asked for one. It happens here because bootstrap
// holds the stack's only provisioning credentials — web derives the same password and connects as
// the plugin's own role.
export async function provisionPluginStorage(env: Env, plugins: Plugin[], log: Log, provision = provisionStorage): Promise<void> {
const ids = storagePluginIds(plugins);
const adminUrl = env["PLUGIN_DB_ADMIN_URL"];
// Still connect with nothing to provision, as long as storage is configured: uninstalling the
// last storage plugin is exactly when an orphaned database needs naming.
if (ids.length === 0 && !adminUrl) return;
if (!adminUrl) throw new Error(`bootstrap: PLUGIN_DB_ADMIN_URL must be set — these plugins declare storage: ${ids.join(", ")}`);
// Provisioned here, connected to from web: a different server means the role is created in one
// place and looked for in another, surfacing inside a plugin as "password authentication failed".
// Warned, not refused — web reaching a pooler that cannot run CREATE DATABASE is a legitimate split.
const mismatch = serverMismatch(adminUrl, env["PLUGIN_DB_URL"]);
if (mismatch) log.warn("PLUGIN_DB_ADMIN_URL and PLUGIN_DB_URL name different servers", { servers: mismatch });
const result = await provision({
adminUrl,
connectionLimit: resolvePluginDbConnectionLimit(env),
pluginIds: ids,
secret: resolvePluginDbSecret(env),
});
if (result.provisioned.length > 0) log.info("plugin storage provisioned", { databases: result.provisioned.join(", ") });
// Never dropped, so an uninstalled plugin's data outlives it — say so, or nobody can find it.
if (result.orphans.length > 0) {
log.warn("plugin databases no installed plugin claims", { databases: result.orphans.join(", ") });
}
}
// Describes the disagreement, or null when they agree (or when web's URL is unset — that is web's
// own boot error to raise, naming the plugin that wanted storage).
export function serverMismatch(adminUrl: string, webUrl: string | undefined): string | null {
if (!webUrl) return null;
const [admin, web] = [safeHostPort(adminUrl), safeHostPort(webUrl)];
if (admin === null || web === null || admin === web) return null; // a malformed URL fails in config.ts
return `${admin} vs ${web}`;
}
function safeHostPort(url: string): string | null {
try {
const parsed = new URL(url);
return `${parsed.hostname}:${parsed.port || "5432"}`;
} catch {
return null;
}
}
// Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
async function seedAdminAndPermissions(env: Env, plugins: Plugin[], log: Log): Promise<void> {
const declared = declaredPermissions(plugins).map((decl) => decl.name);
const { ignored, permissions } = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
if (ignored.length > 0) {
log.warn("ignoring ADMIN_PERMISSIONS entries that are not <resource>:<action>", { ignored: ignored.join(", ") });
}
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({
email,
fetchImpl: tracedFetch,
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
password,
permissions,
});
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
}
if (process.argv[1] === fileURLToPath(import.meta.url)) await main(); if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
+16 -8
View File
@@ -1,12 +1,20 @@
// Optional revocation denylist: instant permission/session revoke without putting Keto back on the // Optional revocation denylist: instant permission/session revoke without putting Keto
// hot path. Off by default — enable with REVOCATION_DENYLIST=true. An admin action records the // back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
// subject as revoked-now; the hot path then rejects that subject's pre-revoke tokens at once,
// forcing a re-mint (which re-reads permissions from Keto, or clears a now-dead session).
// //
// An in-memory, auto-evicting Map — no database, so it stays inside the stateless model. Entries // The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked permission or a
// self-evict after one token TTL, by which point any pre-revoke token has expired anyway. // killed session only takes effect when the token is next minted (re-login / TTL refresh) —
// Single-process: instant on the instance that handled the revoke, elsewhere the guarantee falls // up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
// back to the token TTL. Back it with a shared store for hard multi-instance instant-revoke. // account) that lag is too long. An admin action records the subject as revoked-now and the
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
// re-reads permissions from Keto, or clears a now-dead session).
//
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
// the revoke) passes while every token minted before the revoke is rejected. Entries self-evict
// after one token TTL, by which point any pre-revoke token has expired anyway. Single-process:
// instant on the instance that handled the revoke; across replicas/restarts the guarantee
// falls back to the token TTL (the gap is just no longer closed early). Back it with a shared
// store for hard multi-instance instant-revoke.
export interface Denylist { export interface Denylist {
// Hot-path check: is a token for `sub`, issued at `iat` (unix sec), revoked? A token minted // Hot-path check: is a token for `sub`, issued at `iat` (unix sec), revoked? A token minted
+4 -11
View File
@@ -100,7 +100,9 @@ test("collects oidc nodes as SSO providers (text logo = initial), keeping csrf a
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]); assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
}); });
test("the code field takes a pasted code: one-time-code autofill + numeric inputmode + digits, whitespace around them allowed", () => { test("the code field guards a pasted space: one-time-code autofill + numeric inputmode + digits-only pattern", () => {
// Verification/recovery enter a numeric OTP. Kratos doesn't trim, so a stray pasted space makes it
// reject the code as "invalid"; a digits-only pattern blocks that in the browser before submit.
const view = buildFlowView( const view = buildFlowView(
flow([ flow([
node({ name: "csrf_token", type: "hidden", value: "tok" }), node({ name: "csrf_token", type: "hidden", value: "tok" }),
@@ -111,26 +113,17 @@ test("the code field takes a pasted code: one-time-code autofill + numeric input
); );
assert.deepEqual(view.fields.find((f) => f.name === "code"), { assert.deepEqual(view.fields.find((f) => f.name === "code"), {
autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill
hint: "Digits only.", // the pattern refusal alone reads as a bare "match the requested format"
icon: "i-shield", icon: "i-shield",
id: "field-code", id: "field-code",
inputmode: "numeric", inputmode: "numeric",
label: "Verification code", label: "Verification code",
name: "code", name: "code",
pattern: "\\s*[0-9]*\\s*", pattern: "[0-9]*",
required: true, required: true,
type: "text", type: "text",
}); });
}); });
test("the code flows post to the host, which trims the code; every other flow posts to Kratos", () => {
for (const type of ["recovery", "verification"] as const) assert.equal(buildFlowView(flow([]), type).action, `/${type}?flow=f1`);
for (const type of ["login", "registration", "settings"] as const) {
assert.equal(buildFlowView(flow([]), type).action, "http://127.0.0.1:4433/self-service/login?flow=f1");
}
assert.equal(buildFlowView({ ...flow([]), id: "a&b" }, "recovery").action, "/recovery?flow=a%26b");
});
test("chrome varies per flow type: registration alt, recovery back link", () => { test("chrome varies per flow type: registration alt, recovery back link", () => {
const reg = buildFlowView(flow([]), "registration"); const reg = buildFlowView(flow([]), "registration");
assert.equal(reg.title, "Create account"); assert.equal(reg.title, "Create account");
+8 -16
View File
@@ -1,8 +1,8 @@
// Kratos flow → themed view model. Pure: turns a fetched self-service Flow // Kratos flow → themed view model. Pure: turns a fetched self-service Flow
// (src/auth/kratos-public.ts) into the data views/auth.ejs renders — hidden inputs (incl. the // (src/auth/kratos-public.ts) into the data views/auth.ejs renders — hidden inputs (incl. the
// CSRF token), themed fields, submit buttons, tone-mapped messages, and one SSO button per // CSRF token), themed fields, submit buttons, tone-mapped messages, and one SSO button per
// configured `oidc` provider. The form posts to `flow.ui.action`, except a CODE_FLOWS form, which // configured `oidc` provider. The form posts straight back to `flow.ui.action`, so Kratos
// posts to the host first; Kratos owns the CSRF of both. No providers configured ⇒ no SSO buttons. // owns its CSRF; we only render and map errors. No providers configured ⇒ no SSO buttons.
import { ENGLISH } from "../i18n/english.ts"; import { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts"; import type { Translate } from "../i18n/translate.ts";
@@ -11,13 +11,12 @@ import type { Flow, FlowType, UiNode } from "./kratos-public.ts";
export interface FlowField { export interface FlowField {
autocomplete?: string; autocomplete?: string;
error?: { text: string }; error?: { text: string };
hint?: string; // muted helper text under the input
icon?: string; // Lucide sprite id for the input icon?: string; // Lucide sprite id for the input
id: string; id: string;
inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code) inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code)
label: string; label: string;
name: string; name: string;
pattern?: string; // client-side validity regex pattern?: string; // client-side validity regex; blocks a pasted space before it reaches Kratos
required?: boolean; required?: boolean;
type: string; type: string;
value?: string; value?: string;
@@ -69,9 +68,6 @@ export const AUTH_FLOWS: Record<string, FlowType> = {
"/verification": "verification", "/verification": "verification",
}; };
// The flows that take an emailed one-time code. Their form posts to the host, which trims the code.
export const CODE_FLOWS: ReadonlySet<FlowType> = new Set(["recovery", "verification"]);
// Where each flow's card links; its words come from the catalog under `auth.<flow>.*`. // Where each flow's card links; its words come from the catalog under `auth.<flow>.*`.
const LINKS: Record<FlowType, { alt?: string; back?: boolean }> = { const LINKS: Record<FlowType, { alt?: string; back?: boolean }> = {
login: { alt: "/registration" }, login: { alt: "/registration" },
@@ -126,9 +122,9 @@ const ssoLogo = (value: string): string => (value.charAt(0) || "?").toUpperCase(
function toField(node: UiNode, name: string, type: string, t: Translate): FlowField { function toField(node: UiNode, name: string, type: string, t: Translate): FlowField {
const value = str(node.attributes["value"]); const value = str(node.attributes["value"]);
// The recovery/verification one-time code is numeric: a numeric keypad and a digits pattern. The // The recovery/verification one-time code: numeric, and Kratos doesn't trim it, so a stray pasted
// pattern allows whitespace around the digits, as a paste brings; routes.ts → codeFlowSubmit trims it. // space makes it reject the code as "invalid". A digits-only pattern + numeric keypad block that in
// one-time-code enables OS/email autofill (Kratos sends no autocomplete for the node). // the browser; one-time-code enables OS/email autofill (Kratos sends no autocomplete for the node).
const isCode = name === "code"; const isCode = name === "code";
const autocomplete = str(node.attributes["autocomplete"]) ?? (isCode ? "one-time-code" : undefined); const autocomplete = str(node.attributes["autocomplete"]) ?? (isCode ? "one-time-code" : undefined);
const icon = iconFor(name, type); const icon = iconFor(name, type);
@@ -143,16 +139,12 @@ function toField(node: UiNode, name: string, type: string, t: Translate): FlowFi
...(autocomplete ? { autocomplete } : {}), ...(autocomplete ? { autocomplete } : {}),
...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}), ...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}),
...(icon ? { icon } : {}), ...(icon ? { icon } : {}),
...(isCode ? { hint: t("auth.field.code.hint"), inputmode: "numeric", pattern: "\\s*[0-9]*\\s*" } : {}), ...(isCode ? { inputmode: "numeric", pattern: "[0-9]*" } : {}),
...(node.attributes["required"] === true ? { required: true } : {}), ...(node.attributes["required"] === true ? { required: true } : {}),
...(value ? { value } : {}), ...(value ? { value } : {}),
}; };
} }
function formAction(flow: Flow, type: FlowType): string {
return CODE_FLOWS.has(type) ? `/${type}?flow=${encodeURIComponent(flow.id)}` : flow.ui.action;
}
export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH): FlowView { export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH): FlowView {
const hidden: { name: string; value: string }[] = []; const hidden: { name: string; value: string }[] = [];
const fields: FlowField[] = []; const fields: FlowField[] = [];
@@ -181,7 +173,7 @@ export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH
} }
return { return {
action: formAction(flow, type), action: flow.ui.action,
buttons, buttons,
fields, fields,
hidden, hidden,
-28
View File
@@ -1,28 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { User } from "../http/context.ts";
import { allows, gatesSet } from "./gate.ts";
const holder: User = { email: "holder@example.test", id: "01a06091-ba9f-765f-abf4-b5144c314bc7", permissions: ["x:read"] };
const stranger: User = { email: "stranger@example.test", id: "01a06091-baa3-7b4d-810a-c9ee7e559d98", permissions: [] };
test("allows: ungated and public are open to anyone; session needs a user; permission needs the token", () => {
assert.equal(allows({}, null), true);
assert.equal(allows({ public: true }, null), true);
assert.equal(allows({ session: true }, null), false);
assert.equal(allows({ session: true }, stranger), true); // signed in is the whole gate — no grant
assert.equal(allows({ permission: "x:read" }, null), false);
assert.equal(allows({ permission: "x:read" }, stranger), false);
assert.equal(allows({ permission: "x:read" }, holder), true);
});
test("gatesSet names the gates a declaration sets, so discovery can refuse more than one", () => {
assert.deepEqual(gatesSet({}), []);
assert.deepEqual(gatesSet({ session: true }), ["session"]);
assert.deepEqual(gatesSet({ permission: "x:read", public: true }), ["public", "permission"]);
assert.deepEqual(gatesSet({ permission: "x:read", public: true, session: true }), ["public", "session", "permission"]);
// Only `true` sets a gate, so a manifest spelling one `false` names none — which discovery refuses.
assert.deepEqual(gatesSet({ public: false, session: false }), []);
});
-22
View File
@@ -1,22 +0,0 @@
// One home for the gate rule, so the router and the menu can never disagree about what a visitor
// may reach. README → Public pages & menu items.
import type { User } from "../http/context.ts";
const GATES = ["public", "session", "permission"] as const;
export interface Gate {
permission?: string; // the Keto Permission the caller must hold, `<resource>:<action>`
public?: boolean; // anyone, signed in or not
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
}
export function allows(gate: Gate, user: User | null): boolean {
if (gate.public === true) return true;
if (gate.session === true) return user !== null;
return gate.permission == null || (user?.permissions.includes(gate.permission) ?? false);
}
export function gatesSet(gate: Gate | null | undefined): string[] {
if (gate == null) return [];
return GATES.filter((name) => (name === "permission" ? gate.permission != null : gate[name] === true));
}
+5 -3
View File
@@ -1,6 +1,8 @@
// In-handler authorization, the imperative counterpart to the declarative route `permission` gate. // Auth guards: in-handler authorization, the imperative counterpart to the
// `requireSession` asserts (throws GuardError, which app.ts maps to a response); `can`/`check` are // declarative route `permission` gate. The middleware already verified the session JWT and put
// predicates a handler branches on. `check` is the one live Keto call, for relationship rules. // the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
import type { RequestContext, User } from "../http/context.ts"; import type { RequestContext, User } from "../http/context.ts";
import type { KetoClient } from "./keto-client.ts"; import type { KetoClient } from "./keto-client.ts";
import { localPath } from "../http/safe-url.ts"; import { localPath } from "../http/safe-url.ts";
+2 -2
View File
@@ -100,9 +100,9 @@ test("updateMetadataPublic PATCHes a JSON-Patch `add /metadata_public` so it nev
}); });
test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => { test("createRecoveryCode POSTs the identity id to /admin/recovery/code → { code, link }", async () => {
const { calls, fetchImpl } = recorder(() => res(200, { recovery_code: "123456", recovery_link: "http://kratos/self-service/recovery?flow=f" })); const { calls, fetchImpl } = recorder(() => res(200, { recovery_code: "123456", recovery_link: "http://kratos/self-service/recovery?flow=f&code=123456" }));
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).createRecoveryCode(ID); const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).createRecoveryCode(ID);
assert.deepEqual(out, { code: "123456", link: "http://kratos/self-service/recovery?flow=f" }); assert.deepEqual(out, { code: "123456", link: "http://kratos/self-service/recovery?flow=f&code=123456" });
assert.equal(calls[0]!.method, "POST"); assert.equal(calls[0]!.method, "POST");
assert.match(calls[0]!.url, /\/admin\/recovery\/code$/); assert.match(calls[0]!.url, /\/admin\/recovery\/code$/);
assert.deepEqual(JSON.parse(calls[0]!.body!), { identity_id: ID }); assert.deepEqual(JSON.parse(calls[0]!.body!), { identity_id: ID });
+3 -3
View File
@@ -25,7 +25,7 @@ export interface ListOptions {
pageToken?: string; pageToken?: string;
} }
// A one-time recovery code + the URL of the recovery flow it was minted on, the only flow that accepts it. // A one-time recovery code + the self-service link wrapping it (admin "trigger recovery").
export interface RecoveryCode { export interface RecoveryCode {
code: string; code: string;
link: string; link: string;
@@ -65,8 +65,8 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
return (await res.json()) as Identity; return (await res.json()) as Identity;
}, },
// Mint a recovery code for an identity (admin "trigger recovery"). Kratos mails nothing, so the // Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the
// operator hands the code and link to the user. // user by Kratos; the code/link are also returned so an operator can hand them over directly.
async createRecoveryCode(userId, opts = {}) { async createRecoveryCode(userId, opts = {}) {
const body: Record<string, unknown> = { identity_id: userId }; const body: Record<string, unknown> = { identity_id: userId };
if (opts.expiresIn) body.expires_in = opts.expiresIn; if (opts.expiresIn) body.expires_in = opts.expiresIn;
+26 -20
View File
@@ -1,6 +1,6 @@
// Kratos public-API client: typed fetch wrappers over Ory Kratos' public endpoints. // Kratos public-API client: typed fetch wrappers over Ory Kratos' public endpoints.
// Guards the request contracts (URLs, JSON-accept, cookie relay) and the result mapping // Guards the request contracts (URLs, JSON-accept, cookie relay) and the result mapping
// (200/401/4xx, a submit's redirect, tokenized JWT). Live wiring is verified by the // (200/401/4xx, validation-flow vs success, tokenized JWT). Live wiring is verified by the
// flow pages; these catch contract drift with a mock fetch. // flow pages; these catch contract drift with a mock fetch.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
@@ -17,13 +17,12 @@ function res(status: number, body?: unknown, setCookie: string[] = []): Response
// Records each call so a test can assert URL/method/headers/body. // Records each call so a test can assert URL/method/headers/body.
function recorder(handler: (url: string, init: RequestInit | undefined) => Response) { function recorder(handler: (url: string, init: RequestInit | undefined) => Response) {
const calls: { body: string | undefined; headers: Headers; method: string; redirect: string | undefined; url: string }[] = []; const calls: { body: string | undefined; headers: Headers; method: string; url: string }[] = [];
const fetchImpl = (async (input: unknown, init?: RequestInit) => { const fetchImpl = (async (input: unknown, init?: RequestInit) => {
calls.push({ calls.push({
body: init?.body as string | undefined, body: init?.body as string | undefined,
headers: new Headers(init?.headers), headers: new Headers(init?.headers),
method: init?.method ?? "GET", method: init?.method ?? "GET",
redirect: init?.redirect,
url: String(input), url: String(input),
}); });
return handler(String(input), init); return handler(String(input), init);
@@ -58,28 +57,35 @@ test("getFlow throws a KratosError carrying the status when the flow is gone (41
); );
}); });
test("submitFlow POSTs the form to the flow on Kratos' own base URL, as a browser would, and relays the redirect + Set-Cookie", async () => { test("submitFlow POSTs urlencoded to the action and reports success + relays Set-Cookie", async () => {
const { calls, fetchImpl } = recorder(() => new Response(null, { const { calls, fetchImpl } = recorder(() => res(200, { session: { active: true } }, ["plainpages_session=s; Path=/"]));
headers: new Headers([["location", "http://app/settings?flow=s1"], ["set-cookie", "plainpages_session=s; Path=/"]]),
status: 303,
}));
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }) const out = await createKratosPublic({ baseUrl: BASE, fetchImpl })
.submitFlow("recovery", "f 1", { body: "code=123456&method=code", cookie: "csrf_token=abc" }); .submitFlow(`${BASE}/self-service/login?flow=f`, { body: "identifier=a&password=b", cookie: "csrf_token=abc" });
assert.deepEqual(out, { location: "http://app/settings?flow=s1", setCookie: ["plainpages_session=s; Path=/"], status: 303 }); assert.equal(out.ok, true);
assert.equal(calls[0]!.url, `${BASE}/self-service/recovery?flow=f+1`); assert.equal(out.status, 200);
assert.deepEqual(out.setCookie, ["plainpages_session=s; Path=/"]);
assert.equal(calls[0]!.method, "POST"); assert.equal(calls[0]!.method, "POST");
assert.equal(calls[0]!.redirect, "manual");
assert.equal(calls[0]!.headers.get("accept"), "text/html");
assert.equal(calls[0]!.headers.get("referer"), `${BASE}/`);
assert.equal(calls[0]!.headers.get("content-type"), "application/x-www-form-urlencoded"); assert.equal(calls[0]!.headers.get("content-type"), "application/x-www-form-urlencoded");
assert.equal(calls[0]!.headers.get("cookie"), "csrf_token=abc"); assert.equal(calls[0]!.body, "identifier=a&password=b");
assert.equal(calls[0]!.body, "code=123456&method=code");
}); });
test("submitFlow reports a 4xx without a redirect as a null location, and throws on a Kratos 5xx", async () => { test("submitFlow returns the re-rendered flow (no throw) on a 400 validation error", async () => {
const k = (status: number) => createKratosPublic({ baseUrl: BASE, fetchImpl: recorder(() => res(status, { error: "x" })).fetchImpl }); const flow = { id: "f", ui: { action: "x", messages: [{ id: 4000006, text: "invalid credentials", type: "error" }], method: "POST", nodes: [] } };
assert.deepEqual(await k(400).submitFlow("verification", "f", { body: "x=1" }), { location: null, setCookie: [], status: 400 }); const { fetchImpl } = recorder(() => res(400, flow));
await assert.rejects(k(500).submitFlow("verification", "f", { body: "x=1" }), (e: unknown) => e instanceof KratosError && e.status === 500); const out = await createKratosPublic({ baseUrl: BASE, fetchImpl }).submitFlow(`${BASE}/x`, { body: "x=1" });
assert.equal(out.ok, false);
assert.equal(out.status, 400);
assert.deepEqual(out.body, flow);
});
test("submitFlow surfaces the redirect target — Location header or a 422 redirect_browser_to body", async () => {
const k = (handler: () => Response) => createKratosPublic({ baseUrl: BASE, fetchImpl: recorder(handler).fetchImpl });
const viaHeader = await k(() => new Response(null, { headers: new Headers({ location: "http://app/" }), status: 303 }))
.submitFlow(`${BASE}/x`, { body: "x=1" });
assert.equal(viaHeader.location, "http://app/");
const viaBody = await k(() => res(422, { redirect_browser_to: "http://app/login?flow=next" }))
.submitFlow(`${BASE}/x`, { body: "x=1" });
assert.equal(viaBody.location, "http://app/login?flow=next");
}); });
test("whoami returns the session on 200 (cookie forwarded) and null on 401", async () => { test("whoami returns the session on 200 (cookie forwarded) and null on 401", async () => {
+28 -13
View File
@@ -21,7 +21,7 @@ export interface UiNode {
} }
export interface FlowUi { export interface FlowUi {
action: string; // absolute Kratos URL the form submits to action: string; // absolute Kratos URL the browser POSTs the form to (Kratos owns its CSRF)
messages?: UiText[]; messages?: UiText[];
method: string; method: string;
nodes: UiNode[]; nodes: UiNode[];
@@ -51,7 +51,9 @@ export interface LogoutFlow {
} }
export interface FlowSubmission { export interface FlowSubmission {
location: string | null; // where Kratos sends the browser next body: unknown; // parsed JSON: the re-rendered flow on 400, the success payload on 200
location: string | null; // redirect target (Location header, or a 422 redirect_browser_to)
ok: boolean; // status === 200
setCookie: string[]; setCookie: string[];
status: number; status: number;
} }
@@ -72,10 +74,23 @@ export interface KratosPublic {
createLogoutFlow(opts?: { cookie?: string }): Promise<LogoutFlow | null>; // null ⇒ no active session (401) createLogoutFlow(opts?: { cookie?: string }): Promise<LogoutFlow | null>; // null ⇒ no active session (401)
getFlow(type: FlowType, id: string, opts?: { cookie?: string }): Promise<Flow>; getFlow(type: FlowType, id: string, opts?: { cookie?: string }): Promise<Flow>;
initBrowserFlow(type: FlowType, opts?: { cookie?: string; returnTo?: string }): Promise<FlowInit>; initBrowserFlow(type: FlowType, opts?: { cookie?: string; returnTo?: string }): Promise<FlowInit>;
submitFlow(type: FlowType, flowId: string, opts: { body: string; cookie?: string }): Promise<FlowSubmission>; submitFlow(action: string, opts: { body: string; contentType?: string; cookie?: string }): Promise<FlowSubmission>;
whoami(opts?: { cookie?: string; tokenizeAs?: string }): Promise<Session | null>; whoami(opts?: { cookie?: string; tokenizeAs?: string }): Promise<Session | null>;
} }
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function parseBody(text: string): unknown {
if (!text) return null;
try {
return JSON.parse(text);
} catch {
return text;
}
}
export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosPublic { export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosPublic {
const base = config.baseUrl.replace(/\/+$/, ""); const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch; const http = config.fetchImpl ?? fetch;
@@ -114,16 +129,16 @@ export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof
return (await res.json()) as Flow; return (await res.json()) as Flow;
}, },
async submitFlow(type, flowId, opts) { async submitFlow(action, opts) {
const url = new URL(`${base}/self-service/${type}`); const h = headers(opts.cookie);
url.searchParams.set("flow", flowId); h["content-type"] = opts.contentType ?? "application/x-www-form-urlencoded";
// Asked for HTML, Kratos answers with the redirect a browser posting to it would get. Its CSRF // Manual redirect so we can read a 303 Location instead of following it server-side.
// check demands a same-origin Referer on an https request, and this server-side POST has none. const res = await http(action, { body: opts.body, headers: h, method: "POST", redirect: "manual" });
const h = { ...headers(opts.cookie), accept: "text/html", "content-type": "application/x-www-form-urlencoded", referer: `${base}/` }; const body = parseBody(await res.text());
const res = await http(url, { body: opts.body, headers: h, method: "POST", redirect: "manual" }); const location =
if (res.status >= 500) throw new KratosError(`Kratos submit ${type} flow failed (${res.status})`, res.status, await res.text()); res.headers.get("location") ??
await res.body?.cancel(); (isRecord(body) && typeof body["redirect_browser_to"] === "string" ? body["redirect_browser_to"] : null);
return { location: res.headers.get("location"), setCookie: res.headers.getSetCookie(), status: res.status }; return { body, location, ok: res.status === 200, setCookie: res.headers.getSetCookie(), status: res.status };
}, },
async whoami(opts = {}) { async whoami(opts = {}) {
+2 -14
View File
@@ -92,24 +92,12 @@ test("completeLogin returns null and touches nothing when there is no active ses
assert.equal(touched, false); assert.equal(touched, false);
}); });
test("completeLogin throws if the tokenizer yields no JWT", async () => { test("completeLogin maps a missing email trait to null and throws if the tokenizer yields no JWT", async () => {
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } }; const identity: Identity = { id: ID, traits: {} };
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity }) as Session }); // never returns a tokenized JWT const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity }) as Session }); // never returns a tokenized JWT
await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/); await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/);
}); });
// An identity with no email is no session, decided here so /auth/complete and remintSession cannot
// disagree: `claimsToUser` reads a token carrying none as anonymous, so minting one would hand the
// browser a cookie every later request refuses.
test("completeLogin refuses an identity carrying no email, before it mints anything", async () => {
const identity: Identity = { id: ID, traits: {} };
let touched = false;
const kratosAdmin = adminStub({ updateMetadataPublic: async () => { touched = true; return { id: ID }; } });
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity, tokenized: "h.p.s" }) as Session });
assert.equal(await completeLogin({ keto: ketoStub(), kratosAdmin, kratosPublic }, "c"), null);
assert.equal(touched, false); // no Keto read, no metadata write, no JWT
});
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => { test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } }; const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session }); const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
+3 -9
View File
@@ -31,7 +31,7 @@ export interface LoginDeps {
} }
export interface CompletedLogin { export interface CompletedLogin {
email: string; email: string | null;
userId: string; userId: string;
jwt: string; jwt: string;
permissions: string[]; permissions: string[];
@@ -61,13 +61,7 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
if (!session?.identity) return null; if (!session?.identity) return null;
const userId = session.identity.id; const userId = session.identity.id;
const emailTrait = session.identity.traits?.["email"]; const emailTrait = session.identity.traits?.["email"];
const email = typeof emailTrait === "string" ? emailTrait : ""; const email = typeof emailTrait === "string" ? emailTrait : null;
// No email is no session: `claimsToUser` reads a token carrying none as anonymous, so minting one
// would hand the browser a cookie every later request refuses.
if (!email) {
currentLog()?.warn("session dropped: identity has no email", { sub: userId });
return null;
}
const permissions = await readPermissions(deps.keto, userId); const permissions = await readPermissions(deps.keto, userId);
await deps.kratosAdmin.updateMetadataPublic(userId, { permissions }); await deps.kratosAdmin.updateMetadataPublic(userId, { permissions });
@@ -93,7 +87,7 @@ export interface Reminted {
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> { export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
const completed = await completeLogin(deps, cookie); const completed = await completeLogin(deps, cookie);
if (!completed) return { setCookie: clearSessionCookie(options), user: null }; if (!completed) return { setCookie: clearSessionCookie(options), user: null };
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email, id: completed.userId, permissions: completed.permissions } }; return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.userId, permissions: completed.permissions } };
} }
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is // Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
+3 -10
View File
@@ -1,10 +1,9 @@
// buildAuthRoutes registers only the endpoints the wired clients support — a missing client // buildAuthRoutes registers only the endpoints the wired clients support — a missing client
// means the path isn't in the table at all (the host then 404s). The endpoints' behaviour is // means the path isn't in the table at all (the host then 404s). The endpoints' behaviour is
// covered end-to-end through the app in src/http/app-auth.test.ts. // covered end-to-end through the app in src/http/app.test.ts.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { AUTH_FLOWS } from "./flow-view.ts"; import { AUTH_FLOWS } from "./flow-view.ts";
import { gatesSet } from "./gate.ts";
import type { HydraAdmin } from "./hydra-admin.ts"; import type { HydraAdmin } from "./hydra-admin.ts";
import type { KetoClient } from "./keto-client.ts"; import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts"; import type { KratosAdmin } from "./kratos-admin.ts";
@@ -26,14 +25,12 @@ test("nothing wired ⇒ only the flow-error sink exists", () => {
assert.deepEqual(keys(buildAuthRoutes(deps({}))), ["GET /error"]); assert.deepEqual(keys(buildAuthRoutes(deps({}))), ["GET /error"]);
}); });
test("kratos wired ⇒ the themed flow pages, the two code-flow submits + POST /logout; no OAuth2, no /auth/complete", () => { test("kratos wired ⇒ the themed flow pages + POST /logout; no OAuth2, no /auth/complete", () => {
const got = keys(buildAuthRoutes(deps({ kratos }))); const got = keys(buildAuthRoutes(deps({ kratos })));
assert.deepEqual(got, keys([ assert.deepEqual(got, keys([
...Object.keys(AUTH_FLOWS).map((path) => ({ method: "GET", path })), ...Object.keys(AUTH_FLOWS).map((path) => ({ method: "GET", path })),
{ method: "GET", path: "/error" }, { method: "GET", path: "/error" },
{ method: "POST", path: "/logout" }, { method: "POST", path: "/logout" },
{ method: "POST", path: "/recovery" },
{ method: "POST", path: "/verification" },
])); ]));
}); });
@@ -42,12 +39,8 @@ test("hydra alone ⇒ only RP-initiated logout of the OAuth2 group (login/consen
}); });
test("everything wired ⇒ the full group: OAuth2 challenges, consent GET+POST, /auth/complete", () => { test("everything wired ⇒ the full group: OAuth2 challenges, consent GET+POST, /auth/complete", () => {
const routes = buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin })); const got = keys(buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin })));
const got = keys(routes);
for (const key of ["GET /auth/complete", "GET /login", "GET /oauth2/consent", "GET /oauth2/login", "GET /oauth2/logout", "POST /logout", "POST /oauth2/consent"]) { for (const key of ["GET /auth/complete", "GET /login", "GET /oauth2/consent", "GET /oauth2/login", "GET /oauth2/logout", "POST /logout", "POST /oauth2/consent"]) {
assert.ok(got.includes(key), key); assert.ok(got.includes(key), key);
} }
// Discovery enforces exactly one gate per plugin declaration; nothing checks the host's own table
// at boot, so a route added here without a gate would be silently public.
for (const route of routes) assert.deepEqual(gatesSet(route), ["public"], `${route.method} ${route.path}`);
}); });
+54 -89
View File
@@ -1,5 +1,5 @@
// 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-routes.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, 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.
@@ -8,11 +8,11 @@ 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";
import { AUTH_FLOWS, buildFlowView, CODE_FLOWS } from "./flow-view.ts"; import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
import { HydraError, type HydraAdmin } from "./hydra-admin.ts"; import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
import type { KetoClient } from "./keto-client.ts"; import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts"; import type { KratosAdmin } from "./kratos-admin.ts";
import { type Flow, type FlowSubmission, type FlowType, KratosError, type KratosPublic } from "./kratos-public.ts"; import { type Flow, type FlowType, KratosError, type KratosPublic } from "./kratos-public.ts";
import { clearSessionCookie, completeLogin, sessionCookie } from "./login.ts"; import { clearSessionCookie, completeLogin, sessionCookie } from "./login.ts";
import type { MenuConfig } from "../ui/menu-config.ts"; import type { MenuConfig } from "../ui/menu-config.ts";
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts"; import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
@@ -38,55 +38,11 @@ const FORBIDDEN: RouteResult = { status: 403, view: "403" };
const selfOrigin = (ctx: RequestContext, secure: boolean): string => const selfOrigin = (ctx: RequestContext, secure: boolean): string =>
`${secure ? "https" : "http"}://${ctx.req.headers.host ?? "127.0.0.1:3000"}`; `${secure ? "https" : "http"}://${ctx.req.headers.host ?? "127.0.0.1:3000"}`;
// Where Kratos lands the browser after sign-in, or undefined for its default. A host-relative
// return_to (the gate's deep link) or a chosen language goes through /auth/complete, which mints the
// session JWT first and carries the language across Kratos; any other target (the OAuth2 login
// challenge) is passed as-is, since Kratos allow-lists it.
function flowReturnTo(ctx: RequestContext, secureCookies: boolean): string | undefined {
const raw = ctx.url.searchParams.get("return_to");
const local = localPath(raw);
const chosen = chosenLocale(ctx);
if (raw && !local) return raw; // beside a chosen language too: the target wins, the language is lost
if (!local && !chosen) return undefined;
const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`);
if (local) complete.searchParams.set("return_to", ctx.localeHref(local));
if (chosen) complete.searchParams.set("locale", chosen);
return complete.toString();
}
// What a failed Kratos call returns instead of the flow; rethrows what is genuinely unexpected.
function flowFailure(err: unknown, ctx: RequestContext, flowType: FlowType): RouteResult {
const pathname = ctx.url.pathname;
// Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname };
// No Kratos session on a page that needs one. /settings is public, since recovery lands there with
// a Kratos session but no app JWT, so the gate never sends an anonymous visitor to sign in.
// Not for /login itself, which would redirect to itself.
if (err instanceof KratosError && err.status === 401 && flowType !== "login") {
return { redirect: `/login?return_to=${encodeURIComponent(pathname)}` };
}
// Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user
// is null and the "already signed in" short-circuit can't fire). Initialising a login/
// registration flow then returns Kratos 400 `session_already_available`. Recover by completing
// login (mint the JWT from the live session), honouring return_to — never a 500.
if (err instanceof KratosError && err.status === 400 && err.body.includes("session_already_available")) {
const local = localPath(ctx.url.searchParams.get("return_to"));
return { redirect: local ? `/auth/complete?return_to=${encodeURIComponent(local)}` : "/auth/complete" };
}
// Ory unreachable (Kratos 5xx / connection refused / timeout): "Ory down ⇒ no logins" is
// documented, so render an honest 503 rather than the catch-all "error on our end" 500.
if (!(err instanceof KratosError) || err.status >= 500) {
ctx.log.warn("auth flow failed (Ory unreachable?)", { error: String(err), path: pathname });
return { status: 503, view: "503" };
}
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
}
// Themed Kratos self-service page (one route per AUTH_FLOWS entry: login/registration/recovery/ // Themed Kratos self-service page (one route per AUTH_FLOWS entry: login/registration/recovery/
// verification/settings). // verification/settings).
function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] { function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] {
return async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult> => { return async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult> => {
const pathname = ctx.url.pathname;
// Already signed in? Re-authenticating / re-registering is pointless — send them to the app // Already signed in? Re-authenticating / re-registering is pointless — send them to the app
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.) // dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
if (ctx.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" }; if (ctx.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
@@ -98,14 +54,49 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
try { try {
if (!flowId) { if (!flowId) {
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>. // No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
const returnTo = flowReturnTo(ctx, secureCookies); // A `return_to` is baked into the flow so Kratos lands there after login instead of the
// default completion route. A first-party deep link (host-relative, from the gate's
// return_to) is wrapped through /auth/complete so the session JWT is minted before the
// user reaches the page; an absolute target (the OAuth2 login challenge) is passed
// as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com".
const raw = ctx.url.searchParams.get("return_to");
const local = localPath(raw);
const chosen = chosenLocale(ctx);
let returnTo: string | undefined;
if (local || chosen) {
// The flow's return target is the host's, not Kratos' — so the language the visitor picked
// on the sign-in page survives the round-trip through Kratos and lands on the page after
// it. Without this the most-travelled path in the product (pick Swedish → sign in) drops
// straight back to Accept-Language.
const complete = new URL(`${selfOrigin(ctx, secureCookies)}/auth/complete`);
if (local) complete.searchParams.set("return_to", ctx.localeHref(local));
if (chosen) complete.searchParams.set("locale", chosen);
returnTo = complete.toString();
} else if (raw) returnTo = raw;
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) }); const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
if (setCookie.length) ctx.res.appendHeader("set-cookie", setCookie); if (setCookie.length) ctx.res.appendHeader("set-cookie", setCookie);
return { redirect: `${ctx.url.pathname}?flow=${initiated.id}` }; return { redirect: `${pathname}?flow=${initiated.id}` };
} }
flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {}); flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {});
} catch (err) { } catch (err) {
return flowFailure(err, ctx, flowType); // Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) return { redirect: pathname };
// Already authenticated at Kratos but no app JWT yet (e.g. straight after registration, whose
// `session` hook signs the user in but routes to verification, not /auth/complete — so ctx.user
// is null and the "already signed in" short-circuit above can't fire). Initialising a login/
// registration flow then returns Kratos 400 `session_already_available`. Recover by completing
// login (mint the JWT from the live session), honouring return_to — never a 500.
if (err instanceof KratosError && err.status === 400 && err.body.includes("session_already_available")) {
const local = localPath(ctx.url.searchParams.get("return_to"));
return { redirect: local ? `/auth/complete?return_to=${encodeURIComponent(local)}` : "/auth/complete" };
}
// Ory unreachable (Kratos 5xx / connection refused / timeout): "Ory down ⇒ no logins" is
// documented, so render an honest 503 rather than the catch-all "error on our end" 500.
if (!(err instanceof KratosError) || err.status >= 500) {
ctx.log.warn("auth flow failed (Ory unreachable?)", { error: String(err), path: pathname });
return { status: 503, view: "503" };
}
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
} }
// Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the // Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token. // shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
@@ -114,33 +105,6 @@ 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
// 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
// isn't checked here.
function codeFlowSubmit(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] {
return async (ctx: RequestContext): Promise<RouteResult> => {
const pathname = ctx.url.pathname;
const flowId = ctx.url.searchParams.get("flow");
if (!flowId) return { redirect: pathname };
const form = await readFormBody(ctx.req);
const code = form.get("code");
if (code !== null) form.set("code", code.trim());
const cookie = ctx.req.headers.cookie;
let submitted: FlowSubmission;
try {
submitted = await kratos.submitFlow(flowType, flowId, { body: form.toString(), ...(cookie ? { cookie } : {}) });
} catch (err) {
return flowFailure(err, ctx, flowType);
}
if (submitted.setCookie.length) ctx.res.appendHeader("set-cookie", submitted.setCookie);
// Kratos names our own pages by absolute URL; made host-relative, the redirect keeps a chosen locale.
const own = selfOrigin(ctx, secureCookies);
const location = submitted.location?.startsWith(`${own}/`) ? submitted.location.slice(own.length) : submitted.location;
return { redirect: location ?? `${pathname}?flow=${encodeURIComponent(flowId)}` };
};
}
// OAuth2 login challenge: Hydra hands the browser here when another app logs in *through* us. // OAuth2 login challenge: Hydra hands the browser here when another app logs in *through* us.
// Resolve it via the Kratos session and accept; an unauthenticated user bounces to our themed // Resolve it via the Kratos session and accept; an unauthenticated user bounces to our themed
// login and returns here once signed in. Provider-only. // login and returns here once signed in. Provider-only.
@@ -267,8 +231,10 @@ function logout(kratos: KratosPublic, secureCookies: boolean): BuiltinRoute["han
} }
// Kratos' self-service error sink (kratos.yml flows.error.ui_url → /error). A flow that fails a // Kratos' self-service error sink (kratos.yml flows.error.ui_url → /error). A flow that fails a
// security/expiry check redirects the browser here with ?id=<uuid>; render a themed page with a // security/expiry check redirects the browser here with ?id=<uuid>. Render a themed page with a
// path back into sign-in rather than the catch-all 404. The id is shown for support reference only. // path back into sign-in instead of the catch-all 404 ("Page not found") it used to hit. The
// canonical-host redirect prevents the common cause (a lost cross-host CSRF cookie); this is the
// honest fallback for any genuine flow error. The id is shown only for support reference.
const errorSink = (ctx: RequestContext): RouteResult => const errorSink = (ctx: RequestContext): RouteResult =>
({ data: { id: ctx.url.searchParams.get("id") }, view: "error" }); ({ data: { id: ctx.url.searchParams.get("id") }, view: "error" });
@@ -276,21 +242,20 @@ export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secure
const routes: BuiltinRoute[] = []; const routes: BuiltinRoute[] = [];
if (kratos) { if (kratos) {
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) { for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path, public: true }); routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path });
if (CODE_FLOWS.has(flowType)) routes.push({ handler: codeFlowSubmit(kratos, flowType, secureCookies), method: "POST", path, public: true });
} }
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout", public: true }); routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout" });
} }
if (hydra && kratos) { if (hydra && kratos) {
const provider = { hydra, kratos }; const provider = { hydra, kratos };
routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login", public: true }); routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login" });
routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent", public: true }); routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent" });
routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent", public: true }); routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent" });
} }
if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout", public: true }); if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout" });
if (kratos && kratosAdmin && keto) { if (kratos && kratosAdmin && keto) {
routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete", public: true }); routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete" });
} }
routes.push({ handler: errorSink, method: "GET", path: "/error", public: true }); routes.push({ handler: errorSink, method: "GET", path: "/error" });
return routes; return routes;
} }
-13
View File
@@ -29,19 +29,6 @@ test("the commit-hash image is pushed even when the gate no-ops", () => {
assert.doesNotMatch(step("docker push"), /^\s*if:/m); assert.doesNotMatch(step("docker push"), /^\s*if:/m);
}); });
test("every E2E runner container the gate starts runs as the invoking user", () => {
// The documented hand-run commands carry the same flag (src/compose.test.ts).
const runs = gate.split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
assert.equal(runs.length, 3, "the e2e-tests typecheck, the suite helper and the devstack run");
for (const line of runs)
assert.match(line, /--user "\$\(id -u\):\$\(id -g\)"/, `runs as the caller: ${line.trim()}`);
});
test("the gate typechecks e2e-tests in the runner image, where @playwright/test resolves", () => {
assert.match(gate, /docker compose .*\brun\b.*--no-deps\b.*\be2e\b npm run typecheck/);
assert.match(read("e2e-tests/package.json"), /"typecheck": "tsc --noEmit"/);
});
test("only *.md counts as docs; a dirty tree and a rename both count as changed", () => { test("only *.md counts as docs; a dirty tree and a rename both count as changed", () => {
assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test"); assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test");
assert.match(gate, /git status --porcelain --no-renames/, "uncommitted code and a staged rename can never be skipped over"); assert.match(gate, /git status --porcelain --no-renames/, "uncommitted code and a staged rename can never be skipped over");
+4 -55
View File
@@ -6,13 +6,9 @@
// by running the stack; this catches edits. // by running the stack; this catches edits.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { existsSync, readFileSync, readdirSync } from "node:fs"; import { readFileSync } from "node:fs";
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8"); const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
const composeFiles = (dir: string) =>
readdirSync(new URL(`../${dir}`, import.meta.url))
.filter((f) => f.startsWith("compose.") && f.endsWith(".yml"))
.map((f) => `${dir}${f}`);
const compose = read("compose.yml"); const compose = read("compose.yml");
const override = read("compose.override.yml"); const override = read("compose.override.yml");
const visual = read("e2e-tests/compose.visual.yml"); const visual = read("e2e-tests/compose.visual.yml");
@@ -44,11 +40,10 @@ test("long-running Ory services declare readiness healthchecks", () => {
`${svc} probes :${port}/health/ready`); `${svc} probes :${port}/health/ready`);
}); });
test("web waits for kratos, keto, hydra and postgres to be healthy before starting", () => { test("web waits for kratos, keto and hydra to be healthy before starting", () => {
assert.match(webBlock, /depends_on:/, "web declares dependencies"); assert.match(webBlock, /depends_on:/, "web declares dependencies");
// hydra: the OAuth2 login/consent handler talks to its admin API. postgres: a plugin declaring // hydra: the OAuth2 login/consent handler talks to its admin API.
// `storage` opens its connection in onBoot, before the server listens. for (const svc of ["kratos", "keto", "hydra"])
for (const svc of ["kratos", "keto", "hydra", "postgres"])
assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`), assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`),
`web waits for ${svc} healthy`); `web waits for ${svc} healthy`);
}); });
@@ -79,21 +74,6 @@ test("prod base supplies the app secret via env and mounts no source; dev overri
assert.match(compose, /POSTGRES_PASSWORD:\s*\$\{POSTGRES_PASSWORD\b/, "postgres password via env"); assert.match(compose, /POSTGRES_PASSWORD:\s*\$\{POSTGRES_PASSWORD\b/, "postgres password via env");
}); });
test("the provisioning superuser DSN reaches bootstrap only, never web", () => {
// web runs plugin code, which can read its own environment — so the credentials that may CREATE
// DATABASE/ROLE must never be there. web gets the credential-free base URL and derives each
// plugin's own password from the shared secret instead.
const boot = compose.slice(compose.indexOf("\n bootstrap:"));
const overrideWeb = override.slice(override.indexOf("\n web:"), override.indexOf("\n bootstrap:"));
assert.match(boot, /PLUGIN_DB_ADMIN_URL:/, "bootstrap is given the superuser DSN");
// Reordering the override's services would empty this slice, and every doesNotMatch below would
// then pass against "".
assert.ok(overrideWeb.includes("PLUGIN_DB_URL"), "sliced the dev override's web block");
for (const [name, block] of [["base", webBlock], ["dev override", overrideWeb]] as const)
assert.doesNotMatch(block, /PLUGIN_DB_ADMIN_URL/, `${name} web never sees it`);
assert.match(webBlock, /PLUGIN_DB_URL:\s*\$\{PLUGIN_DB_URL/, "base wires web's base URL from env");
});
test("a one-shot bootstrap seeds the stack before web starts", () => { test("a one-shot bootstrap seeds the stack before web starts", () => {
// MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin + // MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin +
// JWKS, then exits; web waits for it to complete. Live seeding is boot-verified. // JWKS, then exits; web waits for it to complete. Live seeding is boot-verified.
@@ -109,37 +89,6 @@ test("a one-shot bootstrap seeds the stack before web starts", () => {
"web waits for bootstrap to finish"); "web waits for bootstrap to finish");
}); });
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 (AGENTS.md).
const dockerfile = read("Dockerfile");
// 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");
const beforeWorkdir = dockerfile.split("WORKDIR /app")[0]!;
assert.match(beforeWorkdir, /npm ci/, "npm ci runs before WORKDIR /app");
assert.match(beforeWorkdir, /mv\s+node_modules\s+\/node_modules/, "and its tree lands at /node_modules");
for (const f of [...composeFiles(""), ...composeFiles("e2e-tests/")])
assert.ok(!read(f).includes("/app/node_modules"), `${f} mounts nothing at /app/node_modules`);
});
test("the E2E runner writes its artifacts as the invoking user, never as root", () => {
// Same trap as the node_modules mountpoint above, but the runner must write into the checkout,
// so the fix is the uid: root-owned output needs sudo to delete, which a dev box may not have.
// Matched independently of flag order, and counted: a reordered flag that slips out of the
// filter would otherwise leave that command silently unguarded.
const documented = [read("README.md"), ...composeFiles("e2e-tests/").map(read)]
.join("\n").split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
assert.equal(documented.length, 7, "5 compose headers + 2 README lines");
for (const l of documented)
assert.match(l, /--user "\$\(id -u\):\$\(id -g\)"/, `passes the uid: ${l.trim()}`);
// An absent mount source is daemon-created as root, and then that uid can't write it at all.
assert.ok(existsSync(new URL("../e2e-tests/artifacts/.gitkeep", import.meta.url)),
"the mount point exists in the checkout");
const gitignore = read(".gitignore");
assert.match(gitignore, /^\/e2e-tests\/artifacts\/\*$/m, "its output stays ignored");
assert.match(gitignore, /^!\/e2e-tests\/artifacts\/\.gitkeep$/m, "the mount point stays tracked");
});
test("the visual E2E does not drag in the Ory stack", () => { test("the visual E2E does not drag in the Ory stack", () => {
// web's Ory deps are reset for E2E (the dashboard is mock data — no Ory needed). // web's Ory deps are reset for E2E (the dashboard is mock data — no Ory needed).
assert.match(visual, /depends_on:\s*!reset\b/, "E2E resets web's depends_on"); assert.match(visual, /depends_on:\s*!reset\b/, "E2E resets web's depends_on");
+1 -55
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 { loadConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "./config.ts"; import { loadConfig } 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.
@@ -9,60 +9,6 @@ const secureEnv = {
REQUIRE_SECURE_SECRETS: "true", REQUIRE_SECURE_SECRETS: "true",
}; };
// web reads the secret through loadConfig and bootstrap through resolvePluginDbSecret; the two
// deriving different passwords is invisible until a plugin's connection is refused at boot. Compose
// passes an unset variable through as "", which is the case that actually drifted.
test("web and bootstrap resolve the same plugin storage secret", () => {
for (const env of [{}, { PLUGIN_DB_SECRET: "" }, { PLUGIN_DB_SECRET: "a-real-secret" }]) {
assert.equal(loadConfig(env).pluginDbSecret, resolvePluginDbSecret(env), `for ${JSON.stringify(env)}`);
}
assert.match(resolvePluginDbSecret({ PLUGIN_DB_SECRET: "" }), /dev-insecure/); // empty is unset, not a secret
});
// bootstrap writes these passwords into Postgres, so it must refuse the publicly-known throwaway
// before creating a role with one — not leave web to notice afterwards.
test("bootstrap refuses a missing, empty or throwaway plugin storage secret when hardened", () => {
const hardened = { REQUIRE_SECURE_SECRETS: "true" };
for (const secret of [undefined, "", "dev-insecure-plugin-db-secret"]) {
const env = secret === undefined ? hardened : { ...hardened, PLUGIN_DB_SECRET: secret };
assert.throws(() => resolvePluginDbSecret(env), /PLUGIN_DB_SECRET/, `for ${JSON.stringify(secret)}`);
}
assert.equal(resolvePluginDbSecret({ ...hardened, PLUGIN_DB_SECRET: "a-real-secret" }), "a-real-secret");
});
// buildCredentials overwrites the userinfo, so a pasted admin DSN would *work* — and leave a
// privileged password in the process that runs plugin code. Refusing it is the whole guard.
test("PLUGIN_DB_URL carrying credentials is refused, not silently overwritten", () => {
assert.throws(() => loadConfig({ PLUGIN_DB_URL: "postgres://root:hunter2@db:5432/ory" }), /no username or password/);
assert.throws(() => loadConfig({ PLUGIN_DB_URL: "postgres://root@db:5432" }), /no username or password/);
assert.equal(loadConfig({ PLUGIN_DB_URL: "postgres://db:5432" }).pluginDbUrl, "postgres://db:5432");
assert.equal(loadConfig({}).pluginDbUrl, undefined); // unset ⇒ storage off
});
test("a malformed URL value is refused by naming the variable, never echoing the value", () => {
// PLUGIN_DB_URL and OTLP_ENDPOINT can carry credentials; APP_URL and KRATOS_PUBLIC_URL stand for the other keys, one per reader.
const bad: Record<string, string> = {
APP_URL: "https://acme:pa/ss@[host",
KRATOS_PUBLIC_URL: "http://u:pa/ss@[kratos:4433",
OTLP_ENDPOINT: "http://otel:pa/ss@[collector:4318",
PLUGIN_DB_URL: "postgres://admin:pa/ss@db:5432/plugins",
};
for (const [key, value] of Object.entries(bad)) {
assert.throws(
() => loadConfig({ [key]: value }),
(err: Error) => err.message.includes(key) && !err.message.includes("pa/ss") && !err.message.includes(value),
key,
);
}
});
test("the per-role connection ceiling defaults to 10 and rejects nonsense", () => {
assert.equal(resolvePluginDbConnectionLimit({}), 10);
assert.equal(resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "25" }), 25);
assert.throws(() => resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "0" }), /positive integer/);
assert.throws(() => resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "ten" }), /positive integer/);
});
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);
+20 -54
View File
@@ -1,40 +1,17 @@
// Config loaded once from the environment at boot. Fail-loud — a bad value, a missing enforced // Config loaded once from the environment at boot: Ory endpoints, cookie/CSRF
// secret, a bad URL or an out-of-range port throws here, never at request time. Every value has a // secrets, JWKS location, listen port, behaviour toggles. Fail-loud — a bad value, a
// working dev default, so `docker compose up` runs with zero config. // missing enforced secret, a bad URL, or an out-of-range port throws here, never at
// request time.
//
// Environment-agnostic (AGENTS.md): the app never asks "which environment am I?". Every
// behaviour that used to ride on NODE_ENV is its own explicit toggle — `CACHE_TEMPLATES`,
// `REQUIRE_SECURE_SECRETS`. Clean-clone (README): every value has a working dev default,
// so `docker compose up` runs with zero config; a hardened deploy sets the toggles it wants.
// 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];
const DEV_PLUGIN_DB_SECRET = "dev-insecure-plugin-db-secret";
// The one resolution both processes use — they must agree exactly, or web connects with a password
// the role was never given. Compose passes an unset variable through as "", so empty means unset.
// `enforce` says whether storage is actually in play: web once PLUGIN_DB_URL is configured,
// bootstrap once a plugin declares storage. Enforced, the throwaway is refused — bootstrap is what
// writes these passwords into Postgres, so it must refuse *before* creating a role with one.
export function resolvePluginDbSecret(env: Env, enforce?: boolean): string {
return readSecret(env, "PLUGIN_DB_SECRET", DEV_PLUGIN_DB_SECRET, enforce ?? readBool(env, "REQUIRE_SECURE_SECRETS", false));
}
// Only bootstrap provisions, so only bootstrap reads this; env still gets read in one place.
export function resolvePluginDbConnectionLimit(env: Env): number {
return readPosInt(env, "PLUGIN_DB_CONNECTION_LIMIT", 10);
}
// PLUGIN_DB_URL is web's, and web must never hold credentials that outrank a plugin's own role.
// Pasting the admin DSN here would otherwise work — buildCredentials overwrites the userinfo — and
// leave a superuser password in the environment plugin code can read.
function readCredentiallessUrl(env: Env, key: string): string | undefined {
const value = readOptionalUrl(env, key);
if (value === undefined) return undefined;
const url = new URL(value);
if (url.username || url.password) {
throw new Error(`config: ${key} must carry no username or password — each plugin connects as its own role`);
}
return value;
}
export interface Config { export interface Config {
appUrl: string | undefined; // canonical public URL; set ⇒ off-host visitors are redirected here. Unset ⇒ no redirect (explicit toggle) appUrl: string | undefined; // canonical public URL; set ⇒ off-host visitors are redirected here. Unset ⇒ no redirect (explicit toggle)
cacheTemplates: boolean; cacheTemplates: boolean;
@@ -53,10 +30,7 @@ export interface Config {
oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory) oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory)
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export) otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors) otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
pluginDbSecret: string; // derives each plugin's database password (src/plugin-host/storage.ts)
pluginDbUrl: string | undefined; // credential-free Postgres base URL; unset ⇒ plugin storage is off
port: number; port: number;
requireSecureSecrets: boolean; // enforce real secrets — the host's own, and every plugin's declared `secret`
revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
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;
@@ -98,27 +72,26 @@ function readEnum<T extends string>(env: Env, key: string, allowed: readonly T[]
throw new Error(`config: ${key} must be one of ${allowed.join(", ")}, got "${value}"`); throw new Error(`config: ${key} must be one of ${allowed.join(", ")}, got "${value}"`);
} }
// Never echoes the value: a DSN whose password breaks URL parsing (a bare "/") would put the
// password in the boot error and the log.
function assertUrl(key: string, value: string): void {
try {
new URL(value);
} catch {
throw new Error(`config: ${key} must be an absolute URL (its value is not shown; percent-encode special characters in a password)`);
}
}
// An optional absolute URL: unset/empty ⇒ undefined; a set-but-malformed value fails at boot. // An optional absolute URL: unset/empty ⇒ undefined; a set-but-malformed value fails at boot.
function readOptionalUrl(env: Env, key: string): string | undefined { function readOptionalUrl(env: Env, key: string): string | undefined {
const value = env[key] || undefined; const value = env[key] || undefined;
if (value !== undefined) assertUrl(key, value); if (value === undefined) return undefined;
try {
new URL(value);
} catch {
throw new Error(`config: ${key} is not a valid URL: ${value}`);
}
return value; return value;
} }
// An absolute URL: defaults to the Ory service; validated so a typo fails at boot. // An absolute URL: defaults to the Ory service; validated so a typo fails at boot.
function readUrl(env: Env, key: string, devDefault: string): string { function readUrl(env: Env, key: string, devDefault: string): string {
const value = env[key] ?? devDefault; const value = env[key] ?? devDefault;
assertUrl(key, value); try {
new URL(value);
} catch {
throw new Error(`config: ${key} is not a valid URL: ${value}`);
}
return value; return value;
} }
@@ -183,14 +156,7 @@ export function loadConfig(env: Env = process.env): Config {
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5), oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"), otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"), otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
// Per-plugin storage. PLUGIN_DB_URL carries the server and its connection parameters but no
// credentials: the superuser DSN that provisions stays in bootstrap, so a plugin cannot read it
// out of web's environment. Unset ⇒ storage is off and a plugin declaring it fails loud at boot,
// which is also why the secret is only enforced once a URL is configured.
pluginDbSecret: resolvePluginDbSecret(env, requireSecure && Boolean(env["PLUGIN_DB_URL"])),
pluginDbUrl: readCredentiallessUrl(env, "PLUGIN_DB_URL"),
port: readPort(env), port: readPort(env),
requireSecureSecrets: requireSecure,
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission // Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m // change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
// tokenizer TTL + skew, so it outlasts any pre-revoke token). // tokenizer TTL + skew, so it outlasts any pre-revoke token).

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