Compare commits

..

40 Commits

Author SHA1 Message Date
lilleman 581a4dbb81 Record the comprehension check: two scoring runs of item 70, overall mean 6.63
CI / full-gate (push) Successful in 3m23s
Mirror / github-mirror (push) Successful in 4s
2026-11-09 01:38:27 +01:00
lilleman 5a5ce5499d Answer prose round 1: 0 🔴, 3 🟡 (0 misread); comments shortened, README file map lists link-header
CI / full-gate (push) Successful in 3m27s
2026-11-09 01:25:14 +01:00
lilleman c9485631c8 Answer product-owner round 1: 0 🔴, 1 🟡 (0 misread); declined and filed as item 72, comment and changelog say a parent left empty vanishes 2026-11-09 01:25:14 +01:00
lilleman d6b8a7982e Answer stability round 1: 0 🔴, 3 🟡 (0 misread); test the page-token loop and an unmatched nav path, trim chrome comments 2026-11-09 01:25:14 +01:00
lilleman 1bcb31ff58 Answer security round 1: 0 🔴, 0 🟡 (0 misread); no finding 2026-11-09 01:18:01 +01:00
lilleman cdb5a00705 Give the paging loop, Link-header parser and FieldConfig one home each, rename buildPluginChrome to buildPageChrome, and delete a group from its parents
CI / full-gate (push) Successful in 3m24s
2026-11-09 01:16:55 +01:00
lilleman a4740c87ae Answer security round 1: 0 🔴, 0 🟡 (0 misread); no finding
CI / full-gate (push) Successful in 3m23s
Mirror / github-mirror (push) Successful in 3s
2026-11-09 01:06:47 +01:00
lilleman 14be7edcbf Answer prose round 1: 0 🔴, 6 🟡 (0 misread); comments fixed in the stability round's commit
CI / full-gate (push) Successful in 3m20s
2026-11-09 01:06:43 +01:00
lilleman ebb9757e80 Answer stability round 1: 0 🔴, 4 🟡 (0 misread); read every bootstrap setting once at start, test the 409, Keto-grant and password paths 2026-11-09 01:06:43 +01:00
lilleman 784610eac8 Make bootstrap read its settings through config.ts and call Kratos and Keto through their clients
CI / full-gate (push) Successful in 3m21s
2026-11-09 01:01:44 +01:00
lilleman df9a3f3665 Remove the shipped item 68 from the plan
CI / full-gate (push) Successful in 3m27s
Mirror / github-mirror (push) Successful in 4s
2026-11-09 00:55:21 +01:00
lilleman 22b25bf3db Answer prose round 1: 0 🔴, 5 🟡 (0 misread); comments fixed in the stability round's commit
CI / full-gate (push) Successful in 3m23s
2026-11-09 00:51:27 +01:00
lilleman 4311944a24 Answer stability round 1: 0 🔴, 4 🟡 (0 misread); drop the null built-in result, pin the owner's views and locale on the landing slots 2026-11-09 00:51:27 +01:00
lilleman 0116e862ba Reword the PluginOwned comment 2026-11-09 00:26:32 +01:00
lilleman 30e07e0044 Answer security round 1: 0 🔴, 0 🟡 (0 misread); no finding 2026-11-09 00:26:26 +01:00
lilleman 6fe7ba3b6d Return the landing slots' results to the request pipeline instead of sending them
CI / full-gate (push) Successful in 3m22s
2026-11-09 00:25:29 +01:00
lilleman bb09b3a821 Record the reading cost of the chunk 2026-11-09 00:25:29 +01:00
lilleman 8b88cd4a40 Remove the shipped item 63 from the plan
CI / full-gate (push) Successful in 3m22s
Mirror / github-mirror (push) Successful in 4s
2026-11-09 00:15:23 +01:00
lilleman 8e3270d479 Answer prose round 1: 0 🔴, 5 🟡 (0 misread); cut the duplicated specificity line and reflow
CI / full-gate (push) Successful in 3m22s
2026-11-09 00:14:53 +01:00
lilleman 1773c2737f Answer product-owner round 1: 0 🔴, 1 🟡 (0 misread); list plugin-settings among the Admin resources 2026-11-09 00:14:53 +01:00
lilleman 42644ba0f5 Correct the stale and false comments and docs the comprehension seats flagged
CI / full-gate (push) Successful in 3m40s
2026-11-09 00:11:47 +01:00
lilleman c744759869 Merge remote-tracking branch 'origin/main' into item-62
CI / full-gate (push) Successful in 4m0s
Mirror / github-mirror (push) Successful in 4s
2026-11-09 00:05:38 +01:00
lilleman a00d86fb83 Merge remote-tracking branch 'origin/main' into item-62
CI / full-gate (push) Successful in 4m24s
2026-11-08 23:41:11 +01:00
lilleman 2a0ae084ac Expect WebKit's plain-http cross-origin-isolation warning in the console guard
CI / full-gate (push) Successful in 4m21s
Mirror / github-mirror (push) Successful in 4s
2026-11-08 23:26:25 +01:00
renovate-bot f3ebea3c80 Update Playwright to v1.65.0 2026-11-08 23:26:25 +01:00
lilleman 1fc6033dd5 Give the JsonWebKey alias the kid a JWKS selects a key by
CI / full-gate (push) Successful in 3m51s
Mirror / github-mirror (push) Successful in 3s
2026-11-08 23:26:03 +01:00
lilleman 1304cdae56 Read JsonWebKey from webcrypto, where @types/node 26 exports it 2026-11-08 23:26:03 +01:00
renovate-bot 3f07c3e649 Update Node.js to v26
Release-Bump: major
2026-11-08 23:26:03 +01:00
lilleman 0a9c79d08f Refuse a malformed admin DSN before postgres 4 connects to the host it misreads
CI / full-gate (push) Successful in 3m58s
Mirror / github-mirror (push) Successful in 3s
2026-11-08 23:26:03 +01:00
renovate-bot 44f94c7ab0 Update dependency postgres to v4
Release-Bump: major
2026-11-08 23:26:03 +01:00
renovate-bot 2f75995b3d Update renovate/renovate Docker tag to v44.222.3
CI / full-gate (push) Successful in 3m51s
Mirror / github-mirror (push) Successful in 4s
2026-11-08 23:25:58 +01:00
lilleman e8fac7fd5c Merge remote-tracking branch 'origin/main' into item-62
CI / full-gate (push) Successful in 3m26s
2026-11-08 23:21:08 +01:00
lilleman 42661b111f Flush the logger in the OTLP export test, since @larvit/log 2.4.0 batches records
CI / full-gate (push) Successful in 3m51s
Mirror / github-mirror (push) Successful in 3s
2026-11-08 23:14:55 +01:00
renovate-bot ecdf9f3464 Update dependency @larvit/log to v2.4.0 2026-11-08 23:14:55 +01:00
lilleman d6a33e8cc9 Answer prose round 1: 0 🔴, 1 🟡 (0 misread); drop the false refuseForm comment and tighten the changelog
CI / full-gate (push) Successful in 3m24s
2026-11-08 23:11:15 +01:00
lilleman 0bf7901a14 Answer product-owner round 1: 0 🔴, 0 🟡 (0 misread); tell a plugin author a stale include renders the 500 page 2026-11-08 23:11:15 +01:00
lilleman a0ad0ddf2d Answer stability round 1: 0 🔴, 6 🟡 (0 misread); pin which tuples a group delete keeps, test the self-refusal reasons and the failed save, one refuseForm comment 2026-11-08 23:11:15 +01:00
lilleman 5088352fcf Answer security round 1: 0 🔴, 0 🟡 (0 misread); no findings 2026-11-08 23:04:54 +01:00
renovate-bot 58f85baa8f Update dependency ejs to v7
CI / full-gate (push) Successful in 3m44s
Mirror / github-mirror (push) Successful in 4s
Release-Bump: major
2026-11-08 23:04:31 +01:00
lilleman 428800fd62 Items 62, 69, 64: group delete in one Keto transaction, dashboard view rename, permissions kept on self-refusals
CI / full-gate (push) Successful in 3m19s
2026-11-08 23:04:18 +01:00
60 changed files with 797 additions and 416 deletions
+1 -1
View File
@@ -19,4 +19,4 @@ jobs:
run: |
docker run --rm -v "$PWD:/repo" -w /repo \
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
node:24.22.2-alpine3.24 node registry-cleanup/cleanup.ts
node:26.13.0-alpine3.24 node registry-cleanup/cleanup.ts
+3 -3
View File
@@ -22,7 +22,7 @@ jobs:
GIT_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
docker run --rm -v "$PWD:/repo" -w /repo node:26.13.0-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:
@@ -100,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.22.2-alpine3.24 \
docker run --rm -v "$PWD:/repo" -w /repo node:26.13.0-alpine3.24 \
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
docker run --rm -v "$PWD:/repo" -w /repo \
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
node:24.22.2-alpine3.24 \
node:26.13.0-alpine3.24 \
node release-tooling/dockerhub-overview.ts "$VERSION"
+3 -3
View File
@@ -21,7 +21,7 @@ jobs:
-e RENOVATE_PLATFORM=gitea \
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
-e RENOVATE_TOKEN \
renovate/renovate:44.199.2
renovate/renovate:44.222.3
# 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
@@ -60,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.22.2-alpine3.24 \
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:26.13.0-alpine3.24 \
node release-tooling/next-version.ts "$LATEST" $BUMPS)
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
git show origin/main:src/plugin-host/plugin.ts \
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
| docker run -i --rm -v "$PWD:/repo" -w /repo node:26.13.0-alpine3.24 \
node release-tooling/contract-version.ts "$NEXT" -
echo "Releasing $LATEST -> $NEXT"
git tag "$NEXT" origin/main
+22
View File
@@ -12,6 +12,12 @@ The host's contract version is now 0.5.0, so discovery refuses a plugin whose `a
`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`,
@@ -19,6 +25,22 @@ so all of them apply or none does. A `KetoClient` you build by hand, such as a t
method. The admin example saves a user's or group's permissions through it, so a Keto failure leaves
every grant as it was and answers with the error page.
### The admin example deletes a group's grants and members in one Keto transaction
A Keto failure while deleting a group now leaves the group as it was, with its grants and members,
and answers with the error page. It used to leave some grants gone and the members still listed.
### The admin example removes a deleted group from the groups it was nested in
Deleting a group now also removes it from every group it was a member of, in the same Keto
transaction as the rest. It used to stay listed there, so a group re-created with the same name was
back in every parent. A parent whose only member was the deleted group is left with no members, and
so no longer exists as a group.
### The admin example keeps a user's permissions section when it refuses a self-deactivate or self-delete
The refusal page used to drop the permissions section of the user form.
### The admin example lists a user's or group's grants that no installed plugin declares, with a Remove
A direct grant of a name no installed plugin declares, such as one left by an uninstalled plugin,
+1 -1
View File
@@ -1,5 +1,5 @@
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
FROM node:24.22.2-alpine3.24
FROM node:26.13.0-alpine3.24
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
+7 -7
View File
@@ -347,8 +347,8 @@ curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application
**A permission's name is authored in plugin code; only its *grants* live in Keto.** A plugin
declares the permissions it gates on (`permissions:` in the manifest) and the host collects them
into one catalog — `ctx.declaredPermissions` — which is the fixed list the admin screens offer.
Nothing in a GUI invents a name, and a tuple naming something no installed plugin declares gates
nothing.
Nothing in a GUI invents a name. A grant of a name that no installed plugin declares still reaches
the user's JWT, so a plugin may gate on that name without declaring it.
A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant
revoke](#instant-revoke-the-optional-denylist) when you need it sooner.
@@ -1024,7 +1024,7 @@ whenever a child does. The rendered tree is **filtered per user** from the sessi
per-request authz call), so the menu only shows what that person can reach.
Branding (name, logo, default theme) renders in the app shell.
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPageChrome`) renders
in the same app shell on **every** page — dashboard, plugin pages, and the login / registration /
recovery / front pages — so it looks identical signed in or out and just shows fewer items to an
anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a
@@ -1363,8 +1363,8 @@ be `metadata_public`, not `metadata_admin` — the session Kratos hands the toke
public metadata, and the user can already read these permissions in their own JWT. The projection is
a per-login cache, authoritative nowhere; a stale one self-heals on the next login.
Login resolves both direct grants and group membership, so the JWT `permissions` match what the
admin **Effective access** view shows. Cost: **a handful of Keto reads + one identity refresh per
Login resolves both direct grants and group membership, so the JWT `permissions` match what Keto
says the user holds. Cost: **a handful of Keto reads + one identity refresh per
login**, never per request. JWKS is cached, so signature verification hits the network only on key
rotation.
@@ -1777,7 +1777,7 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
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,
fetch-timeout)
link-header, fetch-timeout)
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
@@ -1789,7 +1789,7 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
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.
+1 -1
View File
@@ -6,7 +6,7 @@
# bash ci.sh
#
# Exits non-zero on the first failure. Each E2E suite OWNS a clean stack — never point two suites at
# one backend (auth-refresh revokes the admin's sessions; full-flow writes users/groups/roles to Keto).
# one backend (auth-refresh revokes the admin's sessions; full-flow writes users, groups and grants).
set -euo pipefail
cd "$(dirname "$0")"
+1 -1
View File
@@ -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.22.2-alpine3.24
image: node:26.13.0-alpine3.24
command: node /srv/server.ts
restart: unless-stopped
volumes:
+3 -2
View File
@@ -165,8 +165,9 @@ services:
# Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README).
# DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml. web implements the
# login challenge at /oauth2/login (consent next). Dev permits the http issuer via --dev
# (compose.override.yml); prod sets an https issuer via env (URLS_SELF_ISSUER).
# login challenge at /oauth2/login and the consent challenge at /oauth2/consent. Dev permits the
# http issuer via --dev (compose.override.yml); prod sets an https issuer via env
# (URLS_SELF_ISSUER).
hydra-migrate:
image: oryd/hydra:v26.2.0
depends_on:
+93
View File
@@ -507,3 +507,96 @@ Inherited architect, would restructure first:
- Locality: `src/auth/bootstrap.ts:170` `provisionPluginStorage`: plugin storage lives in three places, across `storage.ts`, `storage-provisioning.ts` and `bootstrap.ts`
- Locality, Shape: `src/http/app.ts:183` `handleRequest`: every new manifest field or slot adds a stage here and a field to `Host`
- Self-sufficiency: `src/plugin-host/system.ts:1` header: calls the built-in admin screens the reference consumer
## 2026-11-09T00:38:11Z, item-70 at 5a5ce54
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 6 | 7 | 6.5 | 6.5 |
| Mid A | 7.5 | 6.5 | 7 | 6.5 | 7 |
| Maintainability senior | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Inherited architect | 7 | 7 | 6 | 7.5 | 6.5 |
| Mean | 7.13 | 6.38 | 6.63 | 6.75 | 6.63 |
Junior A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:67` `createApp`: a 130-line closure whose 19-field `Host` bag is read by every stage, and `switchBase` reads `builtinRoutes` declared 44 lines later
- Locality: `src/http/app.ts:263` `prepareRequest`: a lazily memoized chrome, `shared` and `contextFor` in one function, with two context paths whose reason is implicit
- Self-sufficiency: `src/plugin-host/settings.ts:35` `ValueOfDecl`: nested conditional types that only the README's settings example explains
- Locality: `examples/plugins/auth-admin/admin-groups.ts:270` `lostToSelf`: the self-lockout guard has three entry points that each decide "is the actor in this group" differently
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos status meanings that the code and README do not explain
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
Mid A, hardest first:
- Locality, Overall: `examples/plugins/auth-admin/admin-groups.ts:273` `lostToSelf`: one guard spread over four functions and three call sites, and it over-refuses on purpose
- Locality: `src/i18n/load.ts:43` `loadI18n`: four catalog sources and their precedence, explained only in the README
- Locality, Navigation: `src/http/app.ts:118` `switchBase`: where the language picker points after a POST needs `app.ts` and two i18n files
- Locality: `src/ui/chrome.ts:86` `profileLinks`: re-implements `hide` and `rename` outside `composeNav`, so the override rules have two homes
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
- Self-sufficiency: `src/server.ts:23` trust-boundary comment: cites `docs/decisions.md` for its reason
Maintainability senior, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:67` `createApp`: 133 lines and complexity 26 (grandfathered), and `switchBase` reads a table declared later
- Locality: `src/http/app.ts:349` `answerBuiltinRoute`: the CSRF cookie and the response-hook context differ from `answerPluginRoute` without a comment saying so
- Locality: `examples/plugins/auth-admin/admin-groups.ts:273` `lostToSelf`: the guard assumes Keto's transitive model, and `removesSelf` checks one level of nesting
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: each branch rests on Ory behaviour that only a comment states
- Locality: `src/ui/chrome.ts:86` `profileLinks`: the menu override has two homes
- Locality: `src/i18n/runtime.ts:39` `chain`: a plugin that shadows a core key in English gives a Swedish visitor the English text
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
Inherited architect, would restructure first:
- Shape, Navigation, Overall: `public/css/auth.css:84` `.field`: the generic form building-block rules live in a file named for one feature
- Shape, Navigation: `src/auth/bootstrap.ts:1` `src/auth`: 21 modules across Ory clients, session crypto and flow handlers in one flat directory
- Shape: `src/http/builtin-route.ts:23` `BuiltinRoute`: a second route contract beside the plugin `Route`
- Locality: `src/http/app.ts:202` `Host`: 19 fields, and each new capability adds one here, in `prepareRequest` and in `context.ts`
- Locality: `views/partials/shell.ejs:61` profile popover: hand-copied from `menu.ejs` and kept in step by a comment
- Self-sufficiency: `views/dashboard.ejs:15` card: hard-codes `apiVersion: "0.5.0"`, which nothing ties to `HOST_API_VERSION`
## 2026-11-09T00:38:15Z, item-70 rerun at 5a5ce54
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 6 | 6.5 | 7 | 6.5 |
| Mid A | 7 | 6 | 7 | 6.5 | 6.5 |
| Maintainability senior | 7.5 | 6.5 | 7 | 7 | 7 |
| Inherited architect | 7 | 6.5 | 6 | 7.5 | 6.5 |
| Mean | 7.13 | 6.25 | 6.63 | 7.00 | 6.63 |
Junior A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:67` `createApp`: about 130 lines of closure setup with a forward reference from `switchBase` to `builtinRoutes`, and a 19-field `Host` bag
- Locality: `src/http/app.ts:263` `prepareRequest`: reserved locals exist only through the spread order of an object literal
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos status branches that cannot be checked from the code
- Shape: `src/plugin-host/settings.ts:43` `SettingsOf`: conditional types that took several rereads
- Shape: `src/i18n/load.ts:43` `loadI18n`: two baselines and two catalog sources, with a dense tuple line at `:78`
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
- Shape: `src/plugin-host/discovery.ts:26` `discoverPlugins`: a long loop of `continue` exits
Mid A, hardest first:
- Locality, Overall: `src/ui/chrome.ts:72` `currentMarkedNav`: labels are translated twice, marking must precede the locale, and `markCurrent` mutates nodes
- Locality, Overall: `src/http/app.ts:118` `switchBase`: a forward reference to `builtinRoutes` that only works because the closure runs later
- Locality: `src/plugin-host/plugin.ts:129` `RESERVED_PLUGIN_IDS`: kept in step with `buildAuthRoutes` by hand, and nothing says so
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:446` `groupsRemoveMember`: the Keto subject-set model is never explained in this file
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos statuses that the comments name but the code cannot confirm
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
Maintainability senior, hardest first:
- Locality, Overall: `src/http/app.ts:263` `prepareRequest`: the lazy `ctx.chrome` is memoized here and in `buildContext`, and only a comment guards it
- Locality: `src/ui/chrome.ts:160` `markCurrent`: mutates nodes, safe only because `composeNav` returns fresh objects
- Locality: `examples/plugins/auth-admin/admin-groups.ts:273` `lostToSelf`: over-approximates deliberately, spread over four helpers
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos status meanings that no readable document states
- Shape: `views/partials/filter-bar.ejs:33` include loops: pass `align: null, up: false, width: null` to stop EJS locals leaking, which reads as noise
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
Inherited architect, would restructure first:
- Shape, Navigation, Overall: `src/auth/bootstrap.ts:1` `src/auth`: 21 files over five concerns, with the lying names `jwt-middleware.ts` and `bootstrap.ts`
- Locality, Shape: `src/http/builtin-route.ts:23` `BuiltinRoute`: a second route shape, so `switchBase` at `src/http/app.ts:118` consults two tables
- Locality: `src/http/app.ts:202` `Host`: 19 fields, and a new request capability touches four files
- Shape, Self-sufficiency: `examples/plugins/auth-admin/admin-shared.ts:107` `FieldConfig`: the type for a core partial's option shape lives in an example plugin
- Locality: `src/auth/login.ts:48` `readPermissions`: a second copy of the Keto paging loop that nothing says the barrel boundary forces
+97 -1
View File
@@ -103,7 +103,7 @@ Read most (opens and searches):
- `src/http/app.test.ts` 1
- `src/ui/chrome.test.ts` 1
- `src/ui/chrome.ts` 1
- `views/index.ejs` 1
- `views/dashboard.ejs` 1
- `views/partials/shell.ejs` 1
## Reading cost, chunk e4d3bb0ac092, finished 2026-10-31T08:02:27Z
@@ -485,3 +485,99 @@ Read most (opens and searches):
- `src/http/app.test.ts` 2
- `todo.md` 2
- `src/plugin-host/plugin.ts` 1
## Reading cost, chunk 8d5c5ad2ab7c, finished 2026-11-08T21:39:34Z
145906 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app-admin.test.ts` 8
- `README.md` 6
- `CHANGELOG.md` 5
- `compose.yml` 5
- `docs/decisions.md` 5
- `examples/plugins/auth-admin/admin-grants.test.ts` 4
- `examples/plugins/auth-admin/admin-users.ts` 4
- `src/auth/keto-client.ts` 4
- `todo.md` 4
- `examples/plugins/auth-admin/README.md` 3
## Reading cost, chunk ef984469151f, finished 2026-11-08T22:01:41Z
38668 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 7
- `AGENTS.md` 1
- `README.md` 1
- `docs/decisions.md` 1
## Reading cost, chunk f0194a598d2a, finished 2026-11-08T23:10:24Z
81419 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app-admin.test.ts` 11
- `examples/plugins/auth-admin/admin-users.ts` 7
- `todo.md` 6
- `examples/plugins/auth-admin/admin-groups.test.ts` 4
- `README.md` 3
- `examples/plugins/auth-admin/admin-groups.ts` 3
- `CHANGELOG.md` 2
- `docs/reading-cost.md` 2
- `examples/plugins/auth-admin/admin-grants.ts` 2
- `examples/plugins/auth-admin/i18n/en-US.ts` 2
## Reading cost, chunk a2d8600ec28b, finished 2026-11-08T23:23:17Z
66451 new input tokens before the first commit.
Read most (opens and searches):
- `src/auth/bootstrap.ts` 4
- `src/auth/jwt-middleware.ts` 4
- `README.md` 3
- `ci.sh` 3
- `examples/plugins/scheduling/plugin.ts` 3
- `src/auth/jwt.ts` 3
- `src/ui/chrome.ts` 3
- `compose.yml` 2
- `examples/plugins/auth-admin/admin-groups.ts` 2
- `examples/plugins/auth-admin/views/partials/group-detail-body.ejs` 2
## Reading cost, chunk d95a4d7286d2, finished 2026-11-08T23:59:30Z
82203 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app.ts` 8
- `src/http/app-plugins.test.ts` 4
- `todo.md` 4
- `src/http/app.test.ts` 3
- `src/function-limits.test.ts` 2
- `src/test-tooling/app-harness.ts` 2
- `docs/decisions.md` 1
- `src/auth/routes.ts` 1
- `src/http/builtin-route.test.ts` 1
- `src/http/builtin-route.ts` 1
## Reading cost, chunk 1241ed10b1c1, finished 2026-11-09T00:14:18Z
69646 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 7
- `src/auth/bootstrap.test.ts` 4
- `src/auth/bootstrap.ts` 4
- `src/auth/keto-client.ts` 4
- `src/config.ts` 3
- `src/auth/kratos-admin.ts` 2
- `src/config.test.ts` 2
- `src/function-limits.test.ts` 2
- `AGENTS.md` 1
- `CHANGELOG.md` 1
+3 -3
View File
@@ -53,7 +53,7 @@ services:
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
shifts-upstream:
image: node:24.22.2-alpine3.24
image: node:26.13.0-alpine3.24
command: ["node", "/server.ts"]
volumes:
- ./examples/shifts-upstream/server.ts:/server.ts:ro
@@ -66,7 +66,7 @@ services:
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
mock-oidc:
image: node:24.22.2-alpine3.24
image: node:26.13.0-alpine3.24
command: ["node", "/mock-oidc.ts"]
environment:
ISSUER: http://mock-oidc:9000
@@ -92,7 +92,7 @@ services:
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
proxy:
image: node:24.22.2-alpine3.24
image: node:26.13.0-alpine3.24
command: ["node", "/proxy.ts"]
depends_on:
web:
+7 -3
View File
@@ -12,10 +12,14 @@ import { expect, test as base, type BrowserContext, type Page } from "@playwrigh
const problems: string[] = [];
const allowed: RegExp[] = [];
// The one message the stack itself provokes: the runner reaches `web`/`proxy` by container name over
// The messages the stack itself provokes: the runner reaches `web`/`proxy` by container name over
// plain http, and only a `localhost` origin is trustworthy without TLS — so Chromium drops the COOP
// header the app sends and says so on every page. Over https, where a deployment serves, it applies.
const EXPECTED = [/^console\.error: The Cross-Origin-Opener-Policy header has been ignored/];
// header the app sends, and WebKit reports the page not cross-origin isolated, on every page. Over
// https, where a deployment serves, the header applies.
const EXPECTED = [
/^console\.error: The Cross-Origin-Opener-Policy header has been ignored/,
/^console\.warning: This document is not cross-origin isolated because the document is not a secure context/,
];
// Allow a message for the current test only, when the page under test provokes it on purpose.
export function allowConsole(...patterns: RegExp[]): void {
+8 -8
View File
@@ -7,7 +7,7 @@
"name": "plainpages-e2e",
"devDependencies": {
"@playwright/test": "1.65.0",
"@types/node": "24.19.2",
"@types/node": "26.6.6",
"typescript": "7.0.2"
}
},
@@ -28,13 +28,13 @@
}
},
"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==",
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
"undici-types": "~8.9.0"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
@@ -442,9 +442,9 @@
}
},
"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==",
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
}
+1 -1
View File
@@ -9,7 +9,7 @@
},
"devDependencies": {
"@playwright/test": "1.65.0",
"@types/node": "24.19.2",
"@types/node": "26.6.6",
"typescript": "7.0.2"
}
}
+4 -4
View File
@@ -35,9 +35,9 @@ screen degrades to a themed 503 rather than crashing. Everything else is an ordi
folder-discovered, gated per route by its screen's `<resource>:<action>` permission, rendering the
core building blocks in `views/`.
Each screen is its own resource — `users`, `groups`, `oauth2-clients` — split into `:read` and
`:write`, so a helpdesk account can be given `users:read` alone. Holding none of the six hides the
Admin section entirely.
Each screen is its own resource — `users`, `groups`, `oauth2-clients`, `plugin-settings` — split
into `:read` and `:write` (`plugin-settings` has only `:read`), so a helpdesk account can be given
`users:read` alone. Holding none of the seven hides the Admin section entirely.
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a
GUI, so the host's catalog (`ctx.declaredPermissions`) is the fixed list — and holding one is a
@@ -46,7 +46,7 @@ A grant of a name no installed plugin declares is listed apart on the same page,
## Layout
- `plugin.ts` — the manifest: the Admin nav fragment, the six permissions the plugin declares, and
- `plugin.ts` — the manifest: the Admin nav fragment, the seven permissions the plugin declares, and
the route table — one thin handler per method+path, gated via `permissionName(resource, actionForMethod(method))`
so a GET needs `:read` and a POST `:write`.
- `admin-grants.ts` — the permission picker and the grant diff, shared by the Users and Groups
+1 -2
View File
@@ -6,8 +6,7 @@
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
import { buildPaginationModel, can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, type FieldConfig, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100];
+3 -8
View File
@@ -8,6 +8,7 @@
import { createHash } from "node:crypto";
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
import { pagedTuples } from "./admin-shared.ts";
const PERMISSION_NS = "Permission";
const GRANTED = "granted";
@@ -36,14 +37,8 @@ export function grantTuple(permission: string, subject: GrantSubject): RelationT
// The permissions this subject holds *directly* — one Keto read filtered by the subject, not one per
// declared name. This is the edge the picker edits; `effectivePermissions` adds what a group confers.
export async function heldPermissions(keto: KetoClient, subject: GrantSubject): Promise<string[]> {
const held = new Set<string>();
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ namespace: PERMISSION_NS, relation: GRANTED, ...subject, ...(pageToken ? { pageToken } : {}) });
for (const tuple of page.tuples) held.add(tuple.object);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return [...held].sort();
const tuples = await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED, ...subject });
return [...new Set(tuples.map((tuple) => tuple.object))].sort();
}
// Every declared permission the subject effectively holds — direct grants *plus* anything reached
+17 -23
View File
@@ -2,20 +2,20 @@
// A group is a Keto subject set `Group:<name>#members`; a member is a user or a nested group (see
// parseSubject). Writes go only to Keto (README "stateless"). Keto has no "create object" — a group
// exists exactly while it has ≥1 member, so create writes its first-member tuple and delete removes
// every member tuple. Pure builders turn tuples + the request URL into view models; below them are thin
// every tuple that makes it a group or puts it in another. Pure builders turn tuples + the request URL into view models; below them are thin
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
// each returning a RouteResult.
import { buildPaginationModel, can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
import { buildPaginationModel, can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
import { 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";
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, type FieldConfig, guardedForm, notFound, pagedTuples, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
const GROUP_NS = "Group";
const MEMBERS = "members";
const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100];
// One Keto page of candidate users is fetched for the member pickers (mirrors admin-users).
// One Kratos `listIdentities` page of candidate users is fetched for the member pickers (mirrors
// admin-users).
const LIST_FETCH_SIZE = 250;
const GROUP_NAME = /^[a-z0-9][a-z0-9_-]*$/; // URL-safe; doubles as the path segment
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // a Kratos identity id
@@ -242,18 +242,6 @@ export function buildGroupDetailModel(opts: {
// ---- request handler (imperative shell) ----
// Drain every page of a relation-tuple query.
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
const out: RelationTuple[] = [];
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ ...query, ...(pageToken ? { pageToken } : {}) });
out.push(...page.tuples);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return out;
}
// Build the member-picker options (every user by email + every existing group) and the id→email map
// detail rows render with. One Kratos page + one Keto scan; ample for an admin tool.
export async function memberCandidates(keto: KetoClient, kratosAdmin: KratosAdmin): Promise<{ emailById: Map<string, string>; options: MemberOption[] }> {
@@ -431,19 +419,25 @@ export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
}) }, view: "confirm" };
}, "write");
// POST /auth-admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
// POST /auth-admin/groups/:name/delete — one Keto transaction removes the group's member tuples,
// the tuples naming it as a member of a parent group, and the grants it holds, so a Keto failure
// leaves the group as it was.
export const groupsDelete = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST
const lost = await lostOnDelete(deps, name);
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
// Drop what the group *holds* before what it *contains*: a Keto set exists only through its
// tuples, so leaving the grants behind would resurrect every permission the moment someone
// re-created a group with the same name.
// A Keto set exists only through its tuples, so leaving the grants or the parents' membership
// tuples behind would resurrect every permission and parent membership the moment someone
// re-created a group with the same name. A parent that had only this group as a member is left
// with none and so ceases to exist, as when its last member is removed by hand; its own grants
// and parent memberships stay (todo.md).
const subject = groupSubject(name);
const 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 });
const inParents = await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS, ...subject });
await keto.transactTuples({ delete: [...grants, ...members, ...inParents], insert: [] });
ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
return { redirect: ADMIN_GROUPS_BASE };
});
@@ -6,8 +6,8 @@ import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream";
import { test } from "node:test";
import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "@plainpages/plugin-api";
import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts";
import { GuardError, isValidPermissionName, type KetoClient, type Log, type PageChrome, type RequestContext, type User } from "@plainpages/plugin-api";
import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, pagedTuples, permissionName, requirePermission } from "./admin-shared.ts";
const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] };
const writer: User = { email: "cy@x.io", id: "u3", permissions: ["users:read", "users:write"] };
@@ -100,3 +100,23 @@ test("buildConfirmModel wires the danger action, message, breadcrumbs and title
assert.equal(model.title, "Delete user");
assert.deepEqual(model.breadcrumbs.at(-1), { label: "Delete" });
});
test("pagedTuples follows the page token to the last page and keeps the query on every page", async () => {
const queries: unknown[] = [];
const tuple = (object: string) => ({ namespace: "Group", object, relation: "members", subject_id: "user:a" });
const keto = {
listRelations: async (q: { pageToken?: string } = {}) => {
queries.push(q);
if (q.pageToken === "p2") return { nextPageToken: "p3", tuples: [tuple("b")] };
if (q.pageToken === "p3") return { nextPageToken: null, tuples: [tuple("c")] };
return { nextPageToken: "p2", tuples: [tuple("a")] };
},
} as unknown as KetoClient;
const all = await pagedTuples(keto, { namespace: "Group", relation: "members" });
assert.deepEqual(all.map((t) => t.object), ["a", "b", "c"]);
assert.deepEqual(queries, [
{ namespace: "Group", relation: "members" },
{ namespace: "Group", pageToken: "p2", relation: "members" },
{ namespace: "Group", pageToken: "p3", relation: "members" },
]);
});
+29 -3
View File
@@ -1,8 +1,7 @@
// Shared plumbing for the admin example plugin: the section nav fragment, the screen gate, the
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers.
// Shared plumbing for the admin example plugin's screens.
// Everything imports the host only through the @plainpages/plugin-api barrel.
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { can, CSRF_FIELD, englishTranslator, GuardError, type KetoClient, type NavNode, readFormBody, type RelationQuery, type RelationTuple, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts";
// This plugin's English — its catalog, then the host's — for a view model built outside a request,
@@ -103,3 +102,30 @@ export function buildConfirmModel(opts: {
title: opts.title,
};
}
// The config one text input takes in the core `field` partial.
export interface FieldConfig {
autocomplete?: string;
hint?: string;
icon?: string;
id: string;
label: string;
name: string;
optional?: boolean;
readonly?: boolean;
required?: boolean;
type?: string;
value?: string;
}
// Drain every page of a relation-tuple query.
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
const out: RelationTuple[] = [];
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ ...query, ...(pageToken ? { pageToken } : {}) });
out.push(...page.tuples);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return out;
}
+16 -23
View File
@@ -5,7 +5,7 @@
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";
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, type FieldConfig, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
const DEFAULT_PAGE_SIZE = 25;
@@ -185,20 +185,6 @@ function listFilterBar(state: ListState, total: number, t: Translate) {
};
}
export interface FieldConfig {
autocomplete?: string;
hint?: string;
icon?: string;
id: string;
label: string;
name: string;
optional?: boolean;
readonly?: boolean;
required?: boolean;
type?: string;
value?: string;
}
export function buildUserFormModel(opts: {
canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance
csrfToken?: string;
@@ -391,8 +377,7 @@ export const usersUpdate = withTarget(async (deps, identity, id) => {
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)}` };
@@ -400,9 +385,10 @@ export const usersUpdate = withTarget(async (deps, identity, id) => {
// POST /auth-admin/users/:id/state — toggle active/inactive; a deactivation revokes the target's live
// tokens now (not after the JWT TTL). Self-protection: an admin can't deactivate their own account.
export const usersState = withTarget(async ({ 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);
@@ -412,7 +398,7 @@ export const usersState = withTarget(async ({ ctx, kratosAdmin, revoke, user },
// 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;
@@ -424,9 +410,10 @@ export const usersDeleteConfirm = withTarget((deps, identity, id) => {
}, "write");
// POST /auth-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) => {
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 });
@@ -441,7 +428,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 } : {};
@@ -1,9 +1,11 @@
<%#
Admin group membership body, captured into the shell content slot. Config:
group { name }
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = remove-member endpoint
members { action, rows: { kind:"group"|"user", label, subject }[] } action = remove-member endpoint
add { action, options: {label,value}[] } action = add-member endpoint
del { action } delete the whole group
canWrite? false (a `groups:read` holder) drops the add, remove and delete forms
permissions? PermissionPicker; when set, the group's permission picker is rendered
csrfToken, error?
%><%
const group = locals.group;
+1 -1
View File
@@ -34,7 +34,7 @@ export default definePlugin({
public: true, // the header gates nothing; each child names its own gate, and an empty header is dropped
}],
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
// Permissions this plugin declares: the admin picker offers them and bootstrap grants them to the demo admin.
permissions: [
{ description: "View shifts", name: READ },
{ description: "Create and edit shifts", name: WRITE },
+21 -21
View File
@@ -6,15 +6,15 @@
"": {
"name": "plainpages",
"dependencies": {
"@larvit/log": "2.3.0",
"ejs": "6.0.1",
"@larvit/log": "2.4.0",
"ejs": "7.0.2",
"lucide-static": "1.70.0",
"postgres": "3.4.9"
"postgres": "4.1.0"
},
"devDependencies": {
"@mdn/browser-compat-data": "8.1.8",
"@types/ejs": "3.1.5",
"@types/node": "24.19.2",
"@types/node": "26.6.6",
"acorn": "8.19.0",
"typescript": "7.0.2"
},
@@ -23,9 +23,9 @@
}
},
"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"
@@ -46,13 +46,13 @@
"license": "MIT"
},
"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==",
"version": "26.6.6",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.6.tgz",
"integrity": "sha512-MzQj+8Kx3XI40OmUAE6Qoh58ZMNstNNMwn2nfd3qWsfe7nDNSqJNzes4njhz6AHwrSHOffSLwXU6yb474Q4aog==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
"undici-types": "~8.9.0"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
@@ -409,9 +409,9 @@
}
},
"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"
@@ -427,12 +427,12 @@
"license": "ISC"
},
"node_modules/postgres": {
"version": "3.4.9",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-4.1.0.tgz",
"integrity": "sha512-s+0WH67YAyLdBQUb6XczbziBhY+C9uiQsg9S8R5RHdZhk6sdyK6202E+T2tXpiInSEEjDLRSJ3OpYBe3PN6MsQ==",
"license": "Unlicense",
"engines": {
"node": ">=12"
"node": ">=18"
},
"funding": {
"type": "individual",
@@ -475,9 +475,9 @@
}
},
"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==",
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"dev": true,
"license": "MIT"
}
+4 -4
View File
@@ -16,15 +16,15 @@
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"release-tooling/**/*.test.ts\""
},
"dependencies": {
"@larvit/log": "2.3.0",
"ejs": "6.0.1",
"@larvit/log": "2.4.0",
"ejs": "7.0.2",
"lucide-static": "1.70.0",
"postgres": "3.4.9"
"postgres": "4.1.0"
},
"devDependencies": {
"@mdn/browser-compat-data": "8.1.8",
"@types/ejs": "3.1.5",
"@types/node": "24.19.2",
"@types/node": "26.6.6",
"acorn": "8.19.0",
"typescript": "7.0.2"
}
+5
View File
@@ -20,6 +20,11 @@
resolution is unreliable across engines. color-scheme is still
set as a hint for native widgets (date picker, scrollbars,
selects).
Footgun: a token value an engine of the oldest browser floor can't parse (a
function or unit it lacks) is invalid at computed-value time, so every
declaration reading it through var() computes as `unset`; a fallback declared
before it doesn't help. The test in src/test-tooling/stylesheet-floor.ts
fails on such a value.
------------------------------------------------------------- */
:root {
/* ---- raw LIGHT palette ---- */
+1 -1
View File
@@ -26,7 +26,7 @@ test("every author-facing apiVersion sample matches the shipped contract", () =>
"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`);
+29 -19
View File
@@ -7,6 +7,8 @@ import assert from "node:assert/strict";
import { randomUUID } from "node:crypto";
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, provisionPluginStorage, seedAdmin, seedPermissions, serverMismatch } from "./bootstrap.ts";
import { createLogger } from "../logger.ts";
import { createKetoClient } from "./keto-client.ts";
import { createKratosAdmin } from "./kratos-admin.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import type { ProvisionOptions, ProvisionResult } from "../plugin-host/storage-provisioning.ts";
@@ -75,9 +77,8 @@ test("seedAdmin on a fresh stack creates the identity and grants every permissio
const result = await seedAdmin({
email: "admin@plainpages.local",
fetchImpl,
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }),
kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }),
password: "admin",
permissions: ["admin", "scheduling:read"],
});
@@ -108,9 +109,8 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
const result = await seedAdmin({
email: "admin@plainpages.local",
fetchImpl,
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }),
kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }),
password: "admin",
permissions: ["admin"],
});
@@ -119,19 +119,29 @@ test("seedAdmin is idempotent: a 409 reuses the existing identity and re-grants
assert.deepEqual(granted, { namespace: "Permission", object: "admin", relation: "granted", subject_id: `user:${id}` });
});
test("seedAdmin fails loud on an unexpected Kratos error", async () => {
const fetchImpl = (async () => json(500, { error: "boom" })) as typeof fetch;
await assert.rejects(
seedAdmin({
email: "admin@plainpages.local",
fetchImpl,
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
password: "admin",
permissions: ["admin"],
}),
/Kratos/,
);
const clients = (fetchImpl: typeof fetch) => ({
keto: createKetoClient({ fetchImpl, readUrl: "http://keto:4466", writeUrl: "http://keto:4467" }),
kratos: createKratosAdmin({ baseUrl: "http://kratos:4434", fetchImpl }),
});
test("seedAdmin fails loud on a Kratos error other than 409, without looking the admin up", async () => {
const urls: string[] = [];
const fetchImpl = (async (url) => {
urls.push(String(url));
return json(500, { error: "boom" });
}) as typeof fetch;
await assert.rejects(seedAdmin({ email: "admin@plainpages.local", password: "admin", permissions: ["admin"], ...clients(fetchImpl) }), /bootstrap: Kratos.*500/);
assert.equal(urls.length, 1);
});
test("seedAdmin fails loud when a 409 admin cannot be found", async () => {
const fetchImpl = (async (_url, init) => (init?.method === "POST" ? json(409, {}) : json(200, []))) as typeof fetch;
await assert.rejects(seedAdmin({ email: "admin@plainpages.local", password: "admin", permissions: [], ...clients(fetchImpl) }), /reported as existing but not found/);
});
test("seedAdmin fails loud when Keto refuses a grant, naming the permission", async () => {
const fetchImpl = (async (url) => (String(url).endsWith("/admin/identities") ? json(201, { id: "u1" }) : json(500, {}))) as typeof fetch;
await assert.rejects(seedAdmin({ email: "admin@plainpages.local", password: "admin", permissions: ["users:read"], ...clients(fetchImpl) }), /granting "users:read" in Keto/);
});
test("firstRunBanner prints the login URL, seeded creds, and a change-before-production warning", () => {
+41 -62
View File
@@ -1,19 +1,20 @@
// One-command bootstrap (the MVP bar). One-shot compose service: runs after
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
// 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — every
// discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so a dropped-in
// plugin is usable by the demo admin with no host config edit (the host stays plugin-agnostic).
// Then prints a first-run banner; fails loud on any unexpected upstream error.
// The one-shot compose service (`node src/auth/bootstrap.ts`) that prepares a stack. It starts once
// Kratos and Keto are healthy, `web` waits for it, and it repeats safely on every `docker compose up`.
// Steps, in the order `main` runs them: write the JWKS signing key if absent, create each storage
// plugin's database and role, then seed the demo admin in Kratos and grant it in Keto every
// discovered plugin's declared permissions (plus ADMIN_PERMISSIONS). Any unexpected upstream error
// fails the run loudly.
import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts";
import { type BootstrapConfig, loadBootstrapConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts";
import { discoverPlugins } from "../plugin-host/discovery.ts";
import { declaredPermissions, isValidPermissionName, type Plugin } from "../plugin-host/plugin.ts";
import { provisionStorage } from "../plugin-host/storage-provisioning.ts";
import { storagePluginIds } from "../plugin-host/storage.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
import { createKetoClient, type KetoClient } from "./keto-client.ts";
import { createKratosAdmin, type KratosAdmin } from "./kratos-admin.ts";
import { KratosError } from "./kratos-public.ts";
import { createLogger, runWithLog, tracedFetch, type Log } from "../logger.ts";
type Env = Record<string, string | undefined>;
@@ -70,9 +71,8 @@ export function ensureJwks(path: string, hooks: JwksFsHooks = {}): boolean {
export interface SeedOptions {
email: string;
fetchImpl?: typeof fetch;
ketoWriteUrl: string;
kratosAdminUrl: string;
keto: KetoClient;
kratos: KratosAdmin;
password: string;
permissions: string[];
}
@@ -84,45 +84,29 @@ export interface SeedResult {
}
export async function seedAdmin(opts: SeedOptions): Promise<SeedResult> {
const http = opts.fetchImpl ?? fetch;
const { created, id } = await createOrFindAdmin(opts.kratos, opts.email, opts.password);
// Create the identity. A 409 means it already exists (a re-run) — look up its id.
const res = await http(`${opts.kratosAdminUrl}/admin/identities`, {
body: JSON.stringify(identityPayload(opts.email, opts.password)),
headers: { "content-type": "application/json" },
method: "POST",
});
let created: boolean;
let id: string;
if (res.status === 201) {
id = ((await res.json()) as { id: string }).id;
created = true;
} else if (res.status === 409) {
id = await findIdentityId(http, opts.kratosAdminUrl, opts.email);
created = false;
} else {
throw new Error(`bootstrap: Kratos create identity failed (${res.status}): ${await res.text()}`);
}
// Grant each permission in Keto. PUT is idempotent — re-running just re-asserts the tuple.
for (const permission of opts.permissions) {
const grant = await http(`${opts.ketoWriteUrl}/admin/relation-tuples`, {
body: JSON.stringify(permissionTuple(id, permission)),
headers: { "content-type": "application/json" },
method: "PUT",
});
if (!grant.ok) throw new Error(`bootstrap: Keto grant permission "${permission}" failed (${grant.status}): ${await grant.text()}`);
try {
await opts.keto.writeTuple(permissionTuple(id, permission));
} catch (err) {
throw new Error(`bootstrap: granting "${permission}" in Keto: ${(err as Error).message}`, { cause: err });
}
}
return { created, id, permissions: opts.permissions };
}
async function findIdentityId(http: typeof fetch, adminUrl: string, email: string): Promise<string> {
const res = await http(`${adminUrl}/admin/identities?credentials_identifier=${encodeURIComponent(email)}`);
if (!res.ok) throw new Error(`bootstrap: Kratos lookup failed (${res.status}): ${await res.text()}`);
const found = ((await res.json()) as { id: string }[])[0];
if (!found?.id) throw new Error(`bootstrap: ${email} reported as existing but not found`);
return found.id;
// A Kratos 409 means the admin exists (a re-run), so look its id up by login identifier.
async function createOrFindAdmin(kratos: KratosAdmin, email: string, password: string): Promise<{ created: boolean; id: string }> {
try {
return { created: true, id: (await kratos.createIdentity(identityPayload(email, password))).id };
} catch (err) {
if (!(err instanceof KratosError) || err.status !== 409) throw new Error(`bootstrap: ${(err as Error).message}`, { cause: err });
}
const found = (await kratos.listIdentities({ credentialsIdentifier: email })).identities[0];
if (!found) throw new Error(`bootstrap: ${email} reported as existing but not found`);
return { created: false, id: found.id };
}
// --- First-run banner ----------------------------------------------------------------
@@ -145,21 +129,19 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
async function main() {
const env = { ...process.env }; // snapshot: the storage credentials leave process.env before discovery
// Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME.
const log = createLogger({
format: env["LOG_FORMAT"] === "json" ? "json" : "text",
...(env["SERVICE_NAME"] ? { serviceName: env["SERVICE_NAME"] } : {}),
});
// runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls.
const config = loadBootstrapConfig(env); // first, so a bad setting fails before any step has run
// Structured like the web app so prod logs stay uniform.
const log = createLogger({ format: config.logFormat, ...(config.serviceName ? { serviceName: config.serviceName } : {}) });
// runWithLog makes `log` ambient so the Kratos/Keto clients' tracedFetch traces the seed calls.
await runWithLog(log, async () => {
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
if (ensureJwks(config.jwksFile)) log.info("generated a JWKS signing key");
// Discovery imports every plugin module — and its dependencies — into *this* process, which holds
// the credential that may CREATE DATABASE/ROLE. Same move as server.ts, on the stronger secret.
delete process.env["PLUGIN_DB_ADMIN_URL"];
delete process.env["PLUGIN_DB_SECRET"];
const plugins = await discoverPlugins();
await provisionPluginStorage(env, plugins, log);
await seedAdminAndPermissions(env, plugins, log);
await seedAdminAndPermissions(config, plugins, log);
});
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
}
@@ -212,25 +194,22 @@ function safeHostPort(url: string): string | null {
// Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
async function seedAdminAndPermissions(env: Env, plugins: Plugin[], log: Log): Promise<void> {
async function seedAdminAndPermissions(config: BootstrapConfig, plugins: Plugin[], log: Log): Promise<void> {
const declared = declaredPermissions(plugins).map((decl) => decl.name);
const { ignored, permissions } = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
const { ignored, permissions } = seedPermissions(config.adminPermissions, declared);
if (ignored.length > 0) {
log.warn("ignoring ADMIN_PERMISSIONS entries that are not <resource>:<action>", { ignored: ignored.join(", ") });
}
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({
email,
fetchImpl: tracedFetch,
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
password,
email: config.adminEmail,
keto: createKetoClient({ fetchImpl: tracedFetch, readUrl: config.ketoReadUrl, writeUrl: config.ketoWriteUrl }),
kratos: createKratosAdmin({ baseUrl: config.kratosAdminUrl, fetchImpl: tracedFetch }),
password: config.adminPassword,
permissions,
});
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
console.log(firstRunBanner({ appUrl: config.appUrl, email: config.adminEmail, password: config.adminPassword }));
}
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
+2 -1
View File
@@ -4,8 +4,9 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { createPrivateKey, sign, type JsonWebKey } from "node:crypto";
import { createPrivateKey, sign } from "node:crypto";
import { generateJwks, rotateJwks } from "./gen-jwks.ts";
import type { JsonWebKey } from "./jwks.ts";
import { verifyJws } from "./jwt.ts";
const b64url = (s: string) => Buffer.from(s).toString("base64url");
+1 -7
View File
@@ -3,6 +3,7 @@
// *through* us with. Built-in `fetch` only, no SDK dep (AGENTS.md); `fetchImpl`-injectable
// like the kratos/keto clients. We authenticate the user (login) and grant scopes (consent);
// Hydra mints the tokens.
import { nextPageToken } from "./link-header.ts";
export interface OAuth2Client {
client_id?: string;
@@ -101,13 +102,6 @@ export interface HydraAdmin {
rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>;
}
// Hydra paginates with a Link header; pull the page_token of rel="next" (the href is relative, so
// resolve against a throwaway base to read the query param). Mirrors kratos-admin's helper.
function nextPageToken(link: string | null): string | null {
const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1];
return href ? new URL(href, "http://hydra").searchParams.get("page_token") : null;
}
export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): HydraAdmin {
const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch;
+2 -2
View File
@@ -1,9 +1,9 @@
import assert from "node:assert/strict";
import { generateKeyPairSync, type JsonWebKey } from "node:crypto";
import { generateKeyPairSync } from "node:crypto";
import { dirname, join } from "node:path";
import { test } from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url";
import { cachingJwks, createJwksProvider, loadJwks, staticJwks } from "./jwks.ts";
import { cachingJwks, createJwksProvider, loadJwks, staticJwks, type JsonWebKey } from "./jwks.ts";
const jwk = (kid: string): JsonWebKey => ({ ...(generateKeyPairSync("ec", { namedCurve: "P-256" }).publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid });
const committed = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "ory/kratos/tokenizer/jwks.json");
+5 -1
View File
@@ -1,4 +1,4 @@
import type { JsonWebKey } from "node:crypto";
import type { webcrypto } from "node:crypto";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { currentLog } from "../logger.ts";
@@ -8,6 +8,10 @@ import { currentLog } from "../logger.ts";
// (or re-reads a mounted file), caches for a TTL, and reloads once on a `kid` miss so a rotated-
// in key is picked up without a restart (README: zero-downtime rotation). `createJwksProvider`
// picks the right one from the configured URL scheme and primes it at boot (fail loud).
// node:crypto exports the JWK type only under webcrypto since @types/node 26, and that type lacks
// the `kid` a JWKS selects a key by.
export type JsonWebKey = webcrypto.JsonWebKey & { kid?: string };
export interface JwksProvider {
getKey(kid: string | undefined): Promise<JsonWebKey | null>;
}
+2 -2
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict";
import { generateKeyPairSync, sign, type JsonWebKey, type KeyObject } from "node:crypto";
import { generateKeyPairSync, sign, type KeyObject } from "node:crypto";
import { test } from "node:test";
import { staticJwks } from "./jwks.ts";
import { staticJwks, type JsonWebKey } from "./jwks.ts";
import { authenticate, claimsToUser, resolveSession, verifyToken } from "./jwt-middleware.ts";
import { SESSION_COOKIE } from "./login.ts";
+2 -1
View File
@@ -1,6 +1,7 @@
import assert from "node:assert/strict";
import { generateKeyPairSync, sign } from "node:crypto";
import type { JsonWebKey, KeyObject } from "node:crypto";
import type { KeyObject } from "node:crypto";
import type { JsonWebKey } from "./jwks.ts";
import { test } from "node:test";
import { decodeJws, verifyJws } from "./jwt.ts";
+4 -3
View File
@@ -1,10 +1,11 @@
import { createPublicKey, verify } from "node:crypto";
import type { JsonWebKey, KeyObject } from "node:crypto";
import type { KeyObject } from "node:crypto";
import type { JsonWebKey } from "./jwks.ts";
// JWS signature verification with the Node stdlib — no `jose`/JWT dep:
// `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures
// the Kratos tokenizer produces (see AGENTS.md). Signature only — adds claim checks
// (exp/iss/aud, clock skew), JWKS-by-`kid` fetch/cache/rotation, and `token` bounds.
// the Kratos tokenizer produces. Signature only: `jwt-middleware.ts`
// picks the key by `kid` and checks the claims.
// JOSE `alg` → Node verify parameters. ES* signatures are raw r‖s (IEEE P1363), not DER.
// Extend this map to widen support. Security invariant: never add `HS*`/`none` — this map
+1 -7
View File
@@ -3,6 +3,7 @@
// completion projects Keto permissions into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
import { KratosError } from "./kratos-public.ts";
import { nextPageToken } from "./link-header.ts";
export interface Identity {
id: string;
@@ -41,13 +42,6 @@ export interface KratosAdmin {
updateMetadataPublic(id: string, metadata: unknown): Promise<Identity>;
}
// Kratos paginates with a Link header; pull the page_token of rel="next" (the href is a
// relative path, so resolve it against a throwaway base just to read the query param).
function nextPageToken(link: string | null): string | null {
const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1];
return href ? new URL(href, "http://kratos").searchParams.get("page_token") : null;
}
export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosAdmin {
const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch;
+14
View File
@@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { nextPageToken } from "./link-header.ts";
test("nextPageToken reads the page_token of the rel=next entry among several", () => {
const link = `</admin/identities?page_size=2&page_token=NEXT>; rel="next",</admin/identities?page_size=2>; rel="first"`;
assert.equal(nextPageToken(link), "NEXT");
});
test("nextPageToken is null with no header, no next entry, or a next href without a page_token", () => {
assert.equal(nextPageToken(null), null);
assert.equal(nextPageToken(`</admin/clients?page_size=2>; rel="first"`), null);
assert.equal(nextPageToken(`</admin/clients?page_size=2>; rel="next"`), null);
});
+6
View File
@@ -0,0 +1,6 @@
// The `page_token` of a `Link` header's `rel="next"` entry (Kratos and Hydra paginate with it), or
// null when there is none. The href is relative, so it is resolved against a throwaway base.
export function nextPageToken(link: string | null): string | null {
const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1];
return href ? new URL(href, "http://link-header.invalid").searchParams.get("page_token") : null;
}
+2 -2
View File
@@ -40,8 +40,8 @@ export interface CompletedLogin {
// 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.
// matching the OPL model. 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[]> {
const subject_id = `user:${userId}`;
const names = new Set<string>();
+34 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { loadConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "./config.ts";
import { loadBootstrapConfig, loadConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "./config.ts";
// Explicit secure-secret enforcement (no environment sniffing): secrets are the only
// thing a hardened deploy must supply.
@@ -63,6 +63,39 @@ test("the per-role connection ceiling defaults to 10 and rejects nonsense", () =
assert.throws(() => resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "ten" }), /positive integer/);
});
test("the one-shot bootstrap config has dev defaults and demands no web secret, even when secure secrets are required", () => {
const c = loadBootstrapConfig({ REQUIRE_SECURE_SECRETS: "true" });
assert.deepEqual(c, {
adminEmail: "admin@plainpages.local",
adminPassword: "admin",
adminPermissions: undefined,
appUrl: "http://localhost:3000",
ketoReadUrl: "http://keto:4466",
ketoWriteUrl: "http://keto:4467",
kratosAdminUrl: "http://kratos:4434",
jwksFile: "/etc/config/kratos/tokenizer/jwks.json",
logFormat: "text",
serviceName: undefined,
});
assert.equal(loadBootstrapConfig({ ADMIN_EMAIL: "", ADMIN_PASSWORD: "" }).adminEmail, "admin@plainpages.local");
const set = loadBootstrapConfig({
ADMIN_EMAIL: "a@b.c",
ADMIN_PASSWORD: "s3cret",
ADMIN_PERMISSIONS: "users:read",
APP_URL: "https://x.test",
JWKS_FILE: "/k.json",
KRATOS_ADMIN_URL: "http://k:1",
LOG_FORMAT: "json",
SERVICE_NAME: "boot",
});
assert.deepEqual(
[set.adminEmail, set.adminPassword, set.adminPermissions, set.appUrl, set.jwksFile, set.kratosAdminUrl, set.logFormat, set.serviceName],
["a@b.c", "s3cret", "users:read", "https://x.test", "/k.json", "http://k:1", "json", "boot"],
);
assert.throws(() => loadBootstrapConfig({ LOG_FORMAT: "JSON" }), /LOG_FORMAT/);
assert.throws(() => loadBootstrapConfig({ KETO_WRITE_URL: "not a url" }), /KETO_WRITE_URL/);
});
test("loads dev defaults when the environment is empty", () => {
const c = loadConfig({});
assert.equal(c.port, 3000);
+38 -3
View File
@@ -158,6 +158,43 @@ function readTrustedProxies(env: Env): string[] {
return entries;
}
function readKratosKetoUrls(env: Env) {
return {
ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"),
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
};
}
export interface BootstrapConfig {
adminEmail: string;
adminPassword: string;
adminPermissions: string | undefined; // comma-separated, validated where it is used
appUrl: string; // only printed in the first-run banner; unlike Config.appUrl, it has a default
ketoReadUrl: string;
ketoWriteUrl: string;
kratosAdminUrl: string;
jwksFile: string;
logFormat: Config["logFormat"];
serviceName: string | undefined;
}
// What the one-shot bootstrap service reads, apart from the plugin-storage variables, which
// `resolvePluginDb*` and `PLUGIN_DB_*` cover. Not `loadConfig`: that demands web's own secrets, which
// compose does not give the bootstrap service while it sets REQUIRE_SECURE_SECRETS there.
export function loadBootstrapConfig(env: Env): BootstrapConfig {
return {
adminEmail: env["ADMIN_EMAIL"] || "admin@plainpages.local",
adminPassword: env["ADMIN_PASSWORD"] || "admin",
adminPermissions: readOptional(env, "ADMIN_PERMISSIONS"),
appUrl: readOptionalUrl(env, "APP_URL") ?? "http://localhost:3000",
jwksFile: env["JWKS_FILE"] || "/etc/config/kratos/tokenizer/jwks.json",
logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"),
serviceName: readOptional(env, "SERVICE_NAME"),
...readKratosKetoUrls(env),
};
}
export function loadConfig(env: Env = process.env): Config {
const requireSecure = readBool(env, "REQUIRE_SECURE_SECRETS", false);
return {
@@ -181,9 +218,7 @@ export function loadConfig(env: Env = process.env): Config {
// exp/nbf leeway (s) for Kratos↔web clock drift; the auth E2E sets 0 to time tokens out fast.
jwtClockSkewSec: readNonNegInt(env, "JWT_CLOCK_SKEW_SEC", 60),
jwtIssuer: readOptional(env, "JWT_ISSUER"),
ketoReadUrl: readUrl(env, "KETO_READ_URL", "http://keto:4466"),
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
...readKratosKetoUrls(env),
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
// Console-only unless OTLP_ENDPOINT names a collector.
logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"),
+1 -2
View File
@@ -13,13 +13,12 @@ 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/http/app.ts createApp": { lines: 133, 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 },
};
+78
View File
@@ -11,6 +11,7 @@ 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";
@@ -213,6 +214,11 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
// 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");
@@ -220,6 +226,7 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
// …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);
@@ -368,6 +375,77 @@ test("admin permission grants: a Keto failure applies none of the changes and re
assert.equal(tuples.filter((tp) => tp.subject_set?.object === "eng").length, 0);
});
test("admin users: a refused self-deactivate or self-delete keeps the permissions section", async (t) => {
const identities: Identity[] = [{ id: "admin1", traits: { email: "admin@x" } }];
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { get, post, token } = await adminHarness(t, { keto: fakeKeto(), kratosAdmin });
assert.notEqual(baselineOf(await (await get("/auth-admin/users/admin1")).text()), "", "the plain form carries the section");
const refusals: [Response, RegExp][] = [
[await post("/auth-admin/users/admin1/state", `_csrf=${token}`), /deactivate your own account/],
[await get("/auth-admin/users/admin1/delete"), /delete your own account/],
[await post("/auth-admin/users/admin1/delete", `_csrf=${token}`), /delete your own account/],
];
for (const [res, reason] of refusals) {
assert.equal(res.status, 400);
const html = await res.text();
assert.match(html, reason);
assert.notEqual(baselineOf(html), "");
}
});
test("admin users: a Kratos rejection of a save re-renders the form with its permissions, any other failure is a 500", async (t) => {
const identities: Identity[] = [{ id: "ada", traits: { email: "ada@example.com" } }];
let failure: Error = new KratosError("rejected", 400, "");
const kratosAdmin = stubAdmin({
getIdentity: async (id) => identities.find((i) => i.id === id) ?? null,
listIdentities: async () => ({ identities, nextPageToken: null }),
updateIdentity: async () => { throw failure; },
});
const { post, token } = await adminHarness(t, { keto: fakeKeto(), kratosAdmin });
const rejected = await post("/auth-admin/users/ada", `_csrf=${token}&first=Ada`);
assert.equal(rejected.status, 400);
assert.notEqual(baselineOf(await rejected.text()), "");
failure = new Error("kratos unreachable");
assert.equal((await post("/auth-admin/users/ada", `_csrf=${token}&first=Ada`)).status, 500);
});
test("admin group delete: a Keto failure leaves the group's grants and members in place", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const tuples: RelationTuple[] = [
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` },
{ namespace: "Permission", object: "users:read", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } },
];
const keto = fakeKeto(tuples, { transactTuples: async () => { throw new Error("keto unreachable"); } });
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { post, token } = await adminHarness(t, { keto, kratosAdmin });
const res = await post("/auth-admin/groups/eng/delete", `_csrf=${token}`);
assert.equal(res.status, 500);
assert.equal(tuples.length, 2);
});
test("admin group delete: removes the group from every parent, so re-creating the name restores no nesting", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const member = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Group", object, relation: "members", ...subject } as RelationTuple);
const inEng = { subject_set: { namespace: "Group", object: "eng", relation: "members" } };
const tuples: RelationTuple[] = [
member("eng", { subject_id: `user:${ada}` }),
member("org", { subject_id: `user:${ada}` }),
member("org", inEng),
member("staff", inEng),
member("staff", { subject_id: "user:other" }),
];
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { post, token } = await adminHarness(t, { keto: fakeKeto(tuples), kratosAdmin });
assert.equal((await post("/auth-admin/groups/eng/delete", `_csrf=${token}`)).status, 303);
assert.deepEqual(tuples, [member("org", { subject_id: `user:${ada}` }), member("staff", { subject_id: "user:other" })]);
});
// A double-clicked Save posts twice from one page; the second post finds the first one's result.
// A 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) => {
+16 -2
View File
@@ -78,12 +78,15 @@ test("a plugin that owns a landing page, or short-circuits a hook, translates fr
mkdirSync(join(dir, "demo", "i18n"), { recursive: true });
t.after(() => rmSync(dir, { force: true, recursive: true }));
writeFileSync(join(dir, "demo", "i18n", "en-US.ts"), 'const m = { "demo.hello": "Hello from the plugin" };\nexport default m;\n');
mkdirSync(join(dir, "demo", "views"), { recursive: true });
writeFileSync(join(dir, "demo", "views", "landing.ejs"), '<%= t("demo.hello") %>');
// Every plugin-owned render path: the public landing, the gated dashboard, and a hook short-circuit.
// The landings return a view, so the host's choice of views and context shows in the output.
const demo: Plugin = {
apiVersion: "1.0.0",
dashboard: (ctx) => ({ html: ctx.t("demo.hello") }),
home: (ctx) => ({ html: ctx.t("demo.hello") }),
dashboard: () => ({ view: "landing" }),
home: () => ({ view: "landing" }),
hooks: { onRequest: (ctx) => (ctx.url.pathname === "/hooked" ? { html: ctx.t("demo.hello") } : undefined) },
id: "demo",
};
@@ -100,6 +103,17 @@ test("a plugin that owns a landing page, or short-circuits a hook, translates fr
assert.equal(await (await fetch(`${url}/dashboard`, { headers: { cookie } })).text(), "Hello from the plugin");
});
test("a redirect from a plugin-owned landing keeps the visitor's chosen locale", async (t) => {
const demo: Plugin = { apiVersion: "1.0.0", home: () => ({ redirect: "/demo/next" }), id: "demo" };
const app = createApp({ i18n: createI18n(await loadI18n()), plugins: [demo] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const res = await fetch(`${url}/?locale=sv-SE`, { redirect: "manual" });
assert.equal(res.status, 303);
assert.equal(res.headers.get("location"), "/demo/next?locale=sv-SE");
});
test("an error page renders without composing the menu — it exists for when the shell's data is what failed", async (t) => {
// The chrome getter is lazy on purpose; a render that reads no chrome must not trigger it, or a
// broken menu takes the error pages down with it.
+24 -1
View File
@@ -15,7 +15,7 @@ import { KratosError, type Session } from "../auth/kratos-public.ts";
import { SESSION_COOKIE } from "../auth/login.ts";
import { createLogger } from "../logger.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import { ecJwk, fakeKeto, mintJwt, startApp, stubAdmin, withWhoami } from "../test-tooling/app-harness.ts";
import { ecJwk, fakeKeto, mintJwt, session, startApp, stubAdmin, withWhoami } from "../test-tooling/app-harness.ts";
// A test plugin exercising each RouteResult shape, a path param, and the permission gate.
const demoPlugin: Plugin = {
@@ -350,6 +350,29 @@ test("plugin hooks: onRequest can short-circuit a request and onResponse observe
assert.ok(seen.includes("/hooked/ok:handler ran"));
});
test("plugin hooks: onResponse observes the result of a plugin-owned `/` and `/dashboard`", async (t) => {
const seen: string[] = [];
const owner: Plugin = {
apiVersion: "1.0.0",
dashboard: () => ({ html: "owned dashboard" }),
home: () => ({ html: "owned home" }),
id: "owner",
};
const observer: Plugin = {
apiVersion: "1.0.0",
hooks: { onResponse: (c, r) => void seen.push(`${c.url.pathname}:${r && "html" in r ? r.html : "?"}`) },
id: "observer",
};
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [owner, observer] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
assert.match(await (await fetch(url + "/")).text(), /owned home/);
assert.match(await (await fetch(url + "/dashboard", { headers: { cookie: session() } })).text(), /owned dashboard/);
assert.deepEqual(seen, ["/:owned home", "/dashboard:owned dashboard"]);
});
test("plugin hooks: the request routes by the method and path it arrived with, whatever onRequest does to req", async (t) => {
const rewriter: Plugin = {
apiVersion: "1.0.0",
+1 -1
View File
@@ -406,7 +406,7 @@ test("/error renders a themed sign-in error page (Kratos' flow error sink), not
test("renders the 500 HTML page when a handler throws", async () => {
const dir = mkdtempSync(join(tmpdir(), "pp-views-"));
cpSync(viewsDir, dir, { recursive: true }); // the real views: 500.ejs includes the language picker
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws
writeFileSync(join(dir, "dashboard.ejs"), "<% throw new Error('boom'); %>"); // …but the dashboard view throws
const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir });
try {
await new Promise<void>((resolve) => app.listen(0, resolve));
+33 -28
View File
@@ -3,8 +3,8 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import ejs from "ejs";
import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type RequestCsrf } from "./builtin-route.ts";
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type PluginOwned, type RequestCsrf } from "./builtin-route.ts";
import { buildPageChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type RequestContext, type User } from "./context.ts";
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
import type { Denylist } from "../auth/denylist.ts";
@@ -140,32 +140,21 @@ export function createApp(options: AppOptions = {}): Server {
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href });
// The public landing "/", ungated. A plugin may own it via `home`; else the built-in intro page.
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | PluginOwned> => {
csrf.setCookie();
if (homePlugin) {
// The plugin owns this page, so it runs on its own context — its catalog first, then core.
if (!homePlugin) return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
const pluginCtx = contextFor(homePlugin.id);
const result = (await homePlugin.home(pluginCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, contextFor, result);
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, homePlugin.id), pluginCtx.localeHref);
return null;
}
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
return { ctx: pluginCtx, pluginId: homePlugin.id, result: (await homePlugin.home(pluginCtx)) ?? null };
};
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
// starter page.
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | PluginOwned> => {
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
csrf.setCookie();
if (dashboardPlugin) {
const pluginCtx = contextFor(dashboardPlugin.id); // as serveHome: the owner's own translator
const result = (await dashboardPlugin.dashboard(pluginCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, contextFor, result);
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
return null;
}
return { data: { chrome: ctx.chrome }, view: "index" };
if (!dashboardPlugin) return { data: { chrome: ctx.chrome }, view: "dashboard" };
const pluginCtx = contextFor(dashboardPlugin.id);
return { ctx: pluginCtx, pluginId: dashboardPlugin.id, result: (await dashboardPlugin.dashboard(pluginCtx)) ?? null };
};
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
@@ -279,17 +268,16 @@ async function prepareRequest(host: Host, req: IncomingMessage, res: ServerRespo
const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null);
const t = i18n.translator(locale);
const user = await resolveUser(host, req, res, reqLog);
const { fresh, token } = ensureCsrfToken(req.headers.cookie, host.csrfSecret);
const csrf: RequestCsrf = {
setCookie: (): void => { if (fresh) res.appendHeader("set-cookie", csrfCookie(token, { secure: host.secureCookies })); },
token,
};
const csrf = requestCsrf(host, req, res);
const verifyCsrf = (submitted: string | null | undefined): boolean =>
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: host.csrfSecret, submitted });
// Chrome composes the whole menu, so it is memoized and resolved lazily — a json/redirect
// handler, or the public "/" with a standalone home, never pays for it.
let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: token, currentPath: url.pathname, localeHref: carryLocale, menu: host.menu, plugins: host.plugins, t, translatorFor: (id) => i18n.translator(locale, id), user }));
const chrome = (): PageChrome => (chromeMemo ??= buildPageChrome({
csrfToken: csrf.token, currentPath: url.pathname, localeHref: carryLocale, menu: host.menu, plugins: host.plugins, t,
translatorFor: (id) => i18n.translator(locale, id), user,
}));
const shared = { assetHref: host.assets.href, chrome, declaredPermissions: host.permissionCatalog, declaredSettings: host.settingsCatalog, locale, localeHref: carryLocale, locales: i18n.available, log: reqLog, user, verifyCsrf, ...(host.system ? { system: host.system } : {}) };
return {
carryLocale,
@@ -302,6 +290,14 @@ async function prepareRequest(host: Host, req: IncomingMessage, res: ServerRespo
};
}
function requestCsrf(host: Host, req: IncomingMessage, res: ServerResponse): RequestCsrf {
const { fresh, token } = ensureCsrfToken(req.headers.cookie, host.csrfSecret);
return {
setCookie: (): void => { if (fresh) res.appendHeader("set-cookie", csrfCookie(token, { secure: host.secureCookies })); },
token,
};
}
// A lapsed token still backed by a live Kratos session is silently re-minted — "stay signed
// in". The only place the hot path touches Ory.
async function resolveUser(host: Host, req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<User | null> {
@@ -355,8 +351,17 @@ async function answerBuiltinRoute(host: Host, state: RequestState): Promise<bool
const builtin = matchBuiltinRoute(host.builtinRoutes, state.method, state.pathname);
if (!builtin) return false;
ctx.log.context["http.route"] = builtin.path;
if (!allows(builtin, ctx.user)) await refuse(host, state, builtin, ctx);
else await sendResult(ctx.res, await builtin.handler(ctx, csrf, contextFor), host.viewsFor(ctx), carryLocale);
if (!allows(builtin, ctx.user)) {
await refuse(host, state, builtin, ctx);
return true;
}
const answer = await builtin.handler(ctx, csrf, contextFor);
if ("pluginId" in answer) {
if (host.anyResponseHooks) await runResponseHooks(host.plugins, contextFor, answer.result);
await sendResult(ctx.res, answer.result, host.pluginViewsFor(answer.ctx, answer.pluginId), carryLocale);
} else {
await sendResult(ctx.res, answer, host.viewsFor(ctx), carryLocale);
}
return true;
}
+1 -1
View File
@@ -4,7 +4,7 @@ import { test } from "node:test";
import assert from "node:assert/strict";
import { type BuiltinRoute, matchBuiltinRoute } from "./builtin-route.ts";
const entry = (method: "GET" | "POST", path: string): BuiltinRoute => ({ handler: () => null, method, path });
const entry = (method: "GET" | "POST", path: string): BuiltinRoute => ({ handler: () => ({ html: "" }), method, path });
test("matches exact path + method; GET also answers HEAD", () => {
const get = entry("GET", "/error");
+10 -6
View File
@@ -1,8 +1,6 @@
// The host's internal route table: each built-in endpoint (the auth/OAuth2 group, the landing
// slots, /error) is a named handler with the plugin RouteHandler shape, plus the request's CSRF
// mint (host-only — a plugin reads the token via ctx.chrome instead). app.ts matches this table
// after plugin routes — exact path, a GET route also answering HEAD like the plugin router — and
// pipes the result through sendResult against the core views.
// mint (host-only — a plugin reads the token via ctx.chrome instead).
import type { Gate } from "../auth/gate.ts";
import type { RequestContext } from "./context.ts";
import type { RouteResult } from "../plugin-host/plugin.ts";
@@ -20,10 +18,16 @@ export interface RequestCsrf {
// own context — otherwise the plugin's keys render as bare keys on the pages it owns.
export type PluginContextFactory = (pluginId: string) => RequestContext;
// What a landing slot returns when a plugin owns it. The host renders `result` with that plugin's
// views and `ctx`, and passes it to every plugin's onResponse hook, as it does for a plugin route.
export interface PluginOwned {
ctx: RequestContext; // the owner's own context, from PluginContextFactory
pluginId: string;
result: RouteResult | null;
}
export interface BuiltinRoute extends Gate {
// Returns a RouteResult, or null when the handler wrote to ctx.res itself
// (the landing slots dispatch a plugin's own result against that plugin's views).
handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null;
handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | PluginOwned> | RouteResult | PluginOwned;
method: "GET" | "POST"; // a GET route also answers HEAD, like plugin routes
path: string; // exact pathname
}
+1 -1
View File
@@ -56,7 +56,7 @@ test("createLogger: a set endpoint actually exports log records over OTLP/HTTP",
try {
const log = createLogger({ otlpEndpoint: "http://collector:4318", stderr: () => {}, stdout: () => {} });
log.info("exported");
await new Promise((r) => setTimeout(r, 50)); // export is fire-and-forget in the background
await log.flush(); // the queue batches records for up to a second
} finally {
globalThis.fetch = orig;
}
-1
View File
@@ -1,7 +1,6 @@
// Router: pure core mapping method + pathname → a discovered plugin route. I/O-free;
// app.ts is the shell (build context, gate, call handler, render RouteResult). A route mounts at
// `/<id>` + its path (fullPath, shared with conflict detection); `:name` segments → path params.
// Specificity: a literal segment beats a `:param` (/users/new wins /users/:id), order-independent.
import { fullPath, type Plugin, type Route } from "./plugin.ts";
+1
View File
@@ -20,6 +20,7 @@ export interface ProvisionResult {
// so it is swallowed here. The driver, not `new URL`, decides what parses: it takes a multi-host DSN.
function connect(adminUrl: string): ReturnType<typeof postgres> {
try {
new URL(adminUrl); // postgres 4 connects to whatever a malformed DSN parses to, and its error names that host
return postgres(adminUrl, { connect_timeout: 10, max: 1 });
} catch {
throw new Error("PLUGIN_DB_ADMIN_URL must be a Postgres URL (its value is not shown; percent-encode special characters in a password)");
+2 -2
View File
@@ -1,5 +1,5 @@
// System capabilities: privileged host services a first-party/system plugin (the built-in admin
// screens are the reference consumer) needs but an ordinary domain plugin does not — the Ory admin
// System capabilities: privileged host services a first-party/system plugin (the `auth-admin`
// example plugin is the reference consumer) needs but an ordinary domain plugin does not — the Ory admin
// clients and the instant-revoke hook. Exposed on ctx.system and re-exported via @plainpages/plugin-api.
//
// Every field is optional: it is present only when the host wired that dependency (Ory configured,
+2 -1
View File
@@ -1,4 +1,5 @@
import { generateKeyPairSync, sign, type JsonWebKey } from "node:crypto";
import { generateKeyPairSync, sign } from "node:crypto";
import type { JsonWebKey } from "../auth/jwks.ts";
import type { AddressInfo } from "node:net";
import type { TestContext } from "node:test";
import type { HydraAdmin } from "../auth/hydra-admin.ts";
+21 -11
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildPluginChrome, shellUser } from "./chrome.ts";
import { buildPageChrome, shellUser } from "./chrome.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
@@ -31,7 +31,7 @@ const adminLike: Plugin = {
const labels = (nodes: NavNode[]): string[] => nodes.map((n) => n.label);
test("anonymous: brand from menu, Guest user; the gated Dashboard link is hidden, a public node still shows", () => {
const chrome = buildPluginChrome({ menu: DEFAULT_MENU, plugins: [scheduling, portal] });
const chrome = buildPageChrome({ menu: DEFAULT_MENU, plugins: [scheduling, portal] });
assert.equal(chrome.brand.name, DEFAULT_MENU.branding.name);
assert.equal(chrome.user.name, "Guest");
// Dashboard points at the gated /dashboard — showing it to an anonymous visitor only dead-ends them
@@ -41,12 +41,12 @@ test("anonymous: brand from menu, Guest user; the gated Dashboard link is hidden
});
test("anonymous shell Sign-in link carries the current page as return_to", () => {
assert.equal(buildPluginChrome({ menu: DEFAULT_MENU }).signInHref, "/login"); // no path known
assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal");
assert.equal(buildPageChrome({ menu: DEFAULT_MENU }).signInHref, "/login"); // no path known
assert.equal(buildPageChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal");
});
test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
const chrome = buildPluginChrome({
const chrome = buildPageChrome({
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
user: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] },
});
@@ -58,7 +58,7 @@ test("a permission holder sees the Dashboard link + plugin nav; current path ope
});
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", permissions: ["users:read"] } });
const chrome = buildPageChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", permissions: ["users:read"] } });
const admin = chrome.nav.find((n) => n.label === "Admin")!;
assert.ok(admin); // gated section visible to an admin
assert.equal(admin.open, true); // ancestor of the current leaf opened
@@ -67,9 +67,19 @@ test("a gated section (like the admin plugin) shows to a holder; a sub-path mark
assert.equal(admin.children!.find((c) => c.label === "Groups")!.current, undefined);
});
test("a path no nav leaf owns marks nothing current and opens nothing", () => {
const chrome = buildPageChrome({
currentPath: "/elsewhere", menu: DEFAULT_MENU, plugins: [scheduling],
user: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] },
});
const section = chrome.nav.find((n) => n.label === "Scheduling")!;
assert.equal(section.open, undefined);
assert.equal(section.children!.some((c) => c.current), false);
});
test("branding logo + default theme flow through when set", () => {
const menu: MenuConfig = { branding: { logo: "/logo.svg", name: "Acme", theme: "dark" }, override: {} };
const chrome = buildPluginChrome({ menu });
const chrome = buildPageChrome({ menu });
assert.equal(chrome.brand.logo, "/logo.svg");
assert.equal(chrome.brand.name, "Acme");
assert.equal(chrome.theme, "dark");
@@ -77,10 +87,10 @@ test("branding logo + default theme flow through when set", () => {
test("branding name and sub are translated; an unknown key renders as itself", () => {
const t = (key: string): string => (key === "brand.sub" ? "Undertitel" : key);
const chrome = buildPluginChrome({ menu: { branding: { name: "Acme", sub: "brand.sub" }, override: {} }, t });
const chrome = buildPageChrome({ menu: { branding: { name: "Acme", sub: "brand.sub" }, override: {} }, t });
assert.equal(chrome.brand.name, "Acme");
assert.equal(chrome.brand.sub, "Undertitel");
assert.equal(buildPluginChrome({ menu: { branding: { name: "Acme" }, override: {} } }).brand.sub, undefined);
assert.equal(buildPageChrome({ menu: { branding: { name: "Acme" }, override: {} } }).brand.sub, undefined);
});
test("shellUser derives the profile from the real user; anonymous → Guest", () => {
@@ -97,7 +107,7 @@ const account: Plugin = {
],
};
const entries = (opts: { permissions?: string[]; signedIn?: boolean }): { href: string; icon?: string; label: string }[] =>
buildPluginChrome({
buildPageChrome({
localeHref: (href) => `${href}?locale=sv-SE`,
menu: DEFAULT_MENU,
plugins: [account],
@@ -115,7 +125,7 @@ test("profile menu entries are gated like nav nodes, translated by their plugin
});
test("the central override hides and renames a profile-menu entry by its id; the host translates the new label", () => {
const links = buildPluginChrome({
const links = buildPageChrome({
menu: { ...DEFAULT_MENU, override: { hide: ["help"], rename: { settings: "Preferences" } } },
plugins: [{
apiVersion: "1.0.0", id: "account",
+19 -21
View File
@@ -52,9 +52,24 @@ export interface ChromeOptions {
user?: User | null;
}
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
export function buildPageChrome(opts: ChromeOptions): PageChrome {
const t = opts.t ?? ENGLISH;
const carryLocale = opts.localeHref ?? ((href: string) => href);
// The sign-in link keeps the visitor's locale, and brings it back afterwards via return_to.
const returnTo = opts.currentPath ? `/login?return_to=${encodeURIComponent(carryLocale(opts.currentPath))}` : "/login";
const { theme, ...brand } = branding(opts.menu, t);
return {
brand,
csrfToken: opts.csrfToken ?? "",
// Marked before the locale rides along, so an href still matches the plain request path.
nav: carryLocaleInto(currentMarkedNav(opts, t), carryLocale),
signInHref: carryLocale(returnTo),
...(theme != null ? { theme } : {}),
user: { ...shellUser(opts.user, t), profileLinks: profileLinks(opts, t, carryLocale) },
};
}
function currentMarkedNav(opts: ChromeOptions, t: Translate): NavNode[] {
const fragments: NavNode[][] = [[DASHBOARD_NAV]];
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
// translator before merging. composeNav then runs the core one over the result; already-translated
@@ -62,27 +77,10 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
for (const p of opts.plugins ?? []) {
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
}
const nav = composeNav(fragments, opts.menu.override, opts.user ?? null, t);
if (opts.currentPath) {
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
// Marked before the locale rides along, so an href still matches the plain request path.
const target = bestHref(nav, opts.currentPath);
const target = opts.currentPath ? bestHref(nav, opts.currentPath) : undefined;
if (target) markCurrent(nav, target);
}
// The sign-in link keeps the visitor's locale, and brings it back afterwards via return_to.
const returnTo = opts.currentPath ? `/login?return_to=${encodeURIComponent(carryLocale(opts.currentPath))}` : "/login";
const { theme, ...brand } = branding(opts.menu, t);
return {
brand,
csrfToken: opts.csrfToken ?? "",
nav: carryLocaleInto(nav, carryLocale),
signInHref: carryLocale(returnTo),
...(theme != null ? { theme } : {}),
user: { ...shellUser(opts.user, t), profileLinks: profileLinks(opts, t, carryLocale) },
};
return nav;
}
function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] {
@@ -140,7 +138,7 @@ function carryLocaleInto(nodes: NavNode[], carryLocale: (href: string) => string
}
// The href of the leaf that owns `path`: an exact match, else the longest href that is a parent of
// it (href + "/" prefixes path), so /admin/users/123 resolves to the /admin/users leaf. "/" never
// it (href + "/" prefixes path), so /auth-admin/users/123 resolves to the /auth-admin/users leaf. "/" never
// counts as a parent (it would own everything). Returns undefined when nothing matches.
function bestHref(nodes: NavNode[], path: string): string | undefined {
let best: string | undefined;
+24 -97
View File
@@ -6,22 +6,16 @@
`Bar = 1`
`Next ID = 71`
`Next ID = 73`
## Items
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 62 | 0.5.0 | | | **Delete a group's grants and its membership set in one Keto transaction, so a failure cannot leave it half-deleted.** | 1 | 2 | 2 | 3 | 2 | code health by 2, 2 by 3 | 16.2 |
| 63 | 0.5.0 | | | **Correct the stale and false comments and docs the comprehension seats flagged, so each states what the code does today.** | 2 | 2 | 1 | 3 | 3 | code health by 3, 9 by 2 | 15.4 |
| 68 | 0.5.0 | | | **Make `serveHome` and `serveDashboard` return their result to the request pipeline, as every other stage does, instead of sending it themselves.** | 2 | 1 | 3 | 4 | 3 | code health by 3 | 13.5 |
| 69 | 0.5.0 | | | **Rename `views/index.ejs` to `views/dashboard.ejs`, so the core view's file names the page it renders.** | 1 | 2 | 2 | 3 | 2 | code health by 2 | 12.0 |
| 64 | 0.5.0 | | | **Keep the permissions section on the user form when a self-deactivate or self-delete is refused.** | 1 | 2 | 1 | 2 | 2 | 2 by 3, code health by 1 | 10.2 |
| 66 | 0.5.0 | | | **Make `src/auth/bootstrap.ts` read its settings through `src/config.ts` and call Kratos and Keto through their client modules.** | 2 | 1 | 3 | 4 | 4 | code health by 3 | 9.0 |
| 70 | 0.5.0 | | | **Give the duplicated paging loop, Link-header parser and `FieldConfig` type one home each, and rename `buildPluginChrome` to `buildPageChrome`.** | 2 | 1 | 1 | 2 | 3 | code health by 2 | 9.0 |
| 19 | 0.6.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
| 16 | 0.6.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
| 65 | 0.6.0 | | | **Export `KetoError` from `@plainpages/plugin-api`, so a plugin can tell a Keto failure apart with `instanceof`.** | 1 | 2 | 1 | 2 | 1 | 7 by 3, 1 by 1 | 6.7 |
| 72 | 0.6.0 | | | **Delete a group's grants and parent memberships when its last member leaves, by group delete or by removing the member, so re-creating the name restores none.** | 2 | 2 | 2 | 4 | 3 | 2 by 4 | 4.2 |
| 67 | 0.6.0 | | | **Group `src/auth`'s modules into one subfolder per concern, with `bootstrap.ts` staying at `src/auth/bootstrap.ts`.** | 3 | 1 | 2 | 4 | 6 | code health by 4 | 4.0 |
| 14 | 0.6.0 | | | **Drop a double-clicked duplicate submit, with no client-side JavaScript.** | 4 | 2 | 3 | 7 | 7 | 2 by 7, 1 by 4 | 1.4 |
@@ -32,95 +26,6 @@
## Details
### 62. Delete a group's grants and its membership set in one Keto transaction, so a failure cannot leave it half-deleted.
`groupsDelete` in `examples/plugins/auth-admin/admin-groups.ts` drops each held grant with its own
`deleteTuple`, then the membership tuple, so a Keto failure part-way leaves a group with some grants
gone and its members still listed. Send all the deletes through `KetoClient.transactTuples`, as
`applyGrants` in `admin-grants.ts` does. The comment on delete order stays true inside one
transaction.
### 63. Correct the stale and false comments and docs the comprehension seats flagged, so each states what the code does today.
Prose only; no behaviour changes. Make each listed place easier to read, since the seats named
most of them.
- `src/auth/login.ts:43` and `README.md` ~L1367 name an admin "Effective access" view that doesn't
exist.
- `src/plugin-host/router.ts:4` header says specificity is order-independent; `matchRoute` sorts by
`:param` count only, so a tie (`/a/:x/b` against `/a/b/:y`) falls to discovery order.
- `ci.sh:9` ("users/groups/roles") and `examples/plugins/scheduling/plugin.ts:37` ("Roles", and
`<id>:<action>` where the rule is `<resource>:<action>`) use words the project doesn't have.
- `src/plugin-host/system.ts:1-2` calls "the built-in admin screens" the reference consumer; they
are the `auth-admin` example plugin.
- `examples/plugins/auth-admin/admin-groups.ts:18` says a Keto page; the call is Kratos
`listIdentities`.
- `compose.yml` ~L166 says consent comes next; `src/auth/oauth-consent.ts` implements it.
- `src/auth/jwt.ts:6-7`: "Signature only — adds claim checks…" lacks its subject (the caller,
`jwt-middleware.ts`, adds them).
- `src/ui/chrome.ts` ~L69 and ~L143 use `/admin/users` as example paths; admin lives at
`/auth-admin/users`.
- `README.md` ~L350 says a tuple naming an undeclared permission "gates nothing"; declaring stays
optional, so a plugin may gate on it. `examples/plugins/auth-admin/README.md:49` says six
permissions; `plugin.ts` declares seven.
- `examples/plugins/auth-admin/views/partials/group-detail-body.ejs:2-7` documents member
`kind:"identity"` where the model says `"user"`, and omits the `canWrite` and `permissions`
locals.
- `src/auth/bootstrap.ts:1-8` header lists the JWKS, admin and Keto steps, not the plugin-storage
step.
- The custom-property `unset` footgun is stated only in `src/test-tooling/stylesheet-floor.ts:10-12`;
say it beside the tokens at `public/css/styles.css:9-23`, where an author writes one.
### 68. Make `serveHome` and `serveDashboard` return their result to the request pipeline, as every other stage does, instead of sending it themselves.
In `src/http/app.ts`, both (~L143-169) call `sendResult` with the owning plugin's renderer and
return `null` when a plugin owns the slot, and return a `RouteResult` otherwise, so a reader of
`handleRequest` (~L183) and `prepareRequest` (~L274) meets two return conventions. Return the
result with whatever names its renderer and let the one send stage render it. Make
`prepareRequest` and `handleRequest` easier to read, places the seats found hard. The 19-field
`Host` bag is left alone: no checkable target for it came out of the scoring run.
### 69. Rename `views/index.ejs` to `views/dashboard.ejs`, so the core view's file names the page it renders.
`index.ejs` renders `/dashboard` (`src/http/app.ts` ~L168); `home.ejs` renders `/` and matches the
manifest's `home` slot, so it keeps its name. Core `views/` is an `include()` root for plugin views
(`src/plugin-host/view-resolver.ts`), so a plugin may include `index` today: the rename changes the
contract surface (AGENTS.md → Rules) and ships in a minor. Update `docs/reading-cost.md` and the
README file map. Make `views/index.ejs` easier to find, a place the seats found hard.
### 64. Keep the permissions section on the user form when a self-deactivate or self-delete is refused.
In `examples/plugins/auth-admin/admin-users.ts`, the self-refusals in `usersState` (~L405),
`usersDeleteConfirm` (~L415) and `usersDelete` (~L429) call `formResult` without
`...(await pickerOrNothing(deps, id))` or `canWrite`, so the re-rendered form loses its permissions
section; `usersUpdate` and `usersRecovery` keep it, and `pickerOrNothing`'s comment promises it.
The groups side re-renders through `groupDetailPage`, which keeps it. Make `usersPermissions` and
the `pickerOrNothing` contract easier to read, a place the seats found hard. Not exempt: a
comprehension finding, not a 🔴, and no maintain goal is broken.
### 66. Make `src/auth/bootstrap.ts` read its settings through `src/config.ts` and call Kratos and Keto through their client modules.
`seedAdminAndPermissions` (~L221-233) reads `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `KETO_WRITE_URL`,
`KRATOS_ADMIN_URL` and `APP_URL` from the environment with its own defaults, which repeat
`src/config.ts` (~L170-186) beside its "loaded once" promise. `seedAdmin` (~L86) and
`findIdentityId` call Kratos and Keto with raw `fetch` where `createKratosAdmin` (`createIdentity`,
`listIdentities` with `credentialsIdentifier`) and `createKetoClient` (`writeTuple`) exist. Check
that `loadConfig` doesn't demand settings the one-shot service lacks; else reuse its readers. The
seed stays idempotent (a Kratos 409 still means an existing admin). Make the `bootstrap.ts` header
easier to read, a place the seats found hard.
### 70. Give the duplicated paging loop, Link-header parser and `FieldConfig` type one home each, and rename `buildPluginChrome` to `buildPageChrome`.
- `pagedTuples` (`examples/plugins/auth-admin/admin-groups.ts` ~L246) and `heldPermissions`
(`admin-grants.ts` ~L38) each hand-write the Keto page-token loop; let `heldPermissions` use the
one loop.
- `nextPageToken` is copied in `src/auth/kratos-admin.ts` ~L46 and `src/auth/hydra-admin.ts` ~L106.
- `FieldConfig` lives in `admin-users.ts`, and `admin-groups.ts` and `admin-clients.ts` import it
from there; move it to `admin-shared.ts`.
- `buildPluginChrome` (`src/ui/chrome.ts:55`) builds the chrome for every page, core pages
included; rename it, its test and the README ~L1027 mention. Make `buildPluginChrome` easier to
read, a place the seats found hard.
### 19. Run the documented Docker commands on macOS and fix whatever misbehaves.
Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is
@@ -139,6 +44,28 @@ surface, so this adds to the plugin API: it ships in a minor, and `HOST_API_VERS
`0.6.0` when 0.6.0 is tagged (AGENTS.md → Rules). Adding is safe for installed plugins; removing it
later would take a major.
### 72. Delete a group's grants and parent memberships when its last member leaves, by group delete or by removing the member, so re-creating the name restores none.
In `examples/plugins/auth-admin/admin-groups.ts` a group exists exactly while it has a
`Group:<name>#members` tuple. Two paths empty a group but keep its other tuples, so re-creating the
name silently hands every new member the old permissions and parent memberships:
1. `groupsDelete` of a group that is a parent's only member: the parent ceases to exist but keeps
its `Permission:*#granted@Group:<parent>#members` grants and its memberships in grandparents.
2. `groupsRemoveMember` removing a group's last member, which today is a lone `deleteTuple`.
Fix both in one `transactTuples` with the removal: when the removal leaves a group with no members,
add its grants and the tuples naming it as a member of other groups to the delete. The cascade is
transitive: a grandparent emptied by its parent vanishing is the same bug one level up. The walk
stops at a group that keeps any member, so a nesting cycle (`groupsAddMember` refuses only a
self-nest) ends it. An emptied group grants nobody, so the cascade cannot change what
`lostToSelf` computes. Test it with nesting two levels deep and with a cycle.
When it ships, rewrite the comment at `groupsDelete` that points here, and the CHANGELOG entry "The
admin example removes a deleted group from the groups it was nested in", which then says a parent
left with no members no longer exists as a group. `lostToSelf`, in this file, is a place the seats
found hard; if the change touches it, make it easier to read.
### 67. Group `src/auth`'s modules into one subfolder per concern, with `bootstrap.ts` staying at `src/auth/bootstrap.ts`.
`src/auth` holds 19 modules over several concerns: the per-request session path (`jwt*`, `jwks`,