Compare commits
39 Commits
8ff2e1c0fa
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 581a4dbb81 | |||
| 5a5ce5499d | |||
| c9485631c8 | |||
| d6b8a7982e | |||
| 1bcb31ff58 | |||
| cdb5a00705 | |||
| a4740c87ae | |||
| 14be7edcbf | |||
| ebb9757e80 | |||
| 784610eac8 | |||
| df9a3f3665 | |||
| 22b25bf3db | |||
| 4311944a24 | |||
| 0116e862ba | |||
| 30e07e0044 | |||
| 6fe7ba3b6d | |||
| bb09b3a821 | |||
| 8b88cd4a40 | |||
| 8e3270d479 | |||
| 1773c2737f | |||
| 42644ba0f5 | |||
| c744759869 | |||
| a00d86fb83 | |||
| 2a0ae084ac | |||
| f3ebea3c80 | |||
| 1fc6033dd5 | |||
| 1304cdae56 | |||
| 3f07c3e649 | |||
| 0a9c79d08f | |||
| 44f94c7ab0 | |||
| 2f75995b3d | |||
| e8fac7fd5c | |||
| 42661b111f | |||
| ecdf9f3464 | |||
| d6a33e8cc9 | |||
| 0bf7901a14 | |||
| a0ad0ddf2d | |||
| 5088352fcf | |||
| 428800fd62 |
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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")"
|
||||
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
|
||||
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
|
||||
FROM mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
FROM mcr.microsoft.com/playwright:v1.65.0-noble
|
||||
|
||||
WORKDIR /e2e-tests
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Generated
+20
-20
@@ -6,19 +6,19 @@
|
||||
"": {
|
||||
"name": "plainpages-e2e",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.64.1",
|
||||
"@types/node": "24.19.2",
|
||||
"@playwright/test": "1.65.0",
|
||||
"@types/node": "26.6.6",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.65.0.tgz",
|
||||
"integrity": "sha512-iGURLGuKDLakSEl7Pgfv8h7f+XLT+V861Wfhz0zlEl5nyFuKUopdkFBPKqTllTUp06aOwPuH8iuUEb3yp3MpbQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.64.1"
|
||||
"playwright": "1.65.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -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": {
|
||||
@@ -378,13 +378,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz",
|
||||
"integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.1"
|
||||
"playwright-core": "1.65.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -394,9 +394,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"version": "1.65.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz",
|
||||
"integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.64.1",
|
||||
"@types/node": "24.19.2",
|
||||
"@playwright/test": "1.65.0",
|
||||
"@types/node": "26.6.6",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Generated
+17
-17
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 ---- */
|
||||
|
||||
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"),
|
||||
|
||||
@@ -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 },
|
||||
};
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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));
|
||||
|
||||
+34
-29
@@ -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.
|
||||
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 ??= 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
@@ -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,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";
|
||||
|
||||
|
||||
@@ -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)");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
+20
-22
@@ -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);
|
||||
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) },
|
||||
};
|
||||
const target = opts.currentPath ? bestHref(nav, opts.currentPath) : undefined;
|
||||
if (target) markCurrent(nav, target);
|
||||
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;
|
||||
|
||||
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user