Compare commits

..

33 Commits

Author SHA1 Message Date
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
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
49 changed files with 508 additions and 301 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
+15
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,15 @@ 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 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).
+5 -5
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.
@@ -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.
@@ -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:
+80 -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,82 @@ 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
+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:
+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
+9 -7
View File
@@ -15,7 +15,8 @@ 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
@@ -431,19 +432,20 @@ 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
// 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 behind would resurrect every
// permission the moment someone re-created a group with the same name.
const subject = groupSubject(name);
const held = await heldPermissions(keto, subject);
for (const permission of held) await keto.deleteTuple(grantTuple(permission, subject));
await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS });
const grants = held.map((permission) => grantTuple(permission, subject));
const members = await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS });
await keto.transactTuples({ delete: [...grants, ...members], insert: [] });
ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
return { redirect: ADMIN_GROUPS_BASE };
});
+15 -8
View File
@@ -391,8 +391,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 +399,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 +412,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 +424,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 +442,13 @@ export const usersRecovery = withTarget(async (deps, identity, id) => {
return formResult(ctx, { canWrite: canWriteUsers(ctx), identity, recovery, ...(await pickerOrNothing(deps, id)) });
});
// The picker as a spreadable fragment, so a re-render never silently drops the section.
// The 400 re-render of the user's form with `error` above it. `usersPermissions` refuses through its
// own `refuse`, which puts the error in the permissions section instead.
async function refuseForm(deps: UsersDeps, identity: Identity, id: string, error: string): Promise<RouteResult> {
return { ...formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), error, identity, ...(await pickerOrNothing(deps, id)) }), status: 400 };
}
// The picker as a spreadable fragment, so every form re-render keeps the section.
async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> {
const permissions = await userPermissionPicker(deps, id);
return permissions ? { permissions } : {};
@@ -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 },
+17 -17
View File
@@ -6,15 +6,15 @@
"": {
"name": "plainpages",
"dependencies": {
"@larvit/log": "2.3.0",
"@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": {
@@ -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"
}
+3 -3
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",
"@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");
+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
+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 },
};
+59
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,58 @@ 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);
});
// 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,7 +3,7 @@ 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 { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type PluginOwned, type RequestCsrf } from "./builtin-route.ts";
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type RequestContext, type User } from "./context.ts";
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.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.
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" };
if (!homePlugin) return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
const pluginCtx = contextFor(homePlugin.id);
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 ??= buildPluginChrome({
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";
+2 -2
View File
@@ -66,7 +66,7 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
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.
// /auth-admin/users/new marks the Users base leaf (/auth-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);
if (target) markCurrent(nav, target);
@@ -140,7 +140,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;
+11 -84
View File
@@ -6,19 +6,14 @@
`Bar = 1`
`Next ID = 71`
`Next ID = 72`
## 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 |
| 71 | 0.5.0 | | | **Delete, with a group, every membership tuple in another group that names it, so re-creating the name restores no nested membership.** | 1 | 2 | 2 | 3 | 2 | 2 by 3 | 4.2 |
| 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 |
@@ -32,83 +27,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`
@@ -121,6 +39,15 @@ easier to read, a place the seats found hard.
included; rename it, its test and the README ~L1027 mention. Make `buildPluginChrome` easier to
read, a place the seats found hard.
### 71. Delete, with a group, every membership tuple in another group that names it, so re-creating the name restores no nested membership.
Groups nest: a parent holds `Group:<parent>#members` with `subject_set` `Group:<child>#members`.
`groupsDelete` in `examples/plugins/auth-admin/admin-groups.ts` drops the child's own member tuples
and held grants, but not those parent tuples, so a group re-created with the deleted name is back
in every parent, the orphan class its comment already guards against for grants. List them with
`pagedTuples` filtered on `subject_set` (`namespace: GROUP_NS`, `object: name`, `relation: MEMBERS`)
and add them to the same `transactTuples` delete; extend the comment to say so.
### 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