Compare commits
266 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a00d86fb83 | |||
| e8fac7fd5c | |||
| 42661b111f | |||
| ecdf9f3464 | |||
| d6a33e8cc9 | |||
| 0bf7901a14 | |||
| a0ad0ddf2d | |||
| 5088352fcf | |||
| 58f85baa8f | |||
| 428800fd62 | |||
| 9385f291a0 | |||
| add2e98eb9 | |||
| 23b5f717d7 | |||
| 0727d15964 | |||
| ed48c5a8fd | |||
| 2b19ed27d2 | |||
| 63c90ea3d6 | |||
| 2de7e4d79c | |||
| 4d2e7b59fd | |||
| 1cc98017d2 | |||
| 216089c067 | |||
| 56807434e8 | |||
| 037112bc2c | |||
| e328832374 | |||
| b8a510a1c5 | |||
| 8324a9c59e | |||
| dd39c7a07a | |||
| 05da1d7897 | |||
| 34dfbea156 | |||
| 3179be0d11 | |||
| 4c8ba083ce | |||
| 7ad46d0adf | |||
| be498eecd3 | |||
| b05d678c5f | |||
| 9b52b7e5ff | |||
| f8a611992a | |||
| fdef624300 | |||
| 903dde8a24 | |||
| 9b1b201f65 | |||
| 9ded502be3 | |||
| ea6c32a7db | |||
| c4eb9dde1f | |||
| 5e00bba85e | |||
| b4579131a2 | |||
| dd95c258df | |||
| 77e3d46769 | |||
| b7e9780437 | |||
| 1b53b01333 | |||
| 8544de831a | |||
| 523e8b3724 | |||
| 6767a5d7bd | |||
| a558f8340f | |||
| 777c0bd86d | |||
| 6ba21b8409 | |||
| be8197dcfa | |||
| 14a07dd879 | |||
| c3ca92713e | |||
| bb134a35d7 | |||
| 9a4ce279af | |||
| 1219513b79 | |||
| d6a44b333d | |||
| b19b43685c | |||
| 85219b081a | |||
| a69593eb72 | |||
| 996eef2dc5 | |||
| 6ca63c6c0f | |||
| 0b48f71d65 | |||
| 3a3bd77bb8 | |||
| 4b44d5bfde | |||
| 17cd516ace | |||
| 6a58ce3e81 | |||
| 9af29ffdb9 | |||
| 80070762e7 | |||
| 404f2584ac | |||
| 01f368d663 | |||
| 84368ea20b | |||
| 20b4d23855 | |||
| 28d6fc4774 | |||
| 4239ee12f0 | |||
| a2493edff2 | |||
| 5aa62e2742 | |||
| 99d5928b1d | |||
| 93825a2e4e | |||
| 43e66fce1f | |||
| 48bedf07b1 | |||
| 0a6905a58b | |||
| adfa665c7c | |||
| 254b512020 | |||
| 529dd5cfa1 | |||
| 95a9f28a5f | |||
| 29fac748f9 | |||
| 7100740eb5 | |||
| 0cd5d368da | |||
| a3b8632d5c | |||
| 69f0371b89 | |||
| 190d12532d | |||
| fefc7cee30 | |||
| 0ebccfd1b6 | |||
| d1fb444e51 | |||
| 171f37985d | |||
| c2389f871d | |||
| 8e73d165da | |||
| 04545274f3 | |||
| 8ffbb0b70d | |||
| b8686377c9 | |||
| 217a36252e | |||
| 03bcf216a7 | |||
| 306f322b62 | |||
| 396ce93a2f | |||
| 5be561c4ba | |||
| 8f7bc59b38 | |||
| 622059101f | |||
| 3592a30f98 | |||
| f340933e39 | |||
| 00fe8b0dd2 | |||
| 767c76b16c | |||
| 4cb21d8909 | |||
| ccb4d32d39 | |||
| 053438f968 | |||
| 29d496a02e | |||
| 4779a9a947 | |||
| 03d7be61b3 | |||
| ead604f49d | |||
| 7cf37acb1b | |||
| c78e5509b2 | |||
| 03544e8d8d | |||
| eda07269d1 | |||
| 2170ca728b | |||
| d0e1de96b8 | |||
| 8a93d59988 | |||
| 7539f8ec0d | |||
| 5a204facdb | |||
| 7f874edb4d | |||
| 7ff8140e07 | |||
| cf8fa437cd | |||
| a7781869fd | |||
| ee4d09df06 | |||
| 282c4b17da | |||
| 1adeb776a2 | |||
| b6afdf1ecc | |||
| b478cc7735 | |||
| 8906340cf8 | |||
| 35f71d435b | |||
| c36f393fff | |||
| ad812e17c2 | |||
| fcc5e04e0d | |||
| 6202b3223b | |||
| c1abc67a92 | |||
| 71b12a6c0a | |||
| 72528f01ef | |||
| 65e2340e49 | |||
| c38ad74e3a | |||
| a7135763bb | |||
| 236f4c4610 | |||
| 6358858103 | |||
| 3d757d4726 | |||
| 910ac18798 | |||
| 48c89baf8e | |||
| b863ea26d9 | |||
| 3c0878ed1a | |||
| 21877aeadd | |||
| 85bfadd040 | |||
| 346d232cf7 | |||
| f4969d5c1f | |||
| 76995008eb | |||
| dfd26ada34 | |||
| 5c84cc77fe | |||
| 943520566e | |||
| 8d1e8be6fc | |||
| dbd8af08ba | |||
| 2a4ed30e93 | |||
| 15c5a09667 | |||
| 73e6d5fb08 | |||
| f527850107 | |||
| 6f689e517b | |||
| ea259f09a8 | |||
| ec35aaaa9e | |||
| 14c09a76c0 | |||
| 2be8ebfa67 | |||
| 5b7ad118ab | |||
| 437a2e8580 | |||
| 57a1a2899f | |||
| 352de29013 | |||
| de4eb7d9ee | |||
| a8a9744744 | |||
| 10152d2c6f | |||
| 4e7d897516 | |||
| cd46c77332 | |||
| 16c9822c29 | |||
| 520d2c928d | |||
| be3553c1e2 | |||
| cef2d4f4f2 | |||
| b336dee6d7 | |||
| af157c95fe | |||
| 3f30cfafe6 | |||
| 25b24a1057 | |||
| 1905d2c310 | |||
| 16da74f03f | |||
| bb8e6bc8e8 | |||
| 0f20b386e2 | |||
| 6815caa47e | |||
| 9e5b60a333 | |||
| 21a00f895a | |||
| 2ab482e774 | |||
| f46e97383e | |||
| ce69269ae4 | |||
| 364b04795c | |||
| 19987ba1b0 | |||
| c12fd2742f | |||
| 48655c0ad2 | |||
| a8d4139dd8 | |||
| a190166421 | |||
| ac52abd330 | |||
| a2edfbee0c | |||
| 635006b5fc | |||
| 3e70768433 | |||
| 56ff987c97 | |||
| d8df129fca | |||
| 270d80c8d0 | |||
| 2a3ace885d | |||
| e9a7c0e726 | |||
| 7e3e8acb28 | |||
| eed1050c08 | |||
| 6f11a9928b | |||
| 070d745a4b | |||
| 9370fb36f2 | |||
| 3c5c0c911c | |||
| 44a777dc3f | |||
| 1dfc6aac3a | |||
| 0c0a3bc905 | |||
| 935674bc49 | |||
| e8dbb2a809 | |||
| 79f26491db | |||
| 77b7e6143f | |||
| 18b6a5a64a | |||
| ad3bb3198e | |||
| 74ebf2e7ec | |||
| 53e996394f | |||
| 3491e660e9 | |||
| 667d589401 | |||
| 5a968fd076 | |||
| b81c136fe5 | |||
| c9e25f2852 | |||
| 603e29e8f1 | |||
| 055bfd6506 | |||
| 80cc692634 | |||
| ebf34deda4 | |||
| 937a0739ca | |||
| 44813be8a7 | |||
| 785230673b | |||
| 80981eb791 | |||
| 0097481b4c | |||
| b09f8b7eb6 | |||
| fb464acf54 | |||
| 8220b34fd0 | |||
| 194557fb49 | |||
| 4a0fc2b493 | |||
| 946c45663f | |||
| 41ab968b20 | |||
| ff548a30d2 | |||
| f80c6c6426 | |||
| 3658797764 | |||
| 23f1158211 | |||
| bf9302ce8e | |||
| 2ed134fc86 | |||
| e07edeb6ae |
@@ -19,4 +19,4 @@ jobs:
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
||||
node:24.21.0-alpine3.24 node registry-cleanup/cleanup.ts
|
||||
node:24.22.2-alpine3.24 node registry-cleanup/cleanup.ts
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
name: Release
|
||||
# The Hub overview names a released tag, so it is published here only, never on a push to main.
|
||||
on:
|
||||
push:
|
||||
tags: ['v[0-9]+.[0-9]+.[0-9]+']
|
||||
@@ -21,7 +22,7 @@ jobs:
|
||||
GIT_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.21.0-alpine3.24 \
|
||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
|
||||
- name: Promote the commit-hash image to semver + latest
|
||||
env:
|
||||
@@ -56,7 +57,7 @@ jobs:
|
||||
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||
VERSION=${GIT_TAG#v}
|
||||
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|
||||
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; }
|
||||
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (docs/ci-cd.md)"; exit 1; }
|
||||
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
|
||||
TAGS="$VERSION ${VERSION%.*} latest"
|
||||
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
|
||||
@@ -68,8 +69,9 @@ jobs:
|
||||
if: always()
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# Cleanup, and the runner's Docker config is shared (AGENTS.md) — a lost race here must not
|
||||
# fail a release that published, nor skip the overview job that follows.
|
||||
# This is cleanup: it must not fail a release that published, nor skip the overview job
|
||||
# that follows. The runner's Docker config is shared, so a logout can lose a race
|
||||
# (docs/decisions.md → "CI docker logins share the runner host's Docker config").
|
||||
docker logout gitea.larvit.se || true
|
||||
docker logout docker.io || true
|
||||
|
||||
@@ -98,9 +100,9 @@ jobs:
|
||||
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.21.0-alpine3.24 \
|
||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
|
||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
|
||||
node:24.21.0-alpine3.24 \
|
||||
node:24.22.2-alpine3.24 \
|
||||
node release-tooling/dockerhub-overview.ts "$VERSION"
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
-e RENOVATE_PLATFORM=gitea \
|
||||
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||
-e RENOVATE_TOKEN \
|
||||
renovate/renovate:44.117.2
|
||||
renovate/renovate:44.199.2
|
||||
|
||||
# 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
|
||||
@@ -38,6 +38,8 @@ jobs:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# A persisted token outranks the PAT in the push URL, and Gitea runs nothing for its pushes.
|
||||
persist-credentials: false
|
||||
- name: Tag a release for what Renovate merged
|
||||
env:
|
||||
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}
|
||||
@@ -58,11 +60,11 @@ jobs:
|
||||
if [ -z "$BUMPS" ]; then
|
||||
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
|
||||
fi
|
||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.21.0-alpine3.24 \
|
||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||
node release-tooling/next-version.ts "$LATEST" $BUMPS)
|
||||
# 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.21.0-alpine3.24 \
|
||||
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||
node release-tooling/contract-version.ts "$NEXT" -
|
||||
echo "Releasing $LATEST -> $NEXT"
|
||||
git tag "$NEXT" origin/main
|
||||
|
||||
@@ -10,8 +10,9 @@ change, sweep the prose you touched — this file, `README.md`, the example READ
|
||||
comments — and cut it back to what a competent reader could not infer:
|
||||
|
||||
- **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.
|
||||
rejected", "(declined twice)", dated changelog entries (a decision's date in `docs/decisions.md`
|
||||
is not one), or the symptom that prompted a fix. Record the decision and the reason it
|
||||
*currently* turns on, nothing else.
|
||||
- **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that
|
||||
re-explains a table above it, a file-map entry that expands the filename. The fix is deletion,
|
||||
not trimming.
|
||||
@@ -67,285 +68,72 @@ branch, create a PR and merge it when the CI/CD turns green.
|
||||
**fails loud at boot/discovery** rather than sandboxing at runtime. Runtime crash-isolation is a
|
||||
deliberate **non-goal**.
|
||||
|
||||
## Deliberate architectural deviations (don't re-flag)
|
||||
## Decisions
|
||||
|
||||
Intentional, reasoned choices — an architecture review should honor them, not re-raise them.
|
||||
Revisit only if the stated reason stops holding.
|
||||
Each title below is an entry in `docs/decisions.md`, with its reasoning and the premise it rests
|
||||
on; a review honours them.
|
||||
|
||||
### Structure & contracts
|
||||
|
||||
- **`src/` is grouped by concern**, not flat — `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`,
|
||||
with `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` at the root; tests are
|
||||
co-located. Add a new module to the folder owning its concern. The core ships **no domain
|
||||
screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
|
||||
- **Plugins and config import the host only through a barrel** — `@plainpages/plugin-api` →
|
||||
`plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts`,
|
||||
never a relative `../../src/*` path. These two barrels are the whole contract surface; don't "fix"
|
||||
either back to a relative path. Three consequences:
|
||||
- `@plainpages/plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their
|
||||
DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major
|
||||
`apiVersion` bump, not a free refactor.
|
||||
- **The barrel is a package, not a `#`-import, so a plugin folder may carry its own
|
||||
`package.json`** and depend on npm packages (README → Plugin dependencies). The Dockerfile links
|
||||
it into `/node_modules`, above every plugin scope. Never let a copy reach a plugin's own
|
||||
`node_modules`: two instances of the barrel break `instanceof` across the boundary, which
|
||||
`plugin-api.test.ts` guards by asserting both paths reach one module.
|
||||
- **Plugin storage hands over credentials, not a client** (README → Plugin storage). The host takes
|
||||
`postgres` to run the provisioning DDL, and `storage-provisioning.ts` is the only module importing
|
||||
it — `storage.ts` beside it stays pure so `web` never loads a driver (`src/postgres.test.ts` guards
|
||||
both halves, because one value imported from the wrong module breaks it invisibly). It is never
|
||||
re-exported through the barrel, so no driver shape enters the contract. Three properties hold the design together, so
|
||||
don't trade one away in isolation: passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than
|
||||
stored, which is what keeps the host stateless — whoever holds that secret holds every plugin
|
||||
database, so it ranks with the DB password itself; the provisioning DSN reaches `bootstrap` only
|
||||
(`src/compose.test.ts` guards the split); and provisioning never drops anything, so uninstalling a
|
||||
plugin cannot destroy data — boot logs the orphans instead. Because the host's copy sits in the
|
||||
ambient `/node_modules`, a plugin can `import "postgres"` without declaring it — incidental, not a
|
||||
packaging promise, and a plugin must still depend on its own driver.
|
||||
- **Plugin settings are declared, not discovered** (README → Plugin settings). `settings.ts` is pure and
|
||||
takes the env as an argument, so the whole matrix unit-tests without a stack. Four rules carry the
|
||||
design: the prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`, because a plugin id `db` with key
|
||||
`url` would otherwise name the host's own `PLUGIN_DB_URL`; keys are camelCase so the
|
||||
`camelCase → SNAKE_CASE` mapping is total and no two keys collide, with the residual cross-plugin
|
||||
collision caught by `findConflicts`; `required` and `default` are mutually exclusive, which is what
|
||||
lets `SettingsOf` type a declared key as present rather than `T | undefined`, so no plugin author
|
||||
casts; and a secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings`
|
||||
— not even as a mask or a length. An author mistake is refused at discovery, a bad operator value
|
||||
refuses the boot, and a stray `PLUGIN_SETTING_` variable only warns (the orphan-database precedent).
|
||||
- **The trust boundary is the `web` process, not the plugin.** Per-plugin databases and roles bound
|
||||
*accidents*, not hostile plugins: `PLUGIN_DB_SECRET` is in `web`'s environment during `onBoot`, and
|
||||
a plugin already holds `ctx.system`'s Ory admin clients — so cross-plugin DB isolation is
|
||||
containment, and README says so rather than implying a sandbox. Consistent with priority #7
|
||||
(crash-isolation is a non-goal). `server.ts` still deletes the secret from `process.env` right
|
||||
after `loadConfig`, which is before discovery imports any plugin module — the ordering is the whole
|
||||
point, so move it earlier if anything, **never later**. **Valid while plugins are
|
||||
operator-installed code, not third-party uploads.**
|
||||
- **`ory/postgres/init/init.sql` is the only home for the Ory databases' ACL** — don't re-assert the
|
||||
`REVOKE CONNECT` from `bootstrap`. `REVOKE` only *warns* when the caller doesn't own the database,
|
||||
so under the least-privilege provisioning account the README recommends it would report success
|
||||
while changing nothing, and it hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a server with no
|
||||
`kratos`. It runs only on **first init**, so a revoke added to it later never reaches a volume that
|
||||
already exists — `docker compose down -v` is the dev remedy, a deployed install needs a migration.
|
||||
- **`bootstrap.ts` stays under `src/auth/`** even though it now provisions plugin databases as well
|
||||
as seeding Ory. It is the one-shot service's entrypoint, not an auth module; moving it to
|
||||
`src/bootstrap.ts` would edit `compose.yml`, five e2e compose files and `src/compose.test.ts` for a
|
||||
rename. Reconsider when a third seeding concern lands.
|
||||
- **`BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook.** Adding
|
||||
to the context costs a minor bump and removing one a major, so the shape errs small elsewhere. Pools
|
||||
handed to a plugin are reaped on process exit — revisit if a plugin ever needs an orderly drain.
|
||||
- **`config/` is still a plain dir — no `package.json` of its own**, or `#menu-config` resolves
|
||||
against that instead and boot fails loud. An operator's menu override has no use for
|
||||
dependencies; if that changes, it needs the same package treatment.
|
||||
- **A plugin `package.json` without `"type": "module"` is refused, not warned.** Allowing it costs a
|
||||
warning and a re-parse per file, not a break — Node detects module syntax, so even a `.js` helper
|
||||
loads — and an operator on a read-only third-party mount cannot apply the remedy. Refused anyway
|
||||
because the direction is safe: refuse→warn relaxes freely, warn→refuse breaks installed plugins.
|
||||
**Valid while nothing is installed in the wild.**
|
||||
- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to
|
||||
`plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in
|
||||
`tsconfig.include` and resolve the host through the barrels, so each typechecks in place *and*
|
||||
copies across unchanged. Never commit real plugins/config into the root mount dirs — they ship empty.
|
||||
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the base request
|
||||
context. It protects the I/O-free hot path on the public, bot-hit landing (`/`).
|
||||
- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
|
||||
`dashboard`) and an `onRequest` short-circuit build their context with `contextFor(pluginId)`
|
||||
exactly as a plugin route does — otherwise `ctx.t` is the core translator and the plugin's own keys
|
||||
render as bare keys on the pages it owns.
|
||||
- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web` never
|
||||
touches SMTP. Customization is Kratos' `courier.template_override_path`, not app code.
|
||||
- `src/` is grouped by concern, and the core ships no domain screens
|
||||
- Plugins and config import the host only through the two barrels
|
||||
- The Ory client types are contract-visible through the barrel
|
||||
- The barrel is a package, so a plugin folder may carry its own `package.json`
|
||||
- Plugin storage hands over credentials, not a client
|
||||
- Plugin settings are declared, not discovered
|
||||
- The trust boundary is the `web` process, not the plugin
|
||||
- `ory/postgres/init/init.sql` is the only home for the Ory databases' ACL
|
||||
- `bootstrap.ts` stays under `src/auth/`
|
||||
- `BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook
|
||||
- `config/` is a plain dir with no `package.json` of its own
|
||||
- A plugin `package.json` without `"type": "module"` is refused, not warned
|
||||
- `examples/` mirrors the drop-in mount dirs
|
||||
- `ctx.chrome` is lazily memoized, never built unconditionally
|
||||
- A plugin-owned render always runs on that plugin's context
|
||||
- Email is delegated to Kratos
|
||||
|
||||
### Authorization
|
||||
|
||||
- **Vocabulary: `User` → `Group` → `Permission`, and there is no `Role`.** Keto ships no namespaces —
|
||||
all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one operation ("read shifts");
|
||||
a role is a *bundle*, which here is just a group with several grants (groups nest). Ory's own
|
||||
"permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
|
||||
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
|
||||
word names *who someone is* (a role), and roles are groups here. **Enforced at discovery**
|
||||
(`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route/nav
|
||||
`permission` and every declared name), fail-loud like any other manifest rule — not only in the
|
||||
admin GUI, which an operator removes by not copying it in.
|
||||
- **Names are authored in plugin code; only grants live in Keto.** The host collects every installed
|
||||
plugin's declarations into one catalog (`declaredPermissions` → `ctx.declaredPermissions`), and
|
||||
that catalog *is* the list the admin screens offer. Hence **no Permissions admin screen**: nothing
|
||||
in a GUI invents a name, and holding one is a property of a user or group, edited as a checkbox
|
||||
list there. A Keto tuple naming something no installed plugin declares gates nothing, is not
|
||||
offered, and is never revoked by an unrelated save — the picker only speaks for what it showed.
|
||||
- `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`): users are
|
||||
the *host's*, and cross-plugin sharing is a goal. Cost: collision-freedom is a convention rather
|
||||
than structural. Accepted — the alternative penalizes the sharing case.
|
||||
- **Declaring a permission stays optional.** Mandatory declaration would let `findConflicts` see all
|
||||
overlaps, but would then warn on exactly that legitimate sharing case. Shape is enforced;
|
||||
declaration is not.
|
||||
- `ADMIN_PERMISSIONS` **defaults to empty**, and **an unusable value is dropped with a warning,
|
||||
never fatal** — fail-loud belongs at the manifest boundary where a developer authored the
|
||||
mistake, whereas `bootstrap` gates `web`, so refusing operator env takes the whole stack down
|
||||
(`e2e-tests/compose.auth.yml` seeds a bad value to prove the container survives one). The seed is
|
||||
a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs
|
||||
`docker compose up -d`, not `restart web`. `bootstrap`'s matching `./plugins` mount belongs in
|
||||
`compose.override.yml` and nowhere else: in the base file it would desynchronise prod and collide
|
||||
with the e2e stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a
|
||||
read-only parent is EROFS and the container never starts). Valid while bootstrap is the only
|
||||
writer of grants.
|
||||
- **`actionForMethod` is plugin-local and must not migrate into `@plainpages/plugin-api`.** Inside the admin
|
||||
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
||||
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
||||
verb — a route table must answer "what does this need?" on its own.
|
||||
- **A gate is one of three, named exactly once, and `session` is a first-class one.** A route or nav
|
||||
node names exactly one of `public`, `session`, `permission` — discovery refuses none, two, and a
|
||||
flag spelled anything but `true`, so a forgotten gate fails the boot rather than publishing a page.
|
||||
`src/auth/gate.ts` is the one home of the rule the plugin router, the host's own route table and
|
||||
the menu all read. Exactly-one-gate is a discovery-time rule on manifests, not a runtime
|
||||
invariant: `allows({}, user)` stays open **by design**, because the central override's `groups`
|
||||
builds header nodes that carry no gate. Making `allows` fail closed would hide every
|
||||
operator-grouped section. `session` exists because a plugin whose data is
|
||||
the visitor's own — their upstream account, their own tokens — has no distinction a permission could
|
||||
name; the alternative, granting every newly registered user a permission, couples the identity
|
||||
lifecycle to a Keto write that nothing retries when it fails. A page scoped to "mine" joins on
|
||||
`ctx.user.id`, never the email — an address is user-changeable and can be reassigned to someone
|
||||
who would then inherit the previous holder's rows.
|
||||
- **The reference plugin's two shift pages duplicate a view model and markup on purpose.** An example
|
||||
is read far more often than it is changed, and each page reads top to bottom on its own. **Valid
|
||||
while `examples/plugins/scheduling` stays a teaching artifact rather than a maintained product.**
|
||||
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
|
||||
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
|
||||
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
|
||||
form, a delete-confirm page) is the exception to `actionForMethod` and gates on `:write`. Two
|
||||
grant-specific guards go with it: you cannot revoke your own **direct** grants (self-lockout would
|
||||
need a `curl` against Keto to undo), and a permission held *through a group* renders
|
||||
ticked-but-disabled, because unticked stated the opposite of the truth. **Known gap:** the group
|
||||
paths are unguarded — unticking a permission on a group you belong to, leaving it, or deleting it
|
||||
can still strip your own access. The robust "last effective holder" check needs a reverse Keto
|
||||
query and is deferred.
|
||||
- **`users:write` and `groups:write` are equivalent to full administrative access**: `groups:write`
|
||||
adds you to any group, including one holding every permission; `users:write` mints a recovery code
|
||||
for any account. The containment the split buys is real on the **read** half only (`users:read` is
|
||||
a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
|
||||
- **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed
|
||||
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
|
||||
Kratos' wire shape — don't rename it.
|
||||
- The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
|
||||
- A permission name is always `<resource>:<action>`, enforced at discovery
|
||||
- Permission names are authored in plugin code; only grants live in Keto
|
||||
- A permission's `<resource>` is global, not plugin-scoped
|
||||
- Declaring a permission stays optional
|
||||
- `ADMIN_PERMISSIONS` defaults to empty, and an unusable value is dropped with a warning
|
||||
- `actionForMethod` is plugin-local and stays out of `@plainpages/plugin-api`
|
||||
- A gate is one of `public`, `session` or `permission`, named exactly once
|
||||
- The reference plugin's two shift pages duplicate a view model and markup
|
||||
- A `:read`-only holder is never shown a write affordance
|
||||
- `users:write` and `groups:write` each amount to full administrative access
|
||||
- The CSRF token is unbound to the session
|
||||
- Plainpages says "user" everywhere, where Ory says "identity"
|
||||
|
||||
### i18n
|
||||
|
||||
- **The locale lives in the URL, never in a cookie.** `?locale=sv-SE` → `Accept-Language` → `en-US`,
|
||||
and when the URL asked for one the host carries it onto the links it renders. A cookie would make a
|
||||
page's language invisible in its address and unshareable; the cost is that a plugin wraps its own
|
||||
hrefs. Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a lone language from
|
||||
`Accept-Language` takes the first regional catalog for it.
|
||||
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell, `pagination`,
|
||||
`filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every href in
|
||||
`localeHref`; nav and sign-in are wrapped in `chrome.ts`; the two GET forms carry it as a hidden
|
||||
`locale` input, since a GET submit replaces the whole query string. **A form's `action` counts as a
|
||||
link** — sign-out, consent and auth-card forms carry it too, or picking a language and then saving
|
||||
anything drops back to `Accept-Language`. The obligation stays on the building block, never on each
|
||||
call site. `ctx.localeHref` remains for hrefs a plugin's own markup emits. The one round-trip that
|
||||
cannot carry it is the Kratos sign-in POST (absolute off-site URL).
|
||||
- **`locale` is a host-owned query param** — in `parseListQuery`'s reserved set, so a localized list
|
||||
page doesn't hand a plugin a phantom `locale` filter. The i18n view locals (`t`, `locale`, `locales`,
|
||||
`localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's
|
||||
`data` so a collision loses the key instead of breaking the shell.
|
||||
- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US`
|
||||
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` requires — and a
|
||||
mismatch stops startup. A plugin may ship fewer locales than the host (its strings fall back to
|
||||
`en-US` per key), never one the host lacks.
|
||||
- **`locales/` at the repo root is a drop-in mount**, like `plugins/` and `config/` — `locales/<tag>.ts`
|
||||
for the core, `locales/plugins/<id>/<tag>.ts` for a plugin; a new tag adds a language, an existing
|
||||
one replaces that catalog wholesale. Adding a language must never require forking the image. The
|
||||
SHIPPED `en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is
|
||||
checked rather than trusted (one compared only against itself would boot green with the whole UI
|
||||
rendering keys).
|
||||
- **The language picker is on every page, POST-rendered ones included.** A POST-rendered URL often
|
||||
answers no GET (`POST /admin/users/:id/recovery`), so the host resolves the picker's target
|
||||
(`app.ts` → `switchBase`): this path when it answers GET, else the same-origin Referer, else `/`.
|
||||
Accepted cost: switching language there leaves that POST's own result behind. Valid while the picker
|
||||
is expected on literally every page — if that softens, hiding it after a POST is simpler.
|
||||
- **An unknown translation key renders as itself.** That single rule lets a nav label, branding, or a
|
||||
menu `rename` be either a key or plain text without a second field or a migration. Don't "fix" it
|
||||
into a loud failure: a manifest with plain labels must keep working.
|
||||
- **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value;
|
||||
one carrying markup uses `<%- %>`, and then its `{{vars}}` are escaped at the call site. Don't move
|
||||
escaping into `t()` — every other value in a view would become the odd one out.
|
||||
- **RTL is out of scope until there is a real use case.** `textDirection` sets `<html dir>` because
|
||||
that is free and correct, but the stylesheet keeps physical `left`/`right` properties; a genuine RTL
|
||||
locale needs those moved to logical ones first. Valid while no deployment needs an RTL language.
|
||||
- The locale lives in the URL, never in a cookie
|
||||
- The core building blocks carry the locale, so a plugin doesn't have to
|
||||
- `locale` and the i18n view locals are reserved by the host
|
||||
- Catalogs are checked at boot, not at render
|
||||
- `locales/` at the repo root is a drop-in mount
|
||||
- The language picker is on every page, POST-rendered ones included
|
||||
- An unknown translation key renders as itself
|
||||
- `t()` returns raw text, and the view escapes it
|
||||
- RTL is out of scope until there is a real use case
|
||||
|
||||
### UI
|
||||
|
||||
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
|
||||
owns open/close — the only zero-JS way to dismiss by clicking outside — and the panel sits in the
|
||||
top layer, so a row kebab is not clipped by `.table-wrap`'s `overflow`. Four rules hold it
|
||||
together: the panel carries **`position-anchor: auto`** (a bare `anchor()` resolves to nothing in
|
||||
all three engines); it stays the trigger's **next sibling inside the `.menu` wrapper**, which the
|
||||
open-state style and the old-browser fallback both read; the partial **requires a caller-named
|
||||
`id`** and fails loud without one, since that is the `popovertarget` idref (never generate one —
|
||||
nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
|
||||
`aria-haspopup` is written**: every engine maintains the first itself on a declarative
|
||||
`popovertarget` invoker, so a hand-written one replaces a live state with a static lie, and the
|
||||
second would promise `role="menu"` semantics these panels don't implement. **That guarantee is the
|
||||
declarative attribute's alone** — open a panel from script and no engine applies it, so
|
||||
"enhancing" one of these triggers is what would cost it its accessibility. `<details>` stays where
|
||||
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
||||
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
|
||||
the two in step.
|
||||
- **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s
|
||||
`overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame:
|
||||
they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` —
|
||||
a label click focuses it, and a browser scrolls a focused element into view.
|
||||
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
|
||||
is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
|
||||
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
|
||||
unknown sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test
|
||||
catches anything reaching the nav).
|
||||
- A dropdown is a `<button popovertarget>` and a `[popover]`, never a `<details>`
|
||||
- The document is the one scroller
|
||||
- `ICON_NAMES` is a host-owned registry, not a plugin contract
|
||||
|
||||
### Build, test & release
|
||||
|
||||
- **Deps install to `/node_modules`, above `WORKDIR /app`** — Node resolves upward, so dev's `.:/app`
|
||||
bind mount has nothing to shadow. Not a volume at `/app/node_modules`: the daemon creates a mount
|
||||
destination as root whatever `--user` says, leaving a root-owned dir in the checkout. Nothing may
|
||||
sit at that path now — it shadows `/node_modules` silently (`src/compose.test.ts` guards the compose
|
||||
files, `.dockerignore` the image).
|
||||
- **A container whose output a human then edits or deletes runs as `--user "$(id -u):$(id -g)"`** —
|
||||
the E2E runner (artifacts) and a lockfile edit, or the output is root-owned and needs `sudo`, which
|
||||
a dev box may not have. Not universal: `bootstrap` writes `jwks.json` as root when it is absent on
|
||||
first boot; the committed dev key makes that rare, and when it happens the rotation runbook's
|
||||
host-side `>` needs the file re-owned first (valid while the dev key ships committed). Three
|
||||
consequences: `e2e-tests/artifacts/` is *tracked* (`.gitkeep`), since an absent bind-mount source is
|
||||
daemon-created as root and that uid then cannot write it (README → Upgrading); the runner image sets
|
||||
`HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an unwritable `/`; and
|
||||
rootless Docker wants the flag *dropped*, container root already being the invoking user. Baking a
|
||||
`USER` in instead does not work — the image's `pwuser` is 1001 and no fixed uid matches every host.
|
||||
`src/compose.test.ts` guards every documented command, `src/ci-gate.test.ts` the gate's own.
|
||||
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
|
||||
`e2e-tests/console-guard.ts`, which fails a test on a console error/warning or uncaught exception on
|
||||
any page it opened. A zero-JS app has nothing to say in the console, so the bar is *zero* rather than
|
||||
a curated tolerance list; the two exceptions are narrow — a module-level allowance for the COOP header
|
||||
Chromium drops (the e2e stacks serve plain http over container hostnames), and `allowConsole(re)` for
|
||||
a test whose own page provokes a message on purpose. `src/e2e-console-guard.test.ts` locks the wiring
|
||||
in the *unit* gate, since a spec importing `test` straight from Playwright — or minting a page with
|
||||
a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. Accepted cost: a page
|
||||
outliving its test can log late and fail the next one.
|
||||
- **The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium.**
|
||||
`visual.spec.ts` + `language.spec.ts` are side-effect-free, so parallel runs don't collide, and a
|
||||
console message only appears in the engine that renders the page (`ORY_FREE` in
|
||||
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
|
||||
so widening them means a stack per engine.
|
||||
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** Both git channels in
|
||||
`ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the destination, so
|
||||
`git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a source file that
|
||||
was gone. `src/ci-gate.test.ts` locks the flags as a
|
||||
*text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
|
||||
`release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
|
||||
guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard
|
||||
skipped. `README.md` is the one markdown a test reads — `release-tooling/contract-version.test.ts`
|
||||
checks its `apiVersion` samples — and a README-only change skips that check; accepted, because
|
||||
those samples are illustrative and the copies that matter (`examples/`, `views/`, the template) are
|
||||
gated. **Valid while no markdown file is rendered or executed.**
|
||||
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
||||
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs
|
||||
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
|
||||
file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's
|
||||
web-image build races another run's container creation on the `<project>-web` tag. Accepted for a
|
||||
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
|
||||
- Dependencies install to `/node_modules`, above `WORKDIR /app`
|
||||
- A container whose output a human then edits or deletes runs as the invoking user
|
||||
- Anything the browser logs fails the E2E test that provoked it
|
||||
- The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium
|
||||
- The docs-only CI skip is `*.md` anywhere in the tree
|
||||
- CI docker logins share the runner host's Docker config
|
||||
|
||||
## Docker only — no host tooling
|
||||
|
||||
@@ -355,6 +143,7 @@ Docker Compose. **Never run `node`, `npm`, or `tsc` on the host.**
|
||||
```bash
|
||||
docker compose up # dev server, live reload
|
||||
docker compose run --rm --no-deps web npm run typecheck # strict type check (--no-deps: skip Ory)
|
||||
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --rm --no-deps --build e2e npm run typecheck # the E2E specs
|
||||
docker compose run --rm --no-deps web npm test # tests
|
||||
docker compose -f compose.yml up --build -d # production
|
||||
```
|
||||
@@ -367,7 +156,7 @@ docker compose -f compose.yml up --build -d # production
|
||||
stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands
|
||||
copy-pasteable; deeper detail lives in its own section, linked.
|
||||
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
|
||||
by **what an adopter reaches for first**, not by architectural layering: Overview → Users, groups
|
||||
by **what an adopter reaches for first**, not by architectural layering: Goals → Audience → Overview → Glossary → Users, groups
|
||||
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
||||
Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
|
||||
map → Extending. Place a new section by how early an adopter needs it. **Users, groups &
|
||||
@@ -388,7 +177,7 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
|
||||
If a file genuinely must be plain JavaScript, use `.js`; `"type": "module"` is set in both
|
||||
`package.json`s, so `.js` is ESM.
|
||||
- **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit.
|
||||
- Before finishing a change, run the typecheck and tests above; both must pass.
|
||||
- Before finishing a change, run the typechecks and tests above; all must pass.
|
||||
- Tests use the built-in `node --test` runner — no test framework dependency.
|
||||
- English everywhere.
|
||||
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
|
||||
@@ -429,12 +218,10 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
|
||||
`{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed `%`-encoding).
|
||||
Don't funnel many routes into one dispatcher that re-parses `ctx.url.pathname`: it duplicates the
|
||||
URL shape, ignores the router's params, and has to re-handle HEAD. Factor shared per-request setup
|
||||
into a small `withX` wrapper — see `examples/plugins/admin/`.
|
||||
- **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking
|
||||
canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin
|
||||
routing, 405/404 and error mapping. The pure parts are already extracted and separately tested;
|
||||
what remains is orchestration. Planned split along those seams; don't grow it further without
|
||||
taking one out.
|
||||
into a small `withX` wrapper — see `examples/plugins/auth-admin/`.
|
||||
- **A function stays within 50 lines and complexity 20** (ESLint's defaults, measured by
|
||||
`src/function-limits.test.ts`). Split one that outgrows them; never add it to the test's
|
||||
`grandfathered` list or raise a ceiling there.
|
||||
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer agents.
|
||||
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
|
||||
`src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own
|
||||
@@ -460,3 +247,13 @@ Default to **no comment**. Delete one that restates the adjacent code, repeats a
|
||||
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?
|
||||
|
||||
+252
@@ -4,6 +4,258 @@ The release version **is** the plugin contract version (`HOST_API_VERSION`), so
|
||||
contract break: a plugin's `apiVersion` must match the host's `major.minor` or discovery refuses it
|
||||
at boot. Entries start at 0.3.0.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Plugin contract 0.5: set `apiVersion` to `"0.5.0"`
|
||||
|
||||
The host's contract version is now 0.5.0, so discovery refuses a plugin whose `apiVersion` is still
|
||||
`0.4.x`. Change the literal in each plugin's manifest when you upgrade, and add `assetHref` to any
|
||||
`RequestContext` your tests build by hand: `assetHref: (href) => href`.
|
||||
|
||||
### The core dashboard view is `dashboard`, not `index`
|
||||
|
||||
`views/index.ejs` is now `views/dashboard.ejs`. The `apiVersion` check can't see this: a plugin view
|
||||
that still does `include("index")` fails when it renders, with the 500 page. Change it to
|
||||
`include("dashboard")`.
|
||||
|
||||
### `KetoClient.transactTuples` applies inserts and deletes as one transaction
|
||||
|
||||
`transactTuples({ delete, insert })` sends both lists to Keto in one `PATCH /admin/relation-tuples`,
|
||||
so all of them apply or none does. A `KetoClient` you build by hand, such as a test stub, must add the
|
||||
method. The admin example saves a user's or group's permissions through it, so a Keto failure leaves
|
||||
every grant as it was and answers with the error page.
|
||||
|
||||
### The admin example deletes a group's grants and members in one Keto transaction
|
||||
|
||||
A Keto failure while deleting a group now leaves the group as it was, with its grants and members,
|
||||
and answers with the error page. It used to leave some grants gone and the members still listed.
|
||||
|
||||
### The admin example keeps a user's permissions section when it refuses a self-deactivate or self-delete
|
||||
|
||||
The refusal page used to drop the permissions section of the user form.
|
||||
|
||||
### The admin example lists a user's or group's grants that no installed plugin declares, with a Remove
|
||||
|
||||
A direct grant of a name no installed plugin declares, such as one left by an uninstalled plugin,
|
||||
used to stay invisible and unchangeable, and reinstalling the plugin silently reactivated it. The
|
||||
user and group pages now list these apart from the checkboxes, each with a Remove button (hidden from
|
||||
a `:read`-only holder). The example's catalogs gain `admin.grants.remove`, `admin.grants.removed`,
|
||||
`admin.grants.undeclaredHint` and `admin.grants.undeclaredLegend`, and its routes gain
|
||||
`POST /auth-admin/users/:id/permissions/remove` and `POST /auth-admin/groups/:name/permissions/remove`.
|
||||
|
||||
### An operator can hide or rename a plugin's profile-menu entry
|
||||
|
||||
A `profileMenu` entry takes an optional `id`, and `config/menu.ts`'s `override.hide` and
|
||||
`override.rename` apply to it as they do to a nav node. Nav nodes and profile-menu entries share one
|
||||
set of ids, so the boot refuses an entry whose `id` another entry or nav node already uses.
|
||||
|
||||
### The admin example plugin is now `auth-admin`, and its screens live at `/auth-admin/*`
|
||||
|
||||
The Users, Groups, OAuth2-clients and Plugin-settings screens move from `/admin/*` to
|
||||
`/auth-admin/*`, and nothing redirects the old paths, so update any proxy rule, bookmark, alert or
|
||||
log query that matches `/admin/`. An installed `plugins/admin/` still declares `apiVersion` 0.4, so
|
||||
the boot refuses it: delete it, copy `examples/plugins/auth-admin/` into `plugins/`, and move any
|
||||
`locales/plugins/admin/` to `locales/plugins/auth-admin/`.
|
||||
|
||||
### The `request` log line renames `method`, `path`, `status` and `ms`, and gains the caller, site and route
|
||||
|
||||
Update dashboards and log queries: the `request` line and the request span now use OpenTelemetry's
|
||||
HTTP names. `method`, `path` and `status` become `http.request.method`, `url.path` and
|
||||
`http.response.status_code`. `ms` is gone: latency is the span's duration, so it needs
|
||||
`OTLP_ENDPOINT`. New are `http.route` (when a route matched), `server.address` (the `Host`),
|
||||
`user_agent.original` and `client.address`. A plugin's own lines for a request carry them too,
|
||||
except the status.
|
||||
|
||||
`client.address` is the socket's peer. Set `TRUSTED_PROXIES` (comma-separated IPs or CIDR ranges) to
|
||||
the proxies in front of the host and it follows `X-Forwarded-For`; a bad entry refuses the boot.
|
||||
README → Observability says which ranges to list. The log now holds callers' IPs and user agents,
|
||||
which are personal data: say so in your privacy policy.
|
||||
|
||||
### `buildPaginationModel` builds the pagination partial's model
|
||||
|
||||
Call `buildPaginationModel(page, hrefFor, { label?, pageSizes?, pageParam?, pageSizeParam? })` from
|
||||
`@plainpages/plugin-api` in place of mapping `paginate()`'s result to `partials/pagination` by hand.
|
||||
`hrefFor(n)` is your list's URL on page n, and the rows-per-page form carries the same list state as
|
||||
the links. A list that renamed its params in `parseListQuery` passes the same names here.
|
||||
|
||||
### A plugin can add links to the profile menu
|
||||
|
||||
Declare `profileMenu: [{ href, label, icon?, …gate }]` in the manifest. Each entry names exactly one
|
||||
gate like a nav node, and shows above Sign out to a signed-in visitor who passes it. Discovery
|
||||
refuses an entry without an `href`, a `label`, or exactly one gate.
|
||||
|
||||
### A plugin can version its own `/public/` files
|
||||
|
||||
Wrap the href of a script, image or other file your own markup links in `assetHref("/public/<id>/…")` in a view,
|
||||
or `ctx.assetHref(…)` in a handler. The href gets the file's content hash, and the browser caches the
|
||||
file for a year instead of revalidating it every five minutes.
|
||||
|
||||
### Discovery warns when a gate names a permission no plugin declares
|
||||
|
||||
A route or nav node that gates on a permission no installed plugin declares now logs a boot warning
|
||||
naming the plugin and the permission. The admin screens offer only declared permissions, so nobody can
|
||||
be granted such a gate from them. Add the name to the plugin's `permissions` to silence the warning.
|
||||
|
||||
### Discovery refuses a plugin with the id `error`
|
||||
|
||||
`/error` is the page Kratos sends a failed self-service flow to, and a plugin named `error` would
|
||||
shadow it. Discovery now refuses that id at boot, like the other reserved ids. Rename such a plugin's
|
||||
folder before upgrading.
|
||||
|
||||
### The admin's "group already exists" refusal is translated
|
||||
|
||||
Creating a group whose name is taken shows the refusal in the page's language, not always in English.
|
||||
|
||||
### A malformed URL variable's boot error no longer prints the value
|
||||
|
||||
When a URL variable such as `PLUGIN_DB_URL`, `PLUGIN_DB_ADMIN_URL` or `OTLP_ENDPOINT` can't be parsed,
|
||||
the boot error names the variable and not its value, so a DSN whose password breaks parsing doesn't
|
||||
put the password in the log.
|
||||
|
||||
### A secret plugin setting's bad value stays out of the boot error
|
||||
|
||||
When a `secret` setting's value fails validation, the boot error no longer prints it, so a malformed
|
||||
DSN doesn't put its password in the log.
|
||||
|
||||
### The admin's recovery-code notice links to the recovery flow the code belongs to
|
||||
|
||||
After an admin generates a recovery code for a user, the notice asks them to send the user both the
|
||||
code and its link. The link opens the recovery flow the code belongs to, where the user enters it.
|
||||
Before, the notice linked a new recovery flow at its email step, which doesn't accept the code.
|
||||
|
||||
### A verification or recovery code pasted with whitespace around it is accepted
|
||||
|
||||
The code field on the verification and recovery pages takes a code with spaces or tabs around it.
|
||||
Pasting a code from an email often adds them, and before, the browser refused the code. Those two
|
||||
forms now post to Plainpages, which trims the code and passes the form on to Kratos at
|
||||
`KRATOS_PUBLIC_URL`. A visitor who picked a language now keeps it through both flows.
|
||||
|
||||
### A filter shows its selected and focused choice in browsers without `:has()`
|
||||
|
||||
On Chromium below 105, Firefox below 121 and Safari below 15.4, a filter's segments and chips now show
|
||||
their native radio or checkbox, which marks the selected choice and takes the keyboard focus ring.
|
||||
Before, they showed neither. Newer browsers look as before.
|
||||
|
||||
### The tests fail on a stylesheet feature the browser floor lacks
|
||||
|
||||
`npm test` checks every stylesheet in `public/`, `examples/` and `plugins/` against Chromium 87,
|
||||
Safari 15 and Firefox 115: a feature one of them lacks fails it unless an `@supports` around it
|
||||
names that feature. Mount a plugin in `plugins/<id>/` to check its stylesheets. Pages render as
|
||||
before in every browser.
|
||||
|
||||
### Every release reaches Docker Hub, and the Hub page has a short description
|
||||
|
||||
Releases 0.4.1 to 0.4.20 were tagged in git but never published to Docker Hub or as semver tags in
|
||||
the Gitea registry; they are now, and every later release is. The Hub page gains a one-line
|
||||
description.
|
||||
|
||||
### The admin example stops an administrator locking themselves out through a group
|
||||
|
||||
On the Groups screen, unticking a permission on a group you are in, removing yourself (or a nested
|
||||
group you are in) from its members, or deleting the group is refused when it would take away a
|
||||
permission you don't hold directly. The page names the permissions, and another administrator can
|
||||
make the change. The check errs towards refusing: it also refuses when you would keep the permission
|
||||
through another group, or keep your membership another way.
|
||||
|
||||
### The admin example titles a read-only user page "View user"
|
||||
|
||||
A holder of `users:read` without `users:write` sees the user page titled "View user", with a "View"
|
||||
breadcrumb, instead of "Edit user".
|
||||
|
||||
### Browsers cache stylesheets instead of downloading them on every page
|
||||
|
||||
Every `/public/` URL the host renders carries `?v=<hash of the file>` and is cached for a year as
|
||||
immutable: the shell's stylesheets, favicon and logo, the `styles` a plugin passes to the shell, and
|
||||
the stylesheets of the standalone error and consent pages. A changed file gets a new hash, so
|
||||
browsers fetch it at once. A plugin names its stylesheets exactly as before. Any other `/public/` URL, such as a script
|
||||
a plugin's own markup links, is cached for five minutes, then revalidated with `ETag` or
|
||||
`Last-Modified`. A cache or CDN in front of Plainpages may now store `/public/` responses.
|
||||
|
||||
### A language chosen on a sign-in link no longer drops an absolute return target
|
||||
|
||||
`/login?return_to=<absolute URL>&locale=sv-SE` passed the visitor to the dashboard after sign-in and
|
||||
dropped the OAuth2 login challenge in `return_to`. The target now reaches Kratos, and the chosen
|
||||
language is lost across that round-trip instead.
|
||||
|
||||
### The admin example refuses a permission save made from an out-of-date page
|
||||
|
||||
Two administrators editing one user's or group's permissions no longer overwrite each other. The
|
||||
form carries a token for the grants it showed. A save that would change something, made after those
|
||||
grants changed, is refused with "These permissions changed while you had the page open", and the page
|
||||
shows the current grants. A POST with no token is refused the same way, so a script posting to
|
||||
`/auth-admin/users/:id/permissions` or `/auth-admin/groups/:name/permissions` must first read the token from the
|
||||
page's `baseline` field.
|
||||
|
||||
### The admin example confirms a permission save and reports a partial one
|
||||
|
||||
A save now lands on the page with "Permissions saved." On a user, a changed grant reaches the user's
|
||||
live sessions at once when the revocation denylist is on.
|
||||
|
||||
### A read-only holder sees View, not Edit, on the admin example's users list
|
||||
|
||||
A holder of `users:read` without `users:write` gets a "View" row action with an eye icon. The admin
|
||||
example's catalogs gain `admin.grants.saved`, `admin.grants.stale` and
|
||||
`admin.users.view`, so a translated catalog mounted under `locales/plugins/auth-admin/` must add them.
|
||||
|
||||
### Every page carries a smaller icon sprite
|
||||
|
||||
`i-chart`, `i-copy` and `i-download` are gone from the inlined icon sprite, so a plugin that
|
||||
referenced one now renders a blank icon. Nothing in the host or the example plugins used them. Pick
|
||||
another id from `views/partials/icons.ejs`, or inline your own SVG.
|
||||
|
||||
### The README defines its terms and states its intended scale
|
||||
|
||||
A new Glossary defines host, plugin, manifest, chrome, nav fragment, permission name, system plugin
|
||||
and denylist. Plugin dependencies also states the scale Plainpages is sized for: a handful of plugins
|
||||
per deployment.
|
||||
|
||||
### The mobile nav stays reachable on engines without `:has()`
|
||||
|
||||
At 860px and narrower, an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23,
|
||||
and Firefox below 121) hid the sidebar off-canvas with no way to open it, so the pages the nav links
|
||||
to could not be reached. Now the sidebar moves off-canvas, behind the hamburger, only where `:has()`
|
||||
works. Elsewhere it stays in the flow above the content and the hamburger is hidden.
|
||||
|
||||
### The whole nav tree shows on engines without `:has()`
|
||||
|
||||
On an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23, and Firefox below 121)
|
||||
every nested nav entry was hidden with nothing to open it. Now the tree folds only where `:has()`
|
||||
works and stays fully open elsewhere, with its toggles hidden. The theme switch, which also needs
|
||||
`:has()`, is hidden there.
|
||||
|
||||
### Button and badge text meets 4.5:1 contrast
|
||||
|
||||
The primary button and the current page button take their text colour from the new `--on-accent`
|
||||
token: white in light mode, near-black in dark mode, where white on the accent was 2.7:1. The
|
||||
light-mode success, warning and info badge text is darker. A plugin that overrides `--accent` or
|
||||
`--l-accent` should set `--l-on-accent` and `--d-on-accent` to match.
|
||||
|
||||
### Unused auth stylesheet rules are gone
|
||||
|
||||
`public/css/auth.css` no longer holds the `.auth-view` screen switch, `.tpl-*` preview controls,
|
||||
`.forgot-alert`, `.register-alert`, `.remember` and `.auth-foot`; no core view used them. A plugin
|
||||
view that did must carry its own rules.
|
||||
|
||||
### Colours render on engines without `oklch()`
|
||||
|
||||
The theme palette in `public/css/styles.css` is hex, not `oklch()`. On an engine older than
|
||||
Chromium 111 (Tizen 2024, webOS TV 24) `var(--accent)` was invalid, so the primary button was white
|
||||
text on no background. A plugin that sets a colour custom property, such as `--accent` or `--l-accent`,
|
||||
should use hex, `rgb()` or `hsl()` too.
|
||||
|
||||
### One horizontal inset across the content pane
|
||||
|
||||
The topbar, filter bar, pager, form page and empty-table message inset their content by `--pad-x`
|
||||
(12px), as table cells do, instead of 16px. A plugin that overrides `--pad-x` now moves all of them
|
||||
together.
|
||||
|
||||
### Pages are not kept by a shared cache
|
||||
|
||||
Every page and JSON response now carries `Cache-Control: private, no-store`, so a cache or reverse
|
||||
proxy in front of Plainpages can't store a signed-in page and serve it to the next visitor. A route
|
||||
whose page is the same for everyone can still set its own `cache-control` through
|
||||
`RouteResult.headers`; assets are unchanged.
|
||||
|
||||
## 0.4.0
|
||||
|
||||
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
||||
FROM node:24.21.0-alpine3.24
|
||||
FROM node:24.22.2-alpine3.24
|
||||
|
||||
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
|
||||
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
|
||||
|
||||
@@ -28,17 +28,17 @@ docker compose up -d # http://localhost:3000, live-reloads on source chan
|
||||
**`admin@plainpages.local` / `admin`**.
|
||||
|
||||
**3. Enable user & group admin (optional).** The core ships **no admin GUI** — the Users / Groups
|
||||
/ OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/admin/*`:
|
||||
/ OAuth2-clients screens are a drop-in plugin. Copy it in to mount them at `/auth-admin/*`:
|
||||
|
||||
```bash
|
||||
cp -r examples/plugins/admin plugins/admin
|
||||
cp -r examples/plugins/auth-admin plugins/auth-admin
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The bootstrap grants the seeded admin every permission the installed plugins declare, so the
|
||||
**Admin** section now shows in the menu. Use `up -d`, not `restart web` — the seed runs in the
|
||||
one-shot `bootstrap` service, and only `up` re-runs it. See
|
||||
[`examples/plugins/admin/`](examples/plugins/admin/).
|
||||
[`examples/plugins/auth-admin/`](examples/plugins/auth-admin/).
|
||||
|
||||
**4. Add your first plugin.** The clone is bind-mounted into the container, so a new
|
||||
folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`:
|
||||
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
|
||||
import { definePlugin } from "@plainpages/plugin-api";
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0",
|
||||
apiVersion: "0.5.0",
|
||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||
routes: [
|
||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||
@@ -68,9 +68,22 @@ From here, render real pages against the app shell and fetch upstream data — s
|
||||
[Building plugins](#building-plugins) and the runnable reference in
|
||||
[`examples/plugins/scheduling/`](examples/plugins/scheduling/).
|
||||
|
||||
**Without a clone**, the image runs a trial stack on its own. Save the `compose.yml` the
|
||||
[Docker Hub page](https://hub.docker.com/r/larvit/plainpages) shows, then extract the Ory config the
|
||||
image ships and start, with `X.Y.Z` the release that page names. The page goes on to a first plugin:
|
||||
|
||||
```bash
|
||||
docker run --rm larvit/plainpages:X.Y.Z tar -cf - ory | tar -xf -
|
||||
mkdir -p plugins
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Contents
|
||||
|
||||
- [Goals](#goals)
|
||||
- [Audience](#audience)
|
||||
- [Overview](#overview)
|
||||
- [Glossary](#glossary)
|
||||
- [Users, groups & permissions](#users-groups--permissions)
|
||||
- [naming a permission](#naming-a-permission)
|
||||
- [a worked example](#a-worked-example)
|
||||
@@ -113,7 +126,6 @@ From here, render real pages against the app shell and fetch upstream data — s
|
||||
- [end-to-end](#end-to-end-playwright)
|
||||
- [the full gate](#the-full-gate-one-command)
|
||||
- [CI/CD](#cicd)
|
||||
- [one-time setup](#one-time-ci-setup)
|
||||
- [Production & deployment](#production--deployment)
|
||||
- [Upgrading](#upgrading)
|
||||
- [Observability](#observability)
|
||||
@@ -121,6 +133,64 @@ From here, render real pages against the app shell and fetch upstream data — s
|
||||
- [Project layout](#project-layout)
|
||||
- [Extending the core](#extending-the-core)
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Achieve:** Less boilerplate for a server-rendered web GUI: a new project starts at
|
||||
its own domain, not at sign-in, a menu or a table.
|
||||
- Signal: a new plugin reaches a working, gated page with a menu, tables, filters and
|
||||
forms built only from the core building blocks.
|
||||
- Counter-signal: the core grows features only one project needs.
|
||||
- Why: boilerplate removed by absorbing a project's domain only moves it into the core.
|
||||
2. **Achieve:** Every building block works for the person using it: a non-technical user
|
||||
gets what they meant on the first try, and can't hurt themselves by accident.
|
||||
- Signal: bugs users hit in a shipped flow get fixed and stay fixed, such as a pasted
|
||||
code being refused, a double click acting twice, or an admin locking themselves out.
|
||||
- Counter-signal: a fix adds client-side JavaScript, or a setting every plugin has to
|
||||
know about.
|
||||
- Why: a fix that breaks zero-JS or simplicity moves the irritation from users to every
|
||||
plugin author.
|
||||
3. **Maintain:** Plainpages solves only the parts every project shares: auth, the GUI
|
||||
building blocks (menu, tables, filters, forms) and the plugin structure. Everything
|
||||
else is a plugin.
|
||||
4. **Maintain:** Every page is a server-rendered document that works with zero JavaScript,
|
||||
on old and embedded browsers too. A newer browser only makes it nicer.
|
||||
5. **Maintain:** State lives in the URL. Only state the URL can't carry lives in the
|
||||
database, never in local storage or JavaScript. A cookie carries only what must be bound
|
||||
to the browser: the session and its CSRF token.
|
||||
6. **Maintain:** The frontend is semantic: the HTML says what each thing is, and the CSS
|
||||
styles meaning, not looks. That keeps every page accessible.
|
||||
7. **Achieve:** Hackable: a plugin can change anything, and Plainpages stays out of its way.
|
||||
- Signal: a plugin replaces any page, partial, menu entry or string without forking the
|
||||
host, and fewer plugins need a host change to get unblocked.
|
||||
- Counter-signal: plugins break on upgrade with no version signal, or a plugin mistake
|
||||
shows up at runtime instead of at boot.
|
||||
- Why: freedom that breaks silently costs more than the boilerplate it saved.
|
||||
8. **Achieve:** Speed: pages render fast and stay small.
|
||||
- Signal: server time per page and bytes sent per page fall.
|
||||
- Counter-signal: a building block loses a feature or accessibility to save bytes.
|
||||
- Why: a fast page that does less only moves the work back into each plugin.
|
||||
9. **Achieve:** LLM-friendly: an LLM builds a correct plugin from the README and the
|
||||
examples alone.
|
||||
- Signal: an agent given only those writes a plugin that passes discovery and its E2E
|
||||
test on the first try.
|
||||
- Counter-signal: the README grows to restate what the code already shows.
|
||||
- Why: docs that pass the test by growing cost every later reader, human or LLM.
|
||||
10. **Maintain:** Every behaviour is an explicit configuration choice, the same in every
|
||||
environment.
|
||||
|
||||
## Audience
|
||||
|
||||
- **Developers starting a web GUI project, and the LLMs that build plugins with them.**
|
||||
They may rely on `@plainpages/plugin-api`, the core partials and the packaging promises,
|
||||
all versioned by `apiVersion`. A change that breaks a plugin takes a major bump.
|
||||
- **Operators** who self-host Plainpages. They may rely on semver-tagged images, on
|
||||
configuration through environment variables and the drop-in mounts (`plugins/`,
|
||||
`config/`, `locales/`), and on upgrades within a minor version that need no change on
|
||||
their side.
|
||||
- **Visitors** to a site built on Plainpages, on any browser, including the older engines in
|
||||
TVs, kiosks, thin clients and embedded panels. They may rely on pages that load fast and
|
||||
work without JavaScript, with a keyboard and a screen reader.
|
||||
|
||||
## Overview
|
||||
|
||||
Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu,
|
||||
@@ -130,17 +200,15 @@ public front with an authenticated area behind it.
|
||||
|
||||
- **Included in the core:** themed sign-in / register / reset (Kratos-backed), the design
|
||||
system + app shell, the config-driven menu, sessions, and access control. No domain screens.
|
||||
- **Opt-in admin plugin:** the **users, groups, and OAuth2-clients** screens ship as
|
||||
[`examples/plugins/admin/`](examples/plugins/admin/) — an ordinary plugin, reaching Ory through
|
||||
- **Opt-in auth-admin plugin:** the **users, groups, and OAuth2-clients** screens ship as
|
||||
[`examples/plugins/auth-admin/`](examples/plugins/auth-admin/) — an ordinary plugin, reaching Ory through
|
||||
the privileged [`ctx.system`](#system-capabilities-the-ctxsystem-surface) surface.
|
||||
- **You add:** everything else domain-specific, as **plugins** — built from the same building
|
||||
blocks the admin plugin uses.
|
||||
blocks the auth-admin plugin uses.
|
||||
|
||||
**Priorities:** simplicity, few dependencies, strict TypeScript, no build step, Docker-only,
|
||||
environment-agnostic (no `NODE_ENV` — every behaviour is an explicit config toggle). Identity,
|
||||
sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar services. The shape
|
||||
doesn't change as it grows: every plugin is the same self-contained folder, the hot path is the
|
||||
same I/O-free JWT check, and there is no app database.
|
||||
Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar
|
||||
services. The shape doesn't change as it grows: every plugin is the same self-contained folder,
|
||||
the hot path is the same I/O-free JWT check, and the host stores nothing of its own.
|
||||
|
||||
**Plugins are the extension model.** The plugin API is the product's main surface: powerful,
|
||||
predictable, and overloadable — a plugin can take over as much of a page as it wants. The host
|
||||
@@ -152,6 +220,24 @@ feature removes the need for JS (theme switching, popovers, disclosure) we use i
|
||||
avoid is shipping a client-side runtime, not using the platform. Markup is semantic and accessible
|
||||
(see [AGENTS.md](AGENTS.md)).
|
||||
|
||||
## Glossary
|
||||
|
||||
- **Host** — the `web` container: the Node process that discovers plugins, serves every page and
|
||||
owns no state of its own. Everything outside it (a plugin's upstream, Ory) is reached over the network.
|
||||
- **Plugin** — a folder under `plugins/` that adds domain features.
|
||||
- **Manifest** — the object a plugin's `plugin.ts` exports as its default: its routes, nav,
|
||||
permissions and hooks. The host checks it at boot and refuses a bad one ([The manifest](#the-manifest)).
|
||||
- **Chrome** — everything on a page that isn't the page's own content: brand, menu, user, theme and
|
||||
CSRF token. A handler gets it as `ctx.chrome` ([RequestContext](#requestcontext)).
|
||||
- **Nav fragment** — the `nav` entries one plugin contributes. The host merges every plugin's
|
||||
fragment into the one global menu and filters it per user ([The menu system](#the-menu-system)).
|
||||
- **Permission name** — a `<resource>:<action>` string such as `scheduling:write`, granted in Keto and
|
||||
carried in the session JWT's `permissions` claim ([Naming a permission](#naming-a-permission)).
|
||||
- **System plugin** — a plugin that administers Plainpages' own identity stack, through the
|
||||
privileged `ctx.system` ([System capabilities](#system-capabilities-the-ctxsystem-surface)).
|
||||
- **Denylist** — the optional in-memory list, per instance, of subjects revoked just now, so a
|
||||
deactivation or a user's own permission change lands before the JWT expires ([Instant revoke](#instant-revoke-the-optional-denylist)).
|
||||
|
||||
## Users, groups & permissions
|
||||
|
||||
Authorization here is two hops: a **user** — directly, or through a **group** — is granted a
|
||||
@@ -235,9 +321,9 @@ Against the reference plugins' actual routes:
|
||||
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
|
||||
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||
| `GET /admin/users` | `users:read` | 403 | 403 | ✅ | → `/login` |
|
||||
| `POST /admin/users` | `users:write` | 403 | 403 | ✅ | → `/login` |
|
||||
| `GET /admin/groups` | `groups:read` | 403 | 403 | 403 | → `/login` |
|
||||
| `GET /auth-admin/users` | `users:read` | 403 | 403 | ✅ | → `/login` |
|
||||
| `POST /auth-admin/users` | `users:write` | 403 | 403 | ✅ | → `/login` |
|
||||
| `GET /auth-admin/groups` | `groups:read` | 403 | 403 | 403 | → `/login` |
|
||||
|
||||
Bob reaches the shifts list with no direct grant — `support` → `staff` → `scheduling:read`, two hops
|
||||
resolved by Keto at his login. An anonymous visitor gets a **redirect** carrying `return_to`, not a
|
||||
@@ -247,7 +333,7 @@ door they cannot open.
|
||||
|
||||
### Granting a permission
|
||||
|
||||
Write the tuple. The admin plugin's **Users** and **Groups** screens do exactly this — each offers
|
||||
Write the tuple. The auth-admin plugin's **Users** and **Groups** screens do exactly this — each offers
|
||||
the declared permissions as a checkbox list — or use Keto's write API directly:
|
||||
|
||||
```bash
|
||||
@@ -330,9 +416,9 @@ URL/path-safe (`isValidPluginId`: lowercase `a–z`, digits, dashes) and also na
|
||||
`views/`, its `/public/<id>/` assets, and by convention its nav/permission names.
|
||||
|
||||
`RESERVED_PLUGIN_IDS` are refused at discovery — the gated `dashboard`, the Kratos auth flows
|
||||
(`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`), the `oauth2`
|
||||
(`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`) and their `error` page, the `oauth2`
|
||||
provider routes, and `public` — since plugin routes resolve first and a folder claiming one would
|
||||
silently shadow a built-in. `admin` is **not** reserved: the admin screens are themselves a plugin.
|
||||
silently shadow a built-in.
|
||||
|
||||
Installing a plugin is "drop the folder, restart"; removing one is "delete the folder, restart".
|
||||
|
||||
@@ -350,7 +436,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
||||
import { listThings, createThings } from "./handlers.ts";
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0", // semver string of the host contract this plugin was built against (see Versioning)
|
||||
apiVersion: "0.5.0", // semver string of the host contract this plugin was built against (see Versioning)
|
||||
|
||||
// Nav fragment, merged into the global menu and gate-filtered per user.
|
||||
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
|
||||
@@ -381,6 +467,7 @@ folder-derived `id` to produce the loaded `Plugin`.
|
||||
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
|
||||
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
|
||||
| `profileMenu` | no | `ProfileMenuEntry[]`: `{ href, label, icon?, id?, …gate }` links added above Sign out in the profile dropdown, shown only to a signed-in visitor. Each names [exactly one gate](#public-pages--menu-items); `icon`, `id` and `label` follow the same rules as a `nav` node. |
|
||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||
| `hooks` | no | See [Hooks](#hooks). |
|
||||
| `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). |
|
||||
@@ -433,6 +520,11 @@ export async function listThings(ctx: RequestContext) {
|
||||
nested names like `"things/edit"` work, out-of-bounds names are refused. The template may
|
||||
`include()` the core building-block partials and its own. To load the plugin's own CSS, pass its
|
||||
`/public/<id>/x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`.
|
||||
The host adds the file's content hash to every `/public/` href the shell renders, so browsers and
|
||||
any cache in front of Plainpages keep the file for a year. Get the same for a script or image your
|
||||
own markup links by wrapping its href: `<script src="<%= assetHref("/public/<id>/x.js") %>" defer>`
|
||||
in a view, `ctx.assetHref(href)` in a handler. An unwrapped `/public/` URL is kept for five
|
||||
minutes, then revalidated.
|
||||
- **Finer authorization than the route `permission`** uses the guards from `@plainpages/plugin-api`:
|
||||
`requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and
|
||||
`check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw
|
||||
@@ -471,7 +563,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
||||
import { landing, board } from "./pages.ts";
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0",
|
||||
apiVersion: "0.5.0",
|
||||
home: landing, // owns "/" — the public front page
|
||||
dashboard: board, // owns "/dashboard" — the post-login app home
|
||||
});
|
||||
@@ -503,6 +595,7 @@ interface RequestContext {
|
||||
locale: string; // the locale being served, e.g. "sv-SE"
|
||||
locales: string[]; // every installed locale, sorted
|
||||
localeHref(href): string; // carry an explicitly chosen locale onto a link this page renders
|
||||
assetHref(href): string; // version a /public/ href so it is cached for a year; any other href unchanged
|
||||
query: URLSearchParams; // alias of url.searchParams
|
||||
req: IncomingMessage;
|
||||
res: ServerResponse;
|
||||
@@ -514,7 +607,7 @@ interface RequestContext {
|
||||
}
|
||||
```
|
||||
|
||||
**`ctx.chrome`** — `{ brand, csrfToken, nav, signInHref, theme, user }`. Hand each field to the
|
||||
**`ctx.chrome`** — `{ brand, csrfToken, nav, signInHref, theme, user }` (`user.profileLinks` holds the profile-dropdown links). Hand each field to the
|
||||
matching `partials/shell` local and a `view` result renders the **native app shell**, exactly as
|
||||
`examples/plugins/scheduling/views/overview.ejs` does; a field you omit falls back to its shell
|
||||
default rather than erroring. `chrome.nav` is the whole global menu — every installed plugin's
|
||||
@@ -558,7 +651,7 @@ interface SystemCapabilities { // every field optional — present only
|
||||
|
||||
`ctx.system` is **`undefined` unless the host wired at least one** of these. A system plugin treats
|
||||
every field as optional and **degrades when absent** — the host never fails a request over it. The
|
||||
**admin plugin** ([`examples/plugins/admin/`](examples/plugins/admin/)) is the reference consumer:
|
||||
**auth-admin plugin** ([`examples/plugins/auth-admin/`](examples/plugins/auth-admin/)) is the reference consumer:
|
||||
Users uses `kratosAdmin`, Groups and the permission pickers `keto`, OAuth2 clients `hydra`, and a
|
||||
deactivate/delete or user permission-change calls `revoke` so the change lands before the JWT TTL;
|
||||
a missing capability renders a themed 503.
|
||||
@@ -638,14 +731,15 @@ The host detects collisions across all discovered plugins with `findConflicts` a
|
||||
| --- | --- | --- |
|
||||
| `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. |
|
||||
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
|
||||
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
|
||||
| `nav-id` | error | A nav node or profile-menu entry `id` is used more than once — the central override targets ids, so they must be unique across both. |
|
||||
| `setting` | error | Two settings resolve to the same `PLUGIN_SETTING_` variable name; rename a key or a plugin folder ([Plugin settings](#plugin-settings)). |
|
||||
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
|
||||
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. |
|
||||
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. Also warned: a route, nav node or profile-menu entry gates on a permission no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's `permissions`. |
|
||||
|
||||
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
|
||||
rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function
|
||||
rejects **per-manifest shape errors**: a non-array `nav`/`permissions`/`profileMenu`/`routes`, a non-function
|
||||
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
|
||||
route/nav node that does not name [exactly one gate](#public-pages--menu-items).
|
||||
route, nav node or profile-menu entry that does not name [exactly one gate](#public-pages--menu-items).
|
||||
|
||||
### Hooks
|
||||
|
||||
@@ -735,6 +829,9 @@ included, since the plugin folder *is* the repo. A baked image needs no extra st
|
||||
above every plugin, and a plugin resolves it from there — nothing to declare, just import it. A
|
||||
copy inside your plugin's own `node_modules` would shadow it with a *second* instance of the host's
|
||||
contract, turning a sign-in redirect into a 500, so discovery refuses one there at boot.
|
||||
- **Plainpages is sized for a handful of plugins per deployment, about ten.** Each plugin carries
|
||||
its own dependency tree, so every one adds to disk use and to the memory of the `web` process;
|
||||
hundreds of plugins is not a goal.
|
||||
- **The host never upgrades or dedupes your dependencies.** Two plugins depending on the same package
|
||||
each get their own copy at their own version, so neither can break the other by upgrading — and
|
||||
keeping yours current, and audited, is yours to own. Renovate here watches every manifest in this
|
||||
@@ -757,7 +854,7 @@ camel humps both becoming underscores — so `upstream` on the `scheduling` plug
|
||||
|
||||
```ts
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0",
|
||||
apiVersion: "0.5.0",
|
||||
settings: [
|
||||
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
|
||||
{ key: "pageSize", type: "number", default: 25 },
|
||||
@@ -775,7 +872,7 @@ export default definePlugin({
|
||||
```
|
||||
|
||||
`type` is one of `string`, `number`, `boolean`, `enum` (with `values`) or `url`. A declared type is
|
||||
coerced and checked at boot, so a mistyped value names the plugin, the key and the variable instead
|
||||
coerced and checked at boot, so a mistyped value names the plugin and the variable instead
|
||||
of surfacing later as a broken page.
|
||||
|
||||
**`required` and `default` are mutually exclusive** — a default means the setting can never fail, so
|
||||
@@ -794,7 +891,7 @@ installed plugin declares is only *reported* — it is usually a typo in the one
|
||||
set, and naming it turns two unrelated-looking errors into one. Declaring settings without an
|
||||
`onBoot` warns too: they resolve, but nothing receives them.
|
||||
|
||||
**Reading what a deployment is configured with.** The admin plugin's **Plugin settings** screen
|
||||
**Reading what a deployment is configured with.** The auth-admin plugin's **Plugin settings** screen
|
||||
(`plugin-settings:read`) lists every installed plugin, its declared keys, the variable that sets
|
||||
each, and whether the value came from the environment or the declared default.
|
||||
|
||||
@@ -811,7 +908,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
||||
let sql: ReturnType<typeof postgres>;
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0",
|
||||
apiVersion: "0.5.0",
|
||||
storage: true,
|
||||
hooks: {
|
||||
onBoot: async (boot) => {
|
||||
@@ -906,9 +1003,9 @@ The menu is **driven entirely by config** and assembled from two sources:
|
||||
|
||||
1. **Plugin fragments** — each plugin contributes its own `nav` (above).
|
||||
2. **A central override** — `config/menu.ts` (loaded by `src/ui/menu-config.ts`, validated at
|
||||
boot) — where the operator reorders, renames, groups, or hides items (by node `id`), and
|
||||
sets branding (app name, logo, default theme). The override always wins, applied before
|
||||
the per-user filter. A clean clone needs no `config/menu.ts`; defaults apply.
|
||||
boot) — where the operator reorders, renames, groups or hides nav nodes and renames or hides
|
||||
profile-menu entries, each by its `id`, and sets branding (app name, logo, default theme). The
|
||||
override always wins, applied before the per-user filter. A clean clone needs no `config/menu.ts`; defaults apply.
|
||||
|
||||
`config/` is an **empty drop-in mount point** (like `plugins/`): it ships empty, and you
|
||||
supply `config/menu.ts` by copying the template ([`examples/config/menu.ts`](examples/config/menu.ts))
|
||||
@@ -946,10 +1043,26 @@ fully styled and zero-JS:
|
||||
- **Partials:** app shell, nav tree, filter bar, data table (sort / select / row actions),
|
||||
pagination, form fields, badges, menus, auth cards.
|
||||
- **Helpers:** `composeNav` (menu from config), `parseListQuery`
|
||||
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math), and the
|
||||
(`?q=…&status=…&sort=…&page=…` → filter/sort/pagination), `paginate` (page math),
|
||||
`buildPaginationModel` (page math → the pagination partial, sketched below), and the
|
||||
auth guards (`src/auth/guards.ts`): `requireSession`, `can(permission)` (coarse JWT-claim check,
|
||||
zero I/O), `check(relation, object)` (the one live Keto call).
|
||||
|
||||
A list page's pagination, from URL to partial (`examples/plugins/auth-admin/admin-users.ts` is the full
|
||||
page):
|
||||
|
||||
```ts
|
||||
const query = parseListQuery(ctx.url); // { q, filters, sort, page, pageSize }
|
||||
const page = paginate(rows.length, query.page, query.pageSize); // or an upstream's total
|
||||
const visible = rows.slice(page.from - 1, page.to);
|
||||
const pagination = buildPaginationModel(page, (n) => listHref({ ...query, page: n }), { pageSizes: [25, 50, 100] });
|
||||
// view: <%- include("partials/pagination", pagination) %>
|
||||
```
|
||||
|
||||
`listHref` is yours: the list's URL with all its state. The rows-per-page form's hidden inputs are
|
||||
read off it, so the form carries the same filters, search and sort as the page links. A list that
|
||||
renamed its params in `parseListQuery` passes the same `pageParam`/`pageSizeParam` here.
|
||||
|
||||
## Interactivity: zero-JS spine
|
||||
|
||||
The core and all building blocks **work with zero JavaScript** — theme switching and filtering are
|
||||
@@ -961,6 +1074,18 @@ underneath it — cramped, but nothing is unreachable. List state
|
||||
(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable and shareable; the
|
||||
URL is the only state the UI keeps.
|
||||
|
||||
**Two browser floors** serve goal 4. Every page's content and actions stay reachable on
|
||||
Chromium 87, Safari 15 and Firefox 115, the engines in years-old TVs, kiosks and Qt panels. Only
|
||||
features [Baseline Widely available](https://web-platform-dx.github.io/baseline/) (in every
|
||||
major engine for 30 months) may shape how a page looks; anything newer, like popovers or anchor
|
||||
positioning, only enhances. A feature the floor engines lack, such as `:has()`, may
|
||||
decorate a page but never decide whether something shows or can be read. `npm test` fails on a
|
||||
stylesheet using a feature Chromium 87, Safari 15 or Firefox 115 lacks, unless an `@supports`
|
||||
around it names that feature. It checks every stylesheet in `public/`, `examples/` and `plugins/`,
|
||||
so mount your plugin in `plugins/<id>/` and run it. A few constructs fail even when guarded, such as
|
||||
`@layer`, nested rules and media range syntax, while a media feature passes unguarded, since a
|
||||
browser without it skips the query; `src/test-tooling/stylesheet-floor.ts` lists the rules.
|
||||
|
||||
**A field whose options depend on another field is asked for in steps, one GET form each**, so every
|
||||
choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made.
|
||||
Once the query names a value, that field renders read-only (`field` with `readonly`, and a `link` back
|
||||
@@ -1012,7 +1137,7 @@ catalog for it. There is **no locale cookie**: the URL is the only place a choic
|
||||
link is shareable and a page is what its address says it is. When the URL asked for a language, the
|
||||
host carries `?locale=` onto every link *it* renders and `ctx.localeHref(href)` does the same for a
|
||||
plugin's. The picker lists every installed locale and renders **on every page**. After a POST the
|
||||
current URL may answer no GET (`POST /admin/users/:id/recovery` has no GET sibling), so the host
|
||||
current URL may answer no GET (`POST /auth-admin/users/:id/recovery` has no GET sibling), so the host
|
||||
points the picker at this path when it answers GET, else the page the form was submitted from, else
|
||||
`/` — switching language there leaves that POST's own result behind.
|
||||
|
||||
@@ -1083,7 +1208,7 @@ Rules worth knowing:
|
||||
edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through
|
||||
to them. Keep your catalog for your domain words.
|
||||
- **Reserved view locals:** `t`, `locale`, `locales`, `localeHref`, `localeParam`, `localeSwitch`,
|
||||
`dir`. They are merged after your `data`, so a colliding key of yours is ignored rather than
|
||||
`dir`, and `assetHref`. They are merged after your `data`, so a colliding key of yours is ignored rather than
|
||||
breaking the shell. `locale` is likewise reserved in `parseListQuery`, never returned as a filter.
|
||||
|
||||
**Kratos writes the auth flow's own text** (field labels, validation errors), tagging each string
|
||||
@@ -1106,7 +1231,7 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
|
||||
| --- | --- | --- |
|
||||
| `APP_URL` | _unset_ (dev: `http://localhost:3000`) | the canonical public URL — the **single source** for the host this deployment lives on; set ⇒ off-host visitors are redirected here, unset ⇒ no redirect (see [Canonical host](#canonical-host-one-public-url)) |
|
||||
| `PORT` | `3000` | web listen port |
|
||||
| `CACHE_TEMPLATES` | `false` | cache compiled EJS templates (`true` in prod) |
|
||||
| `CACHE_TEMPLATES` | `false` | cache compiled EJS templates and `/public/` asset versions (`true` in prod) |
|
||||
| `SECURE_COOKIES` | `false` | mark our session/CSRF cookies `Secure` (`true` in prod https; off in dev http) |
|
||||
| `REQUIRE_SECURE_SECRETS` | `false` | when `true`, `CSRF_SECRET` — and `PLUGIN_DB_SECRET` once storage is configured, and every plugin setting declared `secret` — must be supplied and differ from the dev throwaway |
|
||||
| `LOG_LEVEL` | `info` | min severity logged: `error`/`warn`/`info`/`verbose`/`debug`/`silly`/`none` |
|
||||
@@ -1114,6 +1239,7 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
|
||||
| `SERVICE_NAME` | `plainpages` | OTLP `service.name` on every log + span — brand it as your own deployment |
|
||||
| `OTLP_ENDPOINT` | _unset_ | OpenTelemetry Collector HTTP base URI; set ⇒ export logs + traces (unset ⇒ console only) |
|
||||
| `OTLP_PROTOCOL` | `http/json` | OTLP wire format: `http/json` or `http/protobuf` |
|
||||
| `TRUSTED_PROXIES` | _unset_ | comma-separated IPs/CIDR ranges of the reverse proxies in front of the host; only a request from one lets `X-Forwarded-For` name the caller in logs and spans ([Observability](#observability)) |
|
||||
| `KRATOS_PUBLIC_URL` / `KRATOS_ADMIN_URL` | `http://kratos:4433` / `:4434` | identity (self-service / admin) |
|
||||
| `KETO_READ_URL` / `KETO_WRITE_URL` | `http://keto:4466` / `:4467` | authorization check / write |
|
||||
| `HYDRA_ADMIN_URL` | `http://hydra:4445` | OAuth2 provider admin API (login/consent handshake) |
|
||||
@@ -1166,9 +1292,9 @@ override sets `APP_URL=http://localhost:3000`, and `127.0.0.1` is canonicalised
|
||||
A clean clone needs **none** of the above. What can't be auto-generated is **production-only**:
|
||||
|
||||
1. **Production secrets** — every value below ships as a committed dev throwaway and **must** be
|
||||
replaced before a deploy faces the internet. Only the first is enforced:
|
||||
`REQUIRE_SECURE_SECRETS=true` refuses to boot on a missing or throwaway `CSRF_SECRET` and
|
||||
**nothing else** — the rest fail silently, so treat this as a checklist.
|
||||
replaced before a deploy faces the internet. Of them, `REQUIRE_SECURE_SECRETS=true` checks only
|
||||
`CSRF_SECRET`, and the flag's [Configuration](#configuration) row lists what else it checks; the
|
||||
rest fail silently, so treat the table as a checklist.
|
||||
|
||||
| Secret | Where | Protects |
|
||||
| --- | --- | --- |
|
||||
@@ -1299,7 +1425,7 @@ Nothing in the menu or first-party pages needs Hydra.
|
||||
post-logout redirect. The first-party `POST /logout` still owns ending the Kratos session and our
|
||||
JWT cookie.
|
||||
|
||||
Clients are registered from the admin plugin's **OAuth2 clients** screen (`/admin/clients`): Hydra
|
||||
Clients are registered from the auth-admin plugin's **OAuth2 clients** screen (`/auth-admin/clients`): Hydra
|
||||
shows the generated `client_secret` **once**, on the confirmation page. Confidential vs public
|
||||
(PKCE) and the first-party auto-consent flag are set at registration; writes go only to Hydra.
|
||||
|
||||
@@ -1319,6 +1445,11 @@ authorization graph, so treat it the same. `compose.yml` publishes none of the s
|
||||
so a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our two cookies
|
||||
obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
|
||||
|
||||
**The CSRF token isn't bound to the session.** It is a signed double-submit token, so an attacker who
|
||||
can write cookies on your origin can plant one they know. `SameSite=Lax` keeps a cross-site page
|
||||
out, and HSTS (`SECURE_COOKIES=true`) a network attacker. A hostile page on a sibling subdomain can
|
||||
still set cookies, so host nothing untrusted on one.
|
||||
|
||||
**Offboarding is not instant by default** — a revoked permission or deactivated identity lands
|
||||
within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on.
|
||||
|
||||
@@ -1329,8 +1460,8 @@ unauthenticated Ory ports. Install only what you trust, and let the plugin's own
|
||||
|
||||
Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing
|
||||
**every** committed dev secret ([what you must supply](#what-you-must-supply-the-only-manual-prep)).
|
||||
`REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres'
|
||||
or the demo admin's throwaways.
|
||||
`REQUIRE_SECURE_SECRETS` guards only the secrets its [Configuration](#configuration) row lists;
|
||||
nothing fails loud if you ship Ory's, Postgres' or the demo admin's throwaways.
|
||||
|
||||
## Email
|
||||
|
||||
@@ -1393,12 +1524,13 @@ session lives in Kratos and the data lives outside the process.
|
||||
|
||||
## Testing
|
||||
|
||||
Type check and unit tests run off the Ory stack — `--no-deps` keeps `web` from dragging up its
|
||||
`depends_on` services:
|
||||
Type checks and unit tests run off the Ory stack — `--no-deps` keeps each service from dragging up
|
||||
its `depends_on` services:
|
||||
|
||||
```bash
|
||||
docker compose run --rm --no-deps web npm run typecheck # strict tsc --noEmit
|
||||
docker compose run --rm --no-deps web npm test # node --test (units)
|
||||
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
|
||||
```
|
||||
|
||||
### End-to-end (Playwright)
|
||||
@@ -1411,7 +1543,7 @@ no Node/browsers on the host. Five suites, each on its own stack:
|
||||
| `visual.spec.ts` + `language.spec.ts` | `e2e-tests/compose.visual.yml` | Ory-free: the rendered design system (app shell, theme switch, mobile off-canvas, icon sprite, CSRF-guarded sign-out, landing, 404), plugin permission-gating, and [language switching](#languages-i18n) |
|
||||
| `auth-refresh.spec.ts` | `e2e-tests/compose.auth.yml` | The real Ory stack with the session→JWT TTL cut to 8s: a lapsed JWT is silently re-minted from the live Kratos session, and once revoked the stale cookie is cleared |
|
||||
| `oauth-login.spec.ts` | `e2e-tests/compose.oauth.yml` | Another app logging in through us — register a client, start an authorization flow, `/oauth2/login` accept, `/oauth2/consent` Allow → authorization code |
|
||||
| `full-flow.spec.ts` | `e2e-tests/compose.full.yml` | The browser UI: password + mocked-SSO login, menu filtering by permission, admin users/groups/permissions CRUD, OAuth2-clients register → one-time secret → delete, a gated plugin page, logout |
|
||||
| `full-flow.spec.ts` | `e2e-tests/compose.full.yml` | The browser UI: password + mocked-SSO login, emailed-code verification + recovery, menu filtering by permission, admin users/groups/permissions CRUD, OAuth2-clients register → one-time secret → delete, a gated plugin page, logout |
|
||||
| `devstack-login.spec.ts` | `compose.override.yml` + `e2e-tests/compose.devstack.yml` | The plain `docker compose up` topology on the **host network**: sign-in works both from the banner's `localhost:3000` and from `127.0.0.1:3000` via the [canonical-host redirect](#canonical-host-one-public-url) |
|
||||
|
||||
```bash
|
||||
@@ -1437,7 +1569,7 @@ Ory-backed ones share one backend and stay on Chromium.
|
||||
|
||||
### The full gate (one command)
|
||||
|
||||
`ci.sh` is the whole gate in one reproducible command — typecheck → unit tests →
|
||||
`ci.sh` is the whole gate in one reproducible command — typecheck (the specs in `e2e-tests/` too) → unit tests →
|
||||
each E2E suite against its own fresh stack, with a guaranteed `down -v` after each (even on
|
||||
failure) and a non-zero exit on the first failure. Run it locally before a release, or wire
|
||||
it into your CI service:
|
||||
@@ -1452,109 +1584,7 @@ gate runs them serially, one stack up/down per suite.
|
||||
|
||||
## CI/CD
|
||||
|
||||
Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
|
||||
[`ci.sh`](#the-full-gate-one-command) — the exact gate you run locally:
|
||||
|
||||
| Workflow | Trigger | Does |
|
||||
| --- | --- | --- |
|
||||
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image |
|
||||
| `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `latest` (plus `X` once major ≥ 1), sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger |
|
||||
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) |
|
||||
| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
|
||||
| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged |
|
||||
|
||||
`main` is not re-tested on push — its commits are meant to arrive already green from a
|
||||
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
|
||||
|
||||
**Merge gate on `main`** (Gitea branch protection + repo merge settings, set via the API —
|
||||
no repo files involved): direct pushes are blocked, changes land via PR only, the
|
||||
`CI / full-gate (push)` status must be green (admins included), and the only merge style is
|
||||
**fast-forward-only** — history stays linear and `main`'s head is the exact commit hash of
|
||||
the merged branch, which is why the branch's push-triggered status carries over.
|
||||
|
||||
**Container images** — after a green gate, `ci.yml` builds the app image and pushes it as
|
||||
`gitea.larvit.se/larvit/plainpages:<full commit hash>`. Because merges are fast-forward-only, the
|
||||
image for any `main` commit already exists from that commit's branch gate — build once, promote by
|
||||
re-tagging. The build runs inside the required gate, so a missing/expired token or a registry outage
|
||||
blocks **all** merges until restored; use a non-expiring token or track its expiry. Hash tags
|
||||
accumulate one image per gated push, so nightly `registry-cleanup.yml` prunes them
|
||||
([`registry-cleanup/cleanup.ts`](registry-cleanup/cleanup.ts) defines what survives). Don't add a
|
||||
pattern-based org cleanup rule for this package — its age/count heuristics can't see branch heads or
|
||||
release tags and would delete images the workflow protects.
|
||||
|
||||
**Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs
|
||||
`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `latest` and —
|
||||
once the major reaches `1` — `1`; nothing is rebuilt, so the released image is byte-identical to the
|
||||
gated one. While the major is `0` the bare-major tag is skipped, because a `0.x` minor is a contract
|
||||
break and a moving `:0` would carry one. It fails loud if no
|
||||
hash image exists — release tags must point at a commit that went through the gate. The same four
|
||||
tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only.
|
||||
|
||||
The [contract check](#contract-versioning) guards the tag before anything is published, refusing one
|
||||
whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to set.
|
||||
|
||||
**The Docker Hub overview** is published by a separate `publish-overview` job from
|
||||
[`release-tooling/dockerhub-overview.md.tmpl`](release-tooling/dockerhub-overview.md.tmpl), with
|
||||
`{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go
|
||||
stale. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the
|
||||
quick start stays a topology CI has actually run.
|
||||
It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
|
||||
outage leaves the promotion green and the images untouched; and the page has its own door — run the
|
||||
workflow manually with an `overview_version` input to republish it without cutting a release. That
|
||||
input goes through the same contract check as a tag: a non-semver value, or one whose `major.minor`
|
||||
disagrees with the tree being published, is refused. It uses
|
||||
the same `DOCKERHUB_TOKEN` the image push uses, which is why that token needs the **delete** scope.
|
||||
|
||||
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is
|
||||
read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift.
|
||||
Tags go with `--prune`, so deleting one here deletes it there on the next run, and a tag *created* on
|
||||
GitHub is swept away — cut releases on Gitea, never on the mirror. Branches other than `main` match
|
||||
no refspec and are left alone.
|
||||
|
||||
**Dependency updates** — `renovate.yml` runs [Renovate](https://docs.renovatebot.com) nightly
|
||||
(self-hosted, this repo only) against [`renovate.json`](renovate.json), bumping npm deps, Docker base
|
||||
images, Gitea action versions, and the image tags pinned inside workflow `run:` steps (a custom regex
|
||||
manager, so nothing pinned drifts unmanaged). Version-locked sets move together in one PR — the Ory
|
||||
images, and the Playwright runner + its browser image — and every bump keeps the **exact semver pin**
|
||||
exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once
|
||||
`CI / full-gate (push)` is green; only a red gate needs a human.
|
||||
|
||||
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`) cuts **one**
|
||||
`vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and
|
||||
**skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a
|
||||
trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a
|
||||
`Release-Bump: <updateType>` trailer onto the updates that reach a running Plainpages — the rules in
|
||||
[`renovate.json`](renovate.json) name them — and
|
||||
[`release-tooling/next-version.ts`](release-tooling/next-version.ts) turns the highest one into the next
|
||||
version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release
|
||||
version, an update big enough to reach a **minor** stops the job rather than tagging: bump
|
||||
`HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since
|
||||
`nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump,
|
||||
so the trailer is an allowlist: an update outside those rules carries none and rides the next patch
|
||||
release instead of escalating it. It is **tag-only**: the tag hands off to
|
||||
`release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by
|
||||
the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here.
|
||||
|
||||
### One-time CI setup
|
||||
|
||||
| Actions var / secret | Value |
|
||||
| --- | --- |
|
||||
| `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. |
|
||||
| `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a **read/write/delete** token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT reaches every repo under it, and delete is destructive). Delete is what publishing the overview needs; pushing images alone would not. |
|
||||
| `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. |
|
||||
| `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. |
|
||||
| `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. |
|
||||
|
||||
Gitea rejects secret names prefixed `GITEA_`/`GITHUB_`. Each job fails loud until its secret exists.
|
||||
The container package is **org-owned**, so it lists under `larvit/-/packages` — link it to the repo's
|
||||
Packages tab once: `POST /api/v1/packages/larvit/container/plainpages/-/link/plainpages`.
|
||||
|
||||
**The runner** — register one
|
||||
[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode labelled `docker-host`
|
||||
(`labels: ["docker-host:host"]`) on a machine with Docker Engine + Compose, git, and Node +
|
||||
github.com access. Runs must **never overlap** — the e2e stacks use fixed compose project names, the
|
||||
devstack suite uses host networking, and the workflows share the Docker daemon's registry login — so
|
||||
keep exactly one runner at capacity 1, with host ports 3000/4433 free.
|
||||
[The pipeline, releases and CI setup](docs/ci-cd.md).
|
||||
|
||||
## Production & deployment
|
||||
|
||||
@@ -1580,6 +1610,11 @@ Every response carries security headers (`src/http/security-headers.ts`): a stri
|
||||
so a branding logo must live under `/public/` or be a `data:` URI; a plugin route can override any
|
||||
header per-response via `RouteResult.headers`.
|
||||
|
||||
Every page and JSON response is `Cache-Control: private, no-store`, so a cache in front of `web`
|
||||
never keeps one user's page for the next. A route may override it only for a page that is the same for
|
||||
every visitor: no user, no CSRF token, no `Set-Cookie`, since the host adds a fresh CSRF cookie to
|
||||
every plugin route.
|
||||
|
||||
A deep link reached while signed out — or after the session JWT lapses mid-task — bounces to the
|
||||
themed sign-in and returns to the **page that was requested** (`return_to`, validated host-relative
|
||||
by `localPath` in `src/http/safe-url.ts`, so a crafted value can't turn login completion into an open
|
||||
@@ -1600,7 +1635,7 @@ tightened a manifest rule since. Discovery fails loud at boot rather than runnin
|
||||
honour, naming the plugin and the rule:
|
||||
|
||||
```bash
|
||||
rm -rf plugins/admin && cp -r examples/plugins/admin plugins/admin
|
||||
rm -rf plugins/auth-admin && cp -r examples/plugins/auth-admin plugins/auth-admin
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
@@ -1627,9 +1662,32 @@ Logging is **structured** and **OTLP-native**, on
|
||||
handler (an `AsyncLocalStorage`), so logs and traces correlate. Three explicit toggles:
|
||||
`LOG_LEVEL`, `LOG_FORMAT` (`text` dev / `json` prod) and `SERVICE_NAME`.
|
||||
|
||||
Every request emits one access line (`method`, `path` — the query is dropped, it can carry tokens —
|
||||
`status`, `ms`, `requestId`); login/logout, admin writes and missing-permission/CSRF rejections log
|
||||
at `info`/`warn`, the catch-all 500 and Ory-unreachable re-mint at `error`/`warn`. An inbound W3C
|
||||
Every request emits one access line, and its span carries the same fields, named by
|
||||
OpenTelemetry's HTTP conventions:
|
||||
|
||||
- `http.request.method`, `url.path` (the query is dropped, it can carry tokens),
|
||||
`http.response.status_code`, `server.address` (the `Host`), `user_agent.original`,
|
||||
`client.address` and `requestId`.
|
||||
- `http.route`: the matched pattern, such as `/auth-admin/users/:id`. It is absent when no route
|
||||
matched (a 404, a 405, a `/public/` file, a redirect) and on a plugin's `onRequest` short-circuit.
|
||||
- Latency is the span's duration, so it reaches you only with `OTLP_ENDPOINT` set. OTLP carries every
|
||||
attribute as a string.
|
||||
|
||||
A plugin's own lines for a request carry them too, except the status, which is known only when
|
||||
the response closes.
|
||||
|
||||
**These are personal data.** An IP and a user agent identify a caller, so a deployment's privacy
|
||||
policy must say the host logs them.
|
||||
|
||||
`client.address` is the socket's peer. Behind a reverse proxy that is the proxy, so set
|
||||
`TRUSTED_PROXIES` to its IPs or CIDR ranges: for a request from a listed peer, the address is the
|
||||
nearest one in `X-Forwarded-For` that is not itself listed. Anything else in the header is
|
||||
ignored. List only addresses a caller cannot send from. In Docker, `compose.yml` publishes the port
|
||||
on every interface, so a range that includes the bridge gateway lets a direct caller write any
|
||||
`client.address`.
|
||||
|
||||
Login/logout, admin writes and missing-permission/CSRF rejections log at `info`/`warn`, the
|
||||
catch-all 500 and Ory-unreachable re-mint at `error`/`warn`. An inbound W3C
|
||||
`traceparent` is **adopted**, continuing a trace started upstream.
|
||||
|
||||
**Distributed tracing covers every outbound call.** Because the request logger is ambient, all
|
||||
@@ -1713,9 +1771,9 @@ unnecessary here — the signature itself is already invalid.
|
||||
```
|
||||
src/ The app — strict tsc, no build step. *.test.ts sit beside their module.
|
||||
server.ts Entry point; config.ts env loader; logger.ts structured log + trace span + tracedFetch
|
||||
*.test.ts Topology guards with no source counterpart (compose/kratos/keto/hydra/postgres/ci-gate)
|
||||
http/ Request pipeline: app.ts (createApp), builtin-routes, context, body, cookie,
|
||||
static, safe-url (safeUrl + localPath), security-headers
|
||||
*.test.ts Guards with no source counterpart (compose/kratos/keto/hydra/postgres/ci-gate/function-limits)
|
||||
http/ Request pipeline: app.ts (createApp), builtin-route, context, body, cookie,
|
||||
static, safe-url (safeUrl + localPath), security-headers, request-fields · request-tracking (span/log attributes)
|
||||
auth/ Identity + the session-JWT hot path: jwt · jwt-middleware · jwks · gen-jwks (CLI) ·
|
||||
login · guards · csrf · denylist · flow-view · oauth-login · oauth-consent ·
|
||||
routes · bootstrap, and the Ory REST clients (kratos-public/admin, keto, hydra,
|
||||
@@ -1723,13 +1781,15 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
|
||||
i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime ·
|
||||
english · view-locals · locales/ (the core en-US + sv-SE catalogs)
|
||||
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
|
||||
(ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) ·
|
||||
(ctx.system) · discovery · conflicts · router · hooks · view-resolver · storage (the rules) ·
|
||||
storage-provisioning (the DDL; bootstrap-only, holds the driver)
|
||||
ui/ chrome (the one global menu) · shell-context · dashboard · nav (composeNav) ·
|
||||
menu-config (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
|
||||
ui/ chrome (`ctx.chrome`: menu, brand, user) · nav (composeNav) · menu-config
|
||||
(`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
|
||||
test-tooling/ Test-only: app-harness (starts `createApp` over fakes for the `src/http` tests) ·
|
||||
stylesheet-floor (the browser-floor check)
|
||||
|
||||
plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules
|
||||
views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503,
|
||||
views/ Core EJS in the one app shell: home, dashboard, auth, oauth-consent, error, 403/404/500/503,
|
||||
and partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card,
|
||||
alert, menu/popover, theme switch, language picker, icon sprite). Domain screens live
|
||||
in plugins.
|
||||
@@ -1739,7 +1799,7 @@ locales/ Drop-in mount: extra or replacement catalogs. Ships empty.
|
||||
plugins/ Drop-in mount: plugin folders, scanned at /app/plugins. Ships empty.
|
||||
ory/ Ory config — kratos/ (identity schema, oidc claims mapper, tokenizer + dev JWKS),
|
||||
keto/ (namespaces.keto.ts OPL), hydra/, postgres/init/
|
||||
examples/ Copy-in reference mirroring the mount dirs: plugins/scheduling/, plugins/admin/,
|
||||
examples/ Copy-in reference mirroring the mount dirs: plugins/scheduling/, plugins/auth-admin/,
|
||||
config/menu.ts, and shifts-upstream/ (the dev mock backend)
|
||||
e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml;
|
||||
proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow
|
||||
@@ -1748,7 +1808,7 @@ release-tooling/ Everything the release runs: next-version (the bump math),
|
||||
registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts)
|
||||
CHANGELOG.md What changed per release, and how to upgrade a plugin across a minor
|
||||
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
|
||||
.gitea/workflows/ Gitea Actions — see CI/CD
|
||||
.gitea/workflows/ Gitea Actions — see docs/ci-cd.md
|
||||
```
|
||||
|
||||
## Extending the core
|
||||
|
||||
@@ -53,6 +53,11 @@ docker compose build web
|
||||
step "Typecheck"
|
||||
docker compose run --rm --no-deps web npm run typecheck
|
||||
|
||||
# The specs import @playwright/test, which only the runner image installs, so they typecheck there
|
||||
# rather than in the web image. --no-deps skips web; the visual file just names the e2e service.
|
||||
step "Typecheck e2e-tests"
|
||||
docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --rm --no-deps --build e2e npm run typecheck
|
||||
|
||||
step "Unit tests"
|
||||
units=$(docker compose run --rm --no-deps web npm test 2>&1) || { echo "$units"; exit 1; }
|
||||
echo "$units" | grep -E '^. (tests|pass|fail) ' || true
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
# backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
||||
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
|
||||
shifts-upstream:
|
||||
image: node:24.21.0-alpine3.24
|
||||
image: node:24.22.2-alpine3.24
|
||||
command: node /srv/server.ts
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
@@ -58,7 +58,7 @@ services:
|
||||
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
||||
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.31.4
|
||||
image: axllent/mailpit:v1.33.1
|
||||
ports:
|
||||
- "8025:8025"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -24,6 +24,7 @@ services:
|
||||
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-}
|
||||
REQUIRE_SECURE_SECRETS: "true"
|
||||
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
|
||||
TRUSTED_PROXIES: ${TRUSTED_PROXIES:-} # IPs/CIDRs of your reverse proxy; empty ⇒ X-Forwarded-For is ignored
|
||||
# Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/
|
||||
# consent handler) + the one-shot bootstrap (admin + JWKS seed). Postgres too: a plugin that
|
||||
# declares `storage` opens its connection in onBoot, before the server listens.
|
||||
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
# CI/CD
|
||||
|
||||
Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
|
||||
[`ci.sh`](../README.md#the-full-gate-one-command) — the exact gate you run locally:
|
||||
|
||||
| Workflow | Trigger | Does |
|
||||
| --- | --- | --- |
|
||||
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image |
|
||||
| `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `latest` (plus `X` once major ≥ 1), sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger |
|
||||
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) |
|
||||
| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
|
||||
| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged |
|
||||
|
||||
`main` is not re-tested on push — its commits are meant to arrive already green from a
|
||||
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
|
||||
|
||||
**Merge gate on `main`** (Gitea branch protection + repo merge settings, set via the API —
|
||||
no repo files involved): direct pushes are blocked, changes land via PR only, the
|
||||
`CI / full-gate (push)` status must be green (admins included), and the only merge style is
|
||||
**fast-forward-only** — history stays linear and `main`'s head is the exact commit hash of
|
||||
the merged branch, which is why the branch's push-triggered status carries over.
|
||||
|
||||
**Container images** — after a green gate, `ci.yml` builds the app image and pushes it as
|
||||
`gitea.larvit.se/larvit/plainpages:<full commit hash>`. Because merges are fast-forward-only, the
|
||||
image for any `main` commit already exists from that commit's branch gate — build once, promote by
|
||||
re-tagging. The build runs inside the required gate, so a missing/expired token or a registry outage
|
||||
blocks **all** merges until restored; use a non-expiring token or track its expiry. Hash tags
|
||||
accumulate one image per gated push, so nightly `registry-cleanup.yml` prunes them
|
||||
([`registry-cleanup/select-versions.ts`](../registry-cleanup/select-versions.ts) defines what survives). Don't add a
|
||||
pattern-based org cleanup rule for this package — its age/count heuristics can't see branch heads or
|
||||
release tags and would delete images the workflow protects.
|
||||
|
||||
**Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs
|
||||
`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `latest` and —
|
||||
once the major reaches `1` — `1`; nothing is rebuilt, so the released image is byte-identical to the
|
||||
gated one. While the major is `0` the bare-major tag is skipped, because a `0.x` minor is a contract
|
||||
break and a moving `:0` would carry one. It fails loud if no
|
||||
hash image exists — release tags must point at a commit that went through the gate. The same four
|
||||
tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only.
|
||||
|
||||
The [contract check](../README.md#contract-versioning) guards the tag before anything is published, refusing one
|
||||
whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to set.
|
||||
|
||||
**The Docker Hub overview** is published by a separate `publish-overview` job from
|
||||
[`release-tooling/dockerhub-overview.md.tmpl`](../release-tooling/dockerhub-overview.md.tmpl), with
|
||||
`{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go
|
||||
stale. The same job sets the page's short description. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the
|
||||
quick start stays a topology CI has actually run.
|
||||
It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
|
||||
outage leaves the promotion green and the images untouched; and the page has its own door — run the
|
||||
workflow manually with an `overview_version` input to republish it without cutting a release. That
|
||||
input goes through the same contract check as a tag: a non-semver value, or one whose `major.minor`
|
||||
disagrees with the tree being published, is refused. It uses
|
||||
the same `DOCKERHUB_TOKEN` the image push uses, which is why that token needs the **delete** scope.
|
||||
|
||||
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is
|
||||
read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift.
|
||||
Tags go with `--prune`, so deleting one here deletes it there on the next run, and a tag *created* on
|
||||
GitHub is swept away — cut releases on Gitea, never on the mirror. Branches other than `main` match
|
||||
no refspec and are left alone.
|
||||
|
||||
**Dependency updates** — `renovate.yml` runs [Renovate](https://docs.renovatebot.com) nightly
|
||||
(self-hosted, this repo only) against [`renovate.json`](../renovate.json), bumping npm deps, Docker base
|
||||
images, Gitea action versions, and the image tags pinned inside workflow `run:` steps (a custom regex
|
||||
manager, so nothing pinned drifts unmanaged). Version-locked sets move together in one PR — the Ory
|
||||
images, and the Playwright runner + its browser image — and every bump keeps the **exact semver pin**
|
||||
exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once
|
||||
`CI / full-gate (push)` is green; only a red gate needs a human.
|
||||
|
||||
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`) cuts **one**
|
||||
`vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and
|
||||
**skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a
|
||||
trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a
|
||||
`Release-Bump: <updateType>` trailer onto the updates that reach a running Plainpages — the rules in
|
||||
[`renovate.json`](../renovate.json) name them — and
|
||||
[`release-tooling/next-version.ts`](../release-tooling/next-version.ts) turns the highest one into the next
|
||||
version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release
|
||||
version, an update big enough to reach a **minor** stops the job rather than tagging: bump
|
||||
`HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since
|
||||
`nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump,
|
||||
so the trailer is an allowlist: an update outside those rules carries none and rides the next patch
|
||||
release instead of escalating it. It is **tag-only**: the tag hands off to
|
||||
`release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by
|
||||
the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here.
|
||||
|
||||
### One-time CI setup
|
||||
|
||||
| Actions var / secret | Value |
|
||||
| --- | --- |
|
||||
| `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. |
|
||||
| `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a **read/write/delete** token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT reaches every repo under it, and delete is destructive). Delete is what publishing the overview needs; pushing images alone would not. |
|
||||
| `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. |
|
||||
| `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. |
|
||||
| `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. |
|
||||
|
||||
Gitea rejects secret names prefixed `GITEA_`/`GITHUB_`. Each job fails loud until its secret exists.
|
||||
The container package is **org-owned**, so it lists under `larvit/-/packages` — link it to the repo's
|
||||
Packages tab once: `POST /api/v1/packages/larvit/container/plainpages/-/link/plainpages`.
|
||||
|
||||
**The runner** — register one
|
||||
[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode labelled `docker-host`
|
||||
(`labels: ["docker-host:host"]`) on a machine with Docker Engine + Compose, git, and Node +
|
||||
github.com access. Runs must **never overlap** — the e2e stacks use fixed compose project names, the
|
||||
devstack suite uses host networking, and the workflows share the Docker daemon's registry login — so
|
||||
keep exactly one runner at capacity 1, with host ports 3000/4433 free.
|
||||
@@ -0,0 +1,509 @@
|
||||
# Comprehension history
|
||||
|
||||
## 2026-10-31T00:16:02Z, PR #181 at 56ff987, against eed1050
|
||||
|
||||
Ruling: better
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Mid A | same | better | same | better | better |
|
||||
| Inherited architect | same | better | same | better | better |
|
||||
|
||||
Mid A, decided by:
|
||||
|
||||
- Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited
|
||||
- Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a colour edit that breaks the browser floor fails a test instead of an old engine
|
||||
|
||||
Inherited architect, decided by:
|
||||
|
||||
- Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited
|
||||
- Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a red gate explains the symptom in the file it lands on
|
||||
- Self-sufficiency: `README.md:1027` "Two browser floors": the colour rule now sits beside the floor rule
|
||||
|
||||
## 2026-10-31T00:43:19Z, PR #182 at 19987ba, against 3e70768
|
||||
|
||||
Ruling: better
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Mid A | better | better | better | better | better |
|
||||
| Inherited architect | better | better | better | better | better |
|
||||
|
||||
Mid A, decided by:
|
||||
|
||||
- Navigation, Shape, Self-sufficiency, Overall: `public/css/auth.css:1` `auth.css`: about 120 lines of rules for markup no view renders are gone, so a cold reader no longer rules them out first
|
||||
- Locality, Self-sufficiency, Overall: `public/css/styles.css:31` `--on-accent`: the text colour on the accent is one token where four hard-coded `#fff` stood
|
||||
- Self-sufficiency, Navigation, Overall: `public/css/styles.css:287` `@supports not selector(:has(a))`: the nav comment, the partial's header and the rule all say the tree folds only where `:has()` works
|
||||
- Locality: `public/css/styles.css:241` `@supports selector(:has(a))`: the nav-fold behaviour now sits in two blocks about 50 lines apart, the second order-dependent; the comment names it
|
||||
|
||||
Inherited architect, decided by:
|
||||
|
||||
- Navigation, Shape, Overall: `public/css/auth.css:1` `auth.css`: the false `:target` banner and the phantom state machine are gone, so every selector left names something a view emits
|
||||
- Self-sufficiency, Overall: `views/partials/nav-tree.ejs:4` `nav-tree.ejs`: the header and `public/css/styles.css:194` now say the branch folds only where `:has()` works; the old text was untrue on old engines
|
||||
- Locality, Overall: `public/css/styles.css:31` `--l-on-accent`: one token feeds `.btn-primary`, `.page-btn[aria-current]` and `.brand-mark`
|
||||
- Locality: `public/css/styles.css:287` `@supports not selector(:has(a))`: its `.nav-disc` rule sits about 85 lines from the nav rules it overrides, and `stylesheet-floor.test.ts` guards the order for `.theme-switch` only
|
||||
- Self-sufficiency: `src/ui/stylesheet-contrast.test.ts:29` `stylesheet-contrast.test.ts`: the darker badge colours now have a stated 4.5:1 reason someone can find
|
||||
|
||||
## 2026-10-31T01:09:38Z, PR #183 at 0f20b38, against 364b047
|
||||
|
||||
Ruling: better
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Mid A | same | same | same | better | better |
|
||||
| Inherited architect | same | better | same | better | better |
|
||||
|
||||
Mid A, decided by:
|
||||
|
||||
- Self-sufficiency, Overall: `public/css/styles.css:1` `App Shell Template header`: the false `light-dark()` bullet is gone, so no reader has to work out which of two comments is lying
|
||||
- Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment say what an engine without `:has()` gets, without opening the CHANGELOG
|
||||
|
||||
Inherited architect, decided by:
|
||||
|
||||
- Self-sufficiency, Overall: `public/css/styles.css:5` `Architecture` header: the false `light-dark()` bullet is gone, so the header no longer contradicts the TOKENS comment
|
||||
- Locality, Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment put the dependency of the open state on `:has()` where an editor would change it
|
||||
|
||||
## 2026-10-31T01:19:49Z, PR #184 at af157c9, against bb8e6bc
|
||||
|
||||
Ruling: better
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Mid A | better | same | same | better | better |
|
||||
| Inherited architect | better | better | same | better | better |
|
||||
|
||||
Mid A, decided by:
|
||||
|
||||
- Self-sufficiency, Navigation, Overall: `public/css/styles.css:12` `TOKENS` header comment: says all three theme rules need `:has()`, names the engines without it and points to the `@supports not selector(:has(a))` block, where the old text called the swaps "rock-solid"
|
||||
- Navigation, Self-sufficiency, Overall: `README.md:214` `## Glossary`: defines host, plugin, manifest, chrome, nav fragment, permission name, system plugin and denylist and links each to its home section
|
||||
- Self-sufficiency: `README.md:815` `Plugin dependencies`: the scale bullet states the plugin count the per-plugin dependency design assumes
|
||||
|
||||
Inherited architect, decided by:
|
||||
|
||||
- Self-sufficiency, Locality, Overall: `public/css/styles.css:12` `1. TOKENS` comment: an editor of the theme mapping learns of the distant `:has()` fallback from the comment, where the old text said the swap could not fail
|
||||
- Navigation, Self-sufficiency, Overall: `README.md:214` `## Glossary`: a cold reader reaches "chrome" or "denylist" and their home sections in one hop
|
||||
- Self-sufficiency: `README.md:815` `### Plugin dependencies`: the scale bullet gives the plugin count the dependency design assumes
|
||||
|
||||
## 2026-10-31T07:26:19Z, PR #187 at 10152d2, against b336dee
|
||||
|
||||
Ruling: better
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Mid A | same | same | better | better | better |
|
||||
| Inherited architect | same | same | better | better | better |
|
||||
|
||||
Mid A, decided by:
|
||||
|
||||
- Self-sufficiency, Overall: `e2e-tests/mock-oidc.ts:18` `idToken`: the helpers' signatures now say what `res`, `nonce` and the `codes` map hold
|
||||
- Self-sufficiency: `e2e-tests/proxy.ts:14` `toKratos`: `(path: string): boolean` shows the routing predicate's contract
|
||||
- Self-sufficiency: `e2e-tests/tsconfig.json:2` `extends`: the specs are held to the host's strict flags, and the Dockerfile comment explains the path link
|
||||
- Shape, Overall: `src/ui/icons.ts:4` `ICON_NAMES`: the comment "the icons the UI actually references" is true again
|
||||
|
||||
Inherited architect, decided by:
|
||||
|
||||
- Self-sufficiency, Overall: `e2e-tests/mock-oidc.ts:18` `b64`: typed helpers replace a trace through the `createServer` callback
|
||||
- Self-sufficiency: `e2e-tests/proxy.ts:14` `toKratos`: the predicate's contract is readable on its own line
|
||||
- Self-sufficiency: `e2e-tests/Dockerfile:11` `COPY tsconfig.json`: the relative `extends` path works in the checkout and in the image, and a comment names the link
|
||||
- Shape, Overall: `src/ui/icons.ts:4` `ICON_NAMES`: the registry matches its description, with no dead ids
|
||||
|
||||
## 2026-10-31T12:28:23Z, PR #200 at 3592a30, against a8a9744
|
||||
|
||||
Ruling: same
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Mid A | better | better | better | same | better |
|
||||
| Inherited architect | worse | worse | better | same | same |
|
||||
| Maintainability senior | same | same | better | same | same |
|
||||
| Junior A | better | better | better | better | better |
|
||||
|
||||
Mid A, decided by:
|
||||
|
||||
- Navigation, Locality, Shape: `src/ui/chrome.ts:74` `shellUser`: the deleted `buildShellContext` and `buildDashboardModel` left two shell models side by side, and now every page reads one `chrome`
|
||||
- Locality, Shape: `src/auth/routes.ts:58` `flowFailure`: a named unit shared by `flowPage` and `codeFlowSubmit`, where the old `flowPage` held the whole error ladder inline
|
||||
- Shape: `src/auth/kratos-public.ts:117` `submitFlow`: takes a flow type and id instead of an action URL, and the Referer quirk is explained where it applies
|
||||
- Locality: `src/http/static.ts:93` `versioned`: reads the module-level `hashes` map, so an asset href depends on what an earlier request hashed
|
||||
- Navigation, Shape: `src/ui/stylesheet-floor.ts:30` `floorViolations`: a 249-line regex walker that only tests use sits in `src/ui/` beside runtime modules, though the README now points straight at it
|
||||
|
||||
Inherited architect, decided by:
|
||||
|
||||
- Navigation: `public/css/styles.css:25` `:root`: the old comment next to the palette answered the 3am question and the new text drops it
|
||||
- Navigation: `src/ui/stylesheet-floor.ts:1` `floorViolations`: its header never says an unsupported value in a custom property blanks every `var()` reading it, so a cold reader walks the regex walker to find the cause
|
||||
- Navigation, Shape: `src/ui/stylesheet-floor.ts:12` `import`: test-only tooling that pulls a devDependency sits beside runtime modules and is missing from the README file map
|
||||
- Locality: `src/http/static.ts:64` `hashes`: a module-level map shared by every `publicAssets()` instance makes a fresh href depend on request history elsewhere
|
||||
- Locality: `src/http/app.ts:188` `renderPage`: `assetHref` is appended last at three render sites, and only a comment keeps a fourth from emitting unversioned hrefs
|
||||
- Shape: `src/ui/chrome.ts:74` `shellUser`: removes the second shell model that duplicated `PageChrome`
|
||||
- Shape: `src/auth/routes.ts:58` `flowFailure`: one named unit shared by `flowPage` and `codeFlowSubmit`, each handler reads in a screen
|
||||
|
||||
Maintainability senior, decided by:
|
||||
|
||||
- Shape, Locality: `src/ui/chrome.ts:74` `shellUser`: the deleted `ShellModel` left two shapes for the same chrome, and now the shell has one input and one builder
|
||||
- Shape, Locality: `src/auth/routes.ts:58` `flowFailure`: the old `flowPage` mixed about 60 lines of error mapping with `return_to` handling, and now `flowReturnTo` and `flowFailure` read on their own
|
||||
- Locality: `src/http/static.ts:64` `hashes`: a hidden module-level cache filled by two readers, and `assetHref` is merged by hand at three render sites
|
||||
- Shape, Navigation: `src/ui/stylesheet-floor.ts:119` `positive`: a 249-line regex walker that only a test uses sits among runtime view modules
|
||||
|
||||
Junior A, decided by:
|
||||
|
||||
- Navigation, Self-sufficiency: `src/ui/stylesheet-floor.ts:1` `floorViolations`: the README's "Two browser floors" points here and the header lists what is checked, where the base had a prose rule and a hand-written regex test
|
||||
- Shape, Locality: `src/ui/chrome.ts:74` `shellUser`: one shell view model replaces `ShellModel` beside `PageChrome`, and its helpers sit beside their only caller
|
||||
- Shape, Locality: `src/auth/routes.ts:58` `flowFailure`: named units replace about 60 inline lines in `flowPage`, and `codeFlowSubmit` reuses them
|
||||
- Self-sufficiency: `src/auth/kratos-admin.ts:68` `createRecoveryCode`: the comment no longer claims Kratos mails the link
|
||||
- Locality: `src/http/static.ts:64` `hashes`: a module-level mutable map backs asset versioning, hidden state the base didn't have
|
||||
|
||||
## 2026-10-31T12:30:19Z, PR #200 at 3592a30
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Junior A | 7 | 6 | 6.5 | 6.5 | 6.5 |
|
||||
| Mid A | 7 | 6 | 6.5 | 6.5 | 6.5 |
|
||||
| Maintainability senior | 7 | 6 | 6.5 | 6.5 | 6.5 |
|
||||
| Inherited architect | 7 | 5.5 | 5.5 | 7.5 | 6.5 |
|
||||
| Mean | 7.00 | 5.88 | 6.25 | 6.75 | 6.50 |
|
||||
|
||||
Junior A, hardest first:
|
||||
|
||||
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a regex-built `@supports` evaluator whose header says what it enforces but not how it decides a guard names a feature
|
||||
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines in one closure where each stage reads state an earlier stage set
|
||||
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: its 4xx branches can't fire from `codeFlowSubmit` because `submitFlow` throws only on 5xx, and the call site doesn't say so
|
||||
|
||||
Mid A, hardest first:
|
||||
|
||||
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-written CSS walker with three regex passes per declaration, opaque at line level
|
||||
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: a fixed order of about 15 stages that nothing in the code enforces
|
||||
- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment names the tuple `#members` where the code uses `granted`
|
||||
- Locality: `src/auth/routes.ts:121` `codeFlowSubmit`: one idea split across `flow-view.ts`, `routes.ts` and `kratos-public.ts`
|
||||
|
||||
Maintainability senior, hardest first:
|
||||
|
||||
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: a 167-line closure of complexity 43 whose order across closure state nothing names as a contract
|
||||
- Self-sufficiency, Locality: `src/auth/routes.ts:58` `flowFailure`: on the submit path every 4xx branch is dead and nothing at the call site says which can fire
|
||||
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: the one module whose correctness can't be checked by reading
|
||||
- Self-sufficiency: `src/auth/bootstrap.ts:31` `permissionTuple`: the comment says `#members` while the code writes `granted`
|
||||
|
||||
Inherited architect, would restructure first:
|
||||
|
||||
- Shape, Navigation: `src/auth/flow-view.ts:152` `formAction`: the flow-to-route mapping has two homes, here and `routes.ts`, so a code flow's route and its form target are decided in different files
|
||||
- Shape, Navigation: `src/ui/stylesheet-floor.ts:30` `floorViolations`: a CSS linter that never runs at runtime sits among the runtime view helpers
|
||||
- Locality, Shape: `src/http/app.ts:185` `handleRequest`: a level of its own inside `createApp`
|
||||
- Shape: `src/http/builtin-routes.ts:23` `matchBuiltinRoute`: holds no routes, because the table is inline in `app.ts:176` and `buildAuthRoutes`
|
||||
|
||||
## 2026-10-31T12:30:27Z, PR #200 rerun at 3592a30
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Junior A | 7 | 5.5 | 6 | 6.5 | 6 |
|
||||
| Mid A | 7 | 6 | 6.5 | 6.5 | 6.5 |
|
||||
| Maintainability senior | 7 | 5.5 | 6 | 6.5 | 6 |
|
||||
| Inherited architect | 7 | 6 | 6 | 6.5 | 6.5 |
|
||||
| Mean | 7.00 | 5.75 | 6.13 | 6.50 | 6.25 |
|
||||
|
||||
Junior A, hardest first:
|
||||
|
||||
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a regex-built CSS walker that stayed opaque without tracing it by hand
|
||||
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 320 lines of interdependent locals, with a fixed stage order that only a top-to-bottom read shows
|
||||
- Self-sufficiency, Shape: `src/auth/login.ts:40` `completeLogin`: the header says `#members` where the code uses `granted`
|
||||
|
||||
Mid A, hardest first:
|
||||
|
||||
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines of order-dependent stages, with `renderPage` reassigned partway
|
||||
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: how it decides a guard names a feature stayed opaque
|
||||
- Self-sufficiency, Shape: `src/auth/bootstrap.ts:31` `seedPermissions`: the comment says `#members` where the code uses `granted`
|
||||
|
||||
Maintainability senior, hardest first:
|
||||
|
||||
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 167 lines and complexity 43 in a fixed order nobody states
|
||||
- Locality, Overall: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-rolled regex CSS walker, partly opaque
|
||||
- Locality, Shape: `src/plugin-host/plugin.ts:126` `RESERVED_PLUGIN_IDS`: a hand-kept mirror of the host's path segments that has already drifted, as it omits `error`
|
||||
- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment says `#members` where the code uses `granted`
|
||||
|
||||
Inherited architect, would restructure first:
|
||||
|
||||
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: the most concerns and the highest complexity, already grandfathered, and every request-wide concern lands in it
|
||||
- Shape, Navigation: `src/ui/stylesheet-floor.ts:15` `FLOOR`: the 3am rule's home is a test-only module under `ui/`
|
||||
- Shape: `src/auth/routes.ts:275` `buildAuthRoutes`: the 19-module `src/auth/` mixes six concerns, and this file with `flow-view.ts` is where the next flow lands
|
||||
|
||||
## 2026-10-31T13:06:16Z, PR #202 at 8e73d16
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Junior A | 7 | 6 | 6.5 | 6.5 | 6.5 |
|
||||
| Mid A | 7 | 6 | 6 | 6.5 | 6.5 |
|
||||
| Maintainability senior | 7 | 6 | 6 | 6.5 | 6.5 |
|
||||
| Inherited architect | 6.5 | 6 | 6 | 6.5 | 6.5 |
|
||||
| Mean | 6.88 | 6.00 | 6.13 | 6.50 | 6.50 |
|
||||
|
||||
Junior A, hardest first:
|
||||
|
||||
- Locality, Overall: `src/http/app.ts:185` `handleRequest`: about 165 lines in a fixed order that only a top-to-bottom read shows
|
||||
- Self-sufficiency, Shape: `src/auth/login.ts:40` `readPermissions`: the comment says `#members` where the code uses `granted`
|
||||
- Self-sufficiency: `src/plugin-host/settings.ts:35` `SettingsOf`: nested conditional types that need the charter's rule to decode
|
||||
|
||||
Mid A, hardest first:
|
||||
|
||||
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: complexity 43 inside the 318-line `createApp` closure, and `switchBase` reads `builtinRoutes` declared 60 lines later
|
||||
- Locality, Shape: `src/ui/stylesheet-floor.ts:119` `positive`: a regex CSS walker whose how stayed opaque
|
||||
- Self-sufficiency: `src/auth/bootstrap.ts:31` `permissionTuple`: the comment says `#members` where the code uses `granted`
|
||||
|
||||
Maintainability senior, hardest first:
|
||||
|
||||
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: 167 lines and complexity 43 mixing twelve concerns whose order no type expresses
|
||||
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-rolled regex CSS scanner traced by hand
|
||||
- Self-sufficiency: `src/auth/gate.ts:13` `allows`: an empty gate is allowed, and the reason lives only in AGENTS.md
|
||||
|
||||
Inherited architect, would restructure first:
|
||||
|
||||
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: the largest hub, where every request-wide concern lands
|
||||
- Shape, Navigation: `src/auth/routes.ts:275` `buildAuthRoutes`: the 19-module `src/auth/` mixes four concerns with no sub-folder
|
||||
- Shape, Navigation: `src/ui/stylesheet-floor.ts:30` `floorViolations`: test tooling sits among the runtime UI modules
|
||||
|
||||
## 2026-10-31T13:06:21Z, PR #202 rerun at 8e73d16
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Junior A | 7 | 5.5 | 6 | 6 | 6 |
|
||||
| Mid A | 7 | 6 | 6 | 6 | 6.5 |
|
||||
| Maintainability senior | 7 | 6 | 6 | 6 | 6.5 |
|
||||
| Inherited architect | 7 | 5.5 | 6 | 7 | 6.5 |
|
||||
| Mean | 7.00 | 5.75 | 6.00 | 6.25 | 6.38 |
|
||||
|
||||
Junior A, hardest first:
|
||||
|
||||
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: closures over request state in a 318-line `createApp`, and it stayed opaque
|
||||
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: the Kratos status semantics live outside the repo
|
||||
- Self-sufficiency, Shape: `src/auth/login.ts:45` `readPermissions`: the comment says `#members` where the code uses `granted`
|
||||
|
||||
Mid A, hardest first:
|
||||
|
||||
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: one pipeline whose steps mutate what later steps read
|
||||
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: traced by hand
|
||||
- Self-sufficiency, Shape: `src/auth/login.ts:45` `readPermissions`: the comment says `#members` where the code uses `granted`
|
||||
|
||||
Maintainability senior, hardest first:
|
||||
|
||||
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: an unstated, load-bearing step order in shared closure state
|
||||
- Locality: `src/ui/stylesheet-floor.ts:119` `positive`: a hand-rolled CSS scanner built on lookbehind regexes
|
||||
- Self-sufficiency, Shape: `src/auth/bootstrap.ts:31` `permissionTuple`: the comment is false on the security-relevant path
|
||||
|
||||
Inherited architect, would restructure first:
|
||||
|
||||
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: stage order lives only in line order, in a grandfathered complexity-43 function
|
||||
- Shape, Navigation: `src/auth/routes.ts:275` `buildAuthRoutes`: 19 flat modules in `src/auth/`, and the next flow lands here
|
||||
- Shape, Navigation: `src/ui/stylesheet-floor.ts:15` `FLOOR`: the 3am rule's home is a test-only module under `ui/`
|
||||
|
||||
## 2026-11-08T13:22:47Z, PR #214 at 84368ea, against c2389f8
|
||||
|
||||
Ruling: better
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Mid A | better | same | better | better | better |
|
||||
| Inherited architect | better | same | better | better | better |
|
||||
|
||||
Mid A, decided by:
|
||||
|
||||
- Navigation, Shape, Overall: `src/test-tooling/stylesheet-floor.ts:1` `floorViolations`: test-only modules no longer sit beside runtime code in `ui/` and `http/`, and `AGENTS.md:77` names the folder
|
||||
- Self-sufficiency: `src/auth/login.ts:40` `readPermissions`: the comment now names `#granted`, which the code queries
|
||||
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: grouping and the per-group decision are separate named units
|
||||
- Self-sufficiency: `src/test-tooling/stylesheet-floor.ts:10` file header: says why a custom property's value is checked for functions and units
|
||||
- Self-sufficiency: `src/plugin-host/plugin.ts:234` `undeclaredGates`: its comment says why an undeclared gate matters
|
||||
|
||||
Inherited architect, decided by:
|
||||
|
||||
- Self-sufficiency, Shape, Overall: `src/auth/login.ts:40` `readPermissions`: the comment now names the relation the code queries
|
||||
- Self-sufficiency: `src/auth/bootstrap.ts:31` `permissionTuple`: the contradicting comment is gone
|
||||
- Navigation: `src/test-tooling/stylesheet-floor.ts:1` `floorViolations`: the folder is named in AGENTS.md and the README file map
|
||||
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: one loop split into `parenGroups`, `Group` and `keepGroup`
|
||||
- Self-sufficiency: `src/auth/routes.ts:117` `codeFlowSubmit`: a comment says which `flowFailure` branch can fire there
|
||||
|
||||
## 2026-11-08T13:44:57Z, PR #215 at 3a3bd77, against 01f368d
|
||||
|
||||
Ruling: better
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Mid A | better | better | better | same | better |
|
||||
| Inherited architect | better | better | better | better | better |
|
||||
|
||||
Mid A, decided by:
|
||||
|
||||
- Locality, Shape: `src/ui/paginate.ts:68` `buildPaginationModel`: the rows form's hidden inputs are read off hrefFor, so form and links can't drift, where each page used to list them by hand
|
||||
- Shape, Navigation: `examples/plugins/admin/admin-users.ts:140` `buildUsersListModel`: three near-identical listPagination copies become one call each to a named helper
|
||||
- Locality: `src/http/app.ts:271` `shared`: ctx and contextFor spread one option object instead of spelling the same list twice
|
||||
- Shape: `src/ui/paginate.ts:1` header: names both steps truthfully, and PaginationModel types the partial's input
|
||||
- Navigation: `src/http/context.ts:28` `RequestContext.assetHref`: versioning a plugin's asset is a documented contract member, not a host-internal local
|
||||
|
||||
Inherited architect, decided by:
|
||||
|
||||
- Shape, Locality, Navigation: `src/ui/paginate.ts:68` `buildPaginationModel`: one typed function in the module owning the page math replaces three hand-written copies in the example plugins
|
||||
- Locality: `examples/plugins/admin/admin-users.ts:188` `listPagination`: the older version built the hidden inputs field by field, apart from listHref; the round trip through new URL is a little clever, paid for by its comment
|
||||
- Locality, Shape: `src/http/app.ts:271` `shared`: the two buildContext calls differ only in the translator and params
|
||||
- Self-sufficiency: `src/http/context.ts:28` `RequestContext.assetHref`: the contract field says on the spot what it does and how it differs from the view local
|
||||
- Self-sufficiency: `README.md:1050` pagination sketch: URL to partial in five lines, with the param-renaming rule
|
||||
|
||||
## 2026-11-08T14:10:47Z, PR #216 at bb134a3, against 6ca63c6
|
||||
|
||||
Ruling: better
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Mid A | better | same | better | same | better |
|
||||
| Inherited architect | same | better | better | same | better |
|
||||
|
||||
Mid A, decided by:
|
||||
|
||||
- Navigation, Shape: `src/http/request-tracking.ts:9` `trackRequest`: the per-request logger, span and access line now live in a named module instead of the tail of the 318-line `createApp`
|
||||
- Navigation, Shape: `src/http/request-fields.ts:33` `clientAddress`: the caller-IP and X-Forwarded-For rule has its own pure function in a file named for it
|
||||
|
||||
Inherited architect, decided by:
|
||||
|
||||
- Locality, Shape: `src/http/request-tracking.ts:9` `trackRequest`: the exactly-once finalize and close listener are one named unit, changeable without reading `createApp`'s other state
|
||||
- Locality, Shape: `src/http/request-fields.ts:33` `clientAddress`: pure function of peer, header and list, with the reasons for reading from the right beside it
|
||||
|
||||
## 2026-11-08T18:34:54Z, PR #220 at 1b53b01, against 14a07dd
|
||||
|
||||
Ruling: same
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Mid A | same | same | same | same | same |
|
||||
| Inherited architect | same | same | worse | same | same |
|
||||
|
||||
Inherited architect, decided by:
|
||||
|
||||
- Shape: `views/partials/shell.ejs:74` `user.menu`: a third meaning of `menu` beside the sidebar switch at line 19 and the `.menu` popover class
|
||||
- Shape: `src/plugin-host/plugin.ts:9` `import type { ProfileMenuEntry }`: a second contract type defined under `ui/`, with a type-only import cycle through `chrome.ts`
|
||||
|
||||
## 2026-11-08T19:50:52Z, PR #223 at f8a6119, against b7e9780
|
||||
|
||||
Ruling: better
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Mid A | better | better | better | same | better |
|
||||
| Inherited architect | better | better | better | same | better |
|
||||
|
||||
Mid A, decided by:
|
||||
|
||||
- Navigation, Locality, Shape: `src/http/app.ts:183` `handleRequest`: a 20-line list of named stages replaces a 170-line closure
|
||||
- Locality: `src/http/app.ts:213` `Host`: 19 fields passed to every stage, broader than any stage needs
|
||||
- Shape, Navigation: `src/ui/chrome.ts:29` `ShellUser.profileLinks`: the name says what the field holds
|
||||
- Navigation, Shape: `src/plugin-host/conflicts.ts:15` `findConflicts`: collision rules have their own file
|
||||
|
||||
Inherited architect, decided by:
|
||||
|
||||
- Locality, Shape, Navigation: `src/http/app.ts:183` `handleRequest`: named stages replace one 165-line closure
|
||||
- Locality: `src/http/app.ts:213` `Host`: a 19-field bag wider than any stage needs
|
||||
- Shape: `src/ui/chrome.ts:29` `ShellUser.profileLinks`: no longer collides with `MenuConfig` and the `menu` partial
|
||||
- Navigation: `.gitea/workflows/release.yml:60` error pointer: names `docs/ci-cd.md`, one hop from the failure
|
||||
|
||||
## 2026-11-08T21:24:33Z, PR #228 at ed48c5a
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Junior A | 7.5 | 6 | 7 | 7 | 6.5 |
|
||||
| Mid A | 7 | 6 | 6.5 | 6.5 | 6.5 |
|
||||
| Maintainability senior | 7.5 | 6.5 | 7 | 6.5 | 7 |
|
||||
| Inherited architect | 6 | 6 | 6.5 | 7 | 6.5 |
|
||||
| Mean | 7.00 | 6.13 | 6.75 | 6.75 | 6.63 |
|
||||
|
||||
Junior A, hardest first:
|
||||
|
||||
- Locality, Overall: `src/http/app.ts:67` `createApp`: a closure bag whose order matters; `switchBase` uses `builtinRoutes` declared further down
|
||||
- Locality, Overall: `src/http/app.ts:274` `prepareRequest`: builds the context, the factory and the memoized chrome together, and every page path must remember to set the CSRF cookie
|
||||
- Locality, Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:285` `lostToSelf`: the self-lockout rule is spread over four call sites and no single place states it
|
||||
- Shape, Overall: `examples/plugins/auth-admin/admin-grants.ts:99` `buildPermissionPicker`: checked, inherited, read-only and undeclared rows interact and need a truth table
|
||||
- Self-sufficiency: `src/auth/routes.ts:45` `flowReturnTo`: locale and return-to inputs interact, and no table gives the result per input
|
||||
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: four Kratos error branches that need Ory knowledge the repo doesn't hold
|
||||
- Locality: `src/ui/chrome.ts:55` `buildPluginChrome`: the locale rule is put into effect in three places with no single owner
|
||||
- Self-sufficiency: `src/plugin-host/router.ts:4` header: promises order-independent specificity, but ties fall to discovery order
|
||||
|
||||
Mid A, hardest first:
|
||||
|
||||
- Locality, Overall: `examples/plugins/auth-admin/admin-groups.ts:285` `lostToSelf`: needs Keto's transitive group resolution rebuilt by hand, and over-reports
|
||||
- Shape, Locality: `examples/plugins/auth-admin/admin-groups.ts:435` `groupsDelete`: deletes grants one at a time, so it can stop part-way
|
||||
- Shape, Overall: `examples/plugins/auth-admin/admin-grants.ts:99` `buildPermissionPicker`: four overlapping ideas; `checked` means direct or effective
|
||||
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos status codes map to different outcomes and need outside knowledge
|
||||
- Shape, Locality: `src/http/app.ts:183` `handleRequest`: the 19-field `Host` bag and the state threaded between stages
|
||||
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: a hand-rolled parser for `@supports` conditions
|
||||
- Self-sufficiency: `src/plugin-host/settings.ts:35` `ValueOfDecl`: type-level inference with a `const` generic
|
||||
- Self-sufficiency: `src/auth/jwt-middleware.ts:75` `verifyToken`: a denylisted token is thrown as expired so it gets re-minted
|
||||
- Self-sufficiency: `examples/plugins/auth-admin/plugin.ts:22` `on`: each write-intent GET states its `"write"` override in the route table and in the handler
|
||||
|
||||
Maintainability senior, hardest first:
|
||||
|
||||
- Locality, Overall: `examples/plugins/auth-admin/admin-groups.ts:285` `lostToSelf`: the self-lockout guards rebuild Keto's transitive membership and no sentence states the invariant
|
||||
- Shape, Overall: `src/http/app.ts:67` `createApp`: a long closure factory that is safe to read but not safe to change
|
||||
- Shape, Self-sufficiency: `examples/plugins/auth-admin/admin-grants.ts:99` `buildPermissionPicker`: direct, effective and undeclared grant sets with a staleness token on top
|
||||
- Self-sufficiency: `src/plugin-host/settings.ts:35` `ValueOfDecl`: type-level inference whose comments say what it does, not how
|
||||
- Self-sufficiency: `src/auth/routes.ts:45` `flowFailure`: a matrix of Kratos statuses and flow states
|
||||
- Locality: `src/auth/jwks.ts:68` `cachingJwks`: subtle cache state, and the denylist reuses the `expired` flag
|
||||
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: a hand-written parser for `@supports` conditions
|
||||
- Locality: `src/auth/bootstrap.ts:86` `seedAdmin`: calls Kratos and Keto with raw fetch instead of the client modules
|
||||
- Self-sufficiency: `examples/plugins/auth-admin/views/partials/group-detail-body.ejs:2` locals header: documents `identity` and `del`, omits `canWrite` and `permissions`
|
||||
|
||||
Inherited architect, would restructure first:
|
||||
|
||||
- Navigation, Overall: `src/auth/routes.ts:1` `src/auth`: 19 modules holding sessions, Ory clients, flows, the OAuth2 provider and one-shot CLIs
|
||||
- Navigation: `src/auth/bootstrap.ts:1` `bootstrap.ts`: a one-shot process that also provisions plugin storage, placed under auth
|
||||
- Navigation, Locality: `src/ui/chrome.ts:55` `buildPluginChrome`: builds the chrome for every page, the host's own included
|
||||
- Navigation: `views/index.ejs:1` `index.ejs`: the built-in /dashboard, and `home.ejs` is "/", and neither filename says so
|
||||
- Locality: `public/css/styles.css:326` `.btn-primary`: one visual fact spans the partial, the rule, the tokens and the floor policy in `src/test-tooling/`
|
||||
- Shape, Locality: `examples/plugins/auth-admin/admin-groups.ts:113` `admin-groups.ts`: pure view models, Keto queries, lockout policy and handlers in one 463-line file
|
||||
- Shape: `src/http/app.ts:213` `Host`: a 19-field bag that every cross-cutting feature edits
|
||||
- Self-sufficiency: `src/plugin-host/system.ts:1` header: calls the built-in admin screens the reference consumer, but they are a drop-in plugin
|
||||
|
||||
## 2026-11-08T21:30:30Z, PR #228 rerun at 0727d15
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Junior A | 7.5 | 6 | 6.5 | 6.5 | 6.5 |
|
||||
| Mid A | 7 | 6 | 6.5 | 7 | 6.5 |
|
||||
| Maintainability senior | 7.5 | 6.5 | 7 | 7 | 7 |
|
||||
| Inherited architect | 7 | 6.5 | 6 | 7.5 | 6.5 |
|
||||
| Mean | 7.25 | 6.25 | 6.50 | 7.00 | 6.63 |
|
||||
|
||||
Junior A, hardest first:
|
||||
|
||||
- Locality, Shape, Overall: `src/http/app.ts:274` `prepareRequest`: a 19-field `Host` bag and a twice-memoized chrome threaded through one closure, and two return conventions (send and return null, or return a result)
|
||||
- Locality, Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:285` `lostToSelf`: the self-lockout rule differs from the users side and nothing says whether that is deliberate
|
||||
- Shape, Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:18` `LIST_FETCH_SIZE`: the comment says a Keto page but the call is Kratos `listIdentities`
|
||||
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: branches on Kratos status and body text that need Ory knowledge
|
||||
- Shape, Self-sufficiency: `src/auth/jwt-middleware.ts:75` `verifyToken`: a revoked token is reported as expired, with the effect in another file
|
||||
- Locality: `src/i18n/load.ts:43` `loadI18n`: four catalog sources kept apart with a nested spread on one line
|
||||
- Self-sufficiency: `src/server.ts:25` boot order: deleting `PLUGIN_DB_SECRET` is order-dependent and its reason sits in the decision log
|
||||
- Self-sufficiency: `src/plugin-host/system.ts:1` header: calls the built-in admin screens the reference consumer
|
||||
|
||||
Mid A, hardest first:
|
||||
|
||||
- Locality, Shape, Overall: `src/http/app.ts:274` `prepareRequest`: the request order lives only in the sequence of a long closure, and `serveHome` skips `sendResult`
|
||||
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: a hand-written, regex-heavy reader of `@supports` conditions
|
||||
- Locality: `src/auth/jwt-middleware.ts:75` `verifyToken`: revocation reported as expired works only through `resolveUser` in another file
|
||||
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos statuses that need outside knowledge
|
||||
- Locality: `src/auth/jwks.ts:68` `cachingJwks`: four interacting behaviours in one closure
|
||||
- Shape: `src/i18n/load.ts:43` `loadI18n`: merges four catalog sources, with the chain order described in two files
|
||||
- Locality: `src/i18n/locale.ts:81` `chosenLocale`: infers an explicit choice from the output of a URL builder
|
||||
- Shape, Self-sufficiency: `examples/plugins/scheduling/plugin.ts:37` header: says "Roles" and `<id>:<action>` where the project has neither
|
||||
- Self-sufficiency: `compose.yml:166` comment: says consent comes next, but `src/auth/oauth-consent.ts` exists
|
||||
|
||||
Maintainability senior, hardest first:
|
||||
|
||||
- Shape, Overall: `src/test-tooling/stylesheet-floor.ts:122` `positive`: a regex-driven mini-parser whose header states the rules but not how the helpers carry them
|
||||
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: five branches that assume Kratos' error vocabulary
|
||||
- Locality: `src/http/app.ts:274` `prepareRequest`: where the language picker points after a POST is spread over four units in three files
|
||||
- Shape: `src/i18n/load.ts:43` `loadI18n`: three catalog sets, each with its own baseline, and a nested spread
|
||||
- Locality: `src/auth/jwt-middleware.ts:84` `verifyToken`: the denylist travels as `expired`, so `resolveSession` can't show it
|
||||
- Shape: `src/ui/chrome.ts:55` `buildPluginChrome`: labels are translated twice and `markCurrent` mutates the nodes
|
||||
- Locality, Shape: `examples/plugins/auth-admin/admin-users.ts:348` `usersPermissions`: the stale-page, self-revoke and diff guards interact across two files, and `pickerOrNothing`'s promise is broken on the self-delete paths
|
||||
- Shape: `src/plugin-host/router.ts:4` header: promises order-independent specificity, but ties fall to discovery order
|
||||
|
||||
Inherited architect, would restructure first:
|
||||
|
||||
- Shape, Navigation, Overall: `src/auth/bootstrap.ts:1` `src/auth`: 19 modules across the session, Kratos flows, OAuth2 and a second process entrypoint
|
||||
- Navigation, Self-sufficiency: `src/auth/bootstrap.ts:1` header: lists the JWKS, admin and Keto steps but not the plugin storage step, and the file reads the environment outside `config.ts`
|
||||
- Shape, Navigation: `views/index.ejs:1` `index.ejs`: the dashboard, with `home.ejs` as the landing page, and neither name says so
|
||||
- Shape: `src/ui/chrome.ts:55` `buildPluginChrome`: builds the chrome for every page, not only plugin pages
|
||||
- Locality, Navigation: `src/test-tooling/stylesheet-floor.ts:10` header: the custom-property footgun sits in test tooling instead of beside the tokens at `public/css/styles.css:9`
|
||||
- Locality: `src/auth/bootstrap.ts:170` `provisionPluginStorage`: plugin storage lives in three places, across `storage.ts`, `storage-provisioning.ts` and `bootstrap.ts`
|
||||
- Locality, Shape: `src/http/app.ts:183` `handleRequest`: every new manifest field or slot adds a stage here and a field to `Host`
|
||||
- Self-sufficiency: `src/plugin-host/system.ts:1` header: calls the built-in admin screens the reference consumer
|
||||
@@ -0,0 +1,522 @@
|
||||
# Decisions
|
||||
|
||||
"Goal N" is README → Goals. "Code health" is the goal every project shares: the code is easy to
|
||||
understand and change safely.
|
||||
|
||||
## `src/` is grouped by concern, and the core ships no domain screens
|
||||
|
||||
2026-06-24, the maintainer. Code health and goal 3. Valid while each concern folder stays one a
|
||||
reader can name.
|
||||
|
||||
The folders are `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`, and `test-tooling/` (modules only
|
||||
tests import), with `server.ts`, `config.ts`, `logger.ts` and the topology-guard `*.test.ts` at the
|
||||
root. Tests are co-located. Add a new module to the folder owning its concern. Even the admin GUI
|
||||
is a drop-in plugin (`examples/plugins/auth-admin/`).
|
||||
|
||||
## Plugins and config import the host only through the two barrels
|
||||
|
||||
2026-08-17, the maintainer. Goal 7. Valid while plugin authors typecheck against a host tree rather
|
||||
than a published package.
|
||||
|
||||
`@plainpages/plugin-api` → `plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, and
|
||||
`#menu-config` → `src/ui/menu-config.ts`; never a relative `../../src/*` path. These two barrels are
|
||||
the whole contract surface, so don't "fix" either back to a relative path.
|
||||
|
||||
## The Ory client types are contract-visible through the barrel
|
||||
|
||||
2026-07-02, the maintainer. Goal 7. Valid while plugins get Ory admin clients through `ctx.system`.
|
||||
|
||||
`@plainpages/plugin-api` re-exports `KratosAdmin`, `KetoClient`, `HydraAdmin`, their DTOs and their
|
||||
error classes. Changing those shapes needs a major `apiVersion` bump, not a free refactor.
|
||||
|
||||
## The barrel is a package, so a plugin folder may carry its own `package.json`
|
||||
|
||||
2026-08-17, the maintainer. Goal 7. Valid while plugins may depend on npm packages (README → Plugin
|
||||
dependencies).
|
||||
|
||||
The Dockerfile links the barrel into `/node_modules`, above every plugin scope. Never let a copy
|
||||
reach a plugin's own `node_modules`: two instances of the barrel break `instanceof` across the
|
||||
boundary. `plugin-api.test.ts` guards this by asserting both paths reach one module.
|
||||
|
||||
## Plugin storage hands over credentials, not a client
|
||||
|
||||
2026-08-18, the maintainer. Goals 3 and 7. Valid while the host stays stateless (AGENTS.md →
|
||||
priority 2).
|
||||
|
||||
README → Plugin storage describes the contract. The host takes `postgres` only to run the
|
||||
provisioning DDL, and `storage-provisioning.ts` is the only module importing it. `storage.ts`
|
||||
beside it stays pure, so `web` never loads a driver. `src/postgres.test.ts` guards both halves,
|
||||
because one value imported from the wrong module breaks this invisibly. The driver is never
|
||||
re-exported through the barrel, so no driver shape enters the contract.
|
||||
|
||||
Three properties hold the design together, so don't trade one away in isolation:
|
||||
|
||||
- Passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than stored, which keeps the host
|
||||
stateless. Whoever holds that secret holds every plugin database, so it ranks with the DB password
|
||||
itself.
|
||||
- The provisioning DSN reaches `bootstrap` only (`src/compose.test.ts` guards the split).
|
||||
- Provisioning never drops anything, so uninstalling a plugin cannot destroy data; boot logs the
|
||||
orphans instead.
|
||||
|
||||
Because the host's copy sits in the ambient `/node_modules`, a plugin can `import "postgres"`
|
||||
without declaring it. That is incidental, not a packaging promise: a plugin must still depend on its
|
||||
own driver.
|
||||
|
||||
## Plugin settings are declared, not discovered
|
||||
|
||||
2026-08-23, the maintainer. Goals 7 and 10. Valid while settings reach plugins through the
|
||||
environment (README → Plugin settings).
|
||||
|
||||
`settings.ts` is pure and takes the env as an argument, so the whole matrix unit-tests without a
|
||||
stack. Four rules carry the design:
|
||||
|
||||
- The prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`: a plugin id `db` with key `url` would
|
||||
otherwise name the host's own `PLUGIN_DB_URL`.
|
||||
- Keys are camelCase, so the `camelCase → SNAKE_CASE` mapping is total and no two keys collide.
|
||||
`findConflicts` (`plugin-host/conflicts.ts`) catches the residual cross-plugin collision.
|
||||
- `required` and `default` are mutually exclusive. That lets `SettingsOf` type a declared key as
|
||||
present rather than `T | undefined`, so no plugin author casts.
|
||||
- A secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings`, not even
|
||||
as a mask or a length.
|
||||
|
||||
An author mistake is refused at discovery, a bad operator value refuses the boot, and a stray
|
||||
`PLUGIN_SETTING_` variable only warns, as an orphan database does.
|
||||
|
||||
## The trust boundary is the `web` process, not the plugin
|
||||
|
||||
2026-08-19, the maintainer. Goal 7. Valid while plugins are operator-installed code, not third-party
|
||||
uploads.
|
||||
|
||||
Per-plugin databases and roles bound accidents, not hostile plugins: `PLUGIN_DB_SECRET` is in
|
||||
`web`'s environment during `onBoot`, and a plugin already holds `ctx.system`'s Ory admin clients. So
|
||||
cross-plugin DB isolation is containment, and README says so. This follows AGENTS.md → priority 7:
|
||||
crash-isolation is a non-goal.
|
||||
|
||||
`server.ts` still deletes the secret from `process.env` right after `loadConfig`, before discovery
|
||||
imports any plugin module. That ordering is the whole point: move the line earlier if anything,
|
||||
never later.
|
||||
|
||||
## `ory/postgres/init/init.sql` is the only home for the Ory databases' ACL
|
||||
|
||||
2026-08-19, the maintainer. Code health. Valid while Ory's databases are created by Postgres' first
|
||||
init.
|
||||
|
||||
Don't re-assert the `REVOKE CONNECT` from `bootstrap`. `REVOKE` only warns when the caller doesn't
|
||||
own the database, so under the least-privilege provisioning account the README recommends it would
|
||||
report success while changing nothing. It also hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a
|
||||
server with no `kratos`. The script runs only on first init, so a revoke added to it later never
|
||||
reaches a volume that already exists: `docker compose down -v` is the dev remedy, and a deployed
|
||||
install needs a migration.
|
||||
|
||||
## `bootstrap.ts` stays under `src/auth/`
|
||||
|
||||
2026-08-19, the maintainer. Code health. Valid until a third seeding concern lands.
|
||||
|
||||
It provisions plugin databases as well as seeding Ory, but it is the one-shot service's entrypoint,
|
||||
not an auth module. Moving it to `src/bootstrap.ts` would edit `compose.yml`, five e2e compose files
|
||||
and `src/compose.test.ts` for a rename.
|
||||
|
||||
## `BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook
|
||||
|
||||
2026-08-19, the maintainer. Goal 7. Valid while no plugin needs an orderly drain of its pools.
|
||||
|
||||
Removing any field from the context costs a major bump, so all six stay. Adding one costs only a
|
||||
minor, so the context otherwise gains a field only when a plugin needs it. Pools handed to a plugin
|
||||
are reaped on process exit.
|
||||
|
||||
## `config/` is a plain dir with no `package.json` of its own
|
||||
|
||||
2026-07-01, the maintainer. Goal 7. Valid while an operator's menu override needs no dependencies.
|
||||
|
||||
With a `package.json` there, `#menu-config` resolves against it instead and boot fails loud. If the
|
||||
override ever needs dependencies, `config/` needs the package treatment `plugins/` has.
|
||||
|
||||
## A plugin `package.json` without `"type": "module"` is refused, not warned
|
||||
|
||||
2026-08-18, the maintainer. Goal 7. Valid while nothing is installed in the wild.
|
||||
|
||||
Refusing is the safe direction: refuse→warn relaxes freely, while warn→refuse breaks installed
|
||||
plugins. Allowing it would cost only a warning and a re-parse per file, not a break: Node detects
|
||||
module syntax, so even a `.js` helper loads. The cost of refusing is that an operator on a read-only
|
||||
third-party mount cannot apply the remedy.
|
||||
|
||||
## `examples/` mirrors the drop-in mount dirs
|
||||
|
||||
2026-07-01, the maintainer. Goal 9. Valid while the examples are meant to be copied in unchanged.
|
||||
|
||||
`examples/plugins/<id>/` copies to `plugins/<id>/`, and `examples/config/menu.ts` to
|
||||
`config/menu.ts`. Both mirrors are in `tsconfig.include` and resolve the host through the barrels,
|
||||
so each typechecks in place and copies across unchanged. Never commit real plugins or config into
|
||||
the root mount dirs: they ship empty.
|
||||
|
||||
## `ctx.chrome` is lazily memoized, never built unconditionally
|
||||
|
||||
2026-06-23, the maintainer. Goal 8. Valid while the public landing (`/`) renders without I/O.
|
||||
|
||||
Don't make it unconditional or move it into the base request context: it protects the I/O-free hot
|
||||
path on the public, bot-hit landing.
|
||||
|
||||
## A plugin-owned render always runs on that plugin's context
|
||||
|
||||
2026-08-03, the maintainer. Goal 7. Valid while each plugin has its own translator.
|
||||
|
||||
The landing slots (`home`, `dashboard`) and an `onRequest` short-circuit build their context with
|
||||
`contextFor(pluginId)`, exactly as a plugin route does. Otherwise `ctx.t` is the core translator and
|
||||
the plugin's own keys render as bare keys on the pages it owns.
|
||||
|
||||
## Email is delegated to Kratos
|
||||
|
||||
2026-06-23, the maintainer. Goal 3. Valid while Kratos owns the recovery and verification flows.
|
||||
|
||||
`web` never touches SMTP. Mail is customized through Kratos' `courier.template_override_path`.
|
||||
|
||||
## The vocabulary is `User` → `Group` → `Permission`, and there is no `Role`
|
||||
|
||||
2026-08-03, the maintainer. Code health. Valid while Keto holds no namespace of its own.
|
||||
|
||||
Keto ships no namespaces: all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one
|
||||
operation ("read shifts"). A role is a bundle, which here is just a group with several grants, and
|
||||
groups nest. Ory's own "permission" (the `Resource` `permits`: view, edit, delete) is the separate
|
||||
per-row tier.
|
||||
|
||||
## A permission name is always `<resource>:<action>`, enforced at discovery
|
||||
|
||||
2026-08-05, the maintainer. Goal 7. Valid while permissions are named by plugin authors.
|
||||
|
||||
Examples: `scheduling:read`, `users:write`. A bare word names who someone is (a role), and roles are
|
||||
groups here. `shapeError` checks every route, nav and profile-menu `permission`, and every declared
|
||||
name, with `isValidPermissionName` (`plugin-host/plugin.ts`). A bad name refuses the boot like any
|
||||
other manifest rule. The check is not left to the admin GUI, which an operator removes by not
|
||||
copying it in.
|
||||
|
||||
## Permission names are authored in plugin code; only grants live in Keto
|
||||
|
||||
2026-08-05, the maintainer. Goal 2. Valid while the admin screens offer only declared names.
|
||||
|
||||
The host collects every installed plugin's declarations into one catalog (`declaredPermissions` →
|
||||
`ctx.declaredPermissions`), and that catalog is the list the admin screens offer. Hence there is no
|
||||
Permissions admin screen: nothing in a GUI invents a name, and holding one is a property of a user
|
||||
or group, edited as a checkbox list there. A Keto tuple naming something no installed plugin
|
||||
declares is not offered as a checkbox, and is never revoked by an unrelated save: the picker only
|
||||
speaks for what it showed. A plugin may still gate on such a name, so it can be live. The user's or
|
||||
group's page lists such a direct grant separately, each with its own Remove button.
|
||||
|
||||
## A permission's `<resource>` is global, not plugin-scoped
|
||||
|
||||
2026-08-05, the maintainer. Goal 7. Valid while cross-plugin sharing of permissions is wanted.
|
||||
|
||||
Hence `oauth2-clients`, not `clients`. Users are the host's, and plugins share them. The cost is
|
||||
that collision-freedom is a convention rather than structural; the alternative penalizes the
|
||||
sharing case.
|
||||
|
||||
## Declaring a permission stays optional
|
||||
|
||||
2026-08-05, the maintainer. Goal 7. Valid while plugins may share a permission.
|
||||
|
||||
Mandatory declaration would let `findConflicts` see every overlap, but it would then warn on exactly
|
||||
the legitimate sharing case. Shape is enforced; declaration is not.
|
||||
|
||||
## `ADMIN_PERMISSIONS` defaults to empty, and an unusable value is dropped with a warning
|
||||
|
||||
2026-08-05, the maintainer. Goal 10. Valid while `bootstrap` is the only writer of grants.
|
||||
|
||||
Fail-loud belongs at the manifest boundary, where a developer authored the mistake. `bootstrap`
|
||||
gates `web`, so refusing operator env would take the whole stack down;
|
||||
`e2e-tests/compose.auth.yml` seeds a bad value to prove the container survives one.
|
||||
|
||||
The seed is a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs
|
||||
`docker compose up -d`, not `restart web`. `bootstrap`'s matching `./plugins` mount belongs in
|
||||
`compose.override.yml` and nowhere else. In the base file it would desynchronise prod and collide
|
||||
with the e2e stacks, which bind individual plugins inside `/app/plugins`: a nested mount into a
|
||||
read-only parent is EROFS, and the container never starts.
|
||||
|
||||
## `actionForMethod` is plugin-local and stays out of `@plainpages/plugin-api`
|
||||
|
||||
2026-08-05, the maintainer. Code health. Valid while route tables name their permission per route.
|
||||
|
||||
Inside the auth-admin example it keeps the route table and the in-handler guard deriving from one
|
||||
function, so 29 routes × 2 gate sites cannot drift. Generalised, it would make authorization a
|
||||
function of the transport verb, and a route table must answer "what does this need?" on its own.
|
||||
|
||||
## A gate is one of `public`, `session` or `permission`, named exactly once
|
||||
|
||||
2026-09-02, the maintainer. Goal 7. Valid while routes and nav nodes are declared in plugin
|
||||
manifests.
|
||||
|
||||
A route or nav node names exactly one gate. Discovery refuses none, two, and a flag spelled anything
|
||||
but `true`, so a forgotten gate fails the boot rather than publishing a page. `src/auth/gate.ts` is
|
||||
the one home of the rule that the plugin router, the host's own route table and the menu all read.
|
||||
The rule binds manifests only; the comment on `allows` says why a node without a gate passes it.
|
||||
|
||||
`session` exists because a plugin whose data is the visitor's own (their upstream account, their own
|
||||
tokens) has no distinction a permission could name. The alternative, granting every newly
|
||||
registered user a permission, couples the identity lifecycle to a Keto write that nothing retries
|
||||
when it fails. A page scoped to "mine" joins on `ctx.user.id`, never the email: an address is
|
||||
user-changeable and can be reassigned to someone who would then inherit the previous holder's rows.
|
||||
|
||||
## The reference plugin's two shift pages duplicate a view model and markup
|
||||
|
||||
2026-09-02, the maintainer. Goal 9. Valid while `examples/plugins/scheduling` stays a teaching
|
||||
artifact rather than a maintained product.
|
||||
|
||||
Each page then reads top to bottom on its own, and an example rarely changes, so the two copies cost
|
||||
little.
|
||||
|
||||
## A `:read`-only holder is never shown a write affordance
|
||||
|
||||
2026-08-05, the maintainer. Goal 2. Valid while the admin GUI splits `:read` from `:write`.
|
||||
|
||||
The list and detail models carry `canWrite`, and the views drop create, save, delete, add and
|
||||
remove. The permission picker still renders, disabled, because seeing who holds what is the point
|
||||
of `:read`. A write-intent GET (a create form, a delete-confirm page) is the exception to
|
||||
`actionForMethod` and gates on `:write`.
|
||||
|
||||
Three guards go with it:
|
||||
|
||||
- You cannot revoke your own direct grants, since undoing that self-lockout needs a `curl` against
|
||||
Keto.
|
||||
- A permission held through a group renders ticked but disabled, because unticked would state the
|
||||
opposite of the truth.
|
||||
- Unticking a permission on a group you are in, removing yourself (or a nested group you are in)
|
||||
from its members, or deleting the group is refused when you would lose a permission you don't hold
|
||||
directly. Undoing that self-lockout would need a `curl` against Keto too.
|
||||
|
||||
## `users:write` and `groups:write` each amount to full administrative access
|
||||
|
||||
2026-08-05, the maintainer. Goal 2. Valid while the admin GUI splits its write permissions by
|
||||
resource.
|
||||
|
||||
`groups:write` adds you to any group, including one holding every permission; `users:write` mints a
|
||||
recovery code for any account. The containment the split buys is real on the read half only:
|
||||
`users:read` is a safe helpdesk grant. Don't let the per-resource naming imply otherwise in docs.
|
||||
|
||||
## The CSRF token is unbound to the session
|
||||
|
||||
2026-10-30, the maintainer. Goal 5. Valid while the session cookie stays `SameSite=Lax` or stricter.
|
||||
|
||||
Leaving the token unbound is the usual cost of a signed double-submit token (`src/auth/csrf.ts`;
|
||||
README → Security model). `SameSite=Lax` and HSTS cover that cost.
|
||||
|
||||
## Plainpages says "user" everywhere, where Ory says "identity"
|
||||
|
||||
2026-08-05, the maintainer. Code health. Valid while Kratos' wire shape names the DTO `Identity`.
|
||||
|
||||
House style, not a renamed concept. The single exception is the `Identity` DTO in
|
||||
`src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape: don't rename it.
|
||||
|
||||
## The locale lives in the URL, never in a cookie
|
||||
|
||||
2026-08-03, the maintainer. Goal 5. Valid while a page's language should show in its address.
|
||||
|
||||
The host resolves the locale from `?locale=sv-SE`, then `Accept-Language`, then `en-US`. When the
|
||||
URL asked for a locale, the host carries it onto the links it renders. A cookie would make a page's
|
||||
language invisible in its address and unshareable; the cost is that a plugin wraps its own hrefs.
|
||||
Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a lone language from
|
||||
`Accept-Language` takes the first regional catalog for it.
|
||||
|
||||
## The core building blocks carry the locale, so a plugin doesn't have to
|
||||
|
||||
2026-08-03, the maintainer. Goal 1. Valid while the locale lives in the URL.
|
||||
|
||||
The shell, `pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu`
|
||||
wrap every href in `localeHref`. Nav and sign-in are wrapped in `chrome.ts`. The two GET forms carry
|
||||
the locale as a hidden `locale` input, since a GET submit replaces the whole query string.
|
||||
|
||||
A form's `action` counts as a link: sign-out, consent and auth-card forms carry the locale too, or
|
||||
picking a language and then saving anything drops back to `Accept-Language`. The obligation stays on
|
||||
the building block, never on each call site. `ctx.localeHref` remains for hrefs a plugin's own
|
||||
markup emits. The one round-trip that cannot carry the locale is the Kratos sign-in POST, to an
|
||||
absolute off-site URL.
|
||||
|
||||
## `locale` and the i18n view locals are reserved by the host
|
||||
|
||||
2026-08-03, the maintainer. Goal 7. Valid while the locale lives in the URL.
|
||||
|
||||
`locale` is in `parseListQuery`'s reserved set, so a localized list page doesn't hand a plugin a
|
||||
phantom `locale` filter. The view locals `t`, `locale`, `locales`, `localeHref`, `localeParam`,
|
||||
`localeSwitch`, `dir` and `assetHref` are merged after a handler's `data`, so a collision loses the
|
||||
key instead of breaking the shell.
|
||||
|
||||
## Catalogs are checked at boot, not at render
|
||||
|
||||
2026-08-03, the maintainer. Goal 7. Valid while catalogs are TypeScript modules loaded at boot.
|
||||
|
||||
Every locale is compared against its set's `en-US`: keys, string-vs-plural kind, and the plural
|
||||
categories `Intl.PluralRules` requires. A mismatch stops startup. A plugin may ship fewer locales
|
||||
than the host, with its strings falling back to `en-US` per key, but never one the host lacks.
|
||||
|
||||
## `locales/` at the repo root is a drop-in mount
|
||||
|
||||
2026-08-03, the maintainer. Goal 7. Valid while adding a language must not require forking the
|
||||
image.
|
||||
|
||||
Like `plugins/` and `config/`: `locales/<tag>.ts` for the core, `locales/plugins/<id>/<tag>.ts` for
|
||||
a plugin. A new tag adds a language; an existing one replaces that catalog wholesale. The shipped
|
||||
`en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is checked
|
||||
rather than trusted. One compared only against itself would boot green with the whole UI rendering
|
||||
keys.
|
||||
|
||||
## The language picker is on every page, POST-rendered ones included
|
||||
|
||||
2026-08-04, the maintainer. Goal 2. Valid while the picker is expected on literally every page; if
|
||||
that softens, hiding it after a POST is simpler.
|
||||
|
||||
A POST-rendered URL often answers no GET (`POST /auth-admin/users/:id/recovery`), so the host resolves
|
||||
the picker's target (`app.ts` → `switchBase`): this path when it answers GET, else the same-origin
|
||||
Referer, else `/`. The accepted cost: switching language there leaves that POST's own result behind.
|
||||
|
||||
## An unknown translation key renders as itself
|
||||
|
||||
2026-08-03, the maintainer. Goal 7. Valid while labels may be either a key or plain text.
|
||||
|
||||
That single rule lets a nav label, branding, or a menu `rename` be either a key or plain text,
|
||||
without a second field or a migration. Don't "fix" it into a loud failure: a manifest with plain
|
||||
labels must keep working.
|
||||
|
||||
## `t()` returns raw text, and the view escapes it
|
||||
|
||||
2026-08-03, the maintainer. Code health. Valid while views escape with `<%= %>`.
|
||||
|
||||
Messages go through `<%= %>` like any other value. One carrying markup uses `<%- %>`, and then its
|
||||
`{{vars}}` are escaped at the call site. Don't move escaping into `t()`: every other value in a view
|
||||
would become the odd one out.
|
||||
|
||||
## RTL is out of scope until there is a real use case
|
||||
|
||||
2026-08-04, the maintainer. Goal 6. Valid while no deployment needs an RTL language.
|
||||
|
||||
`textDirection` sets `<html dir>` because that is free and correct, but the stylesheet keeps
|
||||
physical `left` and `right` properties. A genuine RTL locale needs those moved to logical ones
|
||||
first.
|
||||
|
||||
## A dropdown is a `<button popovertarget>` and a `[popover]`, never a `<details>`
|
||||
|
||||
2026-08-05, the maintainer. Goals 4 and 6. Valid while the popover API is the only zero-JS way to
|
||||
dismiss on an outside click.
|
||||
|
||||
The browser owns open and close, and the panel sits in the top layer, so a row kebab is not clipped
|
||||
by `.table-wrap`'s `overflow`. Four rules hold it together:
|
||||
|
||||
- The panel carries `position-anchor: auto`: a bare `anchor()` resolves to nothing in all three
|
||||
engines.
|
||||
- The panel stays the trigger's next sibling inside the `.menu` wrapper, which the open-state style
|
||||
and the old-browser fallback both read.
|
||||
- The partial requires a caller-named `id` and fails loud without one, since that is the
|
||||
`popovertarget` idref. Never generate one: nondeterministic HTML forecloses caching.
|
||||
- Neither `aria-expanded` nor `aria-haspopup` is written. Every engine maintains the first itself on
|
||||
a declarative `popovertarget` invoker, so a hand-written one replaces a live state with a static
|
||||
lie. The second would promise `role="menu"` semantics these panels don't implement.
|
||||
|
||||
That `aria-expanded` guarantee is the declarative attribute's alone: open a panel from script and no
|
||||
engine applies it, so "enhancing" one of these triggers would cost it its accessibility. `<details>`
|
||||
stays where it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same
|
||||
block for the profile menu, and its comment says why. Keep the two in step.
|
||||
|
||||
## The document is the one scroller
|
||||
|
||||
2026-09-09, the maintainer. Goal 4. Valid while AGENTS.md → priority 1 holds that a page is a
|
||||
document.
|
||||
|
||||
`.app` is `min-height: 100dvh`. `.nav`'s `overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto`
|
||||
are not leftovers of a bounded frame: they make the off-canvas panel usable with a long tree.
|
||||
`#nav-toggle` is `position: fixed` because a label click focuses it, and a browser scrolls a focused
|
||||
element into view.
|
||||
|
||||
## `ICON_NAMES` is a host-owned registry, not a plugin contract
|
||||
|
||||
2026-08-05, the maintainer. Goal 7. Valid while the `every icon <use> resolves` e2e test guards the
|
||||
nav.
|
||||
|
||||
`src/ui/icons.ts` is deliberately not re-exported from `@plainpages/plugin-api`. The palette may
|
||||
narrow when the last reference to an id goes, and a plugin needing one gets it re-registered in the
|
||||
same change. The accepted cost: an unknown sprite id renders blank instead of failing loud.
|
||||
|
||||
## Dependencies install to `/node_modules`, above `WORKDIR /app`
|
||||
|
||||
2026-08-05, the maintainer. Code health. Valid while dev bind-mounts the checkout at `/app`.
|
||||
|
||||
Node resolves upward, so dev's `.:/app` bind mount has nothing to shadow. A volume at
|
||||
`/app/node_modules` would not do: the daemon creates a mount destination as root whatever `--user`
|
||||
says, leaving a root-owned dir in the checkout. Nothing may sit at that path, since it silently
|
||||
shadows `/node_modules`; `src/compose.test.ts` guards the compose files, and `.dockerignore` the
|
||||
image.
|
||||
|
||||
## A container whose output a human then edits or deletes runs as the invoking user
|
||||
|
||||
2026-08-05, the maintainer. Code health. Valid while a dev box may lack `sudo`.
|
||||
|
||||
That means `--user "$(id -u):$(id -g)"` for the E2E runner (artifacts) and a lockfile edit;
|
||||
otherwise the output is root-owned. It is not universal: `bootstrap` writes `jwks.json` as root when
|
||||
it is absent on first boot. The committed dev key makes that rare, and when it happens the rotation
|
||||
runbook's host-side `>` needs the file re-owned first. That exception is valid while the dev key
|
||||
ships committed.
|
||||
|
||||
Three consequences:
|
||||
|
||||
- `e2e-tests/artifacts/` is tracked (`.gitkeep`), since an absent bind-mount source is
|
||||
daemon-created as root and that uid then cannot write it (README → Upgrading).
|
||||
- The runner image sets `HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an
|
||||
unwritable `/`.
|
||||
- Rootless Docker wants the flag dropped, container root already being the invoking user.
|
||||
|
||||
Baking a `USER` into the image does not work: the image's `pwuser` is 1001, and no fixed uid matches
|
||||
every host. `src/compose.test.ts` guards every documented command, and `src/ci-gate.test.ts` the
|
||||
gate's own.
|
||||
|
||||
## Anything the browser logs fails the E2E test that provoked it
|
||||
|
||||
2026-08-05, the maintainer. Goal 4. Valid while pages ship no JavaScript of their own.
|
||||
|
||||
Every spec takes its `test` from `e2e-tests/console-guard.ts`, which fails a test on a console error
|
||||
or warning, or an uncaught exception, on any page it opened. A zero-JS app has nothing to say in the
|
||||
console, so the bar is zero rather than a curated tolerance list. The two exceptions are narrow: a
|
||||
module-level allowance for the COOP header Chromium drops (the e2e stacks serve plain http over
|
||||
container hostnames), and `allowConsole(re)` for a test whose own page provokes a message on
|
||||
purpose.
|
||||
|
||||
`src/e2e-console-guard.test.ts` locks the wiring in the unit gate, since a spec importing `test`
|
||||
straight from Playwright, or minting a page with a raw `newPage()` instead of `watchedPage()`, would
|
||||
run unwatched and green. The accepted cost: a page outliving its test can log late and fail the
|
||||
next one.
|
||||
|
||||
## The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium
|
||||
|
||||
2026-08-05, the maintainer. Goal 4. Valid while the Ory-backed specs share one backend.
|
||||
|
||||
`visual.spec.ts` and `language.spec.ts` are side-effect-free, so parallel runs don't collide, and a
|
||||
console message only appears in the engine that renders the page (`ORY_FREE` in
|
||||
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
|
||||
so widening them means a stack per engine.
|
||||
|
||||
## The docs-only CI skip is `*.md` anywhere in the tree
|
||||
|
||||
2026-08-05, the maintainer. Code health. Valid while no markdown file is rendered or executed.
|
||||
|
||||
Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the
|
||||
destination, so `git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a
|
||||
source file that was gone. `src/ci-gate.test.ts` locks the flags as a text guard, since the test
|
||||
image ships neither `git` nor `bash`.
|
||||
|
||||
Because a `.md` change skips the gate, the Docker Hub overview is
|
||||
`release-tooling/dockerhub-overview.md.tmpl`. A release reads it and a unit test guards it, so under
|
||||
a `.md` name a broken `{{VERSION}}` would merge with that guard skipped.
|
||||
|
||||
`README.md` is the one markdown file tests read, so a README-only change skips them. That leaves two
|
||||
gaps:
|
||||
|
||||
- An `apiVersion` sample in the README can go stale unchecked by
|
||||
`release-tooling/contract-version.test.ts`. Accepted, because the README's samples are
|
||||
illustrative; the copies authors build from (`examples/`, `views/`, the template) are gated.
|
||||
- A README-only change that drops a command the Hub page shows fails only the next gated change, in
|
||||
`release-tooling/dockerhub-overview.test.ts`.
|
||||
|
||||
## CI docker logins share the runner host's Docker config
|
||||
|
||||
2026-07-04, the maintainer. Code health. Valid while releases keep a single-maintainer cadence; if
|
||||
these races start costing re-runs, serialize the workflows with a `concurrency` group.
|
||||
|
||||
The act_runner is host-mode, so `docker login` and `logout` in the workflows mutate one shared
|
||||
`~/.docker/config.json`. Concurrent jobs can race: one job's logout can 401 another's push, and the
|
||||
recovery is a re-run. Tokens sit in that file between login and logout. In the same class,
|
||||
concurrent runs share the workspace dir, so `ci.sh`'s web-image build races another run's container
|
||||
creation on the `<project>-web` tag.
|
||||
@@ -0,0 +1,515 @@
|
||||
# Reading cost
|
||||
|
||||
## Reading cost, chunk 57e864bce02a, finished 2026-10-30T23:59:00Z
|
||||
|
||||
100624 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `src/http/app.test.ts` 9
|
||||
- `README.md` 7
|
||||
- `src/http/app.ts` 4
|
||||
- `todo.md` 3
|
||||
- `e2e-tests/playwright.config.ts` 2
|
||||
- `AGENTS.md` 1
|
||||
- `CHANGELOG.md` 1
|
||||
- `ci.sh` 1
|
||||
- `examples/plugins/scheduling/views/shifts.ejs` 1
|
||||
- `src/auth/csrf.ts` 1
|
||||
|
||||
## Reading cost, chunk df5e30a4db5f, finished 2026-10-31T00:22:00Z
|
||||
|
||||
80251 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `todo.md` 5
|
||||
- `README.md` 3
|
||||
- `CHANGELOG.md` 2
|
||||
- `public/css/styles.css` 2
|
||||
- `AGENTS.md` 1
|
||||
- `src/ui/stylesheet-floor.test.ts` 1
|
||||
- `src/ui/theme-switch.test.ts` 1
|
||||
|
||||
## Reading cost, chunk 3bf0b1a97d77, finished 2026-10-31T00:48:14Z
|
||||
|
||||
167985 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `public/css/styles.css` 17
|
||||
- `todo.md` 7
|
||||
- `README.md` 6
|
||||
- `public/css/auth.css` 5
|
||||
- `AGENTS.md` 1
|
||||
- `CHANGELOG.md` 1
|
||||
- `examples/plugins/scheduling/public/scheduling.css` 1
|
||||
- `examples/plugins/scheduling/views/overview.ejs` 1
|
||||
- `examples/plugins/scheduling/views/shifts.ejs` 1
|
||||
- `src/http/app.test.ts` 1
|
||||
|
||||
## Reading cost, chunk 91d11dbe63f9, finished 2026-10-31T01:13:44Z
|
||||
|
||||
69010 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `todo.md` 7
|
||||
- `README.md` 4
|
||||
- `public/css/styles.css` 4
|
||||
- `CHANGELOG.md` 1
|
||||
- `src/ui/stylesheet-floor.test.ts` 1
|
||||
|
||||
## Reading cost, chunk 70e46c48db80, finished 2026-10-31T07:17:56Z
|
||||
|
||||
70843 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `README.md` 18
|
||||
- `todo.md` 6
|
||||
- `public/css/styles.css` 5
|
||||
- `CHANGELOG.md` 2
|
||||
- `AGENTS.md` 1
|
||||
- `src/auth/jwt-middleware.ts` 1
|
||||
|
||||
## Reading cost, chunk 9e8ffd0aa8dc, finished 2026-10-31T07:36:53Z
|
||||
|
||||
98975 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `README.md` 9
|
||||
- `ci.sh` 3
|
||||
- `e2e-tests/visual.spec.ts` 3
|
||||
- `todo.md` 3
|
||||
- `CHANGELOG.md` 2
|
||||
- `e2e-tests/compose.visual.yml` 2
|
||||
- `e2e-tests/language.spec.ts` 2
|
||||
- `e2e-tests/mock-oidc.ts` 2
|
||||
- `src/ci-gate.test.ts` 2
|
||||
- `.dockerignore` 1
|
||||
|
||||
## Reading cost, chunk 25b7840ff611, finished 2026-10-31T07:46:18Z
|
||||
|
||||
73781 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `todo.md` 5
|
||||
- `README.md` 2
|
||||
- `src/http/app.ts` 2
|
||||
- `AGENTS.md` 1
|
||||
- `src/http/app.test.ts` 1
|
||||
- `src/ui/chrome.test.ts` 1
|
||||
- `src/ui/chrome.ts` 1
|
||||
- `views/dashboard.ejs` 1
|
||||
- `views/partials/shell.ejs` 1
|
||||
|
||||
## Reading cost, chunk e4d3bb0ac092, finished 2026-10-31T08:02:27Z
|
||||
|
||||
154733 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `todo.md` 10
|
||||
- `README.md` 6
|
||||
- `package.json` 3
|
||||
- `.gitignore` 1
|
||||
- `.npmrc` 1
|
||||
- `AGENTS.md` 1
|
||||
- `ci.sh` 1
|
||||
- `public/css/styles.css` 1
|
||||
- `renovate.json` 1
|
||||
- `src/e2e-console-guard.test.ts` 1
|
||||
|
||||
## Reading cost, chunk 2f36d29bd746, finished 2026-10-31T08:19:09Z
|
||||
|
||||
85029 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `e2e-tests/full-flow.spec.ts` 4
|
||||
- `README.md` 3
|
||||
- `src/auth/routes.test.ts` 3
|
||||
- `todo.md` 3
|
||||
- `src/auth/kratos-public.ts` 2
|
||||
- `src/http/app.test.ts` 2
|
||||
- `ci.sh` 1
|
||||
- `compose.yml` 1
|
||||
- `e2e-tests/auth-refresh.spec.ts` 1
|
||||
- `src/auth/flow-view.ts` 1
|
||||
|
||||
## Reading cost, chunk 3373527a4f72, finished 2026-10-31T08:52:59Z
|
||||
|
||||
138664 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `examples/plugins/admin/admin-users.ts` 8
|
||||
- `README.md` 7
|
||||
- `src/http/app.test.ts` 7
|
||||
- `e2e-tests/full-flow.spec.ts` 3
|
||||
- `examples/plugins/admin/admin-groups.ts` 3
|
||||
- `examples/plugins/admin/admin-users.test.ts` 3
|
||||
- `examples/plugins/admin/i18n/en-US.ts` 3
|
||||
- `src/ui/icons.ts` 3
|
||||
- `todo.md` 3
|
||||
- `CHANGELOG.md` 2
|
||||
|
||||
## Reading cost, chunk 0118907cad57, finished 2026-10-31T09:32:49Z
|
||||
|
||||
116614 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `src/http/app.test.ts` 11
|
||||
- `README.md` 7
|
||||
- `src/http/app.ts` 6
|
||||
- `src/ui/shell.test.ts` 5
|
||||
- `e2e-tests/visual.spec.ts` 4
|
||||
- `src/http/static.ts` 4
|
||||
- `todo.md` 3
|
||||
- `src/auth/flow-view.ts` 2
|
||||
- `views/partials/shell.ejs` 2
|
||||
- `AGENTS.md` 1
|
||||
|
||||
## Reading cost reset, 2026-10-31T09:34:55Z
|
||||
|
||||
## Reading cost, chunk 55cc9f01b477, finished 2026-10-31T09:42:24Z
|
||||
|
||||
94244 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `src/http/app.test.ts` 6
|
||||
- `README.md` 4
|
||||
- `src/http/app-plugins.test.ts` 4
|
||||
- `todo.md` 3
|
||||
- `AGENTS.md` 2
|
||||
- `docs/reading-cost.md` 2
|
||||
- `examples/plugins/admin/README.md` 2
|
||||
- `ci.sh` 1
|
||||
- `examples/plugins/admin/admin-clients.test.ts` 1
|
||||
- `examples/plugins/admin/admin-grants.test.ts` 1
|
||||
|
||||
## Reading cost, chunk 4a8c9037acde, finished 2026-10-31T10:07:56Z
|
||||
|
||||
126616 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `src/http/app-admin.test.ts` 7
|
||||
- `examples/plugins/admin/admin-groups.ts` 4
|
||||
- `README.md` 3
|
||||
- `todo.md` 3
|
||||
- `AGENTS.md` 2
|
||||
- `examples/plugins/admin/README.md` 2
|
||||
- `examples/plugins/admin/admin-grants.ts` 2
|
||||
- `examples/plugins/admin/i18n/en-US.ts` 2
|
||||
- `CHANGELOG.md` 1
|
||||
- `examples/plugins/admin/admin-groups.test.ts` 1
|
||||
|
||||
## Reading cost, chunk c90b25a50479, finished 2026-10-31T10:40:23Z
|
||||
|
||||
90935 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `README.md` 9
|
||||
- `todo.md` 5
|
||||
- `AGENTS.md` 3
|
||||
- `CHANGELOG.md` 1
|
||||
- `package.json` 1
|
||||
- `registry-cleanup/cleanup.ts` 1
|
||||
- `release-tooling/dockerhub-overview.md.tmpl` 1
|
||||
- `release-tooling/dockerhub-overview.test.ts` 1
|
||||
- `release-tooling/dockerhub-overview.ts` 1
|
||||
- `src/ci-gate.test.ts` 1
|
||||
|
||||
## Reading cost, chunk 984f4d4c8a76, finished 2026-10-31T11:18:20Z
|
||||
|
||||
209334 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `README.md` 6
|
||||
- `todo.md` 6
|
||||
- `src/ui/stylesheet-floor.test.ts` 4
|
||||
- `src/ui/stylesheet-floor.ts` 4
|
||||
- `CHANGELOG.md` 3
|
||||
- `AGENTS.md` 2
|
||||
- `examples/plugins/scheduling/public/scheduling.css` 2
|
||||
- `package.json` 2
|
||||
- `public/css/styles.css` 2
|
||||
- `ci.sh` 1
|
||||
|
||||
## Reading cost, chunk de26287532f7, finished 2026-10-31T11:29:32Z
|
||||
|
||||
68410 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `todo.md` 5
|
||||
- `public/css/styles.css` 3
|
||||
- `src/ui/stylesheet-floor.test.ts` 3
|
||||
- `CHANGELOG.md` 2
|
||||
- `README.md` 2
|
||||
- `AGENTS.md` 1
|
||||
- `src/ui/theme-switch.test.ts` 1
|
||||
|
||||
## Reading cost, chunk 4a258a237c53, finished 2026-10-31T12:04:07Z
|
||||
|
||||
154777 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `e2e-tests/full-flow.spec.ts` 9
|
||||
- `todo.md` 6
|
||||
- `README.md` 5
|
||||
- `src/auth/flow-view.ts` 5
|
||||
- `src/auth/flow-view.test.ts` 4
|
||||
- `src/auth/routes.ts` 4
|
||||
- `src/auth/kratos-public.test.ts` 3
|
||||
- `src/i18n/locales/en-US.ts` 3
|
||||
- `e2e-tests/compose.full.yml` 2
|
||||
- `src/auth/kratos-admin.ts` 2
|
||||
|
||||
## Reading cost, chunk d8b41620d631, finished 2026-10-31T12:37:38Z
|
||||
|
||||
70583 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `todo.md` 5
|
||||
- `AGENTS.md` 2
|
||||
- `README.md` 2
|
||||
- `examples/plugins/admin/README.md` 2
|
||||
- `src/auth/kratos-admin.ts` 2
|
||||
- `src/http/app-admin.test.ts` 2
|
||||
- `CHANGELOG.md` 1
|
||||
- `e2e-tests/full-flow.spec.ts` 1
|
||||
- `examples/plugins/admin/admin-users.test.ts` 1
|
||||
- `examples/plugins/admin/admin-users.ts` 1
|
||||
|
||||
## Reading cost, chunk f0a49c300aac, finished 2026-10-31T12:47:27Z
|
||||
|
||||
63526 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `README.md` 4
|
||||
- `src/plugin-host/settings.test.ts` 3
|
||||
- `todo.md` 3
|
||||
- `CHANGELOG.md` 2
|
||||
- `src/plugin-host/settings.ts` 2
|
||||
- `AGENTS.md` 1
|
||||
|
||||
## Reading cost, chunk 1a31ef102e70, finished 2026-10-31T13:14:10Z
|
||||
|
||||
70498 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `README.md` 4
|
||||
- `src/auth/bootstrap.test.ts` 4
|
||||
- `src/auth/bootstrap.ts` 4
|
||||
- `todo.md` 4
|
||||
- `CHANGELOG.md` 1
|
||||
- `src/config.test.ts` 1
|
||||
- `src/config.ts` 1
|
||||
- `src/plugin-host/storage-provisioning.ts` 1
|
||||
- `src/plugin-host/storage.ts` 1
|
||||
|
||||
## Reading cost, chunk 80e22d10c810, finished 2026-11-08T13:06:23Z
|
||||
|
||||
130001 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `README.md` 6
|
||||
- `todo.md` 4
|
||||
- `examples/plugins/admin/admin-groups.ts` 2
|
||||
- `src/auth/bootstrap.ts` 2
|
||||
- `src/auth/login.ts` 2
|
||||
- `src/http/app-admin.test.ts` 2
|
||||
- `AGENTS.md` 1
|
||||
- `CHANGELOG.md` 1
|
||||
- `examples/plugins/admin/i18n/sv-SE.ts` 1
|
||||
- `src/auth/routes.ts` 1
|
||||
|
||||
## Reading cost, chunk b94ae3839c24, finished 2026-11-08T13:27:14Z
|
||||
|
||||
87251 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `todo.md` 9
|
||||
- `README.md` 7
|
||||
- `src/plugin-host/plugin.test.ts` 6
|
||||
- `src/plugin-host/plugin.ts` 5
|
||||
- `src/test-tooling/stylesheet-floor.ts` 4
|
||||
- `AGENTS.md` 3
|
||||
- `src/test-tooling/app-harness.ts` 3
|
||||
- `src/plugin-host/discovery.ts` 2
|
||||
- `src/test-tooling/stylesheet-floor.test.ts` 2
|
||||
- `.dockerignore` 1
|
||||
|
||||
## Reading cost, chunk dcd9bc01df44, finished 2026-11-08T13:55:26Z
|
||||
|
||||
212195 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `README.md` 10
|
||||
- `todo.md` 8
|
||||
- `src/http/app.ts` 4
|
||||
- `src/http/app.test.ts` 3
|
||||
- `src/http/static.ts` 3
|
||||
- `CHANGELOG.md` 2
|
||||
- `examples/plugins/admin/admin-users.ts` 2
|
||||
- `release-tooling/contract-version.test.ts` 2
|
||||
- `examples/plugins/admin/admin-clients.ts` 1
|
||||
- `examples/plugins/admin/admin-groups.ts` 1
|
||||
|
||||
## Reading cost, chunk a6f3d52327a4, finished 2026-11-08T14:17:22Z
|
||||
|
||||
50555 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `todo.md` 4
|
||||
|
||||
## Reading cost, chunk 177d8e8081a6, finished 2026-11-08T18:14:49Z
|
||||
|
||||
66196 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `todo.md` 5
|
||||
- `examples/plugins/admin/plugin.ts` 1
|
||||
|
||||
## Reading cost, chunk dd4d721e79d4, finished 2026-11-08T18:52:37Z
|
||||
|
||||
90532 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `todo.md` 8
|
||||
- `README.md` 7
|
||||
- `src/plugin-host/plugin.ts` 6
|
||||
- `src/plugin-host/plugin.test.ts` 4
|
||||
- `src/ui/chrome.ts` 4
|
||||
- `AGENTS.md` 3
|
||||
- `examples/plugins/scheduling/plugin.ts` 3
|
||||
- `src/plugin-host/plugin-api.ts` 3
|
||||
- `src/ui/chrome.test.ts` 3
|
||||
- `CHANGELOG.md` 2
|
||||
|
||||
## Reading cost, chunk f4e8ff50cb2a, finished 2026-11-08T19:37:35Z
|
||||
|
||||
73698 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `src/http/app.ts` 7
|
||||
- `src/function-limits.test.ts` 2
|
||||
- `src/http/context.ts` 2
|
||||
- `src/http/static.ts` 2
|
||||
- `src/plugin-host/plugin.ts` 2
|
||||
- `todo.md` 2
|
||||
- `AGENTS.md` 1
|
||||
- `src/http/builtin-route.ts` 1
|
||||
- `src/plugin-host/hooks.ts` 1
|
||||
|
||||
## Reading cost reset, 2026-11-08T19:39:42Z
|
||||
|
||||
## Reading cost, chunk 385be0aa8aef, finished 2026-11-08T19:57:05Z
|
||||
|
||||
87493 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `README.md` 19
|
||||
- `src/plugin-host/plugin.ts` 14
|
||||
- `src/plugin-host/plugin.test.ts` 9
|
||||
- `todo.md` 9
|
||||
- `AGENTS.md` 4
|
||||
- `docs/ci-cd.md` 4
|
||||
- `src/compose.test.ts` 2
|
||||
- `src/ci-gate.test.ts` 1
|
||||
- `src/function-limits.test.ts` 1
|
||||
- `src/plugin-host/discovery.ts` 1
|
||||
|
||||
## Reading cost, chunk 7100f96a1d8d, finished 2026-11-08T20:19:54Z
|
||||
|
||||
96357 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `AGENTS.md` 11
|
||||
- `README.md` 4
|
||||
- `docs/decisions.md` 4
|
||||
- `.gitea/workflows/release.yml` 2
|
||||
- `examples/plugins/admin/admin-shared.ts` 2
|
||||
- `src/postgres.test.ts` 2
|
||||
- `todo.md` 2
|
||||
- `src/auth/gate.ts` 1
|
||||
- `src/auth/jwt.ts` 1
|
||||
- `src/server.ts` 1
|
||||
|
||||
## Reading cost, chunk ac2b7411e145, finished 2026-11-08T20:50:21Z
|
||||
|
||||
105677 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `README.md` 15
|
||||
- `src/http/app-admin.test.ts` 7
|
||||
- `todo.md` 5
|
||||
- `CHANGELOG.md` 3
|
||||
- `ci.sh` 3
|
||||
- `examples/README.md` 3
|
||||
- `examples/plugins/auth-admin/admin-shared.test.ts` 3
|
||||
- `src/plugin-host/conflicts.ts` 3
|
||||
- `AGENTS.md` 2
|
||||
- `compose.yml` 2
|
||||
|
||||
## Reading cost, chunk 0d9d4ffb47aa, finished 2026-11-08T20:58:38Z
|
||||
|
||||
48347 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `src/http/app.ts` 4
|
||||
- `src/http/app.test.ts` 2
|
||||
- `todo.md` 2
|
||||
- `src/plugin-host/plugin.ts` 1
|
||||
|
||||
## Reading cost, chunk 8d5c5ad2ab7c, finished 2026-11-08T21:39:34Z
|
||||
|
||||
145906 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `src/http/app-admin.test.ts` 8
|
||||
- `README.md` 6
|
||||
- `CHANGELOG.md` 5
|
||||
- `compose.yml` 5
|
||||
- `docs/decisions.md` 5
|
||||
- `examples/plugins/auth-admin/admin-grants.test.ts` 4
|
||||
- `examples/plugins/auth-admin/admin-users.ts` 4
|
||||
- `src/auth/keto-client.ts` 4
|
||||
- `todo.md` 4
|
||||
- `examples/plugins/auth-admin/README.md` 3
|
||||
|
||||
## Reading cost, chunk ef984469151f, finished 2026-11-08T22:01:41Z
|
||||
|
||||
38668 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `todo.md` 7
|
||||
- `AGENTS.md` 1
|
||||
- `README.md` 1
|
||||
- `docs/decisions.md` 1
|
||||
@@ -1,6 +1,6 @@
|
||||
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
|
||||
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
|
||||
FROM mcr.microsoft.com/playwright:v1.63.0-noble
|
||||
FROM mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
|
||||
WORKDIR /e2e-tests
|
||||
|
||||
@@ -8,6 +8,8 @@ COPY e2e-tests/package.json e2e-tests/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY e2e-tests/ ./
|
||||
# e2e-tests/tsconfig.json extends it, so the specs are held to the host's strictness flags.
|
||||
COPY tsconfig.json /tsconfig.json
|
||||
|
||||
# Runs as the invoking `--user` so artifacts land owned by them, not root — and an arbitrary uid has
|
||||
# no passwd entry here, so its home would be the unwritable `/`. npm's cache follows HOME.
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
|
||||
# login, menu filtering by permission, users/groups/OAuth2-clients CRUD + permission granting, a plugin page, logout. A
|
||||
# login, emailed-code verification + recovery, 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
|
||||
# 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 down -v # tear down after
|
||||
services:
|
||||
web:
|
||||
# The base's full depends_on applies (Hydra included — the admin plugin's OAuth2-clients
|
||||
# The base's full depends_on applies (Hydra included — the auth-admin plugin's OAuth2-clients
|
||||
# screen needs it); only the reference plugin's upstream is added. SSO is enabled here only
|
||||
# (clean clone stays password-only): the mock provider's whole array is the env-settable form
|
||||
# Kratos offers, mapped through the committed claims jsonnet.
|
||||
@@ -24,18 +24,18 @@ services:
|
||||
timeout: 4s
|
||||
retries: 30
|
||||
# plugins/ is empty in the image; bind the example plugins in so the browser flow can open the
|
||||
# gated /scheduling/shifts page and the /admin/* screens (the admin screens ship as a drop-in
|
||||
# plugin, mounted at /app/plugins/admin, reaching the host's Ory clients via ctx.system).
|
||||
# gated /scheduling/shifts page and the /auth-admin/* screens (the admin screens ship as a drop-in
|
||||
# plugin, mounted at /app/plugins/auth-admin, reaching the host's Ory clients via ctx.system).
|
||||
volumes:
|
||||
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
||||
- ./examples/plugins/admin:/app/plugins/admin:ro
|
||||
- ./examples/plugins/auth-admin:/app/plugins/auth-admin:ro
|
||||
|
||||
# bootstrap grants the demo admin every discovered plugin's permission names, so it needs the
|
||||
# example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403.
|
||||
bootstrap:
|
||||
volumes:
|
||||
- ./examples/plugins/scheduling:/app/plugins/scheduling:ro
|
||||
- ./examples/plugins/admin:/app/plugins/admin:ro
|
||||
- ./examples/plugins/auth-admin:/app/plugins/auth-admin:ro
|
||||
|
||||
# Browser-facing URLs (base_url, every ui_url, the after-login redirect) move to the gateway host.
|
||||
# `--dev`: the browser hits the gateway over http, but Kratos marks cookies Secure for a
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
|
||||
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||
shifts-upstream:
|
||||
image: node:24.21.0-alpine3.24
|
||||
image: node:24.22.2-alpine3.24
|
||||
command: ["node", "/server.ts"]
|
||||
volumes:
|
||||
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
||||
@@ -66,7 +66,7 @@ services:
|
||||
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
||||
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
||||
mock-oidc:
|
||||
image: node:24.21.0-alpine3.24
|
||||
image: node:24.22.2-alpine3.24
|
||||
command: ["node", "/mock-oidc.ts"]
|
||||
environment:
|
||||
ISSUER: http://mock-oidc:9000
|
||||
@@ -79,9 +79,20 @@ services:
|
||||
timeout: 4s
|
||||
retries: 15
|
||||
|
||||
# Catches the courier's mail (kratos.yml → smtp://mailpit:1025), so a test can read an emailed code.
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.33.1
|
||||
mem_limit: 128m
|
||||
memswap_limit: 128m
|
||||
healthcheck:
|
||||
test: ["CMD", "/mailpit", "readyz"]
|
||||
interval: 2s
|
||||
timeout: 4s
|
||||
retries: 15
|
||||
|
||||
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
||||
proxy:
|
||||
image: node:24.21.0-alpine3.24
|
||||
image: node:24.22.2-alpine3.24
|
||||
command: ["node", "/proxy.ts"]
|
||||
depends_on:
|
||||
web:
|
||||
@@ -103,12 +114,16 @@ services:
|
||||
dockerfile: e2e-tests/Dockerfile
|
||||
command: ["npx", "playwright", "test", "full-flow.spec.ts"]
|
||||
depends_on:
|
||||
mailpit:
|
||||
condition: service_healthy
|
||||
mock-oidc:
|
||||
condition: service_healthy
|
||||
proxy:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
BASE_URL: http://proxy
|
||||
KETO_WRITE_URL: http://keto:4467
|
||||
KRATOS_ADMIN_URL: http://kratos:4434
|
||||
MAILPIT_URL: http://mailpit:8025
|
||||
volumes:
|
||||
- ./e2e-tests/artifacts:/e2e-tests/artifacts
|
||||
|
||||
+139
-26
@@ -1,5 +1,5 @@
|
||||
import type { Browser, Page } from "@playwright/test";
|
||||
import { expect, test, watchedPage } from "./console-guard.ts";
|
||||
import { allowConsole, expect, test, watchedPage } from "./console-guard.ts";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
// Full browser E2E: the real Playwright UI against the live stack via the same-origin
|
||||
@@ -14,6 +14,9 @@ const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap, holds the
|
||||
const ADMIN_PASSWORD = "admin";
|
||||
const SSO_EMAIL = "sso-user@plainpages.local"; // minted by the mock OIDC provider on first SSO login
|
||||
const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't collide on names
|
||||
const KRATOS_ADMIN = process.env.KRATOS_ADMIN_URL ?? "http://kratos:4434";
|
||||
const KETO_WRITE = process.env.KETO_WRITE_URL ?? "http://keto:4467";
|
||||
const MAILPIT = process.env.MAILPIT_URL ?? "http://mailpit:8025";
|
||||
|
||||
// Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in.
|
||||
async function loginPassword(page: Page): Promise<void> {
|
||||
@@ -53,7 +56,7 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
// The list screens rebuild their query from the list state (sort/page/filter), so they are where a
|
||||
// chosen language is most easily dropped; the core building blocks carry it through.
|
||||
test("a sorted, paged admin list keeps the visitor's language", async () => {
|
||||
await page.goto("/admin/users?locale=sv-SE");
|
||||
await page.goto("/auth-admin/users?locale=sv-SE");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
await expect(page.getByRole("heading", { name: "Användare" })).toBeVisible();
|
||||
|
||||
@@ -78,7 +81,7 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
// A POST that re-renders a page: the write must keep the language, and the picker — which is on
|
||||
// every page — must point somewhere that answers GET rather than at the POST-only URL.
|
||||
test("a write keeps the visitor's language, and the picker still works on the POST-rendered page", async () => {
|
||||
await page.goto("/admin/users?locale=sv-SE");
|
||||
await page.goto("/auth-admin/users?locale=sv-SE");
|
||||
await page.getByRole("link", { name: "Ny användare" }).click();
|
||||
await page.fill('input[name="email"]', `lang-${suffix}@plainpages.local`);
|
||||
await page.getByRole("button", { name: "Skapa användare" }).click();
|
||||
@@ -87,7 +90,7 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
|
||||
// Open the new user's edit page the way the CRUD test does — the row's Edit link carries the id.
|
||||
const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` });
|
||||
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
||||
const editHref = await row.locator('a[href^="/auth-admin/users/"]').first().getAttribute("href");
|
||||
await page.goto(`${editHref}`);
|
||||
await expect(page.locator('button[aria-label="Språk"]')).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route
|
||||
@@ -106,63 +109,111 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
// seeds exactly those), so both gated sections are present in the menu (collapsed by default →
|
||||
// assert they're in the DOM, not necessarily visible).
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1);
|
||||
await expect(page.locator('.sidebar a[href="/auth-admin/users"]')).toHaveCount(1);
|
||||
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("users CRUD: create a user, see it listed, then delete it via the confirm step", async () => {
|
||||
const email = `e2e-${suffix}@plainpages.local`;
|
||||
await page.goto("/admin/users/new");
|
||||
await page.goto("/auth-admin/users/new");
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.fill('input[name="first"]', "E2E");
|
||||
await page.fill('input[name="last"]', "User");
|
||||
await page.locator('.form-card button[type="submit"]').click();
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/users(\?|$)/); // PRG back to the list
|
||||
await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/); // PRG back to the list
|
||||
const row = page.locator("tr", { hasText: email });
|
||||
await expect(row).toBeVisible();
|
||||
|
||||
// Row actions sit behind the kebab popover: opening it reveals them, in the top layer, so the
|
||||
// scrolling table around the row cannot clip the panel.
|
||||
await row.locator("button.kebab").click();
|
||||
await expect(row.locator('a[href^="/admin/users/"]').first()).toBeVisible();
|
||||
await expect(row.locator('a[href^="/auth-admin/users/"]').first()).toBeVisible();
|
||||
|
||||
// Delete through the confirm interstitial (the row's Edit link carries the id).
|
||||
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
||||
const editHref = await row.locator('a[href^="/auth-admin/users/"]').first().getAttribute("href");
|
||||
await page.goto(`${editHref}/delete`);
|
||||
await page.getByRole("button", { name: "Delete user" }).click(); // the confirm form's danger button
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
|
||||
await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/);
|
||||
await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async () => {
|
||||
// Kratos accepts an admin-minted code only in the flow it was minted on, which the notice's link opens.
|
||||
test("recovery code: the notice's link opens the flow that accepts the code, and the code signs the user in", async () => {
|
||||
test.setTimeout(90_000);
|
||||
const email = `admin-recovery-${suffix}@plainpages.local`;
|
||||
await createIdentity(email);
|
||||
await page.goto("/auth-admin/users");
|
||||
const editHref = await page.locator("tr", { hasText: email }).locator('a[href^="/auth-admin/users/"]').first().getAttribute("href");
|
||||
await page.goto(editHref!);
|
||||
await page.getByRole("button", { name: "Generate recovery code" }).click();
|
||||
const code = await page.locator(".recovery-code code").innerText();
|
||||
const link = await page.locator(".alert-body a").getAttribute("href");
|
||||
|
||||
const user = await watchedPage(await browser.newContext());
|
||||
await user.goto(link!);
|
||||
await user.fill('input[name="code"]', code);
|
||||
await user.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(user).toHaveURL(/\/settings/);
|
||||
await user.context().close();
|
||||
});
|
||||
|
||||
test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async ({ browser }) => {
|
||||
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form
|
||||
// enforces it); pick the first option (a user) from the required picker.
|
||||
const group = `e2e-grp-${suffix}`;
|
||||
await page.goto("/admin/groups/new");
|
||||
await page.goto("/auth-admin/groups/new");
|
||||
await page.fill('input[name="name"]', group);
|
||||
await page.locator('select[name="member"]').selectOption({ index: 1 });
|
||||
await page.locator('.form-card button[type="submit"]').click();
|
||||
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
|
||||
await expect(page).toHaveURL(/\/auth-admin\/groups(\?|\/|$)/);
|
||||
await expect(page.locator("main")).toContainText(group);
|
||||
|
||||
// Permissions are declared in plugin code, so the group's detail page offers them as a fixed
|
||||
// checkbox list rather than a create form — there is no Permissions screen to visit.
|
||||
await page.goto(`/admin/groups/${group}`);
|
||||
await page.goto(`/auth-admin/groups/${group}`);
|
||||
allowConsole(/status of 409 .*\/auth-admin\/groups\/[^/]+\/permissions/); // the stale save below, refused on purpose
|
||||
// A second browser signed in as the same admin, opened before the save below, so its form carries the old grants.
|
||||
const staleTab = await watchedPage(await browser.newContext({ storageState: await page.context().storageState() }));
|
||||
await staleTab.goto(`/auth-admin/groups/${group}`);
|
||||
const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
|
||||
await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
|
||||
await expect(scheduling).not.toBeChecked();
|
||||
await scheduling.check();
|
||||
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
|
||||
|
||||
await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`));
|
||||
await expect(page).toHaveURL(new RegExp(`/auth-admin/groups/${group}`));
|
||||
await expect(page.getByText("Permissions saved.")).toBeVisible();
|
||||
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
|
||||
|
||||
// The other tab no longer matches Keto, so its save is refused and shows the grant made above.
|
||||
await staleTab.locator('input[name="permission"][value="scheduling:write"]').check();
|
||||
await staleTab.locator('form:has(input[name="permission"]) button[type="submit"]').click();
|
||||
await expect(staleTab.getByText("changed while you had the page open")).toBeVisible();
|
||||
await expect(staleTab.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
|
||||
await expect(staleTab.locator('input[name="permission"][value="scheduling:write"]')).not.toBeChecked();
|
||||
await staleTab.context().close();
|
||||
|
||||
// No screen can grant a name no plugin declares, so write one to Keto directly.
|
||||
const stray = "retired:write";
|
||||
const put = await fetch(`${KETO_WRITE}/admin/relation-tuples`, {
|
||||
body: JSON.stringify({ namespace: "Permission", object: stray, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "PUT",
|
||||
});
|
||||
expect(put.ok).toBe(true);
|
||||
await page.goto(`/auth-admin/groups/${group}`);
|
||||
await expect(page.getByRole("heading", { name: "Permissions no installed plugin declares" })).toBeVisible();
|
||||
await page.getByRole("button", { name: `Remove ${stray}` }).click();
|
||||
await expect(page.getByText("Permission removed.")).toBeVisible();
|
||||
await expect(page.getByText(stray)).toHaveCount(0);
|
||||
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked(); // the declared grant stays
|
||||
});
|
||||
|
||||
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
|
||||
const name = `e2e-client-${suffix}`;
|
||||
await page.goto("/admin/clients");
|
||||
await page.goto("/auth-admin/clients");
|
||||
await page.getByRole("link", { name: "Register client" }).click();
|
||||
await page.fill('input[name="name"]', name);
|
||||
await page.fill('textarea[name="redirectUris"]', "https://app.example.com/callback");
|
||||
@@ -175,17 +226,17 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
await expect(page.locator("#csecret")).toHaveValue(/.+/);
|
||||
|
||||
// Listed; the row header links to the plain detail, which never shows the secret again.
|
||||
await page.goto("/admin/clients");
|
||||
await page.goto("/auth-admin/clients");
|
||||
const row = page.locator("tr", { hasText: name });
|
||||
await expect(row).toBeVisible();
|
||||
await row.getByRole("link", { name }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/admin/clients/${clientId}`));
|
||||
await expect(page).toHaveURL(new RegExp(`/auth-admin/clients/${clientId}`));
|
||||
await expect(page.locator("#csecret")).toHaveCount(0);
|
||||
|
||||
// Delete through the confirm interstitial (danger link on the detail → confirm form's button).
|
||||
await page.getByRole("link", { name: "Delete client" }).click();
|
||||
await page.getByRole("button", { name: "Delete client" }).click();
|
||||
await expect(page).toHaveURL(/\/admin\/clients(\?|$)/);
|
||||
await expect(page).toHaveURL(/\/auth-admin\/clients(\?|$)/);
|
||||
await expect(page.locator("tr", { hasText: name })).toHaveCount(0);
|
||||
});
|
||||
|
||||
@@ -202,7 +253,7 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
});
|
||||
|
||||
test("plugin settings: the screen names the variable that sets each declared key", async () => {
|
||||
await page.goto("/admin/plugin-settings");
|
||||
await page.goto("/auth-admin/plugin-settings");
|
||||
await expect(page.locator("h1")).toHaveText("Plugin settings");
|
||||
// The reference plugin's one declared setting, and the variable an operator would set for it.
|
||||
const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" });
|
||||
@@ -215,14 +266,14 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
test("logout: signing out ends the session and returns to the login page", async () => {
|
||||
await page.goto("/dashboard");
|
||||
await page.locator("button.profile").click(); // open the profile dropdown
|
||||
// Sign out is the only item in it — the menu offers nothing that goes nowhere.
|
||||
await expect(page.locator("#profile-menu .menu-item")).toHaveText(["Sign out"]);
|
||||
await expect(page.locator("#profile-menu .menu-item")).toHaveText(["My shifts", "Sign out"]);
|
||||
await expect(page.locator('#profile-menu a.menu-item[href="/scheduling/mine"]')).toHaveCount(1);
|
||||
await page.locator('form[action="/logout"] button[type="submit"]').click();
|
||||
await page.waitForURL(/\/login(\?|$)/);
|
||||
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
|
||||
await page.goto("/dashboard");
|
||||
await expect(page).toHaveURL(/\/login(\?|$)/);
|
||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
||||
await expect(page.locator('.sidebar a[href="/auth-admin/users"]')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -230,16 +281,27 @@ test("return_to: a deep link while logged out returns to that page after login",
|
||||
test.setTimeout(90_000);
|
||||
// A gated deep link, logged out → bounced to the themed login (return_to is baked into the Kratos
|
||||
// flow server-side, so it's consumed, not shown in the settled URL).
|
||||
await page.goto("/admin/users");
|
||||
await page.goto("/auth-admin/users");
|
||||
await expect(page).toHaveURL(/\/login(\?|$)/);
|
||||
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
|
||||
await page.fill('input[name="password"]', ADMIN_PASSWORD);
|
||||
await page.locator('.auth-form button[type="submit"]').click();
|
||||
// Completion routes through /auth/complete (mints the JWT) and on to the requested page, not the dashboard.
|
||||
await expect(page).toHaveURL(/\/admin\/users(\?|$)/);
|
||||
await expect(page).toHaveURL(/\/auth-admin\/users(\?|$)/);
|
||||
await expect(page.locator("h1")).toHaveText("Users");
|
||||
});
|
||||
|
||||
test("settings: an anonymous visit signs in first, then lands on the settings page", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto("/settings");
|
||||
await expect(page).toHaveURL(/\/login(\?|$)/);
|
||||
await page.fill('input[name="identifier"]', ADMIN_EMAIL);
|
||||
await page.fill('input[name="password"]', ADMIN_PASSWORD);
|
||||
await page.locator('.auth-form button[type="submit"]').click();
|
||||
await expect(page).toHaveURL(/\/settings(\?|$)/);
|
||||
await expect(page.locator("h1")).toHaveText("Account settings");
|
||||
});
|
||||
|
||||
test("mocked SSO login: the provider button signs a user in via OIDC", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.goto("/login");
|
||||
@@ -248,5 +310,56 @@ test("mocked SSO login: the provider button signs a user in via OIDC", async ({
|
||||
// Mock OIDC auto-approves → Kratos creates the identity → /auth/complete → dashboard, signed in.
|
||||
await expect(page.locator(".profile-mail")).toHaveText(SSO_EMAIL);
|
||||
// A fresh SSO identity holds no permissions, so the gated Admin section stays hidden.
|
||||
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
|
||||
await expect(page.locator('.sidebar a[href="/auth-admin/users"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
// The newest one-time code Kratos' courier mailed to `to`, read from the mail catcher (compose.full.yml).
|
||||
async function mailedCode(to: string): Promise<string> {
|
||||
let code = "";
|
||||
await expect.poll(async () => {
|
||||
const search = (await (await fetch(`${MAILPIT}/api/v1/search?query=${encodeURIComponent(`to:"${to}"`)}`)).json()) as { messages: { ID: string }[] };
|
||||
const id = search.messages[0]?.ID;
|
||||
if (!id) return "";
|
||||
const message = (await (await fetch(`${MAILPIT}/api/v1/message/${id}`)).json()) as { Text: string };
|
||||
code = /\b(\d{6})\b/.exec(message.Text)?.[1] ?? "";
|
||||
return code;
|
||||
}, { timeout: 30_000 }).not.toBe("");
|
||||
return code;
|
||||
}
|
||||
|
||||
async function createIdentity(email: string): Promise<void> {
|
||||
const created = await fetch(`${KRATOS_ADMIN}/admin/identities`, {
|
||||
body: JSON.stringify({ schema_id: "default", traits: { email } }),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
expect(created.status).toBe(201);
|
||||
}
|
||||
|
||||
// Fills the email step, then pastes the mailed code with whitespace around it, as a paste often brings.
|
||||
async function enterMailedCode(page: Page, email: string): Promise<void> {
|
||||
await page.fill('input[name="email"]', email);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
await expect(page.locator('input[name="code"]')).toBeVisible();
|
||||
await page.fill('input[name="code"]', ` ${await mailedCode(email)}\t `);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
}
|
||||
|
||||
test("verification: a code pasted with whitespace around it is accepted", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const email = `verify-${suffix}@plainpages.local`;
|
||||
await createIdentity(email);
|
||||
await page.goto("/verification");
|
||||
await enterMailedCode(page, email);
|
||||
await expect(page.getByText("You successfully verified your email address.")).toBeVisible();
|
||||
});
|
||||
|
||||
test("recovery: a pasted code signs in to the settings page, in the chosen language", async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
const email = `recover-${suffix}@plainpages.local`;
|
||||
await createIdentity(email);
|
||||
await page.goto("/recovery?locale=en-US");
|
||||
await enterMailedCode(page, email);
|
||||
await expect(page).toHaveURL(/\/settings\?.*locale=en-US/);
|
||||
await expect(page.locator("h1")).toHaveText("Account settings");
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// never leaves the compose network. Auto-approves /authorize (no provider login UI), then signs an
|
||||
// RS256 id_token Kratos verifies against /jwks. stdlib only, in-memory, NOT app code. The single
|
||||
// host (mock-oidc:9000) is reachable by both the browser (/authorize) and Kratos (token/jwks).
|
||||
import { createServer } from "node:http";
|
||||
import { createServer, type ServerResponse } from "node:http";
|
||||
import { createSign, generateKeyPairSync, randomUUID } from "node:crypto";
|
||||
|
||||
const ISSUER = process.env.ISSUER ?? "http://mock-oidc:9000";
|
||||
@@ -15,8 +15,8 @@ const KID = "mock-1";
|
||||
const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const jwk = { ...publicKey.export({ format: "jwk" }), alg: "RS256", kid: KID, use: "sig" };
|
||||
|
||||
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString("base64url");
|
||||
function idToken(nonce) {
|
||||
const b64 = (o: unknown): string => Buffer.from(JSON.stringify(o)).toString("base64url");
|
||||
function idToken(nonce: string): string {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = b64({ alg: "RS256", kid: KID, typ: "JWT" });
|
||||
const payload = b64({
|
||||
@@ -27,8 +27,8 @@ function idToken(nonce) {
|
||||
return `${header}.${payload}.${sig}`;
|
||||
}
|
||||
|
||||
const codes = new Map(); // single-use auth code → the nonce Kratos sent (echoed into the id_token)
|
||||
const json = (res, body) => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
|
||||
const codes = new Map<string, string>(); // single-use auth code → the nonce Kratos sent (echoed into the id_token)
|
||||
const json = (res: ServerResponse, body: unknown): void => { res.writeHead(200, { "content-type": "application/json" }); res.end(JSON.stringify(body)); };
|
||||
|
||||
createServer((req, res) => {
|
||||
const url = new URL(req.url ?? "/", ISSUER);
|
||||
|
||||
Generated
+406
-12
@@ -6,17 +6,19 @@
|
||||
"": {
|
||||
"name": "plainpages-e2e",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0"
|
||||
"@playwright/test": "1.64.1",
|
||||
"@types/node": "24.19.2",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz",
|
||||
"integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.63.0"
|
||||
"playwright": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -25,14 +27,364 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
|
||||
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-loong64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-s390x": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-sunos-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz",
|
||||
"integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.63.0"
|
||||
"playwright-core": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -42,9 +394,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -53,6 +405,48 @@
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"tsc": "bin/tsc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||
"@typescript/typescript-linux-arm": "7.0.2",
|
||||
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||
"@typescript/typescript-linux-x64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@
|
||||
"description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test"
|
||||
"test": "playwright test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.63.0"
|
||||
"@playwright/test": "1.64.1",
|
||||
"@types/node": "24.19.2",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
||||
testDir: ".",
|
||||
outputDir: "artifacts/test-output",
|
||||
fullyParallel: true,
|
||||
workers: 4, // Playwright's default is half the cores, so a run's parallelism would depend on the machine
|
||||
forbidOnly: true,
|
||||
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
|
||||
use: {
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ const PORT = Number(process.env.PORT ?? 80);
|
||||
|
||||
// Kratos public owns these prefixes (self-service flows, sessions, its well-known/schemas); the
|
||||
// browser hits them via the flow action + OIDC callbacks. Everything else is the web app.
|
||||
const toKratos = (path) => ["/self-service", "/sessions", "/.well-known/ory", "/schemas"].some((p) => path === p || path.startsWith(`${p}/`));
|
||||
const toKratos = (path: string): boolean => ["/self-service", "/sessions", "/.well-known/ory", "/schemas"].some((p) => path === p || path.startsWith(`${p}/`));
|
||||
|
||||
createServer((req, res) => {
|
||||
const target = toKratos(req.url ?? "/") ? KRATOS : WEB;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": { "lib": ["ESNext", "DOM"] },
|
||||
"include": ["*.ts"]
|
||||
}
|
||||
+103
-7
@@ -10,6 +10,15 @@ const SESSION_COOKIE = "plainpages_jwt"; // src/auth/login.ts — web verifies i
|
||||
const shot = (page: Page, name: string): Promise<Buffer> =>
|
||||
page.screenshot({ fullPage: true, path: `artifacts/screenshots/${test.info().project.name}/${name}.png` });
|
||||
|
||||
// Either side of the stylesheet's 860px and 560px breakpoints.
|
||||
const VIEWPORTS = [
|
||||
{ name: "desktop-xl", width: 1920, height: 1080 },
|
||||
{ name: "laptop", width: 1280, height: 800 },
|
||||
{ name: "tablet", width: 768, height: 1024 },
|
||||
{ name: "phone", width: 390, height: 844 },
|
||||
{ name: "phone-small", width: 320, height: 568 },
|
||||
] as const;
|
||||
|
||||
// Sign a session JWT with the committed dev tokenizer key (bind-mounted at /repo/jwks.json), so the
|
||||
// gated dashboard renders for a "signed-in" user without standing up Ory — web verifies it
|
||||
// with the same key by `kid`, exactly as it verifies a real Kratos-tokenizer JWT.
|
||||
@@ -34,7 +43,7 @@ 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 [1280, 390]) {
|
||||
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);
|
||||
@@ -77,13 +86,21 @@ test("captures the live pages for review", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio
|
||||
await shot(page, "live-03-dark");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/dashboard");
|
||||
await shot(page, "live-04-mobile");
|
||||
await page.setViewportSize({ width: 1280, height: 800 });
|
||||
});
|
||||
|
||||
// The page is a document, so a column wider than the viewport can only show as sideways scroll.
|
||||
for (const { name, width, height } of VIEWPORTS) {
|
||||
for (const [path, label] of [["/dashboard", "dashboard"], ["/", "landing"], ["/scheduling", "plugin"]] as const) {
|
||||
test(`the ${label} page fits ${width}px wide, with no sideways scroll`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height });
|
||||
await page.goto(path);
|
||||
const [scrollWidth, clientWidth] = await page.evaluate(() => [document.documentElement.scrollWidth, document.documentElement.clientWidth] as const);
|
||||
expect(scrollWidth).toBeLessThanOrEqual(clientWidth);
|
||||
await shot(page, `live-${label}-${name}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("every icon <use> resolves to a defined <symbol> (no broken graphics)", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
const missing = await page.evaluate(() => {
|
||||
@@ -148,6 +165,21 @@ test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({
|
||||
expect(offCanvas).toBe(true);
|
||||
});
|
||||
|
||||
test("the hamburger opens the drawer over the scrim, and a link in it navigates", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/dashboard");
|
||||
await page.locator(".hamburger").click();
|
||||
|
||||
const sidebar = page.locator(".sidebar");
|
||||
await expect(sidebar).toBeInViewport({ ratio: 1 });
|
||||
// A click is refused while the scrim sits above the drawer, which a lower z-index would cause.
|
||||
await sidebar.locator("summary").first().click(); // the dashboard is the current page, so its links are in a folded branch
|
||||
const link = sidebar.locator(".nav-children a.nav-self").first();
|
||||
const target = await link.getAttribute("href");
|
||||
await link.click();
|
||||
await expect(page).toHaveURL((url) => url.pathname === target?.split("?")[0]);
|
||||
});
|
||||
|
||||
test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
// The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit).
|
||||
@@ -174,6 +206,70 @@ test("the public landing at / is ungated and links to sign in + register", async
|
||||
await shot(page, "live-05-public-landing");
|
||||
});
|
||||
|
||||
// Measured on the rendered elements, so a theme block that drops --on-accent fails here.
|
||||
const contrastOf = (el: Element): number => {
|
||||
const luminance = (rgb: string): number => {
|
||||
const [r = 0, g = 0, b = 0] = (rgb.match(/[\d.]+/g) ?? []).map((c) => {
|
||||
const v = Number(c) / 255;
|
||||
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const style = getComputedStyle(el);
|
||||
const [hi = 0, lo = 0] = [luminance(style.color), luminance(style.backgroundColor)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
for (const [name, colorScheme, forceDark] of [["light", "light", false], ["dark", "dark", false], ["forced dark", "light", true]] as const) {
|
||||
test(`text on the accent reaches 4.5:1 in ${name} mode`, async ({ page, context }) => {
|
||||
await context.clearCookies();
|
||||
await page.emulateMedia({ colorScheme });
|
||||
await page.goto("/");
|
||||
if (forceDark) await page.locator("#theme-dark").check({ force: true });
|
||||
// No page of this stack has a second page of results, so add the current-page button.
|
||||
await page.locator("#main-content").evaluate((el) => el.insertAdjacentHTML("beforeend", '<a class="page-btn" aria-current="page" href="#">2</a>'));
|
||||
for (const selector of [".landing .btn-primary", ".brand-mark", ".page-btn"]) {
|
||||
await expect.poll(() => page.locator(selector).first().evaluate(contrastOf), { message: selector }).toBeGreaterThanOrEqual(4.5); // polls past the colour transition
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("a nav branch is folded until its toggle opens it — no JavaScript", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
const child = page.locator('.sidebar a[href="/scheduling"]');
|
||||
await expect(child).toBeHidden();
|
||||
await page.locator('.sidebar summary[aria-label="Toggle Scheduling"]').click();
|
||||
await expect(child).toBeVisible();
|
||||
});
|
||||
|
||||
test("the reference plugin's lead text is inset by --pad-x, like the bars around it", async ({ page }) => {
|
||||
await page.goto("/scheduling");
|
||||
const [inset, padX] = await page.locator(".scheduling-page > p").evaluate((p) => [
|
||||
p.getBoundingClientRect().left - (p.parentElement as HTMLElement).getBoundingClientRect().left,
|
||||
parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--pad-x")),
|
||||
] as const);
|
||||
expect(inset).toBe(padX);
|
||||
});
|
||||
|
||||
// transferSize is 0 only for a response the browser's own cache served; a 304 still costs a round trip.
|
||||
test("a repeat page view fetches no stylesheet again", async ({ page }) => {
|
||||
const stylesheets = (): Promise<{ name: string; transferSize: number }[]> => page.evaluate(() =>
|
||||
performance.getEntriesByType("resource")
|
||||
.filter((entry): entry is PerformanceResourceTiming => entry instanceof PerformanceResourceTiming && entry.initiatorType === "link" && entry.name.includes(".css"))
|
||||
.map(({ name, transferSize }) => ({ name, transferSize })));
|
||||
|
||||
await page.goto("/");
|
||||
const first = await stylesheets();
|
||||
expect(first.length, "the landing links styles.css and auth.css").toBeGreaterThanOrEqual(2);
|
||||
for (const { name, transferSize } of first) expect(transferSize, `${name} on the first view`).toBeGreaterThan(0);
|
||||
|
||||
await page.goto("/");
|
||||
const repeat = await stylesheets();
|
||||
// A cache hit completes in no set order, so the two views list the same files in any order.
|
||||
const names = (entries: { name: string }[]): string[] => entries.map(({ name }) => name).sort();
|
||||
expect(names(repeat)).toEqual(names(first));
|
||||
for (const { name, transferSize } of repeat) expect(transferSize, `${name} on the repeat view`).toBe(0);
|
||||
});
|
||||
|
||||
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
|
||||
allowConsole(/status of 404 .*\/no-such-page$/); // the navigation under test, which Chromium and WebKit log — not a sub-resource of it
|
||||
const res = await page.goto("/no-such-page");
|
||||
@@ -216,7 +312,7 @@ test("the reference plugin: public Overview is open to all, My shifts takes any
|
||||
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
||||
await expect(page.locator('.sidebar a[href="/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/mine"]')).toHaveCount(1); // session gate: a session is enough
|
||||
await expect(page.locator('.sidebar nav a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough
|
||||
|
||||
// No shifts upstream on this stack, so this also pins the degraded page: the reason, never a 500
|
||||
// and never a claim about what is assigned.
|
||||
|
||||
+1
-1
@@ -6,6 +6,6 @@ across (or bind-mount your own) and restart.
|
||||
| Path | Copy into | Example of |
|
||||
| --- | --- | --- |
|
||||
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
|
||||
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
|
||||
| [`plugins/auth-admin/`](plugins/auth-admin/) | `plugins/auth-admin/` | The system-admin plugin: the Users / Groups / OAuth2-clients / Plugin-settings screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/auth-admin/README.md). |
|
||||
| [`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. |
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
// cp examples/config/menu.ts config/menu.ts
|
||||
// Absent config = built-in defaults.
|
||||
//
|
||||
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — the
|
||||
// override always wins, applied before the per-user permission filter. Every field is optional.
|
||||
// Brand the app, reorder/rename/group/hide nav nodes and rename/hide profile-menu entries (by their
|
||||
// `id`) across all plugins — the override always wins, applied before the per-user permission
|
||||
// filter. Every field is optional.
|
||||
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system.
|
||||
|
||||
import { defineMenu } from "#menu-config";
|
||||
@@ -16,7 +17,8 @@ export default defineMenu({
|
||||
// theme: "auto", // default color theme: auto | light | dark
|
||||
},
|
||||
|
||||
// Operator override (rename → group → order → hide), keyed by node id.
|
||||
// Operator override (rename → group → order → hide), keyed by nav-node id; `rename` and `hide` also
|
||||
// take a profile-menu entry's id, such as "scheduling:profile-mine".
|
||||
override: {
|
||||
// rename: { people: "Staff" }, // node id → new label (or a catalog key)
|
||||
// groups: [{ id: "admin", label: "Admin", children: ["users", "groups"] }],
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
// The pure half of permission granting: what a submitted checkbox set changes, and the picker the
|
||||
// two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { PermissionDecl } from "@plainpages/plugin-api";
|
||||
import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts";
|
||||
|
||||
const declared: PermissionDecl[] = [
|
||||
{ description: "View users", name: "users:read" },
|
||||
{ description: "Edit users", name: "users:write" },
|
||||
{ name: "groups:read" },
|
||||
];
|
||||
|
||||
test("grantTuple targets a user by subject_id and a group by subject_set", () => {
|
||||
assert.deepEqual(grantTuple("users:read", userSubject("u1")), { namespace: "Permission", object: "users:read", relation: "granted", subject_id: "user:u1" });
|
||||
assert.deepEqual(grantTuple("users:read", groupSubject("eng")), {
|
||||
namespace: "Permission", object: "users:read", relation: "granted",
|
||||
subject_set: { namespace: "Group", object: "eng", relation: "members" },
|
||||
});
|
||||
});
|
||||
|
||||
test("grantDiff: the submitted set is the desired state — tick grants, untick revokes, unchanged is a no-op", () => {
|
||||
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read", "users:write"]), { grant: ["users:write"], revoke: [] });
|
||||
assert.deepEqual(grantDiff(declared, ["users:read", "users:write"], ["users:read"]), { grant: [], revoke: ["users:write"] });
|
||||
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read"]), { grant: [], revoke: [] });
|
||||
assert.deepEqual(grantDiff(declared, ["users:read"], []), { grant: [], revoke: ["users:read"] }); // every box cleared
|
||||
});
|
||||
|
||||
test("grantDiff ignores anything the plugins don't declare, in both directions", () => {
|
||||
// A crafted POST can't grant a name no plugin gates on…
|
||||
assert.deepEqual(grantDiff(declared, [], ["superuser:all"]), { grant: [], revoke: [] });
|
||||
// …and a held name that is no longer declared (its plugin was uninstalled) is left alone rather
|
||||
// than silently revoked by an unrelated save — this screen only speaks for what it offered.
|
||||
assert.deepEqual(grantDiff(declared, ["legacy:thing"], ["users:read"]), { grant: ["users:read"], revoke: [] });
|
||||
});
|
||||
|
||||
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
|
||||
const picker = buildPermissionPicker({ action: "/admin/users/u1/permissions", declared, direct: ["users:write"] });
|
||||
assert.equal(picker.action, "/admin/users/u1/permissions");
|
||||
assert.deepEqual(picker.choices.map((c) => c.name), ["users:read", "users:write", "groups:read"]);
|
||||
assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]);
|
||||
assert.equal(picker.choices[0]?.description, "View users");
|
||||
assert.equal(picker.choices[2]?.description, ""); // a declaration may omit one
|
||||
assert.equal(picker.empty, undefined);
|
||||
assert.equal(picker.readOnly, false);
|
||||
assert.equal(picker.inheritedNote, undefined); // nothing is group-held here
|
||||
});
|
||||
|
||||
// An inherited permission rendered unticked would say "not held" about a grant that reaches the JWT,
|
||||
// and unticking it writes nothing, reading as a successful revoke. So inherited rows are ticked,
|
||||
// disabled, and never posted.
|
||||
test("buildPermissionPicker distinguishes a direct grant from one inherited through a group", () => {
|
||||
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:write"], effective: ["users:read", "users:write"] });
|
||||
assert.deepEqual(picker.choices.map((c) => [c.name, c.checked, c.inherited]), [
|
||||
["users:read", true, true], // effective but not direct → shown as held, not editable here
|
||||
["users:write", true, false], // direct → editable
|
||||
["groups:read", false, false],
|
||||
]);
|
||||
assert.ok(picker.inheritedNote, "the disabled row needs an explanation");
|
||||
});
|
||||
|
||||
test("buildPermissionPicker in read-only mode still shows the state, and marks itself unwritable", () => {
|
||||
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:read"], effective: ["users:read", "groups:read"], readOnly: true });
|
||||
assert.equal(picker.readOnly, true);
|
||||
assert.deepEqual(picker.choices.map((c) => c.checked), [true, false, true]); // a reader still sees who holds what
|
||||
// Every row renders disabled for a reader, so the writable copy would be wrong twice over: "tick to
|
||||
// grant" is false, and "greyed-out means group-held" would misattribute the direct grant.
|
||||
assert.equal(picker.inheritedNote, undefined);
|
||||
assert.notEqual(picker.hint, buildPermissionPicker({ action: "/x", declared, direct: [] }).hint);
|
||||
});
|
||||
|
||||
test("buildPermissionPicker notes the transitive lag for a group, and stays quiet for a user", () => {
|
||||
// A group's members inherit, so the change reaches them at their next re-mint; a user's own grant
|
||||
// change revokes their live tokens, so there is nothing to warn about.
|
||||
assert.ok(buildPermissionPicker({ action: "/x", declared, direct: [], transitive: true }).pending);
|
||||
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: [] }).pending, undefined);
|
||||
});
|
||||
|
||||
test("buildPermissionPicker says so when no plugin declares a permission, rather than rendering an empty box", () => {
|
||||
const picker = buildPermissionPicker({ action: "/x", declared: [], direct: [] });
|
||||
assert.deepEqual(picker.choices, []);
|
||||
assert.ok(picker.empty);
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
# Admin — the system-administration plugin
|
||||
# auth-admin — the plugin that administers users, groups, permissions, OAuth2 clients and plugin settings
|
||||
|
||||
The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped as a **drop-in
|
||||
example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/`
|
||||
(it keeps the id and mount path `admin`, so the screens live at `/admin/*`) and restart:
|
||||
(its folder name `auth-admin` is its id and mount path, so the screens live at `/auth-admin/*`) and restart:
|
||||
|
||||
```bash
|
||||
cp -r examples/plugins/admin plugins/admin
|
||||
cp -r examples/plugins/auth-admin plugins/auth-admin
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
@@ -42,6 +42,7 @@ Admin section entirely.
|
||||
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a
|
||||
GUI, so the host's catalog (`ctx.declaredPermissions`) is the fixed list — and holding one is a
|
||||
property of a user or a group, edited as a checkbox list on those two screens (`admin-grants.ts`).
|
||||
A grant of a name no installed plugin declares is listed apart on the same page, with a Remove.
|
||||
|
||||
## Layout
|
||||
|
||||
@@ -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
|
||||
unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in
|
||||
`src/http/app.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`.
|
||||
`src/http/app-admin.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`.
|
||||
+6
-6
@@ -1,6 +1,6 @@
|
||||
// Built-in OAuth2 clients admin screen: the pure view-model + Hydra-payload builders. A client
|
||||
// is an Ory Hydra OAuth2 client (apps that log in *through* us); writes go only to Hydra. The
|
||||
// HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in app.test.ts.
|
||||
// HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in src/http/app-admin.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
@@ -59,15 +59,15 @@ test("validateClientInput requires a name, ≥1 redirect URI, and absolute redir
|
||||
test("buildClientsListModel filters by search, paginates; the name links to the detail page", () => {
|
||||
const clients = Array.from({ length: 30 }, (_, i) => ({ client_id: `id-${String(i).padStart(2, "0")}`, client_name: `app-${String(i).padStart(2, "0")}`, token_endpoint_auth_method: "client_secret_basic" }));
|
||||
|
||||
const all = buildClientsListModel({ clients, url: "http://x/admin/clients" });
|
||||
const all = buildClientsListModel({ clients, url: "http://x/auth-admin/clients" });
|
||||
assert.equal(all.pagination.summary.total, 30);
|
||||
assert.equal(all.table.rows.length, 25); // default page size
|
||||
assert.equal(all.title, "OAuth2 clients");
|
||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||
assert.equal(first.rowHeader.text, "app-00");
|
||||
assert.equal(first.rowHeader.href, "/admin/clients/id-00");
|
||||
assert.equal(first.rowHeader.href, "/auth-admin/clients/id-00");
|
||||
|
||||
const one = buildClientsListModel({ clients, url: "http://x/admin/clients?q=app-07" });
|
||||
const one = buildClientsListModel({ clients, url: "http://x/auth-admin/clients?q=app-07" });
|
||||
assert.equal(one.pagination.summary.total, 1);
|
||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||
});
|
||||
@@ -75,7 +75,7 @@ test("buildClientsListModel filters by search, paginates; the name links to the
|
||||
test("buildClientFormModel: a register form with name + scope fields; values reflected on error", () => {
|
||||
const m = buildClientFormModel({ csrfToken: "tok.sig" });
|
||||
assert.equal(m.title, "Register client");
|
||||
assert.equal(m.form.action, "/admin/clients");
|
||||
assert.equal(m.form.action, "/auth-admin/clients");
|
||||
assert.equal(m.form.submitLabel, "Register client");
|
||||
assert.equal(m.form.csrfToken, "tok.sig");
|
||||
assert.equal(m.form.nameField.required, true);
|
||||
@@ -94,7 +94,7 @@ test("buildClientDetailModel: client info + delete action; the one-time secret +
|
||||
|
||||
const plain = buildClientDetailModel({ client });
|
||||
assert.equal(plain.title, "Acme");
|
||||
assert.equal(plain.delete.action, "/admin/clients/c1/delete");
|
||||
assert.equal(plain.delete.action, "/auth-admin/clients/c1/delete");
|
||||
assert.equal(plain.created, false);
|
||||
assert.equal(plain.secret, undefined);
|
||||
|
||||
+8
-24
@@ -5,7 +5,7 @@
|
||||
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
|
||||
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
|
||||
|
||||
import { can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
||||
import { buildPaginationModel, can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
||||
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
import type { FieldConfig } from "./admin-users.ts";
|
||||
|
||||
@@ -122,7 +122,7 @@ export function buildClientsListModel(opts: {
|
||||
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }],
|
||||
canWrite: opts.canWrite !== false,
|
||||
filterBar: listFilterBar(state, t),
|
||||
pagination: listPagination(state, page, t),
|
||||
pagination: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.clients.pagination"), pageSizes: PAGE_SIZES }),
|
||||
table: listTable(rows, t),
|
||||
title: t("admin.clients.title"),
|
||||
};
|
||||
@@ -158,22 +158,6 @@ function listFilterBar(state: ListState, t: Translate) {
|
||||
};
|
||||
}
|
||||
|
||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
|
||||
const hidden: { name: string; value: string }[] = [];
|
||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
||||
return {
|
||||
label: t("admin.clients.pagination"),
|
||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
||||
pages: page.pages.map((p) =>
|
||||
p.ellipsis ? { ellipsis: true }
|
||||
: p.current ? { current: true, label: String(p.page) }
|
||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
||||
summary: { from: page.from, to: page.to, total: page.total },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- register form + detail view models ----
|
||||
|
||||
export function buildClientFormModel(opts: {
|
||||
@@ -273,13 +257,13 @@ const canWriteClients = (ctx: RequestContext): boolean => can(ctx, permissionNam
|
||||
const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult =>
|
||||
({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ canWrite: canWriteClients(ctx), client: toClientView(client), csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-detail" });
|
||||
|
||||
// GET /admin/clients — the list.
|
||||
// GET /auth-admin/clients — the list.
|
||||
export const clientsList = withClients(async ({ ctx, hydra }) => {
|
||||
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
|
||||
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ canWrite: canWriteClients(ctx), clients, csrfToken: ctx.chrome.csrfToken, t: ctx.t, url: ctx.url }) }, view: "clients" };
|
||||
});
|
||||
|
||||
// POST /admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
|
||||
// POST /auth-admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
|
||||
// returns it again). A Hydra 4xx (bad redirect/scope) re-renders the form (400); a 5xx rethrows → 500.
|
||||
export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
|
||||
const input = readClientInput((await guardedForm(ctx))!);
|
||||
@@ -296,13 +280,13 @@ export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
|
||||
return clientDetailResult(ctx, created, { created: true, ...(created.client_secret ? { secret: created.client_secret } : {}) });
|
||||
});
|
||||
|
||||
// GET /admin/clients/new — the register form.
|
||||
// GET /auth-admin/clients/new — the register form.
|
||||
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})), "write");
|
||||
|
||||
// GET /admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
|
||||
// GET /auth-admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
|
||||
export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client)));
|
||||
|
||||
// GET /admin/clients/:id/delete — the deliberate confirm step.
|
||||
// GET /auth-admin/clients/:id/delete — the deliberate confirm step.
|
||||
export const clientsDeleteConfirm = withClient((deps, client, id) => {
|
||||
const base = detailHref(id);
|
||||
const name = toClientView(client).name;
|
||||
@@ -314,7 +298,7 @@ export const clientsDeleteConfirm = withClient((deps, client, id) => {
|
||||
}) }, view: "confirm" });
|
||||
}, "write");
|
||||
|
||||
// POST /admin/clients/:id/delete — perform it.
|
||||
// POST /auth-admin/clients/:id/delete — perform it.
|
||||
export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => {
|
||||
await guardedForm(ctx); // CSRF-verify the POST
|
||||
await hydra.deleteClient(id);
|
||||
@@ -0,0 +1,158 @@
|
||||
// The pure half of permission granting: what a submitted checkbox set changes, and the picker the
|
||||
// two screens render from it. The Keto writes and the HTTP round trip are covered in src/http/app-admin.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { PermissionDecl } from "@plainpages/plugin-api";
|
||||
import type { KetoClient } from "@plainpages/plugin-api";
|
||||
import { applyGrants, baselineToken, buildPermissionPicker, grantDiff, grantTuple, groupSubject, isStale, removeUndeclaredGrant, userSubject } from "./admin-grants.ts";
|
||||
|
||||
const declared: PermissionDecl[] = [
|
||||
{ description: "View users", name: "users:read" },
|
||||
{ description: "Edit users", name: "users:write" },
|
||||
{ name: "groups:read" },
|
||||
];
|
||||
|
||||
test("grantTuple targets a user by subject_id and a group by subject_set", () => {
|
||||
assert.deepEqual(grantTuple("users:read", userSubject("u1")), { namespace: "Permission", object: "users:read", relation: "granted", subject_id: "user:u1" });
|
||||
assert.deepEqual(grantTuple("users:read", groupSubject("eng")), {
|
||||
namespace: "Permission", object: "users:read", relation: "granted",
|
||||
subject_set: { namespace: "Group", object: "eng", relation: "members" },
|
||||
});
|
||||
});
|
||||
|
||||
test("grantDiff: the submitted set is the desired state — tick grants, untick revokes, unchanged is a no-op", () => {
|
||||
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read", "users:write"]), { grant: ["users:write"], revoke: [] });
|
||||
assert.deepEqual(grantDiff(declared, ["users:read", "users:write"], ["users:read"]), { grant: [], revoke: ["users:write"] });
|
||||
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read"]), { grant: [], revoke: [] });
|
||||
assert.deepEqual(grantDiff(declared, ["users:read"], []), { grant: [], revoke: ["users:read"] }); // every box cleared
|
||||
});
|
||||
|
||||
test("grantDiff ignores anything the plugins don't declare, in both directions", () => {
|
||||
// A crafted POST can't grant a name no plugin gates on…
|
||||
assert.deepEqual(grantDiff(declared, [], ["superuser:all"]), { grant: [], revoke: [] });
|
||||
// …and a held name that is no longer declared (its plugin was uninstalled) is left alone rather
|
||||
// than silently revoked by an unrelated save — this screen only speaks for what it offered.
|
||||
assert.deepEqual(grantDiff(declared, ["legacy:thing"], ["users:read"]), { grant: ["users:read"], revoke: [] });
|
||||
});
|
||||
|
||||
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
|
||||
const picker = buildPermissionPicker({ action: "/auth-admin/users/u1/permissions", declared, direct: ["users:write"] });
|
||||
assert.equal(picker.action, "/auth-admin/users/u1/permissions");
|
||||
assert.deepEqual(picker.choices.map((c) => c.name), ["users:read", "users:write", "groups:read"]);
|
||||
assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]);
|
||||
assert.equal(picker.choices[0]?.description, "View users");
|
||||
assert.equal(picker.choices[2]?.description, ""); // a declaration may omit one
|
||||
assert.equal(picker.empty, undefined);
|
||||
assert.equal(picker.readOnly, false);
|
||||
assert.equal(picker.inheritedNote, undefined); // nothing is group-held here
|
||||
});
|
||||
|
||||
// An inherited permission rendered unticked would say "not held" about a grant that reaches the JWT,
|
||||
// and unticking it writes nothing, reading as a successful revoke. So inherited rows are ticked,
|
||||
// disabled, and never posted.
|
||||
test("buildPermissionPicker distinguishes a direct grant from one inherited through a group", () => {
|
||||
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:write"], effective: ["users:read", "users:write"] });
|
||||
assert.deepEqual(picker.choices.map((c) => [c.name, c.checked, c.inherited]), [
|
||||
["users:read", true, true], // effective but not direct → shown as held, not editable here
|
||||
["users:write", true, false], // direct → editable
|
||||
["groups:read", false, false],
|
||||
]);
|
||||
assert.ok(picker.inheritedNote, "the disabled row needs an explanation");
|
||||
});
|
||||
|
||||
test("buildPermissionPicker in read-only mode still shows the state, and marks itself unwritable", () => {
|
||||
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:read"], effective: ["users:read", "groups:read"], readOnly: true });
|
||||
assert.equal(picker.readOnly, true);
|
||||
assert.deepEqual(picker.choices.map((c) => c.checked), [true, false, true]); // a reader still sees who holds what
|
||||
// Every row renders disabled for a reader, so the writable copy would be wrong twice over: "tick to
|
||||
// grant" is false, and "greyed-out means group-held" would misattribute the direct grant.
|
||||
assert.equal(picker.inheritedNote, undefined);
|
||||
assert.notEqual(picker.hint, buildPermissionPicker({ action: "/x", declared, direct: [] }).hint);
|
||||
});
|
||||
|
||||
test("buildPermissionPicker notes the transitive lag for a group, and stays quiet for a user", () => {
|
||||
// A group's members inherit, so the change reaches them at their next re-mint; a user's own grant
|
||||
// change revokes their live tokens, so there is nothing to warn about.
|
||||
assert.ok(buildPermissionPicker({ action: "/x", declared, direct: [], transitive: true }).pending);
|
||||
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: [] }).pending, undefined);
|
||||
});
|
||||
|
||||
test("buildPermissionPicker says so when no plugin declares a permission, rather than rendering an empty box", () => {
|
||||
const picker = buildPermissionPicker({ action: "/x", declared: [], direct: [] });
|
||||
assert.deepEqual(picker.choices, []);
|
||||
assert.ok(picker.empty);
|
||||
});
|
||||
|
||||
test("baselineToken depends on the offered grants held, not on their order or on undeclared ones", () => {
|
||||
const token = baselineToken(declared, ["users:read", "users:write"]);
|
||||
assert.match(token, /^[0-9a-f]+$/);
|
||||
assert.equal(baselineToken(declared, ["users:write", "users:read"]), token);
|
||||
assert.equal(baselineToken(declared, ["users:read", "users:write", "legacy:thing"]), token); // undeclared: never shown, never saved
|
||||
assert.notEqual(baselineToken(declared, ["users:read"]), token);
|
||||
assert.notEqual(baselineToken(declared, []), token);
|
||||
assert.notEqual(baselineToken(declared, []), ""); // an empty set is a state too, not an absent token
|
||||
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: ["users:read", "users:write"] }).baseline, token);
|
||||
});
|
||||
|
||||
test("isStale refuses a missing or old token, but only for a save that would change something", () => {
|
||||
const held = ["users:read"];
|
||||
const change = grantDiff(declared, held, ["users:read", "users:write"]);
|
||||
const nothing = grantDiff(declared, held, ["users:read"]);
|
||||
const current = new URLSearchParams({ baseline: baselineToken(declared, held) });
|
||||
const old = new URLSearchParams({ baseline: baselineToken(declared, []) });
|
||||
assert.equal(isStale(current, declared, held, change), false);
|
||||
assert.equal(isStale(old, declared, held, change), true);
|
||||
assert.equal(isStale(new URLSearchParams(), declared, held, change), true); // no token at all
|
||||
assert.equal(isStale(old, declared, held, nothing), false); // a repeated save has nothing to refuse
|
||||
});
|
||||
|
||||
test("applyGrants sends the whole change as one Keto transaction, and a failure applies none of it", async () => {
|
||||
const calls: { delete: string[]; insert: string[] }[] = [];
|
||||
const keto = (fail = false): KetoClient => ({
|
||||
check: async () => false,
|
||||
deleteTuple: async () => { throw new Error("unused"); },
|
||||
expand: async () => ({ type: "leaf" }),
|
||||
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
|
||||
transactTuples: async (c) => {
|
||||
if (fail) throw new Error("boom");
|
||||
calls.push({ delete: c.delete.map((tp) => tp.object), insert: c.insert.map((tp) => tp.object) });
|
||||
},
|
||||
writeTuple: async () => { throw new Error("unused"); },
|
||||
});
|
||||
const diff = { grant: ["a:read", "b:read"], revoke: ["c:read"] };
|
||||
|
||||
await applyGrants(keto(), userSubject("u1"), diff);
|
||||
assert.deepEqual(calls, [{ delete: ["c:read"], insert: ["a:read", "b:read"] }]);
|
||||
await assert.rejects(applyGrants(keto(true), userSubject("u1"), diff), /boom/);
|
||||
});
|
||||
|
||||
test("buildPermissionPicker lists direct grants no plugin declares, with a remove action only for a writer", () => {
|
||||
const direct = ["zed:read", "users:read", "old:write"];
|
||||
const picker = buildPermissionPicker({ action: "/auth-admin/users/u1/permissions", declared, direct });
|
||||
assert.deepEqual(picker.undeclared?.names, ["old:write", "zed:read"]);
|
||||
assert.equal(picker.undeclared?.action, "/auth-admin/users/u1/permissions/remove");
|
||||
assert.ok(picker.undeclared?.remove);
|
||||
assert.equal(buildPermissionPicker({ action: "/x", declared, direct, readOnly: true }).undeclared?.remove, undefined);
|
||||
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: ["users:read"] }).undeclared, undefined);
|
||||
assert.equal(picker.baseline, buildPermissionPicker({ action: "/x", declared, direct: ["users:read"] }).baseline, "an undeclared grant doesn't make a form stale");
|
||||
});
|
||||
|
||||
test("removeUndeclaredGrant deletes one held, undeclared grant and refuses anything else", async () => {
|
||||
const subject = userSubject("u1");
|
||||
const tuples = [grantTuple("old:write", subject), grantTuple("users:read", subject), grantTuple("old:write", userSubject("u2"))];
|
||||
const deleted: string[] = [];
|
||||
const keto: KetoClient = {
|
||||
check: async () => false,
|
||||
deleteTuple: async (tp) => { deleted.push(`${tp.object} ${tp.subject_id}`); },
|
||||
expand: async () => ({ type: "leaf" }),
|
||||
listRelations: async (q = {}) => ({ nextPageToken: null, tuples: tuples.filter((tp) => tp.subject_id === q.subject_id) }),
|
||||
transactTuples: async () => { throw new Error("unused"); },
|
||||
writeTuple: async () => { throw new Error("unused"); },
|
||||
};
|
||||
assert.equal(await removeUndeclaredGrant(keto, subject, declared, "old:write"), "old:write");
|
||||
assert.deepEqual(deleted, ["old:write user:u1"]);
|
||||
assert.equal(await removeUndeclaredGrant(keto, subject, declared, "users:read"), undefined, "a declared name is the picker's to change");
|
||||
assert.equal(await removeUndeclaredGrant(keto, subject, declared, "never:held"), undefined);
|
||||
assert.equal(await removeUndeclaredGrant(keto, subject, declared, null), undefined);
|
||||
assert.deepEqual(deleted, ["old:write user:u1"]);
|
||||
});
|
||||
+71
-6
@@ -3,14 +3,26 @@
|
||||
// resolves a group's grant transitively at login.
|
||||
//
|
||||
// The set of permissions that *exist* is `ctx.declaredPermissions` — the host's catalog, built from
|
||||
// what the installed plugins declare in code. Nothing here invents a name, which is why the old
|
||||
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
|
||||
// what the installed plugins declare in code. Nothing here invents a name: a grant is a property of a
|
||||
// user or a group, edited where they are.
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
|
||||
|
||||
const PERMISSION_NS = "Permission";
|
||||
const GRANTED = "granted";
|
||||
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
|
||||
export const REMOVE_FIELD = "remove"; // the hidden field naming the undeclared grant a Remove form drops
|
||||
export const BASELINE_FIELD = "baseline"; // the hidden field carrying baselineToken
|
||||
export const SAVED_NOTICE = "permissions-saved"; // the `info-msg` a successful save redirects with
|
||||
export const REMOVED_NOTICE = "permission-removed"; // the `info-msg` a Remove redirects with; its own words, since the checkboxes were not saved
|
||||
|
||||
// Only these exact values count, so a crafted link can't put words on the page.
|
||||
export function grantsNotice(url: URL, t: Translate): string | undefined {
|
||||
const msg = url.searchParams.get("info-msg");
|
||||
if (msg === SAVED_NOTICE) return t("admin.grants.saved");
|
||||
return msg === REMOVED_NOTICE ? t("admin.grants.removed") : undefined;
|
||||
}
|
||||
|
||||
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
|
||||
|
||||
@@ -53,6 +65,7 @@ export interface PermissionChoice {
|
||||
|
||||
export interface PermissionPicker {
|
||||
action: string;
|
||||
baseline: string; // baselineToken of the direct grants this form was rendered from
|
||||
choices: PermissionChoice[];
|
||||
empty: string | undefined; // set when no plugin declares a permission — the picker has nothing to offer
|
||||
error?: string; // a rejected save (e.g. the self-revoke guard), rendered above the list
|
||||
@@ -60,11 +73,24 @@ export interface PermissionPicker {
|
||||
hint: string;
|
||||
inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row
|
||||
legend: string;
|
||||
notice?: string; // a confirmation, rendered above the list
|
||||
// Set for a group: its members hold these transitively, so a change reaches them at their next
|
||||
// re-mint rather than at once. The user picker revokes live tokens, so it says nothing.
|
||||
pending: string | undefined;
|
||||
readOnly: boolean; // the viewer holds :read but not :write — show the state, offer no save
|
||||
submit: string;
|
||||
undeclared: UndeclaredGrants | undefined; // set when the subject holds a name no installed plugin declares
|
||||
}
|
||||
|
||||
// Direct grants of names nobody declares (their plugin was uninstalled, or the name was written to
|
||||
// Keto by hand). grantDiff never touches them, so they get their own list and a Remove per name.
|
||||
export interface UndeclaredGrants {
|
||||
action: string; // where each Remove posts
|
||||
field: string;
|
||||
hint: string;
|
||||
legend: string;
|
||||
names: string[];
|
||||
remove: string | undefined; // the button label; unset for a reader, who is shown the names only
|
||||
}
|
||||
|
||||
// The checkbox list: every declared permission, ticked where this subject holds it. A fixed list
|
||||
@@ -81,6 +107,7 @@ export function buildPermissionPicker(opts: {
|
||||
}): PermissionPicker {
|
||||
const t = opts.t ?? ((k: string) => k);
|
||||
const directSet = new Set(opts.direct);
|
||||
const undeclared = undeclaredHeld(opts.declared, opts.direct);
|
||||
const effectiveSet = new Set(opts.effective ?? opts.direct);
|
||||
const choices = opts.declared.map((decl) => ({
|
||||
checked: directSet.has(decl.name) || effectiveSet.has(decl.name),
|
||||
@@ -90,6 +117,7 @@ export function buildPermissionPicker(opts: {
|
||||
}));
|
||||
return {
|
||||
action: opts.action,
|
||||
baseline: baselineToken(opts.declared, opts.direct),
|
||||
choices,
|
||||
empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined,
|
||||
field: PERMISSIONS_FIELD,
|
||||
@@ -101,13 +129,26 @@ export function buildPermissionPicker(opts: {
|
||||
pending: opts.transitive === true ? t("admin.grants.pending") : undefined,
|
||||
readOnly: opts.readOnly === true,
|
||||
submit: t("admin.grants.save"),
|
||||
undeclared: undeclared.length === 0 ? undefined : {
|
||||
action: `${opts.action}/remove`,
|
||||
field: REMOVE_FIELD,
|
||||
hint: t("admin.grants.undeclaredHint"),
|
||||
legend: t("admin.grants.undeclaredLegend"),
|
||||
names: undeclared,
|
||||
remove: opts.readOnly === true ? undefined : t("admin.grants.remove"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const undeclaredHeld = (declared: readonly PermissionDecl[], held: readonly string[]): string[] => {
|
||||
const offered = new Set(declared.map((d) => d.name));
|
||||
return held.filter((name) => !offered.has(name)).sort();
|
||||
};
|
||||
|
||||
// What a submitted set changes. Pure so the diff is testable without Keto: only declared names are
|
||||
// considered, so a crafted POST cannot grant something no plugin gates on, and a held-but-undeclared
|
||||
// name (left over from an uninstalled plugin) is never silently revoked by an unrelated save.
|
||||
export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): { grant: string[]; revoke: string[] } {
|
||||
export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): GrantChange {
|
||||
const offered = new Set(declared.map((d) => d.name));
|
||||
const heldSet = new Set(held);
|
||||
const wantedSet = new Set(wanted.filter((name) => offered.has(name)));
|
||||
@@ -117,7 +158,31 @@ export function grantDiff(declared: readonly PermissionDecl[], held: string[], w
|
||||
};
|
||||
}
|
||||
|
||||
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: { grant: string[]; revoke: string[] }): Promise<void> {
|
||||
for (const name of diff.grant) await keto.writeTuple(grantTuple(name, subject));
|
||||
for (const name of diff.revoke) await keto.deleteTuple(grantTuple(name, subject));
|
||||
// Deletes one held grant of a name no plugin declares and returns the name. Deletes nothing for a
|
||||
// declared name, which the picker owns (so a Remove can't take away this plugin's own gates and needs
|
||||
// no self-lockout guard), or for one not held, as after a double click.
|
||||
export async function removeUndeclaredGrant(keto: KetoClient, subject: GrantSubject, declared: readonly PermissionDecl[], name: string | null): Promise<string | undefined> {
|
||||
if (name === null || declared.some((d) => d.name === name)) return undefined;
|
||||
if (!(await heldPermissions(keto, subject)).includes(name)) return undefined;
|
||||
await keto.deleteTuple(grantTuple(name, subject));
|
||||
return name;
|
||||
}
|
||||
|
||||
// A token for the direct grants a form was rendered from. Like grantDiff it covers only the declared
|
||||
// names the form offered. It is a digest so an empty set is still a token; it is a guard against a
|
||||
// stale page, not a secret, since anyone who can read the page can compute it.
|
||||
export function baselineToken(declared: readonly PermissionDecl[], held: string[]): string {
|
||||
const offered = new Set(declared.map((d) => d.name));
|
||||
return createHash("sha256").update(held.filter((name) => offered.has(name)).sort().join("\n")).digest("hex").slice(0, 16);
|
||||
}
|
||||
|
||||
// True when the form's token is missing or no longer matches Keto *and* the save would change
|
||||
// something. A save that changes nothing, such as a double-clicked Save whose first POST landed, is
|
||||
// harmless, so it is not refused.
|
||||
export const isStale = (form: { get(name: string): string | null }, declared: readonly PermissionDecl[], held: string[], diff: GrantChange): boolean =>
|
||||
(diff.grant.length > 0 || diff.revoke.length > 0) && form.get(BASELINE_FIELD) !== baselineToken(declared, held);
|
||||
|
||||
export interface GrantChange { grant: string[]; revoke: string[] }
|
||||
|
||||
export const applyGrants = (keto: KetoClient, subject: GrantSubject, diff: GrantChange): Promise<void> =>
|
||||
keto.transactTuples({ delete: diff.revoke.map((name) => grantTuple(name, subject)), insert: diff.grant.map((name) => grantTuple(name, subject)) });
|
||||
+9
-9
@@ -1,7 +1,7 @@
|
||||
// Built-in Groups admin screen: the pure view-model + Keto-tuple builders. A group is a
|
||||
// Keto subject set (Group:<name>#members); membership tuples carry users (subject_id) or nested
|
||||
// groups (subject_set). The HTTP routing/gate/CSRF + live Keto/Kratos calls are exercised over
|
||||
// HTTP in app.test.ts.
|
||||
// HTTP in src/http/app-admin.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import {
|
||||
@@ -56,22 +56,22 @@ test("memberView resolves a user subject to its email (else the raw id) and a su
|
||||
test("buildGroupsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
|
||||
const groups = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `team-${String(i).padStart(2, "0")}` }));
|
||||
|
||||
const all = buildGroupsListModel({ groups, url: "http://x/admin/groups" });
|
||||
const all = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups" });
|
||||
assert.equal(all.pagination.summary.total, 30);
|
||||
assert.equal(all.table.rows.length, 25); // default page size
|
||||
assert.equal(all.title, "Groups");
|
||||
// The group name is the row header, linking to its detail page.
|
||||
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
|
||||
assert.equal(first.rowHeader.text, "team-00");
|
||||
assert.equal(first.rowHeader.href, "/admin/groups/team-00");
|
||||
assert.equal(first.rowHeader.href, "/auth-admin/groups/team-00");
|
||||
|
||||
// Search narrows + shows a pill.
|
||||
const one = buildGroupsListModel({ groups, url: "http://x/admin/groups?q=team-07" });
|
||||
const one = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups?q=team-07" });
|
||||
assert.equal(one.pagination.summary.total, 1);
|
||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||
|
||||
// Sort by members descending puts the biggest group first.
|
||||
const desc = buildGroupsListModel({ groups, url: "http://x/admin/groups?sort=-members" });
|
||||
const desc = buildGroupsListModel({ groups, url: "http://x/auth-admin/groups?sort=-members" });
|
||||
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "team-29");
|
||||
});
|
||||
|
||||
@@ -79,7 +79,7 @@ test("buildGroupFormModel: a create form with a required name field + member opt
|
||||
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
|
||||
const m = buildGroupFormModel({ csrfToken: "tok.sig", memberOptions: options });
|
||||
assert.equal(m.title, "New group");
|
||||
assert.equal(m.form.action, "/admin/groups");
|
||||
assert.equal(m.form.action, "/auth-admin/groups");
|
||||
assert.equal(m.form.submitLabel, "Create group");
|
||||
assert.equal(m.form.csrfToken, "tok.sig");
|
||||
assert.equal(m.form.nameField.name, "name");
|
||||
@@ -105,8 +105,8 @@ test("buildGroupDetailModel: members → rows, add-options exclude current membe
|
||||
const m = buildGroupDetailModel({ candidates, group: { name: "eng" }, members });
|
||||
assert.equal(m.title, "eng");
|
||||
assert.equal(m.members.rows.length, 2);
|
||||
assert.equal(m.members.action, "/admin/groups/eng/members/delete");
|
||||
assert.equal(m.add.action, "/admin/groups/eng/members");
|
||||
assert.equal(m.members.action, "/auth-admin/groups/eng/members/delete");
|
||||
assert.equal(m.add.action, "/auth-admin/groups/eng/members");
|
||||
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
|
||||
assert.equal(m.delete.action, "/admin/groups/eng/delete");
|
||||
assert.equal(m.delete.action, "/auth-admin/groups/eng/delete");
|
||||
});
|
||||
+92
-49
@@ -6,8 +6,8 @@
|
||||
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
|
||||
// each returning a RouteResult.
|
||||
|
||||
import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
|
||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts";
|
||||
import { buildPaginationModel, can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
|
||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
||||
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
import type { FieldConfig } from "./admin-users.ts";
|
||||
|
||||
@@ -143,7 +143,7 @@ export function buildGroupsListModel(opts: {
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }],
|
||||
canWrite: opts.canWrite !== false,
|
||||
filterBar: listFilterBar(state, t),
|
||||
pagination: listPagination(state, page, t),
|
||||
pagination: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.groups.pagination"), pageSizes: PAGE_SIZES }),
|
||||
table: listTable(rows, state, sort, t),
|
||||
title: t("admin.groups.title"),
|
||||
};
|
||||
@@ -179,23 +179,6 @@ function listFilterBar(state: ListState, t: Translate) {
|
||||
};
|
||||
}
|
||||
|
||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
|
||||
const hidden: { name: string; value: string }[] = [];
|
||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
||||
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
||||
return {
|
||||
label: t("admin.groups.pagination"),
|
||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
||||
pages: page.pages.map((p) =>
|
||||
p.ellipsis ? { ellipsis: true }
|
||||
: p.current ? { current: true, label: String(p.page) }
|
||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
||||
summary: { from: page.from, to: page.to, total: page.total },
|
||||
};
|
||||
}
|
||||
|
||||
// ---- create form + detail view models ----
|
||||
|
||||
export function buildGroupFormModel(opts: {
|
||||
@@ -293,6 +276,32 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
|
||||
return page.tuples.length > 0;
|
||||
}
|
||||
|
||||
const isMember = (keto: KetoClient, actor: string, group: string): Promise<boolean> =>
|
||||
keto.check({ namespace: GROUP_NS, object: group, relation: MEMBERS, subject_id: `user:${actor}` });
|
||||
|
||||
// The self-lockout guard, the group-side twin of the Users screen's self-revoke one: of the permissions
|
||||
// a change takes from a group the actor is in, those they don't hold directly. One they also hold
|
||||
// through another group is included, since telling those apart needs a reverse Keto query.
|
||||
async function lostToSelf(keto: KetoClient, actor: string, taken: readonly string[]): Promise<string[]> {
|
||||
if (taken.length === 0) return [];
|
||||
const direct = new Set(await heldPermissions(keto, userSubject(actor)));
|
||||
return taken.filter((name) => !direct.has(name));
|
||||
}
|
||||
|
||||
// Leaving a group takes everything it confers, including through its own parents.
|
||||
async function lostOnLeaving({ ctx, keto, user }: GroupsDeps, group: string): Promise<string[]> {
|
||||
return lostToSelf(keto, user.id, await effectivePermissions(keto, groupSubject(group), ctx.declaredPermissions));
|
||||
}
|
||||
|
||||
async function lostOnDelete(deps: GroupsDeps, group: string): Promise<string[]> {
|
||||
return (await isMember(deps.keto, deps.user.id, group)) ? lostOnLeaving(deps, group) : [];
|
||||
}
|
||||
|
||||
async function refuseSelfLockout(deps: GroupsDeps, name: string, action: string, lost: string[]): Promise<RouteResult> {
|
||||
deps.ctx.log.warn("admin: refused a group change that would lock the actor out", { action, actor: deps.user.id, group: name, lost: lost.join(",") });
|
||||
return { ...(await groupDetailPage(deps, name, { pageError: deps.ctx.t("admin.groups.error.selfLockout", { permissions: lost.join(", ") }) })), status: 400 };
|
||||
}
|
||||
|
||||
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate (`groups:read` on
|
||||
// a GET, `groups:write` on a POST) + the Keto and Kratos capabilities (else a themed 503). Each route
|
||||
// below is a thin handler over these.
|
||||
@@ -322,13 +331,13 @@ const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values
|
||||
return { data: { chrome: deps.ctx.chrome, model: buildGroupFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "group-form" };
|
||||
};
|
||||
|
||||
// GET /admin/groups — the list.
|
||||
// GET /auth-admin/groups — the list.
|
||||
export const groupsList = withGroups(async ({ ctx, keto }) => {
|
||||
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
|
||||
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ canWrite: can(ctx, permissionName("groups", "write")), csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" };
|
||||
});
|
||||
|
||||
// POST /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
|
||||
// POST /auth-admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
|
||||
export const groupsCreate = withGroups(async (deps) => {
|
||||
const { ctx, keto, user } = deps;
|
||||
const form = (await guardedForm(ctx))!;
|
||||
@@ -338,22 +347,24 @@ export const groupsCreate = withGroups(async (deps) => {
|
||||
const reject = async (error: string): Promise<RouteResult> => ({ ...(await groupFormResult(deps, { error, values: { member, name } })), status: 400 });
|
||||
if (!isValidGroupName(name)) return reject(ctx.t("admin.groups.validation.name"));
|
||||
if (!tuple) return reject(ctx.t("admin.groups.validation.member"));
|
||||
if (await groupExists(keto, name)) return reject("A group with that name already exists.");
|
||||
if (await groupExists(keto, name)) return reject(ctx.t("admin.groups.error.duplicate"));
|
||||
await keto.writeTuple(tuple);
|
||||
ctx.log.info("admin: group created", { actor: user.id, group: name });
|
||||
return { redirect: detailHref(name) };
|
||||
});
|
||||
|
||||
// GET /admin/groups/new — the create form.
|
||||
// GET /auth-admin/groups/new — the create form.
|
||||
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
|
||||
|
||||
// GET /admin/groups/:name — the detail + membership page.
|
||||
export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => {
|
||||
// The detail + membership page. `notice` and `error` ride the permission picker; `pageError` tops the
|
||||
// page, for any change refused because it would lock the actor out.
|
||||
async function groupDetailPage({ ctx, keto, kratosAdmin }: GroupsDeps, name: string, say: { error?: string; notice?: string; pageError?: string } = {}): Promise<RouteResult> {
|
||||
const { emailById, options } = await memberCandidates(keto, kratosAdmin);
|
||||
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
|
||||
const subject = groupSubject(name);
|
||||
const [direct, effective] = await Promise.all([heldPermissions(keto, subject), effectivePermissions(keto, subject, ctx.declaredPermissions)]);
|
||||
const permissions = buildPermissionPicker({
|
||||
const permissions: PermissionPicker = {
|
||||
...buildPermissionPicker({
|
||||
action: `${detailHref(name)}/permissions`,
|
||||
declared: ctx.declaredPermissions,
|
||||
direct,
|
||||
@@ -361,25 +372,45 @@ export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, nam
|
||||
readOnly: !can(ctx, permissionName("groups", "write")),
|
||||
t: ctx.t,
|
||||
transitive: true, // members inherit, so a change here lands at their next re-mint, not at once
|
||||
});
|
||||
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t }) }, view: "group-detail" };
|
||||
});
|
||||
}),
|
||||
...(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" };
|
||||
}
|
||||
|
||||
// POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
|
||||
// GET /auth-admin/groups/:name — the detail + membership page.
|
||||
export const groupsDetail = withGroupName((deps, name) =>
|
||||
groupDetailPage(deps, name, { notice: grantsNotice(deps.ctx.url, deps.ctx.t) ?? "" }));
|
||||
|
||||
// POST /auth-admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
|
||||
// a group's permissions transitively, so the change reaches them at their next login or re-mint —
|
||||
// the documented instant-revoke tradeoff for anything held through a group.
|
||||
export const groupsPermissions = withGroupName(async ({ ctx, keto, user }, name) => {
|
||||
export const groupsPermissions = withGroupName(async (deps, name) => {
|
||||
const { ctx, keto, user } = deps;
|
||||
const form = (await guardedForm(ctx))!;
|
||||
const subject = groupSubject(name);
|
||||
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
|
||||
const held = await heldPermissions(keto, subject);
|
||||
const diff = grantDiff(ctx.declaredPermissions, held, form.getAll(PERMISSIONS_FIELD));
|
||||
if (isStale(form, ctx.declaredPermissions, held, diff)) return { ...(await groupDetailPage(deps, name, { error: ctx.t("admin.grants.stale") })), status: 409 };
|
||||
const lost = diff.revoke.length > 0 && await isMember(keto, user.id, name) ? await lostToSelf(keto, user.id, diff.revoke) : [];
|
||||
if (lost.length > 0) return refuseSelfLockout(deps, name, "save", lost);
|
||||
await applyGrants(keto, subject, diff);
|
||||
if (diff.grant.length > 0 || diff.revoke.length > 0) {
|
||||
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: diff.grant.join(","), group: name, revoked: diff.revoke.join(",") });
|
||||
}
|
||||
return { redirect: detailHref(name) };
|
||||
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 /auth-admin/groups/:name/permissions/remove — drop one grant no installed plugin declares.
|
||||
export const groupsPermissionsRemove = withGroupName(async ({ ctx, keto, user }, name) => {
|
||||
const form = (await guardedForm(ctx))!;
|
||||
const removed = await removeUndeclaredGrant(keto, groupSubject(name), ctx.declaredPermissions, form.get(REMOVE_FIELD));
|
||||
if (removed) ctx.log.info("admin: undeclared group permission removed", { actor: user.id, group: name, removed });
|
||||
return { redirect: `${detailHref(name)}?info-msg=${REMOVED_NOTICE}` };
|
||||
});
|
||||
|
||||
// POST /auth-admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
|
||||
export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
|
||||
const form = (await guardedForm(ctx))!;
|
||||
const tuple = memberTuple(name, (form.get("member") ?? "").trim());
|
||||
@@ -387,35 +418,47 @@ export const groupsAddMember = withGroupName(async ({ ctx, keto }, name) => {
|
||||
return { redirect: detailHref(name) };
|
||||
});
|
||||
|
||||
// GET /admin/groups/:name/delete — the deliberate confirm step.
|
||||
export const groupsDeleteConfirm = withGroupName((deps, name) => {
|
||||
// GET /auth-admin/groups/:name/delete — the deliberate confirm step.
|
||||
export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
|
||||
const lost = await lostOnDelete(deps, name);
|
||||
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
|
||||
const base = detailHref(name);
|
||||
const tt = deps.ctx.t;
|
||||
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||
return { data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
|
||||
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: tt("admin.groups.title") }, { href: base, label: name }, { label: tt("common.delete") }],
|
||||
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"),
|
||||
message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"),
|
||||
}) }, view: "confirm" });
|
||||
}) }, view: "confirm" };
|
||||
}, "write");
|
||||
|
||||
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
|
||||
export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => {
|
||||
// POST /auth-admin/groups/:name/delete — one Keto transaction removes the group's member tuples
|
||||
// and the grants it holds, so a Keto failure leaves the group as it was.
|
||||
export const groupsDelete = withGroupName(async (deps, name) => {
|
||||
const { ctx, keto, user } = deps;
|
||||
await guardedForm(ctx); // CSRF-verify the POST
|
||||
// Drop what the group *holds* before what it *contains*: a Keto set exists only through its
|
||||
// tuples, so leaving the grants behind would resurrect every permission the moment someone
|
||||
// re-created a group with the same name.
|
||||
const lost = await lostOnDelete(deps, name);
|
||||
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
|
||||
// A Keto set exists only through its tuples, so leaving the grants behind would resurrect every
|
||||
// permission the moment someone re-created a group with the same name.
|
||||
const subject = groupSubject(name);
|
||||
const held = await heldPermissions(keto, subject);
|
||||
for (const permission of held) await keto.deleteTuple(grantTuple(permission, subject));
|
||||
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS });
|
||||
const grants = held.map((permission) => grantTuple(permission, subject));
|
||||
const members = await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS });
|
||||
await keto.transactTuples({ delete: [...grants, ...members], insert: [] });
|
||||
ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
|
||||
return { redirect: ADMIN_GROUPS_BASE };
|
||||
});
|
||||
|
||||
// POST /admin/groups/:name/members/delete — remove one member.
|
||||
export const groupsRemoveMember = withGroupName(async ({ ctx, keto }, name) => {
|
||||
// POST /auth-admin/groups/:name/members/delete — remove one member.
|
||||
export const groupsRemoveMember = withGroupName(async (deps, name) => {
|
||||
const { ctx, keto, user } = deps;
|
||||
const form = (await guardedForm(ctx))!;
|
||||
const tuple = memberTuple(name, (form.get("member") ?? "").trim());
|
||||
if (tuple) await keto.deleteTuple(tuple);
|
||||
if (!tuple) return { redirect: detailHref(name) };
|
||||
// Removing the actor, or a nested group they are in, can take them out of this group.
|
||||
const removesSelf = tuple.subject_set ? await isMember(keto, user.id, tuple.subject_set.object) : tuple.subject_id === `user:${user.id}`;
|
||||
const lost = removesSelf ? await lostOnLeaving(deps, name) : [];
|
||||
if (lost.length > 0) return refuseSelfLockout(deps, name, "remove", lost);
|
||||
await keto.deleteTuple(tuple);
|
||||
return { redirect: detailHref(name) };
|
||||
});
|
||||
+1
-1
@@ -68,7 +68,7 @@ function valueLabel(setting: SettingSummary, t: Translate): string {
|
||||
return setting.value ?? t("admin.pluginSettings.unset");
|
||||
}
|
||||
|
||||
// GET /admin/plugin-settings
|
||||
// GET /auth-admin/plugin-settings
|
||||
export const pluginSettingsList: RouteHandler = (ctx) => {
|
||||
requirePermission(ctx, "plugin-settings");
|
||||
return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" };
|
||||
+5
-5
@@ -1,6 +1,6 @@
|
||||
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
|
||||
// (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the
|
||||
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
|
||||
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app-admin.test.ts.
|
||||
// Import only from the @plainpages/plugin-api barrel — the same contract boundary the plugin code uses.
|
||||
import assert from "node:assert/strict";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
@@ -15,11 +15,11 @@ const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:rea
|
||||
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
|
||||
|
||||
function fakeCtx(opts: { body?: string; method?: string; user?: User | null; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||
const url = new URL("http://localhost/admin/users");
|
||||
const url = new URL("http://localhost/auth-admin/users");
|
||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||
req.method = opts.method ?? "GET";
|
||||
return {
|
||||
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
|
||||
assetHref: (href) => href, chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
|
||||
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
|
||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
};
|
||||
@@ -36,7 +36,7 @@ test("ADMIN_NAV: an ungated Admin header whose screens each gate on their own re
|
||||
assert.equal(ADMIN_NAV.permission, undefined);
|
||||
assert.equal(ADMIN_NAV.href, undefined);
|
||||
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients", "/admin/plugin-settings"]);
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/auth-admin/users", "/auth-admin/groups", "/auth-admin/clients", "/auth-admin/plugin-settings"]);
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read", "plugin-settings:read"]);
|
||||
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes
|
||||
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
|
||||
@@ -64,7 +64,7 @@ test("actionForMethod: read for GET/HEAD, write for every mutation", () => {
|
||||
// ---- auth gates ----
|
||||
|
||||
test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
|
||||
assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
|
||||
assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fauth-admin%2Fusers"); // bounce remembers the page
|
||||
assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
|
||||
assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader);
|
||||
// The whole point of the split: users:read opens the list but not the create/delete POSTs.
|
||||
+7
-5
@@ -9,10 +9,10 @@ import enUS from "./i18n/en-US.ts";
|
||||
// i.e. its unit tests. At runtime the handlers pass ctx.t instead.
|
||||
export const ADMIN_EN: Translate = englishTranslator(enUS);
|
||||
|
||||
export const ADMIN_USERS_BASE = "/admin/users";
|
||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
||||
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||
export const ADMIN_PLUGIN_SETTINGS_BASE = "/admin/plugin-settings";
|
||||
export const ADMIN_USERS_BASE = "/auth-admin/users";
|
||||
export const ADMIN_GROUPS_BASE = "/auth-admin/groups";
|
||||
export const ADMIN_CLIENTS_BASE = "/auth-admin/clients";
|
||||
export const ADMIN_PLUGIN_SETTINGS_BASE = "/auth-admin/plugin-settings";
|
||||
|
||||
// One resource per screen — the `<resource>` half of every permission this plugin gates on.
|
||||
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
|
||||
@@ -29,7 +29,9 @@ export function permissionName(resource: AdminResource, action: AdminAction): st
|
||||
|
||||
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both
|
||||
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately
|
||||
// local: generalised, it would make authorization a function of the transport verb (AGENTS.md).
|
||||
// local: generalised, it would make authorization a function of the transport verb
|
||||
// (docs/decisions.md → "`actionForMethod` is plugin-local and stays out of
|
||||
// `@plainpages/plugin-api`").
|
||||
export function actionForMethod(method: string): AdminAction {
|
||||
const verb = method.toUpperCase();
|
||||
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
||||
+29
-8
@@ -1,5 +1,5 @@
|
||||
// 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.test.ts.
|
||||
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app-admin.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { Identity } from "@plainpages/plugin-api";
|
||||
@@ -38,34 +38,42 @@ test("toUserView maps traits → name/initials/email/state; falls back to the em
|
||||
test("buildUsersListModel filters by search + status, sorts, and paginates", () => {
|
||||
const people = Array.from({ length: 30 }, (_, i) => identity(i + 1, { state: i % 2 ? "inactive" : "active" }));
|
||||
|
||||
const all = buildUsersListModel({ identities: people, url: "http://x/admin/users" });
|
||||
const all = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users" });
|
||||
assert.equal(all.pagination.summary.total, 30);
|
||||
assert.equal(all.table.rows.length, 25); // default page size
|
||||
assert.equal(all.title, "Users");
|
||||
|
||||
// Search narrows to one and shows a pill.
|
||||
const one = buildUsersListModel({ identities: people, url: "http://x/admin/users?q=user7%40example.com" });
|
||||
const one = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?q=user7%40example.com" });
|
||||
assert.equal(one.pagination.summary.total, 1);
|
||||
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
|
||||
|
||||
// Status filter keeps only inactive identities.
|
||||
const inactive = buildUsersListModel({ identities: people, url: "http://x/admin/users?status=inactive" });
|
||||
const inactive = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?status=inactive" });
|
||||
assert.equal(inactive.pagination.summary.total, 15);
|
||||
assert.deepEqual(inactive.filterBar.pills.map((p) => p.label), ["Status"]);
|
||||
|
||||
// Sort by email descending reverses the order.
|
||||
const emailOf = (m: ReturnType<typeof buildUsersListModel>, i: number) => (m.table.rows[i]!.cells[1]) as string;
|
||||
const desc = buildUsersListModel({ identities: people, url: "http://x/admin/users?sort=-email" });
|
||||
const desc = buildUsersListModel({ identities: people, url: "http://x/auth-admin/users?sort=-email" });
|
||||
assert.ok(emailOf(desc, 0) > emailOf(desc, 1));
|
||||
|
||||
// Edit link points at the per-identity route.
|
||||
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/admin/users/${people[0]!.id}$`));
|
||||
assert.match(all.table.rows[0]!.actions![0]!.href!, new RegExp(`/auth-admin/users/${people[0]!.id}$`));
|
||||
assert.deepEqual([all.table.rows[0]!.actions![0]!.icon, all.table.rows[0]!.actions![0]!.label], ["i-edit", "Edit"]);
|
||||
});
|
||||
|
||||
// The detail page of a `users:read` holder is read-only, so the row action must not promise an edit.
|
||||
test("buildUsersListModel: a read-only holder's row action says View, with the same target", () => {
|
||||
const people: Identity[] = [{ id: "u1", traits: { email: "a@x.io" } }];
|
||||
const action = buildUsersListModel({ canWrite: false, identities: people, url: "http://x/auth-admin/users" }).table.rows[0]!.actions![0]!;
|
||||
assert.deepEqual([action.icon, action.label, action.href], ["i-eye", "View", "/auth-admin/users/u1"]);
|
||||
});
|
||||
|
||||
test("buildUserFormModel: create mode has an editable email + password, no edit actions", () => {
|
||||
const m = buildUserFormModel({ csrfToken: "tok.sig" });
|
||||
assert.equal(m.title, "New user");
|
||||
assert.equal(m.form.action, "/admin/users");
|
||||
assert.equal(m.form.action, "/auth-admin/users");
|
||||
assert.equal(m.form.submitLabel, "Create user");
|
||||
assert.equal(m.form.csrfToken, "tok.sig");
|
||||
const names = m.form.fields.map((f) => f.name);
|
||||
@@ -77,7 +85,7 @@ test("buildUserFormModel: create mode has an editable email + password, no edit
|
||||
test("buildUserFormModel: edit mode prefills, locks email, and exposes state/delete/recovery actions", () => {
|
||||
const m = buildUserFormModel({ identity: identity(3) });
|
||||
assert.equal(m.title, "Edit user");
|
||||
assert.equal(m.form.action, `/admin/users/${id(3)}`);
|
||||
assert.equal(m.form.action, `/auth-admin/users/${id(3)}`);
|
||||
assert.equal(m.form.submitLabel, "Save changes");
|
||||
const email = m.form.fields.find((f) => f.name === "email")!;
|
||||
assert.equal(email.value, "user3@example.com");
|
||||
@@ -92,6 +100,19 @@ test("buildUserFormModel: edit mode prefills, locks email, and exposes state/del
|
||||
assert.equal(buildUserFormModel({ identity: identity(4, { state: "inactive" }) }).edit!.nextLabel, "Reactivate");
|
||||
});
|
||||
|
||||
test("buildUserFormModel: the title and breadcrumb say New, Edit, or View for a users:read holder", () => {
|
||||
const cases: [Parameters<typeof buildUserFormModel>[0], string, string][] = [
|
||||
[{}, "New user", "New"],
|
||||
[{ identity: identity(3) }, "Edit user", "Edit"],
|
||||
[{ canWrite: false, identity: identity(3) }, "View user", "View"],
|
||||
];
|
||||
for (const [opts, title, crumb] of cases) {
|
||||
const m = buildUserFormModel(opts);
|
||||
assert.equal(m.title, title);
|
||||
assert.deepEqual(m.breadcrumbs, [{ href: "/auth-admin/users", label: "Users" }, { label: crumb }]);
|
||||
}
|
||||
});
|
||||
|
||||
test("createIdentityPayload: schema/state/traits, name only when given, password only when set", () => {
|
||||
assert.deepEqual(createIdentityPayload({ email: "a@b.c", first: "Ada", last: "Lovelace", password: "" }), {
|
||||
schema_id: "default",
|
||||
+58
-50
@@ -3,8 +3,8 @@
|
||||
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
|
||||
// a shared `withUser` gate.
|
||||
|
||||
import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
|
||||
import { buildPaginationModel, can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
|
||||
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
|
||||
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
|
||||
@@ -137,13 +137,13 @@ export function buildUsersListModel(opts: {
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }],
|
||||
canWrite: opts.canWrite !== false,
|
||||
filterBar: listFilterBar(state, all.length, t),
|
||||
pagination: listPagination(state, page, t),
|
||||
table: listTable(rows, state, sort, t),
|
||||
pagination: buildPaginationModel(page, (n) => listHref(state, { page: n }), { label: t("admin.users.pagination"), pageSizes: PAGE_SIZES }),
|
||||
table: listTable(rows, state, sort, t, opts.canWrite !== false),
|
||||
title: t("admin.users.title"),
|
||||
};
|
||||
}
|
||||
|
||||
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
|
||||
function listTable(rows: UserView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate, canWrite: boolean) {
|
||||
return {
|
||||
actions: true,
|
||||
caption: t("admin.users.title"),
|
||||
@@ -153,7 +153,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 };
|
||||
}),
|
||||
rows: rows.map((u) => ({
|
||||
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: "i-edit", label: t("common.edit") }],
|
||||
actions: [{ href: `${ADMIN_USERS_BASE}/${encodeURIComponent(u.id)}`, icon: canWrite ? "i-edit" : "i-eye", label: t(canWrite ? "common.edit" : "admin.users.view") }],
|
||||
cells: [
|
||||
{ user: { initials: u.initials, name: u.name } },
|
||||
u.email,
|
||||
@@ -185,24 +185,6 @@ function listFilterBar(state: ListState, total: number, t: Translate) {
|
||||
};
|
||||
}
|
||||
|
||||
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
|
||||
const hidden: { name: string; value: string }[] = [];
|
||||
if (state.q) hidden.push({ name: "q", value: state.q });
|
||||
if (state.status !== "all") hidden.push({ name: "status", value: state.status });
|
||||
if (state.sort) hidden.push({ name: "sort", value: state.sort });
|
||||
return {
|
||||
label: t("admin.users.pagination"),
|
||||
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
|
||||
pages: page.pages.map((p) =>
|
||||
p.ellipsis ? { ellipsis: true }
|
||||
: p.current ? { current: true, label: String(p.page) }
|
||||
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
|
||||
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
|
||||
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
|
||||
summary: { from: page.from, to: page.to, total: page.total },
|
||||
};
|
||||
}
|
||||
|
||||
export interface FieldConfig {
|
||||
autocomplete?: string;
|
||||
hint?: string;
|
||||
@@ -243,8 +225,9 @@ 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" });
|
||||
|
||||
const canWrite = opts.canWrite !== false;
|
||||
const mode = !editing ? "new" : canWrite ? "edit" : "view";
|
||||
return {
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: editing ? t("common.edit") : t("common.new") }],
|
||||
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: t({ edit: "common.edit", new: "common.new", view: "admin.users.view" }[mode]) }],
|
||||
canWrite, // the view drops every write affordance when false; the host already 403s the POSTs
|
||||
edit: editing ? {
|
||||
deleteAction: `${idPath}/delete`,
|
||||
@@ -258,7 +241,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") },
|
||||
permissions: editing ? opts.permissions : undefined,
|
||||
recovery: opts.recovery,
|
||||
title: editing ? t("admin.users.edit") : t("admin.users.new"),
|
||||
title: t({ edit: "admin.users.edit", new: "admin.users.new", view: "admin.users.viewTitle" }[mode]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -305,13 +288,13 @@ function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) =>
|
||||
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult =>
|
||||
({ data: { chrome: ctx.chrome, model: buildUserFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "user-form" });
|
||||
|
||||
// GET /admin/users — the filtered/sorted/paged list.
|
||||
// GET /auth-admin/users — the filtered/sorted/paged list.
|
||||
export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
|
||||
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
|
||||
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ canWrite: canWriteUsers(ctx), csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" };
|
||||
});
|
||||
|
||||
// POST /admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
|
||||
// POST /auth-admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
|
||||
export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
|
||||
const input = readUserInput((await guardedForm(ctx))!);
|
||||
try {
|
||||
@@ -324,12 +307,12 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
|
||||
return { redirect: ADMIN_USERS_BASE };
|
||||
});
|
||||
|
||||
// GET /admin/users/new — the empty create form.
|
||||
// GET /auth-admin/users/new — the empty create form.
|
||||
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})), "write");
|
||||
|
||||
// GET /admin/users/:id — the edit form, prefilled.
|
||||
// GET /auth-admin/users/:id — the edit form, prefilled.
|
||||
export const usersEditForm = withTarget(async (deps, identity, id) => {
|
||||
const permissions = await userPermissionPicker(deps, id);
|
||||
const permissions = await userPermissionPicker(deps, id, undefined, grantsNotice(deps.ctx.url, deps.ctx.t));
|
||||
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
|
||||
});
|
||||
|
||||
@@ -338,7 +321,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 grant comes from a group (real, but removed on that group). Undefined when Keto isn't wired —
|
||||
// the rest of the edit page still works.
|
||||
async function userPermissionPicker(deps: UsersDeps, id: string, error?: string): Promise<PermissionPicker | undefined> {
|
||||
async function userPermissionPicker(deps: UsersDeps, id: string, error?: string, notice?: string): Promise<PermissionPicker | undefined> {
|
||||
if (!deps.keto) return undefined;
|
||||
const subject = userSubject(id);
|
||||
const [direct, effective] = await Promise.all([
|
||||
@@ -355,10 +338,11 @@ async function userPermissionPicker(deps: UsersDeps, id: string, error?: string)
|
||||
t: deps.ctx.t,
|
||||
}),
|
||||
...(error ? { error } : {}),
|
||||
...(notice ? { notice } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// POST /admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct*
|
||||
// POST /auth-admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct*
|
||||
// grants; grant what's newly ticked, revoke what's newly unticked. A change to a user's own grants
|
||||
// revokes their live tokens so it lands now rather than at the next re-mint.
|
||||
export const usersPermissions = withTarget(async (deps, identity, id) => {
|
||||
@@ -366,42 +350,59 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
|
||||
const form = (await guardedForm(ctx))!;
|
||||
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
|
||||
const subject = userSubject(id);
|
||||
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
|
||||
const held = await heldPermissions(keto, subject);
|
||||
const refuse = async (error: string, status: number): Promise<RouteResult> => {
|
||||
const permissions = await userPermissionPicker(deps, id, error);
|
||||
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status };
|
||||
};
|
||||
const diff = grantDiff(ctx.declaredPermissions, held, form.getAll(PERMISSIONS_FIELD));
|
||||
if (isStale(form, ctx.declaredPermissions, held, diff)) return refuse(ctx.t("admin.grants.stale"), 409);
|
||||
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
|
||||
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
|
||||
// next request — leaving a `curl` against Keto as the only way back in.
|
||||
if (id === user.id && diff.revoke.length > 0) {
|
||||
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
|
||||
const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke"));
|
||||
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status: 400 };
|
||||
return refuse(ctx.t("admin.grants.selfRevoke"), 400);
|
||||
}
|
||||
await applyGrants(keto, subject, diff);
|
||||
if (diff.grant.length > 0 || diff.revoke.length > 0) {
|
||||
revoke?.(id);
|
||||
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: diff.grant.join(","), revoked: diff.revoke.join(","), target: id });
|
||||
}
|
||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
||||
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 /auth-admin/users/:id/permissions/remove — drop one direct grant no installed plugin declares.
|
||||
export const usersPermissionsRemove = withTarget(async ({ ctx, keto, revoke, user }, _identity, id) => {
|
||||
const form = (await guardedForm(ctx))!;
|
||||
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
|
||||
const removed = await removeUndeclaredGrant(keto, userSubject(id), ctx.declaredPermissions, form.get(REMOVE_FIELD));
|
||||
if (removed) {
|
||||
revoke?.(id);
|
||||
ctx.log.info("admin: undeclared user permission removed", { actor: user.id, removed, target: id });
|
||||
}
|
||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}?info-msg=${REMOVED_NOTICE}` };
|
||||
});
|
||||
|
||||
// POST /auth-admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
|
||||
export const usersUpdate = withTarget(async (deps, identity, id) => {
|
||||
const { ctx, kratosAdmin } = deps;
|
||||
const input = readUserInput((await guardedForm(ctx))!);
|
||||
try {
|
||||
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
|
||||
} catch (err) {
|
||||
// Re-render with the picker, or the permissions section vanishes off the page on a failed save.
|
||||
if (err instanceof KratosError) return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), error: ctx.t("admin.users.error.save"), identity, ...(await pickerOrNothing(deps, id)) }), status: 400 };
|
||||
if (err instanceof KratosError) return refuseForm(deps, identity, id, ctx.t("admin.users.error.save"));
|
||||
throw err;
|
||||
}
|
||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
||||
});
|
||||
|
||||
// POST /admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
|
||||
// POST /auth-admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
|
||||
// tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account.
|
||||
export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
|
||||
export const usersState = withTarget(async (deps, identity, id) => {
|
||||
const { ctx, kratosAdmin, revoke, user } = deps;
|
||||
await guardedForm(ctx); // CSRF-verify the POST (no fields read)
|
||||
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDeactivate"), identity }), status: 400 };
|
||||
if (id === user.id) return refuseForm(deps, identity, id, ctx.t("admin.users.error.selfDeactivate"));
|
||||
const nextState = identity.state === "inactive" ? "active" : "inactive";
|
||||
await kratosAdmin.updateIdentity(id, setStatePayload(identity, nextState));
|
||||
if (nextState === "inactive") revoke?.(id);
|
||||
@@ -409,9 +410,9 @@ export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user },
|
||||
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
|
||||
});
|
||||
|
||||
// GET /admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
|
||||
// GET /auth-admin/users/:id/delete — the deliberate confirm step (zero-JS). Refuses self-delete.
|
||||
export const usersDeleteConfirm = withTarget((deps, identity, id) => {
|
||||
if (id === deps.user.id) return Promise.resolve({ ...formResult(deps.ctx, { error: deps.ctx.t("admin.users.error.selfDelete"), identity }), status: 400 });
|
||||
if (id === deps.user.id) return refuseForm(deps, identity, id, deps.ctx.t("admin.users.error.selfDelete"));
|
||||
const back = `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}`;
|
||||
const view = toUserView(identity);
|
||||
const tt = deps.ctx.t;
|
||||
@@ -422,17 +423,18 @@ export const usersDeleteConfirm = withTarget((deps, identity, id) => {
|
||||
}) }, view: "confirm" });
|
||||
}, "write");
|
||||
|
||||
// POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
|
||||
export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
|
||||
// POST /auth-admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
|
||||
export const usersDelete = withTarget(async (deps, identity, id) => {
|
||||
const { ctx, kratosAdmin, revoke, user } = deps;
|
||||
await guardedForm(ctx); // CSRF-verify the POST
|
||||
if (id === user.id) return { ...formResult(ctx, { error: ctx.t("admin.users.error.selfDelete"), identity }), status: 400 };
|
||||
if (id === user.id) return refuseForm(deps, identity, id, ctx.t("admin.users.error.selfDelete"));
|
||||
await kratosAdmin.deleteIdentity(id);
|
||||
revoke?.(id);
|
||||
ctx.log.info("admin: user deleted", { actor: user.id, target: id });
|
||||
return { redirect: ADMIN_USERS_BASE };
|
||||
});
|
||||
|
||||
// POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
|
||||
// POST /auth-admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
|
||||
export const usersRecovery = withTarget(async (deps, identity, id) => {
|
||||
const { ctx, kratosAdmin } = deps;
|
||||
await guardedForm(ctx); // CSRF-verify the POST
|
||||
@@ -440,7 +442,13 @@ export const usersRecovery = withTarget(async (deps, identity, id) => {
|
||||
return formResult(ctx, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) });
|
||||
});
|
||||
|
||||
// The picker as a spreadable fragment, so a re-render never silently drops the section.
|
||||
// The 400 re-render of the user's form with `error` above it. `usersPermissions` refuses through its
|
||||
// own `refuse`, which puts the error in the permissions section instead.
|
||||
async function refuseForm(deps: UsersDeps, identity: Identity, id: string, error: string): Promise<RouteResult> {
|
||||
return { ...formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), error, identity, ...(await pickerOrNothing(deps, id)) }), status: 400 };
|
||||
}
|
||||
|
||||
// The picker as a spreadable fragment, so every form re-render keeps the section.
|
||||
async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> {
|
||||
const permissions = await userPermissionPicker(deps, id);
|
||||
return permissions ? { permissions } : {};
|
||||
@@ -54,8 +54,14 @@ const messages = {
|
||||
"admin.grants.legend": "Permissions",
|
||||
"admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.",
|
||||
"admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).",
|
||||
"admin.grants.remove": "Remove",
|
||||
"admin.grants.removed": "Permission removed.",
|
||||
"admin.grants.save": "Save permissions",
|
||||
"admin.grants.saved": "Permissions saved.",
|
||||
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.",
|
||||
"admin.grants.stale": "These permissions changed while you had the page open, so nothing was saved. The list now shows the current state. Review it and save again.",
|
||||
"admin.grants.undeclaredHint": "These permissions are held here, but no installed plugin declares them, so the list above can't change them. A plugin may still check one without declaring it.",
|
||||
"admin.grants.undeclaredLegend": "Permissions no installed plugin declares",
|
||||
|
||||
"admin.groups.actions": "Group actions",
|
||||
"admin.groups.addMember": "Add a member",
|
||||
@@ -65,6 +71,8 @@ const messages = {
|
||||
"admin.groups.create": "Create group",
|
||||
"admin.groups.delete": "Delete group",
|
||||
"admin.groups.deleteMessage": "Delete group {{name}}? This can't be undone.",
|
||||
"admin.groups.error.duplicate": "A group with that name already exists.",
|
||||
"admin.groups.error.selfLockout": "This change would lock you out of {{permissions}}, which you hold through this group. Ask another administrator to make it.",
|
||||
"admin.groups.field.name": "Group name",
|
||||
"admin.groups.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
|
||||
"admin.groups.filter": "Filter groups",
|
||||
@@ -137,8 +145,8 @@ const messages = {
|
||||
"admin.users.new": "New user",
|
||||
"admin.users.pagination": "Users pagination",
|
||||
"admin.users.reactivate": "Reactivate",
|
||||
"admin.users.recovery.body": "Give it to the user — they enter it to set a new password (generate a fresh one if it has expired):",
|
||||
"admin.users.recovery.link": "the password-reset screen",
|
||||
"admin.users.recovery.body": "Send the user this link and code. They open the link and enter the code to set a new password (generate a fresh code if it has expired):",
|
||||
"admin.users.recovery.link": "password-reset link",
|
||||
"admin.users.recovery.generate": "Generate recovery code",
|
||||
"admin.users.recovery.title": "Recovery code generated",
|
||||
"admin.users.save": "Save changes",
|
||||
@@ -149,6 +157,8 @@ const messages = {
|
||||
"admin.users.status.inactive": "Inactive",
|
||||
"admin.users.status.label": "Status",
|
||||
"admin.users.title": "Users",
|
||||
"admin.users.view": "View",
|
||||
"admin.users.viewTitle": "View user",
|
||||
};
|
||||
|
||||
export type AdminMessages = typeof messages;
|
||||
@@ -54,8 +54,14 @@ const messages: AdminMessages = {
|
||||
"admin.grants.legend": "Behörigheter",
|
||||
"admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.",
|
||||
"admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).",
|
||||
"admin.grants.remove": "Ta bort",
|
||||
"admin.grants.removed": "Behörigheten är borttagen.",
|
||||
"admin.grants.save": "Spara behörigheter",
|
||||
"admin.grants.saved": "Behörigheterna är sparade.",
|
||||
"admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.",
|
||||
"admin.grants.stale": "Behörigheterna ändrades medan du hade sidan öppen, så ingenting sparades. Listan visar nu aktuellt läge. Granska den och spara igen.",
|
||||
"admin.grants.undeclaredHint": "Behörigheterna innehas här, men ingen installerad plugin deklarerar dem, så listan ovan kan inte ändra dem. En plugin kan ändå kontrollera någon av dem utan att deklarera den.",
|
||||
"admin.grants.undeclaredLegend": "Behörigheter som ingen installerad plugin deklarerar",
|
||||
|
||||
"admin.groups.actions": "Gruppåtgärder",
|
||||
"admin.groups.addMember": "Lägg till en medlem",
|
||||
@@ -65,6 +71,8 @@ const messages: AdminMessages = {
|
||||
"admin.groups.create": "Skapa grupp",
|
||||
"admin.groups.delete": "Radera grupp",
|
||||
"admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det går inte att ångra.",
|
||||
"admin.groups.error.duplicate": "Det finns redan en grupp med det namnet.",
|
||||
"admin.groups.error.selfLockout": "Ändringen skulle låsa ute dig från {{permissions}}, som du har genom den här gruppen. Be en annan administratör att göra den.",
|
||||
"admin.groups.field.name": "Gruppnamn",
|
||||
"admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
|
||||
"admin.groups.filter": "Filtrera grupper",
|
||||
@@ -137,8 +145,8 @@ const messages: AdminMessages = {
|
||||
"admin.users.new": "Ny användare",
|
||||
"admin.users.pagination": "Sidnavigering för användare",
|
||||
"admin.users.reactivate": "Aktivera igen",
|
||||
"admin.users.recovery.body": "Ge den till användaren — koden anges för att sätta ett nytt lösenord (skapa en ny om den hunnit gå ut):",
|
||||
"admin.users.recovery.link": "sidan för lösenordsåterställning",
|
||||
"admin.users.recovery.body": "Skicka länken och koden till användaren. Användaren öppnar länken och anger koden för att sätta ett nytt lösenord (skapa en ny kod om den hunnit gå ut):",
|
||||
"admin.users.recovery.link": "länk för lösenordsåterställning",
|
||||
"admin.users.recovery.generate": "Skapa återställningskod",
|
||||
"admin.users.recovery.title": "Återställningskod skapad",
|
||||
"admin.users.save": "Spara ändringar",
|
||||
@@ -149,6 +157,8 @@ const messages: AdminMessages = {
|
||||
"admin.users.status.inactive": "Inaktiv",
|
||||
"admin.users.status.label": "Status",
|
||||
"admin.users.title": "Användare",
|
||||
"admin.users.view": "Visa",
|
||||
"admin.users.viewTitle": "Visa användare",
|
||||
};
|
||||
|
||||
export default messages;
|
||||
@@ -1,14 +1,14 @@
|
||||
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
|
||||
// this folder to plugins/admin (then restart) to enable it — see README → Quick start.
|
||||
// this folder to plugins/auth-admin (then restart) to enable it — see README → Quick start.
|
||||
//
|
||||
// It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke
|
||||
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
|
||||
|
||||
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api";
|
||||
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
||||
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
|
||||
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsPermissionsRemove, groupsRemoveMember } from "./admin-groups.ts";
|
||||
import { pluginSettingsList } from "./admin-plugin-settings.ts";
|
||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersPermissionsRemove, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||
import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
|
||||
|
||||
// One route factory per screen: a GET gates on `<resource>:read` and a POST on `<resource>:write`,
|
||||
@@ -28,7 +28,7 @@ const clients = on("oauth2-clients");
|
||||
const pluginSettings = on("plugin-settings");
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
apiVersion: "0.5.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
|
||||
nav: [ADMIN_NAV],
|
||||
|
||||
@@ -54,6 +54,7 @@ export default definePlugin({
|
||||
users("POST", "/users/:id/delete", usersDelete),
|
||||
users("POST", "/users/:id/recovery", usersRecovery),
|
||||
users("POST", "/users/:id/permissions", usersPermissions),
|
||||
users("POST", "/users/:id/permissions/remove", usersPermissionsRemove),
|
||||
// Groups
|
||||
groups("GET", "/groups", groupsList),
|
||||
groups("POST", "/groups", groupsCreate),
|
||||
@@ -64,6 +65,7 @@ export default definePlugin({
|
||||
groups("POST", "/groups/:name/delete", groupsDelete),
|
||||
groups("POST", "/groups/:name/members/delete", groupsRemoveMember),
|
||||
groups("POST", "/groups/:name/permissions", groupsPermissions),
|
||||
groups("POST", "/groups/:name/permissions/remove", groupsPermissionsRemove),
|
||||
// OAuth2 clients
|
||||
clients("GET", "/clients", clientsList),
|
||||
clients("POST", "/clients", clientsCreate),
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
const table = include("partials/data-table", model.table);
|
||||
const pager = include("partials/pagination", model.pagination);
|
||||
// Only offer "Register client" to an oauth2-clients:write holder — a :read one would get the 403 page.
|
||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>';
|
||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions,
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
const table = include("partials/data-table", model.table);
|
||||
const pager = include("partials/pagination", model.pagination);
|
||||
// Only offer "New group" to a groups:write holder — a groups:read one would get the 403 page.
|
||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>';
|
||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions,
|
||||
+28
-2
@@ -7,13 +7,17 @@
|
||||
there) and every row when `readOnly` (the viewer holds :read but not :write). Neither can be diffed
|
||||
into an accidental revoke, because grantDiff compares against the direct grants only.
|
||||
|
||||
Locals: csrfToken, permissions ({ action, choices, empty, error, field, hint, inheritedNote, legend, pending, readOnly, submit }).
|
||||
Locals: csrfToken, permissions ({ action, baseline, choices, empty, error, field, hint, inheritedNote, legend, notice, pending, readOnly, submit, undeclared }).
|
||||
|
||||
%>
|
||||
<section class="form-card" aria-labelledby="permissions-h">
|
||||
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
|
||||
<% if (permissions.error) { -%>
|
||||
<%- include("partials/alert", { text: permissions.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<% if (permissions.notice) { -%>
|
||||
<%- include("partials/alert", { text: permissions.notice, tone: "pos" }) %>
|
||||
<% } -%>
|
||||
<% if (permissions.empty) { -%>
|
||||
<p class="cell-muted"><%= permissions.empty %></p>
|
||||
<% } else { -%>
|
||||
@@ -26,8 +30,10 @@
|
||||
<% }) -%>
|
||||
</fieldset>
|
||||
<% } else { -%>
|
||||
<form method="post" action="<%= localeHref(permissions.action) %>">
|
||||
<%# The fragment brings the card, and the notice in it, into view on a long page. %>
|
||||
<form method="post" action="<%= localeHref(permissions.action + '#permissions-h') %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<input type="hidden" name="baseline" value="<%= permissions.baseline %>">
|
||||
<fieldset class="check-group">
|
||||
<legend class="sr-only"><%= permissions.legend %></legend>
|
||||
<% permissions.choices.forEach((c) => { -%>
|
||||
@@ -45,5 +51,25 @@
|
||||
<% if (permissions.pending) { -%>
|
||||
<p class="cell-muted"><%= permissions.pending %></p>
|
||||
<% } -%>
|
||||
<% } -%>
|
||||
<% if (permissions.undeclared) { const u = permissions.undeclared; -%>
|
||||
<h3 class="card-title" id="undeclared-h"><%= u.legend %></h3>
|
||||
<p class="cell-muted"><%= u.hint %></p>
|
||||
<ul class="plain-list" aria-labelledby="undeclared-h">
|
||||
<% u.names.forEach((name) => { -%>
|
||||
<li>
|
||||
<% if (u.remove) { -%>
|
||||
<form method="post" action="<%= localeHref(u.action + '#permissions-h') %>">
|
||||
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
|
||||
<input type="hidden" name="<%= u.field %>" value="<%= name %>">
|
||||
<span class="cell-muted"><%= name %></span>
|
||||
<button class="btn btn-danger" type="submit"><%= u.remove %><span class="sr-only"> <%= name %></span></button>
|
||||
</form>
|
||||
<% } else { -%>
|
||||
<span class="cell-muted"><%= name %></span>
|
||||
<% } -%>
|
||||
</li>
|
||||
<% }) -%>
|
||||
</ul>
|
||||
<% } -%>
|
||||
</section>
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
Admin user create/edit form body, captured into the shell content slot. Config:
|
||||
form { action, csrfToken, submitLabel, cancelHref, fields: field.ejs config[] }
|
||||
edit? { nextLabel, stateAction, recoveryAction, deleteAction } (edit mode only)
|
||||
recovery? { code? } shown after a recovery code is generated (recovery is code-based)
|
||||
recovery? { code, link } shown after a recovery code is generated; link is the URL of the flow the code was minted on, the only flow that accepts the code
|
||||
error? string shown when a write was rejected
|
||||
%><%
|
||||
const form = locals.form;
|
||||
@@ -14,7 +14,7 @@
|
||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<% if (recovery) { -%>
|
||||
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong><%= t("admin.users.recovery.title") %></strong><span><%= t("admin.users.recovery.body") %> <a href="<%= localeHref("/recovery") %>"><%= t("admin.users.recovery.link") %></a></span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
|
||||
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong><%= t("admin.users.recovery.title") %></strong><span><%= t("admin.users.recovery.body") %> <a href="<%= recovery.link %>"><%= t("admin.users.recovery.link") %></a></span><span class="recovery-code"><code><%= recovery.code %></code></span></div></div>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
const table = include("partials/data-table", model.table);
|
||||
const pager = include("partials/pagination", model.pagination);
|
||||
// Only offer "New user" to a users:write holder — a users:read one would get the 403 page.
|
||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>';
|
||||
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/auth-admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions,
|
||||
@@ -10,7 +10,8 @@ What it demonstrates:
|
||||
service and renders the rows with the core building blocks (`shifts.ejs` → app shell, filter-bar,
|
||||
data-table). Search round-trips the URL; zero-JS. (It fetches **all** rows for brevity — for a
|
||||
large list, parse `page`/`pageSize` from `parseListQuery`, forward them upstream as a `?limit`/
|
||||
`?offset`, and render `pagination.ejs` with `paginate()`, exactly as the admin example plugin does.)
|
||||
`?offset`, and render `pagination.ejs` with `paginate()` and `buildPaginationModel()`, as the admin example plugin
|
||||
does.)
|
||||
- **A form that forwards a write upstream** — `GET /scheduling/shifts/new` renders the form,
|
||||
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
|
||||
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
|
||||
|
||||
@@ -12,7 +12,7 @@ let upstreamUrl = "";
|
||||
const upstream = createUpstream(() => upstreamUrl);
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
apiVersion: "0.5.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
|
||||
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
|
||||
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
|
||||
@@ -40,6 +40,9 @@ export default definePlugin({
|
||||
{ description: "Create and edit shifts", name: WRITE },
|
||||
],
|
||||
|
||||
// Also offered in the profile dropdown above Sign out, since the page is the visitor's own.
|
||||
profileMenu: [{ href: MINE_PATH, icon: "i-cal", id: "scheduling:profile-mine", label: "scheduling.nav.mine", session: true }],
|
||||
|
||||
routes: [
|
||||
{ handler: overview(), method: "GET", path: "/", public: true },
|
||||
{ handler: myShifts(upstream), method: "GET", path: "/mine", session: true },
|
||||
|
||||
@@ -4,3 +4,9 @@
|
||||
.scheduling-page .table-wrap {
|
||||
margin-top: var(--space-3, 0.75rem);
|
||||
}
|
||||
/* Inset what sits directly in the pane by --pad-x, as the filter bar, table and pager already are. */
|
||||
.scheduling-page > p,
|
||||
.scheduling-page > .alert,
|
||||
.scheduling-page > .btn {
|
||||
margin-inline: var(--pad-x);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; us
|
||||
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||
return {
|
||||
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
||||
assetHref: (href) => href, chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
||||
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
|
||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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
|
||||
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
|
||||
alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL.
|
||||
alert) — see the README. Zero-JS: search round-trips the URL.
|
||||
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
|
||||
%><%
|
||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
|
||||
Generated
+38
-16
@@ -6,14 +6,16 @@
|
||||
"": {
|
||||
"name": "plainpages",
|
||||
"dependencies": {
|
||||
"@larvit/log": "2.3.0",
|
||||
"ejs": "6.0.1",
|
||||
"lucide-static": "1.52.0",
|
||||
"@larvit/log": "2.4.0",
|
||||
"ejs": "7.0.2",
|
||||
"lucide-static": "1.70.0",
|
||||
"postgres": "3.4.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdn/browser-compat-data": "8.1.8",
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/node": "24.19.1",
|
||||
"@types/node": "24.19.2",
|
||||
"acorn": "8.19.0",
|
||||
"typescript": "7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -21,14 +23,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@larvit/log": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@larvit/log/-/log-2.3.0.tgz",
|
||||
"integrity": "sha512-gLRMDWrFnoMGdAr9NXMXRKOUeceU6Qd/CW0c6qDO+DLFT1xWrqO2ubsHyPqPzlZHVUO3w3Bk7kOWo6C2Ykn4ZQ==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@larvit/log/-/log-2.4.0.tgz",
|
||||
"integrity": "sha512-0BA1S0eSzQMOP527Vy2DL9Zb68TiCgyDACr0gDiI2b/1zfY6Axq9XhR/ZJnF9URIAhQwFPYs3W6lh/T9K8vqgQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@mdn/browser-compat-data": {
|
||||
"version": "8.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@mdn/browser-compat-data/-/browser-compat-data-8.1.8.tgz",
|
||||
"integrity": "sha512-+RK7inkLeZ70kr9Mo+Y81JjZMpQIt0Sx40tHHU0hXGDSdf9YvlawH+4WlUpP3SE8rFLmjqGiTzL+qPPPnncWDw==",
|
||||
"dev": true,
|
||||
"license": "CC0-1.0"
|
||||
},
|
||||
"node_modules/@types/ejs": {
|
||||
"version": "3.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/ejs/-/ejs-3.1.5.tgz",
|
||||
@@ -37,9 +46,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.1.tgz",
|
||||
"integrity": "sha512-aS3/DG0oM05K0RIXXP+hKjinGG5IgSSVGzswZxW3O0sS3pH4/fycXundUC9XsszgKCk4gHXylTEK6hyFxVxnoQ==",
|
||||
"version": "24.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
|
||||
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -386,10 +395,23 @@
|
||||
"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": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-6.0.1.tgz",
|
||||
"integrity": "sha512-UaaM14yby8U3k02ihS1Bmj5Kz2d7CCQM1scxpgs4Mhkq8F1wR2gl3+Ts4h5Ne4Mnt7M9m4Dw7jsuMr3+xO4vZA==",
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-7.0.2.tgz",
|
||||
"integrity": "sha512-LcDSEP6Oclb9sTiSyG0MpG0RuKvLDRxCOOt+2sIk+kdk1rMkFU8SRnT0qaNt5zvU0LOV1YXXOvIoZL/sKwabvw==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"ejs": "bin/cli.js"
|
||||
@@ -399,9 +421,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-static": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.52.0.tgz",
|
||||
"integrity": "sha512-0zZYihAze4ZmUCcyNa9OkWYqn/Y5f9AdTh02AZIxMtP5ravcTTfoSIAnUPBH6ZvKxt+ZW28JB0ldzLRyD82fcw==",
|
||||
"version": "1.70.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.70.0.tgz",
|
||||
"integrity": "sha512-A9UgY98g94IuHQY7rl7/AFwHX7T1qNpzryim+Kw5CGhdV2STobtBY+ib/Z44ohyqXEJB88gvXFAeJ87uOOHKLw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/postgres": {
|
||||
|
||||
+6
-4
@@ -16,14 +16,16 @@
|
||||
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"release-tooling/**/*.test.ts\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@larvit/log": "2.3.0",
|
||||
"ejs": "6.0.1",
|
||||
"lucide-static": "1.52.0",
|
||||
"@larvit/log": "2.4.0",
|
||||
"ejs": "7.0.2",
|
||||
"lucide-static": "1.70.0",
|
||||
"postgres": "3.4.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@mdn/browser-compat-data": "8.1.8",
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/node": "24.19.1",
|
||||
"@types/node": "24.19.2",
|
||||
"acorn": "8.19.0",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
+5
-119
@@ -1,17 +1,11 @@
|
||||
/* =============================================================
|
||||
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 {
|
||||
min-height: 100dvh;
|
||||
display: grid; place-items: center;
|
||||
padding: 40px 20px 96px;
|
||||
background: var(--bg);
|
||||
}
|
||||
@supports (min-height: 100dvh) {
|
||||
.auth-stage { min-height: 100dvh; }
|
||||
}
|
||||
.auth {
|
||||
width: 100%; max-width: 408px;
|
||||
display: flex; flex-direction: column; gap: 22px;
|
||||
@@ -34,13 +28,6 @@
|
||||
.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); }
|
||||
|
||||
/* ---- 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 */
|
||||
.auth-card {
|
||||
background: var(--surface);
|
||||
@@ -118,13 +105,13 @@ body:has(#forgot:target) #login { display: none; }
|
||||
.input:focus { outline: none; border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--accent-bg); }
|
||||
/* gentle validation feedback only after the user has interacted */
|
||||
@supports selector(:user-invalid) {
|
||||
.input:user-invalid { border-color: var(--neg); }
|
||||
.input:user-invalid:focus { box-shadow: 0 0 0 2px var(--neg-bg); }
|
||||
}
|
||||
|
||||
.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) */
|
||||
.btn-block { width: 100%; justify-content: center; height: 40px; font-size: var(--fz); }
|
||||
|
||||
@@ -138,74 +125,6 @@ body:has(#forgot:target) #login { display: none; }
|
||||
.auth-aside a { color: var(--accent); }
|
||||
.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 {
|
||||
display: none; align-items: center; gap: 6px; margin: 0;
|
||||
font-size: var(--fz-xs); color: var(--neg);
|
||||
@@ -213,36 +132,3 @@ body:has(#forgot:target) .tpl-forgot { display: flex; }
|
||||
.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 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; }
|
||||
|
||||
+111
-51
@@ -2,8 +2,6 @@
|
||||
App Shell Template — pure HTML/CSS, no JavaScript
|
||||
-------------------------------------------------------------
|
||||
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,
|
||||
.chip, .badge, .btn, .menu, .table — combine freely.
|
||||
============================================================= */
|
||||
@@ -11,43 +9,46 @@
|
||||
/* ---------- 1. TOKENS ----------------------------------------
|
||||
Two raw palettes (--l-* light, --d-* dark) are defined once.
|
||||
A small mapping (--bg: var(--l-bg) ...) picks the active set.
|
||||
Theme resolution (no JS):
|
||||
Theme resolution (no JS); all three rules below need :has():
|
||||
• default + @media(dark) → follows the OS (Auto)
|
||||
• html:has(#theme-light) → force light
|
||||
• 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
|
||||
resolution is unreliable across engines; :has() variable swaps
|
||||
are rock-solid. color-scheme is still set as a hint for native
|
||||
widgets (date picker, scrollbars, selects).
|
||||
resolution is unreliable across engines. color-scheme is still
|
||||
set as a hint for native widgets (date picker, scrollbars,
|
||||
selects).
|
||||
------------------------------------------------------------- */
|
||||
:root {
|
||||
/* ---- raw LIGHT palette ---- */
|
||||
--l-bg:#f3f3f4; --l-surface:#ffffff; --l-surface-2:#f6f6f7; --l-surface-3:#fbfbfc;
|
||||
--l-border:#e5e5e8; --l-border-2:#d6d6da;
|
||||
--l-text:#18181b; --l-text-muted:#6b6b73; --l-text-faint:#9a9aa2;
|
||||
--l-accent:oklch(0.52 0.12 256); --l-accent-bg:oklch(0.96 0.03 256);
|
||||
--l-accent-bd:oklch(0.82 0.07 256); --l-focus:oklch(0.52 0.14 256);
|
||||
--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: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: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:oklch(0.54 0.12 245);--l-info-bg:oklch(0.96 0.03 245);--l-info-bd:oklch(0.83 0.07 245);
|
||||
--l-accent:#3669ac; --l-accent-bg:#e5f3ff;
|
||||
--l-accent-bd:#a7c7f2; --l-focus:#2a68b7; --l-on-accent:#ffffff;
|
||||
--l-pos:#1f7a3d; --l-pos-bg:#e1f5e4; --l-pos-bd:#a8d5b0;
|
||||
--l-neg:#c53637; --l-neg-bg:#ffebe8; --l-neg-bd:#febab4;
|
||||
--l-warn:#8f5d00; --l-warn-bg:#fef0d4; --l-warn-bd:#e3c78e;
|
||||
--l-info:#1d6aa5;--l-info-bg:#e2f5ff;--l-info-bd:#a2cdf2;
|
||||
|
||||
/* ---- raw DARK palette ---- */
|
||||
--d-bg:#0d0d0f; --d-surface:#161619; --d-surface-2:#1d1d22; --d-surface-3:#1a1a1e;
|
||||
--d-border:#2a2a31; --d-border-2:#3a3a43;
|
||||
--d-text:#ededf0; --d-text-muted:#9b9ba6; --d-text-faint:#6a6a74;
|
||||
--d-accent:oklch(0.70 0.12 256); --d-accent-bg:oklch(0.32 0.06 256);
|
||||
--d-accent-bd:oklch(0.48 0.08 256); --d-focus:oklch(0.74 0.13 256);
|
||||
--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: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: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:oklch(0.74 0.12 245);--d-info-bg:oklch(0.31 0.06 245);--d-info-bd:oklch(0.47 0.08 245);
|
||||
--d-accent:#6ba0e8; --d-accent-bg:#1d3351;
|
||||
--d-accent-bd:#3f5f8a; --d-focus:#72adfb; --d-on-accent:#0d0d0f;
|
||||
--d-pos:#62c37a; --d-pos-bg:#1a3520; --d-pos-bd:#376040;
|
||||
--d-neg:#ff7e76; --d-neg-bg:#4b1d1b; --d-neg-bd:#8a423d;
|
||||
--d-warn:#e4b750; --d-warn-bg:#3e2d10; --d-warn-bd:#72592c;
|
||||
--d-info:#65b2f1;--d-info-bg:#12334c;--d-info-bd:#305f85;
|
||||
|
||||
/* ---- active mapping: default = LIGHT ---- */
|
||||
--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);
|
||||
--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);
|
||||
--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);
|
||||
@@ -58,7 +59,7 @@
|
||||
--radius: 5px;
|
||||
--nav-w: 264px;
|
||||
--row-h: 34px;
|
||||
--pad-x: 12px;
|
||||
--pad-x: 12px; /* side padding of the content pane's bars and form page, and of every table cell, so their text lines up */
|
||||
--fz: 13px;
|
||||
--fz-sm: 12px;
|
||||
--fz-xs: 11px;
|
||||
@@ -68,13 +69,14 @@
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
@supports selector(:has(a)) {
|
||||
/* shared DARK mapping, applied to Auto-via-OS and forced-dark */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
: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);
|
||||
--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);
|
||||
@@ -88,7 +90,7 @@ 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);
|
||||
--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);
|
||||
--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);
|
||||
@@ -101,13 +103,14 @@ 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);
|
||||
--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);
|
||||
--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 ---------------------------------------- */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
@@ -119,11 +122,13 @@ a { color: inherit; text-decoration: none; }
|
||||
summary::-webkit-details-marker { display: none; }
|
||||
summary { list-style: none; cursor: pointer; }
|
||||
|
||||
@supports selector(:focus-visible) {
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--focus);
|
||||
outline-offset: 1px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.sidebar, .scrim, summary, .nav-item, .btn, .chip, .profile, .kebab { transition: .15s ease; }
|
||||
}
|
||||
@@ -150,7 +155,9 @@ summary { list-style: none; cursor: pointer; }
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: var(--nav-w) minmax(0, 1fr);
|
||||
min-height: 100dvh;
|
||||
}
|
||||
@supports (min-height: 100dvh) {
|
||||
.app { min-height: 100dvh; }
|
||||
}
|
||||
|
||||
/* ---------- 4. SIDEBAR -------------------------------------- */
|
||||
@@ -169,9 +176,9 @@ summary { list-style: none; cursor: pointer; }
|
||||
.brand-mark {
|
||||
width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto;
|
||||
background: var(--accent);
|
||||
display: grid; place-items: center; color: #fff;
|
||||
display: grid; place-items: center; color: var(--on-accent);
|
||||
}
|
||||
.brand-mark .ico { stroke: #fff; }
|
||||
.brand-mark .ico { stroke: var(--on-accent); }
|
||||
.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-sub { color: var(--text-faint); font-size: var(--fz-xs); margin-left: auto; }
|
||||
@@ -189,9 +196,10 @@ summary { list-style: none; cursor: pointer; }
|
||||
• CLICKABLE = label is <a href> → navigates on click.
|
||||
STATIC = label is <span> → not a navigation target.
|
||||
So "header" and "clickable" are independent: any combination works.
|
||||
Children sit in a sibling <ul class="nav-children">, revealed by
|
||||
:has() on THIS row's own <details open> (scoped with > so a node
|
||||
only ever controls its own subtree — infinite depth, no JS).
|
||||
Children sit in a sibling <ul class="nav-children">. Where :has() works, a
|
||||
branch is folded unless THIS row's own <details open> (scoped with > so a node
|
||||
only ever controls its own subtree — infinite depth, no JS). Without :has()
|
||||
every branch stays open, since nothing could reopen one.
|
||||
============================================================ */
|
||||
.nav-row { display: flex; align-items: center; gap: 2px; }
|
||||
|
||||
@@ -215,7 +223,10 @@ summary { list-style: none; cursor: pointer; }
|
||||
min-height: 30px; padding: 4px 8px;
|
||||
border-radius: var(--radius);
|
||||
color: var(--text-muted); font-size: var(--fz);
|
||||
position: relative; user-select: none;
|
||||
position: relative;
|
||||
}
|
||||
@supports (user-select: none) {
|
||||
.nav-self { user-select: none; }
|
||||
}
|
||||
a.nav-self:hover { background: var(--surface-2); color: var(--text); }
|
||||
.nav-self .ico { color: var(--text-faint); }
|
||||
@@ -235,8 +246,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.nav-self[aria-current="page"] .ico { color: var(--accent); }
|
||||
|
||||
/* children + infinite indentation with a guide line per level */
|
||||
@supports selector(:has(a)) {
|
||||
.nav-node > .nav-children { display: none; }
|
||||
.nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; }
|
||||
}
|
||||
.nav-children { padding-left: 22px; position: relative; }
|
||||
.nav-children::before {
|
||||
content: ""; position: absolute; left: 10px; top: 2px; bottom: 2px;
|
||||
@@ -271,6 +284,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
}
|
||||
.theme-switch label:hover { color: var(--text); }
|
||||
.theme-switch input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
|
||||
@supports selector(:has(:focus-visible)) {
|
||||
.theme-switch label:has(input:checked) {
|
||||
background: var(--surface); color: var(--text);
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.06); font-weight: 550;
|
||||
@@ -278,6 +292,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.theme-switch label:has(input:focus-visible) {
|
||||
outline: 2px solid var(--focus); outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-actions { display: flex; align-items: center; gap: 4px; }
|
||||
.profile {
|
||||
@@ -309,7 +324,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
}
|
||||
.btn:hover { background: var(--surface-2); }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent);
|
||||
color: #fff; font-weight: 550; }
|
||||
color: var(--on-accent); font-weight: 550; }
|
||||
.btn-primary:hover { filter: brightness(1.06); }
|
||||
.btn-ghost { background: transparent; border-color: transparent; }
|
||||
.btn-ghost:hover { background: var(--surface-2); }
|
||||
@@ -336,7 +351,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.topbar {
|
||||
flex: 0 0 auto; height: 48px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 0 16px; border-bottom: 1px solid var(--border);
|
||||
padding: 0 var(--pad-x); border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.hamburger { display: none; } /* shown only on narrow */
|
||||
@@ -352,7 +367,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.filters {
|
||||
flex: 0 0 auto; background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px 16px;
|
||||
padding: 10px var(--pad-x);
|
||||
display: flex; flex-direction: column; gap: 9px;
|
||||
}
|
||||
.filter-row {
|
||||
@@ -401,7 +416,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
position: relative; display: inline-flex; align-items: center;
|
||||
}
|
||||
.select select {
|
||||
appearance: none; height: 30px; padding: 0 26px 0 9px;
|
||||
height: 30px; padding: 0 26px 0 9px;
|
||||
background: var(--surface); color: var(--text);
|
||||
border: 1px solid var(--border-2); border-radius: var(--radius);
|
||||
font: inherit; font-size: var(--fz-sm); cursor: pointer;
|
||||
@@ -411,8 +426,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
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);
|
||||
}
|
||||
@supports (appearance: none) {
|
||||
.select select { appearance: none; }
|
||||
}
|
||||
@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 {
|
||||
@@ -427,11 +447,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
}
|
||||
.segmented label:hover { color: var(--text); }
|
||||
.segmented input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
|
||||
@supports selector(:has(:focus-visible)) {
|
||||
.segmented label:has(input:checked) {
|
||||
background: var(--surface); color: var(--text); font-weight: 550;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.06);
|
||||
}
|
||||
.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); }
|
||||
|
||||
/* chips (multi-select checkboxes) */
|
||||
@@ -447,12 +469,25 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.chip input { position: absolute; opacity: 0; inset: 0; cursor: pointer; }
|
||||
.chip .chip-dot { width: 7px; height: 7px; border-radius: 50%;
|
||||
background: var(--border-2); }
|
||||
@supports selector(:has(:focus-visible)) {
|
||||
.chip:has(input:checked) {
|
||||
background: var(--accent-bg); border-color: var(--accent-bd);
|
||||
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; }
|
||||
}
|
||||
|
||||
/* date range */
|
||||
.daterange {
|
||||
@@ -473,8 +508,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
display: inline-flex; align-items: center; gap: 7px;
|
||||
font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer;
|
||||
}
|
||||
.check input, .radio input { width: 15px; height: 15px; accent-color: var(--accent);
|
||||
margin: 0; cursor: pointer; }
|
||||
.check input, .radio input { width: 15px; height: 15px; margin: 0; cursor: pointer; }
|
||||
@supports (accent-color: auto) {
|
||||
.check input, .radio input { accent-color: var(--accent); }
|
||||
}
|
||||
.check:hover, .radio:hover { color: var(--text); }
|
||||
/* A stacked group of .check rows in a <fieldset> — the right element for related checkboxes, but the
|
||||
UA gives it a groove border, so reset it like .filter-field and .menu-field do. A disabled row is
|
||||
@@ -482,7 +519,9 @@ 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 .check { align-items: baseline; }
|
||||
.check-group .check input:disabled { cursor: default; }
|
||||
@supports selector(:has(a)) {
|
||||
.check-group .check:has(input:disabled) { opacity: .7; cursor: default; }
|
||||
}
|
||||
.check-group .check .cell-muted { margin-left: auto; padding-left: 12px; font-size: var(--fz-xs); }
|
||||
|
||||
/* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover]
|
||||
@@ -493,17 +532,24 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.menu { display: inline-flex; }
|
||||
.menu-pop {
|
||||
position: absolute; inset: auto; margin: 6px 0 0;
|
||||
position-anchor: auto;
|
||||
position-try-fallbacks: flip-block, flip-inline;
|
||||
top: anchor(bottom); right: anchor(right);
|
||||
min-width: 210px; max-width: min(320px, 92vw); padding: 6px;
|
||||
max-height: 60vh; overflow-y: auto; overflow-wrap: anywhere;
|
||||
max-height: 60vh; overflow-y: auto;
|
||||
background: var(--surface); color: var(--text);
|
||||
border: 1px solid var(--border-2); border-radius: var(--radius);
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,.16);
|
||||
}
|
||||
.menu-pop.up { margin: 0 0 6px; }
|
||||
@supports (overflow-wrap: anywhere) {
|
||||
.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); margin: 0 0 6px; }
|
||||
.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
|
||||
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 —
|
||||
@@ -534,7 +580,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.menu-check { display: flex; align-items: center; gap: 9px; width: 100%;
|
||||
padding: 6px 8px; border-radius: 4px; font-size: var(--fz-sm); cursor: pointer; }
|
||||
.menu-check:hover { background: var(--surface-2); }
|
||||
.menu-check input { accent-color: var(--accent); width: 15px; height: 15px; margin: 0; }
|
||||
.menu-check input { width: 15px; height: 15px; margin: 0; }
|
||||
@supports (accent-color: auto) {
|
||||
.menu-check input { accent-color: var(--accent); }
|
||||
}
|
||||
|
||||
/* active filter pills */
|
||||
.active-pills { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
|
||||
@@ -576,15 +625,19 @@ table.table {
|
||||
}
|
||||
.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); }
|
||||
@supports selector(:has(a)) {
|
||||
.table tbody tr:has(.row-select:checked) td,
|
||||
.table tbody tr:has(.row-select:checked) th { background: var(--accent-bg); }
|
||||
}
|
||||
.col-check { width: 36px; text-align: center; padding: 0; }
|
||||
.col-check input { accent-color: var(--accent); width: 15px; height: 15px;
|
||||
margin: 0; vertical-align: middle; }
|
||||
.col-check input { width: 15px; height: 15px; margin: 0; vertical-align: middle; }
|
||||
@supports (accent-color: auto) {
|
||||
.col-check input { accent-color: var(--accent); }
|
||||
}
|
||||
.col-num { text-align: right; }
|
||||
.cell-strong { font-weight: 550; }
|
||||
.cell-muted { color: var(--text-muted); }
|
||||
.table-empty { text-align: center; color: var(--text-muted); padding: 28px 12px; }
|
||||
.table-empty { text-align: center; color: var(--text-muted); padding: 28px var(--pad-x); }
|
||||
.cell-mono { font-variant-numeric: tabular-nums;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); }
|
||||
|
||||
@@ -642,13 +695,15 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
||||
.kebab:hover { background: var(--surface-2); color: var(--text); }
|
||||
/* Held open — a trigger's panel is its next sibling. Its own rule: :has() is non-forgiving, so an
|
||||
engine that doesn't know :popover-open would drop the hover style along with it. */
|
||||
@supports selector(.kebab:has(+ .menu-pop:popover-open)) {
|
||||
.kebab:has(+ .menu-pop:popover-open) { background: var(--surface-2); color: var(--text); }
|
||||
}
|
||||
|
||||
/* ---------- 10. PAGINATION --------------------------------- */
|
||||
.pager {
|
||||
flex: 0 0 auto;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 8px 16px; border-top: 1px solid var(--border);
|
||||
padding: 8px var(--pad-x); border-top: 1px solid var(--border);
|
||||
background: var(--surface); font-size: var(--fz-sm); color: var(--text-muted);
|
||||
}
|
||||
.pager .spacer { flex: 1 1 auto; }
|
||||
@@ -663,20 +718,25 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
||||
}
|
||||
.page-btn:hover { background: var(--surface-2); color: var(--text); }
|
||||
.page-btn[aria-current="page"] { background: var(--accent); border-color: var(--accent);
|
||||
color: #fff; font-weight: 600; }
|
||||
color: var(--on-accent); font-weight: 600; }
|
||||
.page-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
/* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */
|
||||
.scrim { display: none; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.app { grid-template-columns: minmax(0, 1fr); }
|
||||
.app { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr; }
|
||||
.content { grid-column: 1; grid-row: 2; }
|
||||
.search { min-width: 150px; flex: 1 1 auto; }
|
||||
|
||||
/* Off-canvas only where :has() works: without it nothing can read #nav-toggle, so the sidebar
|
||||
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);
|
||||
}
|
||||
.content { grid-column: 1; }
|
||||
.hamburger { display: inline-flex; }
|
||||
|
||||
/* checkbox-driven open state */
|
||||
@@ -685,7 +745,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
||||
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) {
|
||||
.crumbs, .page-sub { display: none; }
|
||||
@@ -696,7 +756,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
||||
#nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
|
||||
|
||||
/* admin forms: create/edit user, account actions */
|
||||
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
|
||||
.form-page { padding: 16px var(--pad-x); display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
|
||||
.form-card {
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
padding: 18px; background: var(--surface);
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
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);
|
||||
});
|
||||
@@ -12,7 +12,7 @@ test("readHostApiVersion pulls the constant out of the real source, and returns
|
||||
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
|
||||
// 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");
|
||||
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.5.0");
|
||||
});
|
||||
|
||||
test("every author-facing apiVersion sample matches the shipped contract", () => {
|
||||
@@ -23,10 +23,10 @@ test("every author-facing apiVersion sample matches the shipped contract", () =>
|
||||
const [major, minor] = host.split(".");
|
||||
for (const file of [
|
||||
"README.md",
|
||||
"examples/plugins/admin/plugin.ts",
|
||||
"examples/plugins/auth-admin/plugin.ts",
|
||||
"examples/plugins/scheduling/plugin.ts",
|
||||
"release-tooling/dockerhub-overview.md.tmpl",
|
||||
"views/index.ejs",
|
||||
"views/dashboard.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`);
|
||||
|
||||
@@ -131,7 +131,7 @@ services:
|
||||
|
||||
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.31.4
|
||||
image: axllent/mailpit:v1.33.1
|
||||
ports:
|
||||
- "8025:8025"
|
||||
restart: unless-stopped
|
||||
@@ -163,7 +163,7 @@ Every behaviour is an explicit env toggle read at boot — no `NODE_ENV`. The co
|
||||
| --- | --- | --- |
|
||||
| `ADMIN_EMAIL` / `ADMIN_PASSWORD` | `admin@plainpages.local` / `admin` | the seeded first admin (bootstrap service) |
|
||||
| `APP_URL` | unset | canonical public URL; off-host visitors are redirected to it |
|
||||
| `CACHE_TEMPLATES` | `false` | cache compiled templates (`true` in prod) |
|
||||
| `CACHE_TEMPLATES` | `false` | cache compiled templates and `/public/` asset versions (`true` in prod) |
|
||||
| `CSRF_SECRET` | dev throwaway | signs the CSRF token — set a real one in prod |
|
||||
| `KRATOS_*` / `KETO_*` / `HYDRA_*` URLs | compose defaults | the Ory sidecar endpoints |
|
||||
| `LOG_FORMAT` / `LOG_LEVEL` | `text` / `info` | `json` for structured prod logs |
|
||||
@@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
|
||||
import { definePlugin } from "@plainpages/plugin-api";
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0",
|
||||
apiVersion: "0.5.0",
|
||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||
routes: [
|
||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { jwtFrom, leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts";
|
||||
import { DESCRIPTION, hubPatch, jwtFrom, leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts";
|
||||
|
||||
const TEMPLATE = "release-tooling/dockerhub-overview.md.tmpl";
|
||||
const template = () => readFileSync(TEMPLATE, "utf8");
|
||||
@@ -46,3 +46,28 @@ test("jwtFrom accepts only a non-empty string token, never throwing on a hostile
|
||||
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" });
|
||||
});
|
||||
|
||||
@@ -1,6 +1,3 @@
|
||||
// Publishes the Docker Hub repository overview from dockerhub-overview.md.tmpl, rendering
|
||||
// `{{VERSION}}` to the release being published.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -8,6 +5,12 @@ 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);
|
||||
}
|
||||
@@ -53,7 +56,7 @@ async function main(): Promise<number> {
|
||||
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)",
|
||||
"DOCKERHUB_TOKEN (docs/ci-cd.md)",
|
||||
);
|
||||
}
|
||||
// The page is public, so never render a version that resolves to no image.
|
||||
@@ -81,21 +84,21 @@ async function main(): Promise<number> {
|
||||
if (!jwt) return fail("Docker Hub login returned no token");
|
||||
|
||||
const res = await post(`${HUB}/repositories/${repo}/`, {
|
||||
body: JSON.stringify({ full_description: body }),
|
||||
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 overview PATCH failed: ${res.status} ${res.text}` +
|
||||
`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)."
|
||||
"read/write/delete, which pushing images alone does not (docs/ci-cd.md)."
|
||||
: ""),
|
||||
);
|
||||
}
|
||||
process.stdout.write(`Docker Hub overview updated for ${repo} at ${version}\n`);
|
||||
process.stdout.write(`Docker Hub description and overview updated for ${repo} at ${version}\n`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Pure release-version math for the Renovate auto-release (see renovate.yml → auto-release job,
|
||||
// README → CI/CD). Renovate stamps each commit with a `Release-Bump: <updateType>` trailer; the
|
||||
// docs/ci-cd.md). Renovate stamps each commit with a `Release-Bump: <updateType>` trailer; the
|
||||
// workflow feeds those values here to pick the next `vX.Y.Z` tag. Kept side-effect-free and unit
|
||||
// tested (next-version.test.ts) — the git/tag/push side lives in the workflow shell.
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
"automerge": true,
|
||||
"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",
|
||||
"description": "The host's own runtime deps. Release-Bump is opt-in per surface (docs/ci-cd.md): updateType rates the dependency's own jump, not whether it reaches a running Plainpages",
|
||||
"matchDepTypes": ["dependencies"],
|
||||
"matchFileNames": ["package.json"],
|
||||
"matchManagers": ["npm"],
|
||||
|
||||
@@ -28,8 +28,6 @@ export function identityPayload(email: string, password: string) {
|
||||
};
|
||||
}
|
||||
|
||||
// Coarse-permission grant: `Permission:<permission>#members@user:<id>`. Subject ids are `user:<kratos-id>`
|
||||
// (namespaces.keto.ts) — the source of truth the login flow projects into the JWT permissions.
|
||||
export function permissionTuple(userId: string, permission: string) {
|
||||
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
|
||||
}
|
||||
|
||||
@@ -100,9 +100,7 @@ test("collects oidc nodes as SSO providers (text logo = initial), keeping csrf a
|
||||
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
|
||||
});
|
||||
|
||||
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.
|
||||
test("the code field takes a pasted code: one-time-code autofill + numeric inputmode + digits, whitespace around them allowed", () => {
|
||||
const view = buildFlowView(
|
||||
flow([
|
||||
node({ name: "csrf_token", type: "hidden", value: "tok" }),
|
||||
@@ -113,18 +111,26 @@ test("the code field guards a pasted space: one-time-code autofill + numeric inp
|
||||
);
|
||||
assert.deepEqual(view.fields.find((f) => f.name === "code"), {
|
||||
autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill
|
||||
hint: "Digits only — no spaces.", // the pattern refusal alone reads as a bare "match the requested format"
|
||||
hint: "Digits only.", // the pattern refusal alone reads as a bare "match the requested format"
|
||||
icon: "i-shield",
|
||||
id: "field-code",
|
||||
inputmode: "numeric",
|
||||
label: "Verification code",
|
||||
name: "code",
|
||||
pattern: "[0-9]*",
|
||||
pattern: "\\s*[0-9]*\\s*",
|
||||
required: true,
|
||||
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", () => {
|
||||
const reg = buildFlowView(flow([]), "registration");
|
||||
assert.equal(reg.title, "Create account");
|
||||
|
||||
+15
-8
@@ -1,8 +1,8 @@
|
||||
// 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
|
||||
// CSRF token), themed fields, submit buttons, tone-mapped messages, and one SSO button per
|
||||
// configured `oidc` provider. The form posts straight back to `flow.ui.action`, so Kratos
|
||||
// owns its CSRF; we only render and map errors. No providers configured ⇒ no SSO buttons.
|
||||
// configured `oidc` provider. The form posts to `flow.ui.action`, except a CODE_FLOWS form, which
|
||||
// posts to the host first; Kratos owns the CSRF of both. No providers configured ⇒ no SSO buttons.
|
||||
|
||||
import { ENGLISH } from "../i18n/english.ts";
|
||||
import type { Translate } from "../i18n/translate.ts";
|
||||
@@ -17,7 +17,7 @@ export interface FlowField {
|
||||
inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code)
|
||||
label: string;
|
||||
name: string;
|
||||
pattern?: string; // client-side validity regex; blocks a pasted space before it reaches Kratos
|
||||
pattern?: string; // client-side validity regex
|
||||
required?: boolean;
|
||||
type: string;
|
||||
value?: string;
|
||||
@@ -69,6 +69,9 @@ export const AUTH_FLOWS: Record<string, FlowType> = {
|
||||
"/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>.*`.
|
||||
const LINKS: Record<FlowType, { alt?: string; back?: boolean }> = {
|
||||
login: { alt: "/registration" },
|
||||
@@ -123,9 +126,9 @@ const ssoLogo = (value: string): string => (value.charAt(0) || "?").toUpperCase(
|
||||
|
||||
function toField(node: UiNode, name: string, type: string, t: Translate): FlowField {
|
||||
const value = str(node.attributes["value"]);
|
||||
// The recovery/verification one-time code: numeric, and Kratos doesn't trim it, so a stray pasted
|
||||
// space makes it reject the code as "invalid". A digits-only pattern + numeric keypad block that in
|
||||
// the browser; one-time-code enables OS/email autofill (Kratos sends no autocomplete for the node).
|
||||
// The recovery/verification one-time code is numeric: a numeric keypad and a digits pattern. The
|
||||
// pattern allows whitespace around the digits, as a paste brings; routes.ts → codeFlowSubmit trims it.
|
||||
// one-time-code enables OS/email autofill (Kratos sends no autocomplete for the node).
|
||||
const isCode = name === "code";
|
||||
const autocomplete = str(node.attributes["autocomplete"]) ?? (isCode ? "one-time-code" : undefined);
|
||||
const icon = iconFor(name, type);
|
||||
@@ -140,12 +143,16 @@ function toField(node: UiNode, name: string, type: string, t: Translate): FlowFi
|
||||
...(autocomplete ? { autocomplete } : {}),
|
||||
...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}),
|
||||
...(icon ? { icon } : {}),
|
||||
...(isCode ? { hint: t("auth.field.code.hint"), inputmode: "numeric", pattern: "[0-9]*" } : {}),
|
||||
...(isCode ? { hint: t("auth.field.code.hint"), inputmode: "numeric", pattern: "\\s*[0-9]*\\s*" } : {}),
|
||||
...(node.attributes["required"] === true ? { required: true } : {}),
|
||||
...(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 {
|
||||
const hidden: { name: string; value: string }[] = [];
|
||||
const fields: FlowField[] = [];
|
||||
@@ -174,7 +181,7 @@ export function buildFlowView(flow: Flow, type: FlowType, t: Translate = ENGLISH
|
||||
}
|
||||
|
||||
return {
|
||||
action: flow.ui.action,
|
||||
action: formAction(flow, type),
|
||||
buttons,
|
||||
fields,
|
||||
hidden,
|
||||
|
||||
+5
-1
@@ -10,10 +10,14 @@ export interface Gate {
|
||||
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
|
||||
}
|
||||
|
||||
// A gate naming none of the three allows everyone. Discovery refuses one on a plugin's route, nav
|
||||
// node or profile-menu entry, so only the header nodes `config/menu.ts`'s `groups` builds get here
|
||||
// without a gate, and failing closed would hide every section an operator grouped.
|
||||
export function allows(gate: Gate, user: User | null): boolean {
|
||||
if (gate.public === true) return true;
|
||||
if (gate.session === true) return user !== null;
|
||||
return gate.permission == null || (user?.permissions.includes(gate.permission) ?? false);
|
||||
if (gate.permission == null) return true;
|
||||
return user?.permissions.includes(gate.permission) ?? false;
|
||||
}
|
||||
|
||||
export function gatesSet(gate: Gate | null | undefined): string[] {
|
||||
|
||||
@@ -104,3 +104,29 @@ test("deleteTuple DELETEs the write API by query params (204 resolves; non-204 t
|
||||
(e: unknown) => e instanceof KetoError && e.status === 404,
|
||||
);
|
||||
});
|
||||
|
||||
test("transactTuples PATCHes every insert and delete in one request (204 resolves; non-204 throws; empty sends nothing)", async () => {
|
||||
const add = { namespace: "Permission", object: "a:read", relation: "granted", subject_id: USER };
|
||||
const drop = { namespace: "Permission", object: "b:read", relation: "granted", subject_id: USER };
|
||||
const { calls, fetchImpl } = recorder(() => res(204));
|
||||
await keto(fetchImpl).transactTuples({ delete: [drop], insert: [add] });
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0]!.method, "PATCH");
|
||||
assert.equal(calls[0]!.url, `${WRITE}/admin/relation-tuples`);
|
||||
assert.deepEqual(JSON.parse(calls[0]!.body!), [{ action: "insert", relation_tuple: add }, { action: "delete", relation_tuple: drop }]);
|
||||
await keto(fetchImpl).transactTuples({ delete: [], insert: [] });
|
||||
assert.equal(calls.length, 1, "an empty change is not a request");
|
||||
await assert.rejects(
|
||||
keto((async () => res(400, { error: "bad" })) as typeof fetch).transactTuples({ delete: [], insert: [add] }),
|
||||
(e: unknown) => e instanceof KetoError && e.status === 400,
|
||||
);
|
||||
});
|
||||
|
||||
test("trailing slashes on the read and write URLs are stripped before a path is appended", async () => {
|
||||
const { calls, fetchImpl } = recorder((url) => (url.startsWith("http://r") ? res(200, {}) : res(204)));
|
||||
const client = createKetoClient({ fetchImpl, readUrl: "http://r//", writeUrl: "http://w/" });
|
||||
await client.listRelations();
|
||||
await client.transactTuples({ delete: [], insert: [{ namespace: "Permission", object: "a:read", relation: "granted", subject_id: USER }] });
|
||||
assert.match(calls[0]!.url, /^http:\/\/r\/relation-tuples\?/);
|
||||
assert.equal(calls[1]!.url, "http://w/admin/relation-tuples");
|
||||
});
|
||||
|
||||
+36
-15
@@ -1,7 +1,7 @@
|
||||
// Keto client: typed `fetch` wrappers over Ory Keto's relation-tuple APIs —
|
||||
// `check` a permission, `listRelations`/`expand` to inspect them (read API), `writeTuple`/
|
||||
// `deleteTuple` to grant/revoke them (write API). Built-in `fetch` only, no SDK dep (AGENTS.md);
|
||||
// `fetchImpl`-injectable like the kratos clients. Read/write split onto the two ports config.ts
|
||||
// `deleteTuple`/`transactTuples` to grant/revoke them (write API). Built-in `fetch` only, no SDK dep
|
||||
// (AGENTS.md); `fetchImpl`-injectable like the kratos clients. Read/write split onto the two ports config.ts
|
||||
// targets (ketoReadUrl 4466 / ketoWriteUrl 4467).
|
||||
|
||||
// A subject set: a relation on another object (e.g. Group:eng#members), resolved
|
||||
@@ -32,7 +32,7 @@ export interface RelationList {
|
||||
|
||||
// Keto's expand tree: a node is a set operation (union/…) or a leaf. The resolved subject
|
||||
// (subject_id xor subject_set) rides on `tuple`, not the node itself — verified against Keto
|
||||
// v26.2.0. A `subject_set` node carries its members as `children` ("effective access" view).
|
||||
// v26.2.0. A `subject_set` node carries its members as `children`.
|
||||
export interface ExpandTree {
|
||||
children?: ExpandTree[];
|
||||
tuple?: RelationTuple;
|
||||
@@ -56,6 +56,8 @@ export interface KetoClient {
|
||||
deleteTuple(tuple: RelationTuple): Promise<void>;
|
||||
expand(set: SubjectSet, opts?: { maxDepth?: number }): Promise<ExpandTree>;
|
||||
listRelations(query?: RelationQuery): Promise<RelationList>;
|
||||
// Inserts and deletes in one Keto transaction: all of them apply or none does.
|
||||
transactTuples(changes: { delete: RelationTuple[]; insert: RelationTuple[] }): Promise<void>;
|
||||
writeTuple(tuple: RelationTuple): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -75,16 +77,14 @@ function tupleParams(t: Partial<RelationTuple>): URLSearchParams {
|
||||
return p;
|
||||
}
|
||||
|
||||
export function createKetoClient(config: { fetchImpl?: typeof fetch; readUrl: string; writeUrl: string }): KetoClient {
|
||||
const read = config.readUrl.replace(/\/+$/, "");
|
||||
const write = config.writeUrl.replace(/\/+$/, "");
|
||||
const http = config.fetchImpl ?? fetch;
|
||||
const tuples = `${write}/admin/relation-tuples`;
|
||||
|
||||
async function fail(action: string, res: Response): Promise<never> {
|
||||
throw new KetoError(`Keto ${action} failed (${res.status})`, res.status, await res.text());
|
||||
}
|
||||
|
||||
type ReadApi = Pick<KetoClient, "check" | "expand" | "listRelations">;
|
||||
type WriteApi = Pick<KetoClient, "deleteTuple" | "transactTuples" | "writeTuple">;
|
||||
|
||||
function readApi(http: typeof fetch, read: string): ReadApi {
|
||||
return {
|
||||
async check(tuple, opts = {}) {
|
||||
const params = tupleParams(tuple);
|
||||
@@ -95,11 +95,6 @@ export function createKetoClient(config: { fetchImpl?: typeof fetch; readUrl: st
|
||||
return ((await res.json()) as { allowed?: boolean }).allowed === true;
|
||||
},
|
||||
|
||||
async deleteTuple(tuple) {
|
||||
const res = await http(`${tuples}?${tupleParams(tuple)}`, { method: "DELETE" });
|
||||
if (res.status !== 204) await fail("delete tuple", res);
|
||||
},
|
||||
|
||||
async expand(set, opts = {}) {
|
||||
const params = tupleParams(set);
|
||||
if (opts.maxDepth !== undefined) params.set("max-depth", String(opts.maxDepth));
|
||||
@@ -117,11 +112,37 @@ export function createKetoClient(config: { fetchImpl?: typeof fetch; readUrl: st
|
||||
const body = (await res.json()) as { next_page_token?: string; relation_tuples?: RelationTuple[] };
|
||||
return { nextPageToken: body.next_page_token || null, tuples: body.relation_tuples ?? [] };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeApi(http: typeof fetch, write: string): WriteApi {
|
||||
const tuples = `${write}/admin/relation-tuples`;
|
||||
const json = { "content-type": "application/json" };
|
||||
return {
|
||||
async deleteTuple(tuple) {
|
||||
const res = await http(`${tuples}?${tupleParams(tuple)}`, { method: "DELETE" });
|
||||
if (res.status !== 204) await fail("delete tuple", res);
|
||||
},
|
||||
|
||||
async transactTuples(changes) {
|
||||
const patches = [
|
||||
...changes.insert.map((relation_tuple) => ({ action: "insert", relation_tuple })),
|
||||
...changes.delete.map((relation_tuple) => ({ action: "delete", relation_tuple })),
|
||||
];
|
||||
if (patches.length === 0) return;
|
||||
const res = await http(tuples, { body: JSON.stringify(patches), headers: json, method: "PATCH" });
|
||||
if (res.status !== 204) await fail("transact tuples", res);
|
||||
},
|
||||
|
||||
// PUT is idempotent — re-asserting an existing tuple is a no-op grant.
|
||||
async writeTuple(tuple) {
|
||||
const res = await http(tuples, { body: JSON.stringify(tuple), headers: { "content-type": "application/json" }, method: "PUT" });
|
||||
const res = await http(tuples, { body: JSON.stringify(tuple), headers: json, method: "PUT" });
|
||||
if (!res.ok) await fail("write tuple", res);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createKetoClient(config: { fetchImpl?: typeof fetch; readUrl: string; writeUrl: string }): KetoClient {
|
||||
const http = config.fetchImpl ?? fetch;
|
||||
return { ...readApi(http, config.readUrl.replace(/\/+$/, "")), ...writeApi(http, config.writeUrl.replace(/\/+$/, "")) };
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
const { calls, fetchImpl } = recorder(() => res(200, { recovery_code: "123456", recovery_link: "http://kratos/self-service/recovery?flow=f&code=123456" }));
|
||||
const { calls, fetchImpl } = recorder(() => res(200, { recovery_code: "123456", recovery_link: "http://kratos/self-service/recovery?flow=f" }));
|
||||
const out = await createKratosAdmin({ baseUrl: BASE, fetchImpl }).createRecoveryCode(ID);
|
||||
assert.deepEqual(out, { code: "123456", link: "http://kratos/self-service/recovery?flow=f&code=123456" });
|
||||
assert.deepEqual(out, { code: "123456", link: "http://kratos/self-service/recovery?flow=f" });
|
||||
assert.equal(calls[0]!.method, "POST");
|
||||
assert.match(calls[0]!.url, /\/admin\/recovery\/code$/);
|
||||
assert.deepEqual(JSON.parse(calls[0]!.body!), { identity_id: ID });
|
||||
|
||||
@@ -25,7 +25,7 @@ export interface ListOptions {
|
||||
pageToken?: string;
|
||||
}
|
||||
|
||||
// A one-time recovery code + the self-service link wrapping it (admin "trigger recovery").
|
||||
// A one-time recovery code + the URL of the recovery flow it was minted on, the only flow that accepts it.
|
||||
export interface RecoveryCode {
|
||||
code: string;
|
||||
link: string;
|
||||
@@ -65,8 +65,8 @@ export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof
|
||||
return (await res.json()) as Identity;
|
||||
},
|
||||
|
||||
// Mint a recovery code for an identity (admin "trigger recovery") — the link is mailed to the
|
||||
// user by Kratos; the code/link are also returned so an operator can hand them over directly.
|
||||
// Mint a recovery code for an identity (admin "trigger recovery"). Kratos mails nothing, so the
|
||||
// operator hands the code and link to the user.
|
||||
async createRecoveryCode(userId, opts = {}) {
|
||||
const body: Record<string, unknown> = { identity_id: userId };
|
||||
if (opts.expiresIn) body.expires_in = opts.expiresIn;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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
|
||||
// (200/401/4xx, validation-flow vs success, tokenized JWT). Live wiring is verified by the
|
||||
// (200/401/4xx, a submit's redirect, tokenized JWT). Live wiring is verified by the
|
||||
// flow pages; these catch contract drift with a mock fetch.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -17,12 +17,13 @@ function res(status: number, body?: unknown, setCookie: string[] = []): Response
|
||||
|
||||
// Records each call so a test can assert URL/method/headers/body.
|
||||
function recorder(handler: (url: string, init: RequestInit | undefined) => Response) {
|
||||
const calls: { body: string | undefined; headers: Headers; method: string; url: string }[] = [];
|
||||
const calls: { body: string | undefined; headers: Headers; method: string; redirect: string | undefined; url: string }[] = [];
|
||||
const fetchImpl = (async (input: unknown, init?: RequestInit) => {
|
||||
calls.push({
|
||||
body: init?.body as string | undefined,
|
||||
headers: new Headers(init?.headers),
|
||||
method: init?.method ?? "GET",
|
||||
redirect: init?.redirect,
|
||||
url: String(input),
|
||||
});
|
||||
return handler(String(input), init);
|
||||
@@ -57,35 +58,28 @@ test("getFlow throws a KratosError carrying the status when the flow is gone (41
|
||||
);
|
||||
});
|
||||
|
||||
test("submitFlow POSTs urlencoded to the action and reports success + relays Set-Cookie", async () => {
|
||||
const { calls, fetchImpl } = recorder(() => res(200, { session: { active: true } }, ["plainpages_session=s; Path=/"]));
|
||||
test("submitFlow POSTs the form to the flow on Kratos' own base URL, as a browser would, and relays the redirect + Set-Cookie", async () => {
|
||||
const { calls, fetchImpl } = recorder(() => new Response(null, {
|
||||
headers: new Headers([["location", "http://app/settings?flow=s1"], ["set-cookie", "plainpages_session=s; Path=/"]]),
|
||||
status: 303,
|
||||
}));
|
||||
const out = await createKratosPublic({ baseUrl: BASE, fetchImpl })
|
||||
.submitFlow(`${BASE}/self-service/login?flow=f`, { body: "identifier=a&password=b", cookie: "csrf_token=abc" });
|
||||
assert.equal(out.ok, true);
|
||||
assert.equal(out.status, 200);
|
||||
assert.deepEqual(out.setCookie, ["plainpages_session=s; Path=/"]);
|
||||
.submitFlow("recovery", "f 1", { body: "code=123456&method=code", cookie: "csrf_token=abc" });
|
||||
assert.deepEqual(out, { location: "http://app/settings?flow=s1", setCookie: ["plainpages_session=s; Path=/"], status: 303 });
|
||||
assert.equal(calls[0]!.url, `${BASE}/self-service/recovery?flow=f+1`);
|
||||
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]!.body, "identifier=a&password=b");
|
||||
assert.equal(calls[0]!.headers.get("cookie"), "csrf_token=abc");
|
||||
assert.equal(calls[0]!.body, "code=123456&method=code");
|
||||
});
|
||||
|
||||
test("submitFlow returns the re-rendered flow (no throw) on a 400 validation error", async () => {
|
||||
const flow = { id: "f", ui: { action: "x", messages: [{ id: 4000006, text: "invalid credentials", type: "error" }], method: "POST", nodes: [] } };
|
||||
const { fetchImpl } = recorder(() => res(400, flow));
|
||||
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("submitFlow reports a 4xx without a redirect as a null location, and throws on a Kratos 5xx", async () => {
|
||||
const k = (status: number) => createKratosPublic({ baseUrl: BASE, fetchImpl: recorder(() => res(status, { error: "x" })).fetchImpl });
|
||||
assert.deepEqual(await k(400).submitFlow("verification", "f", { body: "x=1" }), { location: null, setCookie: [], status: 400 });
|
||||
await assert.rejects(k(500).submitFlow("verification", "f", { body: "x=1" }), (e: unknown) => e instanceof KratosError && e.status === 500);
|
||||
});
|
||||
|
||||
test("whoami returns the session on 200 (cookie forwarded) and null on 401", async () => {
|
||||
|
||||
+13
-28
@@ -21,7 +21,7 @@ export interface UiNode {
|
||||
}
|
||||
|
||||
export interface FlowUi {
|
||||
action: string; // absolute Kratos URL the browser POSTs the form to (Kratos owns its CSRF)
|
||||
action: string; // absolute Kratos URL the form submits to
|
||||
messages?: UiText[];
|
||||
method: string;
|
||||
nodes: UiNode[];
|
||||
@@ -51,9 +51,7 @@ export interface LogoutFlow {
|
||||
}
|
||||
|
||||
export interface FlowSubmission {
|
||||
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
|
||||
location: string | null; // where Kratos sends the browser next
|
||||
setCookie: string[];
|
||||
status: number;
|
||||
}
|
||||
@@ -74,23 +72,10 @@ export interface KratosPublic {
|
||||
createLogoutFlow(opts?: { cookie?: string }): Promise<LogoutFlow | null>; // null ⇒ no active session (401)
|
||||
getFlow(type: FlowType, id: string, opts?: { cookie?: string }): Promise<Flow>;
|
||||
initBrowserFlow(type: FlowType, opts?: { cookie?: string; returnTo?: string }): Promise<FlowInit>;
|
||||
submitFlow(action: string, opts: { body: string; contentType?: string; cookie?: string }): Promise<FlowSubmission>;
|
||||
submitFlow(type: FlowType, flowId: string, opts: { body: string; cookie?: string }): Promise<FlowSubmission>;
|
||||
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 {
|
||||
const base = config.baseUrl.replace(/\/+$/, "");
|
||||
const http = config.fetchImpl ?? fetch;
|
||||
@@ -129,16 +114,16 @@ export function createKratosPublic(config: { baseUrl: string; fetchImpl?: typeof
|
||||
return (await res.json()) as Flow;
|
||||
},
|
||||
|
||||
async submitFlow(action, opts) {
|
||||
const h = headers(opts.cookie);
|
||||
h["content-type"] = opts.contentType ?? "application/x-www-form-urlencoded";
|
||||
// Manual redirect so we can read a 303 Location instead of following it server-side.
|
||||
const res = await http(action, { body: opts.body, headers: h, method: "POST", redirect: "manual" });
|
||||
const body = parseBody(await res.text());
|
||||
const location =
|
||||
res.headers.get("location") ??
|
||||
(isRecord(body) && typeof body["redirect_browser_to"] === "string" ? body["redirect_browser_to"] : null);
|
||||
return { body, location, ok: res.status === 200, setCookie: res.headers.getSetCookie(), status: res.status };
|
||||
async submitFlow(type, flowId, opts) {
|
||||
const url = new URL(`${base}/self-service/${type}`);
|
||||
url.searchParams.set("flow", flowId);
|
||||
// Asked for HTML, Kratos answers with the redirect a browser posting to it would get. Its CSRF
|
||||
// check demands a same-origin Referer on an https request, and this server-side POST has none.
|
||||
const h = { ...headers(opts.cookie), accept: "text/html", "content-type": "application/x-www-form-urlencoded", referer: `${base}/` };
|
||||
const res = await http(url, { body: opts.body, headers: h, method: "POST", redirect: "manual" });
|
||||
if (res.status >= 500) throw new KratosError(`Kratos submit ${type} flow failed (${res.status})`, res.status, await res.text());
|
||||
await res.body?.cancel();
|
||||
return { location: res.headers.get("location"), setCookie: res.headers.getSetCookie(), status: res.status };
|
||||
},
|
||||
|
||||
async whoami(opts = {}) {
|
||||
|
||||
@@ -16,6 +16,7 @@ const ketoStub = (over: Partial<KetoClient> = {}): KetoClient => ({
|
||||
deleteTuple: async () => {},
|
||||
expand: async () => ({ type: "leaf" }),
|
||||
listRelations: async () => ({ nextPageToken: null, tuples: [] }),
|
||||
transactTuples: async () => {},
|
||||
writeTuple: async () => {},
|
||||
...over,
|
||||
});
|
||||
|
||||
+3
-3
@@ -37,9 +37,9 @@ export interface CompletedLogin {
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
// The coarse permissions a user holds — directly (`Permission:<name>#members@user:<id>`) or transitively via a
|
||||
// group that is a member of the permission. Enumerates the defined permissions (the distinct objects in the Permission
|
||||
// namespace) and asks Keto to resolve each membership, so a permission granted to a group reaches the JWT —
|
||||
// The coarse permissions a user holds — directly (`Permission:<name>#granted@user:<id>`) or transitively via a
|
||||
// group granted the permission. Enumerates the defined permissions (the distinct objects in the Permission
|
||||
// namespace) and asks Keto to check each grant, so a permission granted to a group reaches the JWT —
|
||||
// matching the OPL model and the admin "Effective access" view. At login/refresh only, never per
|
||||
// request; permission count is small, so the per-permission checks are cheap and run in parallel.
|
||||
export async function readPermissions(keto: KetoClient, userId: string): Promise<string[]> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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
|
||||
// covered end-to-end through the app in src/http/app.test.ts.
|
||||
// covered end-to-end through the app in src/http/app-auth.test.ts.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { AUTH_FLOWS } from "./flow-view.ts";
|
||||
@@ -26,12 +26,14 @@ test("nothing wired ⇒ only the flow-error sink exists", () => {
|
||||
assert.deepEqual(keys(buildAuthRoutes(deps({}))), ["GET /error"]);
|
||||
});
|
||||
|
||||
test("kratos wired ⇒ the themed flow pages + POST /logout; no OAuth2, no /auth/complete", () => {
|
||||
test("kratos wired ⇒ the themed flow pages, the two code-flow submits + POST /logout; no OAuth2, no /auth/complete", () => {
|
||||
const got = keys(buildAuthRoutes(deps({ kratos })));
|
||||
assert.deepEqual(got, keys([
|
||||
...Object.keys(AUTH_FLOWS).map((path) => ({ method: "GET", path })),
|
||||
{ method: "GET", path: "/error" },
|
||||
{ method: "POST", path: "/logout" },
|
||||
{ method: "POST", path: "/recovery" },
|
||||
{ method: "POST", path: "/verification" },
|
||||
]));
|
||||
});
|
||||
|
||||
|
||||
+77
-39
@@ -1,18 +1,18 @@
|
||||
// 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, the Hydra OAuth2
|
||||
// (src/http/builtin-route.ts): the themed Kratos self-service pages and their code submits, the Hydra OAuth2
|
||||
// login/consent/logout challenges, login completion, the first-party logout, and Kratos'
|
||||
// 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.
|
||||
import { readFormBody } from "../http/body.ts";
|
||||
import type { BuiltinRoute, RequestCsrf } from "../http/builtin-routes.ts";
|
||||
import type { BuiltinRoute, RequestCsrf } from "../http/builtin-route.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import { CSRF_FIELD } from "./csrf.ts";
|
||||
import { chosenLocale } from "../i18n/locale.ts";
|
||||
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
|
||||
import { AUTH_FLOWS, buildFlowView, CODE_FLOWS } from "./flow-view.ts";
|
||||
import { HydraError, type HydraAdmin } from "./hydra-admin.ts";
|
||||
import type { KetoClient } from "./keto-client.ts";
|
||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
||||
import { type Flow, type FlowType, KratosError, type KratosPublic } from "./kratos-public.ts";
|
||||
import { type Flow, type FlowSubmission, type FlowType, KratosError, type KratosPublic } from "./kratos-public.ts";
|
||||
import { clearSessionCookie, completeLogin, sessionCookie } from "./login.ts";
|
||||
import type { MenuConfig } from "../ui/menu-config.ts";
|
||||
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
|
||||
@@ -38,52 +38,36 @@ const FORBIDDEN: RouteResult = { status: 403, view: "403" };
|
||||
const selfOrigin = (ctx: RequestContext, secure: boolean): string =>
|
||||
`${secure ? "https" : "http"}://${ctx.req.headers.host ?? "127.0.0.1:3000"}`;
|
||||
|
||||
// Themed Kratos self-service page (one route per AUTH_FLOWS entry: login/registration/recovery/
|
||||
// verification/settings).
|
||||
function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] {
|
||||
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
|
||||
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
|
||||
if (ctx.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
|
||||
const cookie = ctx.req.headers.cookie;
|
||||
const flowId = ctx.url.searchParams.get("flow");
|
||||
// Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to
|
||||
// the catch-all 500 (with a stack), not the "Ory unreachable" 503.
|
||||
let flow: Flow;
|
||||
try {
|
||||
if (!flowId) {
|
||||
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
|
||||
// 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".
|
||||
// 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);
|
||||
let returnTo: string | undefined;
|
||||
if (local || chosen) {
|
||||
// The flow's return target is the host's, not Kratos' — so the language the visitor picked
|
||||
// on the sign-in page survives the round-trip through Kratos and lands on the page after
|
||||
// it. Without this the most-travelled path in the product (pick Swedish → sign in) drops
|
||||
// straight back to Accept-Language.
|
||||
if (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);
|
||||
returnTo = complete.toString();
|
||||
} else if (raw) returnTo = raw;
|
||||
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
|
||||
if (setCookie.length) ctx.res.appendHeader("set-cookie", setCookie);
|
||||
return { redirect: `${pathname}?flow=${initiated.id}` };
|
||||
return complete.toString();
|
||||
}
|
||||
flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {});
|
||||
} catch (err) {
|
||||
|
||||
// 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 above can't fire). Initialising a login/
|
||||
// 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")) {
|
||||
@@ -98,6 +82,31 @@ function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boole
|
||||
}
|
||||
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/
|
||||
// verification/settings).
|
||||
function flowPage(kratos: KratosPublic, flowType: FlowType, secureCookies: boolean): BuiltinRoute["handler"] {
|
||||
return async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult> => {
|
||||
// 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.)
|
||||
if (ctx.user && (flowType === "login" || flowType === "registration")) return { redirect: "/dashboard" };
|
||||
const cookie = ctx.req.headers.cookie;
|
||||
const flowId = ctx.url.searchParams.get("flow");
|
||||
// Only the Kratos calls are in the try, so a render/buildFlowView bug below falls through to
|
||||
// the catch-all 500 (with a stack), not the "Ory unreachable" 503.
|
||||
let flow: Flow;
|
||||
try {
|
||||
if (!flowId) {
|
||||
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
|
||||
const returnTo = flowReturnTo(ctx, secureCookies);
|
||||
const { flow: initiated, setCookie } = await kratos.initBrowserFlow(flowType, { ...(cookie ? { cookie } : {}), ...(returnTo ? { returnTo } : {}) });
|
||||
if (setCookie.length) ctx.res.appendHeader("set-cookie", setCookie);
|
||||
return { redirect: `${ctx.url.pathname}?flow=${initiated.id}` };
|
||||
}
|
||||
flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {});
|
||||
} catch (err) {
|
||||
return flowFailure(err, ctx, flowType);
|
||||
}
|
||||
// Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the
|
||||
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
|
||||
csrf.setCookie();
|
||||
@@ -105,6 +114,34 @@ 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. `submitFlow` throws only on a 5xx or an unreachable Kratos, so only
|
||||
// `flowFailure`'s 503 branch can fire 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.
|
||||
// Resolve it via the Kratos session and accept; an unauthenticated user bounces to our themed
|
||||
// login and returns here once signed in. Provider-only.
|
||||
@@ -241,6 +278,7 @@ export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secure
|
||||
if (kratos) {
|
||||
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
|
||||
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path, public: true });
|
||||
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 });
|
||||
}
|
||||
|
||||
+7
-2
@@ -29,14 +29,19 @@ test("the commit-hash image is pushed even when the gate no-ops", () => {
|
||||
assert.doesNotMatch(step("docker push"), /^\s*if:/m);
|
||||
});
|
||||
|
||||
test("every E2E suite the gate runs writes its artifacts as the invoking user", () => {
|
||||
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, 2, "the suite helper and the devstack run");
|
||||
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", () => {
|
||||
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");
|
||||
|
||||
+3
-2
@@ -110,7 +110,8 @@ test("a one-shot bootstrap seeds the stack before web starts", () => {
|
||||
});
|
||||
|
||||
test("deps live above WORKDIR, so no mount creates a root-owned dir in the checkout", () => {
|
||||
// A volume at /app/node_modules would leave a root-owned dir in the checkout (AGENTS.md).
|
||||
// A volume at /app/node_modules would leave a root-owned dir in the checkout
|
||||
// (docs/decisions.md → "Dependencies install to `/node_modules`, above `WORKDIR /app`").
|
||||
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");
|
||||
@@ -129,7 +130,7 @@ test("the E2E runner writes its artifacts as the invoking user, never as root",
|
||||
// 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, 6, "5 compose headers + 1 README block");
|
||||
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.
|
||||
|
||||
@@ -39,6 +39,23 @@ test("PLUGIN_DB_URL carrying credentials is refused, not silently overwritten",
|
||||
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);
|
||||
@@ -80,6 +97,13 @@ test("SERVICE_NAME is overridable so an implementer brands their own logs/traces
|
||||
assert.equal(loadConfig({ SERVICE_NAME: "" }).serviceName, "plainpages"); // empty ⇒ default
|
||||
});
|
||||
|
||||
test("TRUSTED_PROXIES: a comma-separated list of IPs/CIDRs, empty by default, a bad entry refuses the boot", () => {
|
||||
assert.deepEqual(loadConfig({}).trustedProxies, []); // trust nobody's X-Forwarded-For unless told
|
||||
assert.deepEqual(loadConfig({ TRUSTED_PROXIES: "" }).trustedProxies, []); // compose passes ${TRUSTED_PROXIES:-}
|
||||
assert.deepEqual(loadConfig({ TRUSTED_PROXIES: "10.0.0.0/8, ::1" }).trustedProxies, ["10.0.0.0/8", "::1"]);
|
||||
assert.throws(() => loadConfig({ TRUSTED_PROXIES: "10.0.0.1,proxy.internal" }), /TRUSTED_PROXIES/);
|
||||
});
|
||||
|
||||
test("LOG_LEVEL/LOG_FORMAT/OTLP_PROTOCOL are validated enums; OTLP_ENDPOINT an optional URL", () => {
|
||||
assert.equal(loadConfig({ LOG_LEVEL: "debug" }).logLevel, "debug");
|
||||
assert.equal(loadConfig({ LOG_LEVEL: "none" }).logLevel, "none");
|
||||
|
||||
+22
-12
@@ -2,6 +2,8 @@
|
||||
// secret, a bad URL or an out-of-range port throws here, never at request time. Every value has a
|
||||
// working dev default, so `docker compose up` runs with zero config.
|
||||
|
||||
import { parseTrustedProxies } from "./http/request-fields.ts";
|
||||
|
||||
// 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 type LogLevel = (typeof LOG_LEVELS)[number];
|
||||
@@ -61,6 +63,7 @@ export interface Config {
|
||||
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
|
||||
secureCookies: boolean;
|
||||
serviceName: string; // OTLP service.name — an implementer brands their own logs/traces
|
||||
trustedProxies: string[]; // IPs/CIDRs whose X-Forwarded-For names the caller; empty ⇒ the socket peer is the caller
|
||||
}
|
||||
|
||||
type Env = Record<string, string | undefined>;
|
||||
@@ -98,26 +101,27 @@ 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}"`);
|
||||
}
|
||||
|
||||
// An optional absolute URL: unset/empty ⇒ undefined; a set-but-malformed value fails at boot.
|
||||
function readOptionalUrl(env: Env, key: string): string | undefined {
|
||||
const value = env[key] || undefined;
|
||||
if (value === undefined) return undefined;
|
||||
// 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} is not a valid URL: ${value}`);
|
||||
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.
|
||||
function readOptionalUrl(env: Env, key: string): string | undefined {
|
||||
const value = env[key] || undefined;
|
||||
if (value !== undefined) assertUrl(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
// An absolute URL: defaults to the Ory service; validated so a typo fails at boot.
|
||||
function readUrl(env: Env, key: string, devDefault: string): string {
|
||||
const value = env[key] ?? devDefault;
|
||||
try {
|
||||
new URL(value);
|
||||
} catch {
|
||||
throw new Error(`config: ${key} is not a valid URL: ${value}`);
|
||||
}
|
||||
assertUrl(key, value);
|
||||
return value;
|
||||
}
|
||||
|
||||
@@ -148,6 +152,12 @@ function readPosInt(env: Env, key: string, devDefault: number): number {
|
||||
return n;
|
||||
}
|
||||
|
||||
function readTrustedProxies(env: Env): string[] {
|
||||
const entries = (env["TRUSTED_PROXIES"] ?? "").split(",").map((entry) => entry.trim()).filter(Boolean);
|
||||
parseTrustedProxies(entries); // throws on a bad entry; the app parses the list again
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function loadConfig(env: Env = process.env): Config {
|
||||
const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false);
|
||||
return {
|
||||
@@ -175,8 +185,7 @@ export function loadConfig(env: Env = process.env): Config {
|
||||
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),
|
||||
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
|
||||
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
|
||||
// observability. Console-only by default (clean clone). Setting OTLP_ENDPOINT to an
|
||||
// OpenTelemetry Collector exports structured logs + per-request spans there (Loki/Tempo).
|
||||
// Console-only unless OTLP_ENDPOINT names a collector.
|
||||
logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"),
|
||||
logLevel: readEnum(env, "LOG_LEVEL", LOG_LEVELS, "info"),
|
||||
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
|
||||
@@ -198,5 +207,6 @@ export function loadConfig(env: Env = process.env): Config {
|
||||
// Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it.
|
||||
secureCookies: readBool(env, "SECURE_COOKIES", false),
|
||||
serviceName: env["SERVICE_NAME"] || "plainpages", // OTLP service.name; empty ⇒ default
|
||||
trustedProxies: readTrustedProxies(env),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
// The limits and the counting are ESLint's defaults: `max-lines-per-function` (blank lines and
|
||||
// comments count, an IIFE is not held to it) and `complexity`. Not ESLint itself: parsing TypeScript
|
||||
// takes typescript-eslint, which needs the compiler API TypeScript 7 no longer ships. So acorn parses
|
||||
// what Node's own type stripping leaves, which is the code Node runs.
|
||||
import assert from "node:assert/strict";
|
||||
import { globSync, readFileSync } from "node:fs";
|
||||
import { stripTypeScriptTypes } from "node:module";
|
||||
import { test } from "node:test";
|
||||
import { parse } from "acorn";
|
||||
|
||||
const LIMITS = { lines: 50, complexity: 20 };
|
||||
|
||||
// Each function over a limit, at its measurement. A ceiling follows its function down, and a metric
|
||||
// leaves the entry once it fits, so a function that shrank can't grow back.
|
||||
const grandfathered: Record<string, { lines?: number; complexity?: number }> = {
|
||||
"src/http/app.ts createApp": { lines: 144, complexity: 26 },
|
||||
"src/auth/flow-view.ts buildFlowView": { complexity: 24 },
|
||||
"src/auth/hydra-admin.ts createHydraAdmin": { lines: 87 },
|
||||
"src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 },
|
||||
"src/auth/kratos-public.ts createKratosPublic": { lines: 60 },
|
||||
"src/plugin-host/discovery.ts discoverPlugins": { lines: 74, complexity: 22 },
|
||||
"src/config.ts loadConfig": { lines: 52 },
|
||||
"release-tooling/dockerhub-overview.ts main": { lines: 57 },
|
||||
"examples/plugins/auth-admin/admin-users.ts buildUserFormModel": { complexity: 21 },
|
||||
};
|
||||
|
||||
interface AstNode {
|
||||
type: string;
|
||||
loc: { start: { line: number }; end: { line: number } };
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface Measured {
|
||||
name: string;
|
||||
lines: number;
|
||||
complexity: number;
|
||||
}
|
||||
|
||||
const isNode = (value: unknown): value is AstNode =>
|
||||
typeof value === "object" && value !== null && typeof (value as { type?: unknown }).type === "string";
|
||||
|
||||
const FUNCTIONS = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
|
||||
const BRANCHES = new Set([
|
||||
"IfStatement", "ConditionalExpression", "LogicalExpression", "ForStatement", "ForInStatement",
|
||||
"ForOfStatement", "WhileStatement", "DoWhileStatement", "CatchClause", "AssignmentPattern",
|
||||
]);
|
||||
|
||||
function branches(node: AstNode): number {
|
||||
if (BRANCHES.has(node.type)) return 1;
|
||||
if (node.type === "SwitchCase") return node.test ? 1 : 0;
|
||||
if (node.type === "AssignmentExpression") return ["&&=", "||=", "??="].includes(String(node.operator)) ? 1 : 0;
|
||||
if (node.type === "MemberExpression" || node.type === "CallExpression") return node.optional === true ? 1 : 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function nameOf(fn: AstNode, parent: AstNode | undefined): string {
|
||||
let id = fn.id;
|
||||
if (!id && parent?.type === "VariableDeclarator") id = parent.id;
|
||||
if (!id && (parent?.type === "Property" || parent?.type === "MethodDefinition")) id = parent.key;
|
||||
return isNode(id) && typeof id.name === "string" ? id.name : "anonymous";
|
||||
}
|
||||
|
||||
// A function is named by the path of names from the outermost one, e.g. `createApp > handleRequest`,
|
||||
// so an entry above doesn't depend on line numbers.
|
||||
function measure(source: string): Measured[] {
|
||||
const found: Measured[] = [];
|
||||
const visit = (node: AstNode, parent: AstNode | undefined, enclosing: Measured | undefined): void => {
|
||||
let current = enclosing;
|
||||
if (FUNCTIONS.has(node.type)) {
|
||||
const name = nameOf(node, parent);
|
||||
const iife = parent?.type === "CallExpression" && parent.callee === node;
|
||||
const lines = iife ? 0 : node.loc.end.line - node.loc.start.line + 1;
|
||||
current = { name: enclosing ? `${enclosing.name} > ${name}` : name, lines, complexity: 1 };
|
||||
found.push(current);
|
||||
} else if (current) {
|
||||
current.complexity += branches(node);
|
||||
}
|
||||
for (const value of Object.values(node)) {
|
||||
for (const child of [value].flat()) if (isNode(child)) visit(child, node, current);
|
||||
}
|
||||
};
|
||||
const program: unknown = parse(stripTypeScriptTypes(source), { ecmaVersion: "latest", sourceType: "module", locations: true });
|
||||
assert.ok(isNode(program));
|
||||
visit(program, undefined, undefined);
|
||||
return found;
|
||||
}
|
||||
|
||||
const root = new URL("..", import.meta.url);
|
||||
// The drop-in mounts hold an operator's code, not this repo's. Tests are left out: each is a flat
|
||||
// script, and a `describe` block wraps many of them.
|
||||
const files = globSync("**/*.ts", {
|
||||
cwd: root,
|
||||
exclude: ["**/node_modules/**", "plugins/**", "config/**", "locales/**", "**/*.d.ts", "**/*.test.ts", "**/*.spec.ts"],
|
||||
});
|
||||
// A list, not a map: two unnamed siblings share a key, and each must be checked.
|
||||
const measured = files.flatMap((file) =>
|
||||
measure(readFileSync(new URL(file, root), "utf8")).map((m) => ({ ...m, key: `${file} ${m.name}` })),
|
||||
);
|
||||
const METRICS = ["lines", "complexity"] as const;
|
||||
|
||||
test("measure counts lines and complexity as ESLint does", () => {
|
||||
const source = [
|
||||
"function outer(a = 1): void {",
|
||||
" if (a && b?.c) {}",
|
||||
" for (const x of y) {}",
|
||||
" for (const k in y) {}",
|
||||
" for (;;) break;",
|
||||
" while (a) a--;",
|
||||
" do {} while (a);",
|
||||
" try {} catch {}",
|
||||
" switch (a) { case 1: break; default: }",
|
||||
" a ||= 2; a &&= 3; a ??= 4;",
|
||||
" b?.();",
|
||||
" y.map(() => {});",
|
||||
" const inner = () => (a ? 1 : 2);",
|
||||
" const o = { prop() {}, key: function () {} };",
|
||||
" (function () { if (a) {} })();",
|
||||
"}",
|
||||
"class K { method() {} }",
|
||||
].join("\n");
|
||||
assert.deepEqual(measure(source), [
|
||||
{ name: "outer", lines: 16, complexity: 16 },
|
||||
{ name: "outer > anonymous", lines: 1, complexity: 1 },
|
||||
{ name: "outer > inner", lines: 1, complexity: 2 },
|
||||
{ name: "outer > prop", lines: 1, complexity: 1 },
|
||||
{ name: "outer > key", lines: 1, complexity: 1 },
|
||||
{ name: "outer > anonymous", lines: 0, complexity: 2 },
|
||||
{ name: "method", lines: 1, complexity: 1 },
|
||||
]);
|
||||
});
|
||||
|
||||
test(`every function fits ${LIMITS.lines} lines and complexity ${LIMITS.complexity}, or its grandfathered ceiling`, () => {
|
||||
assert.ok(files.length > 50, "found too few files: is the glob broken?");
|
||||
const over = measured.flatMap((m) =>
|
||||
METRICS.filter((metric) => m[metric] > (grandfathered[m.key]?.[metric] ?? LIMITS[metric])).map(
|
||||
(metric) => `${m.key}: ${metric} ${m[metric]}`,
|
||||
),
|
||||
);
|
||||
assert.deepEqual(over, [], "split the function; never raise a ceiling");
|
||||
});
|
||||
|
||||
test("every grandfathered ceiling equals its function's measurement, which is over the limit", () => {
|
||||
const stale = Object.entries(grandfathered).flatMap(([key, ceiling]) => {
|
||||
const found = measured.filter((m) => m.key === key);
|
||||
if (found.length === 0) return [`${key}: no such function; rename the entry if it moved, else delete it`];
|
||||
return METRICS.flatMap((metric) => {
|
||||
const allowed = ceiling[metric];
|
||||
if (allowed === undefined) return [];
|
||||
const now = Math.max(...found.map((m) => m[metric]));
|
||||
if (now <= LIMITS[metric]) return [`${key}: ${metric} ${now} fits; delete it`];
|
||||
return now < allowed ? [`${key}: ${metric} ${now}; lower the ceiling to it`] : [];
|
||||
});
|
||||
});
|
||||
assert.deepEqual(stale, []);
|
||||
});
|
||||
@@ -0,0 +1,695 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test, type TestContext } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createApp, type AppOptions } from "./app.ts";
|
||||
import { createDenylist } from "../auth/denylist.ts";
|
||||
import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
|
||||
import { staticJwks } from "../auth/jwks.ts";
|
||||
import type { OAuth2Client } from "../auth/hydra-admin.ts";
|
||||
import type { RelationTuple } from "../auth/keto-client.ts";
|
||||
import type { Identity } from "../auth/kratos-admin.ts";
|
||||
import { KratosError } from "../auth/kratos-public.ts";
|
||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import adminManifest from "../../examples/plugins/auth-admin/plugin.ts";
|
||||
import { createI18n } from "../i18n/runtime.ts";
|
||||
import { loadI18n } from "../i18n/load.ts";
|
||||
import { ecJwk, fakeKeto, mintJwt, stubAdmin, stubHydra } from "../test-tooling/app-harness.ts";
|
||||
|
||||
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
|
||||
// ctx.system, views from examples/plugins — exactly as an operator would after copying it in.
|
||||
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
|
||||
const adminPlugin: Plugin = { ...adminManifest, id: "auth-admin" };
|
||||
|
||||
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
|
||||
// CSRF cookie. get(path, permissions)/post(path, body) carry them; `token` is the matching CSRF field.
|
||||
const ADMIN_CSRF = "admin-secret";
|
||||
async function adminHarness(t: TestContext, opts: AppOptions = {}, sub = "admin1") {
|
||||
// Mount the plugin's catalogs the way server.ts does, so its screens render words, not keys.
|
||||
const i18n = createI18n(await loadI18n({ pluginIds: [adminPlugin.id], pluginsDir: examplesPluginsDir }));
|
||||
const app = createApp({ csrfSecret: ADMIN_CSRF, i18n, jwks: staticJwks([ecJwk]), pluginsDir: examplesPluginsDir, plugins: [adminPlugin], ...opts });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const token = issueCsrfToken(ADMIN_CSRF);
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const cookie = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub })}; ${CSRF_COOKIE}=${token}`;
|
||||
const get = (path: string, permissions: string[] = ADMIN_ALL) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
|
||||
const post = (path: string, body: string) =>
|
||||
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(ADMIN_ALL) }, method: "POST", redirect: "manual" });
|
||||
return { get, post, token, url };
|
||||
}
|
||||
// What the plugin itself declares — the harness holds every screen's read and write, so a screen
|
||||
// test exercises the screen rather than the gate. assertAdminGate covers the refusals.
|
||||
const ADMIN_ALL = (adminManifest.permissions ?? []).map((p) => p.name);
|
||||
// The permission form's concurrency token, read off the page the way a browser would post it back.
|
||||
const baselineOf = (html: string): string => /name="baseline" value="([0-9a-f]+)"/.exec(html)?.[1] ?? "";
|
||||
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
|
||||
async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
|
||||
const anon = await fetch(url + path, { redirect: "manual" });
|
||||
assert.equal(anon.status, 303);
|
||||
assert.equal(anon.headers.get("location"), `/login?return_to=${encodeURIComponent(path)}`); // remembers the page
|
||||
assert.equal((await get(path, [])).status, 403);
|
||||
}
|
||||
|
||||
|
||||
// Built-in Users admin screen: gate + every CRUD action over HTTP against a mock Kratos admin.
|
||||
test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => {
|
||||
const mk = (email: string, over: Partial<Identity> = {}): Identity =>
|
||||
({ id: randomUUID(), schema_id: "default", state: "active", traits: { email, name: { first: "Ada", last: "Lovelace" } }, ...over });
|
||||
const store: Identity[] = [mk("ada@example.com"), mk("babbage@example.com", { state: "inactive" }), mk("you@example.com", { id: "admin1" })];
|
||||
let lastCreate: { traits?: unknown } | undefined;
|
||||
const kratosAdmin = stubAdmin({
|
||||
createIdentity: async (payload) => { lastCreate = payload as { traits?: unknown }; const created = mk("grace@example.com"); store.push(created); return created; },
|
||||
createRecoveryCode: async (id) => ({ code: "123456", link: `http://kratos/self-service/recovery?flow=flow-${id}` }),
|
||||
deleteIdentity: async (id) => { const i = store.findIndex((x) => x.id === id); if (i >= 0) store.splice(i, 1); },
|
||||
getIdentity: async (id) => store.find((x) => x.id === id) ?? null,
|
||||
listIdentities: async () => ({ identities: store, nextPageToken: null }),
|
||||
updateIdentity: async (id, payload) => { const it = store.find((x) => x.id === id)!; Object.assign(it, payload); return it; },
|
||||
});
|
||||
const denylist = createDenylist(); // a deactivate/delete should revoke the target's live tokens instantly
|
||||
const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin });
|
||||
|
||||
await assertAdminGate(url, get, "/auth-admin/users");
|
||||
|
||||
// Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
|
||||
// by its own read permission — proving the drop-in nav fragment. A user holding only users:read
|
||||
// sees Users and nothing else; holding none of the three, composeNav drops the emptied header.
|
||||
assert.match(await (await get("/dashboard")).text(), /href="\/auth-admin\/users"/);
|
||||
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
|
||||
assert.match(usersOnlyNav, /href="\/auth-admin\/users"/);
|
||||
assert.doesNotMatch(usersOnlyNav, /href="\/auth-admin\/groups"/);
|
||||
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/auth-admin\/users"/);
|
||||
|
||||
// The read/write split: users:read opens the list but is refused on every mutation, and the
|
||||
// resources don't leak — a users holder is not a groups holder.
|
||||
assert.equal((await get("/auth-admin/users", ["users:read"])).status, 200);
|
||||
assert.equal((await get("/auth-admin/groups", ["users:read", "users:write"])).status, 403);
|
||||
const readOnlyPost = await fetch(url + "/auth-admin/users", {
|
||||
body: `_csrf=${token}&email=nope@example.com`,
|
||||
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${SESSION_COOKIE}=${mintJwt({ email: "r@x", exp: Math.floor(Date.now() / 1000) + 600, permissions: ["users:read"], sub: "reader1" })}; ${CSRF_COOKIE}=${token}` },
|
||||
method: "POST",
|
||||
redirect: "manual",
|
||||
});
|
||||
assert.equal(readOnlyPost.status, 403);
|
||||
assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false);
|
||||
|
||||
// List: the admin sees the rows + the "add" link; the status filter narrows server-side.
|
||||
const listHtml = await (await get("/auth-admin/users")).text();
|
||||
assert.match(listHtml, /ada@example\.com/);
|
||||
assert.match(listHtml, /href="\/auth-admin\/users\/new"/);
|
||||
assert.doesNotMatch(await (await get("/auth-admin/users?status=inactive")).text(), /ada@example\.com/);
|
||||
|
||||
// Create: the form renders; a valid post creates the identity and redirects to the list.
|
||||
assert.match(await (await get("/auth-admin/users/new")).text(), /Create user/);
|
||||
const created = await post("/auth-admin/users", `_csrf=${token}&email=grace%40example.com&first=Grace&last=Hopper&password=`);
|
||||
assert.equal(created.status, 303);
|
||||
assert.equal(created.headers.get("location"), "/auth-admin/users");
|
||||
assert.deepEqual(lastCreate?.traits, { email: "grace@example.com", name: { first: "Grace", last: "Hopper" } });
|
||||
|
||||
// A create with no CSRF token is refused and creates nothing.
|
||||
const before = store.length;
|
||||
assert.equal((await post("/auth-admin/users", "email=x%40y.z")).status, 403);
|
||||
assert.equal(store.length, before);
|
||||
|
||||
// CSRF also guards a POST to an *existing* target (the per-route :id handlers), not just the
|
||||
// collection: a delete with no token is refused (403) and removes nothing.
|
||||
assert.equal((await post(`/auth-admin/users/${store[1]!.id}/delete`, "")).status, 403);
|
||||
assert.ok(store.some((x) => x.id === store[1]!.id));
|
||||
|
||||
// Edit: email is read-only + prefilled; a post rewrites the name.
|
||||
const target = store[0]!;
|
||||
const editHtml = await (await get(`/auth-admin/users/${target.id}`)).text();
|
||||
assert.match(editHtml, /name="email"[^>]*readonly/);
|
||||
assert.match(editHtml, /value="ada@example\.com"/);
|
||||
const updated = await post(`/auth-admin/users/${target.id}`, `_csrf=${token}&first=Ada&last=King`);
|
||||
assert.equal(updated.status, 303);
|
||||
assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" });
|
||||
|
||||
// Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once.
|
||||
await post(`/auth-admin/users/${target.id}/state`, `_csrf=${token}`);
|
||||
assert.equal(target.state, "inactive");
|
||||
assert.equal(denylist.isRevoked(target.id, 0), true);
|
||||
|
||||
// Recovery: renders the edit page (200) showing the generated code, linked to the flow it was minted on.
|
||||
const rec = await post(`/auth-admin/users/${target.id}/recovery`, `_csrf=${token}`);
|
||||
assert.equal(rec.status, 200);
|
||||
const recHtml = await rec.text();
|
||||
assert.match(recHtml, /Recovery code generated/);
|
||||
assert.match(recHtml, /<code>123456<\/code>/);
|
||||
assert.match(recHtml, new RegExp(`<a href="http://kratos/self-service/recovery\\?flow=flow-${target.id}">`));
|
||||
|
||||
// Delete needs a deliberate confirm step (zero-JS): GET renders the interstitial, POST performs it.
|
||||
const confirm = await (await get(`/auth-admin/users/${target.id}/delete`)).text();
|
||||
assert.match(confirm, /Cancel/);
|
||||
assert.match(confirm, new RegExp(`action="/auth-admin/users/${target.id}/delete"`));
|
||||
const del = await post(`/auth-admin/users/${target.id}/delete`, `_csrf=${token}`);
|
||||
assert.equal(del.status, 303);
|
||||
assert.ok(!store.some((x) => x.id === target.id));
|
||||
|
||||
// Self-protection: an admin can't delete or deactivate their own account (JWT sub = admin1).
|
||||
assert.equal((await post(`/auth-admin/users/admin1/delete`, `_csrf=${token}`)).status, 400);
|
||||
assert.ok(store.some((x) => x.id === "admin1"));
|
||||
assert.equal((await post(`/auth-admin/users/admin1/state`, `_csrf=${token}`)).status, 400);
|
||||
assert.equal(store.find((x) => x.id === "admin1")!.state, "active");
|
||||
|
||||
// Unknown id → 404; malformed %-encoding → 404 (not a 500), matching groups/permissions/clients.
|
||||
assert.equal((await get(`/auth-admin/users/${randomUUID()}`)).status, 404);
|
||||
assert.equal((await get("/auth-admin/users/%ZZ")).status, 404);
|
||||
});
|
||||
|
||||
// Built-in Groups admin screen: gate + list/create/membership/delete over HTTP against a
|
||||
// fakeKeto (tuples are the only state) and a stub Kratos admin (resolves member emails).
|
||||
test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-guarded)", async (t) => {
|
||||
const ada = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b01";
|
||||
const grace = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b02";
|
||||
const identities: Identity[] = [
|
||||
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } },
|
||||
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } },
|
||||
];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }];
|
||||
const keto = fakeKeto(tuples);
|
||||
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const { get, post, token, url } = await adminHarness(t, { keto, kratosAdmin });
|
||||
|
||||
await assertAdminGate(url, get, "/auth-admin/groups");
|
||||
|
||||
// List: the existing group shows + the "add" link.
|
||||
const listHtml = await (await get("/auth-admin/groups")).text();
|
||||
assert.match(listHtml, /href="\/auth-admin\/groups\/eng"/);
|
||||
assert.match(listHtml, /href="\/auth-admin\/groups\/new"/);
|
||||
|
||||
// Create: the form renders; a valid post writes the first-member tuple and redirects to the detail.
|
||||
assert.match(await (await get("/auth-admin/groups/new")).text(), /Create group/);
|
||||
const created = await post("/auth-admin/groups", `_csrf=${token}&name=design&member=user:${grace}`);
|
||||
assert.equal(created.status, 303);
|
||||
assert.equal(created.headers.get("location"), "/auth-admin/groups/design");
|
||||
assert.ok(tuples.some((tp) => tp.object === "design" && tp.subject_id === `user:${grace}`));
|
||||
|
||||
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written.
|
||||
const before = tuples.length;
|
||||
assert.equal((await post("/auth-admin/groups", `_csrf=${token}&name=Bad Name&member=user:${grace}`)).status, 400);
|
||||
const duplicate = await post("/auth-admin/groups?locale=sv-SE", `_csrf=${token}&name=eng&member=user:${grace}`);
|
||||
assert.equal(duplicate.status, 400);
|
||||
assert.match(await duplicate.text(), /Det finns redan en grupp med det namnet\./);
|
||||
assert.equal((await post("/auth-admin/groups", `name=x&member=user:${grace}`)).status, 403);
|
||||
assert.equal(tuples.length, before);
|
||||
|
||||
// Detail: lists the current member by email.
|
||||
assert.match(await (await get("/auth-admin/groups/eng")).text(), /ada@example\.com/);
|
||||
|
||||
// Add a member, then remove it.
|
||||
await post("/auth-admin/groups/eng/members", `_csrf=${token}&member=user:${grace}`);
|
||||
assert.ok(tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
||||
await post("/auth-admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
|
||||
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
|
||||
|
||||
// Give it a permission first, so the delete below has an orphan to avoid leaving behind.
|
||||
const permissionsBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
|
||||
await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${permissionsBaseline}&permission=users%3Aread`);
|
||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng"));
|
||||
|
||||
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
|
||||
assert.match(await (await get("/auth-admin/groups/eng/delete")).text(), /Cancel/);
|
||||
const bystanders: RelationTuple[] = [
|
||||
{ namespace: "Group", object: "ops", relation: "members", subject_id: `user:${ada}` },
|
||||
{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` },
|
||||
];
|
||||
tuples.push(...bystanders);
|
||||
const del = await post("/auth-admin/groups/eng/delete", `_csrf=${token}`);
|
||||
assert.equal(del.status, 303);
|
||||
assert.equal(del.headers.get("location"), "/auth-admin/groups");
|
||||
assert.ok(!tuples.some((tp) => tp.object === "eng"));
|
||||
// …and the permissions it held go with it. A Keto set exists only through its tuples, so an
|
||||
// orphaned grant would resurrect the moment someone re-created a group with the same name.
|
||||
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.subject_set?.object === "eng"));
|
||||
assert.ok(bystanders.every((tp) => tuples.includes(tp)), "only the deleted group's own tuples go");
|
||||
|
||||
// An invalid group name in the path → 404; malformed %-encoding doesn't 500.
|
||||
assert.equal((await get("/auth-admin/groups/Bad%20Name")).status, 404);
|
||||
assert.equal((await get("/auth-admin/groups/%ZZ")).status, 404);
|
||||
});
|
||||
|
||||
// Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
|
||||
// so the checkboxes are a fixed list and the POST is the desired state.
|
||||
test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
||||
const keto = fakeKeto(tuples);
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const denylist = createDenylist();
|
||||
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||
|
||||
// The user edit page renders one checkbox per declared permission, ticked where already held.
|
||||
const edit = await (await get(`/auth-admin/users/${ada}`)).text();
|
||||
for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) {
|
||||
assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name);
|
||||
}
|
||||
assert.match(edit, /value="users:read"[^>]*checked/); // held → ticked
|
||||
assert.doesNotMatch(edit, /value="groups:write"[^>]*checked/); // not held → unticked
|
||||
assert.doesNotMatch(edit, /Permissions saved/); // no confirmation before a save
|
||||
|
||||
// Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
|
||||
const baseline = baselineOf(edit);
|
||||
const saved = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||
assert.equal(saved.status, 303);
|
||||
assert.deepEqual(
|
||||
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
|
||||
["groups:read", "users:write"],
|
||||
);
|
||||
assert.equal(denylist.isRevoked(ada, 0), true); // a change to your own grants revokes live tokens
|
||||
|
||||
// The redirect lands on a page that confirms the save, and shows the new state.
|
||||
const landing = saved.headers.get("location") ?? "";
|
||||
assert.match(landing, new RegExp(`^/auth-admin/users/${ada}\\?info-msg=permissions-saved$`));
|
||||
const confirmed = await (await get(landing)).text();
|
||||
assert.match(confirmed, /Permissions saved/);
|
||||
assert.match(confirmed, /value="users:write"[^>]*checked/);
|
||||
|
||||
// A crafted POST can't grant something no plugin declares.
|
||||
await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(confirmed)}&permission=users%3Awrite&permission=superuser%3Aall`);
|
||||
assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
|
||||
|
||||
// The same picker on a group writes the group's subject_set, which Keto resolves transitively.
|
||||
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||
const groupBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
|
||||
const groupSaved = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`);
|
||||
assert.equal(groupSaved.headers.get("location"), "/auth-admin/groups/eng?info-msg=permissions-saved");
|
||||
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
|
||||
});
|
||||
|
||||
// Either half of a change reaches the user's live sessions, not just a save that does both.
|
||||
test("admin permission grants: a grant-only and a revoke-only user save each revoke live tokens", async (t) => {
|
||||
const [grantee, revokee] = [randomUUID(), randomUUID()];
|
||||
const identities: Identity[] = [{ id: grantee, traits: { email: "g@example.com" } }, { id: revokee, traits: { email: "r@example.com" } }];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${revokee}` }];
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const denylist = createDenylist();
|
||||
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
|
||||
const save = async (id: string, permissions: string) =>
|
||||
post(`/auth-admin/users/${id}/permissions`, `_csrf=${token}&baseline=${baselineOf(await (await get(`/auth-admin/users/${id}`)).text())}${permissions}`);
|
||||
|
||||
await save(grantee, "&permission=users%3Aread");
|
||||
assert.equal(denylist.isRevoked(grantee, 0), true);
|
||||
await save(revokee, "");
|
||||
assert.equal(denylist.isRevoked(revokee, 0), true);
|
||||
assert.equal(tuples.filter((tp) => tp.subject_id === `user:${revokee}`).length, 0);
|
||||
});
|
||||
|
||||
// Remove needs Keto, but not the revocation denylist.
|
||||
test("admin permission grants: Remove answers 503 without Keto, and still deletes where no denylist is wired", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const noKeto = await adminHarness(t, { kratosAdmin });
|
||||
assert.equal((await noKeto.post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${noKeto.token}&remove=old%3Awrite`)).status, 503);
|
||||
|
||||
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "old:write", relation: "granted", subject_id: `user:${ada}` }];
|
||||
const noDenylist = await adminHarness(t, { keto: fakeKeto(tuples), kratosAdmin });
|
||||
assert.equal((await noDenylist.post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${noDenylist.token}&remove=old%3Awrite`)).status, 303);
|
||||
assert.equal(tuples.length, 0);
|
||||
});
|
||||
|
||||
// Two admins open the same page; the second save would silently undo the first.
|
||||
test("admin permission grants: a save from a stale page is refused, and the page re-renders the current state", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
||||
const keto = fakeKeto(tuples);
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||
const stale = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text());
|
||||
|
||||
// Another admin grants users:write while this page is open.
|
||||
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
|
||||
const before = JSON.stringify(tuples);
|
||||
const refused = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=groups%3Aread`);
|
||||
assert.equal(refused.status, 409);
|
||||
const page = await refused.text();
|
||||
assert.match(page, /changed while you had the page open/);
|
||||
assert.equal(JSON.stringify(tuples), before, "nothing was applied");
|
||||
assert.match(page, /value="users:write"[^>]*checked/); // the re-render shows the other admin's grant
|
||||
assert.notEqual(baselineOf(page), stale);
|
||||
|
||||
// Saving from the re-rendered page goes through.
|
||||
const retried = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baselineOf(page)}&permission=users%3Aread&permission=users%3Awrite&permission=groups%3Aread`);
|
||||
assert.equal(retried.status, 303);
|
||||
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === `user:${ada}`));
|
||||
|
||||
// A POST with no token at all (a crafted request, or a form rendered before this check existed) is stale too.
|
||||
assert.equal((await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Aread`)).status, 409);
|
||||
|
||||
// Groups are checked the same way.
|
||||
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||
const groupStale = baselineOf(await (await get("/auth-admin/groups/eng")).text());
|
||||
tuples.push({ namespace: "Permission", object: "groups:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
|
||||
const groupRefused = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupStale}&permission=users%3Aread`);
|
||||
assert.equal(groupRefused.status, 409);
|
||||
assert.match(await groupRefused.text(), /changed while you had the page open/);
|
||||
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_set?.object === "eng"), "the group keeps its grant");
|
||||
});
|
||||
|
||||
test("admin permission grants: a Keto failure applies none of the changes and revokes no live token", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
||||
const keto = fakeKeto(tuples, { transactTuples: async () => { throw new Error("keto unreachable"); } });
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const denylist = createDenylist();
|
||||
const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
|
||||
const baseline = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text());
|
||||
|
||||
const res = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${baseline}&permission=groups%3Aread&permission=users%3Awrite`);
|
||||
assert.equal(res.status, 500);
|
||||
assert.deepEqual(tuples.map((tp) => tp.object), ["users:read"]);
|
||||
assert.equal(denylist.isRevoked(ada, 0), false);
|
||||
|
||||
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
|
||||
const groupBaseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
|
||||
const groupRes = await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${groupBaseline}&permission=groups%3Aread`);
|
||||
assert.equal(groupRes.status, 500);
|
||||
assert.equal(tuples.filter((tp) => tp.subject_set?.object === "eng").length, 0);
|
||||
});
|
||||
|
||||
test("admin users: a refused self-deactivate or self-delete keeps the permissions section", async (t) => {
|
||||
const identities: Identity[] = [{ id: "admin1", traits: { email: "admin@x" } }];
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const { get, post, token } = await adminHarness(t, { keto: fakeKeto(), kratosAdmin });
|
||||
assert.notEqual(baselineOf(await (await get("/auth-admin/users/admin1")).text()), "", "the plain form carries the section");
|
||||
|
||||
const refusals: [Response, RegExp][] = [
|
||||
[await post("/auth-admin/users/admin1/state", `_csrf=${token}`), /deactivate your own account/],
|
||||
[await get("/auth-admin/users/admin1/delete"), /delete your own account/],
|
||||
[await post("/auth-admin/users/admin1/delete", `_csrf=${token}`), /delete your own account/],
|
||||
];
|
||||
for (const [res, reason] of refusals) {
|
||||
assert.equal(res.status, 400);
|
||||
const html = await res.text();
|
||||
assert.match(html, reason);
|
||||
assert.notEqual(baselineOf(html), "");
|
||||
}
|
||||
});
|
||||
|
||||
test("admin users: a Kratos rejection of a save re-renders the form with its permissions, any other failure is a 500", async (t) => {
|
||||
const identities: Identity[] = [{ id: "ada", traits: { email: "ada@example.com" } }];
|
||||
let failure: Error = new KratosError("rejected", 400, "");
|
||||
const kratosAdmin = stubAdmin({
|
||||
getIdentity: async (id) => identities.find((i) => i.id === id) ?? null,
|
||||
listIdentities: async () => ({ identities, nextPageToken: null }),
|
||||
updateIdentity: async () => { throw failure; },
|
||||
});
|
||||
const { post, token } = await adminHarness(t, { keto: fakeKeto(), kratosAdmin });
|
||||
|
||||
const rejected = await post("/auth-admin/users/ada", `_csrf=${token}&first=Ada`);
|
||||
assert.equal(rejected.status, 400);
|
||||
assert.notEqual(baselineOf(await rejected.text()), "");
|
||||
failure = new Error("kratos unreachable");
|
||||
assert.equal((await post("/auth-admin/users/ada", `_csrf=${token}&first=Ada`)).status, 500);
|
||||
});
|
||||
|
||||
test("admin group delete: a Keto failure leaves the group's grants and members in place", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const tuples: RelationTuple[] = [
|
||||
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` },
|
||||
{ namespace: "Permission", object: "users:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } },
|
||||
];
|
||||
const keto = fakeKeto(tuples, { transactTuples: async () => { throw new Error("keto unreachable"); } });
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const { post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||
|
||||
const res = await post("/auth-admin/groups/eng/delete", `_csrf=${token}`);
|
||||
assert.equal(res.status, 500);
|
||||
assert.equal(tuples.length, 2);
|
||||
});
|
||||
|
||||
// A double-clicked Save posts twice from one page; the second post finds the first one's result.
|
||||
// A save that changes nothing has nothing to refuse and nothing to revoke.
|
||||
test("admin permission grants: a save that changes nothing is not refused for a stale page and leaves live tokens alone", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const denylist = createDenylist();
|
||||
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
|
||||
const stale = baselineOf(await (await get(`/auth-admin/users/${ada}`)).text());
|
||||
tuples.push({ namespace: "Permission", object: "users:write", relation: "granted", subject_id: `user:${ada}` });
|
||||
const before = JSON.stringify(tuples);
|
||||
|
||||
const repeated = await post(`/auth-admin/users/${ada}/permissions`, `_csrf=${token}&baseline=${stale}&permission=users%3Aread&permission=users%3Awrite`);
|
||||
assert.equal(repeated.status, 303);
|
||||
assert.equal(JSON.stringify(tuples), before);
|
||||
assert.equal(denylist.isRevoked(ada, 0), false);
|
||||
});
|
||||
|
||||
test("admin permission grants: a grant no plugin declares is listed with a Remove, which deletes it and revokes live tokens", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const held = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Permission", object, relation: "granted", ...subject });
|
||||
const eng = { subject_set: { namespace: "Group", object: "eng", relation: "members" } };
|
||||
const tuples: RelationTuple[] = [
|
||||
held("users:read", { subject_id: `user:${ada}` }), held("old:write", { subject_id: `user:${ada}` }),
|
||||
held("old:write", eng), held("old:write", { subject_id: "user:someone-else" }),
|
||||
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` },
|
||||
];
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const denylist = createDenylist();
|
||||
const { get, post, token } = await adminHarness(t, { denylist, keto: fakeKeto(tuples), kratosAdmin });
|
||||
|
||||
const page = await (await get(`/auth-admin/users/${ada}`)).text();
|
||||
assert.match(page, /Permissions no installed plugin declares/);
|
||||
assert.match(page, new RegExp(`action="/auth-admin/users/${ada}/permissions/remove#permissions-h"`));
|
||||
assert.match(page, /name="remove" value="old:write"/);
|
||||
assert.doesNotMatch(page, /name="remove" value="users:read"/, "a declared name has no Remove");
|
||||
const readerPage = await (await get(`/auth-admin/users/${ada}`, ["users:read"])).text();
|
||||
assert.match(readerPage, /old:write/);
|
||||
assert.doesNotMatch(readerPage, /permissions\/remove/);
|
||||
|
||||
const declared = await post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${token}&remove=users%3Aread`);
|
||||
assert.equal(declared.status, 303);
|
||||
assert.equal(tuples.length, 5);
|
||||
assert.equal(denylist.isRevoked(ada, 0), false);
|
||||
|
||||
const removed = await post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${token}&remove=old%3Awrite`);
|
||||
assert.equal(removed.status, 303);
|
||||
assert.equal(removed.headers.get("location"), `/auth-admin/users/${ada}?info-msg=permission-removed`);
|
||||
assert.match(await (await get(removed.headers.get("location")!)).text(), /Permission removed\./);
|
||||
assert.equal(tuples.length, 4);
|
||||
assert.ok(!tuples.some((tp) => tp.object === "old:write" && tp.subject_id === `user:${ada}`));
|
||||
assert.ok(tuples.some((tp) => tp.object === "old:write" && tp.subject_set?.object === "eng"), "the group's grant is untouched");
|
||||
assert.equal(denylist.isRevoked(ada, 0), true);
|
||||
assert.equal((await post(`/auth-admin/users/${ada}/permissions/remove`, `_csrf=${token}&remove=old%3Awrite`)).status, 303, "a second click is harmless");
|
||||
assert.equal((await post(`/auth-admin/users/${ada}/permissions/remove`, "remove=old%3Awrite")).status, 403, "CSRF-guarded");
|
||||
|
||||
const groupPage = await (await get("/auth-admin/groups/eng")).text();
|
||||
assert.match(groupPage, /action="\/auth-admin\/groups\/eng\/permissions\/remove#permissions-h"/);
|
||||
const groupRemoved = await post("/auth-admin/groups/eng/permissions/remove", `_csrf=${token}&remove=old%3Awrite`);
|
||||
assert.equal(groupRemoved.status, 303);
|
||||
assert.equal(groupRemoved.headers.get("location"), "/auth-admin/groups/eng?info-msg=permission-removed");
|
||||
assert.ok(!tuples.some((tp) => tp.object === "old:write" && tp.subject_set?.object === "eng"));
|
||||
assert.ok(tuples.some((tp) => tp.object === "old:write" && tp.subject_id === "user:someone-else"), "another subject's grant is untouched");
|
||||
});
|
||||
|
||||
// Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
|
||||
// hook lands it on the next request — recovery would be a curl against Keto. Guarded like
|
||||
// self-deactivate and self-delete are. (`admin1` is the harness's own sub.)
|
||||
test("admin permission grants: you can't revoke your own permissions, but you can still grant", async (t) => {
|
||||
const identities: Identity[] = [{ id: "admin1", traits: { email: "you@example.com" } }];
|
||||
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:write", relation: "granted", subject_id: "user:admin1" }];
|
||||
const keto = fakeKeto(tuples);
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin });
|
||||
const baseline = baselineOf(await (await get("/auth-admin/users/admin1")).text());
|
||||
|
||||
const refused = await post("/auth-admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}`); // every box cleared
|
||||
assert.equal(refused.status, 400);
|
||||
assert.match(await refused.text(), /lock yourself out/);
|
||||
assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
|
||||
|
||||
// Granting yourself more is not a lockout, so it goes through.
|
||||
const granted = await post("/auth-admin/users/admin1/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite&permission=groups%3Aread`);
|
||||
assert.equal(granted.status, 303);
|
||||
assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
|
||||
});
|
||||
|
||||
// A check over the tuple array that expands nested groups.
|
||||
function resolvingCheck(tuples: RelationTuple[]) {
|
||||
const reaches = (q: RelationTuple, ns: string, object: string, relation: string, seen: Set<string>): boolean => {
|
||||
const key = `${ns}:${object}#${relation}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return tuples.some((tp) => tp.namespace === ns && tp.object === object && tp.relation === relation && (
|
||||
(q.subject_id !== undefined && tp.subject_id === q.subject_id) ||
|
||||
(q.subject_set !== undefined && tp.subject_set?.namespace === q.subject_set.namespace && tp.subject_set.object === q.subject_set.object) ||
|
||||
(tp.subject_set !== undefined && reaches(q, tp.subject_set.namespace, tp.subject_set.object, tp.subject_set.relation, seen))));
|
||||
};
|
||||
return async (q: RelationTuple) => reaches(q, q.namespace, q.object, q.relation, new Set());
|
||||
}
|
||||
|
||||
// A member value must be a UUID, so this test signs in as `you`.
|
||||
test("admin groups: you can't take away permissions you hold only through a group you are in", async (t) => {
|
||||
const ada = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b01";
|
||||
const you = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b02";
|
||||
const identities: Identity[] = [{ id: you, traits: { email: "you@example.com" } }, { id: ada, traits: { email: "ada@example.com" } }];
|
||||
const member = (group: string, subject: string): RelationTuple => ({ namespace: "Group", object: group, relation: "members", subject_id: subject });
|
||||
const nested = (group: string, child: string): RelationTuple => ({ namespace: "Group", object: group, relation: "members", subject_set: { namespace: "Group", object: child, relation: "members" } });
|
||||
const grant = (permission: string, group: string): RelationTuple => ({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } });
|
||||
const tuples: RelationTuple[] = [
|
||||
member("eng", `user:${you}`), member("eng", `user:${ada}`), grant("users:write", "eng"), grant("groups:write", "eng"),
|
||||
{ namespace: "Permission", object: "groups:write", relation: "granted", subject_id: `user:${you}` }, // also held directly
|
||||
nested("leads", "eng"), grant("oauth2-clients:write", "leads"), // reached through eng
|
||||
member("social", `user:${you}`), // confers nothing
|
||||
member("design", `user:${ada}`), grant("users:write", "design"), // you are not in it
|
||||
];
|
||||
const keto = fakeKeto(tuples, { check: resolvingCheck(tuples) });
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
const { get, post, token } = await adminHarness(t, { keto, kratosAdmin }, you);
|
||||
const baseline = baselineOf(await (await get("/auth-admin/groups/eng")).text());
|
||||
// A refusal renders that group's own page and names what you would lose.
|
||||
const refusedWithMessage = async (res: Response, group: string, lost: string) => {
|
||||
assert.equal(res.status, 400);
|
||||
const html = await res.text();
|
||||
assert.match(html, new RegExp(`lock you out of ${lost}, which`));
|
||||
assert.match(html, new RegExp(`action="/auth-admin/groups/${group}/members"`));
|
||||
};
|
||||
|
||||
// Unticking users:write on eng would take it from you: refused, nothing revoked.
|
||||
const before = JSON.stringify(tuples);
|
||||
await refusedWithMessage(await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=groups%3Awrite`), "eng", "users:write");
|
||||
// Leaving eng, removing eng from leads, or deleting eng (the confirm step too) are refused the same
|
||||
// way; leaving eng also loses what it gets from leads.
|
||||
await refusedWithMessage(await post("/auth-admin/groups/eng/members/delete", `_csrf=${token}&member=user:${you}`), "eng", "oauth2-clients:write, users:write");
|
||||
await refusedWithMessage(await post("/auth-admin/groups/leads/members/delete", `_csrf=${token}&member=group:eng`), "leads", "oauth2-clients:write");
|
||||
await refusedWithMessage(await get("/auth-admin/groups/eng/delete"), "eng", "oauth2-clients:write, users:write");
|
||||
await refusedWithMessage(await post("/auth-admin/groups/eng/delete", `_csrf=${token}`), "eng", "oauth2-clients:write, users:write");
|
||||
assert.equal(JSON.stringify(tuples), before, "nothing changed");
|
||||
|
||||
// You hold groups:write directly too, so unticking it on eng costs you nothing.
|
||||
assert.equal((await post("/auth-admin/groups/eng/permissions", `_csrf=${token}&baseline=${baseline}&permission=users%3Awrite`)).status, 303);
|
||||
// Removing someone else, leaving a group that confers nothing, and deleting a group you are not in all go through.
|
||||
assert.equal((await post("/auth-admin/groups/eng/members/delete", `_csrf=${token}&member=user:${ada}`)).status, 303);
|
||||
assert.equal((await post("/auth-admin/groups/social/members/delete", `_csrf=${token}&member=user:${you}`)).status, 303);
|
||||
assert.equal((await post("/auth-admin/groups/design/delete", `_csrf=${token}`)).status, 303);
|
||||
assert.ok(!tuples.some((tp) => tp.object === "design" || tp.subject_set?.object === "design"));
|
||||
});
|
||||
|
||||
// The read/write split is only honest if the UI models it: a users:read holder must not be shown
|
||||
// buttons that 403 on submit. The gate already refuses them (asserted above); this is the affordance.
|
||||
test("admin screens render no write affordance for a read-only holder", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
const keto = fakeKeto([{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }]);
|
||||
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
|
||||
// Hydra is wired so the clients screen renders for real — without it the page is a 503 and the
|
||||
// "no Register button" assertion below would pass without proving anything.
|
||||
const reporting = { client_id: "existing", client_name: "Reporting" };
|
||||
const hydra = stubHydra({ getClient: async (id) => (id === reporting.client_id ? reporting : null), listClients: async () => ({ clients: [reporting], nextPageToken: null }) });
|
||||
const { get } = await adminHarness(t, { hydra, keto, kratosAdmin });
|
||||
const readOnly = ["users:read", "groups:read"];
|
||||
|
||||
const list = await (await get("/auth-admin/users", readOnly)).text();
|
||||
assert.doesNotMatch(list, /href="\/auth-admin\/users\/new"/); // no "New user"
|
||||
assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read
|
||||
|
||||
// (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.)
|
||||
const detail = await (await get(`/auth-admin/users/${ada}`, readOnly)).text();
|
||||
assert.doesNotMatch(detail, /Save changes/);
|
||||
assert.doesNotMatch(detail, /Generate recovery code/);
|
||||
assert.doesNotMatch(detail, /Delete user/);
|
||||
assert.doesNotMatch(detail, /Save permissions/);
|
||||
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
|
||||
|
||||
const group = await (await get("/auth-admin/groups/eng", readOnly)).text();
|
||||
assert.doesNotMatch(group, /Add a member/);
|
||||
assert.doesNotMatch(group, /Delete group/);
|
||||
assert.doesNotMatch(group, /Save permissions/);
|
||||
|
||||
// The OAuth2-clients screen is held to the same rule.
|
||||
const clientsRes = await get("/auth-admin/clients", ["oauth2-clients:read"]);
|
||||
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
|
||||
const clients = await clientsRes.text();
|
||||
assert.match(clients, /Reporting/); // the list is there — that's what :read buys
|
||||
assert.doesNotMatch(clients, /href="\/auth-admin\/clients\/new"/);
|
||||
// The detail page is where Delete lives, so check it too and not just the list.
|
||||
const clientDetail = await (await get("/auth-admin/clients/existing", ["oauth2-clients:read"])).text();
|
||||
assert.match(clientDetail, /Reporting/);
|
||||
assert.doesNotMatch(clientDetail, /clients\/existing\/delete/);
|
||||
|
||||
// A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than
|
||||
// rendering a form whose submit would 403.
|
||||
for (const path of ["/auth-admin/users/new", "/auth-admin/groups/new", `/auth-admin/users/${ada}/delete`, "/auth-admin/groups/eng/delete"]) {
|
||||
assert.equal((await get(path, readOnly)).status, 403, path);
|
||||
}
|
||||
assert.equal((await get("/auth-admin/clients/new", ["oauth2-clients:read"])).status, 403);
|
||||
|
||||
// A writer sees the affordances the reader didn't.
|
||||
const writable = await (await get(`/auth-admin/users/${ada}`, ["users:read", "users:write"])).text();
|
||||
assert.match(writable, /Save changes/);
|
||||
assert.match(writable, /Save permissions/);
|
||||
});
|
||||
|
||||
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
|
||||
// in-memory Hydra. Registration shows the one-time client_secret on the post-create page (no PRG).
|
||||
test("admin OAuth2 clients screen: gate, list, register (one-time secret), detail, delete (CSRF-guarded)", async (t) => {
|
||||
const store: OAuth2Client[] = [
|
||||
{ client_id: "existing", client_name: "Reporting", redirect_uris: ["https://reporting.example/cb"], scope: "openid", token_endpoint_auth_method: "client_secret_basic" },
|
||||
];
|
||||
let seq = 0;
|
||||
const hydra = stubHydra({
|
||||
createClient: async (c) => { const created = { ...c, client_id: `gen-${++seq}`, client_secret: `secret-${seq}` }; store.push(created); return created; },
|
||||
deleteClient: async (id) => { const i = store.findIndex((c) => c.client_id === id); if (i >= 0) store.splice(i, 1); },
|
||||
getClient: async (id) => store.find((c) => c.client_id === id) ?? null,
|
||||
listClients: async () => ({ clients: store, nextPageToken: null }),
|
||||
});
|
||||
const { get, post, token, url } = await adminHarness(t, { hydra });
|
||||
|
||||
await assertAdminGate(url, get, "/auth-admin/clients");
|
||||
|
||||
// List: the existing client shows + the "register" link.
|
||||
const listHtml = await (await get("/auth-admin/clients")).text();
|
||||
assert.match(listHtml, /href="\/auth-admin\/clients\/existing"/);
|
||||
assert.match(listHtml, /href="\/auth-admin\/clients\/new"/);
|
||||
assert.match(listHtml, /Reporting/);
|
||||
|
||||
// Register: the form renders (with confidential-vs-public guidance); a valid post creates the
|
||||
// client and shows the one-time secret + id.
|
||||
const formHtml = await (await get("/auth-admin/clients/new")).text();
|
||||
assert.match(formHtml, /Register client/);
|
||||
assert.match(formHtml, /keep a secret/i); // guidance on the public-vs-confidential choice (apostrophes arrive escaped: t() text goes through <%= %>)
|
||||
const created = await post("/auth-admin/clients", `_csrf=${token}&name=Grafana&redirectUris=${encodeURIComponent("https://graf/cb")}&scope=openid+offline_access`);
|
||||
assert.equal(created.status, 200); // not a redirect — the secret is shown once
|
||||
const createdHtml = await created.text();
|
||||
assert.match(createdHtml, /Client registered/);
|
||||
assert.match(createdHtml, /secret-1/); // the one-time client_secret
|
||||
assert.match(createdHtml, /gen-1/); // the generated client_id
|
||||
assert.ok(store.some((c) => c.client_name === "Grafana" && c.token_endpoint_auth_method === "client_secret_basic"));
|
||||
|
||||
// Invalid input (missing redirect URI) and a missing CSRF token are both refused, nothing created.
|
||||
const before = store.length;
|
||||
assert.equal((await post("/auth-admin/clients", `_csrf=${token}&name=NoRedirect&redirectUris=`)).status, 400);
|
||||
assert.equal((await post("/auth-admin/clients", `name=x&redirectUris=${encodeURIComponent("https://x/cb")}`)).status, 403);
|
||||
assert.equal(store.length, before);
|
||||
|
||||
// Detail: read-only info, never the secret again, a delete control.
|
||||
const detail = await (await get("/auth-admin/clients/existing")).text();
|
||||
assert.match(detail, /reporting\.example\/cb/);
|
||||
assert.doesNotMatch(detail, /Client secret/i); // the secret is shown only once, at creation
|
||||
assert.match(detail, /delete and re-register/i); // no edit: the lifecycle guidance is surfaced
|
||||
assert.match(detail, /href="\/auth-admin\/clients\/existing\/delete"/);
|
||||
|
||||
// Delete: a confirm step (GET) then the POST removes the client, back to the list.
|
||||
assert.match(await (await get("/auth-admin/clients/existing/delete")).text(), /Cancel/);
|
||||
const del = await post("/auth-admin/clients/existing/delete", `_csrf=${token}`);
|
||||
assert.equal(del.status, 303);
|
||||
assert.equal(del.headers.get("location"), "/auth-admin/clients");
|
||||
assert.ok(!store.some((c) => c.client_id === "existing"));
|
||||
|
||||
// Unknown id → 404; malformed %-encoding doesn't 500.
|
||||
assert.equal((await get("/auth-admin/clients/does-not-exist")).status, 404);
|
||||
assert.equal((await get("/auth-admin/clients/%ZZ")).status, 404);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,439 @@
|
||||
import assert from "node:assert/strict";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { test } from "node:test";
|
||||
import { createApp } from "./app.ts";
|
||||
import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
|
||||
import { HydraError, type HydraAdmin } from "../auth/hydra-admin.ts";
|
||||
import { staticJwks } from "../auth/jwks.ts";
|
||||
import type { Identity } from "../auth/kratos-admin.ts";
|
||||
import { KratosError, type Flow, type FlowSubmission, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
|
||||
import { SESSION_COOKIE } from "../auth/login.ts";
|
||||
import { createI18n } from "../i18n/runtime.ts";
|
||||
import { loadI18n } from "../i18n/load.ts";
|
||||
import { ecJwk, fakeKeto, mintJwt, mockKratos, OAUTH_SUBJECT, stubAdmin, stubHydra, withWhoami } from "../test-tooling/app-harness.ts";
|
||||
|
||||
// A re-rendered login flow: csrf hidden, themed fields, a submit, and a failed-attempt message.
|
||||
const node = (attrs: Record<string, unknown>, label?: string): UiNode => ({ attributes: attrs, group: "default", messages: [], meta: label ? { label: { id: 1, text: label, type: "info" } } : {}, type: "input" });
|
||||
const loginFlow = (id: string): Flow => ({
|
||||
id,
|
||||
ui: {
|
||||
action: `http://127.0.0.1:4433/self-service/login?flow=${id}`,
|
||||
messages: [{ id: 4000006, text: "The provided credentials are invalid.", type: "error" }],
|
||||
method: "post",
|
||||
nodes: [
|
||||
node({ name: "csrf_token", type: "hidden", value: "tok" }),
|
||||
node({ name: "identifier", required: true, type: "email" }, "E-Mail"),
|
||||
node({ name: "password", required: true, type: "password" }, "Password"),
|
||||
node({ name: "method", type: "submit", value: "password" }, "Sign in"),
|
||||
{ attributes: { name: "provider", type: "submit", value: "google" }, group: "oidc", messages: [], meta: { label: { id: 1, text: "Sign in with Google", type: "info" } }, type: "input" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// GET dispatch for the themed auth pages: the same handler branches on session presence.
|
||||
test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a signed-in user is sent home, except /settings", async (t) => {
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), kratos: mockKratos(async (_t, id) => { if (id === "stale") throw new KratosError("gone", 410, ""); return loginFlow(id); }) });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
// Anonymous, no ?flow= → init one + relay Kratos' CSRF cookie.
|
||||
const init = await fetch(url + "/login", { redirect: "manual" });
|
||||
assert.equal(init.status, 303);
|
||||
assert.equal(init.headers.get("location"), "/login?flow=new1");
|
||||
assert.match(init.headers.get("set-cookie") ?? "", /csrf_token=abc/);
|
||||
// A stale flow id (Kratos 410) bounces back to a fresh init.
|
||||
const stale = await fetch(url + "/login?flow=stale", { redirect: "manual" });
|
||||
assert.equal(stale.status, 303);
|
||||
assert.equal(stale.headers.get("location"), "/login");
|
||||
|
||||
// Already signed in → /login + /registration short-circuit to the app dashboard; /settings stays reachable.
|
||||
const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, permissions: [], sub: "u1" })}` }, redirect: "manual" as const };
|
||||
for (const path of ["/login", "/registration"]) {
|
||||
const res = await fetch(url + path, signedIn);
|
||||
assert.equal(res.status, 303, `${path} while signed in → 303`);
|
||||
assert.equal(res.headers.get("location"), "/dashboard");
|
||||
}
|
||||
assert.equal((await fetch(url + "/settings", signedIn)).headers.get("location"), "/settings?flow=new1");
|
||||
});
|
||||
|
||||
test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via /auth/complete, never 500", async (t) => {
|
||||
// After registration's `session` hook the user holds a Kratos session but no app JWT — 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), preserving return_to — never fall through to the catch-all 500.
|
||||
const sessionRace = new KratosError("Kratos init login flow failed (400)", 400, JSON.stringify({ error: { id: "session_already_available" } }));
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(async () => loginFlow("x")), initBrowserFlow: async () => { throw sessionRace; } } });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
const recover = await fetch(url + "/login", { redirect: "manual" });
|
||||
assert.equal(recover.status, 303);
|
||||
assert.equal(recover.headers.get("location"), "/auth/complete");
|
||||
// return_to is carried through so the deep link still lands after the JWT is minted.
|
||||
const deep = await fetch(url + "/login?return_to=" + encodeURIComponent("/admin/users"), { redirect: "manual" });
|
||||
assert.equal(deep.headers.get("location"), "/auth/complete?return_to=%2Fadmin%2Fusers");
|
||||
|
||||
// A genuinely unexpected Kratos 400 is still surfaced as a 500 (not masked as a session race).
|
||||
const app2 = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(async () => loginFlow("x")), initBrowserFlow: async () => { throw new KratosError("bad", 400, JSON.stringify({ error: { id: "security_csrf_violation" } })); } } });
|
||||
await new Promise<void>((r) => app2.listen(0, r));
|
||||
t.after(() => app2.close());
|
||||
const url2 = `http://localhost:${(app2.address() as AddressInfo).port}`;
|
||||
assert.equal((await fetch(url2 + "/login", { redirect: "manual" })).status, 500);
|
||||
});
|
||||
|
||||
test("themed auth GET: an anonymous hit on /settings (Kratos 401) bounces to sign-in with the page as return_to, never 500", async (t) => {
|
||||
let failure = new KratosError("Kratos init settings flow failed (401)", 401, "");
|
||||
const throwing = async (): Promise<never> => { throw failure; };
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), kratos: { ...mockKratos(throwing), initBrowserFlow: throwing } });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
const init = await fetch(url + "/settings", { redirect: "manual" });
|
||||
assert.equal(init.status, 303);
|
||||
assert.equal(init.headers.get("location"), "/login?return_to=%2Fsettings");
|
||||
const existing = await fetch(url + "/settings?flow=f1", { redirect: "manual" });
|
||||
assert.equal(existing.headers.get("location"), "/login?return_to=%2Fsettings");
|
||||
// /login redirecting to itself would loop, so a 401 there stays the catch-all 500.
|
||||
assert.equal((await fetch(url + "/login", { redirect: "manual" })).status, 500);
|
||||
// Only a 401 means "sign in": an Ory outage on /settings is still the honest 503.
|
||||
failure = new KratosError("Kratos init settings flow failed (500)", 500, "");
|
||||
assert.equal((await fetch(url + "/settings", { redirect: "manual" })).status, 503);
|
||||
});
|
||||
|
||||
// return_to: a deep-link login lands back on the requested page. The gate redirects to
|
||||
// /login?return_to=<host-relative path>; /login bakes that into the Kratos flow so completion
|
||||
// returns there — but a first-party path must route via /auth/complete first (to mint the JWT).
|
||||
test("login return_to: a first-party deep link is wrapped through /auth/complete; an absolute target passes through as-is", async (t) => {
|
||||
let lastReturnTo: string | undefined;
|
||||
const kratos: KratosPublic = {
|
||||
...mockKratos(async (_t, id) => loginFlow(id)),
|
||||
initBrowserFlow: async (_t: FlowType, opts = {}) => { lastReturnTo = opts.returnTo; return { flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: [] }; },
|
||||
};
|
||||
const app = createApp({ kratos });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
// A host-relative deep link → wrapped: Kratos returns to <origin>/auth/complete?return_to=<path>,
|
||||
// so the JWT is minted before the user lands on the page (query preserved, re-encoded).
|
||||
await fetch(url + "/login?return_to=" + encodeURIComponent("/admin/users?q=1"), { redirect: "manual" });
|
||||
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Fq%3D1$/);
|
||||
|
||||
// An absolute target (the OAuth2 login challenge) is passed to Kratos unchanged — Kratos
|
||||
// allow-lists it. A protocol-relative "//evil.com" is likewise not wrapped (Kratos rejects it).
|
||||
const abs = "http://localhost/oauth2/login?login_challenge=abc";
|
||||
await fetch(url + "/login?return_to=" + encodeURIComponent(abs), { redirect: "manual" });
|
||||
assert.equal(lastReturnTo, abs);
|
||||
await fetch(url + "/login?return_to=" + encodeURIComponent("//evil.com"), { redirect: "manual" });
|
||||
assert.equal(lastReturnTo, "//evil.com");
|
||||
});
|
||||
|
||||
test("login return_to: a chosen language alone still routes through /auth/complete, so it survives the sign-in", async (t) => {
|
||||
let lastReturnTo: string | undefined;
|
||||
const kratos: KratosPublic = {
|
||||
...mockKratos(async (_t, id) => loginFlow(id)),
|
||||
initBrowserFlow: async (_t: FlowType, opts = {}) => { lastReturnTo = opts.returnTo; return { flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: [] }; },
|
||||
};
|
||||
const app = createApp({ i18n: createI18n(await loadI18n()), kratos });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
lastReturnTo = "unset";
|
||||
await fetch(url + "/login", { redirect: "manual" });
|
||||
assert.equal(lastReturnTo, undefined, "no target and no language: Kratos keeps its default");
|
||||
await fetch(url + "/login?locale=sv-SE", { redirect: "manual" });
|
||||
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?locale=sv-SE$/);
|
||||
await fetch(url + "/login?locale=sv-SE&return_to=" + encodeURIComponent("/admin/users"), { redirect: "manual" });
|
||||
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Flocale%3Dsv-SE&locale=sv-SE$/);
|
||||
|
||||
// An absolute target still reaches Kratos beside a chosen language; the language is lost instead.
|
||||
const abs = "http://localhost/oauth2/login?login_challenge=abc";
|
||||
await fetch(url + "/login?locale=sv-SE&return_to=" + encodeURIComponent(abs), { redirect: "manual" });
|
||||
assert.equal(lastReturnTo, abs);
|
||||
});
|
||||
|
||||
// "Ory down ⇒ no logins" is documented; the auth path should say so honestly (503), not the
|
||||
// generic "error on our end" 500 the catch-all renders.
|
||||
test("auth flow when Ory is unreachable → an honest 503, not the catch-all 500", async (t) => {
|
||||
const boom = () => { throw new KratosError("kratos down", 503, ""); };
|
||||
const down: KratosPublic = { ...mockKratos(async () => boom()), initBrowserFlow: async () => boom() };
|
||||
const app = createApp({ kratos: down });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
const init = await fetch(url + "/login", { redirect: "manual" }); // init (no ?flow=) with Kratos down
|
||||
assert.equal(init.status, 503);
|
||||
assert.match(await init.text(), /unavailable/i);
|
||||
assert.equal((await fetch(url + "/login?flow=f1")).status, 503); // fetching a flow, Kratos down
|
||||
|
||||
// A network-level throw (refused/timeout — not a KratosError) is treated the same way.
|
||||
const refused: KratosPublic = { ...mockKratos(async () => { throw new Error("ECONNREFUSED"); }), initBrowserFlow: async () => { throw new Error("ECONNREFUSED"); } };
|
||||
const app2 = createApp({ kratos: refused });
|
||||
await new Promise<void>((r) => app2.listen(0, r));
|
||||
t.after(() => app2.close());
|
||||
assert.equal((await fetch(`http://localhost:${(app2.address() as AddressInfo).port}/login`, { redirect: "manual" })).status, 503);
|
||||
});
|
||||
|
||||
test("renders a fetched flow as the themed auth page: fields post straight to Kratos, errors surface", async (t) => {
|
||||
const app = createApp({ kratos: mockKratos(async (_t, id) => loginFlow(id)) });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const html = await (await fetch(`http://localhost:${(app.address() as AddressInfo).port}/login?flow=f1`)).text();
|
||||
|
||||
// The form posts to flow.ui.action (Kratos owns CSRF); csrf rides as a hidden input.
|
||||
assert.match(html, /<form class="auth-card" method="post" action="http:\/\/127\.0\.0\.1:4433\/self-service\/login\?flow=f1"/);
|
||||
assert.match(html, /<input type="hidden" name="csrf_token" value="tok">/);
|
||||
assert.match(html, /name="identifier"/);
|
||||
assert.match(html, /name="password"[^>]*type="password"/);
|
||||
assert.match(html, /<button type="submit"[^>]*name="method" value="password">Sign in<\/button>/);
|
||||
assert.match(html, /<a href="\/registration">Create one<\/a>/); // alt link to register
|
||||
// Configured OIDC provider → an SSO submit button in the same form (posts provider=google);
|
||||
// `formnovalidate` so it bypasses the required email/password fields (SSO needs neither).
|
||||
assert.match(html, /<div class="sso"/);
|
||||
assert.match(html, /<button type="submit" class="sso-btn" name="provider" value="google" formnovalidate>.*Sign in with Google<\/span><\/button>/s);
|
||||
// The flow-level error renders as an alert.
|
||||
assert.match(html, /class="alert alert-neg"/);
|
||||
assert.match(html, /The credentials are invalid\./); // 4000006 → our wording (README → Translating)
|
||||
});
|
||||
|
||||
// Login completion: /auth/complete is where Kratos lands the browser after login.
|
||||
test("login completion (/auth/complete): a live session mints the JWT cookie; no session → /login, no cookie", async (t) => {
|
||||
const identity: Identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55", traits: { email: "admin@plainpages.local" } };
|
||||
let projected: unknown;
|
||||
const kratos = withWhoami(async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session);
|
||||
const kratosAdmin = stubAdmin({ updateMetadataPublic: async (_id, meta) => { projected = meta; return identity; } });
|
||||
const keto = fakeKeto([], { check: async () => true, listRelations: async () => ({ nextPageToken: null, tuples: [{ namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${identity.id}` }] }) });
|
||||
const complete = async (app: ReturnType<typeof createApp>, cookie?: string, returnTo?: string) => {
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const q = returnTo ? `?return_to=${encodeURIComponent(returnTo)}` : "";
|
||||
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete${q}`, { headers: cookie ? { cookie } : {}, redirect: "manual" });
|
||||
};
|
||||
|
||||
// Live Kratos session: permissions from Keto → projection → tokenize → JWT cookie, land on the dashboard.
|
||||
const ok = await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s");
|
||||
assert.equal(ok.status, 303);
|
||||
assert.equal(ok.headers.get("location"), "/dashboard");
|
||||
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
|
||||
assert.deepEqual(projected, { permissions: ["admin"] }); // Keto permissions projected onto the identity for the tokenizer
|
||||
|
||||
// return_to: a safe host-relative target lands the user back where they were headed; an
|
||||
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
|
||||
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "/admin/users?q=1")).headers.get("location"), "/admin/users?q=1");
|
||||
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "//evil.com")).headers.get("location"), "/dashboard");
|
||||
|
||||
// No Kratos session: nothing minted, bounce to /login with no cookie.
|
||||
const none = await complete(createApp({ keto: fakeKeto(), kratos: withWhoami(async () => null), kratosAdmin: stubAdmin({}) }));
|
||||
assert.equal(none.status, 303);
|
||||
assert.equal(none.headers.get("location"), "/login");
|
||||
assert.equal(none.headers.get("set-cookie"), null);
|
||||
});
|
||||
|
||||
test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clears our JWT; bad token → 403", async (t) => {
|
||||
const logoutUrl = "http://127.0.0.1:4433/self-service/logout?token=lt";
|
||||
// Real Kratos keys off its own session cookie (plainpages_session), not our always-present CSRF cookie.
|
||||
const kratos: KratosPublic = { ...mockKratos(async () => { throw new Error("unused"); }), createLogoutFlow: async (o) => (o?.cookie?.includes("plainpages_session") ? { logoutToken: "lt", logoutUrl } : null) };
|
||||
const csrfSecret = "logout-secret";
|
||||
const app = createApp({ csrfSecret, kratos });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const token = issueCsrfToken(csrfSecret);
|
||||
const post = (cookie: string, body: string) =>
|
||||
fetch(url + "/logout", { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie }, method: "POST", redirect: "manual" });
|
||||
|
||||
// Valid double-submit (cookie token === form token) + active session → Kratos logout URL, JWT cleared.
|
||||
const out = await post(`${CSRF_COOKIE}=${token}; ${SESSION_COOKIE}=x; plainpages_session=s`, `_csrf=${token}`);
|
||||
assert.equal(out.status, 303);
|
||||
assert.equal(out.headers.get("location"), logoutUrl);
|
||||
assert.match(out.headers.getSetCookie().join("\n"), /plainpages_jwt=;.*Max-Age=0/);
|
||||
|
||||
// No active Kratos session → clear our cookie and land on /login ourselves.
|
||||
const none = await post(`${CSRF_COOKIE}=${token}`, `_csrf=${token}`);
|
||||
assert.equal(none.status, 303);
|
||||
assert.equal(none.headers.get("location"), "/login");
|
||||
assert.match(none.headers.getSetCookie().join("\n"), /plainpages_jwt=;.*Max-Age=0/);
|
||||
|
||||
// Missing field and a forged token are both refused (no Kratos call, no cookie cleared).
|
||||
assert.equal((await post(`${CSRF_COOKIE}=${token}`, "")).status, 403);
|
||||
assert.equal((await post(`${CSRF_COOKIE}=${token}`, "_csrf=forged.sig")).status, 403);
|
||||
assert.equal((await post("", `_csrf=${token}`)).status, 403); // no cookie to match
|
||||
});
|
||||
|
||||
|
||||
const oauthSession = (): Session => ({ active: true, identity: { id: OAUTH_SUBJECT, traits: { email: "ada@x.io" } } });
|
||||
|
||||
test("OAuth2 login challenge (/oauth2/login): a Kratos session accepts via Hydra; no session bounces to /login; missing challenge → 400", async (t) => {
|
||||
const identity = { id: "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b55" };
|
||||
let acceptedSubject: string | undefined;
|
||||
const hydra = stubHydra({ acceptLoginRequest: async (_c, b) => { acceptedSubject = b.subject; return { redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }; } });
|
||||
|
||||
const signedIn = createApp({ hydra, kratos: withWhoami(async () => ({ active: true, identity }) as Session) });
|
||||
await new Promise<void>((r) => signedIn.listen(0, r));
|
||||
t.after(() => signedIn.close());
|
||||
const base = `http://localhost:${(signedIn.address() as AddressInfo).port}`;
|
||||
|
||||
// Signed in: accept the challenge with the Kratos identity → 303 to Hydra's resume URL.
|
||||
const accept = await fetch(base + "/oauth2/login?login_challenge=chal1", { headers: { cookie: "plainpages_session=s" }, redirect: "manual" });
|
||||
assert.equal(accept.status, 303);
|
||||
assert.match(accept.headers.get("location") ?? "", /\/oauth2\/auth\?login_verifier=v/);
|
||||
assert.equal(acceptedSubject, identity.id);
|
||||
|
||||
// Missing login_challenge → 400 (someone hit the endpoint directly).
|
||||
assert.equal((await fetch(base + "/oauth2/login", { redirect: "manual" })).status, 400);
|
||||
|
||||
// Not signed in: bounce to the themed login, return_to carrying an absolute URL back to here.
|
||||
const anon = createApp({ hydra: stubHydra(), kratos: withWhoami(async () => null) });
|
||||
await new Promise<void>((r) => anon.listen(0, r));
|
||||
t.after(() => anon.close());
|
||||
const bounce = await fetch(`http://localhost:${(anon.address() as AddressInfo).port}/oauth2/login?login_challenge=chal1`, { redirect: "manual" });
|
||||
assert.equal(bounce.status, 303);
|
||||
const loc = bounce.headers.get("location") ?? "";
|
||||
assert.match(loc, /^\/login\?return_to=/);
|
||||
assert.match(decodeURIComponent(loc.split("return_to=")[1]!), /^http:\/\/[^/]+\/oauth2\/login\?login_challenge=chal1$/);
|
||||
});
|
||||
|
||||
test("OAuth2 consent challenge (/oauth2/consent): skip auto-accepts; a third-party shows the screen; allow/deny POST; CSRF-guarded; missing challenge", async (t) => {
|
||||
const csrfSecret = "consent-secret";
|
||||
let granted: { grant_scope?: string[]; session?: unknown } | undefined;
|
||||
const hydra = stubHydra({
|
||||
acceptConsentRequest: async (_c, b) => { granted = b; return { redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }; },
|
||||
rejectConsentRequest: async () => ({ redirect: "http://acme.example/cb?error=access_denied" }),
|
||||
});
|
||||
const app = createApp({ csrfSecret, hydra, kratos: withWhoami(async () => oauthSession()) });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const token = issueCsrfToken(csrfSecret);
|
||||
const post = (body: string) =>
|
||||
fetch(base + "/oauth2/consent", { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${CSRF_COOKIE}=${token}` }, method: "POST", redirect: "manual" });
|
||||
|
||||
// Third-party (default stub: not first-party, not skipped) → 200 consent screen listing the
|
||||
// client + scopes, with a CSRF cookie its form echoes back; posts to our own /oauth2/consent.
|
||||
const page = await fetch(base + "/oauth2/consent?consent_challenge=cons1", { redirect: "manual" });
|
||||
assert.equal(page.status, 200);
|
||||
const html = await page.text();
|
||||
assert.match(html, /Authorize Acme Reports/);
|
||||
assert.match(html, /openid/);
|
||||
assert.match(html, /profile/);
|
||||
assert.match(html, /action="\/oauth2\/consent"/);
|
||||
// Informed consent: the screen names the account being authorized + offers a sign-out escape.
|
||||
assert.match(html, /Signed in as.*ada@x\.io/s);
|
||||
assert.match(html, /action="\/logout".*Sign out/s);
|
||||
assert.match(page.headers.get("set-cookie") ?? "", /plainpages_csrf=/);
|
||||
|
||||
// Allow → 303 to Hydra, granting the scopes re-read from the challenge (never form-supplied) +
|
||||
// id_token claims from the Kratos identity.
|
||||
const allow = await post(`_csrf=${token}&consent_challenge=cons1&decision=allow`);
|
||||
assert.equal(allow.status, 303);
|
||||
assert.match(allow.headers.get("location") ?? "", /\/oauth2\/auth\?consent_verifier=v/);
|
||||
assert.deepEqual(granted?.grant_scope, ["openid", "profile"]);
|
||||
assert.deepEqual(granted?.session, { id_token: { email: "ada@x.io" } });
|
||||
|
||||
// Deny → 303 back to the client with access_denied.
|
||||
const deny = await post(`_csrf=${token}&consent_challenge=cons1&decision=deny`);
|
||||
assert.equal(deny.status, 303);
|
||||
assert.equal(deny.headers.get("location"), "http://acme.example/cb?error=access_denied");
|
||||
|
||||
// Forged/missing CSRF → 403 (no Hydra call); missing challenge → 400.
|
||||
assert.equal((await post("decision=allow")).status, 403);
|
||||
assert.equal((await fetch(base + "/oauth2/consent", { redirect: "manual" })).status, 400);
|
||||
|
||||
// A Hydra-skipped client auto-accepts on GET (no screen) → 303 to Hydra.
|
||||
const skip = createApp({ hydra: stubHydra({ getConsentRequest: async () => ({ challenge: "cons1", requested_scope: ["openid"], skip: true, subject: OAUTH_SUBJECT }) }), kratos: withWhoami(async () => oauthSession()) });
|
||||
await new Promise<void>((r) => skip.listen(0, r));
|
||||
t.after(() => skip.close());
|
||||
const auto = await fetch(`http://localhost:${(skip.address() as AddressInfo).port}/oauth2/consent?consent_challenge=cons1`, { redirect: "manual" });
|
||||
assert.equal(auto.status, 303);
|
||||
assert.match(auto.headers.get("location") ?? "", /consent_verifier=v/);
|
||||
});
|
||||
|
||||
test("OAuth2 RP-initiated logout (/oauth2/logout): accepts the logout challenge → 303 to Hydra; missing → 400", async (t) => {
|
||||
let acceptedChallenge: string | undefined;
|
||||
const hydra = stubHydra({ acceptLogoutRequest: async (c) => { acceptedChallenge = c; return { redirect: "http://acme.example/post-logout" }; } });
|
||||
const app = createApp({ hydra, kratos: withWhoami(async () => null) });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const base = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
const ok = await fetch(base + "/oauth2/logout?logout_challenge=lc1", { redirect: "manual" });
|
||||
assert.equal(ok.status, 303);
|
||||
assert.equal(ok.headers.get("location"), "http://acme.example/post-logout");
|
||||
assert.equal(acceptedChallenge, "lc1");
|
||||
|
||||
assert.equal((await fetch(base + "/oauth2/logout", { redirect: "manual" })).status, 400);
|
||||
});
|
||||
|
||||
// All three OAuth2 challenge endpoints share one degrade contract (the documented "byte-identical"
|
||||
// behaviour): a stale/consumed challenge (Hydra 4xx — back button, slow login) → recoverable 400,
|
||||
// a genuine Hydra outage (5xx) → 500.
|
||||
test("OAuth2 challenge endpoints degrade identically: stale Hydra 4xx → 400, outage 5xx → 500", async (t) => {
|
||||
const endpoints: { make: (status: number) => Partial<HydraAdmin>; path: string }[] = [
|
||||
{ make: (s) => ({ getLoginRequest: async () => { throw new HydraError("x", s, ""); } }), path: "/oauth2/login?login_challenge=x" },
|
||||
{ make: (s) => ({ getConsentRequest: async () => { throw new HydraError("x", s, ""); } }), path: "/oauth2/consent?consent_challenge=x" },
|
||||
{ make: (s) => ({ acceptLogoutRequest: async () => { throw new HydraError("x", s, ""); } }), path: "/oauth2/logout?logout_challenge=x" },
|
||||
];
|
||||
for (const { make, path } of endpoints) {
|
||||
for (const [status, expected] of [[410, 400], [503, 500]] as const) {
|
||||
const app = createApp({ hydra: stubHydra(make(status)), kratos: withWhoami(async () => null) });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}${path}`, { redirect: "manual" });
|
||||
assert.equal(res.status, expected, `${path} ${status} → ${expected}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("code-flow POST: the host trims the code, relays the POST to Kratos and Kratos' redirect + cookies back", async (t) => {
|
||||
const calls: { body: string; cookie: string | undefined; flowId: string; type: FlowType }[] = [];
|
||||
let answer: () => Promise<FlowSubmission> = async () =>
|
||||
({ location: "http://localhost:3000/settings?flow=s1", setCookie: ["plainpages_session=s; Path=/; HttpOnly"], status: 303 });
|
||||
const kratos: KratosPublic = {
|
||||
...mockKratos(async () => { throw new Error("unused"); }),
|
||||
submitFlow: async (type, flowId, opts) => { calls.push({ body: opts.body, cookie: opts.cookie, flowId, type }); return answer(); },
|
||||
};
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), kratos });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const post = (path: string, body: string) => fetch(url + path, {
|
||||
body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: "csrf_token_x=c" }, method: "POST", redirect: "manual",
|
||||
});
|
||||
|
||||
const res = await post("/recovery?flow=f1", "csrf_token=tok&code=%C2%A0123456%09%0A&method=code");
|
||||
assert.equal(res.status, 303);
|
||||
assert.equal(res.headers.get("location"), "http://localhost:3000/settings?flow=s1");
|
||||
assert.match(res.headers.get("set-cookie") ?? "", /plainpages_session=s/);
|
||||
assert.deepEqual(calls[0], { body: "csrf_token=tok&code=123456&method=code", cookie: "csrf_token_x=c", flowId: "f1", type: "recovery" });
|
||||
|
||||
// The first step has no code field; its body passes through unchanged.
|
||||
await post("/verification?flow=f2", "csrf_token=tok&email=a%40b.c&method=code");
|
||||
assert.deepEqual(calls[1], { body: "csrf_token=tok&email=a%40b.c&method=code", cookie: "csrf_token_x=c", flowId: "f2", type: "verification" });
|
||||
|
||||
// No ?flow= → start over rather than post a flow-less submit.
|
||||
const noFlow = await post("/recovery", "code=1");
|
||||
assert.equal(noFlow.headers.get("location"), "/recovery");
|
||||
assert.equal(calls.length, 2);
|
||||
|
||||
// Kratos' absolute redirect to this host becomes host-relative, so it keeps the chosen locale.
|
||||
answer = async () => ({ location: `${url}/settings?flow=s1`, setCookie: [], status: 303 });
|
||||
assert.equal((await post("/recovery?flow=f1&locale=en-US", "code=1")).headers.get("location"), "/settings?flow=s1&locale=en-US");
|
||||
|
||||
// Kratos answered without a redirect → back to the flow, whose page shows its state.
|
||||
answer = async () => ({ location: null, setCookie: [], status: 400 });
|
||||
assert.equal((await post("/recovery?flow=f1", "code=1")).headers.get("location"), "/recovery?flow=f1");
|
||||
|
||||
// Kratos down → the honest 503 the flow pages render, not a 500.
|
||||
answer = async () => { throw new KratosError("bad gateway", 502, ""); };
|
||||
assert.equal((await post("/recovery?flow=f1", "code=1")).status, 503);
|
||||
answer = async () => { throw new TypeError("fetch failed"); };
|
||||
assert.equal((await post("/recovery?flow=f1", "code=1")).status, 503);
|
||||
|
||||
// Sign-in posts straight to Kratos, so the host has no POST /login.
|
||||
assert.notEqual((await post("/login?flow=f1", "x=1")).status, 303);
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user