85 Commits

Author SHA1 Message Date
lilleman ba4503b4d1 Merge branch 'main' into auto-release-only-when-affected
CI / full-gate (push) Successful in 2m52s
Mirror / github-mirror (push) Successful in 4s
Release / retag-image (push) Successful in 17s
Release / publish-overview (push) Successful in 5s
2026-08-22 22:16:22 +02:00
lilleman 4849a7a3c5 Follow mailpit to v1.31.0 in the published quick start
CI / full-gate (push) Successful in 2m51s
2026-08-22 21:58:14 +02:00
lilleman 5b73ec6fb7 Merge branch 'main' into renovate/renovate-renovate-44.x
CI / full-gate (push) Successful in 2m54s
Mirror / github-mirror (push) Successful in 6s
2026-08-22 21:54:19 +02:00
lilleman 8932392ab8 Merge branch 'main' into auto-release-only-when-affected
CI / full-gate (push) Failing after 10s
2026-08-22 21:53:53 +02:00
lilleman 42af07c255 Use one Docker Hub credential for images and the overview
CI / full-gate (push) Successful in 2m50s
2026-08-22 14:27:31 +02:00
lilleman 734cdf6892 Name the scope the overview actually needs
CI / full-gate (push) Successful in 2m51s
2026-08-22 14:21:14 +02:00
lilleman 716e0f50a7 Give each version mismatch its own remedy, and stop publishing a moving bare-major tag
CI / full-gate (push) Successful in 2m51s
2026-08-22 12:27:43 +02:00
lilleman 2d62553a10 Refuse a 0.x minor mismatch, and republish the overview from the named release's tree
CI / full-gate (push) Successful in 2m50s
2026-08-22 12:13:41 +02:00
lilleman d8b7a2d64a Keep the proven always() guard on the overview job
CI / full-gate (push) Successful in 2m51s
2026-08-22 12:03:18 +02:00
lilleman d62c7faabd Scope the sidecar trailer by package so a mixed branch cannot lose it
CI / full-gate (push) Successful in 2m50s
2026-08-22 12:01:35 +02:00
lilleman 6c07856159 Accept the README-only gate skip, and say why it is safe
CI / full-gate (push) Successful in 2m48s
2026-08-22 11:58:28 +02:00
lilleman 41084441ff Manage the published quick start's pins, and fail closed on every release-tooling edge
CI / full-gate (push) Successful in 2m50s
2026-08-22 11:45:46 +02:00
lilleman 08d4f6271d Gate the overview's manual trigger on the same contract check as a tag
CI / full-gate (push) Successful in 2m51s
2026-08-22 11:27:35 +02:00
lilleman 5820264885 Trim the prose to what is true now
CI / full-gate (push) Successful in 2m51s
2026-08-22 11:24:16 +02:00
lilleman b708aab981 Keep the release at v0.1.0 — nothing consumed the old contract
CI / full-gate (push) Successful in 3m2s
2026-08-22 11:21:42 +02:00
renovate-bot 86c9e8e4ca Update renovate/renovate Docker tag to v44.39.1
CI / full-gate (push) Successful in 2m49s
2026-08-22 04:18:35 +00:00
renovate-bot 33c42b2c65 Update axllent/mailpit Docker tag to v1.31.0
CI / full-gate (push) Successful in 3m48s
Mirror / github-mirror (push) Successful in 6s
2026-08-22 04:18:30 +00:00
renovate-bot 452beeb0e1 Update renovate/renovate Docker tag to v44.37.1
CI / full-gate (push) Successful in 2m58s
Mirror / github-mirror (push) Successful in 7s
2026-08-21 04:18:10 +00:00
lilleman 99e77ebabf Release this as v0.2.0, and give the Hub overview its own job, token and template
CI / full-gate (push) Successful in 3m8s
2026-08-20 23:32:10 +02:00
lilleman 2148822dad Make the plugin contract version the release version, and publish the Docker Hub overview from CI
CI / full-gate (push) Successful in 3m8s
2026-08-20 23:16:11 +02:00
lilleman 7f839afb32 Release nothing when no Renovate commit reached the app
CI / full-gate (push) Successful in 2m58s
2026-08-20 23:02:21 +02:00
lilleman 1f61235e88 Scope Release-Bump to the surfaces that ship, and drop the inert package.json versions
CI / full-gate (push) Successful in 3m7s
Mirror / github-mirror (push) Successful in 6s
Release / retag-image (push) Successful in 21s
2026-08-20 22:43:36 +02:00
lilleman 24007b15f3 Rewrap the auto-release paragraph
CI / full-gate (push) Successful in 2m58s
2026-08-20 22:04:23 +02:00
lilleman 3eceee0cf1 Lift the pre-release freeze — releases resume and the plugin contract goes live
CI / full-gate (push) Successful in 2m59s
2026-08-20 22:02:29 +02:00
lilleman 9fa0f60f79 Merge branch 'main' into renovate/renovate-renovate-44.x
CI / full-gate (push) Successful in 2m59s
Mirror / github-mirror (push) Successful in 6s
2026-08-20 21:52:27 +02:00
lilleman 4f791d8f11 Merge remote-tracking branch 'origin/main' into plugin-storage
CI / full-gate (push) Successful in 3m11s
Mirror / github-mirror (push) Successful in 6s
# Conflicts:
#	package-lock.json
#	package.json
2026-08-20 21:47:22 +02:00
renovate-bot 94e9e8bc60 Update dependency lucide-static to v1.33.0
CI / full-gate (push) Successful in 2m59s
Mirror / github-mirror (push) Successful in 6s
Release-Bump: minor
2026-08-20 04:17:56 +00:00
lilleman 03d14b1a20 Merge remote-tracking branch 'origin/main' into plugin-storage
CI / full-gate (push) Successful in 3m10s
# Conflicts:
#	package-lock.json
#	package.json
2026-08-19 20:34:30 +02:00
lilleman 3552b85d63 Merge branch 'main' into renovate/renovate-renovate-44.x
CI / full-gate (push) Successful in 2m50s
2026-08-19 20:29:09 +02:00
renovate-bot dde6fccb35 Update renovate/renovate Docker tag to v44.33.2
CI / full-gate (push) Successful in 2m45s
Release-Bump: minor
2026-08-19 04:18:20 +00:00
renovate-bot 19b3fbc802 Update dependency lucide-static to v1.32.0
CI / full-gate (push) Successful in 3m9s
Mirror / github-mirror (push) Successful in 6s
Release-Bump: minor
2026-08-19 04:18:11 +00:00
lilleman 47541ae97b Warn rather than refuse on a storage URL mismatch, and scrub the provisioning DSN before discovery
CI / full-gate (push) Successful in 2m59s
2026-08-19 01:03:40 +02:00
lilleman c5c9cce2b6 Keep role re-assertion within a non-superuser provisioner's rights, and test the second boot
CI / full-gate (push) Successful in 2m58s
2026-08-19 00:44:49 +02:00
lilleman 6db14a2205 Isolate the storage CI stack, prove least-privilege provisioning, drop the secret before discovery
CI / full-gate (push) Successful in 2m58s
2026-08-19 00:20:17 +02:00
lilleman ae8f105360 Confine the Postgres driver to bootstrap, bound plugin connections, and gate the storage DDL
CI / full-gate (push) Failing after 23s
2026-08-19 00:08:35 +02:00
lilleman bba048e38f Refuse a throwaway plugin storage secret in bootstrap, before any role is created
CI / full-gate (push) Successful in 2m56s
2026-08-18 23:24:34 +02:00
lilleman bf638dfb19 Give a plugin a Postgres database of its own
CI / full-gate (push) Successful in 2m58s
2026-08-18 23:12:13 +02:00
lilleman 5cc6c3d93e Todo: note the code-field hint landed, the paste fix did not
CI / full-gate (push) Successful in 2m49s
Mirror / github-mirror (push) Successful in 6s
2026-08-18 22:07:21 +02:00
lilleman 04af61a5e5 Hint the code field's digits-only rule, so the browser's refusal isn't bare 2026-08-18 22:07:12 +02:00
lilleman a64a60644d Todo: record the flow-POST proxy findings for the verification-code fix
CI / full-gate (push) Successful in 2m44s
2026-08-18 22:00:31 +02:00
lilleman 950eb5a911 Todo: record the manifest-over-.env decision for plugin config
CI / full-gate (push) Successful in 2m47s
2026-08-18 21:55:35 +02:00
lilleman 091011cfe5 Let Renovate reach the example plugins' manifests 2026-08-18 21:55:35 +02:00
lilleman d55898eb8c Refuse a stray package.json or node_modules in config/ by name 2026-08-18 21:55:32 +02:00
lilleman f992cb6b2c Merge branch 'main' into plugin-dependencies
CI / full-gate (push) Successful in 2m45s
Mirror / github-mirror (push) Successful in 7s
2026-08-18 18:33:24 +02:00
lilleman 7d1f7750d3 Refuse a node_modules at the plugins/ root, where it outranks the host's
CI / full-gate (push) Successful in 2m43s
2026-08-18 08:14:21 +02:00
lilleman 3f9787df48 Follow symlinked plugin folders, and keep a plugin .npmrc out of the image
CI / full-gate (push) Successful in 2m44s
2026-08-18 07:49:51 +02:00
renovate-bot a261570796 Update renovate/renovate Docker tag to v44.32.6
CI / full-gate (push) Successful in 2m45s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: minor
2026-08-18 04:18:04 +00:00
lilleman 77343e859a Fail loud on a null package.json and a stray plugins/package.json
CI / full-gate (push) Successful in 2m42s
2026-08-17 22:50:29 +02:00
lilleman 616040fda6 Refuse a shadowing barrel copy, and record the packaging contract
CI / full-gate (push) Successful in 2m45s
2026-08-17 22:34:56 +02:00
lilleman fee4fe632b Let a plugin carry its own package.json and npm dependencies
CI / full-gate (push) Successful in 2m53s
2026-08-17 22:23:53 +02:00
lilleman 1ba6dbdc51 Merge branch 'main' into prose-diet
CI / full-gate (push) Successful in 2m42s
Mirror / github-mirror (push) Successful in 6s
2026-08-17 21:05:29 +02:00
renovate-bot 09ab2fcb85 Update renovate/renovate Docker tag to v44.31.0
CI / full-gate (push) Successful in 2m39s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: minor
2026-08-17 04:17:51 +00:00
renovate-bot a977ecf1c7 Update renovate/renovate Docker tag to v44.30.3
CI / full-gate (push) Successful in 3m39s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: minor
2026-08-15 04:18:17 +00:00
renovate-bot 04a508c169 Update postgres Docker tag to v18.6
CI / full-gate (push) Successful in 2m39s
Mirror / github-mirror (push) Successful in 5s
Release-Bump: minor
2026-08-14 04:18:05 +00:00
renovate-bot d74989c4e0 Update renovate/renovate Docker tag to v44.27.0
CI / full-gate (push) Successful in 2m38s
Mirror / github-mirror (push) Successful in 4s
Release-Bump: minor
2026-08-13 04:18:02 +00:00
renovate-bot 43d7062d9e Update renovate/renovate Docker tag to v44.24.3
CI / full-gate (push) Successful in 2m38s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: minor
2026-08-12 04:18:15 +00:00
renovate-bot d5ee0353d4 Update renovate/renovate Docker tag to v44.23.0
CI / full-gate (push) Successful in 2m38s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: minor
2026-08-11 04:17:48 +00:00
renovate-bot 137c35478d Update dependency lucide-static to v1.31.0
CI / full-gate (push) Successful in 2m50s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: minor
2026-08-10 04:17:44 +00:00
renovate-bot 352f2f2794 Update axllent/mailpit Docker tag to v1.30.7
CI / full-gate (push) Successful in 2m38s
Mirror / github-mirror (push) Successful in 2s
Release-Bump: patch
2026-08-09 04:18:09 +00:00
renovate-bot 3b6f2c1ed3 Update renovate/renovate Docker tag to v44.14.10
CI / full-gate (push) Successful in 3m36s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: patch
2026-08-08 04:18:02 +00:00
renovate-bot f99a029bc5 Update renovate/renovate Docker tag to v44.14.3
CI / full-gate (push) Successful in 2m39s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: minor
2026-08-07 04:18:27 +00:00
renovate-bot 1daa2b1777 Update renovate/renovate Docker tag to v44.13.3
CI / full-gate (push) Successful in 2m38s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: minor
2026-08-06 04:18:26 +00:00
lilleman 8a5d2dfd6c Make the two persona references self-contained now that the section is gone
CI / full-gate (push) Successful in 2m40s
2026-08-05 23:42:58 +02:00
lilleman a005acb93d Cut non-essential prose from docs and comments, and require the same of every future change
CI / full-gate (push) Successful in 2m38s
README loses the competitor comparison, the personas and the repeated philosophy; the
five near-identical E2E command blocks become a table plus one command, and the file
map a clause per entry. AGENTS.md keeps every decision but drops the narrative around
them. todo.md's completed items collapse to their task line — git holds the rest.

Comments lose restatement, README duplication and history ("used to", "originally",
dated notes). AGENTS.md gains a Prose discipline section making this a standing pass on
every change rather than a one-off cleanup.

src/compose.test.ts now expects 6 documented E2E run commands, not 10, since the README
states the command once instead of per suite.
2026-08-05 23:41:12 +02:00
lilleman f5240ef7f6 Record macOS as a supported dev host and the commands still unverified there
CI / full-gate (push) Successful in 2m37s
Mirror / github-mirror (push) Successful in 3s
2026-08-05 22:55:48 +02:00
lilleman 78f5f72151 Correct the artifacts upgrade runbook and the bootstrap exception's stated reason
CI / full-gate (push) Successful in 2m39s
2026-08-05 22:46:05 +02:00
lilleman 1cf34a0d45 Harden the artifact-ownership guards and document the root-owned upgrade trap
CI / full-gate (push) Successful in 2m38s
2026-08-05 22:40:36 +02:00
lilleman 073ec294e9 Run the E2E runner as the invoking user so its artifacts aren't root-owned
CI / full-gate (push) Successful in 2m37s
2026-08-05 22:25:17 +02:00
lilleman e8b91ecd09 Compress AGENTS.md and add a standing rule to trim it on every edit
CI / full-gate (push) Successful in 2m34s
Mirror / github-mirror (push) Successful in 3s
2026-08-05 21:52:56 +02:00
lilleman ab5c24deb7 Cut the node_modules prose to one home each; drop a stray tracked file
CI / full-gate (push) Successful in 2m38s
2026-08-05 19:52:09 +02:00
lilleman 3d3313c0ee Document the shadowing risk and the leftover dir; close two holes in the mount guard
CI / full-gate (push) Successful in 2m38s
2026-08-05 19:17:07 +02:00
lilleman bcf4d7fb1f Install deps above WORKDIR so no root-owned node_modules lands in the checkout
CI / full-gate (push) Successful in 2m45s
2026-08-05 18:18:49 +02:00
lilleman 2852722873 Record the stale-copy trap as accepted until the apiVersion freeze lifts
CI / full-gate (push) Successful in 2m41s
Mirror / github-mirror (push) Successful in 3s
2026-08-05 18:00:38 +02:00
lilleman 45b16824f1 Point a failed discovery at the stale plugins/ copy, and document upgrading
CI / full-gate (push) Successful in 2m39s
2026-08-05 17:52:43 +02:00
lilleman f76cd2a267 Todo updates
CI / full-gate (push) Successful in 2m42s
2026-08-05 17:48:55 +02:00
lilleman 38ebe40398 Never fail the boot on operator env; drop unusable ADMIN_PERMISSIONS with a warning
CI / full-gate (push) Successful in 2m40s
2026-08-05 17:36:04 +02:00
lilleman 1f0956dd58 Keep the group-delete confirm in user terms, not tuple mechanics
CI / full-gate (push) Successful in 2m39s
2026-08-05 15:39:08 +02:00
lilleman edcd9fefc8 Deleting a group revokes the permissions it granted 2026-08-05 15:39:08 +02:00
lilleman 1787754781 Extend the read-only treatment to OAuth2 clients and write-intent GETs 2026-08-05 15:38:56 +02:00
lilleman 765f349007 Model the read/write split in the UI: read-only views, self-revoke and inherited-grant guards 2026-08-05 15:38:37 +02:00
lilleman 29d654c012 Permissions are a fixed list from plugin code; grant them on Users and Groups 2026-08-05 15:38:21 +02:00
lilleman fb4382be9d Enforce the permission-name rule at discovery, for every plugin 2026-08-05 15:38:00 +02:00
lilleman 90cbc47607 Seed the demo admin from the mounted plugins, not the image's empty copy 2026-08-05 15:37:39 +02:00
lilleman 27fee5f8a3 Permission names are <resource>:<action>, replacing the catch-all admin permission 2026-08-05 15:37:39 +02:00
lilleman 9412b90946 More todos
CI / full-gate (push) Successful in 2s
Mirror / github-mirror (push) Successful in 3s
2026-08-05 14:18:09 +02:00
120 changed files with 3886 additions and 2787 deletions
+5
View File
@@ -1,9 +1,14 @@
.git .git
# Load-bearing both ways: a stray copy would bake in at /app/node_modules and shadow /node_modules,
# and matching only the root one is what lets a baked plugin keep its own deps. Never `**/node_modules`.
node_modules node_modules
npm-debug.log npm-debug.log
*.log *.log
.DS_Store .DS_Store
# A plugin's .npmrc is where a private-registry token would sit — never in a shipped image.
plugins/**/.npmrc
e2e-tests/artifacts e2e-tests/artifacts
# Orchestration, not test code — keep them out of the runner image (COPY e2e-tests/ ./) # Orchestration, not test code — keep them out of the runner image (COPY e2e-tests/ ./)
e2e-tests/Dockerfile e2e-tests/Dockerfile
+65 -7
View File
@@ -2,12 +2,27 @@ name: Release
on: on:
push: push:
tags: ['v[0-9]+.[0-9]+.[0-9]+'] tags: ['v[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch:
inputs:
overview_version:
description: 'Released version to republish the overview for, without the leading v (e.g. 0.1.0)'
required: true
jobs: jobs:
retag-image: retag-image:
if: github.event_name == 'push'
runs-on: docker-host runs-on: docker-host
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
# Before anything is published: the contract version IS the release version, so a tag that
# disagrees would ship a host misreporting itself to every plugin's compatibility check.
- name: Refuse a tag that disagrees with HOST_API_VERSION
env:
GIT_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
docker run --rm -v "$PWD:/repo" -w /repo node:24.19.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 - name: Promote the commit-hash image to semver + latest
env: env:
GIT_TAG: ${{ github.ref_name }} GIT_TAG: ${{ github.ref_name }}
@@ -15,34 +30,77 @@ jobs:
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }} REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
REPO: gitea.larvit.se/${{ github.repository }} REPO: gitea.larvit.se/${{ github.repository }}
run: | run: |
set -euo pipefail
COMMIT=$(git rev-parse 'HEAD^{commit}') COMMIT=$(git rev-parse 'HEAD^{commit}')
VERSION=${GIT_TAG#v} VERSION=${GIT_TAG#v}
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
docker pull "$REPO:$COMMIT" \ docker pull "$REPO:$COMMIT" \
|| { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; } || { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; }
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do # No bare-major tag while major is 0: a 0.x minor is a contract break, so `:0` would move
# across one and abort boot for everything tracking it. `:0.1` only moves across patches.
TAGS="$VERSION ${VERSION%.*} latest"
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
for TAG in $TAGS; do
docker tag "$REPO:$COMMIT" "$REPO:$TAG" docker tag "$REPO:$COMMIT" "$REPO:$TAG"
docker push "$REPO:$TAG" docker push "$REPO:$TAG"
done done
- name: Sync the release tags to Docker Hub - name: Sync the release tags to Docker Hub
env: env:
DOCKERHUB_REPO: docker.io/${{ github.repository }} DOCKERHUB_IMAGE: docker.io/${{ github.repository }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }} DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
GIT_TAG: ${{ github.ref_name }} GIT_TAG: ${{ github.ref_name }}
REPO: gitea.larvit.se/${{ github.repository }} REPO: gitea.larvit.se/${{ github.repository }}
run: | run: |
set -euo pipefail
COMMIT=$(git rev-parse 'HEAD^{commit}') COMMIT=$(git rev-parse 'HEAD^{commit}')
VERSION=${GIT_TAG#v} VERSION=${GIT_TAG#v}
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \ [ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; } || { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; }
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do TAGS="$VERSION ${VERSION%.*} latest"
docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG" if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
docker push "$DOCKERHUB_REPO:$TAG" for TAG in $TAGS; do
docker tag "$REPO:$COMMIT" "$DOCKERHUB_IMAGE:$TAG"
docker push "$DOCKERHUB_IMAGE:$TAG"
done done
- name: Log out of the registries - name: Log out of the registries
if: always() if: always()
run: | run: |
docker logout gitea.larvit.se set -uo pipefail
docker logout docker.io # Cleanup, and the runner's Docker config is shared (AGENTS.md) — a lost race here must not
# fail a release that published, nor skip the overview job that follows.
docker logout gitea.larvit.se || true
docker logout docker.io || true
publish-overview:
if: always() && (github.event_name == 'workflow_dispatch' || needs.retag-image.result == 'success')
needs: [retag-image]
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
if: github.event_name == 'push'
# Publish the named release's own tree, so the page never pairs one Plainpages tag with another
# release's sidecar pins. A version that was never released fails here.
- uses: actions/checkout@v7.0.1
if: github.event_name == 'workflow_dispatch'
with:
ref: refs/tags/v${{ inputs.overview_version }}
- name: Publish the Docker Hub overview
env:
DOCKERHUB_REPO: ${{ github.repository }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
GIT_TAG: ${{ github.ref_name }}
INPUT_VERSION: ${{ inputs.overview_version }}
run: |
set -euo pipefail
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
VERSION=${VERSION#v}
# An empty dispatch input falls back to the branch name, so gate this like a tag.
docker run --rm -v "$PWD:/repo" -w /repo node:24.19.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.19.0-alpine3.24 \
node release-tooling/dockerhub-overview.ts "$VERSION"
+12 -7
View File
@@ -21,21 +21,19 @@ jobs:
-e RENOVATE_PLATFORM=gitea \ -e RENOVATE_PLATFORM=gitea \
-e RENOVATE_REPOSITORIES=${{ github.repository }} \ -e RENOVATE_REPOSITORIES=${{ github.repository }} \
-e RENOVATE_TOKEN \ -e RENOVATE_TOKEN \
renovate/renovate:44.11.7 renovate/renovate:44.39.1
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the # After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the # last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
# trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit # trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit
# (a human owns that release) or nothing new merged. ff-only merges keep the renovate commit's # (a human owns that release), nothing new merged, or nothing that merged carried a `Release-Bump:`
# trailer — a release nobody can observe is noise. ff-only merges keep the renovate commit's
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer; # authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
# pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the # pre-1.0 shifts down (release-tooling/next-version.ts). Tag-only — release.yml promotes the
# already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't). # already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't).
# Off until the Actions variable AUTO_RELEASE is set to 'true': Plainpages is pre-announcement and
# deliberately carries no tags, so an automated bump would only invent a version nobody consumes.
auto-release: auto-release:
runs-on: docker-host runs-on: docker-host
needs: renovate needs: renovate
if: vars.AUTO_RELEASE == 'true'
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
with: with:
@@ -57,8 +55,15 @@ jobs:
fi fi
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \ BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; }) --format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
if [ -z "$BUMPS" ]; then
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.19.0-alpine3.24 \ NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
node auto-release/next-version.ts "$LATEST" $BUMPS) 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.19.0-alpine3.24 \
node release-tooling/contract-version.ts "$NEXT" -
echo "Releasing $LATEST -> $NEXT" echo "Releasing $LATEST -> $NEXT"
git tag "$NEXT" origin/main git tag "$NEXT" origin/main
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT" git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
+4 -2
View File
@@ -3,8 +3,10 @@
*.log *.log
node_modules node_modules
# Playwright E2E outputs (screenshots, html report, traces) # Playwright E2E outputs (screenshots, html report, traces). The dir itself is tracked: an absent
e2e-tests/artifacts/ # bind-mount source is created by the daemon as root, which the unprivileged runner cannot write.
/e2e-tests/artifacts/*
!/e2e-tests/artifacts/.gitkeep
# plugins/ is a drop-in mount point, not committed code — keep it empty (see examples/plugins/ for the reference) # plugins/ is a drop-in mount point, not committed code — keep it empty (see examples/plugins/ for the reference)
/plugins/* /plugins/*
+360 -296
View File
@@ -3,238 +3,307 @@
Guidance for AI agents and contributors working in this repo. Read `README.md` for Guidance for AI agents and contributors working in this repo. Read `README.md` for
commands and layout. commands and layout.
## Prose discipline
Every word in this repo is read again on every future task, so prose is a recurring cost. On **any**
change, sweep the prose you touched — this file, `README.md`, the example READMEs, and code
comments — and cut it back to what a competent reader could not infer:
- **Delete history.** Git holds it. No "this moved from X", "used to be Y", "was tried and
rejected", "(declined twice)", dated changelog entries, or the symptom that prompted a fix. Record
the decision and the reason it *currently* turns on, nothing else.
- **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that
re-explains a table above it, a file-map entry that expands the filename. The fix is deletion,
not trimming.
- **Delete the self-evident** and anything already stated once elsewhere. **One home per fact**
link to it instead of repeating it; the same sentence in five files is five chances to drift.
- **Give every accepted risk an expiry** ("valid while X"), and delete the entry once X stops
holding.
- **Keep** the surprising why, the footgun, the invariant, the external constraint, and the one-time
setup a reader cannot dig out of the code. Once a line has earned its place, make it short and
information-dense.
Trimming is not a separate task to schedule — do it in the same change, every time.
## How to work with tasks ## How to work with tasks
Use the file `todo.md`. Use the file `todo.md`.
For each todo item, interview the user extensively to deeply understand the scope and goal of each. When done, check the completed task in `todo.md`. Commit all changes and push to a new branch, create a PR and merge it when the CI/CD turns green. For each todo item, interview the user extensively to deeply understand the scope and goal of
each. When done, check the completed task in `todo.md`. Commit all changes and push to a new
branch, create a PR and merge it when the CI/CD turns green.
## Project priorities (do not erode) ## Project priorities (do not erode)
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable. 1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, 2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
`@larvit/log` — the last itself zero-dependency, for structured/OTLP logging). `postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
Prefer the Node standard library; justify any new dependency; do not add The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
frameworks. The app is Postgres database, which the host provisions but never reads or writes inside. Auth/identity/OAuth are
**stateless — no database**. Auth/identity/OAuth are **Ory sidecar services** **Ory sidecar services** reached over their REST APIs with built-in `fetch` — no SDK. New
(Kratos/Keto/Hydra, backed by Postgres), reached over their REST APIs with capabilities ship as **plugin folders** under `plugins/` that get their data from an upstream
built-in `fetch` — no SDK dependency. New capabilities ship as **plugin service or their own database, not as core code.
folders** under `plugins/` that fetch their data from upstream services, not as
core code. See `README.md` for the architecture.
3. **Strict TypeScript**`tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`, 3. **Strict TypeScript**`tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer `exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types;
exact types and limit nullable and multi option types when possible. KISS. limit nullable and multi-option types.
4. **Environment-agnostic**the app never asks *which environment* it runs in; there is 4. **Environment-agnostic**no `NODE_ENV` branching. Every behaviour is an **explicit config
no `NODE_ENV` (or equivalent) branching. Every behaviour is an **explicit config toggle** read once in `src/config.ts`; compose files set them per deployment.
toggle** (e.g. `CACHE_TEMPLATES`, `REQUIRE_SECURE_SECRETS`, a future "disable email"), 5. **Semantic, accessible DOM** — the right element for the job (landmarks, one `<h1>` per page +
read once in `src/config.ts`. Compose files set the toggles per deployment. sane heading order, lists, `<table>` with row/column headers, `<fieldset>`/`<legend>`, `<button>`
5. **Semantic, accessible DOM** — markup is a first-class concern. Use the right element vs `<a>`); ARIA only to fill real gaps. Classes/ids name *meaning*, not looks.
for the job (landmarks, one `<h1>` per page + sane heading order, lists, `<table>` with 6. **Full, parallel E2E** — every user-facing flow has a Playwright test, shipped in the same change
row/column headers, `<fieldset>`/`<legend>`, `<button>` vs `<a>`); add ARIA only to fill as the surface. Tests stay independent and side-effect-free so the suite runs `fullyParallel`.
real gaps (`aria-current`, `aria-sort`, labels). Classes/ids name *meaning*, not looks. 7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the only way to
Prefer native semantics over `div` + ARIA. New views and partials keep this bar. add domain features. It optimises for being powerful, predictable and overloadable, and the host
6. **Full, parallel E2E** — every user-facing flow (each page, form, guard, plugin route) **fails loud at boot/discovery** rather than sandboxing at runtime. Runtime crash-isolation is a
has a Playwright E2E test, and a new surface ships *with* its E2E in the same change. deliberate **non-goal**.
Tests stay independent and side-effect-free so the suite runs `fullyParallel` — keep it
that way as it grows (never serialise on shared state); parallelism is what keeps it
fast. E2E runs in Docker against the live stack — see `README.md`.
7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the
only way to add domain features. It optimises for being **powerful, predictable, and
overloadable** (a plugin can take over as much of a page as it wants), and the host
**fails loud at boot/discovery** (bad manifest, version mismatch, or conflict stops
startup with a clear message) rather than sandboxing at runtime. Runtime crash-isolation
is a deliberate **non-goal** — diagnose at deploy time, not in production. Keep this
contract stable; see `README.md` → Building plugins.
## Deliberate architectural deviations (don't re-flag) ## Deliberate architectural deviations (don't re-flag)
Intentional, reasoned choices — an architecture review should honor them, not re-raise Intentional, reasoned choices — an architecture review should honor them, not re-raise them.
them. Revisit only if the stated reason stops holding. Revisit only if the stated reason stops holding.
- **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/` ### Structure & contracts
(session-JWT hot path, guards, and the Ory REST clients), `i18n/` (locale resolution + the
catalogs, `locales/` holding the data), `plugin-host/` - **`src/` is grouped by concern**, not flat — `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`,
(discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel + `system.ts`, the with `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` at the root; tests are
`ctx.system` capability surface), and `ui/` (design-system view-models + menu/chrome); co-located. Add a new module to the folder owning its concern. The core ships **no domain
`server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
are co-located (`foo.test.ts` beside `foo.ts`). Add a new module to the folder that owns its - **Plugins and config import the host only through a barrel** — `@plainpages/plugin-api`
concern rather than to the root; don't reintroduce a flat tree. The core ships **no domain `plugin-api/index.ts``src/plugin-host/plugin-api.ts`, `#menu-config``src/ui/menu-config.ts`,
screens** — even the admin GUI (users/groups/permissions) is a drop-in plugin (`examples/plugins/admin/`), never a relative `../../src/*` path. These two barrels are the whole contract surface; don't "fix"
not `src/` code. either back to a relative path. Three consequences:
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the - `@plainpages/plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their
base request context. It protects the I/O-free hot path on the public, bot-hit landing DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major
(`/`). (Declined twice.) `apiVersion` bump, not a free refactor.
- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web` - **The barrel is a package, not a `#`-import, so a plugin folder may carry its own
never touches SMTP. Customization is Kratos' built-in `courier.template_override_path`, `package.json`** and depend on npm packages (README → Plugin dependencies). The Dockerfile links
not app code — keeping `web` stateless and dependency-light (see [Email](README.md#email)). it into `/node_modules`, above every plugin scope. Never let a copy reach a plugin's own
- **Plugins and config import the host only via package.json `imports`** — `#plugin-api` `node_modules`: two instances of the barrel break `instanceof` across the boundary, which
`src/plugin-host/plugin-api.ts`, `#menu-config``src/ui/menu-config.ts` — never a `plugin-api.test.ts` guards by asserting both paths reach one module.
relative `../../src/*` path. These two barrels are the whole author/operator contract - **Plugin storage hands over credentials, not a client** (README → Plugin storage). The host takes
surface; the `src/*` behind them may be refactored freely. Depth-independent and `postgres` to run the provisioning DDL, and `storage-provisioning.ts` is the only module importing
refactor-stable by design — don't "fix" a `#`-import back to a relative path. it — `storage.ts` beside it stays pure so `web` never loads a driver (`src/postgres.test.ts` guards
**One caveat:** `#plugin-api` re-exports the Ory client types for the `ctx.system` surface both halves, because one value imported from the wrong module breaks it invisibly). It is never
(`KratosAdmin`/`KetoClient`/`HydraAdmin` + their DTOs and error classes). Those shapes are re-exported through the barrel, so no driver shape enters the contract. Three properties hold the design together, so
therefore **contract-visible** — changing them is a plugin-API break needing a major don't trade one away in isolation: passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than
`apiVersion` bump, not a free refactor. Keep the Ory clients stable, or bump the version. stored, which is what keeps the host stateless — whoever holds that secret holds every plugin
- **A plugin/config folder must stay a plain folder — no `package.json` of its own.** Node database, so it ranks with the DB password itself; the provisioning DSN reaches `bootstrap` only
resolves `#`-specifiers against the nearest parent `package.json`; a `package.json` inside (`src/compose.test.ts` guards the split); and provisioning never drops anything, so uninstalling a
the folder becomes its own scope and `#plugin-api`/`#menu-config` stop resolving. Accepted plugin cannot destroy data — boot logs the orphans instead. Because the host's copy sits in the
cost of the `#`-import contract (fits the stateless, no-per-plugin-deps ethos). A plugin ambient `/node_modules`, a plugin can `import "postgres"` without declaring it — incidental, not a
kept in its own repo typechecks against the barrel only when mounted under the host tree packaging promise, and a plugin must still depend on its own driver.
(or by adding a local `imports` map / vendored stub). - **The trust boundary is the `web` process, not the plugin.** Per-plugin databases and roles bound
*accidents*, not hostile plugins: `PLUGIN_DB_SECRET` is in `web`'s environment during `onBoot`, and
a plugin already holds `ctx.system`'s Ory admin clients — so cross-plugin DB isolation is
containment, and README says so rather than implying a sandbox. Consistent with priority #7
(crash-isolation is a non-goal). `server.ts` still deletes the secret from `process.env` right
after `loadConfig`, which is before discovery imports any plugin module — the ordering is the whole
point, so move it earlier if anything, **never later**. **Valid while plugins are
operator-installed code, not third-party uploads.**
- **`ory/postgres/init/init.sql` is the only home for the Ory databases' ACL** — don't re-assert the
`REVOKE CONNECT` from `bootstrap`. `REVOKE` only *warns* when the caller doesn't own the database,
so under the least-privilege provisioning account the README recommends it would report success
while changing nothing, and it hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a server with no
`kratos`. It runs only on **first init**, so a revoke added to it later never reaches a volume that
already exists — `docker compose down -v` is the dev remedy, a deployed install needs a migration.
- **`bootstrap.ts` stays under `src/auth/`** even though it now provisions plugin databases as well
as seeding Ory. It is the one-shot service's entrypoint, not an auth module; moving it to
`src/bootstrap.ts` would edit `compose.yml`, five e2e compose files and `src/compose.test.ts` for a
rename. Reconsider when a third seeding concern lands.
- **`BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook.** Adding
to the context costs a minor bump and removing one a major, so the shape errs small elsewhere. Pools
handed to a plugin are reaped on process exit — revisit if a plugin ever needs an orderly drain.
- **`config/` is still a plain dir — no `package.json` of its own**, or `#menu-config` resolves
against that instead and boot fails loud. An operator's menu override has no use for
dependencies; if that changes, it needs the same package treatment.
- **A plugin `package.json` without `"type": "module"` is refused, not warned.** Allowing it costs a
warning and a re-parse per file, not a break — Node detects module syntax, so even a `.js` helper
loads — and an operator on a read-only third-party mount cannot apply the remedy. Refused anyway
because the direction is safe: refuse→warn relaxes freely, warn→refuse breaks installed plugins.
**Valid while nothing is installed in the wild.**
- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to - **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to
`plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirror folders are in `plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in
`tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks `tsconfig.include` and resolve the host through the barrels, so each typechecks in place *and*
in place *and* copies across unchanged. Never commit real plugins/config into the root copies across unchanged. Never commit real plugins/config into the root mount dirs — they ship empty.
mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise). - **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the base request
- **Authorization vocabulary: `User``Group``Permission`, and there is no `Role`.** Keto ships context. It protects the I/O-free hot path on the public, bot-hit landing (`/`).
no namespaces — all four in `ory/keto/namespaces.keto.ts` are ours. `Permission` follows RBAC,
where a permission is one operation ("read shifts") and a role is a *bundle* of them; a route
gates on one operation, so it gates on a permission, and a bundle is just a group with several
grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the
separate per-row tier.
- **Plainpages says "user" everywhere; Ory's word for it is "identity".** Kratos calls the record
an identity, but Ory's own docs state it uses that term *interchangeably* with "users" and
"accounts" — so this is house style, not a renamed concept, and "user" is the word readers
already know (Nielsen's heuristic #2: match between the system and the real world). One note in
README → Auth records the mapping so nobody has to rediscover it. The single exception is the
`Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape and keeps Ory's
name — don't rename that one.
- **The locale lives in the URL, never in a cookie.** `?locale=sv-SE``Accept-Language``en-US`,
and when the URL asked for one the host carries it onto the links it renders (`ctx.localeHref`).
A cookie would make a page's language invisible in its address and unshareable; the cost is that a
plugin must wrap its own hrefs. Matching is exact on a full tag (`sv-FI``sv-SE`), except that a
lone language from `Accept-Language` takes the first regional catalog for it. Decided 2026-08-03.
- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US`
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` says that locale needs —
and a mismatch stops startup, same fail-loud contract as a bad manifest. A plugin may ship fewer
locales than the host (its strings fall back to `en-US` per key), never one the host lacks.
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell (breadcrumbs),
`pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every
href they render in `localeHref`; the nav and the sign-in link are wrapped upstream in `chrome.ts`;
and the two GET forms
(filter bar, rows-per-page) carry it as a hidden `locale` input, since a GET submit replaces the
whole query string and no href wrapper can reach it. Putting the obligation on each call site was
tried first and missed five of eight sites inside one commit — including the admin screens.
`ctx.localeHref` remains for hrefs a plugin's own markup emits (the admin example's delete links).
**A form's `action` counts as a link** — a POST replaces the URL as completely as a GET submit, so
the sign-out, consent and auth-card forms carry it too; without that, picking a language and then
saving anything drops back to `Accept-Language`. The one round-trip that cannot carry it is the
Kratos sign-in POST, whose action is an absolute off-site URL.
Decided 2026-08-03 after an architecture review; a second pass then found breadcrumbs still raw,
so: when a link renders from the core chrome, it is the chrome's job to carry the locale.
- **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set (`list-query.ts`),
so a localized list page doesn't hand a plugin a phantom `locale` filter; the i18n view locals (`t`, `locale`, `locales`, `localeHref`,
`localeParam`, `localeSwitch`, `dir`) are likewise reserved names, merged after a handler's `data`
so a collision loses the key instead of breaking the shell.
- **The language picker is on every page, POST-rendered ones included.** Maintainer's call
2026-08-04, overriding an earlier decision to hide it there. The problem it was hiding is real: a
POST-rendered URL frequently answers no GET (`POST /admin/users/:id/recovery`), so a link back to
it dead-ends on a 405. The host therefore resolves the picker's target (`app.ts``switchBase`):
this path when it answers GET, else the same-origin Referer, else `/`. Accepted cost: switching
language on such a page leaves that POST's own result behind (a re-rendered form's input, or a
one-time recovery code). Valid while the picker is expected on literally every page — if that ever
softens, hiding it after a POST is the simpler answer.
- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`, - **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
`dashboard`) and an `onRequest` short-circuit dispatch a plugin's handler, so they build the `dashboard`) and an `onRequest` short-circuit build their context with `contextFor(pluginId)`
context with `contextFor(pluginId)` exactly as a plugin route does — otherwise `ctx.t` is the core exactly as a plugin route does — otherwise `ctx.t` is the core translator and the plugin's own keys
translator and the plugin's own keys render as bare keys on the pages it owns. Found by review render as bare keys on the pages it owns.
2026-08-03 after all three paths shipped with the host's context. - **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web` never
- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`** — `locales/<tag>.ts` touches SMTP. Customization is Kratos' `courier.template_override_path`, not app code.
for the core and `locales/plugins/<id>/<tag>.ts` for an installed plugin, each adding a language or
replacing that tag's catalog wholesale. Adding a language must never require forking the image or a ### Authorization
vendored plugin folder. The SHIPPED `en-US` (core's, or the plugin's own) stays the parity baseline
even when the mount replaces it, so a mounted catalog is checked rather than trusted (one compared - **Vocabulary: `User``Group``Permission`, and there is no `Role`.** Keto ships no namespaces —
only against itself would boot green with the whole UI rendering keys), and each half is reported all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one operation ("read shifts");
under the folder it actually lives in. a role is a *bundle*, which here is just a group with several grants (groups nest). Ory's own
- **RTL is out of scope until there is a real use case.** `textDirection` sets `<html dir>` from the "permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
locale's script because that is free and correct, but the stylesheet keeps physical `left`/`right` - **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
properties — a genuine RTL locale needs those moved to logical ones first. Don't convert the CSS or word names *who someone is* (a role), and roles are groups here. **Enforced at discovery**
file findings about it on spec. Maintainer's call 2026-08-04; valid while no deployment needs an (`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route/nav
RTL language. A catalog there `permission` and every declared name), fail-loud like any other manifest rule — not only in the
for a new tag adds a language; one for a tag the image ships replaces that catalog wholesale, held admin GUI, which an operator removes by not copying it in.
to the same parity check. Adding a language must not require forking the image. - **Names are authored in plugin code; only grants live in Keto.** The host collects every installed
- **An unknown translation key renders as itself.** That single rule is what lets a nav label, plugin's declarations into one catalog (`declaredPermissions``ctx.declaredPermissions`), and
branding, or a menu `rename` be either a key or plain text without a second field or a migration. that catalog *is* the list the admin screens offer. Hence **no Permissions admin screen**: nothing
Don't "fix" it into a loud failure: a manifest with plain labels must keep working. in a GUI invents a name, and holding one is a property of a user or group, edited as a checkbox
- **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value, list there. A Keto tuple naming something no installed plugin declares gates nothing, is not
so nothing is double-escaped; a message carrying markup uses `<%- %>`, and then its `{{vars}}` are offered, and is never revoked by an unrelated save — the picker only speaks for what it showed.
escaped at the call site (see `views/partials/pagination.ejs`). Don't move escaping into `t()` - `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`): users are
every other value in a view would then be the odd one out. the *host's*, and cross-plugin sharing is a goal. Cost: collision-freedom is a convention rather
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so than structural. Accepted — the alternative penalizes the sharing case.
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: - **Declaring a permission stays optional.** Mandatory declaration would let `findConflicts` see all
concurrent jobs can race (one job's logout can 401 another's push — recover by re-running), overlaps, but would then warn on exactly that legitimate sharing case. Shape is enforced;
and tokens sit in that file between login and logout. Same class: concurrent runs share the declaration is not.
workspace dir, so ci.sh's web-image build races another run's container creation on the - `ADMIN_PERMISSIONS` **defaults to empty**, and **an unusable value is dropped with a warning,
`<project>-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow never fatal** — fail-loud belongs at the manifest boundary where a developer authored the
`concurrency` group if it ever bites. mistake, whereas `bootstrap` gates `web`, so refusing operator env takes the whole stack down
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** No test, build step or (`e2e-tests/compose.auth.yml` seeds a bad value to prove the container survives one). The seed is
workflow reads a markdown file (`README-dockerhub.md` is pasted into Docker Hub by hand), so a a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs
nested `examples/plugins/admin/README.md` edit is as safe to skip as `README.md`, and narrowing it `docker compose up -d`, not `restart web`. `bootstrap`'s matching `./plugins` mount belongs in
would spend the full gate on one. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`: `compose.override.yml` and nowhere else: in the base file it would desynchronise prod and collide
rename detection names only the destination, so `git mv src/app.ts notes.md` otherwise read as docs with the e2e stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a
and skipped the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a read-only parent is EROFS and the container never starts). Valid while bootstrap is the only
*text* guard — the test image (`node:24.19.0-alpine3.24`) ships neither `git` nor `bash`, so it writer of grants.
cannot exercise the function; behaviour was verified against a scratch repo across ten scenarios. - **`actionForMethod` is plugin-local and must not migrate into `@plainpages/plugin-api`.** Inside the admin
Revisit if a `.md` ever becomes load-bearing. Decided 2026-08-05. example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
- **Plainpages is pre-announcement: no tags, no releases.** The repo carried tags up to `v0.2.2` from × 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
the `auto-release` job; all of them — and the semver container tags — were deleted 2026-08-05, and verb — a route table must answer "what does this need?" on its own.
the job is gated behind the `AUTO_RELEASE` Actions variable (unset ⇒ skipped, the fail-safe - **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
direction on every unknown-`vars` path). A version only communicates to consumers, and there are `canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
none; same reasoning that freezes `HOST_API_VERSION` at 1.0.0. Note the coupling: disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
`registry-cleanup` keeps a hash image only while its commit is a branch head *or* release-tagged, form, a delete-confirm page) is the exception to `actionForMethod` and gates on `:write`. Two
so with zero tags only branch heads survive the nightly prune — a hand-cut tag must sit on `main`'s grant-specific guards go with it: you cannot revoke your own **direct** grants (self-lockout would
tip. `mirror.yml` pushes tags with `--prune` so the deletions actually reach the public GitHub need a `curl` against Keto to undo), and a permission held *through a group* renders
mirror; that makes the runner's tag view load-bearing (hence `fetch-tags: true`) and means a tag ticked-but-disabled, because unticked stated the opposite of the truth. **Known gap:** the group
or Release created on GitHub is swept away, so releases are cut on Gitea only. Valid until the paths are unguarded — unticking a permission on a group you belong to, leaving it, or deleting it
maintainer says Plainpages is ready to show people. can still strip your own access. The robust "last effective holder" check needs a reverse Keto
query and is deferred.
- **`users:write` and `groups:write` are equivalent to full administrative access**: `groups:write`
adds you to any group, including one holding every permission; `users:write` mints a recovery code
for any account. The containment the split buys is real on the **read** half only (`users:read` is
a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
- **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
Kratos' wire shape — don't rename it.
### i18n
- **The locale lives in the URL, never in a cookie.** `?locale=sv-SE``Accept-Language``en-US`,
and when the URL asked for one the host carries it onto the links it renders. A cookie would make a
page's language invisible in its address and unshareable; the cost is that a plugin wraps its own
hrefs. Matching is exact on a full tag (`sv-FI``sv-SE`), except that a lone language from
`Accept-Language` takes the first regional catalog for it.
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell, `pagination`,
`filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every href in
`localeHref`; nav and sign-in are wrapped in `chrome.ts`; the two GET forms carry it as a hidden
`locale` input, since a GET submit replaces the whole query string. **A form's `action` counts as a
link** — sign-out, consent and auth-card forms carry it too, or picking a language and then saving
anything drops back to `Accept-Language`. The obligation stays on the building block, never on each
call site. `ctx.localeHref` remains for hrefs a plugin's own markup emits. The one round-trip that
cannot carry it is the Kratos sign-in POST (absolute off-site URL).
- **`locale` is a host-owned query param** — in `parseListQuery`'s reserved set, so a localized list
page doesn't hand a plugin a phantom `locale` filter. The i18n view locals (`t`, `locale`, `locales`,
`localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's
`data` so a collision loses the key instead of breaking the shell.
- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US`
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` requires — and a
mismatch stops startup. A plugin may ship fewer locales than the host (its strings fall back to
`en-US` per key), never one the host lacks.
- **`locales/` at the repo root is a drop-in mount**, like `plugins/` and `config/``locales/<tag>.ts`
for the core, `locales/plugins/<id>/<tag>.ts` for a plugin; a new tag adds a language, an existing
one replaces that catalog wholesale. Adding a language must never require forking the image. The
SHIPPED `en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is
checked rather than trusted (one compared only against itself would boot green with the whole UI
rendering keys).
- **The language picker is on every page, POST-rendered ones included.** A POST-rendered URL often
answers no GET (`POST /admin/users/:id/recovery`), so the host resolves the picker's target
(`app.ts``switchBase`): this path when it answers GET, else the same-origin Referer, else `/`.
Accepted cost: switching language there leaves that POST's own result behind. Valid while the picker
is expected on literally every page — if that softens, hiding it after a POST is simpler.
- **An unknown translation key renders as itself.** That single rule lets a nav label, branding, or a
menu `rename` be either a key or plain text without a second field or a migration. Don't "fix" it
into a loud failure: a manifest with plain labels must keep working.
- **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value;
one carrying markup uses `<%- %>`, and then its `{{vars}}` are escaped at the call site. Don't move
escaping into `t()` — every other value in a view would become the odd one out.
- **RTL is out of scope until there is a real use case.** `textDirection` sets `<html dir>` because
that is free and correct, but the stylesheet keeps physical `left`/`right` properties; a genuine RTL
locale needs those moved to logical ones first. Valid while no deployment needs an RTL language.
### UI
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then - **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
owns open/close, which is the only zero-JS way to dismiss a menu by clicking outside it (the whole owns open/close the only zero-JS way to dismiss by clicking outside — and the panel sits in the
point), and the panel sits in the top layer so a row kebab is no longer clipped by `.table-wrap`'s top layer, so a row kebab is not clipped by `.table-wrap`'s `overflow`. Four rules hold it
`overflow`. Four rules hold it together, none of them cosmetic. The panel carries together: the panel carries **`position-anchor: auto`** (a bare `anchor()` resolves to nothing in
**`position-anchor: auto`** — a bare `anchor()` resolves to nothing in Chromium, Firefox *and* all three engines); it stays the trigger's **next sibling inside the `.menu` wrapper**, which the
WebKit alike, which is why the popover test in `visual.spec.ts` runs in all three rather open-state style and the old-browser fallback both read; the partial **requires a caller-named
than resting on a one-time manual measurement. The panel stays the trigger's **next sibling inside `id`** and fails loud without one, since that is the `popovertarget` idref (never generate one —
the `.menu` wrapper**, because the open-state style and the old-browser fallback both read that nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
adjacency, and a two-element partial cannot be dropped into an arbitrary layout. The `menu` partial `aria-haspopup` is written**, because a zero-JS invoker cannot keep the first truthful and the
**requires a caller-named `id`** and fails loud without one: it is the `popovertarget` idref, and second would promise `role="menu"` semantics these panels don't implement. `<details>` stays where
generated random ids were tried and rejected the same day — nondeterministic HTML forecloses the it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
still-open caching decision and names nothing a reader can use. And **neither `aria-expanded` nor profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
`aria-haspopup` is written**: a zero-JS invoker cannot keep the first truthful, and the second would the two in step.
promise `role="menu"` keyboard semantics these panels do not implement. `<details>` stays where it - **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
profile menu because its trigger composes escaped user values and its one item is a CSRF POST form, to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
neither of which the partial's `Item` shapes cover — keep the two in step, or fold it in if unknown sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test
`todo.md`'s "does the profile dropdown still earn a dropdown" settles the other way. Decided 2026-08-05. catches anything reaching the nav).
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract.** It is
deliberately not re-exported from `#plugin-api`, and README → Nav & permission gates already tells an ### Build, test & release
author that using a new icon means registering it there. So the palette may narrow when the last
reference to an id goes — `i-gear` left with the settings menu 2026-08-05 — and a plugin needing one - **Deps install to `/node_modules`, above `WORKDIR /app`** — Node resolves upward, so dev's `.:/app`
gets it re-registered in the same change. Accepted cost: an unknown sprite id renders a blank icon bind mount has nothing to shadow. Not a volume at `/app/node_modules`: the daemon creates a mount
instead of failing loud; the `every icon <use> resolves to a defined <symbol>` e2e test catches it for destination as root whatever `--user` says, leaving a root-owned dir in the checkout. Nothing may
anything reaching the nav. Removing an id is a core edit, so weigh it per icon rather than sweeping the sit at that path now — it shadows `/node_modules` silently (`src/compose.test.ts` guards the compose
registry — a few ids are registered ahead of a caller (see `todo.md`). files, `.dockerignore` the image).
- **A container whose output a human then edits or deletes runs as `--user "$(id -u):$(id -g)"`** —
the E2E runner (artifacts) and a lockfile edit, or the output is root-owned and needs `sudo`, which
a dev box may not have. Not universal: `bootstrap` writes `jwks.json` as root when it is absent on
first boot; the committed dev key makes that rare, and when it happens the rotation runbook's
host-side `>` needs the file re-owned first (valid while the dev key ships committed). Three
consequences: `e2e-tests/artifacts/` is *tracked* (`.gitkeep`), since an absent bind-mount source is
daemon-created as root and that uid then cannot write it (README → Upgrading); the runner image sets
`HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an unwritable `/`; and
rootless Docker wants the flag *dropped*, container root already being the invoking user. Baking a
`USER` in instead does not work — the image's `pwuser` is 1001 and no fixed uid matches every host.
`src/compose.test.ts` guards every documented command, `src/ci-gate.test.ts` the gate's own.
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from - **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
`e2e-tests/console-guard.ts`, which watches every page a test opens: a console error or warning, or `e2e-tests/console-guard.ts`, which fails a test on a console error/warning or uncaught exception on
an uncaught exception, fails that test. A zero-JS app has nothing to say in the console, so the bar any page it opened. A zero-JS app has nothing to say in the console, so the bar is *zero* rather than
is *zero* rather than a curated list of tolerated noise — and the two exceptions are explicit and a curated tolerance list; the two exceptions are narrow — a module-level allowance for the COOP header
narrow: one module-level allowance for the COOP header Chromium drops because the e2e stacks serve Chromium drops (the e2e stacks serve plain http over container hostnames), and `allowConsole(re)` for
plain http over container hostnames (a deployment serves https, where it applies), and a test whose own page provokes a message on purpose. `src/e2e-console-guard.test.ts` locks the wiring
`allowConsole(re)` for a test whose own page provokes a message on purpose — the 404 spec, whose in the *unit* gate, since a spec importing `test` straight from Playwright — or minting a page with
navigation Chromium and WebKit log. Each record carries the message's origin URL, so that allowance a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. Accepted cost: a page
can name the page under test and still see a sub-resource of it 404. `src/e2e-console-guard.test.ts` outliving its test can log late and fail the next one.
locks the wiring in the *unit* gate: a spec importing `test` straight from Playwright — or minting a
page with a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. The buffer
clears at teardown rather than setup so a `beforeAll` is watched too (full-flow runs a whole login in
one); the accepted cost is that a page outliving its test, as a serial describe's does, can log late
and fail the next test instead of its own. Verified by negative control in all three engines.
- **The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium.** - **The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium.**
`visual.spec.ts` + `language.spec.ts` are side-effect-free, so three parallel runs don't collide, `visual.spec.ts` + `language.spec.ts` are side-effect-free, so parallel runs don't collide, and a
and a console message only appears in the engine that renders the page — the reason the per-test console message only appears in the engine that renders the page (`ORY_FREE` in
`@engines` tag is gone: the whole Ory-free suite is the engine matrix now (`ORY_FREE` in
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend, `e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
where a second engine's run would race the first, so widening them means giving each engine its own so widening them means a stack per engine.
stack. Screenshots are written per project name for the same reason. Decided 2026-08-05. - **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** Both git channels in
`ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the destination, so
`git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a source file that
was gone. `src/ci-gate.test.ts` locks the flags as a
*text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
`release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard
skipped. `README.md` is the one markdown a test reads — `release-tooling/contract-version.test.ts`
checks its `apiVersion` samples — and a README-only change skips that check; accepted, because
those samples are illustrative and the copies that matter (`examples/`, `views/`, the template) are
gated. **Valid while no markdown file is rendered or executed.**
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's
web-image build races another run's container creation on the `<project>-web` tag. Accepted for a
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
## Docker only — no host tooling ## Docker only — no host tooling
@@ -252,102 +321,97 @@ docker compose -f compose.yml up --build -d # production
`README.md` serves two readers, in this order — preserve it when editing: `README.md` serves two readers, in this order — preserve it when editing:
1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets 1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets the
the stack up (`docker compose up`, sign in) and a *minimal* plugin live. Nothing comes stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands
before Quick start — no philosophy, no rationale. Keep its commands copy-pasteable and the copy-pasteable; deeper detail lives in its own section, linked.
example plugin as small as possible; deeper detail lives in its own section, linked. 2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
2. **Returning developer (rest).** A **Contents** ToC immediately after Quick start, then by **what an adopter reaches for first**, not by architectural layering: Overview → Users, groups
sections ordered by **what a developer adopting Plainpages reaches for, in priority & permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
order** — not by architectural layering. The value that sets the order: getting up and Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
running **building plugins** comes first, then **configuring and securing** the system map → Extending. Place a new section by how early an adopter needs it. **Users, groups &
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are permissions precedes Building plugins** because a manifest's `permission:` gate is unreadable
deliberately deferred — they're not top of mind when starting out. Concretely: Overview → without the model, and it is the one home for that model.
Users, groups & permissions → Building plugins → menu/blocks/interactivity →
Configuration → Auth → Email → Architecture → Testing → Production → Observability → the
JWT-rotation runbook → the Project-layout file map → Extending. When adding a section, place
it by this value (how early an adopter needs it), not by where it sits in the stack.
**Users, groups & permissions precedes Building plugins** because a manifest's Keep the ToC in sync when you add/rename/remove an `H2`/`H3`. **Don't document internals** — how a
`permission:` gate is unreadable without the model, and operators need it as much as plugin script reaches a decision, what a function guards; a developer reads that off the code in seconds.
authors. It is the one home for that model — the plugin and auth sections link to it rather The README earns its length on how to use and operate Plainpages, the external contracts, and
than restating it. one-time setup. A file-map or table row gets a clause, not a paragraph.
When editing: put content in the section it belongs to (don't prepend rationale above Quick
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
one home, linking to it rather than restating (credentials, env vars, rotation steps).
**Don't document internals here.** How a script reaches a decision, why one run behaved
differently from another, what a function guards — a developer doesn't need it day to day and
can read it off the code or a run's log in seconds. Prose like that only makes the README
longer and harder to consume, for humans and machines alike. It belongs in the code it
describes, or nowhere. The README earns its length on what you cannot dig out: how to use and
operate Plainpages, the external contracts, and one-time setup (secrets, accounts, tokens).
Same test before adding a row to a table or the file map — a clause, not a paragraph.
## Rules ## Rules
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable** - Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or (`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or decorators. Import
decorators. Import local modules with their `.ts` extension. local modules with their `.ts` extension.
- **No `.mjs`.** Write modules as `.ts` (Prio 1) — even standalone scripts run in bare - **No `.mjs`.** Write modules as `.ts` — even standalone scripts run in bare `node:24` containers.
`node:24` containers (the e2e mock servers, `examples/shifts-upstream/server.ts`): Node If a file genuinely must be plain JavaScript, use `.js`; `"type": "module"` is set in both
strips types and detects ESM from syntax, no package.json needed. If a file genuinely
must be plain JavaScript, use `.js` (Prio 2); `"type": "module"` is already set in both
`package.json`s, so `.js` is ESM. `package.json`s, so `.js` is ESM.
- **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit. - **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit.
- Before finishing a change, run the typecheck and tests above; both must pass. - Before finishing a change, run the typecheck and tests above; both must pass.
- Tests use the built-in `node --test` runner — no test framework dependency. - Tests use the built-in `node --test` runner — no test framework dependency.
- English everywhere. Keep code comments short and information-dense. Self explained code - English everywhere.
without any comment at all is the preferred solution. - Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
- Do not comment about history in the code or README. Like "This function included X before, ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images
but it moved to Y". by tag.
- Do not comment about the absence of things, if it is not very unexpected. Banned is things - **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist*: its
like "This function does not calculate pi, that is done in function Z". rules name exactly what carries the trailer, so a dependency outside them never escalates the
- Pin all dependencies and Docker images to exact, human-readable **semantic release version and nothing fails to say so. A new manifest, compose file, custom manager or dep
versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept type is a decision: can it reach a running Plainpages? If yes it needs a rule; if no, record nothing
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g. and let it ride the next patch.
`node:24.16.0-alpine3.24`). - **`HOST_API_VERSION` *is* the release version.** Its `major.minor` must equal the release tag's, and
- **`HOST_API_VERSION` is frozen at 1.0.0 until the first external install**, even for additive both release paths refuse a tag that disagrees (`release-tooling/contract-version.ts`). The patch
contract changes (i18n added four `RequestContext` fields and several barrel exports without a digit may lag on purpose: `checkApiVersion`
minor bump). Valid while nothing is installed against it: with no third-party plugin in the wild, ignores patch, and auto-release cuts patch releases with no commit to bump a constant in. So a
a version bump can only produce noise. The promotion trigger is the first external plugin — from dependency update big enough to force a **minor** is plugin-visible by definition — `auto-release`
then on, follow the versioning table in README → Contract versioning as written. Decided 2026-08-03. stops rather than tagging, and the fix is to bump `HOST_API_VERSION` to that `X.Y.0` in a PR, merge
**The frozen surface includes `views/partials/*.ejs`**, not just the manifest and the barrel: the it, then tag. Never bump it to "catch up" with a patch release. **The contract surface
view resolver makes every core partial an `include()` root for a plugin's views, so their option includes `views/partials/*.ejs`** — the view resolver makes every core partial an `include()` root
names and emitted markup are author-visible (under this freeze the popover change dropped the `menu` for a plugin's views, so their option names and emitted markup are author-visible. Know the hole
partial's `open?` and rewrote its markup). Know the hole that leaves discovery fails loud on a bad that leaves: discovery fails loud on a bad `apiVersion`, but `include("menu", { open: true })`
`apiVersion`, but `include("menu", { open: true })` silently ignores the option and a plugin styling silently ignores a dropped option, so the partial vocabulary is a surface the version check cannot
`.menu > summary` silently loses it. Promotion must cover the partial vocabulary too. Added 2026-08-05. police for you.
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version the - **The contract surface also includes the packaging promises** (README → Plugin dependencies): the
plugin was built against — bumped by hand on rebuild, **never** the host's barrel is ambient at `/node_modules` with nothing for a plugin to declare, `"type": "module"` is
`HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the mandatory, and the host neither upgrades nor dedupes a plugin's dependencies. Same hole as the
host, so `checkApiVersion` can never fire and a breaking change slips through silently. partials — move the publish point, rename the package or start hoisting and every installed plugin
- **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler breaks with no version signal. Note the promise is deliberately *not* "your deps are yours alone":
per `{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed build-time dedupe for baked images stays open, module-instance sharing stays unpromised.
`%`-encoding — no manual path-slicing/decoding). Don't funnel many routes into one dispatcher - **Publishing `@plainpages/plugin-api` to a registry is deferred, not rejected.** Today it is
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and `private` and shaped as a shim — `index.ts` re-exports `../src/…`, so `npm pack` would ship a
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability broken tree. The trigger is the first plugin author outside this repo — the first who cannot
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`. typecheck against a mounted host tree. Whoever does it must first make the artifact self-contained
(types-only `.d.ts`, or move the barrel into `plugin-api/`).
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version it was built
against — bumped by hand on rebuild, **never** the host's `HOST_API_VERSION` constant. Importing
the constant makes every plugin always equal the host, so `checkApiVersion` can never fire.
- **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler per
`{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed `%`-encoding).
Don't funnel many routes into one dispatcher that re-parses `ctx.url.pathname`: it duplicates the
URL shape, ignores the router's params, and has to re-handle HEAD. Factor shared per-request setup
into a small `withX` wrapper — see `examples/plugins/admin/`.
- **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking - **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking
canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin
routing, 405/404 and error mapping. The pure parts are already extracted and separately tested; what routing, 405/404 and error mapping. The pure parts are already extracted and separately tested;
remains is orchestration. Planned split along those seams; don't grow it further without taking one what remains is orchestration. Planned split along those seams; don't grow it further without
out. Raised by the architecture review 2026-08-03, deliberately not done inside the i18n change. taking one out.
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer - Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer agents.
agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in - **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
`src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own `src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own
`i18n/`. Operator/developer-facing text — boot errors, log messages, guard messages — stays English. `i18n/`. Operator/developer-facing text — boot errors, log messages, guard messages — stays English.
A pure view-model builder takes an optional `t` defaulting to its own English, so a unit test reads A pure view-model builder takes an optional `t` defaulting to its own English, so a unit test reads
in words; handlers pass `ctx.t`. in words; handlers pass `ctx.t`.
- **One verb per action in the English UI: sign in, sign out, create account.** Not "log in", - **One verb per action in the English UI: sign in, sign out, create account.** Not "log in", "log
"log out" or "sign up", inflections included — a second spelling for one button reads as a second out" or "sign up", inflections included — a second spelling for one button reads as a second thing;
thing; the noun ("a sign-in error", "the sign-in identifier") is unaffected. A plugin's catalog and the noun ("a sign-in error") is unaffected. A plugin's catalog and every other locale follow the
every other locale follow the same rule in their own language. An unmapped Kratos id renders same rule in their own language. An unmapped Kratos id renders Kratos' own wording — map the id when
Kratos' own wording — map the id when it matters. **Held by the author, never by a test:** as the it matters. **Held by the author, never by a test:** slightly different wording is often the right
UI grows, slightly different wording is often the right call, and a check that fails the build on call, and a build-failing check takes that judgment away.
a word takes that judgment away. Maintainer's call 2026-08-05, dropping the guard that shipped - Use well formed, standard compliant, rich URIs. Prefer state in the URL over POSTing it, for
with the rule. example on list pages with filters and pagination. Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for `ids=x,y`.
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
and not "ids=x,y". ## Comments
Default to **no comment**. Delete one that restates the adjacent code, repeats a convention used
elsewhere, justifies self-evident code, or records history. Write one only for what a competent
reader of *this* codebase could not infer: a surprising why, a footgun, an invariant, an external
constraint. See [Prose discipline](#prose-discipline).
+11 -5
View File
@@ -1,14 +1,20 @@
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag. # Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
FROM node:24.19.0-alpine3.24 FROM node:24.19.0-alpine3.24
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
# Dev deps kept so typecheck/test run in-image.
COPY package.json package-lock.json .npmrc /deps/
RUN cd /deps && npm ci && mv node_modules /node_modules && rm -rf /deps
# The barrel as a package, so a plugin folder can own a package.json. Linked because it re-exports /app.
RUN mkdir -p /node_modules/@plainpages && ln -s /app/plugin-api /node_modules/@plainpages/plugin-api
WORKDIR /app WORKDIR /app
# Reproducible install from the lockfile. Dev deps kept so typecheck/test run in-image.
COPY package.json package-lock.json .npmrc ./
RUN npm ci
COPY . . COPY . .
# The host uid running a lockfile edit has no home here, so npm's cache would land in unwritable /.
ENV npm_config_cache=/tmp/.npm
ENV PORT=3000 ENV PORT=3000
EXPOSE 3000 EXPOSE 3000
CMD ["node", "src/server.ts"] CMD ["node", "src/server.ts"]
+834 -1037
View File
File diff suppressed because it is too large Load Diff
+31 -2
View File
@@ -60,13 +60,42 @@ echo "$units" | grep -E '^. (tests|pass|fail) ' || true
count=$(echo "$units" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | head -1 || true) count=$(echo "$units" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | head -1 || true)
[ "${count:-0}" -ge 50 ] || { echo "only ${count:-0} unit tests ran — test glob broken?"; exit 1; } [ "${count:-0}" -ge 50 ] || { echo "only ${count:-0} unit tests ran — test glob broken?"; exit 1; }
# Plugin storage against a real Postgres. The step above runs --no-deps, so this suite's integration
# test skips there — and it is the only thing proving the DDL actually grants what it claims, rather
# than that the SQL text is the text we wrote. `node --test` counts a skip, so the floor won't catch it.
step "Plugin storage (real Postgres)"
# Own project name, like every E2E suite below: the default project is the DEV stack, so a bare
# `down -v` here would delete the operator's pgdata — Ory identities and every plugin database.
# --wait, because initdb on a cold volume outlasts the suite's connect timeout.
storage_rc=0
storage_proj=plainpages-storage
storage_files=(-p "$storage_proj" -f compose.yml) # no override merge, like the e2e suites below
storage_dsn="postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/ory"
storage_out=""
docker compose "${storage_files[@]}" up -d --wait postgres >/dev/null || storage_rc=$?
# `if`, not `&&`: a false `&&` returns non-zero, which under `set -e` would exit before teardown.
if [ "$storage_rc" -eq 0 ]; then
# --build like the e2e suites: this stack mounts no source, so without it the step would test
# whatever `web` image that project last baked.
storage_out=$(docker compose "${storage_files[@]}" run --build --rm --no-deps \
-e "PLUGIN_DB_ADMIN_URL=$storage_dsn" \
web node --test src/plugin-host/storage.test.ts 2>&1) || storage_rc=$?
fi
docker compose "${storage_files[@]}" down -v >/dev/null 2>&1 || true # also covers a failed `up`
echo "$storage_out" | grep -E '^. (tests|pass|fail|skipped) ' || true
[ "$storage_rc" -eq 0 ] || { echo "$storage_out"; echo "plugin storage integration tests failed (exit $storage_rc)"; exit "$storage_rc"; }
# A skip here exits 0 and proves nothing — the same trap the unit floor above guards against.
echo "$storage_out" | grep -qE '^. skipped 0$' || { echo "storage integration test skipped — PLUGIN_DB_ADMIN_URL not wired through"; exit 1; }
# Run one E2E suite against its OWN named stack, then always tear it down (even on failure). The # Run one E2E suite against its OWN named stack, then always tear it down (even on failure). The
# per-suite project name keeps a flaky teardown from leaking containers/volumes into the next suite. # per-suite project name keeps a flaky teardown from leaking containers/volumes into the next suite.
# --user: the runner writes screenshots + the report into the checkout, so they must belong to
# whoever ran the gate — root-owned output needs sudo to delete, and a dev box may have none.
e2e() { e2e() {
step "E2E: $1" step "E2E: $1"
local proj="plainpages-e2e-$(basename "$1" .yml | tr '.' '-')" # dots aren't valid in a compose project name local proj="plainpages-e2e-$(basename "$1" .yml | tr '.' '-')" # dots aren't valid in a compose project name
local rc=0 local rc=0
docker compose -p "$proj" -f compose.yml -f "$1" run --build --rm e2e || rc=$? docker compose -p "$proj" -f compose.yml -f "$1" run --user "$(id -u):$(id -g)" --build --rm e2e || rc=$?
docker compose -p "$proj" -f compose.yml -f "$1" down -v >/dev/null 2>&1 || true docker compose -p "$proj" -f compose.yml -f "$1" down -v >/dev/null 2>&1 || true
[ "$rc" -eq 0 ] || { echo "E2E suite $1 failed (exit $rc)"; exit "$rc"; } [ "$rc" -eq 0 ] || { echo "E2E suite $1 failed (exit $rc)"; exit "$rc"; }
} }
@@ -82,7 +111,7 @@ e2e e2e-tests/compose.full.yml # full browser flow: login (password + SSO),
step "E2E: e2e-tests/compose.devstack.yml (dev-stack login: localhost works + 127.0.0.1 canonicalised)" step "E2E: e2e-tests/compose.devstack.yml (dev-stack login: localhost works + 127.0.0.1 canonicalised)"
devstack_files=(-f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml) devstack_files=(-f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml)
rc=0 rc=0
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" run --build --rm e2e || rc=$? docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" run --user "$(id -u):$(id -g)" --build --rm e2e || rc=$?
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" down -v >/dev/null 2>&1 || true docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" down -v >/dev/null 2>&1 || true
[ "$rc" -eq 0 ] || { echo "E2E suite e2e-tests/compose.devstack.yml failed (exit $rc)"; exit "$rc"; } [ "$rc" -eq 0 ] || { echo "E2E suite e2e-tests/compose.devstack.yml failed (exit $rc)"; exit "$rc"; }
+24 -2
View File
@@ -1,5 +1,9 @@
# Development overrides, merged automatically by `docker compose up`. # Development overrides, merged automatically by `docker compose up`.
# Mounts the source for live editing and restarts on change via `node --watch`. # Mounts the source for live editing and restarts on change via `node --watch`.
# web connects with it and bootstrap provisions against it, so the two must agree — one home.
x-plugin-db-url: &plugin-db-url postgres://postgres:5432
services: services:
web: web:
command: node --watch src/server.ts command: node --watch src/server.ts
@@ -13,15 +17,33 @@ services:
CACHE_TEMPLATES: "false" CACHE_TEMPLATES: "false"
LOG_FORMAT: "text" # human-readable logs in dev (base sets json for prod log pipelines) LOG_FORMAT: "text" # human-readable logs in dev (base sets json for prod log pipelines)
LOG_LEVEL: "debug" # verbose by default while developing (base defaults to info) LOG_LEVEL: "debug" # verbose by default while developing (base defaults to info)
# Point plugin storage at the bundled Postgres, so a dropped-in plugin declaring `storage`
# works with no further config; the secret falls back to the dev throwaway (config.ts).
PLUGIN_DB_URL: *plugin-db-url
REQUIRE_SECURE_SECRETS: "false" REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent
SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/ SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/
volumes: volumes:
- .:/app - .:/app
- /app/node_modules
# Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise): # Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise):
# - ./config:/app/config:ro # your config/menu.ts — see examples/config/menu.ts for a template # - ./config:/app/config:ro # your config/menu.ts — see examples/config/menu.ts for a template
# Mirror web's source mount so bootstrap discovers the same plugins *and* runs the same code. Only
# dev needs saying: the base file gives both services the image's baked copy, and it is the
# `.:/app` above — dev-only — that makes web diverge onto the host tree. Without the mirror,
# bootstrap silently runs whatever `src/` was baked at image-build time, so an edit to
# bootstrap.ts appears to do nothing until someone remembers `--build`.
# It belongs here and not in the base file, where it would desynchronise prod and collide with the
# e2e stacks, which bind individual plugins *inside* /app/plugins.
bootstrap:
# Provisions the plugin databases web connects to above, as the dev superuser.
environment:
PLUGIN_DB_ADMIN_URL: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/ory
PLUGIN_DB_URL: *plugin-db-url
REQUIRE_SECURE_SECRETS: "false" # dev derives from the throwaway, as web does
volumes:
- .:/app
# Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so # Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so
# the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this # the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service — # backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
@@ -36,7 +58,7 @@ services:
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025). # Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env. # kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
mailpit: mailpit:
image: axllent/mailpit:v1.30.6 image: axllent/mailpit:v1.31.0
ports: ports:
- "8025:8025" - "8025:8025"
restart: unless-stopped restart: unless-stopped
+23 -5
View File
@@ -17,10 +17,16 @@ services:
CACHE_TEMPLATES: "true" CACHE_TEMPLATES: "true"
CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret} CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}
LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector
# Per-plugin Postgres storage. Explicit toggle: unset ⇒ off, and a plugin declaring `storage`
# refuses to boot rather than run without its data. The URL carries no credentials — each
# plugin's own password is derived from the secret (README → Plugin storage).
PLUGIN_DB_SECRET: ${PLUGIN_DB_SECRET:-}
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-}
REQUIRE_SECURE_SECRETS: "true" REQUIRE_SECURE_SECRETS: "true"
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
# Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/ # Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/
# consent handler) + the one-shot bootstrap (admin + JWKS seed). # consent handler) + the one-shot bootstrap (admin + JWKS seed). Postgres too: a plugin that
# declares `storage` opens its connection in onBoot, before the server listens.
depends_on: depends_on:
bootstrap: bootstrap:
condition: service_completed_successfully condition: service_completed_successfully
@@ -30,17 +36,20 @@ services:
condition: service_healthy condition: service_healthy
hydra: hydra:
condition: service_healthy condition: service_healthy
postgres:
condition: service_healthy
# verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL). # verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
# Read-only — bootstrap is the only writer. # Read-only — bootstrap is the only writer.
volumes: volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
restart: unless-stopped restart: unless-stopped
# Ory's storage only (Kratos/Keto/Hydra) — the web app never connects here. # The stack's storage: one database per Ory service (init/init.sql), plus one per plugin that
# init/init.sql creates one database per service. Dev defaults below; supply # declares `storage` — bootstrap creates those at boot, since only it holds superuser credentials.
# A plugin connects as its own role from inside web. Dev defaults below; supply
# POSTGRES_USER/PASSWORD via env in production. # POSTGRES_USER/PASSWORD via env in production.
postgres: postgres:
image: postgres:18.4-alpine3.23 image: postgres:18.6-alpine3.23
environment: environment:
POSTGRES_USER: ${POSTGRES_USER:-ory} POSTGRES_USER: ${POSTGRES_USER:-ory}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ory} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ory}
@@ -127,16 +136,25 @@ services:
condition: service_healthy condition: service_healthy
keto: keto:
condition: service_healthy condition: service_healthy
postgres:
condition: service_healthy
environment: environment:
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
# Base permissions for the demo admin; bootstrap also grants every discovered plugin's declared # Base permissions for the demo admin; bootstrap also grants every discovered plugin's declared
# permission names (so the reference plugin — and any drop-in — works out of the box). # permission names (so the reference plugin — and any drop-in — works out of the box).
ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-admin} ADMIN_PERMISSIONS: ${ADMIN_PERMISSIONS:-}
APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
KETO_WRITE_URL: http://keto:4467 KETO_WRITE_URL: http://keto:4467
KRATOS_ADMIN_URL: http://kratos:4434 KRATOS_ADMIN_URL: http://kratos:4434
# The superuser DSN that creates each plugin's database and role lives ONLY here — never in
# web, so plugin code cannot read it out of its own environment. Unset ⇒ a plugin declaring
# `storage` fails the seed loudly. The secret must match web's; both derive the same passwords.
PLUGIN_DB_ADMIN_URL: ${PLUGIN_DB_ADMIN_URL:-}
PLUGIN_DB_SECRET: ${PLUGIN_DB_SECRET:-}
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-} # only to refuse a mismatch: what bootstrap creates, web connects to
REQUIRE_SECURE_SECRETS: "true" # refuse the throwaway secret here too, before any role is created
volumes: volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
command: node src/auth/bootstrap.ts command: node src/auth/bootstrap.ts
+4
View File
@@ -9,4 +9,8 @@ RUN npm ci
COPY e2e-tests/ ./ COPY e2e-tests/ ./
# Runs as the invoking `--user` so artifacts land owned by them, not root — and an arbitrary uid has
# no passwd entry here, so its home would be the unwritable `/`. npm's cache follows HOME.
ENV HOME=/tmp
CMD ["npx", "playwright", "test"] CMD ["npx", "playwright", "test"]
View File
+2 -2
View File
@@ -83,7 +83,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
const claims1 = jwtClaims(jwt1); const claims1 = jwtClaims(jwt1);
expect(claims1.email).toBe(ADMIN_EMAIL); expect(claims1.email).toBe(ADMIN_EMAIL);
expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy(); expect(claims1.sub, "sub is the Kratos identity id").toBeTruthy();
expect(claims1.permissions, "permissions are projected from Keto").toContain("admin"); expect(claims1.permissions, "permissions are projected from Keto").toContain("users:read");
// 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT. // 2. Token timeout → refresh: once the 8s TTL lapses, the next request re-mints a fresh JWT.
const jwt2Line = await awaitJwtSetCookie(session, jwt1); const jwt2Line = await awaitJwtSetCookie(session, jwt1);
@@ -91,7 +91,7 @@ test("an expired session JWT is silently re-minted while Kratos lives, then clea
expect(jwt2, "a different token was minted").not.toBe(jwt1); expect(jwt2, "a different token was minted").not.toBe(jwt1);
const claims2 = jwtClaims(jwt2); const claims2 = jwtClaims(jwt2);
expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp); expect(claims2.exp, "the new token expires later").toBeGreaterThan(claims1.exp);
expect(claims2.permissions, "re-mint re-reads permissions from Keto").toContain("admin"); expect(claims2.permissions, "re-mint re-reads permissions from Keto").toContain("users:read");
// 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared. // 3. Kill the Kratos session: now the lapsed token cannot refresh — the cookie is cleared.
const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" }); const revoke = await fetch(`${KRATOS_ADMIN}/admin/identities/${claims1.sub}/sessions`, { method: "DELETE" });
+13 -1
View File
@@ -2,7 +2,7 @@
# visual suite (e2e-tests/compose.visual.yml) covers the design system; this is its full-stack counterpart: # visual suite (e2e-tests/compose.visual.yml) covers the design system; this is its full-stack counterpart:
# real Postgres + Kratos + Keto + bootstrap + web, with a SHORT tokenizer TTL (ory/kratos/e2e.yml) # real Postgres + Kratos + Keto + bootstrap + web, with a SHORT tokenizer TTL (ory/kratos/e2e.yml)
# and zero clock skew, so the JWT lapses and re-mints within seconds instead of ~10m. # and zero clock skew, so the JWT lapses and re-mints within seconds instead of ~10m.
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml run --build --rm e2e # docker compose -f compose.yml -f e2e-tests/compose.auth.yml run --user "$(id -u):$(id -g)" --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml down -v # tear down after # docker compose -f compose.yml -f e2e-tests/compose.auth.yml down -v # tear down after
services: services:
web: web:
@@ -30,6 +30,18 @@ services:
timeout: 4s timeout: 4s
retries: 30 retries: 30
# This stack mounts no plugins, so nothing declares a permission for the bootstrap to seed — and
# the suite asserts that Keto's grants reach the JWT claim. Name one explicitly so there is
# something to project.
#
# `admin` rides along on purpose: it was this setting's default until 2026-08-05 and is not a legal
# `<resource>:<action>` name, so it is exactly the leftover an upgrading deployment carries. An
# earlier revision made that fatal, and bootstrap gates `web` — so if the boot ever refuses operator
# env again, `web` never turns healthy and this suite fails instead of CI going green over it.
bootstrap:
environment:
ADMIN_PERMISSIONS: admin,users:read
# Shorten the session→JWT TTL and expose a network-resolvable base_url (ory/kratos/e2e.yml), # Shorten the session→JWT TTL and expose a network-resolvable base_url (ory/kratos/e2e.yml),
# merged after the base config. # merged after the base config.
kratos: kratos:
+1 -1
View File
@@ -4,7 +4,7 @@
# runs against the *plain* `docker compose up` topology and drives the browser on the HOST network, so # runs against the *plain* `docker compose up` topology and drives the browser on the HOST network, so
# it sees http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host # it sees http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host
# browser does. Merge the dev override so the live stack is byte-for-byte `docker compose up`: # browser does. Merge the dev override so the live stack is byte-for-byte `docker compose up`:
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml run --build --rm e2e # docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml run --user "$(id -u):$(id -g)" --build --rm e2e
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml down -v # tear down # docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml down -v # tear down
services: services:
web: web:
+2 -2
View File
@@ -1,8 +1,8 @@
# Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO # Full browser E2E — the real Playwright UI flow against the live stack: password + mocked-SSO
# login, menu filtering by permission, users/groups/permissions/OAuth2-clients CRUD, a plugin page, logout. A # login, menu filtering by permission, users/groups/OAuth2-clients CRUD + permission granting, a plugin page, logout. A
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies # tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test. # round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e # docker compose -f compose.yml -f e2e-tests/compose.full.yml run --user "$(id -u):$(id -g)" --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after # docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
services: services:
web: web:
+1 -1
View File
@@ -3,7 +3,7 @@
# it via the Kratos session and accepts. Runs against the real stack (Postgres + Kratos + Keto + # it via the Kratos session and accepts. Runs against the real stack (Postgres + Kratos + Keto +
# Hydra + bootstrap + web). The runner drives the flow over HTTP (fetch, manual cookies), so it # Hydra + bootstrap + web). The runner drives the flow over HTTP (fetch, manual cookies), so it
# reaches the Ory services by their compose-network names. # reaches the Ory services by their compose-network names.
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml run --build --rm e2e # docker compose -f compose.yml -f e2e-tests/compose.oauth.yml run --user "$(id -u):$(id -g)" --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v # tear down after # docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v # tear down after
services: services:
web: web:
+1 -1
View File
@@ -1,7 +1,7 @@
# Playwright E2E. Brings up the app + a Playwright runner and exercises the live pages (design # Playwright E2E. Brings up the app + a Playwright runner and exercises the live pages (design
# system, theme switch, mobile layout, CSRF, landing, 404, plugin gating, language switching) — # system, theme switch, mobile layout, CSRF, landing, 404, plugin gating, language switching) —
# Ory-free, so it's fast. # Ory-free, so it's fast.
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --build --rm e2e # docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --build --rm e2e
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v # tear down after # docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v # tear down after
# --build rebuilds the runner (the image bakes in e2e-tests/) so spec edits are picked up. # --build rebuilds the runner (the image bakes in e2e-tests/) so spec edits are picked up.
# Screenshots + HTML report land in ./e2e-tests/artifacts/ (git-ignored). # Screenshots + HTML report land in ./e2e-tests/artifacts/ (git-ignored).
+7 -14
View File
@@ -1,20 +1,13 @@
import { expect, test } from "./console-guard.ts"; import { expect, test } from "./console-guard.ts";
// Regression: the from-scratch dev experience the README/banner advertises must work. `docker compose // The from-scratch dev experience the banner advertises: `docker compose up`, open the printed
// up`, open the printed login URL (http://localhost:3000), sign in as the seeded admin → you land on // login URL, sign in as the seeded admin, land on the dashboard. A host-scoped Kratos CSRF cookie
// the dashboard, signed in. Originally this dumped the user on http://127.0.0.1:3000/error?id=… // cannot cross `localhost`↔`127.0.0.1`, so a cross-host login POST loses it and Kratos redirects to
// ("Page not found"): the banner printed `localhost` but kratos.yml hard-coded `127.0.0.1`, and a // its error sink; APP_URL canonicalises every off-host visitor onto one cookie host instead.
// host-scoped Kratos CSRF cookie can't cross `localhost`↔`127.0.0.1`, so the cross-host login POST
// lost it and Kratos redirected to its error sink.
// //
// The fix makes APP_URL the single source for the public host: the web app canonicalises every // The runner is on the host network against the plain `docker compose up` topology, so it sees
// off-host visitor onto it (so localhost / 127.0.0.1 / any alias funnel to one cookie host), Kratos' // http://localhost:3000 and http://127.0.0.1:4433 exactly as a host browser does. The proxied
// browser URLs derive from it, and a real /error page replaces the 404. // full-flow suite cannot catch this — it fronts web + Kratos on one origin.
//
// This is faithful to the user's environment: the runner uses the host network
// (e2e-tests/compose.devstack.yml) against the plain `docker compose up` topology, so it sees
// http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host browser
// does. The proxied full-flow suite can't catch this regression — it fronts web + Kratos on one origin.
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
const ADMIN_PASSWORD = "admin"; const ADMIN_PASSWORD = "admin";
+17 -12
View File
@@ -50,8 +50,8 @@ test.describe.serial("authenticated admin journey", () => {
}); });
test.afterAll(async () => { await page.context().close(); }); test.afterAll(async () => { await page.context().close(); });
// The list screens rebuild their query from the list state (sort/page/filter), so they are where // The list screens rebuild their query from the list state (sort/page/filter), so they are where a
// a chosen language used to get dropped the core building blocks carry it now. // chosen language is most easily dropped; the core building blocks carry it through.
test("a sorted, paged admin list keeps the visitor's language", async () => { test("a sorted, paged admin list keeps the visitor's language", async () => {
await page.goto("/admin/users?locale=sv-SE"); await page.goto("/admin/users?locale=sv-SE");
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE"); await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
@@ -102,8 +102,9 @@ test.describe.serial("authenticated admin journey", () => {
}); });
test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => { test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present // The signed-in admin holds every permission the two mounted plugins declare (the bootstrap
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible). // seeds exactly those), so both gated sections are present in the menu (collapsed by default →
// assert they're in the DOM, not necessarily visible).
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1); await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1);
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1); await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
@@ -135,7 +136,7 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("tr", { hasText: email })).toHaveCount(0); await expect(page.locator("tr", { hasText: email })).toHaveCount(0);
}); });
test("groups + permissions CRUD: create one of each (writes go to Keto) and see them listed", async () => { test("groups CRUD: create a group (writes go to Keto), see it listed, then grant it a permission", async () => {
// A Keto set exists only while it has ≥1 member, so create needs a first member (the form // A Keto set exists only while it has ≥1 member, so create needs a first member (the form
// enforces it); pick the first option (a user) from the required picker. // enforces it); pick the first option (a user) from the required picker.
const group = `e2e-grp-${suffix}`; const group = `e2e-grp-${suffix}`;
@@ -146,13 +147,17 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/); await expect(page).toHaveURL(/\/admin\/groups(\?|\/|$)/);
await expect(page.locator("main")).toContainText(group); await expect(page.locator("main")).toContainText(group);
const permission = `e2e-permission-${suffix}`; // Permissions are declared in plugin code, so the group's detail page offers them as a fixed
await page.goto("/admin/permissions/new"); // checkbox list rather than a create form — there is no Permissions screen to visit.
await page.fill('input[name="name"]', permission); await page.goto(`/admin/groups/${group}`);
await page.locator('select[name="member"]').selectOption({ index: 1 }); const scheduling = page.locator('input[name="permission"][value="scheduling:read"]');
await page.locator('.form-card button[type="submit"]').click(); await expect(scheduling).toHaveCount(1); // declared by the reference plugin, so it's on offer
await expect(page).toHaveURL(/\/admin\/permissions(\?|\/|$)/); await expect(scheduling).not.toBeChecked();
await expect(page.locator("main")).toContainText(permission); await scheduling.check();
await page.locator('form:has(input[name="permission"]) button[type="submit"]').click();
await expect(page).toHaveURL(new RegExp(`/admin/groups/${group}`));
await expect(page.locator('input[name="permission"][value="scheduling:read"]')).toBeChecked();
}); });
test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => { test("OAuth2 clients CRUD: register a client (writes go to Hydra), see the one-time secret once, then delete it via the confirm step", async () => {
-2
View File
@@ -1,12 +1,10 @@
{ {
"name": "plainpages-e2e", "name": "plainpages-e2e",
"version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "plainpages-e2e", "name": "plainpages-e2e",
"version": "0.1.0",
"devDependencies": { "devDependencies": {
"@playwright/test": "1.62.1" "@playwright/test": "1.62.1"
} }
-1
View File
@@ -1,6 +1,5 @@
{ {
"name": "plainpages-e2e", "name": "plainpages-e2e",
"version": "0.1.0",
"private": true, "private": true,
"description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).", "description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
"type": "module", "type": "module",
+2 -3
View File
@@ -56,9 +56,8 @@ test("every icon <use> resolves to a defined <symbol> (no broken graphics)", asy
expect(missing).toEqual([]); expect(missing).toEqual([]);
}); });
// (The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component // The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component and
// (list-query/data-table/filter-bar) and exercised live with real data by the full-flow E2E's admin // exercised live by the full-flow E2E's admin Users list, so it has no Ory-free counterpart here.
// Users list. The mock-data dashboard that used to host it in this Ory-free suite is gone.)
test("theme switch flips the palette with no JavaScript", async ({ page }) => { test("theme switch flips the palette with no JavaScript", async ({ page }) => {
await page.goto("/dashboard"); await page.goto("/dashboard");
+1 -1
View File
@@ -5,7 +5,7 @@ across (or bind-mount your own) and restart.
| Path | Copy into | Example of | | Path | Copy into | Example of |
| --- | --- | --- | | --- | --- | --- |
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). | | [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). | | [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). | | [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. | | [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
+6 -7
View File
@@ -1,11 +1,10 @@
// Reference config/menu.ts — copy into the (empty) config/ mount at the repo root: // Reference config/menu.ts — copy into the empty config/ mount at the repo root:
// cp examples/config/menu.ts config/menu.ts // cp examples/config/menu.ts config/menu.ts
// config/ ships empty; mount your own or copy this in. Absent config = built-in defaults. // Absent config = built-in defaults.
// //
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — // Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — the
// the override always wins, applied before the per-user permission filter. Every field is // override always wins, applied before the per-user permission filter. Every field is optional.
// optional; delete one to fall back to the default. // See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system.
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README.md (The menu system).
import { defineMenu } from "#menu-config"; import { defineMenu } from "#menu-config";
@@ -20,7 +19,7 @@ export default defineMenu({
// Operator override (rename → group → order → hide), keyed by node id. // Operator override (rename → group → order → hide), keyed by node id.
override: { override: {
// rename: { people: "Staff" }, // node id → new label (or a catalog key) // rename: { people: "Staff" }, // node id → new label (or a catalog key)
// groups: [{ id: "admin", label: "Admin", children: ["users", "permissions"] }], // groups: [{ id: "admin", label: "Admin", children: ["users", "groups"] }],
// order: ["people", "reports"], // top-level order by id // order: ["people", "reports"], // top-level order by id
// hide: ["teams"], // remove nodes (any depth) // hide: ["teams"], // remove nodes (any depth)
}, },
+34 -22
View File
@@ -1,22 +1,22 @@
# Admin — the system-administration plugin # Admin — the system-administration plugin
The Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. These used to be The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped as a **drop-in
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/`
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the (it keeps the id and mount path `admin`, so the screens live at `/admin/*`) and restart:
screens live at `/admin/*`) and restart:
```bash ```bash
cp -r examples/plugins/admin plugins/admin cp -r examples/plugins/admin plugins/admin
docker compose restart web docker compose up -d
``` ```
The seeded `admin@plainpages.local` already holds the `admin` permission, so the section appears in the The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so
menu and the screens work immediately. the section appears in the menu and the screens work immediately. An older copy already in
`plugins/` is yours — the host never updates it — so re-copy after a pull; a stale one stops the boot
with a message naming it ([README → Upgrading](../../../README.md#upgrading)).
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`) the nav Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`), the nav
labels included, which are catalog keys in `admin-shared.ts`. Each pure view-model builder takes an labels included. Each pure view-model builder takes an optional `t` defaulting to the plugin's own
optional `t`; the handlers pass `ctx.t`, and the default is the plugin's own English so a unit test English, so a unit test reads in words rather than keys.
reads in words rather than keys. (README → [Languages](../../../README.md#languages-i18n).)
## What it demonstrates — a *system* plugin ## What it demonstrates — a *system* plugin
@@ -25,29 +25,41 @@ reference](../scheduling/README.md)). The admin screens instead administer **Pla
stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin: stack**, so they use the privileged **`ctx.system`** surface the host exposes to a system plugin:
- **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users). - **`ctx.system.kratosAdmin`** — create/edit/deactivate/delete Kratos identities (Users).
- **`ctx.system.keto`** — read/write the Keto relationship graph (Groups, Permissions). - **`ctx.system.keto`** — read/write the Keto relationship graph (group membership, permission grants).
- **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients. - **`ctx.system.hydra`** — register/list/delete Ory Hydra OAuth2 clients.
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a - **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL. user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto, `ctx.system` is populated only when the host wired those services. Where a capability is absent the
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather screen degrades to a themed 503 rather than crashing. Everything else is an ordinary plugin:
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered, folder-discovered, gated per route by its screen's `<resource>:<action>` permission, rendering the
gated per route by `permission: "admin"`, rendering the core building blocks in `views/`. 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.
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a
GUI, so the host's catalog (`ctx.declaredPermissions`) is the fixed list — and holding one is a
property of a user or a group, edited as a checkbox list on those two screens (`admin-grants.ts`).
## Layout ## Layout
- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission, and the - `plugin.ts` — the manifest: the Admin nav fragment, the six permissions the plugin declares, and
route table — one thin handler per method+path, all gated by `permission: "admin"`. the route table — one thin handler per method+path, gated via `permissionName(resource, actionForMethod(method))`
- `admin-users.ts` · `admin-groups.ts` · `admin-permissions.ts` · `admin-clients.ts` — each a set of pure 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
screens: what a submitted checkbox set grants and revokes, against the host's declared catalog.
- `admin-users.ts` · `admin-groups.ts` · `admin-clients.ts` — each a set of pure
view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on
`ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the `ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the
admin gate + the needed `ctx.system` clients once. screen's permission gate + the needed `ctx.system` clients once.
- `admin-shared.ts` — the shared gate (`requireAdmin`), CSRF form reader (`guardedForm`), confirm - `admin-shared.ts` — the permission naming (`permissionName` / `actionForMethod`), the shared gate
(`requirePermission`), CSRF form reader (`guardedForm`), confirm
model, nav fragment, and the not-found / unavailable helpers. model, nav fragment, and the not-found / unavailable helpers.
- `views/` — the screens' EJS, plus the admin-specific body partials under `views/partials/`. They - `views/` — the screens' EJS, plus the admin-specific body partials under `views/partials/`. They
`include()` the core building-block partials (shell, data-table, filter-bar, field, …). `include()` the core building-block partials (shell, data-table, filter-bar, field, …).
The four screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders The three screens hold **no state** — everything lives in Ory. Handlers are thin, so their builders
unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in unit-test as pure functions with no host; the HTTP routing/gate/CSRF is covered in
`src/http/app.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`. `src/http/app.test.ts` (which mounts this plugin) and end-to-end in `e2e-tests/full-flow.spec.ts`.
+16 -10
View File
@@ -5,8 +5,8 @@
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin // PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded. // per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
import { type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api"; import { 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, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
@@ -100,6 +100,7 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
} }
export function buildClientsListModel(opts: { export function buildClientsListModel(opts: {
canWrite?: boolean;
clients: OAuth2Client[]; clients: OAuth2Client[];
csrfToken?: string; csrfToken?: string;
t?: Translate; t?: Translate;
@@ -119,6 +120,7 @@ export function buildClientsListModel(opts: {
return { return {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }], breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.nav.section") }, { label: t("admin.clients.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, t), filterBar: listFilterBar(state, t),
pagination: listPagination(state, page, t), pagination: listPagination(state, page, t),
table: listTable(rows, t), table: listTable(rows, t),
@@ -208,6 +210,7 @@ export function buildClientFormModel(opts: {
} }
export function buildClientDetailModel(opts: { export function buildClientDetailModel(opts: {
canWrite?: boolean;
client: ClientView; client: ClientView;
created?: boolean; // just registered → success banner + the one-time secret (if any) created?: boolean; // just registered → success banner + the one-time secret (if any)
csrfToken?: string; csrfToken?: string;
@@ -218,6 +221,7 @@ export function buildClientDetailModel(opts: {
const base = detailHref(opts.client.id); const base = detailHref(opts.client.id);
return { return {
breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: opts.client.name }], breadcrumbs: [{ href: ADMIN_CLIENTS_BASE, label: t("admin.clients.title") }, { label: opts.client.name }],
canWrite: opts.canWrite !== false,
client: opts.client, client: opts.client,
created: opts.created ?? false, created: opts.created ?? false,
csrfToken: opts.csrfToken ?? "", csrfToken: opts.csrfToken ?? "",
@@ -243,9 +247,9 @@ function readClientInput(form: URLSearchParams): ClientInput {
// Hydra capability (else a themed 503). Each route below is a thin handler over these. // Hydra capability (else a themed 503). Each route below is a thin handler over these.
interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; } interface ClientsDeps { ctx: RequestContext; hydra: HydraAdmin; user: User; }
function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteHandler { function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return async (ctx) => { return async (ctx) => {
const user = requireAdmin(ctx); const user = requirePermission(ctx, "oauth2-clients", action);
const hydra = ctx.system?.hydra; const hydra = ctx.system?.hydra;
if (!hydra) return unavailable(ctx, ctx.t("admin.capability.hydra")); if (!hydra) return unavailable(ctx, ctx.t("admin.capability.hydra"));
return inner({ ctx, hydra, user }); return inner({ ctx, hydra, user });
@@ -253,24 +257,26 @@ function withClients(inner: (deps: ClientsDeps) => Promise<RouteResult>): RouteH
} }
// Same, plus the target client from ctx.params.id (unknown → themed 404). // Same, plus the target client from ctx.params.id (unknown → themed 404).
function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) => Promise<RouteResult>): RouteHandler { function withClient(inner: (deps: ClientsDeps, client: OAuth2Client, id: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return withClients(async (deps) => { return withClients(async (deps) => {
const id = deps.ctx.params["id"] ?? ""; const id = deps.ctx.params["id"] ?? "";
const client = await deps.hydra.getClient(id); const client = await deps.hydra.getClient(id);
if (!client) return notFound(deps.ctx); if (!client) return notFound(deps.ctx);
return inner(deps, client, id); return inner(deps, client, id);
}); }, action);
} }
const clientFormResult = (ctx: RequestContext, extra: { error?: string; values?: Partial<ClientInput> }): RouteResult => const clientFormResult = (ctx: RequestContext, extra: { error?: string; values?: Partial<ClientInput> }): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildClientFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-form" }); ({ data: { chrome: ctx.chrome, model: buildClientFormModel({ csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-form" });
const canWriteClients = (ctx: RequestContext): boolean => can(ctx, permissionName("oauth2-clients", "write"));
const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult => const clientDetailResult = (ctx: RequestContext, client: OAuth2Client, extra: { created?: boolean; secret?: string } = {}): RouteResult =>
({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ client: toClientView(client), csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-detail" }); ({ data: { chrome: ctx.chrome, model: buildClientDetailModel({ canWrite: canWriteClients(ctx), client: toClientView(client), csrfToken: ctx.chrome.csrfToken, t: ctx.t, ...extra }) }, view: "client-detail" });
// GET /admin/clients — the list. // GET /admin/clients — the list.
export const clientsList = withClients(async ({ ctx, hydra }) => { export const clientsList = withClients(async ({ ctx, hydra }) => {
const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE }); const { clients } = await hydra.listClients({ pageSize: LIST_FETCH_SIZE });
return { data: { chrome: ctx.chrome, model: buildClientsListModel({ clients, csrfToken: ctx.chrome.csrfToken, t: ctx.t, url: ctx.url }) }, view: "clients" }; return { data: { chrome: ctx.chrome, model: buildClientsListModel({ canWrite: canWriteClients(ctx), clients, csrfToken: ctx.chrome.csrfToken, t: ctx.t, url: ctx.url }) }, view: "clients" };
}); });
// POST /admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never // POST /admin/clients — register; on success show the one-time secret directly (no PRG, Hydra never
@@ -291,7 +297,7 @@ export const clientsCreate = withClients(async ({ ctx, hydra, user }) => {
}); });
// GET /admin/clients/new — the register form. // GET /admin/clients/new — the register form.
export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {}))); export const clientsNewForm = withClients(({ ctx }) => Promise.resolve(clientFormResult(ctx, {})), "write");
// GET /admin/clients/:id — the detail (read-only; the secret is shown only once, at creation). // GET /admin/clients/:id — the detail (read-only; the secret is shown only once, at creation).
export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client))); export const clientsDetail = withClient((deps, client) => Promise.resolve(clientDetailResult(deps.ctx, client)));
@@ -306,7 +312,7 @@ export const clientsDeleteConfirm = withClient((deps, client, id) => {
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.clients.delete"), cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.clients.delete"),
message: tt("admin.clients.deleteMessage", { name }), title: tt("admin.clients.delete"), message: tt("admin.clients.deleteMessage", { name }), title: tt("admin.clients.delete"),
}) }, view: "confirm" }); }) }, view: "confirm" });
}); }, "write");
// POST /admin/clients/:id/delete — perform it. // POST /admin/clients/:id/delete — perform it.
export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => { export const clientsDelete = withClient(async ({ ctx, hydra, user }, _client, id) => {
@@ -0,0 +1,83 @@
// The pure half of permission granting: what a submitted checkbox set changes, and the picker the
// two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import type { PermissionDecl } from "@plainpages/plugin-api";
import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts";
const declared: PermissionDecl[] = [
{ description: "View users", name: "users:read" },
{ description: "Edit users", name: "users:write" },
{ name: "groups:read" },
];
test("grantTuple targets a user by subject_id and a group by subject_set", () => {
assert.deepEqual(grantTuple("users:read", userSubject("u1")), { namespace: "Permission", object: "users:read", relation: "granted", subject_id: "user:u1" });
assert.deepEqual(grantTuple("users:read", groupSubject("eng")), {
namespace: "Permission", object: "users:read", relation: "granted",
subject_set: { namespace: "Group", object: "eng", relation: "members" },
});
});
test("grantDiff: the submitted set is the desired state — tick grants, untick revokes, unchanged is a no-op", () => {
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read", "users:write"]), { grant: ["users:write"], revoke: [] });
assert.deepEqual(grantDiff(declared, ["users:read", "users:write"], ["users:read"]), { grant: [], revoke: ["users:write"] });
assert.deepEqual(grantDiff(declared, ["users:read"], ["users:read"]), { grant: [], revoke: [] });
assert.deepEqual(grantDiff(declared, ["users:read"], []), { grant: [], revoke: ["users:read"] }); // every box cleared
});
test("grantDiff ignores anything the plugins don't declare, in both directions", () => {
// A crafted POST can't grant a name no plugin gates on…
assert.deepEqual(grantDiff(declared, [], ["superuser:all"]), { grant: [], revoke: [] });
// …and a held name that is no longer declared (its plugin was uninstalled) is left alone rather
// than silently revoked by an unrelated save — this screen only speaks for what it offered.
assert.deepEqual(grantDiff(declared, ["legacy:thing"], ["users:read"]), { grant: ["users:read"], revoke: [] });
});
test("buildPermissionPicker ticks what is held and carries each declaration's description", () => {
const picker = buildPermissionPicker({ action: "/admin/users/u1/permissions", declared, direct: ["users:write"] });
assert.equal(picker.action, "/admin/users/u1/permissions");
assert.deepEqual(picker.choices.map((c) => c.name), ["users:read", "users:write", "groups:read"]);
assert.deepEqual(picker.choices.map((c) => c.checked), [false, true, false]);
assert.equal(picker.choices[0]?.description, "View users");
assert.equal(picker.choices[2]?.description, ""); // a declaration may omit one
assert.equal(picker.empty, undefined);
assert.equal(picker.readOnly, false);
assert.equal(picker.inheritedNote, undefined); // nothing is group-held here
});
// An inherited permission rendered unticked would say "not held" about a grant that reaches the JWT,
// and unticking it writes nothing, reading as a successful revoke. So inherited rows are ticked,
// disabled, and never posted.
test("buildPermissionPicker distinguishes a direct grant from one inherited through a group", () => {
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:write"], effective: ["users:read", "users:write"] });
assert.deepEqual(picker.choices.map((c) => [c.name, c.checked, c.inherited]), [
["users:read", true, true], // effective but not direct → shown as held, not editable here
["users:write", true, false], // direct → editable
["groups:read", false, false],
]);
assert.ok(picker.inheritedNote, "the disabled row needs an explanation");
});
test("buildPermissionPicker in read-only mode still shows the state, and marks itself unwritable", () => {
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:read"], effective: ["users:read", "groups:read"], readOnly: true });
assert.equal(picker.readOnly, true);
assert.deepEqual(picker.choices.map((c) => c.checked), [true, false, true]); // a reader still sees who holds what
// Every row renders disabled for a reader, so the writable copy would be wrong twice over: "tick to
// grant" is false, and "greyed-out means group-held" would misattribute the direct grant.
assert.equal(picker.inheritedNote, undefined);
assert.notEqual(picker.hint, buildPermissionPicker({ action: "/x", declared, direct: [] }).hint);
});
test("buildPermissionPicker notes the transitive lag for a group, and stays quiet for a user", () => {
// A group's members inherit, so the change reaches them at their next re-mint; a user's own grant
// change revokes their live tokens, so there is nothing to warn about.
assert.ok(buildPermissionPicker({ action: "/x", declared, direct: [], transitive: true }).pending);
assert.equal(buildPermissionPicker({ action: "/x", declared, direct: [] }).pending, undefined);
});
test("buildPermissionPicker says so when no plugin declares a permission, rather than rendering an empty box", () => {
const picker = buildPermissionPicker({ action: "/x", declared: [], direct: [] });
assert.deepEqual(picker.choices, []);
assert.ok(picker.empty);
});
+123
View File
@@ -0,0 +1,123 @@
// Permission grants, shared by the Users and Groups screens. A permission is held by a user
// (`Permission:<name>#granted@user:<id>`) or by a whole group (`…@Group:<name>#members`), and Keto
// resolves a group's grant transitively at login.
//
// The set of permissions that *exist* is `ctx.declaredPermissions` — the host's catalog, built from
// what the installed plugins declare in code. Nothing here invents a name, which is why the old
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
const PERMISSION_NS = "Permission";
const GRANTED = "granted";
export const PERMISSIONS_FIELD = "permission"; // the checkbox name the two forms post
export type GrantSubject = { subject_id: string } | { subject_set: SubjectSet };
export const userSubject = (id: string): GrantSubject => ({ subject_id: `user:${id}` });
export const groupSubject = (name: string): GrantSubject => ({ subject_set: { namespace: "Group", object: name, relation: "members" } });
export function grantTuple(permission: string, subject: GrantSubject): RelationTuple {
return { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject };
}
// 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();
}
// Every declared permission the subject effectively holds — direct grants *plus* anything reached
// through a group, which is what actually lands in their JWT. One Keto check per declared name;
// the catalog is small and this is an admin screen (login does the same walk).
export async function effectivePermissions(keto: KetoClient, subject: GrantSubject, declared: readonly PermissionDecl[]): Promise<string[]> {
const held = await Promise.all(declared.map((decl) => keto.check({ namespace: PERMISSION_NS, object: decl.name, relation: GRANTED, ...subject })));
return declared.filter((_, i) => held[i]).map((decl) => decl.name);
}
export interface PermissionChoice {
checked: boolean; // held directly — the only state this form can change
description: string;
// Effective through a group, not granted directly. Rendered ticked but disabled: the grant is real
// (it reaches the JWT), and it is removed by editing the group, not this subject.
inherited: boolean;
name: string;
}
export interface PermissionPicker {
action: string;
choices: PermissionChoice[];
empty: string | undefined; // set when no plugin declares a permission — the picker has nothing to offer
error?: string; // a rejected save (e.g. the self-revoke guard), rendered above the list
field: string;
hint: string;
inheritedNote: string | undefined; // set when at least one choice is group-held, to explain the disabled row
legend: string;
// Set for a group: its members hold these transitively, so a change reaches them at their next
// re-mint rather than at once. The user picker revokes live tokens, so it says nothing.
pending: string | undefined;
readOnly: boolean; // the viewer holds :read but not :write — show the state, offer no save
submit: string;
}
// The checkbox list: every declared permission, ticked where this subject holds it. A fixed list
// means the form is the whole truth — what it posts back *is* the desired set of *direct* grants
// (grantDiff). An inherited row is disabled, so it never posts and can never be diffed into a revoke.
export function buildPermissionPicker(opts: {
action: string;
declared: readonly PermissionDecl[];
direct: string[];
effective?: string[]; // omit when the caller can't resolve group-held grants; then only direct shows
readOnly?: boolean;
t?: Translate;
transitive?: boolean; // a group: its members inherit, so the change lands at their next re-mint
}): PermissionPicker {
const t = opts.t ?? ((k: string) => k);
const directSet = new Set(opts.direct);
const effectiveSet = new Set(opts.effective ?? opts.direct);
const choices = opts.declared.map((decl) => ({
checked: directSet.has(decl.name) || effectiveSet.has(decl.name),
description: decl.description ?? "",
inherited: !directSet.has(decl.name) && effectiveSet.has(decl.name),
name: decl.name,
}));
return {
action: opts.action,
choices,
empty: opts.declared.length === 0 ? t("admin.grants.none") : undefined,
field: PERMISSIONS_FIELD,
// A reader sees every row disabled, so "tick to grant" is false and "greyed-out means group-held"
// is worse than false — it would misattribute a *direct* grant to a group that doesn't hold it.
hint: t(opts.readOnly === true ? "admin.grants.hintReadOnly" : "admin.grants.hint"),
inheritedNote: opts.readOnly !== true && choices.some((c) => c.inherited) ? t("admin.grants.inherited") : undefined,
legend: t("admin.grants.legend"),
pending: opts.transitive === true ? t("admin.grants.pending") : undefined,
readOnly: opts.readOnly === true,
submit: t("admin.grants.save"),
};
}
// What a submitted set changes. Pure so the diff is testable without Keto: only declared names are
// considered, so a crafted POST cannot grant something no plugin gates on, and a held-but-undeclared
// name (left over from an uninstalled plugin) is never silently revoked by an unrelated save.
export function grantDiff(declared: readonly PermissionDecl[], held: string[], wanted: string[]): { grant: string[]; revoke: string[] } {
const offered = new Set(declared.map((d) => d.name));
const heldSet = new Set(held);
const wantedSet = new Set(wanted.filter((name) => offered.has(name)));
return {
grant: [...wantedSet].filter((name) => !heldSet.has(name)).sort(),
revoke: [...heldSet].filter((name) => offered.has(name) && !wantedSet.has(name)).sort(),
};
}
export async function applyGrants(keto: KetoClient, subject: GrantSubject, diff: { grant: string[]; revoke: string[] }): Promise<void> {
for (const name of diff.grant) await keto.writeTuple(grantTuple(name, subject));
for (const name of diff.revoke) await keto.deleteTuple(grantTuple(name, subject));
}
+1 -1
View File
@@ -14,7 +14,7 @@ import {
memberView, memberView,
parseSubject, parseSubject,
} from "./admin-groups.ts"; } from "./admin-groups.ts";
import type { RelationTuple } from "#plugin-api"; import type { RelationTuple } from "@plainpages/plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`; const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (group: string, n: number): RelationTuple => const userTuple = (group: string, n: number): RelationTuple =>
+54 -14
View File
@@ -6,8 +6,9 @@
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded, // per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
// each returning a RouteResult. // each returning a RouteResult.
import { type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "#plugin-api"; import { 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 { ADMIN_EN, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
const GROUP_NS = "Group"; const GROUP_NS = "Group";
@@ -110,6 +111,7 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
} }
export function buildGroupsListModel(opts: { export function buildGroupsListModel(opts: {
canWrite?: boolean;
csrfToken?: string; csrfToken?: string;
groups: GroupView[]; groups: GroupView[];
t?: Translate; t?: Translate;
@@ -139,6 +141,7 @@ export function buildGroupsListModel(opts: {
return { return {
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }], breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.nav.section") }, { label: t("admin.groups.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, t), filterBar: listFilterBar(state, t),
pagination: listPagination(state, page, t), pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t), table: listTable(rows, state, sort, t),
@@ -224,11 +227,13 @@ export function buildGroupFormModel(opts: {
} }
export function buildGroupDetailModel(opts: { export function buildGroupDetailModel(opts: {
canWrite?: boolean; // false ⇒ a `groups:read` holder: show the members, offer no edit
candidates: MemberOption[]; candidates: MemberOption[];
csrfToken?: string; csrfToken?: string;
error?: string; error?: string;
group: { name: string }; group: { name: string };
members: MemberView[]; members: MemberView[];
permissions?: PermissionPicker;
t?: Translate; t?: Translate;
}) { }) {
const t = opts.t ?? ADMIN_EN; const t = opts.t ?? ADMIN_EN;
@@ -237,21 +242,24 @@ export function buildGroupDetailModel(opts: {
const taken = new Set(opts.members.map((m) => m.subject)); const taken = new Set(opts.members.map((m) => m.subject));
const self = `group:${name}`; // a group can't be a member of itself const self = `group:${name}`; // a group can't be a member of itself
const options = opts.candidates.filter((c) => c.value !== self && !taken.has(c.value)); const options = opts.candidates.filter((c) => c.value !== self && !taken.has(c.value));
const canWrite = opts.canWrite !== false;
return { return {
add: { action: `${base}/members`, options }, add: { action: `${base}/members`, options },
breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: name }], breadcrumbs: [{ href: ADMIN_GROUPS_BASE, label: t("admin.groups.title") }, { label: name }],
canWrite, // the view drops add/remove/delete when false; the host already 403s those POSTs
csrfToken: opts.csrfToken ?? "", csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` }, delete: { action: `${base}/delete` },
error: opts.error, error: opts.error,
group: { name }, group: { name },
members: { action: `${base}/members/delete`, rows: opts.members }, members: { action: `${base}/members/delete`, rows: opts.members },
permissions: opts.permissions,
title: name, title: name,
}; };
} }
// ---- request handler (imperative shell) ---- // ---- request handler (imperative shell) ----
// Drain every page of a relation-tuple query. (Reused by the Roles screen — same membership model.) // Drain every page of a relation-tuple query.
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> { export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
const out: RelationTuple[] = []; const out: RelationTuple[] = [];
let pageToken: string | undefined; let pageToken: string | undefined;
@@ -285,13 +293,14 @@ async function groupExists(keto: KetoClient, name: string): Promise<boolean> {
return page.tuples.length > 0; return page.tuples.length > 0;
} }
// Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate + the Keto and // Shared per-request deps for the Groups screen, resolved by `withGroups`: the gate (`groups:read` on
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these. // a GET, `groups:write` on a POST) + the Keto and Kratos capabilities (else a themed 503). Each route
// below is a thin handler over these.
interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: User; } interface GroupsDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; user: User; }
function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHandler { function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return async (ctx) => { return async (ctx) => {
const user = requireAdmin(ctx); const user = requirePermission(ctx, "groups", action);
const keto = ctx.system?.keto; const keto = ctx.system?.keto;
const kratosAdmin = ctx.system?.kratosAdmin; const kratosAdmin = ctx.system?.kratosAdmin;
if (!keto || !kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.keto")); if (!keto || !kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.keto"));
@@ -300,12 +309,12 @@ function withGroups(inner: (deps: GroupsDeps) => Promise<RouteResult>): RouteHan
} }
// Same, plus the validated :name from ctx.params (an invalid group name → themed 404). // Same, plus the validated :name from ctx.params (an invalid group name → themed 404).
function withGroupName(inner: (deps: GroupsDeps, name: string) => Promise<RouteResult>): RouteHandler { function withGroupName(inner: (deps: GroupsDeps, name: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return withGroups((deps) => { return withGroups((deps) => {
const name = deps.ctx.params["name"] ?? ""; const name = deps.ctx.params["name"] ?? "";
if (!isValidGroupName(name)) return Promise.resolve(notFound(deps.ctx)); if (!isValidGroupName(name)) return Promise.resolve(notFound(deps.ctx));
return inner(deps, name); return inner(deps, name);
}); }, action);
} }
const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => { const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
@@ -316,7 +325,7 @@ const groupFormResult = async (deps: GroupsDeps, extra: { error?: string; values
// GET /admin/groups — the list. // GET /admin/groups — the list.
export const groupsList = withGroups(async ({ ctx, keto }) => { export const groupsList = withGroups(async ({ ctx, keto }) => {
const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS })); const groups = groupsFromTuples(await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS }));
return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" }; return { data: { chrome: ctx.chrome, model: buildGroupsListModel({ canWrite: can(ctx, permissionName("groups", "write")), csrfToken: ctx.chrome.csrfToken, groups, t: ctx.t, url: ctx.url }) }, view: "groups" };
}); });
// POST /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple). // POST /admin/groups — create (a group exists once it has ≥1 member, so this writes the first tuple).
@@ -336,13 +345,38 @@ export const groupsCreate = withGroups(async (deps) => {
}); });
// GET /admin/groups/new — the create form. // GET /admin/groups/new — the create form.
export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {})); export const groupsNewForm = withGroups((deps) => groupFormResult(deps, {}), "write");
// GET /admin/groups/:name — the detail + membership page. // GET /admin/groups/:name — the detail + membership page.
export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => { export const groupsDetail = withGroupName(async ({ ctx, keto, kratosAdmin }, name) => {
const { emailById, options } = await memberCandidates(keto, kratosAdmin); const { emailById, options } = await memberCandidates(keto, kratosAdmin);
const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById)); const members = (await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS })).map((t) => memberView(t, emailById));
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, t: ctx.t }) }, view: "group-detail" }; const subject = groupSubject(name);
const [direct, effective] = await Promise.all([heldPermissions(keto, subject), effectivePermissions(keto, subject, ctx.declaredPermissions)]);
const permissions = buildPermissionPicker({
action: `${detailHref(name)}/permissions`,
declared: ctx.declaredPermissions,
direct,
effective, // a group nested in another group inherits its permissions too
readOnly: !can(ctx, permissionName("groups", "write")),
t: ctx.t,
transitive: true, // members inherit, so a change here lands at their next re-mint, not at once
});
return { data: { chrome: ctx.chrome, model: buildGroupDetailModel({ canWrite: !permissions.readOnly, candidates: options, csrfToken: ctx.chrome.csrfToken, group: { name }, members, permissions, t: ctx.t }) }, view: "group-detail" };
});
// POST /admin/groups/:name/permissions — the submitted checkboxes are the desired set. Members hold
// a group's permissions transitively, so the change reaches them at their next login or re-mint —
// the documented instant-revoke tradeoff for anything held through a group.
export const groupsPermissions = withGroupName(async ({ ctx, keto, user }, name) => {
const form = (await guardedForm(ctx))!;
const subject = groupSubject(name);
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
await applyGrants(keto, subject, diff);
if (diff.grant.length > 0 || diff.revoke.length > 0) {
ctx.log.info("admin: group permissions changed", { actor: user.id, granted: diff.grant.join(","), group: name, revoked: diff.revoke.join(",") });
}
return { redirect: detailHref(name) };
}); });
// POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest). // POST /admin/groups/:name/members — add a member (skip an invalid member or a self-nest).
@@ -362,13 +396,19 @@ export const groupsDeleteConfirm = withGroupName((deps, name) => {
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"), cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.groups.delete"),
message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"), message: tt("admin.groups.deleteMessage", { name }), title: tt("admin.groups.delete"),
}) }, view: "confirm" }); }) }, view: "confirm" });
}); }, "write");
// POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist). // POST /admin/groups/:name/delete — remove every member tuple (the group ceases to exist).
export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => { export const groupsDelete = withGroupName(async ({ ctx, keto, user }, name) => {
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
// Drop what the group *holds* before what it *contains*: a Keto set exists only through its
// tuples, so leaving the grants behind would resurrect every permission the moment someone
// re-created a group with the same name.
const 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 }); await keto.deleteTuple({ namespace: GROUP_NS, object: name, relation: MEMBERS });
ctx.log.info("admin: group deleted", { actor: user.id, group: name }); ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
return { redirect: ADMIN_GROUPS_BASE }; return { redirect: ADMIN_GROUPS_BASE };
}); });
@@ -1,106 +0,0 @@
// Built-in Roles admin screen: the pure view-model + Keto builders. A permission is a
// Keto subject set (Permission:<name>#members); members are users (subject_id) or groups (subject_set) —
// "assign permissions to users/groups". The "effective access" view flattens a Keto `expand` tree into the
// distinct set of users who hold the permission directly or transitively via a group. The HTTP
// routing/gate/CSRF + live Keto/Kratos calls are exercised over HTTP in app.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
import { memberView } from "./admin-groups.ts";
import {
buildPermissionDetailModel,
buildPermissionFormModel,
buildPermissionsListModel,
expandToEffectiveUsers,
isValidRoleName,
permissionGrantTuple,
} from "./admin-permissions.ts";
import type { ExpandTree, RelationTuple } from "#plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (permission: string, n: number): RelationTuple =>
({ namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${uid(n)}` });
const groupTuple = (permission: string, group: string): RelationTuple =>
({ namespace: "Permission", object: permission, relation: "granted", subject_set: { namespace: "Group", object: group, relation: "members" } });
test("isValidRoleName + permissionGrantTuple map the form value to a Permission tuple over a user/group (else null)", () => {
for (const ok of ["admin", "editor", "team-a", "a1_b9"]) assert.equal(isValidRoleName(ok), true, ok);
for (const bad of ["", "Admin", "a b", "-bad", "a".repeat(65)]) assert.equal(isValidRoleName(bad), false, bad);
assert.deepEqual(permissionGrantTuple("editor", `user:${uid(2)}`), { namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${uid(2)}` });
assert.deepEqual(permissionGrantTuple("editor", "group:eng"), { namespace: "Permission", object: "editor", relation: "granted", subject_set: { namespace: "Group", object: "eng", relation: "members" } });
for (const bad of ["", "user:not-a-uuid", "group:Bad Name", "nope:x"]) assert.equal(permissionGrantTuple("editor", bad), null, bad);
});
test("expandToEffectiveUsers flattens an expand tree → sorted distinct user ids, transitive through groups", () => {
// The subject rides on each node's `tuple` (Keto v26.2.0 shape, verified live).
const leaf = (n: number): ExpandTree => ({ tuple: { namespace: "", object: "", relation: "", subject_id: `user:${uid(n)}` }, type: "leaf" });
const tree: ExpandTree = {
children: [
leaf(1), // direct
{
children: [leaf(2), leaf(1)], // via group + dup
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Group", object: "eng", relation: "members" } }, // a member group, not a user
type: "union",
},
],
tuple: { namespace: "", object: "", relation: "", subject_set: { namespace: "Permission", object: "admin", relation: "granted" } },
type: "union",
};
assert.deepEqual(expandToEffectiveUsers(tree), [uid(1), uid(2)]);
assert.deepEqual(expandToEffectiveUsers(null), []);
assert.deepEqual(expandToEffectiveUsers({ type: "leaf" }), []); // an empty permission
});
test("buildPermissionsListModel filters by search, sorts, paginates; the name links to the detail page", () => {
const permissions = Array.from({ length: 30 }, (_, i) => ({ memberCount: i + 1, name: `permission-${String(i).padStart(2, "0")}` }));
const all = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions" });
assert.equal(all.pagination.summary.total, 30);
assert.equal(all.table.rows.length, 25); // default page size
assert.equal(all.title, "Permissions");
const first = all.table.rows[0]!.cells[0] as { rowHeader: { href: string; text: string } };
assert.equal(first.rowHeader.text, "permission-00");
assert.equal(first.rowHeader.href, "/admin/permissions/permission-00");
const one = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?q=permission-07" });
assert.equal(one.pagination.summary.total, 1);
assert.deepEqual(one.filterBar.pills.map((p) => p.label), ["Search"]);
const desc = buildPermissionsListModel({ permissions, url: "http://x/admin/permissions?sort=-members" });
assert.equal((desc.table.rows[0]!.cells[0] as { rowHeader: { text: string } }).rowHeader.text, "permission-29");
});
test("buildPermissionFormModel: a create form with a required name field + member options (user or group)", () => {
const options = [{ label: "ada@example.com", value: `user:${uid(1)}` }, { label: "eng (group)", value: "group:eng" }];
const m = buildPermissionFormModel({ csrfToken: "tok.sig", memberOptions: options });
assert.equal(m.title, "New permission");
assert.equal(m.form.action, "/admin/permissions");
assert.equal(m.form.submitLabel, "Create permission");
assert.equal(m.form.csrfToken, "tok.sig");
assert.equal(m.form.nameField.required, true);
assert.deepEqual(m.form.memberOptions, options);
const err = buildPermissionFormModel({ error: "That name is taken.", memberOptions: options, values: { member: "group:eng", name: "Admin" } });
assert.equal(err.error, "That name is taken.");
assert.equal(err.form.nameField.value, "Admin");
assert.equal(err.form.selectedMember, "group:eng");
});
test("buildPermissionDetailModel: members → rows, add-options exclude current members, effective access listed, actions wired", () => {
const members = [memberView(userTuple("admin", 1), new Map([[uid(1), "ada@example.com"]])), memberView(groupTuple("admin", "eng"), new Map())];
const candidates = [
{ label: "ada@example.com", value: `user:${uid(1)}` }, // already a member → excluded
{ label: "grace@example.com", value: `user:${uid(2)}` },
{ label: "eng (group)", value: "group:eng" }, // already a member → excluded
{ label: "ops (group)", value: "group:ops" },
];
const effective = [{ label: "ada@example.com" }, { label: "grace@example.com" }]; // ada direct, grace via eng
const m = buildPermissionDetailModel({ candidates, effective, members, permission: { name: "admin" } });
assert.equal(m.title, "admin");
assert.equal(m.members.rows.length, 2);
assert.equal(m.members.action, "/admin/permissions/admin/members/delete");
assert.equal(m.add.action, "/admin/permissions/admin/members");
assert.deepEqual(m.add.options.map((o) => o.value), [`user:${uid(2)}`, "group:ops"]);
assert.deepEqual(m.effective.map((e) => e.label), ["ada@example.com", "grace@example.com"]);
assert.equal(m.delete.action, "/admin/permissions/admin/delete");
});
-374
View File
@@ -1,374 +0,0 @@
// Permissions admin screen: list / create / delete Keto permissions and assign
// them to users and groups. A permission is a Keto subject set `Permission:<name>#members` (OPL: members are users
// or groups, resolved transitively) — the source of truth for the JWT `permissions` claim. It shares the
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
// are reused from admin-groups. The permission-specific piece is the **effective access** view:
// `keto.expand(Permission:<name>#members)` flattened to the distinct users who hold the permission directly or via
// a group — matching what login projects into the JWT (login.ts readPermissions). Writes go only to Keto;
// Kratos is read only to label members. Below the builders are thin per-route handlers (keyed on
// ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded.
import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
import { ADMIN_EN, ADMIN_PERMISSION, ADMIN_PERMISSIONS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts";
import {
type GroupView,
groupsFromTuples,
isValidGroupName,
memberCandidates,
type MemberOption,
type MemberView,
memberView,
pagedTuples,
parseSubject,
} from "./admin-groups.ts";
import type { FieldConfig } from "./admin-users.ts";
const PERMISSION_NS = "Permission";
const GRANTED = "granted";
const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100];
// Expand far past any sane group-nesting depth so the effective-access view never silently
// under-reports the deepest members (Keto's own default is shallow).
const EXPAND_MAX_DEPTH = 50;
// A permission and a group share the URL-safe name rule and the user|group membership model.
export type PermissionView = GroupView;
export const isValidRoleName = isValidGroupName;
export const permissionsFromTuples = groupsFromTuples;
export interface EffectiveUser {
label: string; // email (or the raw id when unresolved)
}
// The full membership tuple for assigning/revoking `value` to/from `permission` (null if value is invalid).
export function permissionGrantTuple(permission: string, value: string): RelationTuple | null {
const subject = parseSubject(value);
return subject ? { namespace: PERMISSION_NS, object: permission, relation: GRANTED, ...subject } : null;
}
// Flatten a Keto `expand` tree → the sorted, distinct user ids that effectively hold the permission
// (direct leaves + users reached through member groups, any depth). The subject rides on each
// node's `tuple`; subject-set nodes (the groups) contribute nothing directly — their members
// surface as leaves under them.
export function expandToEffectiveUsers(tree: ExpandTree | null | undefined): string[] {
const ids = new Set<string>();
const walk = (node?: ExpandTree | null): void => {
if (!node) return;
const subjectId = node.tuple?.subject_id;
if (subjectId?.startsWith("user:")) ids.add(subjectId.slice("user:".length));
node.children?.forEach(walk);
};
walk(tree);
return [...ids].sort();
}
// ---- list view model ----
interface ListState {
page: number;
pageSize: number;
q: string;
sort: string | null;
}
const SORT: Record<string, (r: PermissionView) => number | string> = {
members: (r) => r.memberCount,
name: (r) => r.name,
};
const COLUMNS = [
{ key: "name", label: "admin.permissions.column.name" },
{ key: "members", label: "admin.permissions.column.members" },
];
function detailHref(name: string): string {
return `${ADMIN_PERMISSIONS_BASE}/${encodeURIComponent(name)}`;
}
function listHref(state: ListState, overrides: Partial<ListState> = {}): string {
const s = { ...state, ...overrides };
const p = new URLSearchParams();
if (s.q) p.set("q", s.q);
if (s.sort) p.set("sort", s.sort);
if (s.page > 1) p.set("page", String(s.page));
if (s.pageSize !== DEFAULT_PAGE_SIZE) p.set("pageSize", String(s.pageSize));
const qs = p.toString();
return qs ? `${ADMIN_PERMISSIONS_BASE}?${qs}` : ADMIN_PERMISSIONS_BASE;
}
export function buildPermissionsListModel(opts: {
csrfToken?: string;
permissions: PermissionView[];
t?: Translate;
url: URL | URLSearchParams | string;
}) {
const t = opts.t ?? ADMIN_EN;
const query = parseListQuery(opts.url, { defaultPageSize: DEFAULT_PAGE_SIZE });
const sort = query.sort && SORT[query.sort.field] ? query.sort : null;
const sortToken = sort ? (sort.dir === "desc" ? `-${sort.field}` : sort.field) : null;
const needle = query.q.toLowerCase();
let list = opts.permissions.filter((r) => !needle || r.name.toLowerCase().includes(needle));
if (sort) {
const get = SORT[sort.field]!;
const dir = sort.dir === "desc" ? -1 : 1;
list = [...list].sort((a, b) => {
const av = get(a), bv = get(b);
const cmp = typeof av === "number" && typeof bv === "number" ? av - bv : String(av).localeCompare(String(bv));
return cmp * dir;
});
}
const page = paginate(list.length, query.page, query.pageSize, { boundaries: 1, siblings: 1 });
const start = (page.page - 1) * page.pageSize;
const rows = list.slice(start, start + page.pageSize);
const state: ListState = { page: page.page, pageSize: page.pageSize, q: query.q, sort: sortToken };
return {
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.nav.section") }, { label: t("admin.permissions.title") }],
filterBar: listFilterBar(state, t),
pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t),
title: t("admin.permissions.title"),
};
}
function listTable(rows: PermissionView[], state: ListState, sort: { dir: "asc" | "desc"; field: string } | null, t: Translate) {
return {
caption: t("admin.permissions.title"),
columns: COLUMNS.map((c) => {
const dir = sort && sort.field === c.key ? sort.dir : undefined;
const next = dir === "asc" ? `-${c.key}` : c.key;
return { href: listHref(state, { page: 1, sort: next }), label: t(c.label), sort: dir, sortable: true };
}),
rows: rows.map((r) => ({
cells: [{ rowHeader: { href: detailHref(r.name), text: r.name } }, String(r.memberCount)],
name: r.name,
})),
};
}
function listFilterBar(state: ListState, t: Translate) {
const pills: { label: string; remove: string; value: string }[] = [];
if (state.q) pills.push({ label: t("filter.search"), remove: listHref(state, { page: 1, q: "" }), value: state.q });
return {
applyLabel: t("filter.apply"),
clearHref: ADMIN_PERMISSIONS_BASE,
label: t("admin.permissions.filter"),
pills,
rows: [[
{ label: t("admin.permissions.searchLabel"), name: "q", placeholder: t("admin.permissions.searchPlaceholder"), type: "search", value: state.q },
{ type: "spacer" },
]],
};
}
function listPagination(state: ListState, page: ReturnType<typeof paginate>, t: Translate) {
const hidden: { name: string; value: string }[] = [];
if (state.q) hidden.push({ name: "q", value: state.q });
if (state.sort) hidden.push({ name: "sort", value: state.sort });
return {
label: t("admin.permissions.pagination"),
next: { href: page.next ? listHref(state, { page: page.next }) : undefined },
pages: page.pages.map((p) =>
p.ellipsis ? { ellipsis: true }
: p.current ? { current: true, label: String(p.page) }
: { href: listHref(state, { page: p.page as number }), label: String(p.page) }),
prev: { href: page.prev ? listHref(state, { page: page.prev }) : undefined },
rows: { hidden, label: t("pagination.rows"), name: "pageSize", options: PAGE_SIZES, submitLabel: t("pagination.go"), value: state.pageSize },
summary: { from: page.from, to: page.to, total: page.total },
};
}
// ---- create form + detail view models ----
export function buildPermissionFormModel(opts: {
csrfToken?: string;
error?: string;
memberOptions: MemberOption[];
t?: Translate;
values?: { member?: string; name?: string };
}) {
const t = opts.t ?? ADMIN_EN;
const nameField: FieldConfig = {
autocomplete: "off", hint: t("admin.permissions.field.nameHint"), icon: "i-shield",
id: "name", label: t("admin.permissions.field.name"), name: "name", required: true, value: opts.values?.name ?? "",
};
return {
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: t("common.new") }],
error: opts.error,
form: {
action: ADMIN_PERMISSIONS_BASE,
cancelHref: ADMIN_PERMISSIONS_BASE,
csrfToken: opts.csrfToken ?? "",
memberOptions: opts.memberOptions,
nameField,
selectedMember: opts.values?.member ?? "",
submitLabel: t("admin.permissions.create"),
},
title: t("admin.permissions.new"),
};
}
export function buildPermissionDetailModel(opts: {
candidates: MemberOption[];
csrfToken?: string;
effective: EffectiveUser[];
error?: string;
members: MemberView[];
permission: { name: string };
t?: Translate;
}) {
const t = opts.t ?? ADMIN_EN;
const name = opts.permission.name;
const base = detailHref(name);
const taken = new Set(opts.members.map((m) => m.subject));
const options = opts.candidates.filter((c) => !taken.has(c.value)); // members are users/groups, never the permission itself
return {
add: { action: `${base}/members`, options },
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: t("admin.permissions.title") }, { label: name }],
csrfToken: opts.csrfToken ?? "",
delete: { action: `${base}/delete` },
effective: opts.effective,
error: opts.error,
members: { action: `${base}/members/delete`, rows: opts.members },
permission: { name },
title: name,
};
}
// ---- request handler (imperative shell) ----
// instant-revoke: a permission change for a `user:<id>` member must take effect now, so revoke that
// user's live tokens (a re-mint then re-reads permissions from Keto). A `group:<name>` change is
// transitive across many users — left to lag (documented), so only direct user members revoke.
function revokeUserMember(revoke: ((sub: string) => void) | undefined, member: string): void {
if (revoke && member.startsWith("user:")) revoke(member.slice("user:".length));
}
// A permission exists exactly while it has ≥1 member (Keto has no create-object).
async function roleExists(keto: KetoClient, name: string): Promise<boolean> {
const page = await keto.listRelations({ namespace: PERMISSION_NS, object: name, relation: GRANTED, pageSize: 1 });
return page.tuples.length > 0;
}
// The distinct users who effectively hold the permission (expand → flatten → label by email). Skipped for
// an empty permission (no member tuples) so we don't expand a non-existent Keto object.
async function effectiveUsers(keto: KetoClient, name: string, hasMembers: boolean, emailById: Map<string, string>): Promise<EffectiveUser[]> {
if (!hasMembers) return [];
const tree = await keto.expand({ namespace: PERMISSION_NS, object: name, relation: GRANTED }, { maxDepth: EXPAND_MAX_DEPTH });
return expandToEffectiveUsers(tree)
.map((id) => ({ label: emailById.get(id) ?? `user:${id}` }))
.sort((a, b) => a.label.localeCompare(b.label));
}
// Shared per-request deps for the Roles screen, resolved by `withRoles`: the gate + the Keto and
// Kratos capabilities (else a themed 503). Each route below is a thin handler over these.
interface RolesDeps { ctx: RequestContext; keto: KetoClient; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
function withRoles(inner: (deps: RolesDeps) => Promise<RouteResult>): RouteHandler {
return async (ctx) => {
const user = requireAdmin(ctx);
const keto = ctx.system?.keto;
const kratosAdmin = ctx.system?.kratosAdmin;
if (!keto || !kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.keto"));
return inner({ ctx, keto, kratosAdmin, revoke: ctx.system?.revoke, user });
};
}
// Same, plus the validated :name from ctx.params (an invalid permission name → themed 404).
function withRoleName(inner: (deps: RolesDeps, name: string) => Promise<RouteResult>): RouteHandler {
return withRoles((deps) => {
const name = deps.ctx.params["name"] ?? "";
if (!isValidRoleName(name)) return Promise.resolve(notFound(deps.ctx));
return inner(deps, name);
});
}
const roleFormResult = async (deps: RolesDeps, extra: { error?: string; values?: { member?: string; name?: string } }): Promise<RouteResult> => {
const { options } = await memberCandidates(deps.keto, deps.kratosAdmin);
return { data: { chrome: deps.ctx.chrome, model: buildPermissionFormModel({ csrfToken: deps.ctx.chrome.csrfToken, memberOptions: options, t: deps.ctx.t, ...extra }) }, view: "permission-form" };
};
// The permission detail (members + effective access). With `error` set it's a 400 (a rejected action).
const permissionDetailResult = async (deps: RolesDeps, name: string, error?: string): Promise<RouteResult> => {
const { emailById, options } = await memberCandidates(deps.keto, deps.kratosAdmin);
const tuples = await pagedTuples(deps.keto, { namespace: PERMISSION_NS, object: name, relation: GRANTED });
const members = tuples.map((t) => memberView(t, emailById));
const effective = await effectiveUsers(deps.keto, name, tuples.length > 0, emailById);
const result: RouteResult = { data: { chrome: deps.ctx.chrome, model: buildPermissionDetailModel({ candidates: options, csrfToken: deps.ctx.chrome.csrfToken, effective, members, permission: { name }, t: deps.ctx.t, ...(error ? { error } : {}) }) }, view: "permission-detail" };
return error ? { ...result, status: 400 } : result;
};
// GET /admin/permissions — the list.
export const rolesList = withRoles(async ({ ctx, keto }) => {
const permissions = permissionsFromTuples(await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED }));
return { data: { chrome: ctx.chrome, model: buildPermissionsListModel({ csrfToken: ctx.chrome.csrfToken, permissions, t: ctx.t, url: ctx.url }) }, view: "permissions" };
});
// POST /admin/permissions — create + assign the first member (a *user* grant revokes their live tokens).
export const rolesCreate = withRoles(async (deps) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const name = (form.get("name") ?? "").trim();
const member = (form.get("member") ?? "").trim();
const tuple = permissionGrantTuple(name, member);
const reject = async (error: string): Promise<RouteResult> => ({ ...(await roleFormResult(deps, { error, values: { member, name } })), status: 400 });
if (!isValidRoleName(name)) return reject(ctx.t("admin.permissions.validation.name"));
if (!tuple) return reject(ctx.t("admin.permissions.validation.member"));
if (await roleExists(keto, name)) return reject("A permission with that name already exists.");
await keto.writeTuple(tuple);
revokeUserMember(revoke, member);
ctx.log.info("admin: permission created + first member assigned", { actor: user.id, member, permission: name });
return { redirect: detailHref(name) };
});
// GET /admin/permissions/new — the create form.
export const rolesNewForm = withRoles((deps) => roleFormResult(deps, {}));
// GET /admin/permissions/:name — the detail (members + effective access via Keto expand).
export const rolesDetail = withRoleName((deps, name) => permissionDetailResult(deps, name));
// POST /admin/permissions/:name/members — assign a user/group; a *user* grant revokes their live tokens.
export const rolesAddMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const member = (form.get("member") ?? "").trim();
const tuple = permissionGrantTuple(name, member); // the picker only offers real users/groups
if (tuple) { await keto.writeTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission assigned", { actor: user.id, member, permission: name }); }
return { redirect: detailHref(name) };
});
// GET /admin/permissions/:name/delete — confirm, except the admin permission can't be deleted.
export const rolesDeleteConfirm = withRoleName((deps, name) => {
if (name === ADMIN_PERMISSION) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.adminUndeletable"));
const base = detailHref(name);
const tt = deps.ctx.t;
return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({
breadcrumbs: [{ href: ADMIN_PERMISSIONS_BASE, label: tt("admin.permissions.title") }, { href: base, label: name }, { label: tt("common.delete") }],
cancelHref: base, confirmAction: `${base}/delete`, confirmLabel: tt("admin.permissions.delete"),
message: tt("admin.permissions.deleteMessage", { name }), title: tt("admin.permissions.delete"),
}) }, view: "confirm" });
});
// POST /admin/permissions/:name/delete — remove every member tuple (a whole-permission delete lags per the
// documented instant-revoke tradeoff; the admin permission is protected).
export const rolesDelete = withRoleName(async (deps, name) => {
const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST
if (name === ADMIN_PERMISSION) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.adminUndeletable"));
await keto.deleteTuple({ namespace: PERMISSION_NS, object: name, relation: GRANTED });
ctx.log.info("admin: permission deleted", { actor: user.id, permission: name });
return { redirect: ADMIN_PERMISSIONS_BASE };
});
// POST /admin/permissions/:name/members/delete — unassign; a *user* unassign revokes their live tokens.
// Self-protection: an admin can't revoke their own *direct* admin grant (a group-held admin isn't
// covered — the robust "last effective admin" check is deferred).
export const rolesRemoveMember = withRoleName(async (deps, name) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
const member = (form.get("member") ?? "").trim();
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return permissionDetailResult(deps, name, deps.ctx.t("admin.permissions.error.selfRevoke"));
const tuple = permissionGrantTuple(name, member);
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: permission unassigned", { actor: user.id, member, permission: name }); }
return { redirect: detailHref(name) };
});
+44 -16
View File
@@ -1,15 +1,16 @@
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical // Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
// (requireAdmin/guardedForm gate every admin write) and reused across all four screens, so pin the // (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts. // contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
// Import only from the #plugin-api barrel — the same contract boundary the plugin code uses. // Import only from the @plainpages/plugin-api barrel — the same contract boundary the plugin code uses.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { test } from "node:test"; import { test } from "node:test";
import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api"; import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "@plainpages/plugin-api";
import { ADMIN_EN, ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts"; import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts";
const admin: User = { email: "ada@x.io", id: "u1", permissions: ["admin"] }; 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"] };
const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] }; const member: User = { email: "bo@x.io", id: "u2", permissions: ["scheduling:read"] };
const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome; const CHROME = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } } as PageChrome;
@@ -18,7 +19,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
req.method = opts.method ?? "GET"; req.method = opts.method ?? "GET";
return { return {
chrome: CHROME, user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {}, chrome: CHROME, declaredPermissions: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url, query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
verifyCsrf: opts.verifyCsrf ?? (() => true), verifyCsrf: opts.verifyCsrf ?? (() => true),
}; };
@@ -26,24 +27,51 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
// ---- nav fragment ---- // ---- nav fragment ----
test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => { test("ADMIN_NAV: an ungated Admin header whose three screens each gate on their own read permission", () => {
assert.equal(ADMIN_NAV.id, "admin"); assert.equal(ADMIN_NAV.id, "admin");
assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin // No gate on the header: a user may hold one screen's permission and not another's. composeNav
// drops a header left with no visible children, so holding none of the three hides the section.
// Both halves matter — give the header an `href` and it survives the filter as a visible leaf,
// ungated, for anonymous visitors included.
assert.equal(ADMIN_NAV.permission, undefined);
assert.equal(ADMIN_NAV.href, undefined);
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/permissions", "/admin/clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read"]);
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes // Labels are catalog keys; the host translates them with this plugin's catalog when it composes
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys. // the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.permissions", "admin.nav.clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients"]);
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "Permissions", "OAuth2 clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients"]);
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
});
// ---- permission naming ----
test("permissionName builds <resource>:<action>, and the host agrees the result is well-formed", () => {
assert.equal(permissionName("users", "read"), "users:read");
assert.equal(permissionName("oauth2-clients", "write"), "oauth2-clients:write");
assert.ok(isValidPermissionName(permissionName("oauth2-clients", "write"))); // the rule discovery enforces
});
test("actionForMethod: read for GET/HEAD, write for every mutation", () => {
assert.equal(actionForMethod("GET"), "read");
assert.equal(actionForMethod("HEAD"), "read"); // a GET route also answers HEAD
assert.equal(actionForMethod("POST"), "write");
assert.equal(actionForMethod("DELETE"), "write"); // anything that isn't a read is a write
assert.equal(actionForMethod("get"), "read"); // method case is the caller's
}); });
// ---- auth gates ---- // ---- auth gates ----
test("requireAdmin: anonymous → 401→/login, signed-in non-admin → 403, admin → the user", () => { test("requirePermission: anonymous → 401→/login, wrong permission → 403, and read never grants write", () => {
assert.throws(() => requireAdmin(fakeCtx({ user: null })), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page assert.throws(() => requirePermission(fakeCtx({ user: null }), "users"), (e: unknown) => e instanceof GuardError && e.status === 401 && e.location === "/login?return_to=%2Fadmin%2Fusers"); // bounce remembers the page
assert.throws(() => requireAdmin(fakeCtx({ user: member })), (e: unknown) => e instanceof GuardError && e.status === 403); assert.throws(() => requirePermission(fakeCtx({ user: member }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requireAdmin(fakeCtx({ user: admin })), admin); assert.equal(requirePermission(fakeCtx({ user: reader }), "users"), reader);
// The whole point of the split: users:read opens the list but not the create/delete POSTs.
assert.throws(() => requirePermission(fakeCtx({ method: "POST", user: reader }), "users"), (e: unknown) => e instanceof GuardError && e.status === 403);
assert.equal(requirePermission(fakeCtx({ method: "POST", user: writer }), "users"), writer);
// Resources don't leak into each other: a users holder is not a groups holder.
assert.throws(() => requirePermission(fakeCtx({ user: writer }), "groups"), (e: unknown) => e instanceof GuardError && e.status === 403);
}); });
test("guardedForm: valid double-submit → the parsed body, bad token → 403, non-POST → undefined", async () => { test("guardedForm: valid double-submit → the parsed body, bad token → 403, non-POST → undefined", async () => {
+41 -24
View File
@@ -1,46 +1,63 @@
// Shared plumbing for the admin example plugin: the section nav fragment, the admin-only gate, the // 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 // CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers.
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens; // Everything imports the host only through the @plainpages/plugin-api barrel.
// everything imports the host only through the #plugin-api barrel.
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "#plugin-api"; import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
// This plugin's English (its catalog, then the host's — the screens reuse core words like Cancel and // This plugin's English its catalog, then the host's — for a view model built outside a request,
// Search), for a view model built outside a request: its unit tests. At runtime the handlers pass // i.e. its unit tests. At runtime the handlers pass ctx.t instead.
// ctx.t, which reads this catalog in the visitor's locale first, then the host's.
export const ADMIN_EN: Translate = englishTranslator(enUS); export const ADMIN_EN: Translate = englishTranslator(enUS);
export const ADMIN_PERMISSION = "admin"; // the permission gating the whole admin section
export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_USERS_BASE = "/admin/users";
export const ADMIN_GROUPS_BASE = "/admin/groups"; export const ADMIN_GROUPS_BASE = "/admin/groups";
export const ADMIN_PERMISSIONS_BASE = "/admin/permissions";
export const ADMIN_CLIENTS_BASE = "/admin/clients"; export const ADMIN_CLIENTS_BASE = "/admin/clients";
export type AdminScreen = "clients" | "groups" | "permissions" | "users"; // One resource per screen — the `<resource>` half of every permission this plugin gates on.
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
// There is no `permissions` resource: permissions are declared in plugin code, not created here, so
// holding a grant is a property of a user or a group and is edited on those two screens.
export type AdminResource = "groups" | "oauth2-clients" | "users";
// The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into export type AdminAction = "read" | "write";
// the one global menu, filters per user (the header's `permission` drops the whole subtree for a
// non-admin), and current-marks the active item — so there is no `current`/`open` state here. // `<resource>:<action>` (README → Naming a permission).
export function permissionName(resource: AdminResource, action: AdminAction): string {
return `${resource}:${action}`;
}
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately
// local: generalised, it would make authorization a function of the transport verb (AGENTS.md).
export function actionForMethod(method: string): AdminAction {
const verb = method.toUpperCase();
return verb === "GET" || verb === "HEAD" ? "read" : "write";
}
// The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own
// read permission. The header carries no `permission` because a user may hold one screen's and not
// another's; composeNav drops a header left with no visible children, so a user holding none of the
// three never sees the section. The host current-marks the active item — no `current`/`open` here.
export const ADMIN_NAV: NavNode = { export const ADMIN_NAV: NavNode = {
children: [ children: [
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users" }, { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups" }, { href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") },
{ href: ADMIN_PERMISSIONS_BASE, icon: "i-shield", id: "permissions", label: "admin.nav.permissions" }, { href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") },
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients" },
], ],
icon: "i-shield", icon: "i-shield",
id: "admin", id: "admin",
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
permission: ADMIN_PERMISSION,
}; };
// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the // The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
// host enforces this before the handler runs; this is defence-in-depth and what a direct unit test // declares the same permission, so this is defence-in-depth and what a direct unit test relies on.
// relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403. // `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create
export function requireAdmin(ctx: RequestContext): User { // form or a delete-confirm page — which refuses a reader rather than rendering a form whose submit
// would 403. The route table declares the same override, so the two cannot disagree.
export function requirePermission(ctx: RequestContext, resource: AdminResource, action?: AdminAction): User {
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept) const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin permission required"); const permission = permissionName(resource, action ?? actionForMethod(ctx.req.method ?? "GET"));
if (!can(ctx, permission)) throw new GuardError(403, `${permission} required`);
return user; return user;
} }
+1 -1
View File
@@ -2,7 +2,7 @@
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts. // routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import type { Identity } from "#plugin-api"; import type { Identity } from "@plainpages/plugin-api";
import { import {
buildUserFormModel, buildUserFormModel,
buildUsersListModel, buildUsersListModel,
+96 -24
View File
@@ -1,11 +1,11 @@
// Users admin screen: list Kratos identities (filter/sort/paginate) + // Users admin screen: list Kratos identities (filter/sort/paginate) +
// create/edit/deactivate/delete/trigger-recovery. Writes go only to Kratos via the admin client // create/edit/deactivate/delete/trigger-recovery. Pure builders turn identities + the request URL
// (README "stateless"). Pure builders turn identities + the request URL into building-block view // into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
// models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate // a shared `withUser` gate.
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
import { type Identity, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api"; import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { ADMIN_EN, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
@@ -105,6 +105,7 @@ function listHref(state: ListState, overrides: Partial<ListState> = {}): string
} }
export function buildUsersListModel(opts: { export function buildUsersListModel(opts: {
canWrite?: boolean;
csrfToken?: string; csrfToken?: string;
identities: Identity[]; identities: Identity[];
t?: Translate; t?: Translate;
@@ -134,6 +135,7 @@ export function buildUsersListModel(opts: {
return { return {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }], breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.nav.section") }, { label: t("admin.users.title") }],
canWrite: opts.canWrite !== false,
filterBar: listFilterBar(state, all.length, t), filterBar: listFilterBar(state, all.length, t),
pagination: listPagination(state, page, t), pagination: listPagination(state, page, t),
table: listTable(rows, state, sort, t), table: listTable(rows, state, sort, t),
@@ -216,9 +218,11 @@ export interface FieldConfig {
} }
export function buildUserFormModel(opts: { export function buildUserFormModel(opts: {
canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance
csrfToken?: string; csrfToken?: string;
error?: string; error?: string;
identity?: Identity | null; identity?: Identity | null;
permissions?: PermissionPicker; // editing only — a user that doesn't exist yet can hold nothing
recovery?: RecoveryCode; recovery?: RecoveryCode;
t?: Translate; t?: Translate;
values?: Partial<UserInput>; values?: Partial<UserInput>;
@@ -238,8 +242,10 @@ export function buildUserFormModel(opts: {
]; ];
if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" }); if (!editing) fields.push({ autocomplete: "new-password", hint: t("admin.users.field.passwordHint"), icon: "i-lock", id: "password", label: t("admin.users.field.password"), name: "password", optional: true, type: "password" });
const canWrite = opts.canWrite !== false;
return { return {
breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: editing ? t("common.edit") : t("common.new") }], breadcrumbs: [{ href: ADMIN_USERS_BASE, label: t("admin.users.title") }, { label: editing ? t("common.edit") : t("common.new") }],
canWrite, // the view drops every write affordance when false; the host already 403s the POSTs
edit: editing ? { edit: editing ? {
deleteAction: `${idPath}/delete`, deleteAction: `${idPath}/delete`,
id: view!.id, id: view!.id,
@@ -250,6 +256,7 @@ export function buildUserFormModel(opts: {
} : undefined, } : undefined,
error: opts.error, error: opts.error,
form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") }, form: { action: idPath, cancelHref: ADMIN_USERS_BASE, csrfToken: opts.csrfToken ?? "", fields, submitLabel: editing ? t("admin.users.save") : t("admin.users.create") },
permissions: editing ? opts.permissions : undefined,
recovery: opts.recovery, recovery: opts.recovery,
title: editing ? t("admin.users.edit") : t("admin.users.new"), title: editing ? t("admin.users.edit") : t("admin.users.new"),
}; };
@@ -266,30 +273,33 @@ function readUserInput(form: URLSearchParams): UserInput {
}; };
} }
// Shared per-request deps for the Users screen, resolved by `withUser`: the gate (admin only) and // Shared per-request deps for the Users screen, resolved by `withUser`: the gate (`users:read` on a
// the Kratos capability (else a themed 503). Each route below is a thin handler over these. // GET, `users:write` on a POST) and the Kratos capability (else a themed 503). Each route below is a
interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; } // thin handler over these.
// `keto` is optional the way every other capability here is: without it the page still lists and
// edits users, it just can't show the permission picker.
interface UsersDeps { ctx: RequestContext; keto: KetoClient | undefined; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; }
// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the // Resolve the shared deps, then run `inner`. The route's own `permission` already gated at the host;
// host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it. // `requirePermission` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it.
function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>): RouteHandler { function withUser(inner: (deps: UsersDeps) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return async (ctx) => { return async (ctx) => {
const user = requireAdmin(ctx); const user = requirePermission(ctx, "users", action);
const kratosAdmin = ctx.system?.kratosAdmin; const kratosAdmin = ctx.system?.kratosAdmin;
if (!kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.kratos")); if (!kratosAdmin) return unavailable(ctx, ctx.t("admin.capability.kratos"));
return inner({ ctx, kratosAdmin, revoke: ctx.system?.revoke, user }); return inner({ ctx, keto: ctx.system?.keto, kratosAdmin, revoke: ctx.system?.revoke, user });
}; };
} }
// Same, plus the target identity from ctx.params.id (unknown id → themed 404). The router already // Same, plus the target identity from ctx.params.id (unknown id → themed 404). The router already
// decoded the id and 404s malformed %-encoding, so no manual decode is needed here. // decoded the id and 404s malformed %-encoding, so no manual decode is needed here.
function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) => Promise<RouteResult>): RouteHandler { function withTarget(inner: (deps: UsersDeps, identity: Identity, id: string) => Promise<RouteResult>, action?: AdminAction): RouteHandler {
return withUser(async (deps) => { return withUser(async (deps) => {
const id = deps.ctx.params["id"] ?? ""; const id = deps.ctx.params["id"] ?? "";
const identity = await deps.kratosAdmin.getIdentity(id); const identity = await deps.kratosAdmin.getIdentity(id);
if (!identity) return notFound(deps.ctx); if (!identity) return notFound(deps.ctx);
return inner(deps, identity, id); return inner(deps, identity, id);
}); }, action);
} }
const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult => const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormModel>[0]): RouteResult =>
@@ -298,7 +308,7 @@ const formResult = (ctx: RequestContext, extra: Parameters<typeof buildUserFormM
// GET /admin/users — the filtered/sorted/paged list. // GET /admin/users — the filtered/sorted/paged list.
export const usersList = withUser(async ({ ctx, kratosAdmin }) => { export const usersList = withUser(async ({ ctx, kratosAdmin }) => {
const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE }); const { identities } = await kratosAdmin.listIdentities({ pageSize: LIST_FETCH_SIZE });
return { data: { chrome: ctx.chrome, model: buildUsersListModel({ csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" }; return { data: { chrome: ctx.chrome, model: buildUsersListModel({ canWrite: canWriteUsers(ctx), csrfToken: ctx.chrome.csrfToken, identities, t: ctx.t, url: ctx.url }) }, view: "users" };
}); });
// POST /admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input. // POST /admin/users — create; a Kratos 4xx re-renders the form (400), keeping the input.
@@ -315,18 +325,73 @@ export const usersCreate = withUser(async ({ ctx, kratosAdmin, user }) => {
}); });
// GET /admin/users/new — the empty create form. // GET /admin/users/new — the empty create form.
export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {}))); export const usersNewForm = withUser(({ ctx }) => Promise.resolve(formResult(ctx, {})), "write");
// GET /admin/users/:id — the edit form, prefilled. // GET /admin/users/:id — the edit form, prefilled.
export const usersEditForm = withTarget((deps, identity) => Promise.resolve(formResult(deps.ctx, { identity }))); export const usersEditForm = withTarget(async (deps, identity, id) => {
const permissions = await userPermissionPicker(deps, id);
return formResult(deps.ctx, { canWrite: canWriteUsers(deps.ctx), identity, ...(permissions ? { permissions } : {}) });
});
const canWriteUsers = (ctx: RequestContext): boolean => can(ctx, permissionName("users", "write"));
// The checkbox list of declared permissions: ticked where this user holds one, and disabled where
// the grant comes from a group (real, but removed on that group). Undefined when Keto isn't wired —
// the rest of the edit page still works.
async function userPermissionPicker(deps: UsersDeps, id: string, error?: string): Promise<PermissionPicker | undefined> {
if (!deps.keto) return undefined;
const subject = userSubject(id);
const [direct, effective] = await Promise.all([
heldPermissions(deps.keto, subject),
effectivePermissions(deps.keto, subject, deps.ctx.declaredPermissions),
]);
return {
...buildPermissionPicker({
action: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}/permissions`,
declared: deps.ctx.declaredPermissions,
direct,
effective,
readOnly: !canWriteUsers(deps.ctx),
t: deps.ctx.t,
}),
...(error ? { error } : {}),
};
}
// POST /admin/users/:id/permissions — the submitted checkboxes are the desired set of *direct*
// grants; grant what's newly ticked, revoke what's newly unticked. A change to a user's own grants
// revokes their live tokens so it lands now rather than at the next re-mint.
export const usersPermissions = withTarget(async (deps, identity, id) => {
const { ctx, keto, revoke, user } = deps;
const form = (await guardedForm(ctx))!;
if (!keto) return unavailable(ctx, ctx.t("admin.capability.keto"));
const subject = userSubject(id);
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
// next request — leaving a `curl` against Keto as the only way back in.
if (id === user.id && diff.revoke.length > 0) {
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke"));
return { ...formResult(ctx, { canWrite: canWriteUsers(ctx), identity, ...(permissions ? { permissions } : {}) }), status: 400 };
}
await applyGrants(keto, subject, diff);
if (diff.grant.length > 0 || diff.revoke.length > 0) {
revoke?.(id);
ctx.log.info("admin: user permissions changed", { actor: user.id, granted: diff.grant.join(","), revoked: diff.revoke.join(","), target: id });
}
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
});
// POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400). // POST /admin/users/:id — save edits; a Kratos 4xx re-renders the form (400).
export const usersUpdate = withTarget(async ({ ctx, kratosAdmin }, identity, id) => { export const usersUpdate = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin } = deps;
const input = readUserInput((await guardedForm(ctx))!); const input = readUserInput((await guardedForm(ctx))!);
try { try {
await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input)); await kratosAdmin.updateIdentity(id, updateIdentityPayload(identity, input));
} catch (err) { } catch (err) {
if (err instanceof KratosError) return { ...formResult(ctx, { error: ctx.t("admin.users.error.save"), identity }), status: 400 }; // 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 };
throw err; throw err;
} }
return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` }; return { redirect: `${ADMIN_USERS_BASE}/${encodeURIComponent(id)}` };
@@ -355,7 +420,7 @@ export const usersDeleteConfirm = withTarget((deps, identity, id) => {
cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: tt("admin.users.delete"), cancelHref: back, confirmAction: `${back}/delete`, confirmLabel: tt("admin.users.delete"),
message: tt("admin.users.deleteMessage", { email: view.email }), title: tt("admin.users.delete"), message: tt("admin.users.deleteMessage", { email: view.email }), title: tt("admin.users.delete"),
}) }, view: "confirm" }); }) }, view: "confirm" });
}); }, "write");
// POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete. // POST /admin/users/:id/delete — perform it; revoke the gone account's live tokens. Refuses self-delete.
export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => { export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user }, identity, id) => {
@@ -368,12 +433,19 @@ export const usersDelete = withTarget(async ({ ctx, kratosAdmin, revoke, user },
}); });
// POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page. // POST /admin/users/:id/recovery — mint a one-time recovery code, shown on the edit page.
export const usersRecovery = withTarget(async ({ ctx, kratosAdmin }, identity, id) => { export const usersRecovery = withTarget(async (deps, identity, id) => {
const { ctx, kratosAdmin } = deps;
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
const recovery = await kratosAdmin.createRecoveryCode(id); const recovery = await kratosAdmin.createRecoveryCode(id);
return formResult(ctx, { identity, recovery }); 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.
async function pickerOrNothing(deps: UsersDeps, id: string): Promise<{ permissions?: PermissionPicker }> {
const permissions = await userPermissionPicker(deps, id);
return permissions ? { permissions } : {};
}
function createError(err: KratosError, t: Translate): string { function createError(err: KratosError, t: Translate): string {
return err.status === 409 return err.status === 409
? t("admin.users.error.duplicate") ? t("admin.users.error.duplicate")
+10 -31
View File
@@ -48,6 +48,15 @@ const messages = {
"admin.common.type": "Type", "admin.common.type": "Type",
"admin.common.user": "User", "admin.common.user": "User",
"admin.grants.hint": "Which permissions exist is set by the plugins installed on this system. Tick to grant, untick to revoke.",
"admin.grants.hintReadOnly": "Which permissions exist is set by the plugins installed on this system. You can see these, but not change them.",
"admin.grants.inherited": "Greyed-out permissions come from a group. Change them on that group.",
"admin.grants.legend": "Permissions",
"admin.grants.none": "No installed plugin declares a permission, so there is nothing to grant.",
"admin.grants.pending": "Members get this at their next sign-in (up to 10 minutes).",
"admin.grants.save": "Save permissions",
"admin.grants.selfRevoke": "You can't revoke your own permissions — ask another administrator, so you can't lock yourself out.",
"admin.groups.actions": "Group actions", "admin.groups.actions": "Group actions",
"admin.groups.addMember": "Add a member", "admin.groups.addMember": "Add a member",
"admin.groups.allMembers": "All users and groups are already members.", "admin.groups.allMembers": "All users and groups are already members.",
@@ -55,7 +64,7 @@ const messages = {
"admin.groups.column.name": "Group", "admin.groups.column.name": "Group",
"admin.groups.create": "Create group", "admin.groups.create": "Create group",
"admin.groups.delete": "Delete group", "admin.groups.delete": "Delete group",
"admin.groups.deleteMessage": "Delete group {{name}}? This removes the group and all its memberships.", "admin.groups.deleteMessage": "Delete group {{name}}? This can't be undone.",
"admin.groups.field.name": "Group name", "admin.groups.field.name": "Group name",
"admin.groups.field.nameHint": "Lowercase letters, digits, dashes and underscores.", "admin.groups.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
"admin.groups.filter": "Filter groups", "admin.groups.filter": "Filter groups",
@@ -74,42 +83,12 @@ const messages = {
"admin.nav.clients": "OAuth2 clients", "admin.nav.clients": "OAuth2 clients",
"admin.nav.groups": "Groups", "admin.nav.groups": "Groups",
"admin.nav.permissions": "Permissions",
"admin.nav.section": "Admin", "admin.nav.section": "Admin",
"admin.nav.users": "Users", "admin.nav.users": "Users",
"admin.notFound.message": "That item doesn't exist.", "admin.notFound.message": "That item doesn't exist.",
"admin.notFound.title": "Not found", "admin.notFound.title": "Not found",
"admin.permissions.actions": "Permission actions",
"admin.permissions.allAssigned": "All users and groups already have this permission.",
"admin.permissions.assign": "Assign the permission",
"admin.permissions.assignAction": "Assign",
"admin.permissions.assignTo": "Assign to",
"admin.permissions.assignedTo": "Assigned to",
"admin.permissions.column.members": "Members",
"admin.permissions.column.name": "Permission",
"admin.permissions.create": "Create permission",
"admin.permissions.delete": "Delete permission",
"admin.permissions.deleteMessage": "Delete permission {{name}}? This revokes it from everyone it's assigned to.",
"admin.permissions.error.adminUndeletable": "The admin permission can't be deleted — it would remove all admin access.",
"admin.permissions.error.selfRevoke": "You can't revoke your own admin access.",
"admin.permissions.effective": "Effective access",
"admin.permissions.effectiveHint": "Everyone who holds this permission — directly or through a group (resolved by Keto).",
"admin.permissions.field.name": "Permission name",
"admin.permissions.field.nameHint": "Lowercase letters, digits, dashes and underscores.",
"admin.permissions.filter": "Filter permissions",
"admin.permissions.new": "New permission",
"admin.permissions.noEffective": "No users hold this permission yet.",
"admin.permissions.noMembers": "Not assigned to anyone yet.",
"admin.permissions.pagination": "Permissions pagination",
"admin.permissions.revoke": "Revoke",
"admin.permissions.searchLabel": "Search permissions",
"admin.permissions.searchPlaceholder": "Search permission name…",
"admin.permissions.title": "Permissions",
"admin.permissions.validation.member": "Pick a user or group to assign the permission to.",
"admin.permissions.validation.name": "Permission names use lowercase letters, digits, dashes and underscores.",
"admin.unavailable.message": "{{what}} is not configured on this deployment.", "admin.unavailable.message": "{{what}} is not configured on this deployment.",
"admin.unavailable.title": "Admin unavailable", "admin.unavailable.title": "Admin unavailable",
+10 -31
View File
@@ -48,6 +48,15 @@ const messages: AdminMessages = {
"admin.common.type": "Typ", "admin.common.type": "Typ",
"admin.common.user": "Användare", "admin.common.user": "Användare",
"admin.grants.hint": "Vilka behörigheter som finns bestäms av de plugins som är installerade. Kryssa i för att tilldela, ur för att återkalla.",
"admin.grants.hintReadOnly": "Vilka behörigheter som finns bestäms av de plugins som är installerade. Du kan se dem, men inte ändra dem.",
"admin.grants.inherited": "Gråmarkerade behörigheter kommer från en grupp. Ändra dem på gruppen.",
"admin.grants.legend": "Behörigheter",
"admin.grants.none": "Ingen installerad plugin deklarerar någon behörighet, så det finns inget att tilldela.",
"admin.grants.pending": "Medlemmar får detta vid nästa inloggning (upp till 10 minuter).",
"admin.grants.save": "Spara behörigheter",
"admin.grants.selfRevoke": "Du kan inte återkalla dina egna behörigheter — be en annan administratör, så att du inte låser ute dig själv.",
"admin.groups.actions": "Gruppåtgärder", "admin.groups.actions": "Gruppåtgärder",
"admin.groups.addMember": "Lägg till en medlem", "admin.groups.addMember": "Lägg till en medlem",
"admin.groups.allMembers": "Alla användare och grupper är redan medlemmar.", "admin.groups.allMembers": "Alla användare och grupper är redan medlemmar.",
@@ -55,7 +64,7 @@ const messages: AdminMessages = {
"admin.groups.column.name": "Grupp", "admin.groups.column.name": "Grupp",
"admin.groups.create": "Skapa grupp", "admin.groups.create": "Skapa grupp",
"admin.groups.delete": "Radera grupp", "admin.groups.delete": "Radera grupp",
"admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det tar bort gruppen och alla dess medlemskap.", "admin.groups.deleteMessage": "Ta bort gruppen {{name}}? Det går inte att ångra.",
"admin.groups.field.name": "Gruppnamn", "admin.groups.field.name": "Gruppnamn",
"admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.", "admin.groups.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
"admin.groups.filter": "Filtrera grupper", "admin.groups.filter": "Filtrera grupper",
@@ -74,42 +83,12 @@ const messages: AdminMessages = {
"admin.nav.clients": "OAuth2-klienter", "admin.nav.clients": "OAuth2-klienter",
"admin.nav.groups": "Grupper", "admin.nav.groups": "Grupper",
"admin.nav.permissions": "Behörigheter",
"admin.nav.section": "Administration", "admin.nav.section": "Administration",
"admin.nav.users": "Användare", "admin.nav.users": "Användare",
"admin.notFound.message": "Objektet finns inte.", "admin.notFound.message": "Objektet finns inte.",
"admin.notFound.title": "Hittades inte", "admin.notFound.title": "Hittades inte",
"admin.permissions.actions": "Behörighetsåtgärder",
"admin.permissions.allAssigned": "Alla användare och grupper har redan den här behörigheten.",
"admin.permissions.assign": "Tilldela behörigheten",
"admin.permissions.assignAction": "Tilldela",
"admin.permissions.assignTo": "Tilldela till",
"admin.permissions.assignedTo": "Tilldelad till",
"admin.permissions.column.members": "Medlemmar",
"admin.permissions.column.name": "Behörighet",
"admin.permissions.create": "Skapa behörighet",
"admin.permissions.delete": "Radera behörighet",
"admin.permissions.deleteMessage": "Ta bort behörigheten {{name}}? Den återkallas från alla den är tilldelad till.",
"admin.permissions.error.adminUndeletable": "Behörigheten admin kan inte tas bort — det skulle ta bort all administratörsåtkomst.",
"admin.permissions.error.selfRevoke": "Du kan inte återkalla din egen administratörsåtkomst.",
"admin.permissions.effective": "Faktisk åtkomst",
"admin.permissions.effectiveHint": "Alla som har behörigheten — direkt eller via en grupp (uppslaget av Keto).",
"admin.permissions.field.name": "Behörighetens namn",
"admin.permissions.field.nameHint": "Små bokstäver, siffror, bindestreck och understreck.",
"admin.permissions.filter": "Filtrera behörigheter",
"admin.permissions.new": "Ny behörighet",
"admin.permissions.noEffective": "Ingen användare har den här behörigheten ännu.",
"admin.permissions.noMembers": "Inte tilldelad till någon ännu.",
"admin.permissions.pagination": "Sidnavigering för behörigheter",
"admin.permissions.revoke": "Återkalla",
"admin.permissions.searchLabel": "Sök behörigheter",
"admin.permissions.searchPlaceholder": "Sök på behörighetens namn…",
"admin.permissions.title": "Behörigheter",
"admin.permissions.validation.member": "Välj en användare eller grupp att tilldela behörigheten till.",
"admin.permissions.validation.name": "Behörighetsnamn använder små bokstäver, siffror, bindestreck och understreck.",
"admin.unavailable.message": "{{what}} är inte konfigurerat i den här installationen.", "admin.unavailable.message": "{{what}} är inte konfigurerat i den här installationen.",
"admin.unavailable.title": "Administrationen är otillgänglig", "admin.unavailable.title": "Administrationen är otillgänglig",
+62
View File
@@ -0,0 +1,62 @@
// The manifest's own invariants. A route gating on a permission the manifest doesn't declare is
// silent: bootstrap seeds only declared names, so the demo admin would simply 403 on that screen
// with nothing in the logs to explain it. Pin the two halves against each other here.
import assert from "node:assert/strict";
import { test } from "node:test";
import { isValidPermissionName } from "@plainpages/plugin-api";
import manifest from "./plugin.ts";
const routes = manifest.routes ?? [];
const declared = (manifest.permissions ?? []).map((p) => p.name);
test("every route is gated, and gates on a permission the manifest declares", () => {
assert.ok(routes.length > 0);
for (const route of routes) {
assert.equal(route.public, undefined, `${route.method} ${route.path} must not be public`);
assert.ok(route.permission, `${route.method} ${route.path} has no permission`);
assert.ok(declared.includes(route.permission!), `${route.method} ${route.path} gates on undeclared ${route.permission}`);
}
});
test("the manifest declares no permission it never gates on", () => {
const gated = new Set(routes.map((r) => r.permission));
for (const name of declared) assert.ok(gated.has(name), `declared but unused: ${name}`);
});
// A nav permission is a plain string the host matches against the JWT claim: a typo ("user:read")
// passes discovery's shape check and silently hides that menu item forever. Same silent-failure
// class the route checks above close, so close it on the nav side too.
test("every nav permission is one the manifest declares", () => {
const navPermissions: string[] = [];
const walk = (nodes: typeof manifest.nav): void => {
for (const node of nodes ?? []) {
if (node.permission != null) navPermissions.push(node.permission);
walk(node.children);
}
};
walk(manifest.nav);
assert.equal(navPermissions.length, 3);
for (const name of navPermissions) assert.ok(declared.includes(name), `nav gates on undeclared ${name}`);
});
test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => {
for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it
// Three screens × read/write. There is deliberately no `permissions:` pair: permissions are
// declared in plugin code, so holding one is edited on the user or group that holds it.
assert.deepEqual([...declared].sort(), [
"groups:read", "groups:write",
"oauth2-clients:read", "oauth2-clients:write",
"users:read", "users:write",
]);
});
test("GET routes gate on read and mutations on write, so a reader can open a screen but not change it", () => {
// …except a write-intent GET — a create form or a delete-confirm page, which exists only to start a
// write. Those gate on `:write` so a reader is refused there rather than at the submit.
const writeIntent = (path: string): boolean => path.endsWith("/new") || path.endsWith("/delete");
for (const route of routes) {
const action = route.method === "GET" && !writeIntent(route.path) ? "read" : "write";
assert.ok(route.permission?.endsWith(`:${action}`), `${route.method} ${route.path}${route.permission}`);
}
assert.equal(routes.filter((r) => r.method === "GET" && writeIntent(r.path)).length, 6); // 2 per screen
});
+56 -49
View File
@@ -1,65 +1,72 @@
// Admin example plugin: the Users / Groups / Roles / OAuth2-clients screens for running the system. // Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
// These used to ship inside the core; they were extracted here so a fresh clone has no built-in admin // this folder to plugins/admin (then restart) to enable it — see README → Quick start.
// GUI. Copy this folder to plugins/admin (then restart) to enable it — see README → Quick start.
// //
// It is a *system* plugin: its handlers reach the host's Ory admin clients (Kratos/Keto/Hydra) and the // It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke
// instant-revoke hook via ctx.system, which the host populates when those services are wired (the dev // hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
// stack wires all of them). Where a capability is absent the screen degrades to a themed 503.
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api"; import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api";
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts"; import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts"; import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-permissions.ts"; import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts"; import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts";
// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor // One route factory per screen: a GET gates on `<resource>:read` and a POST on `<resource>:write`,
// to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are // derived through the same two helpers the in-handler guard uses, so the table below cannot drift
// thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style. // from it. The host redirects an anonymous visitor to /login, gives a signed-in user missing the
const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION }); // permission the 403 page, and filters the nav the same way. Handlers are thin and keyed on
// ctx.params (the host extracts :id / :name), the idiomatic per-route style.
// `action` overrides the method's default for a *write-intent GET* — a create form or a
// delete-confirm page, which exists only to start a write and so refuses a reader rather than
// rendering a form whose submit would 403. The handler's own guard takes the same override.
const on = (resource: AdminResource) => (method: HttpMethod, path: string, handler: RouteHandler, action?: AdminAction): Route =>
({ handler, method, path, permission: permissionName(resource, action ?? actionForMethod(method)) });
const users = on("users");
const groups = on("groups");
const clients = on("oauth2-clients");
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV], nav: [ADMIN_NAV],
permissions: [{ description: "Administer users, groups, permissions, and OAuth2 clients", name: ADMIN_PERMISSION }], permissions: [
{ description: "View users and the permissions they hold", name: "users:read" },
{ description: "Create, edit and delete users, and grant them permissions", name: "users:write" },
{ description: "View groups, their members and the permissions they hold", name: "groups:read" },
{ description: "Create and delete groups, and change their members and permissions", name: "groups:write" },
{ description: "View OAuth2 clients", name: "oauth2-clients:read" },
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
],
routes: [ routes: [
// Users // Users
r("GET", "/users", usersList), users("GET", "/users", usersList),
r("POST", "/users", usersCreate), users("POST", "/users", usersCreate),
r("GET", "/users/new", usersNewForm), users("GET", "/users/new", usersNewForm, "write"),
r("GET", "/users/:id", usersEditForm), users("GET", "/users/:id", usersEditForm),
r("POST", "/users/:id", usersUpdate), users("POST", "/users/:id", usersUpdate),
r("POST", "/users/:id/state", usersState), users("POST", "/users/:id/state", usersState),
r("GET", "/users/:id/delete", usersDeleteConfirm), users("GET", "/users/:id/delete", usersDeleteConfirm, "write"),
r("POST", "/users/:id/delete", usersDelete), users("POST", "/users/:id/delete", usersDelete),
r("POST", "/users/:id/recovery", usersRecovery), users("POST", "/users/:id/recovery", usersRecovery),
users("POST", "/users/:id/permissions", usersPermissions),
// Groups // Groups
r("GET", "/groups", groupsList), groups("GET", "/groups", groupsList),
r("POST", "/groups", groupsCreate), groups("POST", "/groups", groupsCreate),
r("GET", "/groups/new", groupsNewForm), groups("GET", "/groups/new", groupsNewForm, "write"),
r("GET", "/groups/:name", groupsDetail), groups("GET", "/groups/:name", groupsDetail),
r("POST", "/groups/:name/members", groupsAddMember), groups("POST", "/groups/:name/members", groupsAddMember),
r("GET", "/groups/:name/delete", groupsDeleteConfirm), groups("GET", "/groups/:name/delete", groupsDeleteConfirm, "write"),
r("POST", "/groups/:name/delete", groupsDelete), groups("POST", "/groups/:name/delete", groupsDelete),
r("POST", "/groups/:name/members/delete", groupsRemoveMember), groups("POST", "/groups/:name/members/delete", groupsRemoveMember),
// Roles groups("POST", "/groups/:name/permissions", groupsPermissions),
r("GET", "/permissions", rolesList),
r("POST", "/permissions", rolesCreate),
r("GET", "/permissions/new", rolesNewForm),
r("GET", "/permissions/:name", rolesDetail),
r("POST", "/permissions/:name/members", rolesAddMember),
r("GET", "/permissions/:name/delete", rolesDeleteConfirm),
r("POST", "/permissions/:name/delete", rolesDelete),
r("POST", "/permissions/:name/members/delete", rolesRemoveMember),
// OAuth2 clients // OAuth2 clients
r("GET", "/clients", clientsList), clients("GET", "/clients", clientsList),
r("POST", "/clients", clientsCreate), clients("POST", "/clients", clientsCreate),
r("GET", "/clients/new", clientsNewForm), clients("GET", "/clients/new", clientsNewForm, "write"),
r("GET", "/clients/:id", clientsDetail), clients("GET", "/clients/:id", clientsDetail),
r("GET", "/clients/:id/delete", clientsDeleteConfirm), clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"),
r("POST", "/clients/:id/delete", clientsDelete), clients("POST", "/clients/:id/delete", clientsDelete),
], ],
}); });
@@ -3,7 +3,7 @@
shell. Doubles as the post-register page when `created`/`secret` are set. shell. Doubles as the post-register page when `created`/`secret` are set.
%><% %><%
const nav = include("partials/nav-tree", { nodes: chrome.nav }); const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/client-detail-body", { client: model.client, created: model.created, csrfToken: chrome.csrfToken, del: model.delete, secret: model.secret }); const body = include("partials/client-detail-body", { canWrite: model.canWrite, client: model.client, created: model.created, csrfToken: chrome.csrfToken, del: model.delete, secret: model.secret });
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
body, body,
+3 -2
View File
@@ -1,12 +1,13 @@
<%# <%#
OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as OAuth2 clients admin list: apps that log in *through* us (Hydra). Same building blocks as
the Permissions screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts). the Groups screen, around the shell, backed by live Hydra OAuth2 clients (admin-clients.ts).
%><% %><%
const nav = include("partials/nav-tree", { nodes: chrome.nav }); const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar); const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table); const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination); const pager = include("partials/pagination", model.pagination);
const actions = '<a class="btn btn-primary" href="' + localeHref("/admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>'; // Only offer "Register client" to an oauth2-clients:write holder — a :read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/clients/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.clients.registerClient") + '</a>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
@@ -2,7 +2,7 @@
Group admin detail / membership page: the group-detail body in the app shell. Group admin detail / membership page: the group-detail body in the app shell.
%><% %><%
const nav = include("partials/nav-tree", { nodes: chrome.nav }); const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/group-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members }); const body = include("partials/group-detail-body", { add: model.add, canWrite: model.canWrite, csrfToken: model.csrfToken, del: model.delete, error: model.error, group: model.group, members: model.members, permissions: model.permissions });
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
body, body,
+2 -1
View File
@@ -6,7 +6,8 @@
const filters = include("partials/filter-bar", model.filterBar); const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table); const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination); const pager = include("partials/pagination", model.pagination);
const actions = '<a class="btn btn-primary" href="' + localeHref("/admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>'; // Only offer "New group" to a groups:write holder — a groups:read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/groups/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.groups.new") + '</a>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
@@ -31,8 +31,10 @@
<dt><%= t("admin.clients.field.redirectUris") %></dt><dd><% if (c.redirectUris.length) { %><ul class="plain-list"><% c.redirectUris.forEach((u) => { %><li><%= u %></li><% }) %></ul><% } else { %>—<% } %></dd> <dt><%= t("admin.clients.field.redirectUris") %></dt><dd><% if (c.redirectUris.length) { %><ul class="plain-list"><% c.redirectUris.forEach((u) => { %><li><%= u %></li><% }) %></ul><% } else { %>—<% } %></dd>
</dl> </dl>
</section> </section>
<% if (locals.canWrite !== false) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.clients.title") %>"> <section class="form-card admin-actions" aria-label="<%= t("admin.clients.title") %>">
<p class="field-hint"><%= t("admin.clients.rereg") %></p> <p class="field-hint"><%= t("admin.clients.rereg") %></p>
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a> <a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.clients.delete") %></a>
</section> </section>
<% } -%>
</div> </div>
@@ -21,13 +21,14 @@
<% if (members.rows.length) { -%> <% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: group.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody> <div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: group.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%> <% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= localeHref(members.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("common.remove") %></button></form></td></tr> <tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><% if (locals.canWrite !== false) { %><form method="post" action="<%= localeHref(members.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("common.remove") %></button></form><% } %></td></tr>
<% }) -%> <% }) -%>
</tbody></table></div> </tbody></table></div>
<% } else { -%> <% } else { -%>
<p class="cell-muted"><%= t("admin.groups.noMembers") %></p> <p class="cell-muted"><%= t("admin.groups.noMembers") %></p>
<% } -%> <% } -%>
</section> </section>
<% if (locals.canWrite !== false) { -%>
<section class="form-card" aria-labelledby="add-h"> <section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2> <h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2>
<% if (add.options.length) { -%> <% if (add.options.length) { -%>
@@ -36,7 +37,13 @@
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p> <p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
<% } -%> <% } -%>
</section> </section>
<% } -%>
<% if (locals.permissions) { -%>
<%- include("partials/permission-picker", { csrfToken: csrf, permissions: locals.permissions }) %>
<% } -%>
<% if (locals.canWrite !== false) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>"> <section class="form-card admin-actions" aria-label="<%= t("admin.groups.actions") %>">
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a> <a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.groups.delete") %></a>
</section> </section>
<% } -%>
</div> </div>
@@ -1,57 +0,0 @@
<%#
Admin permission detail body, captured into the shell content slot. Config:
permission { name }
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = revoke endpoint
effective { label }[] users who hold the permission (expand)
add { action, options: {label,value}[] } action = assign endpoint
del { action } delete the whole permission
csrfToken, error?
%><%
const permission = locals.permission;
const members = locals.members;
const effective = locals.effective;
const add = locals.add;
const del = locals.del;
const csrf = locals.csrfToken;
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<section class="form-card" aria-labelledby="members-h">
<h2 class="card-title" id="members-h"><%= t("admin.permissions.assignedTo") %></h2>
<% if (members.rows.length) { -%>
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: permission.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody>
<% members.rows.forEach((m) => { -%>
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= localeHref(members.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("admin.permissions.revoke") %></button></form></td></tr>
<% }) -%>
</tbody></table></div>
<% } else { -%>
<p class="cell-muted"><%= t("admin.permissions.noMembers") %></p>
<% } -%>
</section>
<section class="form-card" aria-labelledby="effective-h">
<h2 class="card-title" id="effective-h"><%= t("admin.permissions.effective") %></h2>
<p class="field-hint"><%= t("admin.permissions.effectiveHint") %></p>
<% if (effective.length) { -%>
<ul class="plain-list">
<% effective.forEach((u) => { -%>
<li><span class="cell-strong"><%= u.label %></span></li>
<% }) -%>
</ul>
<% } else { -%>
<p class="cell-muted"><%= t("admin.permissions.noEffective") %></p>
<% } -%>
</section>
<section class="form-card" aria-labelledby="add-h">
<h2 class="card-title" id="add-h"><%= t("admin.permissions.assign") %></h2>
<% if (add.options.length) { -%>
<form class="inline-form" method="post" action="<%= localeHref(add.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("admin.permissions.assignAction") %></button></form>
<% } else { -%>
<p class="cell-muted"><%= t("admin.permissions.allAssigned") %></p>
<% } -%>
</section>
<section class="form-card admin-actions" aria-label="<%= t("admin.permissions.actions") %>">
<a class="btn btn-danger" href="<%= localeHref(del.action) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.permissions.delete") %></a>
</section>
</div>
@@ -1,26 +0,0 @@
<%#
Admin permission create form body, captured into the shell content slot. Config:
form { action, csrfToken, submitLabel, cancelHref, nameField: field.ejs config,
memberOptions: {label,value}[], selectedMember }
error? string shown when a write was rejected
%><%
const form = locals.form;
-%>
<div class="form-page">
<% if (locals.error) { -%>
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
<% } -%>
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
<%- include("partials/field", form.nameField) %>
<div class="field">
<label for="member"><%= t("admin.permissions.assignTo") %></label>
<span class="select"><select id="member" name="member" required><option value="" disabled<% if (!form.selectedMember) { %> selected<% } %>><%= t("admin.common.chooseMember") %></option><% form.memberOptions.forEach((o) => { %><option value="<%= o.value %>"<% if (form.selectedMember === o.value) { %> selected<% } %>><%= o.label %></option><% }) %></select></span>
<span class="field-hint">A permission exists once assigned; add more users or groups after creating it.</span>
</div>
<div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
</div>
</form>
</div>
@@ -0,0 +1,49 @@
<%#
The permission picker, shared by the user-edit and group-detail pages. A fieldset of checkboxes —
one per permission the installed plugins declare — ticked where this user/group holds it. The whole
set posts back, so what is submitted IS the desired set of *direct* grants (see admin-grants.ts).
Two rows never post, by design: an `inherited` one (the grant comes from a group, so it is changed
there) and every row when `readOnly` (the viewer holds :read but not :write). Neither can be diffed
into an accidental revoke, because grantDiff compares against the direct grants only.
Locals: csrfToken, permissions ({ action, choices, empty, error, field, hint, inheritedNote, legend, pending, readOnly, submit }).
%>
<section class="form-card" aria-labelledby="permissions-h">
<h2 class="card-title" id="permissions-h"><%= permissions.legend %></h2>
<% if (permissions.error) { -%>
<%- include("partials/alert", { text: permissions.error, tone: "neg" }) %>
<% } -%>
<% if (permissions.empty) { -%>
<p class="cell-muted"><%= permissions.empty %></p>
<% } else { -%>
<p class="cell-muted"><%= permissions.hint %></p>
<% if (permissions.readOnly) { -%>
<fieldset class="check-group">
<legend class="sr-only"><%= permissions.legend %></legend>
<% permissions.choices.forEach((c) => { -%>
<label class="check"><input type="checkbox"<%= c.checked ? " checked" : "" %> disabled><span><%= c.description || c.name %></span><span class="cell-muted"><%= c.name %></span></label>
<% }) -%>
</fieldset>
<% } else { -%>
<form method="post" action="<%= localeHref(permissions.action) %>">
<input type="hidden" name="_csrf" value="<%= csrfToken %>">
<fieldset class="check-group">
<legend class="sr-only"><%= permissions.legend %></legend>
<% permissions.choices.forEach((c) => { -%>
<label class="check"><input type="checkbox" name="<%= permissions.field %>" value="<%= c.name %>"<%= c.checked ? " checked" : "" %><%= c.inherited ? " disabled" : "" %>><span><%= c.description || c.name %></span><span class="cell-muted"><%= c.name %></span></label>
<% }) -%>
</fieldset>
<div class="form-actions">
<button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><%= permissions.submit %></button>
</div>
</form>
<% } -%>
<% if (permissions.inheritedNote) { -%>
<p class="cell-muted"><%= permissions.inheritedNote %></p>
<% } -%>
<% if (permissions.pending) { -%>
<p class="cell-muted"><%= permissions.pending %></p>
<% } -%>
<% } -%>
</section>
@@ -23,10 +23,15 @@
<% }) -%> <% }) -%>
<div class="form-actions"> <div class="form-actions">
<a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a> <a class="btn" href="<%= localeHref(form.cancelHref) %>"><%= t("common.cancel") %></a>
<% if (locals.canWrite !== false) { -%>
<button class="btn btn-primary" type="submit"><%= form.submitLabel %></button> <button class="btn btn-primary" type="submit"><%= form.submitLabel %></button>
<% } -%>
</div> </div>
</form> </form>
<% if (edit) { -%> <% if (edit && locals.permissions) { -%>
<%- include("partials/permission-picker", { csrfToken: form.csrfToken, permissions: locals.permissions }) %>
<% } -%>
<% if (edit && locals.canWrite !== false) { -%>
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>"> <section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
<form method="post" action="<%= localeHref(edit.recoveryAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form> <form method="post" action="<%= localeHref(edit.recoveryAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
<form method="post" action="<%= localeHref(edit.stateAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form> <form method="post" action="<%= localeHref(edit.stateAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
@@ -1,16 +0,0 @@
<%#
Permission admin detail page: the permission-detail body (members · effective access) in the shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/permission-detail-body", { add: model.add, csrfToken: model.csrfToken, del: model.delete, effective: model.effective, error: model.error, members: model.members, permission: model.permission });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -1,16 +0,0 @@
<%#
Permission admin create page: the permission-form body captured into the app shell.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/permission-form-body", { error: model.error, form: model.form });
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
@@ -1,21 +0,0 @@
<%#
Permissions admin list: the same building blocks as the Groups screen, around the shell, backed
by live Keto Permission subject sets (admin-permissions.ts). Filter/sort/page round-trip the URL.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination);
const actions = '<a class="btn btn-primary" href="' + localeHref("/admin/permissions/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.permissions.new") + '</a>';
-%>
<%- include("partials/shell", {
actions,
body: filters + table + pager,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+1 -1
View File
@@ -2,7 +2,7 @@
Users admin create/edit page: the user-form body captured into the app shell. Users admin create/edit page: the user-form body captured into the app shell.
%><% %><%
const nav = include("partials/nav-tree", { nodes: chrome.nav }); const nav = include("partials/nav-tree", { nodes: chrome.nav });
const body = include("partials/user-form-body", { edit: model.edit, error: model.error, form: model.form, recovery: model.recovery }); const body = include("partials/user-form-body", { canWrite: model.canWrite, edit: model.edit, error: model.error, form: model.form, permissions: model.permissions, recovery: model.recovery });
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
body, body,
+2 -1
View File
@@ -6,7 +6,8 @@
const filters = include("partials/filter-bar", model.filterBar); const filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table); const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination); const pager = include("partials/pagination", model.pagination);
const actions = '<a class="btn btn-primary" href="' + localeHref("/admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>'; // Only offer "New user" to a users:write holder — a users:read one would get the 403 page.
const actions = model.canWrite === false ? "" : '<a class="btn btn-primary" href="' + localeHref("/admin/users/new") + '"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg>' + t("admin.users.new") + '</a>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
+1 -1
View File
@@ -2,7 +2,7 @@
// looked up here first and fall back to the host's, so a plugin owns its words without prefixing // looked up here first and fall back to the host's, so a plugin owns its words without prefixing
// them, and `shifts.count` shows the plural form (host: README → Translating). // them, and `shifts.count` shows the plural form (host: README → Translating).
import type { PluralMessage } from "#plugin-api"; import type { PluralMessage } from "@plainpages/plugin-api";
const messages = { const messages = {
"scheduling.field.assignee": "Assignee", "scheduling.field.assignee": "Assignee",
+2 -2
View File
@@ -2,7 +2,7 @@
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this // data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins. // folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
import { definePlugin } from "#plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts"; import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is // The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
@@ -11,7 +11,7 @@ const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstrea
const upstream = createUpstream(upstreamUrl); const upstream = createUpstream(upstreamUrl);
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION
// onBoot runs after discovery, before the server listens: validate the plugin's own config so a // onBoot runs after discovery, before the server listens: validate the plugin's own config so a
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later. // typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
+4 -4
View File
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import test from "node:test"; import test from "node:test";
// Import only from the #plugin-api barrel — the same contract boundary shifts.ts uses (the host may // Import only from the @plainpages/plugin-api barrel — the same contract boundary shifts.ts uses (the host may
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches. // refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api"; import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
import { import {
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput, assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
@@ -18,7 +18,7 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; ve
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts"); const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return { return {
chrome: CHROME, user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {}, chrome: CHROME, declaredPermissions: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url, query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
verifyCsrf: opts.verifyCsrf ?? (() => true), verifyCsrf: opts.verifyCsrf ?? (() => true),
}; };
@@ -51,7 +51,7 @@ test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not
try { try {
const manifest = (await import("./plugin.ts")).default; const manifest = (await import("./plugin.ts")).default;
assert.equal(typeof manifest.hooks?.onBoot, "function"); assert.equal(typeof manifest.hooks?.onBoot, "function");
assert.throws(() => manifest.hooks!.onBoot!(), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud assert.throws(() => manifest.hooks!.onBoot!({}), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud
} finally { } finally {
if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"]; if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"];
else process.env["SCHEDULING_UPSTREAM"] = prev; else process.env["SCHEDULING_UPSTREAM"] = prev;
+2 -2
View File
@@ -5,8 +5,8 @@
// Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as // Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as
// pure functions against a mock upstream with no network (README.md → Local dev & test story). // pure functions against a mock upstream with no network (README.md → Local dev & test story).
// One import from the host's #plugin-api barrel — the stable author surface (see README.md → Building plugins). // One import from the host's @plainpages/plugin-api barrel — the stable author surface (see README.md → Building plugins).
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "#plugin-api"; import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
// The plugin's own English (its catalog, then the host's), for a view model built outside a request: // The plugin's own English (its catalog, then the host's), for a view model built outside a request:
-1
View File
@@ -1 +0,0 @@
{"errors":null,"message":"not found","url":"https://gitea.larvit.se/api/swagger"}
+9 -1
View File
@@ -1,6 +1,14 @@
-- Runs once on first boot (docker-entrypoint-initdb.d), as the POSTGRES_USER. -- Runs once on first boot (docker-entrypoint-initdb.d), as the POSTGRES_USER.
-- One database per Ory service: each owns its schema and runs its own migrations, -- One database per Ory service: each owns its schema and runs its own migrations,
-- so they never collide. The web app never connects here (stateless — see README). -- so they never collide. A plugin's database does not belong here: bootstrap provisions those on
-- every boot, so one dropped in later is picked up too (README → Plugin storage).
CREATE DATABASE kratos; CREATE DATABASE kratos;
CREATE DATABASE keto; CREATE DATABASE keto;
CREATE DATABASE hydra; CREATE DATABASE hydra;
-- Postgres grants CONNECT to PUBLIC by default, so every plugin role could otherwise open the auth
-- plane's databases and read pg_catalog; table data stays protected either way. Ory connects as the
-- POSTGRES_USER, which owns these and keeps its access.
REVOKE CONNECT ON DATABASE kratos FROM PUBLIC;
REVOKE CONNECT ON DATABASE keto FROM PUBLIC;
REVOKE CONNECT ON DATABASE hydra FROM PUBLIC;
+18 -6
View File
@@ -1,16 +1,15 @@
{ {
"name": "plainpages", "name": "plainpages",
"version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "plainpages", "name": "plainpages",
"version": "0.1.0",
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "6.0.1", "ejs": "6.0.1",
"lucide-static": "1.28.0" "lucide-static": "1.33.0",
"postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
@@ -400,11 +399,24 @@
} }
}, },
"node_modules/lucide-static": { "node_modules/lucide-static": {
"version": "1.28.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.28.0.tgz", "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.33.0.tgz",
"integrity": "sha512-dC3VJwRFsjEVX7Iaq4rY88pm7Fi2OmOb8P0WRzXsUMgbt7sCmFX8bLhaDBeNW6JdRjuele+jKqqFaam4yr+Ygg==", "integrity": "sha512-jNGgvTNcLUfVRX4N9PH9pVVTJzoph/BmYmgU838bYBQodkUJL4nAThkuymFz1x3OUYMhJxPndC7rdg1sxOPYKg==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/postgres": {
"version": "3.4.9",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
"license": "Unlicense",
"engines": {
"node": ">=12"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/porsager"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+4 -5
View File
@@ -1,26 +1,25 @@
{ {
"name": "plainpages", "name": "plainpages",
"version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24" "node": ">=24"
}, },
"imports": { "imports": {
"#menu-config": "./src/ui/menu-config.ts", "#menu-config": "./src/ui/menu-config.ts"
"#plugin-api": "./src/plugin-host/plugin-api.ts"
}, },
"scripts": { "scripts": {
"start": "node src/server.ts", "start": "node src/server.ts",
"dev": "node --watch src/server.ts", "dev": "node --watch src/server.ts",
"gen-jwks": "node src/auth/gen-jwks.ts", "gen-jwks": "node src/auth/gen-jwks.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"auto-release/**/*.test.ts\"" "test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"release-tooling/**/*.test.ts\""
}, },
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "6.0.1", "ejs": "6.0.1",
"lucide-static": "1.28.0" "lucide-static": "1.33.0",
"postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
+2
View File
@@ -0,0 +1,2 @@
// Re-export rather than the surface itself: a package's `exports` target may not escape its folder.
export * from "../src/plugin-host/plugin-api.ts";
+6
View File
@@ -0,0 +1,6 @@
{
"name": "@plainpages/plugin-api",
"private": true,
"type": "module",
"exports": "./index.ts"
}
+8
View File
@@ -474,6 +474,14 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.check input, .radio input { width: 15px; height: 15px; accent-color: var(--accent); .check input, .radio input { width: 15px; height: 15px; accent-color: var(--accent);
margin: 0; cursor: pointer; } margin: 0; cursor: pointer; }
.check:hover, .radio:hover { color: var(--text); } .check:hover, .radio:hover { color: var(--text); }
/* A stacked group of .check rows in a <fieldset> — the right element for related checkboxes, but the
UA gives it a groove border, so reset it like .filter-field and .menu-field do. A disabled row is
still readable: it states a fact (a permission held through a group) rather than offering an edit. */
.check-group { border: 0; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.check-group .check { align-items: baseline; }
.check-group .check input:disabled { cursor: default; }
.check-group .check:has(input:disabled) { opacity: .7; cursor: default; }
.check-group .check .cell-muted { margin-left: auto; padding-left: 12px; font-size: var(--fz-xs); }
/* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover] /* popover menu (language picker, profile, row kebab) — a <button popovertarget> and its [popover]
panel, wrapped so the pair is one element in any layout. The browser owns open/close, and the top panel, wrapped so the pair is one element in any layout. The browser owns open/close, and the top
+58
View File
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { checkTagMatchesContract, readHostApiVersion } from "./contract-version.ts";
test("readHostApiVersion pulls the constant out of the real source, and returns null when absent", () => {
const real = readFileSync("src/plugin-host/plugin.ts", "utf8");
assert.match(readHostApiVersion(real) ?? "", /^\d+\.\d+\.\d+$/);
assert.equal(readHostApiVersion('export const SOMETHING_ELSE = "1.0.0";'), null);
});
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
// makes an accidental edit fail here rather than at release time.
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.1.0");
});
test("every author-facing apiVersion sample matches the shipped contract", () => {
// A plugin author copies these; a stale one produces a boot-aborting refuse on first run. The
// examples deliberately write a literal rather than importing the constant (AGENTS.md), so this
// is the only thing keeping the copies honest.
const host = readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")) ?? "";
const [major, minor] = host.split(".");
for (const file of [
"README.md",
"examples/plugins/admin/plugin.ts",
"examples/plugins/scheduling/plugin.ts",
"release-tooling/dockerhub-overview.md.tmpl",
"views/index.ejs",
]) {
const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]);
assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`);
for (const sample of found) {
const [sMajor, sMinor] = (sample ?? "").split(".");
assert.equal(`${sMajor}.${sMinor}`, `${major}.${minor}`, `${file} samples apiVersion ${sample}, host is ${host}`);
}
}
});
test("checkTagMatchesContract: major.minor must agree, patch may lag", () => {
assert.equal(checkTagMatchesContract("v0.1.0", "0.1.0").ok, true);
assert.equal(checkTagMatchesContract("v0.1.7", "0.1.0").ok, true); // auto-release cut patches
assert.equal(checkTagMatchesContract("0.1.0", "0.1.0").ok, true); // bare tag, no v
assert.equal(checkTagMatchesContract("v0.2.0", "0.1.0").ok, false); // plugin-visible, needs a bump
assert.equal(checkTagMatchesContract("v1.0.0", "0.1.0").ok, false);
});
test("checkTagMatchesContract names what to fix rather than just failing", () => {
const res = checkTagMatchesContract("v0.2.0", "0.1.0");
assert.equal(res.ok, false);
assert.match(res.ok === false ? res.error : "", /HOST_API_VERSION to 0\.2\.0/);
});
test("checkTagMatchesContract rejects junk on either side without throwing", () => {
assert.equal(checkTagMatchesContract("v0.1.0", null).ok, false); // constant not found
assert.equal(checkTagMatchesContract("nope", "0.1.0").ok, false);
assert.equal(checkTagMatchesContract("v0.1.0", "1.0").ok, false);
});
+49
View File
@@ -0,0 +1,49 @@
import { readFileSync } from "node:fs";
export type ContractCheck = { ok: true } | { ok: false; error: string };
const SEMVER = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
export function readHostApiVersion(source: string): string | null {
return /^export const HOST_API_VERSION = "([^"]+)";/m.exec(source)?.[1] ?? null;
}
// Patch is deliberately not compared: checkApiVersion ignores it, and auto-release cuts patch
// releases with no commit to bump the constant in.
export function checkTagMatchesContract(tag: string, hostApiVersion: string | null): ContractCheck {
if (hostApiVersion === null) {
return { error: "HOST_API_VERSION not found", ok: false };
}
const t = SEMVER.exec(tag);
if (!t) return { error: `tag must be vX.Y.Z, got ${JSON.stringify(tag)}`, ok: false };
const h = SEMVER.exec(hostApiVersion);
if (!h) return { error: `HOST_API_VERSION must be X.Y.Z, got ${JSON.stringify(hostApiVersion)}`, ok: false };
if (t[1] === h[1] && t[2] === h[2]) return { ok: true };
return {
error:
`${tag} does not match HOST_API_VERSION ${hostApiVersion} — the contract version IS the release ` +
`version. Set HOST_API_VERSION to ${t[1]}.${t[2]}.0 in src/plugin-host/plugin.ts, merge that, ` +
"then tag.",
ok: false,
};
}
// CLI: node release-tooling/contract-version.ts <tag> <path/to/plugin.ts | -> → exits 1 on
// mismatch. `-` reads the source on stdin, so a caller checking a ref other than its checkout
// (`git show origin/main:… | …`) needs no scratch file in the workspace.
if (process.argv[1]?.endsWith("/contract-version.ts")) {
const [, , tag, pluginPath = "src/plugin-host/plugin.ts"] = process.argv;
let source = "";
try {
source = readFileSync(pluginPath === "-" ? 0 : pluginPath, "utf8");
} catch (err) {
process.stderr.write(`${pluginPath}: ${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
}
const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source));
if (!result.ok) {
process.stderr.write(`${pluginPath}: ${result.error}\n`);
process.exit(1);
}
process.stdout.write(`${tag} matches HOST_API_VERSION\n`);
}
@@ -2,14 +2,15 @@
A self-hostable foundation for server-rendered web apps — public or gated pages from a A self-hostable foundation for server-rendered web apps — public or gated pages from a
zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in. zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in.
Every domain feature is a drop-in plugin folder; the app is stateless, no build step. Every domain feature is a drop-in plugin folder, with a Postgres database of its own if it wants
one; the host itself is stateless, and there is no build step.
**Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>** **Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>**
([GitHub mirror](https://github.com/larvit/plainpages)) ([GitHub mirror](https://github.com/larvit/plainpages))
## Tags ## Tags
`X.Y.Z` · `X.Y` · `X` · `latest` — each is a release promoted from a CI-gated build. `X.Y.Z` · `X.Y` · `latest` — each is a release promoted from a CI-gated build.
Pin the exact `X.Y.Z` you deploy. Pin the exact `X.Y.Z` you deploy.
## Quick start ## Quick start
@@ -21,7 +22,7 @@ so there is nothing to clone. In an empty directory, save this as `compose.yml`:
```yaml ```yaml
services: services:
web: web:
image: larvit/plainpages:0.0.2 image: larvit/plainpages:{{VERSION}}
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
@@ -40,7 +41,7 @@ services:
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user. # One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
bootstrap: bootstrap:
image: larvit/plainpages:0.0.2 image: larvit/plainpages:{{VERSION}}
command: node src/auth/bootstrap.ts command: node src/auth/bootstrap.ts
depends_on: depends_on:
kratos: kratos:
@@ -53,7 +54,7 @@ services:
restart: "on-failure:5" restart: "on-failure:5"
postgres: postgres:
image: postgres:18.4-alpine3.23 image: postgres:18.6-alpine3.23
environment: environment:
POSTGRES_DB: ory POSTGRES_DB: ory
POSTGRES_PASSWORD: ory POSTGRES_PASSWORD: ory
@@ -130,7 +131,7 @@ services:
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025 # Catches Kratos' recovery/verification emails — UI on http://localhost:8025
mailpit: mailpit:
image: axllent/mailpit:v1.30.1 image: axllent/mailpit:v1.31.0
ports: ports:
- "8025:8025" - "8025:8025"
restart: unless-stopped restart: unless-stopped
@@ -142,7 +143,7 @@ volumes:
Extract the Ory config the image ships, then start: Extract the Ory config the image ships, then start:
```bash ```bash
docker run --rm larvit/plainpages:0.0.2 tar -cf - ory | tar -xf - docker run --rm larvit/plainpages:{{VERSION}} tar -cf - ory | tar -xf -
mkdir -p plugins mkdir -p plugins
docker compose up -d docker compose up -d
``` ```
@@ -178,10 +179,10 @@ Everything domain-specific is a plugin folder — the compose above mounts `./pl
into the app. Create `plugins/hello/plugin.ts`: into the app. Create `plugins/hello/plugin.ts`:
```ts ```ts
import { definePlugin } from "#plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", apiVersion: "0.1.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [ routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) }, { method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { jwtFrom, leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts";
const TEMPLATE = "release-tooling/dockerhub-overview.md.tmpl";
const template = () => readFileSync(TEMPLATE, "utf8");
test("renderOverview substitutes every occurrence, not just the first", () => {
const out = renderOverview("pull a:{{VERSION}} then b:{{VERSION}}", "1.2.3");
assert.equal(out, "pull a:1.2.3 then b:1.2.3");
});
test("leftoverPlaceholders catches a typo'd placeholder, deduped, and passes clean text", () => {
assert.deepEqual(leftoverPlaceholders("a {{VERISON}} b {{VERISON}}"), ["{{VERISON}}"]);
assert.deepEqual(leftoverPlaceholders(renderOverview("x {{VERSION}}", "0.1.0")), []);
});
test("the real template renders clean, and the release owns its own image tag", () => {
const rendered = renderOverview(template(), "9.9.9");
assert.deepEqual(leftoverPlaceholders(rendered), []);
assert.match(rendered, /larvit\/plainpages:9\.9\.9/); // the placeholder actually reaches the examples
assert.doesNotMatch(rendered, /larvit\/plainpages:\d+\.\d+\.\d+(?<!9\.9\.9)/);
});
test("the quick start's sidecars are pinned to the same versions this repo runs", () => {
// The page is published automatically, so a drifted pin here ships a topology CI never tested.
const pins = (source: string) =>
new Map([...source.matchAll(/image: ([^:\s]+):(v?\d\S*)/g)].map((m) => [m[1] ?? "", m[2] ?? ""]));
const ours = new Map([
...pins(readFileSync("compose.override.yml", "utf8")),
...pins(readFileSync("compose.yml", "utf8")), // production wins: the template is the prod quick start
]);
const published = pins(template());
assert.ok(published.size > 0, "the template should pin sidecars");
for (const [image, tag] of published) {
assert.equal(tag, ours.get(image), `${TEMPLATE} pins ${image}:${tag}, this repo runs ${ours.get(image)}`);
}
});
test("jwtFrom accepts only a non-empty string token, never throwing on a hostile body", () => {
assert.equal(jwtFrom({ token: "abc" }), "abc");
assert.equal(jwtFrom(null), null); // valid JSON, and the shape a proxy can return
assert.equal(jwtFrom("<html>rate limited</html>"), null);
assert.equal(jwtFrom({}), null);
assert.equal(jwtFrom({ token: "" }), null);
assert.equal(jwtFrom({ token: 42 }), null);
});
+104
View File
@@ -0,0 +1,104 @@
// Publishes the Docker Hub repository overview from dockerhub-overview.md.tmpl, rendering
// `{{VERSION}}` to the release being published.
import { readFileSync } from "node:fs";
import { join } from "node:path";
const HUB = "https://hub.docker.com/v2";
const TIMEOUT_MS = 30_000;
const VERSION = /^\d+\.\d+\.\d+$/;
export function renderOverview(source: string, version: string): string {
return source.replaceAll("{{VERSION}}", version);
}
// A typo'd placeholder would publish literal braces to a public page, so fail the release instead.
export function leftoverPlaceholders(rendered: string): string[] {
return [...new Set(rendered.match(/\{\{[^}]*\}\}/g) ?? [])];
}
export function jwtFrom(body: unknown): string | null {
if (typeof body !== "object" || body === null || !("token" in body)) return null;
return typeof body.token === "string" && body.token !== "" ? body.token : null;
}
type Fetched = { error: string } | { json: unknown; ok: boolean; status: number; text: string };
// fetch and its body readers throw; this is the one edge that converts that into a value.
async function post(url: string, init: RequestInit): Promise<Fetched> {
try {
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
const text = await res.text();
let json: unknown = null;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return { json, ok: res.ok, status: res.status, text };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
async function main(): Promise<number> {
const fail = (message: string): number => {
process.stderr.write(`${message}\n`);
return 1;
};
const [, , version] = process.argv;
const repo = process.env["DOCKERHUB_REPO"];
const user = process.env["DOCKERHUB_USER"];
const token = process.env["DOCKERHUB_TOKEN"];
if (!version || !repo || !user || !token) {
return fail(
"usage: dockerhub-overview.ts <X.Y.Z>; needs DOCKERHUB_REPO, DOCKERHUB_USER and " +
"DOCKERHUB_TOKEN (README -> CI/CD)",
);
}
// The page is public, so never render a version that resolves to no image.
if (!VERSION.test(version)) return fail(`version must be X.Y.Z, got ${JSON.stringify(version)}`);
const templatePath = join(import.meta.dirname, "dockerhub-overview.md.tmpl");
let template = "";
try {
template = readFileSync(templatePath, "utf8");
} catch (err) {
return fail(`${templatePath}: ${err instanceof Error ? err.message : String(err)}`);
}
const body = renderOverview(template, version);
const leftover = leftoverPlaceholders(body);
if (leftover.length > 0) return fail(`${templatePath} has unrendered placeholders: ${leftover.join(", ")}`);
const login = await post(`${HUB}/users/login`, {
body: JSON.stringify({ password: token, username: user }),
headers: { "content-type": "application/json" },
method: "POST",
});
if ("error" in login) return fail(`Docker Hub login unreachable: ${login.error}`);
if (!login.ok) return fail(`Docker Hub login failed: ${login.status} ${login.text}`);
const jwt = jwtFrom(login.json);
if (!jwt) return fail("Docker Hub login returned no token");
const res = await post(`${HUB}/repositories/${repo}/`, {
body: JSON.stringify({ full_description: body }),
headers: { authorization: `Bearer ${jwt}`, "content-type": "application/json" },
method: "PATCH",
});
if ("error" in res) return fail(`Docker Hub unreachable: ${res.error}`);
if (!res.ok) {
return fail(
`Docker Hub overview PATCH failed: ${res.status} ${res.text}` +
(res.status === 403
? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " +
"read/write/delete, which pushing images alone does not (README -> CI/CD)."
: ""),
);
}
process.stdout.write(`Docker Hub overview updated for ${repo} at ${version}\n`);
return 0;
}
if (process.argv[1]?.endsWith("/dockerhub-overview.ts")) {
process.exitCode = await main();
}
@@ -35,6 +35,8 @@ test("nextVersion at/after 1.0.0: literal semver", () => {
assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0"); assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0");
assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0"); assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0");
assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4"); assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4");
// the whole chain: a major dependency bump releases a major host, once the 0.x shift-down is gone
assert.equal(nextVersion("v1.2.3", maxLevel(["patch", "major"])), "v2.0.0");
}); });
test("nextVersion rejects a tag that is not vX.Y.Z", () => { test("nextVersion rejects a tag that is not vX.Y.Z", () => {
@@ -36,7 +36,7 @@ export function nextVersion(latestTag: string, level: Bump): string {
return `v${major}.${minor}.${patch + 1}`; return `v${major}.${minor}.${patch + 1}`;
} }
// CLI: node auto-release/next-version.ts <latestTag> [updateType...] → prints the next tag. // CLI: node release-tooling/next-version.ts <latestTag> [updateType...] → prints the next tag.
if (process.argv[1]?.endsWith("/next-version.ts")) { if (process.argv[1]?.endsWith("/next-version.ts")) {
const [, , latestTag, ...updateTypes] = process.argv; const [, , latestTag, ...updateTypes] = process.argv;
process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes))); process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes)));
+42 -3
View File
@@ -1,9 +1,41 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"], "extends": ["config:recommended"],
"description": "ignorePaths overrides config:recommended's :ignoreModulesAndTests, which ignores **/examples/** — an example plugin's dependencies get update PRs like any other manifest here",
"ignorePaths": ["**/node_modules/**"],
"automerge": true, "automerge": true,
"commitBody": "Release-Bump: {{{updateType}}}",
"packageRules": [ "packageRules": [
{
"description": "The host's own runtime deps. Release-Bump is opt-in per surface (README → CI/CD): updateType rates the dependency's own jump, not whether it reaches a running Plainpages",
"matchDepTypes": ["dependencies"],
"matchFileNames": ["package.json"],
"matchManagers": ["npm"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "The shipped image's base — e2e-tests/Dockerfile is test-only",
"matchFileNames": ["Dockerfile"],
"matchManagers": ["dockerfile"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "The production topology — compose.override.yml is dev, e2e-tests/compose.*.yml are test",
"matchFileNames": ["compose.yml"],
"matchManagers": ["docker-compose"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "The production sidecars, wherever they are pinned — compose.yml and the published quick start move in one branch, so the trailer must not depend on which upgrade sorts first. mailpit is dev-only and stays out",
"matchDatasources": ["docker"],
"matchPackageNames": ["oryd/hydra", "oryd/keto", "oryd/kratos", "postgres"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "node is pinned to one version across Dockerfile, dev, E2E and CI, so Renovate moves them in a single branch whose commitBody would otherwise depend on upgrade order — the Dockerfile copy ships, so any node bump is a product change",
"matchDatasources": ["docker"],
"matchPackageNames": ["node"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{ {
"description": "Ory services share one release train - update kratos, keto and hydra together", "description": "Ory services share one release train - update kratos, keto and hydra together",
"matchDatasources": ["docker"], "matchDatasources": ["docker"],
@@ -27,8 +59,15 @@
}, },
{ {
"customType": "regex", "customType": "regex",
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, auto-release)", "description": "The published quick start ships a compose file, so its sidecars move with the repo's own pins. The version group starts at a digit, which skips the {{VERSION}} placeholder the release renders",
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/renovate.yml"], "managerFilePatterns": ["release-tooling/dockerhub-overview.md.tmpl"],
"matchStrings": ["image: (?<depName>[^:\\s]+):(?<currentValue>v?\\d[^\\s]*)"],
"datasourceTemplate": "docker"
},
{
"customType": "regex",
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, renovate auto-release, release)",
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/release.yml", ".gitea/workflows/renovate.yml"],
"matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"], "matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"],
"depNameTemplate": "node", "depNameTemplate": "node",
"datasourceTemplate": "docker" "datasourceTemplate": "docker"
+91 -9
View File
@@ -5,7 +5,10 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, seedAdmin, seedPermissions } from "./bootstrap.ts"; import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, provisionPluginStorage, seedAdmin, seedPermissions, serverMismatch } from "./bootstrap.ts";
import { createLogger } from "../logger.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import type { ProvisionOptions, ProvisionResult } from "../plugin-host/storage-provisioning.ts";
const json = (status: number, body?: unknown) => const json = (status: number, body?: unknown) =>
new Response(body === undefined ? null : JSON.stringify(body), { new Response(body === undefined ? null : JSON.stringify(body), {
@@ -30,14 +33,33 @@ test("permissionTuple grants a permission to user:<id> in the Permission namespa
}); });
}); });
test("seedPermissions unions ADMIN_PERMISSIONS (default 'admin') with the discovered plugins' declared permissions", () => { test("seedPermissions unions ADMIN_PERMISSIONS (empty by default) with the discovered plugins' declared permissions", () => {
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two tokens → the demo admin // Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two names → the demo admin
// gets exactly today's behaviour, but derived from discovery, not hardcoded in the host. // holds exactly what the installed plugins gate on, derived from discovery, not hardcoded here.
assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["admin", "scheduling:read", "scheduling:write"]); const names = (env: string | undefined, declared: string[]): string[] => seedPermissions(env, declared).permissions;
assert.deepEqual(seedPermissions(undefined, []), ["admin"]); // no plugins → just the base admin permission assert.deepEqual(names(undefined, ["scheduling:read", "scheduling:write"]), ["scheduling:read", "scheduling:write"]);
assert.deepEqual(seedPermissions("admin, ops ", ["inventory:read"]), ["admin", "ops", "inventory:read"]); // env trimmed + extended // No plugins → nothing to grant. A host-invented base would be a permission that gates nothing.
assert.deepEqual(seedPermissions("admin,scheduling:read", ["scheduling:read"]), ["admin", "scheduling:read"]); // dedup, no double grant assert.deepEqual(names(undefined, []), []);
assert.deepEqual(seedPermissions("admin,, ", [" scheduling:read ", ""]), ["admin", "scheduling:read"]); // blanks dropped, tokens trimmed (both sides) assert.deepEqual(names("ops:read, ops:write ", ["inventory:read"]), ["ops:read", "ops:write", "inventory:read"]); // env trimmed + extended
assert.deepEqual(names("scheduling:read", ["scheduling:read"]), ["scheduling:read"]); // dedup, no double grant
assert.deepEqual(names(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
});
// Bootstrap gates `web`, so it must never refuse to start over operator env — a leftover
// ADMIN_PERMISSIONS would otherwise brick the whole stack. Drop what it can't use, report it, seed
// the rest.
test("seedPermissions drops an ADMIN_PERMISSIONS name that isn't <resource>:<action>, and never throws", () => {
const legacy = seedPermissions("admin", ["users:read"]);
assert.deepEqual(legacy, { ignored: ["admin"], permissions: ["users:read"] });
const mixed = seedPermissions("admin, ops:read ,Bad Name", ["users:read"]);
assert.deepEqual(mixed, { ignored: ["admin", "Bad Name"], permissions: ["ops:read", "users:read"] });
// Whatever an operator puts there, the boot survives it — that is the property, not the parsing.
for (const value of ["admin", "Bad Name", ":", "::", "a".repeat(200), ",,,", "ADMIN", "1"]) {
assert.doesNotThrow(() => seedPermissions(value, ["users:read"]), value);
assert.deepEqual(seedPermissions(value, ["users:read"]).permissions.includes("users:read"), true, value);
}
}); });
test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => { test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => {
@@ -132,3 +154,63 @@ test("ensureJwks generates a key only when the file is absent", () => {
assert.equal(ensureJwks(path, { exists: () => true, write }), false); assert.equal(ensureJwks(path, { exists: () => true, write }), false);
assert.equal(writes.length, 1); // present → nothing written assert.equal(writes.length, 1); // present → nothing written
}); });
// --- Plugin storage provisioning -----------------------------------------------------
// The provisioner is injected, so the branch decisions are testable without a Postgres.
const SILENT = createLogger({ level: "none" });
const storagePlugin = (id: string): Plugin => ({ apiVersion: "1.0.0", id, storage: true });
const EMPTY: ProvisionResult = { orphans: [], provisioned: [] };
function recordingProvisioner(result: ProvisionResult = EMPTY) {
const calls: ProvisionOptions[] = [];
return { calls, provision: async (options: ProvisionOptions) => { calls.push(options); return result; } };
}
test("provisioning is skipped entirely when nothing declares storage and none is configured", async () => {
const { calls, provision } = recordingProvisioner();
await provisionPluginStorage({}, [{ apiVersion: "1.0.0", id: "plain" }], SILENT, provision);
assert.deepEqual(calls, []); // no connection attempted, so an unconfigured stack still boots
});
// Uninstalling the last storage plugin is exactly when a left-behind database needs naming.
test("provisioning still runs with nothing to provision, so orphans are reported", async () => {
const { calls, provision } = recordingProvisioner({ orphans: ["plugin_gone"], provisioned: [] });
await provisionPluginStorage({ PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db:5432/ory" }, [], SILENT, provision);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0]?.pluginIds, []);
});
test("a plugin declaring storage without a provisioning DSN fails loud, naming the plugin", async () => {
const { calls, provision } = recordingProvisioner();
await assert.rejects(
provisionPluginStorage({}, [storagePlugin("things")], SILENT, provision),
/PLUGIN_DB_ADMIN_URL.*things/s,
);
assert.deepEqual(calls, []);
});
test("the connection limit and derived secret reach the provisioner", async () => {
const { calls, provision } = recordingProvisioner();
const env = { PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db:5432/ory", PLUGIN_DB_CONNECTION_LIMIT: "25", PLUGIN_DB_SECRET: "real" };
await provisionPluginStorage(env, [storagePlugin("things")], SILENT, provision);
assert.equal(calls[0]?.connectionLimit, 25);
assert.equal(calls[0]?.secret, "real");
assert.deepEqual(calls[0]?.pluginIds, ["things"]);
});
// bootstrap creates the role on one server; web tells the plugin to connect to another. Left
// unsaid it surfaces inside a plugin as "password authentication failed", naming neither. Warned
// rather than refused: web reaching a pooler bootstrap cannot provision through is legitimate.
test("a storage URL mismatch is reported, and provisioning still runs", async () => {
const { calls, provision } = recordingProvisioner();
const env = { PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db-a:5432/ory", PLUGIN_DB_URL: "postgres://db-b:5432" };
await provisionPluginStorage(env, [storagePlugin("things")], SILENT, provision);
assert.equal(calls.length, 1);
});
test("the same server spelled with an implicit port still agrees", () => {
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", "postgres://db"), null); // 5432 is the default
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", undefined), null); // web's own boot error to raise
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", "postgres://db:6543"), "db:5432 vs db:6543");
});
+87 -16
View File
@@ -2,15 +2,21 @@
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`: // 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); // 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; // 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 — `admin` plus // 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — every
// every discovered plugin's declared permission names, so a dropped-in plugin is usable by // discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so a dropped-in
// the demo admin with no host config edit (the host stays plugin-agnostic). // 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. // Then prints a first-run banner; fails loud on any unexpected upstream error.
import { existsSync, writeFileSync } from "node:fs"; import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts";
import { discoverPlugins } from "../plugin-host/discovery.ts"; import { discoverPlugins } from "../plugin-host/discovery.ts";
import { declaredPermissions, isValidPermissionName, type Plugin } from "../plugin-host/plugin.ts";
import { provisionStorage } from "../plugin-host/storage-provisioning.ts";
import { storagePluginIds } from "../plugin-host/storage.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts"; import { generateJwks, type JwkSet } from "./gen-jwks.ts";
import { createLogger, runWithLog, tracedFetch } from "../logger.ts"; import { createLogger, runWithLog, tracedFetch, type Log } from "../logger.ts";
type Env = Record<string, string | undefined>;
// --- Pure payload builders (the Kratos/Keto request contracts) ----------------------- // --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
@@ -28,13 +34,20 @@ export function permissionTuple(userId: string, permission: string) {
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` }; return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
} }
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, default just `admin`) // ADMIN_PERMISSIONS (empty by default) unioned with every discovered plugin's declared names, so
// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a // the host names no plugin yet a dropped-in one is seeded out of the box.
// coarse permission — granted as a Keto `Permission:<token>#members` tuple). So the host names no plugin, yet a //
// dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped. // ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the
export function seedPermissions(adminRolesEnv: string | undefined, declaredPermissions: string[]): string[] { // same `<resource>:<action>` rule as a manifest — but *dropped with a warning*, never fatal:
// fail-loud belongs at the manifest boundary where a developer authored the mistake, whereas this
// is operator env and bootstrap gates `web`, so the whole stack must not refuse to start over a
// stale variable. The name it would have written gates nothing anyway.
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): { ignored: string[]; permissions: string[] } {
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean); const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
return [...new Set([...clean((adminRolesEnv ?? "admin").split(",")), ...clean(declaredPermissions)])]; const configured = clean((adminPermissionsEnv ?? "").split(","));
const ignored = configured.filter((name) => !isValidPermissionName(name));
const valid = configured.filter((name) => isValidPermissionName(name));
return { ignored, permissions: [...new Set([...valid, ...clean(declaredNames)])] };
} }
// --- JWKS safety net ----------------------------------------------------------------- // --- JWKS safety net -----------------------------------------------------------------
@@ -133,7 +146,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
// --- CLI (the bootstrap container entrypoint) ---------------------------------------- // --- CLI (the bootstrap container entrypoint) ----------------------------------------
async function main() { async function main() {
const env = process.env; 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. // Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME.
const log = createLogger({ const log = createLogger({
format: env["LOG_FORMAT"] === "json" ? "json" : "text", format: env["LOG_FORMAT"] === "json" ? "json" : "text",
@@ -142,11 +155,71 @@ async function main() {
// runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls. // runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls.
await runWithLog(log, async () => { await runWithLog(log, async () => {
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key"); if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
// Discovery imports every plugin module — and its dependencies — into *this* process, which holds
// the credential that may CREATE DATABASE/ROLE. Same move as server.ts, on the stronger secret.
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 log.end(); // flush any pending OTLP spans/logs before the one-shot exits
}
// Seed `admin` (or ADMIN_PERMISSIONS) + every discovered plugin's declared permission names, so the // A database and login role for each plugin that asked for one. It happens here because bootstrap
// holds the stack's only provisioning credentials — web derives the same password and connects as
// the plugin's own role.
export async function provisionPluginStorage(env: Env, plugins: Plugin[], log: Log, provision = provisionStorage): Promise<void> {
const ids = storagePluginIds(plugins);
const adminUrl = env["PLUGIN_DB_ADMIN_URL"];
// Still connect with nothing to provision, as long as storage is configured: uninstalling the
// last storage plugin is exactly when an orphaned database needs naming.
if (ids.length === 0 && !adminUrl) return;
if (!adminUrl) throw new Error(`bootstrap: PLUGIN_DB_ADMIN_URL must be set — these plugins declare storage: ${ids.join(", ")}`);
// Provisioned here, connected to from web: a different server means the role is created in one
// place and looked for in another, surfacing inside a plugin as "password authentication failed".
// Warned, not refused — web reaching a pooler that cannot run CREATE DATABASE is a legitimate split.
const mismatch = serverMismatch(adminUrl, env["PLUGIN_DB_URL"]);
if (mismatch) log.warn("PLUGIN_DB_ADMIN_URL and PLUGIN_DB_URL name different servers", { servers: mismatch });
const result = await provision({
adminUrl,
connectionLimit: resolvePluginDbConnectionLimit(env),
pluginIds: ids,
secret: resolvePluginDbSecret(env),
});
if (result.provisioned.length > 0) log.info("plugin storage provisioned", { databases: result.provisioned.join(", ") });
// Never dropped, so an uninstalled plugin's data outlives it — say so, or nobody can find it.
if (result.orphans.length > 0) {
log.warn("plugin databases no installed plugin claims", { databases: result.orphans.join(", ") });
}
}
// Describes the disagreement, or null when they agree (or when web's URL is unset — that is web's
// own boot error to raise, naming the plugin that wanted storage).
export function serverMismatch(adminUrl: string, webUrl: string | undefined): string | null {
if (!webUrl) return null;
const [admin, web] = [safeHostPort(adminUrl), safeHostPort(webUrl)];
if (admin === null || web === null || admin === web) return null; // a malformed URL fails in config.ts
return `${admin} vs ${web}`;
}
function safeHostPort(url: string): string | null {
try {
const parsed = new URL(url);
return `${parsed.hostname}:${parsed.port || "5432"}`;
} catch {
return null;
}
}
// Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit. // shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.name)); async function seedAdminAndPermissions(env: Env, plugins: Plugin[], log: Log): Promise<void> {
const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared); const declared = declaredPermissions(plugins).map((decl) => decl.name);
const { ignored, permissions } = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
if (ignored.length > 0) {
log.warn("ignoring ADMIN_PERMISSIONS entries that are not <resource>:<action>", { ignored: ignored.join(", ") });
}
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local"; const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
const password = env["ADMIN_PASSWORD"] ?? "admin"; const password = env["ADMIN_PASSWORD"] ?? "admin";
const result = await seedAdmin({ const result = await seedAdmin({
@@ -160,8 +233,6 @@ async function main() {
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") }); 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. // 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: env["APP_URL"] ?? "http://localhost:3000", email, password }));
});
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
} }
if (process.argv[1] === fileURLToPath(import.meta.url)) await main(); if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
+8 -16
View File
@@ -1,20 +1,12 @@
// Optional revocation denylist: instant permission/session revoke without putting Keto // Optional revocation denylist: instant permission/session revoke without putting Keto back on the
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true. // hot path. Off by default — enable with REVOCATION_DENYLIST=true. An admin action records the
// subject as revoked-now; the hot path then rejects that subject's pre-revoke tokens at once,
// forcing a re-mint (which re-reads permissions from Keto, or clears a now-dead session).
// //
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked permission or a // An in-memory, auto-evicting Map — no database, so it stays inside the stateless model. Entries
// killed session only takes effect when the token is next minted (re-login / TTL refresh) — // self-evict after one token TTL, by which point any pre-revoke token has expired anyway.
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised // Single-process: instant on the instance that handled the revoke, elsewhere the guarantee falls
// account) that lag is too long. An admin action records the subject as revoked-now and the // back to the token TTL. Back it with a shared store for hard multi-instance instant-revoke.
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
// re-reads permissions from Keto, or clears a now-dead session).
//
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
// the revoke) passes while every token minted before the revoke is rejected. Entries self-evict
// after one token TTL, by which point any pre-revoke token has expired anyway. Single-process:
// instant on the instance that handled the revoke; across replicas/restarts the guarantee
// falls back to the token TTL (the gap is just no longer closed early). Back it with a shared
// store for hard multi-instance instant-revoke.
export interface Denylist { export interface Denylist {
// Hot-path check: is a token for `sub`, issued at `iat` (unix sec), revoked? A token minted // Hot-path check: is a token for `sub`, issued at `iat` (unix sec), revoked? A token minted
+1
View File
@@ -113,6 +113,7 @@ test("the code field guards a pasted space: one-time-code autofill + numeric inp
); );
assert.deepEqual(view.fields.find((f) => f.name === "code"), { assert.deepEqual(view.fields.find((f) => f.name === "code"), {
autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill
hint: "Digits only — no spaces.", // the pattern refusal alone reads as a bare "match the requested format"
icon: "i-shield", icon: "i-shield",
id: "field-code", id: "field-code",
inputmode: "numeric", inputmode: "numeric",
+2 -1
View File
@@ -11,6 +11,7 @@ import type { Flow, FlowType, UiNode } from "./kratos-public.ts";
export interface FlowField { export interface FlowField {
autocomplete?: string; autocomplete?: string;
error?: { text: string }; error?: { text: string };
hint?: string; // muted helper text under the input
icon?: string; // Lucide sprite id for the input icon?: string; // Lucide sprite id for the input
id: string; id: string;
inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code) inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code)
@@ -139,7 +140,7 @@ function toField(node: UiNode, name: string, type: string, t: Translate): FlowFi
...(autocomplete ? { autocomplete } : {}), ...(autocomplete ? { autocomplete } : {}),
...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}), ...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}),
...(icon ? { icon } : {}), ...(icon ? { icon } : {}),
...(isCode ? { inputmode: "numeric", pattern: "[0-9]*" } : {}), ...(isCode ? { hint: t("auth.field.code.hint"), inputmode: "numeric", pattern: "[0-9]*" } : {}),
...(node.attributes["required"] === true ? { required: true } : {}), ...(node.attributes["required"] === true ? { required: true } : {}),
...(value ? { value } : {}), ...(value ? { value } : {}),
}; };
+3 -5
View File
@@ -1,8 +1,6 @@
// Auth guards: in-handler authorization, the imperative counterpart to the // In-handler authorization, the imperative counterpart to the declarative route `permission` gate.
// declarative route `permission` gate. The middleware already verified the session JWT and put // `requireSession` asserts (throws GuardError, which app.ts maps to a response); `can`/`check` are
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps // predicates a handler branches on. `check` is the one live Keto call, for relationship rules.
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
import type { RequestContext, User } from "../http/context.ts"; import type { RequestContext, User } from "../http/context.ts";
import type { KetoClient } from "./keto-client.ts"; import type { KetoClient } from "./keto-client.ts";
import { localPath } from "../http/safe-url.ts"; import { localPath } from "../http/safe-url.ts";
+2 -4
View File
@@ -231,10 +231,8 @@ function logout(kratos: KratosPublic, secureCookies: boolean): BuiltinRoute["han
} }
// Kratos' self-service error sink (kratos.yml flows.error.ui_url → /error). A flow that fails a // Kratos' self-service error sink (kratos.yml flows.error.ui_url → /error). A flow that fails a
// security/expiry check redirects the browser here with ?id=<uuid>. Render a themed page with a // security/expiry check redirects the browser here with ?id=<uuid>; render a themed page with a
// path back into sign-in instead of the catch-all 404 ("Page not found") it used to hit. The // path back into sign-in rather than the catch-all 404. The id is shown for support reference only.
// canonical-host redirect prevents the common cause (a lost cross-host CSRF cookie); this is the
// honest fallback for any genuine flow error. The id is shown only for support reference.
const errorSink = (ctx: RequestContext): RouteResult => const errorSink = (ctx: RequestContext): RouteResult =>
({ data: { id: ctx.url.searchParams.get("id") }, view: "error" }); ({ data: { id: ctx.url.searchParams.get("id") }, view: "error" });
+8
View File
@@ -29,6 +29,14 @@ test("the commit-hash image is pushed even when the gate no-ops", () => {
assert.doesNotMatch(step("docker push"), /^\s*if:/m); assert.doesNotMatch(step("docker push"), /^\s*if:/m);
}); });
test("every E2E suite the gate runs writes its artifacts as the invoking user", () => {
// The documented hand-run commands carry the same flag (src/compose.test.ts).
const runs = gate.split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
assert.equal(runs.length, 2, "the suite helper and the devstack run");
for (const line of runs)
assert.match(line, /--user "\$\(id -u\):\$\(id -g\)"/, `runs as the caller: ${line.trim()}`);
});
test("only *.md counts as docs; a dirty tree and a rename both count as changed", () => { test("only *.md counts as docs; a dirty tree and a rename both count as changed", () => {
assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test"); assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test");
assert.match(gate, /git status --porcelain --no-renames/, "uncommitted code and a staged rename can never be skipped over"); assert.match(gate, /git status --porcelain --no-renames/, "uncommitted code and a staged rename can never be skipped over");
+55 -4
View File
@@ -6,9 +6,13 @@
// by running the stack; this catches edits. // by running the stack; this catches edits.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { existsSync, readFileSync, readdirSync } from "node:fs";
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8"); const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
const composeFiles = (dir: string) =>
readdirSync(new URL(`../${dir}`, import.meta.url))
.filter((f) => f.startsWith("compose.") && f.endsWith(".yml"))
.map((f) => `${dir}${f}`);
const compose = read("compose.yml"); const compose = read("compose.yml");
const override = read("compose.override.yml"); const override = read("compose.override.yml");
const visual = read("e2e-tests/compose.visual.yml"); const visual = read("e2e-tests/compose.visual.yml");
@@ -40,10 +44,11 @@ test("long-running Ory services declare readiness healthchecks", () => {
`${svc} probes :${port}/health/ready`); `${svc} probes :${port}/health/ready`);
}); });
test("web waits for kratos, keto and hydra to be healthy before starting", () => { test("web waits for kratos, keto, hydra and postgres to be healthy before starting", () => {
assert.match(webBlock, /depends_on:/, "web declares dependencies"); assert.match(webBlock, /depends_on:/, "web declares dependencies");
// hydra: the OAuth2 login/consent handler talks to its admin API. // hydra: the OAuth2 login/consent handler talks to its admin API. postgres: a plugin declaring
for (const svc of ["kratos", "keto", "hydra"]) // `storage` opens its connection in onBoot, before the server listens.
for (const svc of ["kratos", "keto", "hydra", "postgres"])
assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`), assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`),
`web waits for ${svc} healthy`); `web waits for ${svc} healthy`);
}); });
@@ -74,6 +79,21 @@ test("prod base supplies the app secret via env and mounts no source; dev overri
assert.match(compose, /POSTGRES_PASSWORD:\s*\$\{POSTGRES_PASSWORD\b/, "postgres password via env"); assert.match(compose, /POSTGRES_PASSWORD:\s*\$\{POSTGRES_PASSWORD\b/, "postgres password via env");
}); });
test("the provisioning superuser DSN reaches bootstrap only, never web", () => {
// web runs plugin code, which can read its own environment — so the credentials that may CREATE
// DATABASE/ROLE must never be there. web gets the credential-free base URL and derives each
// plugin's own password from the shared secret instead.
const boot = compose.slice(compose.indexOf("\n bootstrap:"));
const overrideWeb = override.slice(override.indexOf("\n web:"), override.indexOf("\n bootstrap:"));
assert.match(boot, /PLUGIN_DB_ADMIN_URL:/, "bootstrap is given the superuser DSN");
// Reordering the override's services would empty this slice, and every doesNotMatch below would
// then pass against "".
assert.ok(overrideWeb.includes("PLUGIN_DB_URL"), "sliced the dev override's web block");
for (const [name, block] of [["base", webBlock], ["dev override", overrideWeb]] as const)
assert.doesNotMatch(block, /PLUGIN_DB_ADMIN_URL/, `${name} web never sees it`);
assert.match(webBlock, /PLUGIN_DB_URL:\s*\$\{PLUGIN_DB_URL/, "base wires web's base URL from env");
});
test("a one-shot bootstrap seeds the stack before web starts", () => { test("a one-shot bootstrap seeds the stack before web starts", () => {
// MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin + // MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin +
// JWKS, then exits; web waits for it to complete. Live seeding is boot-verified. // JWKS, then exits; web waits for it to complete. Live seeding is boot-verified.
@@ -89,6 +109,37 @@ test("a one-shot bootstrap seeds the stack before web starts", () => {
"web waits for bootstrap to finish"); "web waits for bootstrap to finish");
}); });
test("deps live above WORKDIR, so no mount creates a root-owned dir in the checkout", () => {
// A volume at /app/node_modules would leave a root-owned dir in the checkout (AGENTS.md).
const dockerfile = read("Dockerfile");
// split() returns the whole file when the marker is missing, widening "before" to "anywhere".
assert.ok(dockerfile.includes("WORKDIR /app"), "the app dir is /app");
const beforeWorkdir = dockerfile.split("WORKDIR /app")[0]!;
assert.match(beforeWorkdir, /npm ci/, "npm ci runs before WORKDIR /app");
assert.match(beforeWorkdir, /mv\s+node_modules\s+\/node_modules/, "and its tree lands at /node_modules");
for (const f of [...composeFiles(""), ...composeFiles("e2e-tests/")])
assert.ok(!read(f).includes("/app/node_modules"), `${f} mounts nothing at /app/node_modules`);
});
test("the E2E runner writes its artifacts as the invoking user, never as root", () => {
// Same trap as the node_modules mountpoint above, but the runner must write into the checkout,
// so the fix is the uid: root-owned output needs sudo to delete, which a dev box may not have.
// Matched independently of flag order, and counted: a reordered flag that slips out of the
// filter would otherwise leave that command silently unguarded.
const documented = [read("README.md"), ...composeFiles("e2e-tests/").map(read)]
.join("\n").split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
assert.equal(documented.length, 6, "5 compose headers + 1 README block");
for (const l of documented)
assert.match(l, /--user "\$\(id -u\):\$\(id -g\)"/, `passes the uid: ${l.trim()}`);
// An absent mount source is daemon-created as root, and then that uid can't write it at all.
assert.ok(existsSync(new URL("../e2e-tests/artifacts/.gitkeep", import.meta.url)),
"the mount point exists in the checkout");
const gitignore = read(".gitignore");
assert.match(gitignore, /^\/e2e-tests\/artifacts\/\*$/m, "its output stays ignored");
assert.match(gitignore, /^!\/e2e-tests\/artifacts\/\.gitkeep$/m, "the mount point stays tracked");
});
test("the visual E2E does not drag in the Ory stack", () => { test("the visual E2E does not drag in the Ory stack", () => {
// web's Ory deps are reset for E2E (the dashboard is mock data — no Ory needed). // web's Ory deps are reset for E2E (the dashboard is mock data — no Ory needed).
assert.match(visual, /depends_on:\s*!reset\b/, "E2E resets web's depends_on"); assert.match(visual, /depends_on:\s*!reset\b/, "E2E resets web's depends_on");
+38 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { loadConfig } from "./config.ts"; import { loadConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "./config.ts";
// Explicit secure-secret enforcement (no environment sniffing): secrets are the only // Explicit secure-secret enforcement (no environment sniffing): secrets are the only
// thing a hardened deploy must supply. // thing a hardened deploy must supply.
@@ -9,6 +9,43 @@ const secureEnv = {
REQUIRE_SECURE_SECRETS: "true", REQUIRE_SECURE_SECRETS: "true",
}; };
// web reads the secret through loadConfig and bootstrap through resolvePluginDbSecret; the two
// deriving different passwords is invisible until a plugin's connection is refused at boot. Compose
// passes an unset variable through as "", which is the case that actually drifted.
test("web and bootstrap resolve the same plugin storage secret", () => {
for (const env of [{}, { PLUGIN_DB_SECRET: "" }, { PLUGIN_DB_SECRET: "a-real-secret" }]) {
assert.equal(loadConfig(env).pluginDbSecret, resolvePluginDbSecret(env), `for ${JSON.stringify(env)}`);
}
assert.match(resolvePluginDbSecret({ PLUGIN_DB_SECRET: "" }), /dev-insecure/); // empty is unset, not a secret
});
// bootstrap writes these passwords into Postgres, so it must refuse the publicly-known throwaway
// before creating a role with one — not leave web to notice afterwards.
test("bootstrap refuses a missing, empty or throwaway plugin storage secret when hardened", () => {
const hardened = { REQUIRE_SECURE_SECRETS: "true" };
for (const secret of [undefined, "", "dev-insecure-plugin-db-secret"]) {
const env = secret === undefined ? hardened : { ...hardened, PLUGIN_DB_SECRET: secret };
assert.throws(() => resolvePluginDbSecret(env), /PLUGIN_DB_SECRET/, `for ${JSON.stringify(secret)}`);
}
assert.equal(resolvePluginDbSecret({ ...hardened, PLUGIN_DB_SECRET: "a-real-secret" }), "a-real-secret");
});
// buildCredentials overwrites the userinfo, so a pasted admin DSN would *work* — and leave a
// privileged password in the process that runs plugin code. Refusing it is the whole guard.
test("PLUGIN_DB_URL carrying credentials is refused, not silently overwritten", () => {
assert.throws(() => loadConfig({ PLUGIN_DB_URL: "postgres://root:hunter2@db:5432/ory" }), /no username or password/);
assert.throws(() => loadConfig({ PLUGIN_DB_URL: "postgres://root@db:5432" }), /no username or password/);
assert.equal(loadConfig({ PLUGIN_DB_URL: "postgres://db:5432" }).pluginDbUrl, "postgres://db:5432");
assert.equal(loadConfig({}).pluginDbUrl, undefined); // unset ⇒ storage off
});
test("the per-role connection ceiling defaults to 10 and rejects nonsense", () => {
assert.equal(resolvePluginDbConnectionLimit({}), 10);
assert.equal(resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "25" }), 25);
assert.throws(() => resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "0" }), /positive integer/);
assert.throws(() => resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "ten" }), /positive integer/);
});
test("loads dev defaults when the environment is empty", () => { test("loads dev defaults when the environment is empty", () => {
const c = loadConfig({}); const c = loadConfig({});
assert.equal(c.port, 3000); assert.equal(c.port, 3000);
+40 -9
View File
@@ -1,17 +1,40 @@
// Config loaded once from the environment at boot: Ory endpoints, cookie/CSRF // Config loaded once from the environment at boot. Fail-loud — a bad value, a missing enforced
// secrets, JWKS location, listen port, behaviour toggles. Fail-loud — a bad value, a // secret, a bad URL or an out-of-range port throws here, never at request time. Every value has a
// missing enforced secret, a bad URL, or an out-of-range port throws here, never at // working dev default, so `docker compose up` runs with zero config.
// request time.
//
// Environment-agnostic (AGENTS.md): the app never asks "which environment am I?". Every
// behaviour that used to ride on NODE_ENV is its own explicit toggle — `CACHE_TEMPLATES`,
// `REQUIRE_SECURE_SECRETS`. Clean-clone (README): every value has a working dev default,
// so `docker compose up` runs with zero config; a hardened deploy sets the toggles it wants.
// Log verbosity, most→least severe; "none" silences everything (matches @larvit/log's levels). // Log verbosity, most→least severe; "none" silences everything (matches @larvit/log's levels).
export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const; export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const;
export type LogLevel = (typeof LOG_LEVELS)[number]; export type LogLevel = (typeof LOG_LEVELS)[number];
const DEV_PLUGIN_DB_SECRET = "dev-insecure-plugin-db-secret";
// The one resolution both processes use — they must agree exactly, or web connects with a password
// the role was never given. Compose passes an unset variable through as "", so empty means unset.
// `enforce` says whether storage is actually in play: web once PLUGIN_DB_URL is configured,
// bootstrap once a plugin declares storage. Enforced, the throwaway is refused — bootstrap is what
// writes these passwords into Postgres, so it must refuse *before* creating a role with one.
export function resolvePluginDbSecret(env: Env, enforce?: boolean): string {
return readSecret(env, "PLUGIN_DB_SECRET", DEV_PLUGIN_DB_SECRET, enforce ?? readBool(env, "REQUIRE_SECURE_SECRETS", false));
}
// Only bootstrap provisions, so only bootstrap reads this; env still gets read in one place.
export function resolvePluginDbConnectionLimit(env: Env): number {
return readPosInt(env, "PLUGIN_DB_CONNECTION_LIMIT", 10);
}
// PLUGIN_DB_URL is web's, and web must never hold credentials that outrank a plugin's own role.
// Pasting the admin DSN here would otherwise work — buildCredentials overwrites the userinfo — and
// leave a superuser password in the environment plugin code can read.
function readCredentiallessUrl(env: Env, key: string): string | undefined {
const value = readOptionalUrl(env, key);
if (value === undefined) return undefined;
const url = new URL(value);
if (url.username || url.password) {
throw new Error(`config: ${key} must carry no username or password — each plugin connects as its own role`);
}
return value;
}
export interface Config { export interface Config {
appUrl: string | undefined; // canonical public URL; set ⇒ off-host visitors are redirected here. Unset ⇒ no redirect (explicit toggle) appUrl: string | undefined; // canonical public URL; set ⇒ off-host visitors are redirected here. Unset ⇒ no redirect (explicit toggle)
cacheTemplates: boolean; cacheTemplates: boolean;
@@ -30,6 +53,8 @@ export interface Config {
oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory) oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory)
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export) otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors) otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
pluginDbSecret: string; // derives each plugin's database password (src/plugin-host/storage.ts)
pluginDbUrl: string | undefined; // credential-free Postgres base URL; unset ⇒ plugin storage is off
port: number; port: number;
revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
@@ -156,6 +181,12 @@ export function loadConfig(env: Env = process.env): Config {
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5), oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"), otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"), otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
// Per-plugin storage. PLUGIN_DB_URL carries the server and its connection parameters but no
// credentials: the superuser DSN that provisions stays in bootstrap, so a plugin cannot read it
// out of web's environment. Unset ⇒ storage is off and a plugin declaring it fails loud at boot,
// which is also why the secret is only enforced once a URL is configured.
pluginDbSecret: resolvePluginDbSecret(env, requireSecure && Boolean(env["PLUGIN_DB_URL"])),
pluginDbUrl: readCredentiallessUrl(env, "PLUGIN_DB_URL"),
port: readPort(env), port: readPort(env),
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission // Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m // change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
+140 -95
View File
@@ -15,7 +15,7 @@ import { CSRF_COOKIE, issueCsrfToken } from "../auth/csrf.ts";
import { can, check, GuardError, requireSession } from "../auth/guards.ts"; import { can, check, GuardError, requireSession } from "../auth/guards.ts";
import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts"; import { HydraError, type HydraAdmin, type OAuth2Client } from "../auth/hydra-admin.ts";
import { staticJwks } from "../auth/jwks.ts"; import { staticJwks } from "../auth/jwks.ts";
import type { ExpandTree, KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts"; import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
import type { Identity, KratosAdmin } from "../auth/kratos-admin.ts"; import type { Identity, KratosAdmin } from "../auth/kratos-admin.ts";
import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts"; import { KratosError, type Flow, type FlowType, type KratosPublic, type Session, type UiNode } from "../auth/kratos-public.ts";
import { SESSION_COOKIE } from "../auth/login.ts"; import { SESSION_COOKIE } from "../auth/login.ts";
@@ -27,15 +27,12 @@ import type { MenuConfig } from "../ui/menu-config.ts";
import { loadI18n } from "../i18n/load.ts"; import { loadI18n } from "../i18n/load.ts";
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views"); const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
// The admin screens ship as a drop-in example plugin; the HTTP-level admin tests mount it via // The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
// createApp (with stub Ory clients on ctx.system + views from examples/plugins) exactly as an // ctx.system, views from examples/plugins exactly as an operator would after copying it in.
// operator would after copying it into plugins/.
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins"); const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
const adminPlugin: Plugin = { ...adminManifest, id: "admin" }; const adminPlugin: Plugin = { ...adminManifest, id: "admin" };
// A session JWT signed with a throwaway test key — the verify path. Wired into the shared // A session JWT signed with a throwaway test key; `staticJwks([ecJwk])` is the matching verify side.
// `server` (and the per-test apps) so a request can present a valid session; the dashboard and the
// gated routes need one. `staticJwks([ecJwk])` is the matching verify side.
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" }); const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" }; const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url"); const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url");
@@ -101,8 +98,7 @@ test("plugins replace either landing: `home` owns the public /, `dashboard` owns
const dir = mkdtempSync(join(tmpdir(), "pp-home-")); const dir = mkdtempSync(join(tmpdir(), "pp-home-"));
mkdirSync(join(dir, "portal", "views"), { recursive: true }); mkdirSync(join(dir, "portal", "views"), { recursive: true });
writeFileSync(join(dir, "portal", "views", "welcome.ejs"), `<h1>Welcome to <%= brand %></h1><a href="/login">Sign in</a>`); writeFileSync(join(dir, "portal", "views", "welcome.ejs"), `<h1>Welcome to <%= brand %></h1><a href="/login">Sign in</a>`);
// The dashboard view renders the native app shell from ctx.chrome — the blessed plugin ergonomics: // The dashboard view renders the native app shell from ctx.chrome.
// its own title/body, the global menu (chrome.nav), the signed-in user, the Sign-out CSRF token.
writeFileSync(join(dir, "portal", "views", "board.ejs"), writeFileSync(join(dir, "portal", "views", "board.ejs"),
`<%- include("partials/shell", { body: "<p>Hi " + user.email + "</p>", brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), theme: chrome.theme, title: "My Portal", user: chrome.user }) %>`); `<%- include("partials/shell", { body: "<p>Hi " + user.email + "</p>", brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), theme: chrome.theme, title: "My Portal", user: chrome.user }) %>`);
t.after(() => rmSync(dir, { force: true, recursive: true })); t.after(() => rmSync(dir, { force: true, recursive: true }));
@@ -326,9 +322,8 @@ function rawGet(port: number, path: string, host: string, method = "GET"): Promi
} }
test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the configured origin (path+query kept)", async (t) => { test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the configured origin (path+query kept)", async (t) => {
// The fix for the localhost-vs-127.0.0.1 / multi-domain trap: reach the app on any host and it // Reach the app on any host and it sends you to APP_URL's, so the browser, the themed form and the
// sends you to APP_URL's host, so the browser, the themed form, and the cross-origin Kratos POST // cross-origin Kratos POST share ONE cookie host. Same-host requests pass straight through.
// all share ONE cookie host. Off-canonical only — same-host requests pass straight through.
const app = createApp({ jwks: staticJwks([ecJwk]), appUrl: "http://canonical.example:3000" }); const app = createApp({ jwks: staticJwks([ecJwk]), appUrl: "http://canonical.example:3000" });
await new Promise<void>((r) => app.listen(0, r)); await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close()); t.after(() => app.close());
@@ -359,8 +354,8 @@ test("no APP_URL configured ⇒ no canonical redirect (unit-test apps and host-a
}); });
test("/error renders a themed sign-in error page (Kratos' flow error sink), not the 404", async () => { test("/error renders a themed sign-in error page (Kratos' flow error sink), not the 404", async () => {
// Kratos' flows.error.ui_url points here; a flow error redirects to /error?id=<uuid>. Without a // Kratos' flows.error.ui_url points here; a flow error redirects to /error?id=<uuid>, which must
// handler it 404'd as "Page not found" (confusing). It must be a real, themed page now. // land on a real themed page rather than the catch-all 404.
const res = await fetch(base + `/error?id=${randomUUID()}`, { redirect: "manual" }); const res = await fetch(base + `/error?id=${randomUUID()}`, { redirect: "manual" });
assert.equal(res.status, 200); assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /text\/html/); assert.match(res.headers.get("content-type") ?? "", /text\/html/);
@@ -871,11 +866,14 @@ async function adminHarness(t: TestContext, opts: AppOptions = {}) {
const token = issueCsrfToken(ADMIN_CSRF); const token = issueCsrfToken(ADMIN_CSRF);
const nowSec = Math.floor(Date.now() / 1000); const nowSec = Math.floor(Date.now() / 1000);
const cookie = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`; const cookie = (permissions: string[]) => `${SESSION_COOKIE}=${mintJwt({ email: "admin@x", exp: nowSec + 600, permissions, sub: "admin1" })}; ${CSRF_COOKIE}=${token}`;
const get = (path: string, permissions: string[] = ["admin"]) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" }); const get = (path: string, permissions: string[] = ADMIN_ALL) => fetch(url + path, { headers: { cookie: cookie(permissions) }, redirect: "manual" });
const post = (path: string, body: string) => const post = (path: string, body: string) =>
fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(["admin"]) }, method: "POST", redirect: "manual" }); fetch(url + path, { body, headers: { "content-type": "application/x-www-form-urlencoded", cookie: cookie(ADMIN_ALL) }, method: "POST", redirect: "manual" });
return { get, post, token, url }; return { get, post, token, url };
} }
// What the plugin itself declares — the harness holds every screen's read and write, so a screen
// test exercises the screen rather than the gate. assertAdminGate covers the refusals.
const ADMIN_ALL = (adminManifest.permissions ?? []).map((p) => p.name);
// Every admin route is gated: anonymous → /login, a signed-in non-admin → 403. // Every admin route is gated: anonymous → /login, a signed-in non-admin → 403.
async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) { async function assertAdminGate(url: string, get: (path: string, permissions?: string[]) => Promise<Response>, path: string) {
const anon = await fetch(url + path, { redirect: "manual" }); const anon = await fetch(url + path, { redirect: "manual" });
@@ -1105,11 +1103,28 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
await assertAdminGate(url, get, "/admin/users"); await assertAdminGate(url, get, "/admin/users");
// Nav: the admin plugin's section composes into the one global menu for an admin, and is filtered // Nav: the admin plugin's section composes into the one global menu, and each screen is filtered
// out for a signed-in non-admin (the gate on the section header) — proving the drop-in nav fragment. // by its own read permission — proving the drop-in nav fragment. A user holding only users:read
// sees Users and nothing else; holding none of the three, composeNav drops the emptied header.
assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/); assert.match(await (await get("/dashboard")).text(), /href="\/admin\/users"/);
const usersOnlyNav = await (await get("/dashboard", ["users:read"])).text();
assert.match(usersOnlyNav, /href="\/admin\/users"/);
assert.doesNotMatch(usersOnlyNav, /href="\/admin\/groups"/);
assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/); assert.doesNotMatch(await (await get("/dashboard", ["scheduling:read"])).text(), /href="\/admin\/users"/);
// The read/write split: users:read opens the list but is refused on every mutation, and the
// resources don't leak — a users holder is not a groups holder.
assert.equal((await get("/admin/users", ["users:read"])).status, 200);
assert.equal((await get("/admin/groups", ["users:read", "users:write"])).status, 403);
const readOnlyPost = await fetch(url + "/admin/users", {
body: `_csrf=${token}&email=nope@example.com`,
headers: { "content-type": "application/x-www-form-urlencoded", cookie: `${SESSION_COOKIE}=${mintJwt({ email: "r@x", exp: Math.floor(Date.now() / 1000) + 600, permissions: ["users:read"], sub: "reader1" })}; ${CSRF_COOKIE}=${token}` },
method: "POST",
redirect: "manual",
});
assert.equal(readOnlyPost.status, 403);
assert.equal(store.some((i) => i.traits?.email === "nope@example.com"), false);
// List: the admin sees the rows + the "add" link; the status filter narrows server-side. // List: the admin sees the rows + the "add" link; the status filter narrows server-side.
const listHtml = await (await get("/admin/users")).text(); const listHtml = await (await get("/admin/users")).text();
assert.match(listHtml, /ada@example\.com/); assert.match(listHtml, /ada@example\.com/);
@@ -1218,107 +1233,137 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`); await post("/admin/groups/eng/members/delete", `_csrf=${token}&member=user:${grace}`);
assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`)); assert.ok(!tuples.some((tp) => tp.object === "eng" && tp.subject_id === `user:${grace}`));
// Give it a permission first, so the delete below has an orphan to avoid leaving behind.
await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=users%3Aread`);
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "users:read" && tp.subject_set?.object === "eng"));
// Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list. // Delete the group: a confirm step (GET) then the POST removes every member tuple, back to the list.
assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/); assert.match(await (await get("/admin/groups/eng/delete")).text(), /Cancel/);
const del = await post("/admin/groups/eng/delete", `_csrf=${token}`); const del = await post("/admin/groups/eng/delete", `_csrf=${token}`);
assert.equal(del.status, 303); assert.equal(del.status, 303);
assert.equal(del.headers.get("location"), "/admin/groups"); assert.equal(del.headers.get("location"), "/admin/groups");
assert.ok(!tuples.some((tp) => tp.object === "eng")); assert.ok(!tuples.some((tp) => tp.object === "eng"));
// …and the permissions it held go with it. A Keto set exists only through its tuples, so an
// orphaned grant would resurrect the moment someone re-created a group with the same name.
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.subject_set?.object === "eng"));
// An invalid group name in the path → 404; malformed %-encoding doesn't 500. // An invalid group name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/groups/Bad%20Name")).status, 404); assert.equal((await get("/admin/groups/Bad%20Name")).status, 404);
assert.equal((await get("/admin/groups/%ZZ")).status, 404); assert.equal((await get("/admin/groups/%ZZ")).status, 404);
}); });
// Built-in Roles admin screen: gate + list/create/assign/revoke/delete over HTTP // Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
// against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the // so the checkboxes are a fixed list and the POST is the desired state.
// effective-access view surfaces a user reachable only through a group. test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => {
const ada = randomUUID(); const ada = randomUUID();
const grace = randomUUID(); const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const identities: Identity[] = [ const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:read", relation: "granted", subject_id: `user:${ada}` }];
{ id: ada, schema_id: "default", state: "active", traits: { email: "ada@example.com" } }, const keto = fakeKeto(tuples);
{ id: grace, schema_id: "default", state: "active", traits: { email: "grace@example.com" } }, const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
]; const denylist = createDenylist();
// grace is in the `eng` group; `editor` is an existing permission whose only direct member is ada. const { get, post, token } = await adminHarness(t, { denylist, keto, kratosAdmin });
const tuples: RelationTuple[] = [
{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${grace}` }, // The user edit page renders one checkbox per declared permission, ticked where already held.
{ namespace: "Permission", object: "editor", relation: "granted", subject_id: `user:${ada}` }, const edit = await (await get(`/admin/users/${ada}`)).text();
]; for (const name of ["users:read", "users:write", "groups:read", "groups:write", "oauth2-clients:read", "oauth2-clients:write"]) {
// Mirror Keto's expand shape: the subject rides on `tuple`, set nodes carry members as children. assert.match(edit, new RegExp(`value="${name.replace(":", ":")}"`), name);
const expandSet = (set: SubjectSet): ExpandTree => ({ }
children: tuples assert.match(edit, /value="users:read"[^>]*checked/); // held → ticked
.filter((tp) => tp.namespace === set.namespace && tp.object === set.object && tp.relation === set.relation) assert.doesNotMatch(edit, /value="groups:write"[^>]*checked/); // not held → unticked
.map((tp) => (tp.subject_id ? { tuple: { namespace: "", object: "", relation: "", subject_id: tp.subject_id }, type: "leaf" } : expandSet(tp.subject_set!))),
tuple: { namespace: "", object: "", relation: "", subject_set: set }, // Save a new set: users:write is added, users:read is dropped — the POST is the whole truth.
type: "union", const saved = await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
assert.equal(saved.status, 303);
assert.deepEqual(
tuples.filter((tp) => tp.subject_id === `user:${ada}`).map((tp) => tp.object).sort(),
["groups:read", "users:write"],
);
assert.equal(denylist.isRevoked(ada, 0), true); // a change to your own grants revokes live tokens
// A crafted POST can't grant something no plugin declares.
await post(`/admin/users/${ada}/permissions`, `_csrf=${token}&permission=users%3Awrite&permission=superuser%3Aall`);
assert.ok(!tuples.some((tp) => tp.object === "superuser:all"));
// The same picker on a group writes the group's subject_set, which Keto resolves transitively.
tuples.push({ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` });
await post("/admin/groups/eng/permissions", `_csrf=${token}&permission=groups%3Aread`);
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "groups:read" && tp.subject_set?.object === "eng"));
}); });
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist(); // granting/revoking a *user's* permission revokes their live tokens (a group change is transitive → left to lag)
const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
await assertAdminGate(url, get, "/admin/permissions"); // Revoking your own grants can remove the last users:write on the deployment, and the instant-revoke
// hook lands it on the next request — recovery would be a curl against Keto. Guarded like
// self-deactivate and self-delete are. (`admin1` is the harness's own sub.)
test("admin permission grants: you can't revoke your own permissions, but you can still grant", async (t) => {
const identities: Identity[] = [{ id: "admin1", traits: { email: "you@example.com" } }];
const tuples: RelationTuple[] = [{ namespace: "Permission", object: "users:write", relation: "granted", subject_id: "user:admin1" }];
const keto = fakeKeto(tuples);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { post, token } = await adminHarness(t, { keto, kratosAdmin });
// List: the existing permission shows + the "add" link. const refused = await post("/admin/users/admin1/permissions", `_csrf=${token}`); // every box cleared
const listHtml = await (await get("/admin/permissions")).text(); assert.equal(refused.status, 400);
assert.match(listHtml, /href="\/admin\/permissions\/editor"/); assert.match(await refused.text(), /lock yourself out/);
assert.match(listHtml, /href="\/admin\/permissions\/new"/); assert.ok(tuples.some((tp) => tp.object === "users:write" && tp.subject_id === "user:admin1"), "nothing was revoked");
// Create: a valid post writes the first-member tuple and redirects to the detail. // Granting yourself more is not a lockout, so it goes through.
assert.match(await (await get("/admin/permissions/new")).text(), /Create permission/); const granted = await post("/admin/users/admin1/permissions", `_csrf=${token}&permission=users%3Awrite&permission=groups%3Aread`);
const created = await post("/admin/permissions", `_csrf=${token}&name=viewer&member=user:${ada}`); assert.equal(granted.status, 303);
assert.equal(created.status, 303); assert.ok(tuples.some((tp) => tp.object === "groups:read" && tp.subject_id === "user:admin1"));
assert.equal(created.headers.get("location"), "/admin/permissions/viewer"); });
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "viewer" && tp.subject_id === `user:${ada}`));
assert.equal(denylist.isRevoked(ada, 0), true); // assigning a permission to a user revokes their stale token so the grant lands now
// An invalid name, a duplicate name, or a missing CSRF token are all refused, nothing written. // The read/write split is only honest if the UI models it: a users:read holder must not be shown
const before = tuples.length; // buttons that 403 on submit. The gate already refuses them (asserted above); this is the affordance.
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=Bad Name&member=user:${ada}`)).status, 400); test("admin screens render no write affordance for a read-only holder", async (t) => {
assert.equal((await post("/admin/permissions", `_csrf=${token}&name=editor&member=user:${ada}`)).status, 400); // already exists const ada = randomUUID();
assert.equal((await post("/admin/permissions", `name=x&member=user:${ada}`)).status, 403); const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
assert.equal(tuples.length, before); const keto = fakeKeto([{ namespace: "Group", object: "eng", relation: "members", subject_id: `user:${ada}` }]);
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
// Hydra is wired so the clients screen renders for real — without it the page is a 503 and the
// "no Register button" assertion below would pass without proving anything.
const reporting = { client_id: "existing", client_name: "Reporting" };
const hydra = stubHydra({ getClient: async (id) => (id === reporting.client_id ? reporting : null), listClients: async () => ({ clients: [reporting], nextPageToken: null }) });
const { get } = await adminHarness(t, { hydra, keto, kratosAdmin });
const readOnly = ["users:read", "groups:read"];
// Detail: ada (direct) is in the effective-access list; grace (only reachable via a group) is not const list = await (await get("/admin/users", readOnly)).text();
// yet — though grace appears elsewhere as an assignable candidate, so target the effective <li>. assert.doesNotMatch(list, /href="\/admin\/users\/new"/); // no "New user"
const effectiveLi = (email: string) => new RegExp(`<li><span class="cell-strong">${email.replace(".", "\\.")}`); assert.match(list, /ada@example\.com/); // but the list itself is there — that's the point of :read
const detail = await (await get("/admin/permissions/editor")).text();
assert.match(detail, effectiveLi("ada@example.com"));
assert.doesNotMatch(detail, effectiveLi("grace@example.com"));
// Assign the `eng` group to the permission → grace now holds it transitively (effective access via expand). // (The shell's own sign-out is a POST form, so assert on the affordances by name, not on <form>.)
await post("/admin/permissions/editor/members", `_csrf=${token}&member=group:eng`); const detail = await (await get(`/admin/users/${ada}`, readOnly)).text();
assert.ok(tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng")); assert.doesNotMatch(detail, /Save changes/);
const withGroup = await (await get("/admin/permissions/editor")).text(); assert.doesNotMatch(detail, /Generate recovery code/);
assert.match(withGroup, effectiveLi("grace@example.com")); assert.doesNotMatch(detail, /Delete user/);
assert.doesNotMatch(detail, /Save permissions/);
assert.match(detail, /type="checkbox"[^>]*disabled/); // the permissions are shown, just not editable
// Revoke the group membership. const group = await (await get("/admin/groups/eng", readOnly)).text();
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=group:eng`); assert.doesNotMatch(group, /Add a member/);
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor" && tp.subject_set?.object === "eng")); assert.doesNotMatch(group, /Delete group/);
assert.doesNotMatch(group, /Save permissions/);
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate. // The OAuth2-clients screen is held to the same rule.
await post("/admin/permissions/editor/members", `_csrf=${token}&member=user:${grace}`); const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
await post("/admin/permissions/editor/members/delete", `_csrf=${token}&member=user:${grace}`); assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
assert.equal(denylist.isRevoked(grace, 0), true); const clients = await clientsRes.text();
assert.match(clients, /Reporting/); // the list is there — that's what :read buys
assert.doesNotMatch(clients, /href="\/admin\/clients\/new"/);
// The detail page is where Delete lives, so check it too and not just the list.
const clientDetail = await (await get("/admin/clients/existing", ["oauth2-clients:read"])).text();
assert.match(clientDetail, /Reporting/);
assert.doesNotMatch(clientDetail, /clients\/existing\/delete/);
// Delete the permission: a confirm step (GET) then the POST removes every member tuple, back to the list. // A write-intent GET — a create form or a delete-confirm — refuses a reader outright rather than
assert.match(await (await get("/admin/permissions/editor/delete")).text(), /Cancel/); // rendering a form whose submit would 403.
const del = await post("/admin/permissions/editor/delete", `_csrf=${token}`); for (const path of ["/admin/users/new", "/admin/groups/new", `/admin/users/${ada}/delete`, "/admin/groups/eng/delete"]) {
assert.equal(del.status, 303); assert.equal((await get(path, readOnly)).status, 403, path);
assert.equal(del.headers.get("location"), "/admin/permissions"); }
assert.ok(!tuples.some((tp) => tp.namespace === "Permission" && tp.object === "editor")); assert.equal((await get("/admin/clients/new", ["oauth2-clients:read"])).status, 403);
// Self-protection: the admin permission can't be deleted, nor can you revoke your own admin (sub admin1). // A writer sees the affordances the reader didn't.
tuples.push({ namespace: "Permission", object: "admin", relation: "granted", subject_id: "user:admin1" }); const writable = await (await get(`/admin/users/${ada}`, ["users:read", "users:write"])).text();
assert.equal((await post("/admin/permissions/admin/delete", `_csrf=${token}`)).status, 400); assert.match(writable, /Save changes/);
assert.ok(tuples.some((tp) => tp.object === "admin")); assert.match(writable, /Save permissions/);
assert.equal((await post("/admin/permissions/admin/members/delete", `_csrf=${token}&member=user:admin1`)).status, 400);
assert.ok(tuples.some((tp) => tp.object === "admin" && tp.subject_id === "user:admin1"));
// An invalid permission name in the path → 404; malformed %-encoding doesn't 500.
assert.equal((await get("/admin/permissions/Bad%20Name")).status, 404);
assert.equal((await get("/admin/permissions/%ZZ")).status, 404);
}); });
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an // Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
+46 -96
View File
@@ -25,7 +25,7 @@ import type { KratosPublic } from "../auth/kratos-public.ts";
import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts"; import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts";
import { remintSession } from "../auth/login.ts"; import { remintSession } from "../auth/login.ts";
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts"; import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import type { Plugin, RouteHandler, RouteResult } from "../plugin-host/plugin.ts"; import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
import type { SystemCapabilities } from "../plugin-host/system.ts"; import type { SystemCapabilities } from "../plugin-host/system.ts";
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts"; import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
import { buildAuthRoutes } from "../auth/routes.ts"; import { buildAuthRoutes } from "../auth/routes.ts";
@@ -39,15 +39,11 @@ const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
export interface AppOptions { export interface AppOptions {
appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect
auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks
// Cache compiled templates; caller decides (server passes config.cacheTemplates). cache?: boolean; // cache compiled EJS templates (config.cacheTemplates); off ⇒ edits show live
// Off by default so edits show live; the app itself never inspects the environment.
cache?: boolean;
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
// Loaded translation catalogs (server.ts passes the discovered ones). Omitted ⇒ the built-in i18n?: I18n; // discovered catalogs; omitted ⇒ the built-in en-US only, so an unwired app still renders English
// en-US catalog only, so an unwired app still renders real English.
i18n?: I18n;
jwks?: JwksProvider; // verify the session JWT → ctx.user/permissions; absent ⇒ always anonymous jwks?: JwksProvider; // verify the session JWT → ctx.user/permissions; absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
@@ -62,15 +58,12 @@ export interface AppOptions {
} }
export function createApp(options: AppOptions = {}): Server { export function createApp(options: AppOptions = {}): Server {
// The denylist (when enabled) rides in the verify options so resolveSession rejects a revoked // The denylist rides in the verify options so resolveSession rejects a revoked subject on the hot
// subject on the hot path; the bound `revoke` is handed to the admin handlers that should // path; the bound `revoke` goes to the admin handlers. Both absent ⇒ the feature is fully off.
// revoke instantly. Both absent ⇒ the feature is fully off (no cost, no behaviour change).
const denylist = options.denylist; const denylist = options.denylist;
const authOptions: VerifyOptions = denylist ? { ...(options.auth ?? {}), denylist } : (options.auth ?? {}); const authOptions: VerifyOptions = denylist ? { ...(options.auth ?? {}), denylist } : (options.auth ?? {});
const revoke = denylist ? (sub: string): void => denylist.revoke(sub) : undefined; const revoke = denylist ? (sub: string): void => denylist.revoke(sub) : undefined;
const cache = options.cache ?? false; const cache = options.cache ?? false;
// Canonical public host (APP_URL): when set, an off-host GET/HEAD visitor is redirected here so
// every cookie (esp. Kratos' cross-origin CSRF cookie) shares one host. Omitted ⇒ feature off.
const canonical = options.appUrl ? new URL(options.appUrl) : undefined; const canonical = options.appUrl ? new URL(options.appUrl) : undefined;
const canonicalHost = canonical?.host; // host[:port], default ports omitted — matches the Host header const canonicalHost = canonical?.host; // host[:port], default ports omitted — matches the Host header
const canonicalOrigin = canonical?.origin; // scheme + host[:port], no trailing slash const canonicalOrigin = canonical?.origin; // scheme + host[:port], no trailing slash
@@ -82,9 +75,7 @@ export function createApp(options: AppOptions = {}): Server {
const keto = options.keto; const keto = options.keto;
const kratos = options.kratos; const kratos = options.kratos;
const kratosAdmin = options.kratosAdmin; const kratosAdmin = options.kratosAdmin;
// Privileged host services handed to a system plugin via ctx.system — the Ory admin clients and // Only the wired capabilities are present; with none wired ctx.system stays undefined.
// the instant-revoke hook. Only the wired capabilities are present; with none wired ctx.system
// stays undefined, so an ordinary deployment (no Ory, hence no system plugin) pays nothing.
const system: SystemCapabilities | undefined = kratosAdmin || keto || hydra || revoke const system: SystemCapabilities | undefined = kratosAdmin || keto || hydra || revoke
? { ...(hydra ? { hydra } : {}), ...(keto ? { keto } : {}), ...(kratosAdmin ? { kratosAdmin } : {}), ...(revoke ? { revoke } : {}) } ? { ...(hydra ? { hydra } : {}), ...(keto ? { keto } : {}), ...(kratosAdmin ? { kratosAdmin } : {}), ...(revoke ? { revoke } : {}) }
: undefined; : undefined;
@@ -93,11 +84,10 @@ export function createApp(options: AppOptions = {}): Server {
const menu = options.menu ?? DEFAULT_MENU; const menu = options.menu ?? DEFAULT_MENU;
const plugins = options.plugins ?? []; const plugins = options.plugins ?? [];
const pluginIds = new Set(plugins.map((p) => p.id)); const pluginIds = new Set(plugins.map((p) => p.id));
// A plugin may fully replace the public landing "/" (`home`) or the gated dashboard "/dashboard" // `find` is unambiguous: findConflicts guarantees at most one owner of each landing slot.
// (`dashboard`) — Discovery's findConflicts guarantees at most one of each, so `find` is
// unambiguous; the predicates narrow the slot to defined.
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function"); const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function"); const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
const permissionCatalog = declaredPermissions(plugins);
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free). // Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest); const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse); const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
@@ -112,19 +102,11 @@ export function createApp(options: AppOptions = {}): Server {
const render = (view: string, data: Record<string, unknown>): Promise<string> => const render = (view: string, data: Record<string, unknown>): Promise<string> =>
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache, views: [viewsDir] }); ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache, views: [viewsDir] });
// A `view` RouteResult renders plugins/<id>/views/<view>.ejs; such views may include() the core
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir }); const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
// Every view renders with its context's i18n locals (t/locale/dir/localeSwitch/localeParam) merged // Where the language picker points. Normally the page itself; after a POST that URL may answer no
// in, so a view — core or plugin, at any include depth — calls `t(...)` without its handler passing // GET (POST /admin/users/:id/delete has no GET sibling), so fall back to the page the form was
// it. A plugin's context carries that plugin's translator, so its own catalog wins in its own views. // submitted from, then to the front page — the picker is on every page, so every link must land.
// They are merged LAST: these names are reserved (README → Building plugins), and a handler that
// happens to use one loses that key rather than breaking the shell that renders around it.
// Where the language picker on this page should point. Normally the page itself; after a POST
// that URL may answer no GET (POST /admin/users/:id/delete has no GET sibling), so fall back to
// the page the form was submitted from, then to the front page — the picker is on every page, so
// every one of its links has to land somewhere real.
const switchBase = (req: IncomingMessage, url: URL): string => { const switchBase = (req: IncomingMessage, url: URL): string => {
const method = (req.method ?? "GET").toUpperCase(); const method = (req.method ?? "GET").toUpperCase();
if (method === "GET" || method === "HEAD") return `${url.pathname}${url.search}`; if (method === "GET" || method === "HEAD") return `${url.pathname}${url.search}`;
@@ -144,6 +126,8 @@ export function createApp(options: AppOptions = {}): Server {
t: ctx.t, t: ctx.t,
url: ctx.url, url: ctx.url,
}); });
// i18n locals go last: their names are reserved, so a handler's colliding key loses instead of
// breaking the shell around it.
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) }); const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) });
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) }); const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) });
@@ -152,10 +136,7 @@ export function createApp(options: AppOptions = {}): Server {
res.end(html); res.end(html);
}; };
// The public landing "/": ungated — anyone may see it. A plugin may fully own it via `home` // The public landing "/", ungated. A plugin may own it via `home`; else the built-in intro page.
// (rendered against its own views, native shell via ctx.chrome, with a fresh CSRF cookie for
// any form it ships). Else the built-in intro page with prominent sign-in / register links
// (`user` picks "go to dashboard" vs sign-in; the shell's Sign-out form needs the CSRF cookie).
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => { const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
csrf.setCookie(); csrf.setCookie();
if (homePlugin) { if (homePlugin) {
@@ -169,10 +150,8 @@ export function createApp(options: AppOptions = {}): Server {
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" }; return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
}; };
// The post-login app home "/dashboard", gated to a signed-in user: anonymous bounces to sign // "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
// in, remembering /dashboard as return_to. A plugin may fully own it via `dashboard` — its // starter page.
// handler renders against its own views, same path as a plugin route. Else the built-in
// mock-data People list with the one global menu (ctx.chrome.nav) + branding from config/menu.ts.
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => { const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 }; if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent. // The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
@@ -211,26 +190,21 @@ export function createApp(options: AppOptions = {}): Server {
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override). // (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
for (const [name, value] of secHeaderEntries) res.setHeader(name, value); for (const [name, value] of secHeaderEntries) res.setHeader(name, value);
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
// /public/<id>/… serves a plugin's public/; everything else the core public/.
// Before auth: assets don't need a verified user, and the JWT cookie rides every request. // Before auth: assets don't need a verified user, and the JWT cookie rides every request.
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds); const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) })); await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) }));
return; return;
} }
// Rendered pages content-negotiate on Accept-Language, so a cache in front of us must key on // A cache in front of us must key on the language. Set after the static branch: an asset is
// it — otherwise the first visitor's language is served to everyone. Set after the static // the same bytes in every language, and a Vary there fragments its entry per raw header.
// branch above: an asset is the same bytes in every language, and a Vary there would fragment
// its cache entry per raw header string.
res.setHeader("vary", "accept-language"); res.setHeader("vary", "accept-language");
// Canonical host (APP_URL): a visitor who reached us on a different host (localhost vs // Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
// 127.0.0.1, a secondary domain) is sent to the configured origin, path + query preserved, so // the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
// the browser, the themed forms, and the cross-origin Kratos POST all share one cookie host — // host-scoped Kratos CSRF cookie is lost and login dumps onto /error. GET/HEAD only: a 308
// otherwise the host-scoped Kratos CSRF cookie is lost and login dumps onto /error. Static // must not replay a cross-host POST.
// assets above are served on any host (health checks). GET/HEAD only — a 308 must not replay a
// cross-host POST; first-party forms are always served from a canonical page anyway.
if (canonicalHost && (method === "GET" || method === "HEAD")) { if (canonicalHost && (method === "GET" || method === "HEAD")) {
const host = req.headers.host; const host = req.headers.host;
if (host !== undefined && host !== canonicalHost) { if (host !== undefined && host !== canonicalHost) {
@@ -239,18 +213,14 @@ export function createApp(options: AppOptions = {}): Server {
} }
} }
// Which language this request is served in: ?locale wins, else Accept-Language, else en-US. // `explicit` (the URL asked for a locale) is what makes the choice travel: the chrome, this
// `explicit` (the URL asked) is what makes the choice travel: the chrome, this request's // request's redirects and ctx.localeHref then carry ?locale onto the links they emit.
// redirects and ctx.localeHref then carry ?locale onto the links they emit.
const { explicit, locale } = i18n.resolve({ acceptLanguage: req.headers["accept-language"], param: url.searchParams.get("locale") }); const { explicit, locale } = i18n.resolve({ acceptLanguage: req.headers["accept-language"], param: url.searchParams.get("locale") });
const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null); const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null);
const t = i18n.translator(locale); const t = i18n.translator(locale);
// Verify the session JWT once (cached JWKS) → ctx.user/permissions; none/invalid ⇒ anonymous. // A lapsed token still backed by a live Kratos session is silently re-minted — "stay signed
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory // in". The only place the hot path touches Ory.
// clients), silently re-mint it — "stay signed in": re-read permissions from Keto, re-tokenize,
// and set the fresh cookie via setHeader so it rides whatever response this request produces
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
let user: User | null = null; let user: User | null = null;
if (jwks) { if (jwks) {
const auth = await resolveSession(req.headers.cookie, jwks, authOptions); const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
@@ -261,32 +231,25 @@ export function createApp(options: AppOptions = {}): Server {
user = reminted.user; user = reminted.user;
res.appendHeader("set-cookie", reminted.setCookie); res.appendHeader("set-cookie", reminted.setCookie);
} catch (err) { } catch (err) {
// Ory unreachable (Kratos/Keto 5xx, refused, timeout) — degrade to anonymous instead of // Ory unreachable — degrade to anonymous instead of 500ing every lapsed request. Leave
// 500ing every lapsed request. Leave the cookie alone: it can re-mint once Ory recovers. // the cookie alone: it can re-mint once Ory recovers.
reqLog.warn("session re-mint failed (Ory unreachable?)", { error: String(err) }); reqLog.warn("session re-mint failed (Ory unreachable?)", { error: String(err) });
} }
} }
} }
// CSRF token for this request's first-party forms: reuse a genuine cookie token, else mint
// one (a page-emitting handler Set-Cookies it via csrfMint). Verified on our own
// state-changing routes.
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret); const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
const csrfMint: RequestCsrf = { const csrfMint: RequestCsrf = {
setCookie: (): void => { if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); }, setCookie: (): void => { if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); },
token: csrf.token, token: csrf.token,
}; };
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret).
const verifyCsrf = (submitted: string | null | undefined): boolean => const verifyCsrf = (submitted: string | null | undefined): boolean =>
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted }); verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
// Chrome (brand/global-nav/user/theme/csrf) composes the whole menu, so it's resolved lazily and // Chrome composes the whole menu, so it is memoized and resolved lazily — a json/redirect
// at most once per request: this app-level memo shares it across the contexts below, and each // handler, or the public "/" with a standalone home, never pays for it.
// ctx.chrome getter only triggers it when a handler actually reads it (a json/redirect handler,
// or the public "/" with a standalone home, never composes the menu).
let chromeMemo: PageChrome | undefined; let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, localeHref: carryLocale, menu, plugins, t, translatorFor: (id) => i18n.translator(locale, id), user })); const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, localeHref: carryLocale, menu, plugins, t, translatorFor: (id) => i18n.translator(locale, id), user }));
// The i18n half of every context: the locale, its translator, and the link carrier. A plugin // A plugin's context gets the plugin's own translator — its catalog first, then core.
// route swaps in the plugin's own translator (its catalog first, then core).
const i18nFor = (pluginId?: string) => ({ const i18nFor = (pluginId?: string) => ({
locale, locale,
localeHref: carryLocale, localeHref: carryLocale,
@@ -294,35 +257,30 @@ export function createApp(options: AppOptions = {}): Server {
t: pluginId === undefined ? t : i18n.translator(locale, pluginId), t: pluginId === undefined ? t : i18n.translator(locale, pluginId),
}); });
// base context (no route params yet); reused for the built-in routes. A plugin-owned render // Base context (no route params), for the built-in routes. Every plugin-owned render — a
// (a landing slot, a hook short-circuit, a plugin route) gets `contextFor(id)` instead, so its // landing slot, a hook short-circuit, a plugin route gets `contextFor(id)` instead.
// own catalog is what `ctx.t` reads. const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
const ctx = buildContext(req, res, { chrome, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext => const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
buildContext(req, res, { chrome, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) }); buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
renderPage = viewsFor(ctx); renderPage = viewsFor(ctx);
// Plugin onRequest hooks run before routing and may short-circuit the request. // Plugin onRequest hooks run before routing and may short-circuit the request.
if (anyRequestHooks) { if (anyRequestHooks) {
const short = await runRequestHooks(plugins, contextFor); const short = await runRequestHooks(plugins, contextFor);
if (short) { if (short) {
// Set the fresh CSRF cookie like every other page-emitting path, so a form the hook // Like every other page-emitting path, so a form the hook renders has its matching cookie.
// renders (its token is in ctx.chrome.csrfToken) has the matching double-submit cookie.
csrfMint.setCookie(); csrfMint.setCookie();
await sendResult(res, short.result, pluginViewsFor(short.ctx, short.plugin.id), carryLocale); await sendResult(res, short.result, pluginViewsFor(short.ctx, short.plugin.id), carryLocale);
return; return;
} }
} }
// Plugin routes (any method): gate on the route's permission, then run the handler. The
// handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh
// CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname); const match = matchRoute(plugins, method, pathname);
if (match) { if (match) {
const routeCtx = contextFor(match.plugin.id, match.params); const routeCtx = contextFor(match.plugin.id, match.params);
if (!isAuthorized(match.route, routeCtx.permissions)) { if (!isAuthorized(match.route, routeCtx.permissions)) {
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as // Anonymous → sign in, remembering the page as return_to; a signed-in user who simply
// return_to; a signed-in user who simply lacks the permission gets the 403 page. // lacks the permission gets the 403 page.
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; } if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id }); reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
sendHtml(res, 403, await renderPage("403", {})); sendHtml(res, 403, await renderPage("403", {}));
@@ -337,9 +295,6 @@ export function createApp(options: AppOptions = {}): Server {
return; return;
} }
// Built-in endpoints (the auth/OAuth2 group, the landing slots, /error) from the internal
// route table — same handler shape as plugin routes; a `view` result renders the core views,
// null means the handler wrote to ctx.res itself.
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname); const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
if (builtin) { if (builtin) {
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale); await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
@@ -382,20 +337,16 @@ export function createApp(options: AppOptions = {}): Server {
}; };
return createServer((req, res) => { return createServer((req, res) => {
// Per-request log + trace span: a "request" span, continuing an upstream W3C traceparent // "close" (not "finish") fires on both a completed response and a premature disconnect, so an
// when present (distributed tracing across a proxy). "close" (not "finish") fires on both a // aborted request is still logged and its span flushed.
// completed response and a premature disconnect/abort, so an aborted/truncated request is still
// logged and its span flushed.
const startMs = Date.now(); const startMs = Date.now();
const reqLog = requestLogger(log, { const reqLog = requestLogger(log, {
requestId: randomUUID(), requestId: randomUUID(),
...(typeof req.headers.traceparent === "string" ? { traceparent: req.headers.traceparent } : {}), ...(typeof req.headers.traceparent === "string" ? { traceparent: req.headers.traceparent } : {}),
}); });
// end() must run exactly once, after BOTH the handler has fully unwound (settled) AND the // end() must run exactly once, after BOTH the handler has unwound AND the response has closed.
// response has closed (the access line is then emitted with the final status). Ending earlier // Earlier would throw "already ended" from a still-running handler's ctx.log on a client abort,
// would throw "already ended" from a still-running handler's ctx.log/tracedFetch on a client // or drop the access line on the happy path (the handler settles before close).
// abort, or drop the access line on the happy path (handler settles before close). Coordinating
// the two signals avoids both. Logging must never crash a served request, so it's all guarded.
let settled = false; let settled = false;
let closed = false; let closed = false;
const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); }; const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); };
@@ -407,9 +358,8 @@ export function createApp(options: AppOptions = {}): Server {
} catch { /* never let logging crash a served request */ } } catch { /* never let logging crash a served request */ }
finalize(); finalize();
}); });
// Make reqLog ambient for the whole handler (sync body + every await) so all outbound fetch is // Make reqLog ambient for the whole handler so all outbound fetch is traced. The .catch logs a
// traced. handleRequest owns its own try/catch; the .catch logs a pathological escape via the // pathological escape via the app logger — not reqLog, which may be the thing that broke.
// app logger (not reqLog, which may be the thing that broke), never crashing the request.
void runWithLog(reqLog, () => handleRequest(req, res, reqLog)) void runWithLog(reqLog, () => handleRequest(req, res, reqLog))
.catch((err) => log.error("request handler escaped its try/catch", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) })) .catch((err) => log.error("request handler escaped its try/catch", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) }))
.finally(() => { settled = true; finalize(); }); .finally(() => { settled = true; finalize(); });
+9 -2
View File
@@ -1,5 +1,6 @@
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
import type { PermissionDecl } from "../plugin-host/plugin.ts"; // type-only
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
import { DEFAULT_LOCALE } from "../i18n/catalog.ts"; import { DEFAULT_LOCALE } from "../i18n/catalog.ts";
import { ENGLISH } from "../i18n/english.ts"; import { ENGLISH } from "../i18n/english.ts";
@@ -30,13 +31,17 @@ export interface RequestContext {
// on off-site URLs. The host already does this for the chrome and its own redirects; a plugin // on off-site URLs. The host already does this for the chrome and its own redirects; a plugin
// wraps the hrefs it builds itself. // wraps the hrefs it builds itself.
localeHref(href: string): string; localeHref(href: string): string;
// Every installed locale, sorted. With `localeLabel` (from #plugin-api) it is what a plugin needs // Every installed locale, sorted. With `localeLabel` (from @plainpages/plugin-api) it is what a
// to build its own language picker; the host's own picker is already in the shell. // plugin needs to build its own language picker; the host's own picker is already in the shell.
locales: string[]; locales: string[];
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to // Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by // log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path. // requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
log: Log; log: Log;
// Every permission the installed plugins declare, deduped and sorted — the fixed list an admin
// screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is
// what *this user holds*. Empty when no installed plugin declares any.
declaredPermissions: readonly PermissionDecl[];
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id } params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q") query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
@@ -61,6 +66,7 @@ export interface BuildContextOptions {
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing). // ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
// The host's factory is memoised, so the menu composes at most once per request across contexts. // The host's factory is memoised, so the menu composes at most once per request across contexts.
chrome?: () => PageChrome; chrome?: () => PageChrome;
declaredPermissions?: readonly PermissionDecl[];
user?: User | null; user?: User | null;
locale?: string; locale?: string;
localeHref?: (href: string) => string; localeHref?: (href: string) => string;
@@ -89,6 +95,7 @@ export function buildContext(
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
return { return {
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); }, get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
declaredPermissions: options.declaredPermissions ?? [],
user, user,
locale: options.locale ?? DEFAULT_LOCALE, locale: options.locale ?? DEFAULT_LOCALE,
localeHref: options.localeHref ?? ((href) => href), localeHref: options.localeHref ?? ((href) => href),
+4 -11
View File
@@ -1,15 +1,8 @@
// URL safety helpers. Two pure, dependency-free guards: // safeUrl(value) — a URL field is emitted verbatim into an href/src, so a `javascript:`/`data:`
// // URL from untrusted data would be live XSS. Relative or http(s) passes,
// safeUrl(value) — sanitise an untrusted URL before rendering it in an href/src attribute.
// Partials escape *text*, but a URL field is emitted verbatim, so a
// `javascript:`/`data:` URL from upstream/user data would be live XSS. The
// contract (README.md → Routes & handlers) is: a relative or http(s) URL is allowed,
// anything else collapses to "#". Exported to plugins via plugin-api.ts. // anything else collapses to "#". Exported to plugins via plugin-api.ts.
// // localPath(value) — the redirect-URI allowlist for `return_to`: host-relative passes, absolute
// localPath(value) — validate a redirect target is a *same-origin* path (the redirect-URI // or protocol-relative is rejected, so a crafted value can't open-redirect.
// allowlist). Used for `return_to`: a host-relative "/a/b?x=1" passes, an
// absolute or protocol-relative ("//evil.com", "https://evil.com") is rejected
// so a crafted ?return_to= can't turn login completion into an open redirect.
// ASCII control chars + space that browsers strip/ignore when resolving a URL — strip them before // ASCII control chars + space that browsers strip/ignore when resolving a URL — strip them before
// the scheme check so "java\tscript:" / a leading space can't masquerade as relative. // the scheme check so "java\tscript:" / a leading space can't masquerade as relative.
+5 -9
View File
@@ -1,15 +1,11 @@
// Response security headers: set once per request in app.ts so every response — page, // Set once per request in app.ts, so every response carries them (writeHead merges with setHeader).
// JSON, redirect, static, or error — carries them (writeHead merges with setHeader). A plugin route // A plugin route may override any per-response via RouteResult.headers.
// may override any of them per-response via RouteResult.headers (e.g. relax the CSP to ship its own JS).
// Strict default CSP for the zero-JS, server-rendered core: // The non-obvious parts of the CSP:
// - script-src 'self' : the core ships no JS; a plugin may still serve its own /public/<id>/*.js for // - script-src 'self' with no 'unsafe-inline' ⇒ an injected <script> can't run. A plugin may still
// opt-in progressive enhancement. No 'unsafe-inline' ⇒ an injected <script> // serve its own /public/<id>/*.js for opt-in progressive enhancement.
// can't run (the main XSS sink).
// - style-src adds 'unsafe-inline': a few partials carry inline style= attributes. // - style-src adds 'unsafe-inline': a few partials carry inline style= attributes.
// - img-src adds data: : favicon + inline data URIs.
// - no form-action: the themed login form posts to Kratos' (often cross-origin) action URL. // - no form-action: the themed login form posts to Kratos' (often cross-origin) action URL.
// - frame-ancestors 'none' : clickjacking guard (the modern X-Frame-Options).
const CSP = [ const CSP = [
"base-uri 'self'", "base-uri 'self'",
"default-src 'self'", "default-src 'self'",
+6 -8
View File
@@ -1,12 +1,10 @@
// Catalog discovery: import src/i18n/locales/<tag>.ts and plugins/<id>/i18n/<tag>.ts, then // Catalog discovery: import src/i18n/locales/<tag>.ts and plugins/<id>/i18n/<tag>.ts, then check
// check every one against its set's en-US baseline. The imperative shell over catalog.ts's pure // every one against its set's en-US baseline. The imperative shell over catalog.ts's pure rules,
// rules — the same contract as plugin discovery: one boot-stopping Error listing every problem, // with plugin discovery's contract: one boot-stopping Error listing every problem, so a
// so a half-translated deploy is caught at startup rather than as a stray English word in production. // half-translated deploy is caught at startup rather than as a stray English word in production.
// //
// Installed locales are whatever the core folder holds; a plugin may translate fewer of them (its // A plugin may translate fewer locales than the core holds (its strings then render in en-US) but
// strings then render in en-US on that page) but never one the host does not have. The operator's // never one the host lacks. The operator's `locales/` mount extends both sides.
// `locales/` mount extends both sides — `locales/<tag>.ts` for the core, `locales/plugins/<id>/<tag>.ts`
// for a plugin — so adding a language never means forking the image or a vendored plugin.
import { existsSync, readdirSync } from "node:fs"; import { existsSync, readdirSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
+1
View File
@@ -9,6 +9,7 @@ const messages = {
"auth.continue": "Continue", "auth.continue": "Continue",
// Kratos labels its own form fields; these translate the ones the built-in identity schema uses, // Kratos labels its own form fields; these translate the ones the built-in identity schema uses,
// keyed on the input name. A deployment's extra traits keep Kratos' label until a plugin covers them. // keyed on the input name. A deployment's extra traits keep Kratos' label until a plugin covers them.
"auth.field.code.hint": "Digits only — no spaces.",
"auth.field.email": "Email", "auth.field.email": "Email",
"auth.field.identifier": "Email", "auth.field.identifier": "Email",
"auth.field.password": "Password", "auth.field.password": "Password",
+1
View File
@@ -2,6 +2,7 @@ import type { CoreMessages } from "./en-US.ts";
const messages: CoreMessages = { const messages: CoreMessages = {
"auth.continue": "Fortsätt", "auth.continue": "Fortsätt",
"auth.field.code.hint": "Endast siffror — inga mellanslag.",
"auth.field.email": "E-postadress", "auth.field.email": "E-postadress",
"auth.field.identifier": "E-postadress", "auth.field.identifier": "E-postadress",
"auth.field.password": "Lösenord", "auth.field.password": "Lösenord",
+6 -9
View File
@@ -1,12 +1,9 @@
// The translator: a key + vars → the string to render. Pure and synchronous — views call it // The translator: a key + vars → the string to render. Two rules the rest of the app leans on:
// as `t("shell.signOut")` and handlers as `ctx.t(...)`. // · the lookup walks a catalog chain (plugin locale → plugin en-US → core locale → core en-US)
// // and returns the key itself when nothing has it — so a plain nav label like "Shifts" is its
// Two rules the rest of the app leans on: // own fallback and a manifest needs no catalog to keep working.
// · the lookup walks a catalog chain (plugin locale → plugin en-US → core locale → core en-US) and, // · the result is raw text, escaped by the view with <%= %> like any other value, so a
// when nothing has the key, returns the key itself. That is what makes a plain nav label like // translation is never double-escaped and one carrying markup is rendered with <%- %>.
// "Shifts" its own fallback — a manifest needs no catalog to keep working.
// · the result is raw text. Views escape with <%= %> exactly as they do for any other value, so a
// translation is never double-escaped, and a message that carries markup is rendered with <%- %>.
import { isPluralMessage, type Catalog, type PluralMessage } from "./catalog.ts"; import { isPluralMessage, type Catalog, type PluralMessage } from "./catalog.ts";
+10 -17
View File
@@ -21,11 +21,9 @@ export interface LoggerOptions {
stdout?: (msg: string) => void; stdout?: (msg: string) => void;
} }
// The app-level logger: a Log tagged service.name so every console line, OTLP log record and span is // The app-level logger, tagged service.name. With otlpEndpoint set, logs + spans also export to that
// attributed to the service. Level + format + name are explicit toggles (LOG_LEVEL/LOG_FORMAT/ // OTLP/HTTP collector; unset ⇒ console only, at zero export cost. The conditional spreads keep
// SERVICE_NAME — environment-agnostic, AGENTS.md §4). With otlpEndpoint set, logs + spans also export // exactOptionalPropertyTypes happy (no `key: undefined`).
// to that OTLP/HTTP collector (e.g. an OpenTelemetry Collector fronting Tempo/Loki); unset ⇒ console
// only, at zero export cost. Conditional spreads keep exactOptionalPropertyTypes happy (no `key: undefined`).
export function createLogger(opts: LoggerOptions = {}): Log { export function createLogger(opts: LoggerOptions = {}): Log {
return new Log({ return new Log({
context: { "service.name": opts.serviceName || SERVICE_NAME }, context: { "service.name": opts.serviceName || SERVICE_NAME },
@@ -49,13 +47,10 @@ export function currentLog(): Log | undefined {
return requestStore.getStore(); return requestStore.getStore();
} }
// A drop-in `fetch` that traces through the active request log — a client span nested under the // A drop-in `fetch` that traces through the active request log — a client span under the request
// request span, with a W3C `traceparent` injected so the downstream service continues the same // span, with a W3C `traceparent` injected so the downstream service continues the same trace.
// trace. Outside a request (no ambient log) or for a non-string/URL input it's a plain `fetch`. // Outside a request, or for a non-string/URL input, it is a plain `fetch`. Note log.fetch throws
// server.ts wires this (under the Ory timeout) into every Kratos/Keto/Hydra/JWKS call; a plugin // synchronously once the request log has ended; app.ts ends it only after the handler unwinds.
// uses it for its upstream calls (exported via plugin-api.ts). The trace-setup adds no throw of its
// own, but log.fetch throws synchronously if the request log has already ended (app.ts ends it only
// after the handler unwinds, so a live handler never hits that).
export const tracedFetch: typeof fetch = (input, init) => { export const tracedFetch: typeof fetch = (input, init) => {
const log = currentLog(); const log = currentLog();
if (log && (typeof input === "string" || input instanceof URL)) return log.fetch(input, init); if (log && (typeof input === "string" || input instanceof URL)) return log.fetch(input, init);
@@ -63,11 +58,9 @@ export const tracedFetch: typeof fetch = (input, init) => {
}; };
// A per-request child logger holding a "request" trace span. `clone` (not parentLog) gives the // A per-request child logger holding a "request" trace span. `clone` (not parentLog) gives the
// request its own root trace so requests aren't all nested under one app-lifetime span while // request its own root trace, so requests aren't all nested under one app-lifetime span, while
// inheriting the parent's level/format/streams/OTLP. A valid upstream W3C `traceparent` is adopted // inheriting the parent's level/format/streams/OTLP. A valid upstream `traceparent` is adopted;
// (the span continues that distributed trace across a reverse proxy/gateway; malformed ⇒ ignored, a // malformed ⇒ ignored, a fresh trace starts. `end()` on response finish exports the span.
// fresh trace starts). `requestId` tags every line + the span for log↔trace correlation. Flush with
// `end()` on response finish to export the span — a no-op when OTLP is off.
export function requestLogger(appLog: Log, opts: { requestId: string; traceparent?: string | undefined }): Log { export function requestLogger(appLog: Log, opts: { requestId: string; traceparent?: string | undefined }): Log {
return appLog.clone({ return appLog.clone({
context: { ...appLog.context, requestId: opts.requestId }, context: { ...appLog.context, requestId: opts.requestId },
+83 -15
View File
@@ -1,9 +1,10 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { test, type TestContext } from "node:test"; import { test, type TestContext } from "node:test";
import { discoverPlugins } from "./discovery.ts"; import { discoverPlugins } from "./discovery.ts";
import { HOST_API_VERSION } from "./plugin.ts";
// Write a throwaway plugins/ tree of `relpath → source` and clean it up after the test. Fixtures // Write a throwaway plugins/ tree of `relpath → source` and clean it up after the test. Fixtures
// default-export plain objects — definePlugin is identity, so a literal is an equivalent manifest. // default-export plain objects — definePlugin is identity, so a literal is an equivalent manifest.
@@ -19,7 +20,7 @@ function scaffold(t: TestContext, files: Record<string, string>): string {
} }
const full = (id: string): string => const full = (id: string): string =>
`export default { apiVersion: "1.0.0", nav: [{ id: "${id}:root", label: "${id}" }], ` + `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}" }], ` +
`routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`; `routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`;
test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => { test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => {
@@ -27,13 +28,19 @@ test("a missing plugins/ dir means zero plugins, not an error (clean clone)", as
}); });
test("discovers each folder's manifest, sorted, id derived from the folder name", async (t) => { test("discovers each folder's manifest, sorted, id derived from the folder name", async (t) => {
const dir = scaffold(t, { "beta/plugin.ts": full("beta"), "alpha/plugin.ts": full("alpha") }); const dir = scaffold(t, {
"beta/plugin.ts": full("beta"),
"alpha/plugin.ts": full("alpha"),
"gamma/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", storage: true };`,
});
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta"]); // deterministic order assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta", "gamma"]); // deterministic order
assert.equal(plugins[0]?.apiVersion, "1.0.0"); assert.equal(plugins[0]?.apiVersion, HOST_API_VERSION);
assert.equal(plugins[0]?.nav?.[0]?.label, "alpha"); assert.equal(plugins[0]?.nav?.[0]?.label, "alpha");
assert.equal(typeof plugins[0]?.routes?.[0]?.handler, "function"); // handlers survive import assert.equal(typeof plugins[0]?.routes?.[0]?.handler, "function"); // handlers survive import
assert.equal(plugins[0]?.storage, undefined); // storage is opt-in, never assumed
assert.equal(plugins[2]?.storage, true);
}); });
// Every per-plugin problem and every error-level conflict aborts boot with a message naming it. // Every per-plugin problem and every error-level conflict aborts boot with a message naming it.
@@ -45,15 +52,30 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
{ name: "no default export", files: { "named-only/plugin.ts": "export const x = 1;" }, match: /named-only.*default/s }, { name: "no default export", files: { "named-only/plugin.ts": "export const x = 1;" }, match: /named-only.*default/s },
{ name: "import throws", files: { "explodes/plugin.ts": "throw new Error('boom');" }, match: /explodes.*boom/s }, { name: "import throws", files: { "explodes/plugin.ts": "throw new Error('boom');" }, match: /explodes.*boom/s },
{ name: "incompatible apiVersion", files: { "future/plugin.ts": `export default { apiVersion: "2.0.0" };` }, match: /future.*apiVersion/s }, { name: "incompatible apiVersion", files: { "future/plugin.ts": `export default { apiVersion: "2.0.0" };` }, match: /future.*apiVersion/s },
{ name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "1.0.0", routes: "nope" };` }, match: /weird.*routes.*array/s }, { name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: "nope" };` }, match: /weird.*routes.*array/s },
{ name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "1.0.0", home: "nope" };` }, match: /weirdhome.*home.*function/s }, { name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: "nope" };` }, match: /weirdhome.*home.*function/s },
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s }, { name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
{ name: "non-boolean storage", files: { "weirdstore/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", storage: "postgres://db" };` }, match: /weirdstore.*storage.*boolean/s },
// The folder name becomes a Postgres identifier, which truncates past 63 bytes.
{ name: "a storage plugin whose folder name overflows a Postgres identifier", files: { [`${"a".repeat(57)}/plugin.ts`]: `export default { apiVersion: "${HOST_API_VERSION}", storage: true };` }, match: /storage.*56 characters/s },
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s }, { name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ }, { name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s }, { name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x" }] };` }, match: /contranav.*public.*permission/s }, { name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s },
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ }, // A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ }, // only in the admin GUI, so it holds for a plugin installed without that GUI.
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
{ name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s },
{ name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s },
{ name: "a plugin shipping its own copy of the barrel", files: { "shadow/node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "shadow/plugin.ts": full("shadow") }, match: /shadow.*@plainpages\/plugin-api/s },
{ name: "a plugin package.json that forgets type: module", files: { "cjs/package.json": `{ "name": "cjs" }`, "cjs/plugin.ts": full("cjs") }, match: /cjs.*"type": "module"/s },
{ name: "a plugin package.json that is not valid JSON", files: { "bent/package.json": `{`, "bent/plugin.ts": full("bent") }, match: /bent.*package\.json.*JSON/s },
{ name: "a plugin package.json holding null", files: { "nul/package.json": `null`, "nul/plugin.ts": full("nul") }, match: /nul.*"type": "module"/s },
// `npm install --prefix plugins` — the documented command with one path segment dropped.
{ name: "a package.json in the scan root itself", files: { "package.json": `{ "name": "oops" }`, "ok/plugin.ts": full("ok") }, match: /plugins\/package\.json must not exist/ },
{ name: "a node_modules in the scan root itself", files: { "node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "ok/plugin.ts": full("ok") }, match: /plugins\/node_modules must not exist/ },
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ html: "b" }) };` }, match: /home/ },
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
]; ];
for (const c of badCases) { for (const c of badCases) {
@@ -62,8 +84,20 @@ for (const c of badCases) {
}); });
} }
// The reader of a discovery failure is usually an operator whose plugins/ copy went stale after an
// upgrade, not the author of the manifest — so the message has to carry the remedy, not just the
// rule. A pre-existing `plugins/admin` gating on the old `admin` permission is exactly this case.
test("a discovery failure tells the operator their plugins/ copy may just be out of date", async (t) => {
const dir = scaffold(t, { "admin/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/users", permission: "admin", handler: () => ({ html: "x" }) }] };` });
await assert.rejects(discoverPlugins({ dir }), (err: Error) => {
assert.match(err.message, /gates on "admin"/); // what is wrong
assert.match(err.message, /re-copy it/); // …and what to do about it
return true;
});
});
test("a route + nav node may be marked public and load fine", async (t) => { test("a route + nav node may be marked public and load fine", async (t) => {
const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` }); const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` });
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
assert.equal(plugins.length, 1); assert.equal(plugins.length, 1);
assert.equal(plugins[0]?.routes?.[0]?.public, true); assert.equal(plugins[0]?.routes?.[0]?.public, true);
@@ -78,15 +112,49 @@ test("`admin` is not reserved — the admin screens ship as a drop-in plugin mou
}); });
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => { test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` }); const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` });
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
assert.equal(plugins.length, 1); assert.equal(plugins.length, 1);
assert.equal(typeof plugins[0]?.home, "function"); assert.equal(typeof plugins[0]?.home, "function");
assert.equal(typeof plugins[0]?.dashboard, "function"); assert.equal(typeof plugins[0]?.dashboard, "function");
}); });
// Host deps sit at /node_modules, above every plugin scope, so the barrel resolves from a folder
// that has its own package.json (README → Plugin dependencies).
test("a plugin may carry its own package.json, node_modules and dependencies", async (t) => {
const dir = scaffold(t, {
"shop/package.json": `{ "name": "shop", "version": "0.0.0", "type": "module", "dependencies": { "price-tag": "1.0.0" } }`,
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`,
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
`export default definePlugin({ apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`,
});
const plugins = await discoverPlugins({ dir });
assert.deepEqual(plugins.map((p) => p.id), ["shop"]);
assert.deepEqual(await plugins[0]?.routes?.[0]?.handler(null as never), { html: "20 kr" });
});
test("a plugin folder may be a symlink", async (t) => {
const ownRepo = scaffold(t, { "my-plugin/plugin.ts": full("my-plugin") });
const dir = scaffold(t, {});
symlinkSync(join(ownRepo, "my-plugin"), join(dir, "linked"));
const plugins = await discoverPlugins({ dir });
assert.deepEqual(plugins.map((p) => p.id), ["linked"]); // the link name is the id, not the target's
});
test("a dangling plugin symlink fails loud rather than vanishing", async (t) => {
const dir = scaffold(t, {});
symlinkSync(join(dir, "gone"), join(dir, "broken"));
await assert.rejects(discoverPlugins({ dir }), /broken.*plugin\.ts/s);
});
test("a shared permission name only warns — both plugins still load", async (t) => { test("a shared permission name only warns — both plugins still load", async (t) => {
const shared = `export default { apiVersion: "1.0.0", permissions: [{ name: "shared:read" }] };`; const shared = `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "shared:read" }] };`;
const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared }); const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
const warnings: string[] = []; const warnings: string[] = [];
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } }); const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
+79 -6
View File
@@ -4,10 +4,11 @@
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics // error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
// (older-minor apiVersion, shared permission name) log and load continues. Folder name = id. // (older-minor apiVersion, shared permission name) log and load continues. Folder name = id.
import { existsSync, readdirSync } from "node:fs"; import { existsSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { checkApiVersion, findConflicts, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts"; import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
@@ -27,6 +28,14 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
const errors: string[] = []; const errors: string[] = [];
const plugins: Plugin[] = []; const plugins: Plugin[] = [];
// `npm install --prefix plugins` instead of `--prefix plugins/<id>`: the package.json becomes the
// scope for every plugin below it, and the node_modules outranks the host's own — barrel included.
for (const stray of ["node_modules", "package.json"]) {
if (existsSync(join(dir, stray))) {
errors.push(`plugins/${stray} must not exist — it sits above every plugin and shadows the host's own; delete plugins/{node_modules,package.json,package-lock.json} and install into plugins/<id>`);
}
}
for (const id of pluginFolders(dir)) { for (const id of pluginFolders(dir)) {
const fail = (msg: string): void => void errors.push(`plugins/${id}: ${msg}`); const fail = (msg: string): void => void errors.push(`plugins/${id}: ${msg}`);
@@ -37,6 +46,8 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
if (RESERVED_PLUGIN_IDS.has(id)) { fail(`"${id}" is a reserved id — it would shadow a built-in host route`); continue; } if (RESERVED_PLUGIN_IDS.has(id)) { fail(`"${id}" is a reserved id — it would shadow a built-in host route`); continue; }
const file = join(dir, id, "plugin.ts"); const file = join(dir, id, "plugin.ts");
if (!existsSync(file)) { fail("no plugin.ts found"); continue; } if (!existsSync(file)) { fail("no plugin.ts found"); continue; }
const packaging = packagingError(join(dir, id));
if (packaging) { fail(packaging); continue; }
let mod: { default?: unknown }; let mod: { default?: unknown };
try { try {
@@ -56,6 +67,13 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
const shape = shapeError(manifest); const shape = shapeError(manifest);
if (shape) { fail(shape); continue; } if (shape) { fail(shape); continue; }
// The folder name becomes a Postgres identifier, which truncates past 63 bytes — two long ids
// would then share one database. Only checked for a plugin that asked for storage.
if (manifest.storage === true && !isValidStoragePluginId(id)) {
fail(`declares storage, so its folder name must be at most ${MAX_STORAGE_PLUGIN_ID_LENGTH} characters`);
continue;
}
plugins.push({ ...manifest, id }); // identity is the folder, not the manifest plugins.push({ ...manifest, id }); // identity is the folder, not the manifest
} }
@@ -65,20 +83,48 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
} }
if (errors.length) { if (errors.length) {
throw new Error(`Plugin discovery failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`); // `plugins/` is a drop-in mount the operator owns, so the reader of this message often didn't
// write the manifest — they copied it. Tightening a contract rule breaks those copies at boot,
// and the rule alone doesn't tell them the remedy is one command.
throw new Error(
`Plugin discovery failed:\n${errors.map((e) => ` - ${e}`).join("\n")}\n` +
`A plugin under plugins/ is your own copy. If it came from examples/, re-copy it — ` +
`the host contract may have changed since (see README → Upgrading).`,
);
} }
return plugins; return plugins;
} }
// Subfolders of plugins/, sorted for deterministic load order + stable conflict messages. Hidden // Sorted for deterministic load order + stable conflict messages. A symlink counts as a folder, and
// entries (.git, .DS_Store, …) and non-directories are skipped — only folders are plugins. // one whose target the container cannot see trips "no plugin.ts found" rather than vanishing.
function pluginFolders(dir: string): string[] { function pluginFolders(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }) return readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith(".")) .filter((e) => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith(".") && e.name !== "node_modules")
.map((e) => e.name) .map((e) => e.name)
.sort(); .sort();
} }
// A barrel copy resolves before the host's, so its GuardError matches no `instanceof` here and a
// sign-in redirect becomes a 500.
function packagingError(folder: string): string | null {
if (existsSync(join(folder, "node_modules", "@plainpages", "plugin-api"))) {
return "ships its own copy of @plainpages/plugin-api — remove it; the host provides the one instance";
}
const file = join(folder, "package.json");
if (!existsSync(file)) return null;
let manifest: { type?: unknown } | null;
try {
manifest = JSON.parse(readFileSync(file, "utf8")) as { type?: unknown } | null;
} catch (err) {
return `package.json could not be read as JSON — ${messageOf(err)}`;
}
return manifest?.type === "module"
? null
: `package.json must set "type": "module" — npm writes no type, and Node then re-parses every file in the folder`;
}
function asManifest(value: unknown): PluginManifest | null { function asManifest(value: unknown): PluginManifest | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as PluginManifest) : null; return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as PluginManifest) : null;
} }
@@ -93,6 +139,8 @@ function shapeError(manifest: PluginManifest): string | null {
for (const slot of ["home", "dashboard"] as const) { for (const slot of ["home", "dashboard"] as const) {
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`; if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
} }
// A truthy non-boolean (a DSN, say) must not quietly read as "provision me one".
if (manifest.storage !== undefined && typeof manifest.storage !== "boolean") return `"storage" must be a boolean`;
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs // `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
// "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous. // "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) { for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
@@ -100,6 +148,20 @@ function shapeError(manifest: PluginManifest): string | null {
} }
const navContradiction = findPublicNavContradiction(manifest.nav); const navContradiction = findPublicNavContradiction(manifest.nav);
if (navContradiction) return navContradiction; if (navContradiction) return navContradiction;
// Every permission name the manifest mentions — gated on or declared — must be `<resource>:<action>`.
// A bare word names a role, and roles are groups here (README → Naming a permission).
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
if (route?.permission != null && !isValidPermissionName(route.permission)) {
return `route "${route.method} ${route.path}" gates on "${route.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
}
}
for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) {
if (decl?.name == null || !isValidPermissionName(decl.name)) {
return `declared permission "${decl?.name}" is not <resource>:<action>, e.g. "things:read"`;
}
}
const navPermission = findInvalidNavPermission(manifest.nav);
if (navPermission) return navPermission;
return null; return null;
} }
@@ -113,6 +175,17 @@ function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null
return null; return null;
} }
function findInvalidNavPermission(nodes: PluginManifest["nav"]): string | null {
for (const node of Array.isArray(nodes) ? nodes : []) {
if (node?.permission != null && !isValidPermissionName(node.permission)) {
return `nav node "${node.label ?? node.id ?? "?"}" gates on "${node.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
}
const inChild = findInvalidNavPermission(node?.children);
if (inChild) return inChild;
}
return null;
}
function messageOf(err: unknown): string { function messageOf(err: unknown): string {
return err instanceof Error ? err.message : String(err); return err instanceof Error ? err.message : String(err);
} }
+5 -2
View File
@@ -12,14 +12,17 @@ function plugin(id: string, hooks: PluginHooks): Plugin {
test("runBootHooks runs each onBoot in order, skips plugins without one, and a throw aborts", async () => { test("runBootHooks runs each onBoot in order, skips plugins without one, and a throw aborts", async () => {
const calls: string[] = []; const calls: string[] = [];
const scoped: string[] = []; // each hook is handed a context built for its own plugin
const bootContextFor = (built: Plugin) => { scoped.push(built.id); return {}; };
await runBootHooks([ await runBootHooks([
plugin("a", { onBoot: () => void calls.push("a") }), plugin("a", { onBoot: () => void calls.push("a") }),
plugin("b", {}), // no onBoot → skipped plugin("b", {}), // no onBoot → skipped
plugin("c", { onBoot: async () => void calls.push("c") }), plugin("c", { onBoot: async () => void calls.push("c") }),
]); ], bootContextFor);
assert.deepEqual(calls, ["a", "c"]); assert.deepEqual(calls, ["a", "c"]);
assert.deepEqual(scoped, ["a", "c"]); // and built only for the plugins that have one
await assert.rejects(runBootHooks([plugin("x", { onBoot: () => { throw new Error("boom"); } })]), /boom/); await assert.rejects(runBootHooks([plugin("x", { onBoot: () => { throw new Error("boom"); } })], () => ({})), /boom/);
}); });
test("runRequestHooks short-circuits on the first RouteResult (with its plugin); later hooks skipped", async () => { test("runRequestHooks short-circuits on the first RouteResult (with its plugin); later hooks skipped", async () => {
+8 -4
View File
@@ -4,11 +4,15 @@
// entirely when no plugin declares the hook, so the no-hooks hot path stays free. // entirely when no plugin declares the hook, so the no-hooks hot path stays free.
import type { RequestContext } from "../http/context.ts"; import type { RequestContext } from "../http/context.ts";
import type { Plugin, RouteResult } from "./plugin.ts"; import type { BootContext, Plugin, RouteResult } from "./plugin.ts";
// After discovery, before the server listens. A throw aborts boot. // After discovery, before the server listens. A throw aborts boot. Each hook gets a context built
export async function runBootHooks(plugins: Plugin[]): Promise<void> { // for its own plugin, so one plugin is never handed another's storage credentials.
for (const plugin of plugins) await plugin.hooks?.onBoot?.(); export async function runBootHooks(plugins: Plugin[], bootContextFor: (plugin: Plugin) => BootContext): Promise<void> {
for (const plugin of plugins) {
const onBoot = plugin.hooks?.onBoot;
if (onBoot) await onBoot(bootContextFor(plugin));
}
} }
// Before route matching. The first hook to return a RouteResult short-circuits the request — its // Before route matching. The first hook to return a RouteResult short-circuits the request — its

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