Compare commits

..

32 Commits

Author SHA1 Message Date
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
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 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
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
78 changed files with 418 additions and 2019 deletions
+1 -1
View File
@@ -19,4 +19,4 @@ jobs:
run: | run: |
docker run --rm -v "$PWD:/repo" -w /repo \ docker run --rm -v "$PWD:/repo" -w /repo \
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \ -e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
node:24.21.0-alpine3.24 node registry-cleanup/cleanup.ts node:24.19.0-alpine3.24 node registry-cleanup/cleanup.ts
+7 -65
View File
@@ -2,27 +2,12 @@ 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.21.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 }}
@@ -30,77 +15,34 @@ 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; }
# No bare-major tag while major is 0: a 0.x minor is a contract break, so `:0` would move for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
# 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_IMAGE: docker.io/${{ github.repository }} DOCKERHUB_REPO: 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
TAGS="$VERSION ${VERSION%.*} latest" for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG"
for TAG in $TAGS; do docker push "$DOCKERHUB_REPO:$TAG"
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: |
set -uo pipefail docker logout gitea.larvit.se
# Cleanup, and the runner's Docker config is shared (AGENTS.md) — a lost race here must not docker logout docker.io
# 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.21.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.21.0-alpine3.24 \
node release-tooling/dockerhub-overview.ts "$VERSION"
+5 -13
View File
@@ -21,15 +21,14 @@ 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.145.1 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), nothing new merged, or nothing that merged carried a `Release-Bump:` # (a human owns that release) or nothing new merged. ff-only merges keep the renovate commit's
# 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 (release-tooling/next-version.ts). Tag-only — release.yml promotes the # pre-1.0 shifts down (auto-release/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).
auto-release: auto-release:
runs-on: docker-host runs-on: docker-host
@@ -55,15 +54,8 @@ 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 NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0 node auto-release/next-version.ts "$LATEST" $BUMPS)
fi
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.21.0-alpine3.24 \
node release-tooling/next-version.ts "$LATEST" $BUMPS)
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
git show origin/main:src/plugin-host/plugin.ts \
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.21.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"
+19 -73
View File
@@ -36,15 +36,6 @@ 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.
**A page is a document**: it scrolls, and the chrome scrolls with it. Nothing may bound the
viewport to hold content still — no `height: 100dvh` frame, no `overflow: hidden` on `body`, no
`position: sticky` header. Each such box buys an app-like look with CSS the next reader has to
reverse-engineer, and is one more thing to undo before the content under it can be reached.
Overlays are not this: the skip link, the mobile off-canvas nav and its scrim sit *above* the
document rather than holding it still, and have no other spelling — the document keeps scrolling
behind the open nav, accepted rather than overlooked. Only the document scroller gets keyboard
paging unconditionally and back/forward scroll restoration, and a page a box clips fails silently:
nothing in a test or a console says content is unreachable below the fold.
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`, 2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks. `postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
@@ -102,16 +93,6 @@ Revisit only if the stated reason stops holding.
plugin cannot destroy data — boot logs the orphans instead. Because the host's copy sits in the plugin cannot destroy data — boot logs the orphans instead. Because the host's copy sits in the
ambient `/node_modules`, a plugin can `import "postgres"` without declaring it — incidental, not a ambient `/node_modules`, a plugin can `import "postgres"` without declaring it — incidental, not a
packaging promise, and a plugin must still depend on its own driver. packaging promise, and a plugin must still depend on its own driver.
- **Plugin settings are declared, not discovered** (README → Plugin settings). `settings.ts` is pure and
takes the env as an argument, so the whole matrix unit-tests without a stack. Four rules carry the
design: the prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`, because a plugin id `db` with key
`url` would otherwise name the host's own `PLUGIN_DB_URL`; keys are camelCase so the
`camelCase → SNAKE_CASE` mapping is total and no two keys collide, with the residual cross-plugin
collision caught by `findConflicts`; `required` and `default` are mutually exclusive, which is what
lets `SettingsOf` type a declared key as present rather than `T | undefined`, so no plugin author
casts; and a secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings`
— not even as a mask or a length. An author mistake is refused at discovery, a bad operator value
refuses the boot, and a stray `PLUGIN_SETTING_` variable only warns (the orphan-database precedent).
- **The trust boundary is the `web` process, not the plugin.** Per-plugin databases and roles bound - **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 *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 a plugin already holds `ctx.system`'s Ory admin clients — so cross-plugin DB isolation is
@@ -191,22 +172,6 @@ Revisit only if the stated reason stops holding.
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport × 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
verb — a route table must answer "what does this need?" on its own. verb — a route table must answer "what does this need?" on its own.
- **A gate is one of three, named exactly once, and `session` is a first-class one.** A route or nav
node names exactly one of `public`, `session`, `permission` — discovery refuses none, two, and a
flag spelled anything but `true`, so a forgotten gate fails the boot rather than publishing a page.
`src/auth/gate.ts` is the one home of the rule the plugin router, the host's own route table and
the menu all read. Exactly-one-gate is a discovery-time rule on manifests, not a runtime
invariant: `allows({}, user)` stays open **by design**, because the central override's `groups`
builds header nodes that carry no gate. Making `allows` fail closed would hide every
operator-grouped section. `session` exists because a plugin whose data is
the visitor's own — their upstream account, their own tokens — has no distinction a permission could
name; the alternative, granting every newly registered user a permission, couples the identity
lifecycle to a Keto write that nothing retries when it fails. A page scoped to "mine" joins on
`ctx.user.id`, never the email — an address is user-changeable and can be reassigned to someone
who would then inherit the previous holder's rows.
- **The reference plugin's two shift pages duplicate a view model and markup on purpose.** An example
is read far more often than it is changed, and each page reads top to bottom on its own. **Valid
while `examples/plugins/scheduling` stays a teaching artifact rather than a maintained product.**
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry - **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders, `canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
@@ -279,18 +244,11 @@ Revisit only if the stated reason stops holding.
open-state style and the old-browser fallback both read; the partial **requires a caller-named open-state style and the old-browser fallback both read; the partial **requires a caller-named
`id`** and fails loud without one, since that is the `popovertarget` idref (never generate one — `id`** and fails loud without one, since that is the `popovertarget` idref (never generate one —
nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
`aria-haspopup` is written**: every engine maintains the first itself on a declarative `aria-haspopup` is written**, because a zero-JS invoker cannot keep the first truthful and the
`popovertarget` invoker, so a hand-written one replaces a live state with a static lie, and the second would promise `role="menu"` semantics these panels don't implement. `<details>` stays where
second would promise `role="menu"` semantics these panels don't implement. **That guarantee is the
declarative attribute's alone** — open a panel from script and no engine applies it, so
"enhancing" one of these triggers is what would cost it its accessibility. `<details>` stays where
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
the two in step. the two in step.
- **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s
`overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame:
they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` —
a label click focuses it, and a browser scrolls a focused element into view.
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it - **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
@@ -329,17 +287,12 @@ Revisit only if the stated reason stops holding.
console message only appears in the engine that renders the page (`ORY_FREE` in console message only appears in the engine that renders the page (`ORY_FREE` in
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend, `e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
so widening them means a stack per engine. so widening them means a stack per engine.
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** Both git channels in - **The docs-only CI skip is `*.md` anywhere in the tree, not just the root** — no test, build step or
`ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the destination, so workflow reads a markdown file. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`:
`git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a source file that rename detection names only the destination, so `git mv src/app.ts notes.md` would otherwise read as
was gone. `src/ci-gate.test.ts` locks the flags as a 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 *text* guard — the test image ships neither `git` nor `bash`. Revisit if a `.md` ever becomes
`release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test load-bearing.
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 - **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 `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 can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
@@ -394,18 +347,14 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never - Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images
by tag. by tag.
- **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist*: its - **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist* — only
rules name exactly what carries the trailer, so a dependency outside them never escalates the the root `package.json`'s runtime deps, the `Dockerfile` base and `compose.yml`'s services carry the
release version and nothing fails to say so. A new manifest, compose file, custom manager or dep trailer, so a dependency added anywhere else never escalates the release version and nothing fails to
type is a decision: can it reach a running Plainpages? If yes it needs a rule; if no, record nothing say so. A new manifest, compose file, custom manager or dep type is a decision: can it reach a
and let it ride the next patch. running Plainpages? If yes it needs a rule; if no, record nothing and let it ride the next patch.
- **`HOST_API_VERSION` *is* the release version.** Its `major.minor` must equal the release tag's, and - **`HOST_API_VERSION` is a live promise as of the v0.1.0 release** (the app's version and the
both release paths refuse a tag that disagrees (`release-tooling/contract-version.ts`). The patch contract's move independently). Bump it with every contract change, per the table in README →
digit may lag on purpose: `checkApiVersion` Contract versioning: major on a breaking one, minor on an additive one. **The contract surface
ignores patch, and auto-release cuts patch releases with no commit to bump a constant in. So a
dependency update big enough to force a **minor** is plugin-visible by definition — `auto-release`
stops rather than tagging, and the fix is to bump `HOST_API_VERSION` to that `X.Y.0` in a PR, merge
it, then tag. Never bump it to "catch up" with a patch release. **The contract surface
includes `views/partials/*.ejs`** — the view resolver makes every core partial an `include()` root includes `views/partials/*.ejs`** — the view resolver makes every core partial an `include()` root
for a plugin's views, so their option names and emitted markup are author-visible. Know the hole for a plugin's views, so their option names and emitted markup are author-visible. Know the hole
that leaves: discovery fails loud on a bad `apiVersion`, but `include("menu", { open: true })` that leaves: discovery fails loud on a bad `apiVersion`, but `include("menu", { open: true })`
@@ -447,12 +396,9 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
same rule in their own language. An unmapped Kratos id renders Kratos' own wording — map the id when same rule in their own language. An unmapped Kratos id renders Kratos' own wording — map the id when
it matters. **Held by the author, never by a test:** slightly different wording is often the right it matters. **Held by the author, never by a test:** slightly different wording is often the right
call, and a build-failing check takes that judgment away. call, and a build-failing check takes that judgment away.
- Use well formed, standard compliant, rich URIs. **State lives in the URL or on the server, never in - Use well formed, standard compliant, rich URIs. Prefer state in the URL over POSTing it, for
a cookie.** Prefer state in the URL over POSTing it, for example on list pages with filters and example on list pages with filters and pagination. Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not
pagination. A message for the page a redirect lands on rides its query string — `info-msg`, `ids=x,y`.
`warn-msg`, `error-msg` — since a fragment never reaches the server. A cookie carries only what
must be bound to the browser: the session (`plainpages_jwt`) and the CSRF token (`plainpages_csrf`).
Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not `ids=x,y`.
## Comments ## Comments
-88
View File
@@ -1,88 +0,0 @@
# Changelog
The release version **is** the plugin contract version (`HOST_API_VERSION`), so a minor is a
contract break: a plugin's `apiVersion` must match the host's `major.minor` or discovery refuses it
at boot. Entries start at 0.3.0.
## 0.4.0
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
that relied on filling it scrolls the document instead.
### The document scrolls, and the chrome scrolls with it
`.app` was a `100dvh` box with `overflow: hidden`, so a page was only reachable below the fold if its
own wrapper was a flex child with `overflow-y: auto`. `.table-wrap` and `.shell-auth` were; nothing
else was, and a long page in `.form-page` clipped everything past the window in every engine.
Now the shell is `min-height: 100dvh` and nothing bounds the viewport. The sidebar and topbar scroll
with the page, and keyboard paging, back/forward scroll restoration and find-in-page work without a
page doing anything.
The sticky `thead` on `data-table` goes with it: a header only sticks to a scrollport that moves, and
there is no longer one. A plugin that wants a full-height pane owns that in its own stylesheet; the
shell offers no opt-out, per the simplicity priority in `AGENTS.md`.
### Upgrading a plugin
1. Set `apiVersion: "0.4.0"`.
2. A page that scrolled the whole window needs no change — it now scrolls the document.
3. A page holding a region that filled the content column (`flex: 1 1 auto; min-height: 0` with its
own `overflow`) no longer gets a bounded column to fill, so that region grows and the page scrolls.
Either let it, or give the region its own height in the plugin's stylesheet.
4. A `data-table` no longer scrolls its rows in a bounded region: the page scrolls, and the header
scrolls with it.
The sidebar stretches the whole document, so on a long page its footer — theme, language, profile and
**Sign out** — sits at the end of that page rather than the bottom of the screen.
## 0.3.0
**Breaking.** Set `apiVersion: "0.3.0"`, and name a gate on every route and nav node.
### A session is a gate of its own
`session: true` takes any signed-in user, with no grant to hold — for a page whose data is the
visitor's own (their upstream account, their own tokens), where there is no distinction a permission
could name. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
permission gate does.
Every route and nav node now names **exactly one** of `public: true`, `session: true` or
`permission: "<resource>:<action>"`, and a gate is spelled `true`:
- Naming **none** is refused. It used to mean public, so a forgotten gate published a page; it now
fails the boot instead.
- Naming **two** is refused, as before.
- Spelling one anything but `true` is refused — `public: false` and `session: "yes"` both set no gate
while reading as if they set one.
A section header gates nothing itself, so it takes `public: true` and lets each child decide; the
host still drops a header whose children all filtered out.
`Gate` is exported from `@plainpages/plugin-api`, and `Route` and `NavNode` extend it.
### Filter bars take a multi-select
The `filter-bar` partial gains a `multiselect` control — the same checkboxes on the same query
parameter as `chips`, but behind a button once the list is too long to lay on the bar. Config is
`{ name, legend?, note?, value?, options }`, and the panel says what a capped list left out.
### Fixed
- An identity carrying no email no longer yields a session at all. Login used to mint a JWT for one,
which every later request then rejected as anonymous — leaving the browser holding a dead cookie
and no way to tell why.
### Dependencies
- Node 24.20.0.
### Upgrading a plugin
1. Set `apiVersion: "0.3.0"`.
2. Give every route and nav node a gate. Anything that relied on omitting one was public — say
`public: true` outright.
A page that scopes rows to the signed-in visitor should join on `ctx.user.id`. An email address is
user-changeable and can be reassigned to someone else, who would then inherit the previous holder's
rows. The reference plugin's new `/scheduling/mine` page shows the shape.
+1 -1
View File
@@ -1,5 +1,5 @@
# 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.21.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 # 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). # instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
@@ -10,7 +10,7 @@ one; the host itself is stateless, and there is no build step.
## Tags ## Tags
`X.Y.Z` · `X.Y` · `latest` — each is a release promoted from a CI-gated build. `X.Y.Z` · `X.Y` · `X` · `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
@@ -22,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:{{VERSION}} image: larvit/plainpages:0.1.0
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
@@ -41,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:{{VERSION}} image: larvit/plainpages:0.1.0
command: node src/auth/bootstrap.ts command: node src/auth/bootstrap.ts
depends_on: depends_on:
kratos: kratos:
@@ -54,7 +54,7 @@ services:
restart: "on-failure:5" restart: "on-failure:5"
postgres: postgres:
image: postgres:18.6-alpine3.23 image: postgres:18.4-alpine3.23
environment: environment:
POSTGRES_DB: ory POSTGRES_DB: ory
POSTGRES_PASSWORD: ory POSTGRES_PASSWORD: ory
@@ -131,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.31.6 image: axllent/mailpit:v1.30.1
ports: ports:
- "8025:8025" - "8025:8025"
restart: unless-stopped restart: unless-stopped
@@ -143,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:{{VERSION}} tar -cf - ory | tar -xf - docker run --rm larvit/plainpages:0.1.0 tar -cf - ory | tar -xf -
mkdir -p plugins mkdir -p plugins
docker compose up -d docker compose up -d
``` ```
@@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
import { definePlugin } from "@plainpages/plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "0.4.0", apiVersion: "1.0.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>" }) },
+45 -154
View File
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
import { definePlugin } from "@plainpages/plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "0.4.0", apiVersion: "1.0.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>" }) },
@@ -89,7 +89,6 @@ From here, render real pages against the app shell and fetch upstream data — s
- [hooks](#hooks) - [hooks](#hooks)
- [where they live & mounting](#where-plugins-live-and-how-to-mount-them) - [where they live & mounting](#where-plugins-live-and-how-to-mount-them)
- [dependencies](#plugin-dependencies) - [dependencies](#plugin-dependencies)
- [settings](#plugin-settings)
- [storage](#plugin-storage) - [storage](#plugin-storage)
- [local dev & test](#local-dev--test-story) - [local dev & test](#local-dev--test-story)
- [The menu system](#the-menu-system) - [The menu system](#the-menu-system)
@@ -231,7 +230,6 @@ Against the reference plugins' actual routes:
| Request | Gate | alice | bob | carol | anonymous | | Request | Gate | alice | bob | carol | anonymous |
| --- | --- | --- | --- | --- | --- | | --- | --- | --- | --- | --- | --- |
| `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ | | `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ |
| `GET /scheduling/mine` | `session: true` | ✅ | ✅ | ✅ | → `/login` |
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` | | `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` | | `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` | | `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
@@ -350,9 +348,9 @@ import { definePlugin } from "@plainpages/plugin-api";
import { listThings, createThings } from "./handlers.ts"; import { listThings, createThings } from "./handlers.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "0.4.0", // semver string of the host contract this plugin was built against (see Versioning) apiVersion: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning)
// Nav fragment, merged into the global menu and gate-filtered per user. // Nav fragment, merged into the global menu and permission-filtered per user.
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }], nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
@@ -362,7 +360,7 @@ export default definePlugin({
{ description: "Create and edit things", name: "things:write" }, { description: "Create and edit things", name: "things:write" },
], ],
// Route handlers, mounted under the plugin's path (/things). The gate runs first. // Route handlers, mounted under the plugin's path (/things). `permission` gates first.
routes: [ routes: [
{ method: "GET", path: "/", permission: "things:read", handler: listThings }, { method: "GET", path: "/", permission: "things:read", handler: listThings },
{ method: "POST", path: "/", permission: "things:write", handler: createThings }, { method: "POST", path: "/", permission: "things:write", handler: createThings },
@@ -379,24 +377,23 @@ folder-derived `id` to produce the loaded `Plugin`.
| `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). | | `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). |
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). | | `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
| `routes` | no | See [Routes & handlers](#routes--handlers). | | `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). | | `hooks` | no | See [Hooks](#hooks). |
| `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). |
| `storage` | no | `true` ⇒ the host provisions a Postgres database and login role for this plugin and hands the credentials to `onBoot`. See [Plugin storage](#plugin-storage). | | `storage` | no | `true` ⇒ the host provisions a Postgres database and login role for this plugin and hands the credentials to `onBoot`. See [Plugin storage](#plugin-storage). |
A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional. A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional.
### Routes & handlers ### Routes & handlers
A route is `{ method, path, handler }` plus [exactly one gate](#public-pages--menu-items) — A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's
`permission`, `public: true` or `session: true`. `path` is **relative to the plugin's
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches
`method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the
gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with `permission` gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with
the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login` the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login`
with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page. with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page.
`public: true` means no gate at all (see [Public pages](#public-pages--menu-items)).
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`. `method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
@@ -471,7 +468,7 @@ import { definePlugin } from "@plainpages/plugin-api";
import { landing, board } from "./pages.ts"; import { landing, board } from "./pages.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "0.4.0", apiVersion: "1.0.0",
home: landing, // owns "/" — the public front page home: landing, // owns "/" — the public front page
dashboard: board, // owns "/dashboard" — the post-login app home dashboard: board, // owns "/dashboard" — the post-login app home
}); });
@@ -570,28 +567,20 @@ system plugins you author or vendor. An ordinary domain plugin ignores it.
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
applies the central override and then **filters per user** by the permissions in the session JWT: a applies the central override and then **filters per user** by the permissions in the session JWT: a
node shows iff it is `public`, is `session` and someone is signed in, or names a `permission` the node shows iff it is `public`, declares no `permission`, or the user holds that name. A node's `icon`
user holds. A node's `icon` is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are
available ids are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
there.
**Gating a section header.** A `permission` on the header takes the whole subtree with it. When the **Gating a section header.** A `permission` on the header takes the whole subtree with it. When the
children need *different* permissions, mark the header `public: true` — it then gates nothing, each children need *different* permissions, leave the header ungated and gate each child — `composeNav`
child decides, and `composeNav` drops a header whose children all filtered out. That only works while drops a header whose children all filtered out. That only works while the header carries **no
the header carries **no `href`**: give it one and it survives as a leaf, visible to everyone. `href`**: give it one and it survives as an ungated leaf, visible to everyone.
#### Public pages & menu items #### Public pages & menu items
A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu — A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu.
open stated outright, so it is a deliberate choice rather than a forgotten gate. That is the same as omitting `permission`, but stated outright so public is a deliberate choice
rather than a forgotten gate. The two are **mutually exclusive** — declaring both is refused at boot.
**`session: true`** takes any signed-in user, with no grant to hold — for a plugin whose data is the
visitor's own. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
permission gate does.
Every route and nav node names **exactly one** of the three, spelled `true` (or a permission name).
Naming none, naming two, or spelling one `false` is refused at boot — so a forgotten gate fails the
plugin instead of publishing a page.
A public page still renders in the native shell; for an anonymous visitor `ctx.user` is `null`, the A public page still renders in the native shell; for an anonymous visitor `ctx.user` is `null`, the
shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden, shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden,
@@ -604,30 +593,22 @@ works without editing host config.
### Contract versioning ### Contract versioning
Each manifest declares `apiVersion` — a **semver** string naming the **Plainpages release** it was Each manifest declares `apiVersion` — a **semver** string naming the host contract it was built
built against. The host's `HOST_API_VERSION` *is* its release version, so a plugin author reads one against — against the host's `HOST_API_VERSION`. The host bumps **major** on a breaking
version off the image they run and writes it down — there is no separate contract number. Both manifest/handler change and **minor** on an additive one. At discovery it parses both with
release paths refuse a tag whose `major.minor` disagrees with the constant, so the two cannot drift. `parseSemver` (strict: no ranges, `v` prefixes, or leading zeros) and applies provider/consumer
semantics in `checkApiVersion`:
Patch releases are invisible here — `checkApiVersion` ignores the patch digit, which is what lets
dependency updates ship continuously without touching any plugin. At discovery the host parses both
versions with `parseSemver` (strict: no ranges, `v` prefixes, or leading zeros) and applies
provider/consumer semantics in `checkApiVersion`:
| Plugin `apiVersion` vs host | Result | Host action | | Plugin `apiVersion` vs host | Result | Host action |
| --- | --- | --- | | --- | --- | --- |
| same major, same minor (patch ignored) | `ok` | load | | same major, same minor (patch ignored) | `ok` | load |
| **major `0`**, plugin minor **<** host minor | `refuse` | **abort boot** — pre-1.0 the minor is the breaking slot | | same major, plugin minor **<** host minor | `warn` | load, log — additive-compatible, newer features exist |
| same major, plugin minor **<** host minor | `warn` | load, log — built against an older release; check that release's notes |
| same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host | | same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host |
| different major | `refuse` | **abort boot** — incompatible contract | | different major | `refuse` | **abort boot** — incompatible contract |
| missing / not a valid semver | `refuse` | **abort boot** — must be declared | | missing / not a valid semver | `refuse` | **abort boot** — must be declared |
The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies
the compatibility. One digit carries the whole release, so a **minor** means either the plugin the caret-style compatibility.
contract changed or a dependency moved far enough to warrant one.
[`CHANGELOG.md`](CHANGELOG.md) is what a minor sends you to: what broke, and what to change.
### Conflict rules ### Conflict rules
@@ -645,7 +626,7 @@ The host detects collisions across all discovered plugins with `findConflicts` a
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a `home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
route/nav node that does not name [exactly one gate](#public-pages--menu-items). route/nav node setting both `public` and `permission`.
### Hooks ### Hooks
@@ -748,56 +729,6 @@ barrel's types on disk: typecheck it mounted under the host tree, or vendor a ty
`node_modules`** and point tsconfig `paths` at it — a stub inside is the shadowing copy discovery `node_modules`** and point tsconfig `paths` at it — a stub inside is the shadowing copy discovery
refuses, and it would travel with the folder you mount. refuses, and it would travel with the folder you mount.
### Plugin settings
A plugin declares the configuration it accepts, and the host resolves it from the environment at
boot. Each key becomes one variable — `PLUGIN_SETTING_<ID>_<KEY>`, the id's dashes and the key's
camel humps both becoming underscores — so `upstream` on the `scheduling` plugin is set by
`PLUGIN_SETTING_SCHEDULING_UPSTREAM`.
```ts
export default definePlugin({
apiVersion: "0.4.0",
settings: [
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
{ key: "pageSize", type: "number", default: 25 },
{ key: "mode", type: "enum", values: ["strict", "lenient"], default: "strict" },
{ key: "apiKey", type: "string", secret: true, default: "dev-insecure-key" },
],
hooks: {
onBoot: ({ settings }) => {
settings.upstream; // string — required, so the boot already refused without it
settings.pageSize; // number — defaulted, so always present
start(settings);
},
},
});
```
`type` is one of `string`, `number`, `boolean`, `enum` (with `values`) or `url`. A declared type is
coerced and checked at boot, so a mistyped value names the plugin, the key and the variable instead
of surfacing later as a broken page.
**`required` and `default` are mutually exclusive** — a default means the setting can never fail, so
declaring both is refused at discovery. That leaves three cases, and the type `onBoot` receives
follows them exactly: `required: true` is always present, a `default` is always present, and a
setting with neither is `T | undefined`, so the plugin has to handle its absence.
**Secrets.** `secret: true` marks a value the host reads but never renders — not in a boot log, not
in an error, not on the admin screen, which shows only whether it resolved and from where. With
`REQUIRE_SECURE_SECRETS=true` a secret that is unset, or still equal to its declared default, refuses
the boot — the same rule the host applies to its own secrets.
**Where it fails, and where it warns.** A malformed declaration is refused at discovery; a missing
`required` value or a value that will not coerce refuses the boot. A `PLUGIN_SETTING_` variable no
installed plugin declares is only *reported* — it is usually a typo in the one the operator meant to
set, and naming it turns two unrelated-looking errors into one. Declaring settings without an
`onBoot` warns too: they resolve, but nothing receives them.
**Reading what a deployment is configured with.** The admin plugin's **Plugin settings** screen
(`plugin-settings:read`) lists every installed plugin, its declared keys, the variable that sets
each, and whether the value came from the environment or the declared default.
### Plugin storage ### Plugin storage
A plugin that needs to keep data sets `storage: true`. The host then provisions a Postgres A plugin that needs to keep data sets `storage: true`. The host then provisions a Postgres
@@ -811,7 +742,7 @@ import { definePlugin } from "@plainpages/plugin-api";
let sql: ReturnType<typeof postgres>; let sql: ReturnType<typeof postgres>;
export default definePlugin({ export default definePlugin({
apiVersion: "0.4.0", apiVersion: "1.0.0",
storage: true, storage: true,
hooks: { hooks: {
onBoot: async (boot) => { onBoot: async (boot) => {
@@ -921,10 +852,9 @@ The menu is **driven entirely by config** and assembled from two sources:
export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } }); export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
``` ```
Every nav node a **plugin** declares names one gate — a `permission`, **`public: true`** (everyone) Every nav item may carry a `permission`; the rendered tree is **filtered per user** from the session
or **`session: true`** (anyone signed in); a header this override groups takes none, and shows JWT (no per-request authz call), so the menu only shows what that person can reach. An item may
whenever a child does. The rendered tree is **filtered per user** from the session JWT (no instead be **`public: true`** to show it to everyone — mutually exclusive with `permission`.
per-request authz call), so the menu only shows what that person can reach.
Branding (name, logo, default theme) renders in the app shell. Branding (name, logo, default theme) renders in the app shell.
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders **One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders
@@ -933,11 +863,6 @@ recovery / front pages — so it looks identical signed in or out and just shows
anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a
chrome-free layout opts out with the shell's `menu: false`. chrome-free layout opts out with the shell's `menu: false`.
**The document scrolls, and the chrome scrolls with it.** Nothing bounds the viewport, so a page is
reachable below the fold without adding a scroll region of its own, and browser paging, scroll
restoration and find-in-page work without a page doing anything. A plugin that wants a full-height
pane owns that in its own stylesheet.
## Building blocks ## Building blocks
Plainpages is a **component library, not a page generator** — reusable EJS partials + TS helpers, Plainpages is a **component library, not a page generator** — reusable EJS partials + TS helpers,
@@ -961,16 +886,6 @@ underneath it — cramped, but nothing is unreachable. List state
(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable and shareable; the (`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable and shareable; the
URL is the only state the UI keeps. URL is the only state the UI keeps.
**A field whose options depend on another field is asked for in steps, one GET form each**, so every
choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made.
Once the query names a value, that field renders read-only (`field` with `readonly`, and a `link` back
to the URL without it) above the next step's form, which carries the earlier choices as hidden inputs,
plus `locale` from `localeParam`. The form that writes comes last and posts to the URL naming every
choice, so its handler reads them from `ctx.query`, never from the body; a value the query names that
the step does not offer is that step's error, never a silent fallback. Don't redraw a half-filled form
through a submit that writes nothing instead: a `required` field blocks it, and the choice never
reaches the URL.
Plugins that genuinely need it — live dashboards, bulk actions, client-side validation — may **opt Plugins that genuinely need it — live dashboards, bulk actions, client-side validation — may **opt
into progressive enhancement** (htmx, Alpine, vanilla JS) on top of working server-rendered HTML. into progressive enhancement** (htmx, Alpine, vanilla JS) on top of working server-rendered HTML.
The baseline never depends on it. The baseline never depends on it.
@@ -1108,7 +1023,7 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
| `PORT` | `3000` | web listen port | | `PORT` | `3000` | web listen port |
| `CACHE_TEMPLATES` | `false` | cache compiled EJS templates (`true` in prod) | | `CACHE_TEMPLATES` | `false` | cache compiled EJS templates (`true` in prod) |
| `SECURE_COOKIES` | `false` | mark our session/CSRF cookies `Secure` (`true` in prod https; off in dev http) | | `SECURE_COOKIES` | `false` | mark our session/CSRF cookies `Secure` (`true` in prod https; off in dev http) |
| `REQUIRE_SECURE_SECRETS` | `false` | when `true`, `CSRF_SECRET` — and `PLUGIN_DB_SECRET` once storage is configured, and every plugin setting declared `secret` — must be supplied and differ from the dev throwaway | | `REQUIRE_SECURE_SECRETS` | `false` | when `true`, `CSRF_SECRET` — and `PLUGIN_DB_SECRET` once storage is configured — must be supplied and differ from the dev throwaway |
| `LOG_LEVEL` | `info` | min severity logged: `error`/`warn`/`info`/`verbose`/`debug`/`silly`/`none` | | `LOG_LEVEL` | `info` | min severity logged: `error`/`warn`/`info`/`verbose`/`debug`/`silly`/`none` |
| `LOG_FORMAT` | `text` | log line format: `text` (human-readable, dev) or `json` (structured, prod) | | `LOG_FORMAT` | `text` | log line format: `text` (human-readable, dev) or `json` (structured, prod) |
| `SERVICE_NAME` | `plainpages` | OTLP `service.name` on every log + span — brand it as your own deployment | | `SERVICE_NAME` | `plainpages` | OTLP `service.name` on every log + span — brand it as your own deployment |
@@ -1125,7 +1040,6 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
| `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew | | `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
| `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` | | `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` |
| `PLUGIN_DB_URL` | _unset_ (dev: `postgres://postgres:5432`) | credential-free Postgres base URL for [plugin storage](#plugin-storage); unset ⇒ storage off, and a plugin declaring it aborts boot | | `PLUGIN_DB_URL` | _unset_ (dev: `postgres://postgres:5432`) | credential-free Postgres base URL for [plugin storage](#plugin-storage); unset ⇒ storage off, and a plugin declaring it aborts boot |
| `PLUGIN_SETTING_<ID>_<KEY>` | per declaration | one variable per key a plugin declares in `settings`; see [Plugin settings](#plugin-settings) |
| `PLUGIN_DB_ADMIN_URL` | _unset_ (dev: the bundled superuser) | the DSN that provisions each plugin's database and role — read by the one-shot `bootstrap` service **only**, never by `web` | | `PLUGIN_DB_ADMIN_URL` | _unset_ (dev: the bundled superuser) | the DSN that provisions each plugin's database and role — read by the one-shot `bootstrap` service **only**, never by `web` |
| `PLUGIN_DB_SECRET` | dev throwaway | derives each plugin's database password; `REQUIRE_SECURE_SECRETS` enforces it in `web` once `PLUGIN_DB_URL` is set, and in `bootstrap` whenever a plugin declares storage | | `PLUGIN_DB_SECRET` | dev throwaway | derives each plugin's database password; `REQUIRE_SECURE_SECRETS` enforces it in `web` once `PLUGIN_DB_URL` is set, and in `bootstrap` whenever a plugin declares storage |
| `PLUGIN_DB_CONNECTION_LIMIT` | `10` | per-role Postgres connection ceiling, so one plugin's pools cannot exhaust the server Ory shares; read by `bootstrap` when provisioning | | `PLUGIN_DB_CONNECTION_LIMIT` | `10` | per-role Postgres connection ceiling, so one plugin's pools cannot exhaust the server Ory shares; read by `bootstrap` when provisioning |
@@ -1458,7 +1372,7 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
| Workflow | Trigger | Does | | Workflow | Trigger | Does |
| --- | --- | --- | | --- | --- | --- |
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image | | `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image |
| `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `latest` (plus `X` once major ≥ 1), sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger | | `release.yml` | push of a `vX.Y.Z` tag | re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`; sync those tags to Docker Hub |
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) | | `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) |
| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head | | `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged | | `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged |
@@ -1483,27 +1397,11 @@ pattern-based org cleanup rule for this package — its age/count heuristics can
release tags and would delete images the workflow protects. release tags and would delete images the workflow protects.
**Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs **Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs
`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `latest` and — `release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `1`, `latest`;
once the major reaches `1` — `1`; nothing is rebuilt, so the released image is byte-identical to the nothing is rebuilt, so the released image is byte-identical to the gated one. It fails loud if no
gated one. While the major is `0` the bare-major tag is skipped, because a `0.x` minor is a contract
break and a moving `:0` would carry one. It fails loud if no
hash image exists — release tags must point at a commit that went through the gate. The same four hash image exists — release tags must point at a commit that went through the gate. The same four
tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only. tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only. The Docker Hub
repository **description** is maintained by hand from [`README-dockerhub.md`](README-dockerhub.md).
The [contract check](#contract-versioning) guards the tag before anything is published, refusing one
whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to set.
**The Docker Hub overview** is published by a separate `publish-overview` job from
[`release-tooling/dockerhub-overview.md.tmpl`](release-tooling/dockerhub-overview.md.tmpl), with
`{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go
stale. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the
quick start stays a topology CI has actually run.
It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
outage leaves the promotion green and the images untouched; and the page has its own door — run the
workflow manually with an `overview_version` input to republish it without cutting a release. That
input goes through the same contract check as a tag: a non-semver value, or one whose `major.minor`
disagrees with the tree being published, is refused. It uses
the same `DOCKERHUB_TOKEN` the image push uses, which is why that token needs the **delete** scope.
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is **GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is
read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift. read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift.
@@ -1521,17 +1419,13 @@ exact. Each PR runs the normal gate on its `renovate/*` branch and automerges on
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`) cuts **one** **Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`) cuts **one**
`vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and
**skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a **skips** when the tip isn't a Renovate commit or nothing new merged. Renovate stamps a
trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a `Release-Bump: <updateType>` trailer onto the updates that reach a running Plainpages — the root
`Release-Bump: <updateType>` trailer onto the updates that reach a running Plainpages — the rules in `package.json`'s runtime dependencies, the image base, and `compose.yml`'s services — and
[`renovate.json`](renovate.json) name them — and [`auto-release/next-version.ts`](auto-release/next-version.ts) turns the highest one into the next
[`release-tooling/next-version.ts`](release-tooling/next-version.ts) turns the highest one into the next version; pre-1.0 it never auto-crosses into `1.0.0`. `updateType` rates the *dependency's* own jump,
version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release so the trailer is an allowlist in [`renovate.json`](renovate.json): a devDependency, E2E or CI-only
version, an update big enough to reach a **minor** stops the job rather than tagging: bump bump carries none and rides the next patch release instead of escalating it. It is **tag-only**: the tag hands off to
`HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since
`nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump,
so the trailer is an allowlist: an update outside those rules carries none and rides the next patch
release instead of escalating it. It is **tag-only**: the tag hands off to
`release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by `release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by
the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here. the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here.
@@ -1540,7 +1434,7 @@ the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never tou
| Actions var / secret | Value | | Actions var / secret | Value |
| --- | --- | | --- | --- |
| `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. | | `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. |
| `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a **read/write/delete** token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT reaches every repo under it, and delete is destructive). Delete is what publishing the overview needs; pushing images alone would not. | | `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a read/write token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT can push to every repo under it). |
| `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. | | `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. |
| `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. | | `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. |
| `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. | | `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. |
@@ -1743,12 +1637,9 @@ examples/ Copy-in reference mirroring the mount dirs: plugins/schedul
config/menu.ts, and shifts-upstream/ (the dev mock backend) config/menu.ts, and shifts-upstream/ (the dev mock backend)
e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml; e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml;
proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow
release-tooling/ Everything the release runs: next-version (the bump math), contract-version
(the HOST_API_VERSION↔tag gate), dockerhub-overview (+ its .md.tmpl)
registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts)
CHANGELOG.md What changed per release, and how to upgrade a plugin across a minor
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
.gitea/workflows/ Gitea Actions — see CI/CD .gitea/workflows/ Gitea Actions — see CI/CD
README-dockerhub.md The Docker Hub repository description, pasted over by hand when it changes
``` ```
## Extending the core ## Extending the core
@@ -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 release-tooling/next-version.ts <latestTag> [updateType...] → prints the next tag. // CLI: node auto-release/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)));
+5 -5
View File
@@ -22,7 +22,7 @@ services:
PLUGIN_DB_URL: *plugin-db-url 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
PLUGIN_SETTING_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
# 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):
@@ -46,10 +46,10 @@ services:
# 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 (PLUGIN_SETTING_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 —
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead. # stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead.
shifts-upstream: shifts-upstream:
image: node:24.21.0-alpine3.24 image: node:24.19.0-alpine3.24
command: node /srv/server.ts command: node /srv/server.ts
restart: unless-stopped restart: unless-stopped
volumes: volumes:
@@ -58,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.31.6 image: axllent/mailpit:v1.31.0
ports: ports:
- "8025:8025" - "8025:8025"
restart: unless-stopped restart: unless-stopped
+1 -1
View File
@@ -1,6 +1,6 @@
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/. # Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network. # Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
FROM mcr.microsoft.com/playwright:v1.64.1-noble FROM mcr.microsoft.com/playwright:v1.62.1-noble
WORKDIR /e2e-tests WORKDIR /e2e-tests
+3 -3
View File
@@ -53,7 +53,7 @@ services:
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows. # The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
shifts-upstream: shifts-upstream:
image: node:24.21.0-alpine3.24 image: node:24.19.0-alpine3.24
command: ["node", "/server.ts"] command: ["node", "/server.ts"]
volumes: volumes:
- ./examples/shifts-upstream/server.ts:/server.ts:ro - ./examples/shifts-upstream/server.ts:/server.ts:ro
@@ -66,7 +66,7 @@ services:
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos # Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos. # verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
mock-oidc: mock-oidc:
image: node:24.21.0-alpine3.24 image: node:24.19.0-alpine3.24
command: ["node", "/mock-oidc.ts"] command: ["node", "/mock-oidc.ts"]
environment: environment:
ISSUER: http://mock-oidc:9000 ISSUER: http://mock-oidc:9000
@@ -81,7 +81,7 @@ services:
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts). # Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
proxy: proxy:
image: node:24.21.0-alpine3.24 image: node:24.19.0-alpine3.24
command: ["node", "/proxy.ts"] command: ["node", "/proxy.ts"]
depends_on: depends_on:
web: web:
-17
View File
@@ -193,23 +193,6 @@ test.describe.serial("authenticated admin journey", () => {
await page.goto("/scheduling/shifts"); await page.goto("/scheduling/shifts");
await expect(page.locator("h1")).toHaveText("Shifts"); await expect(page.locator("h1")).toHaveText("Shifts");
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
// The admin owns none of the demo's rows, so an empty page is the no-leak assertion.
await page.goto("/scheduling/mine");
await expect(page.locator("h1")).toHaveText("My shifts");
await expect(page.getByText("No shifts are assigned to admin@plainpages.local")).toBeVisible();
await expect(page.locator("table")).not.toContainText("Morning — Front desk");
});
test("plugin settings: the screen names the variable that sets each declared key", async () => {
await page.goto("/admin/plugin-settings");
await expect(page.locator("h1")).toHaveText("Plugin settings");
// The reference plugin's one declared setting, and the variable an operator would set for it.
const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" });
await expect(scheduling).toContainText("upstream");
await expect(scheduling).toContainText("http://shifts-upstream:4000"); // resolved, and its source shown
// Every installed plugin gets a section, so "declares none" is distinguishable from "not installed".
await expect(page.locator("h2", { hasText: "admin" })).toHaveCount(1);
}); });
test("logout: signing out ends the session and returns to the login page", async () => { test("logout: signing out ends the session and returns to the login page", async () => {
+30 -12
View File
@@ -6,17 +6,17 @@
"": { "": {
"name": "plainpages-e2e", "name": "plainpages-e2e",
"devDependencies": { "devDependencies": {
"@playwright/test": "1.64.1" "@playwright/test": "1.62.1"
} }
}, },
"node_modules/@playwright/test": { "node_modules/@playwright/test": {
"version": "1.64.1", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", "integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright": "1.64.1" "playwright": "1.62.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
@@ -25,26 +25,44 @@
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": { "node_modules/playwright": {
"version": "1.64.1", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"playwright-core": "1.64.1" "playwright-core": "1.62.1"
}, },
"bin": { "bin": {
"playwright": "cli.js" "playwright": "cli.js"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
},
"optionalDependencies": {
"fsevents": "2.3.2"
} }
}, },
"node_modules/playwright-core": { "node_modules/playwright-core": {
"version": "1.64.1", "version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"bin": { "bin": {
+1 -1
View File
@@ -7,6 +7,6 @@
"test": "playwright test" "test": "playwright test"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.64.1" "@playwright/test": "1.62.1"
} }
} }
+5 -58
View File
@@ -28,45 +28,6 @@ test.beforeEach(async ({ context }) => {
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]); await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
}); });
// A key press, not scrollIntoView (a script can scroll a box no reader can) and not the wheel
// (Firefox's synthetic event never reaches the document).
for (const [name, path, tail] of [
["the starter dashboard", "/dashboard", ".form-actions .btn"],
["the public landing", "/", ".landing-actions .btn"],
] as const) {
for (const width of [1280, 390]) {
test(`${name} scrolls to its end at ${width}px wide`, async ({ page }) => {
await page.setViewportSize({ width, height: 200 });
await page.goto(path);
const overflows = await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight);
expect(overflows, "the page must overflow, or it proves nothing").toBe(true);
await page.keyboard.press("End");
await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 });
});
}
}
// Green only while #nav-toggle is position: fixed — a label tap focuses it, and focus scrolls into view.
test("closing the mobile drawer leaves the reader where the scrim found them", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 200 });
await page.goto("/dashboard");
await page.locator(".hamburger").click();
await expect(page.locator("#nav-toggle")).toBeChecked();
// Scripted, because a key press with focus on the toggle does not scroll in every engine — and
// what is under test is closing the drawer, not how the reader got down the page.
await page.evaluate(() => window.scrollTo(0, 120));
const at = await page.evaluate(() => window.scrollY);
expect(at, "the page must have somewhere to scroll behind the scrim").toBeGreaterThan(0);
// The exposed strip beside the 264px panel: the scrim spans the viewport, so its centre is under
// the drawer and a centre click lands on the panel instead.
await page.locator(".scrim").click({ position: { x: 340, y: 100 } });
await expect(page.locator("#nav-toggle")).not.toBeChecked();
expect(await page.evaluate(() => window.scrollY), "closing the drawer must not move the page").toBe(at);
});
test("captures the live pages for review", async ({ page }) => { test("captures the live pages for review", async ({ page }) => {
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page.locator(".sidebar")).toBeVisible(); await expect(page.locator(".sidebar")).toBeVisible();
@@ -133,7 +94,6 @@ test("a popover menu sits on its trigger and closes on an outside click or Esc
await expect(panel).toBeVisible(); await expect(panel).toBeVisible();
await page.keyboard.press("Escape"); await page.keyboard.press("Escape");
await expect(panel).toBeHidden(); await expect(panel).toBeHidden();
await expect(trigger).toBeFocused(); // the browser returns focus, so no trigger needs a tabindex
}); });
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => { test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
@@ -182,11 +142,11 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to
await expect(page.getByRole("link", { name: "Back home" })).toBeVisible(); await expect(page.getByRole("link", { name: "Back home" })).toBeVisible();
}); });
// The reference plugin (plugins/scheduling) ships discovered in the image, and shows all three // The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
// gates: the public Overview is reachable by anyone, My shifts takes any session, and the shifts // reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated,
// list needs a permission. The authenticated list/form flow is the full E2E (full-flow.spec). // so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
// Side-effect-free. // E2E (full-flow.spec). Side-effect-free.
test("the reference plugin: public Overview is open to all, My shifts takes any session, the gated Shifts redirects to /login", async ({ page, request }) => { test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => {
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these // `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
// probes are genuinely anonymous. // probes are genuinely anonymous.
// The public overview is reachable with no session (200), not bounced to sign in. // The public overview is reachable with no session (200), not bounced to sign in.
@@ -205,23 +165,10 @@ test("the reference plugin: public Overview is open to all, My shifts takes any
expect(res.status()).toBe(303); expect(res.status()).toBe(303);
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts"); expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
// A `session: true` route bounces an anonymous visitor the same way — no permission involved.
const mine = await request.get("/scheduling/mine", { maxRedirects: 0 });
expect(mine.status()).toBe(303);
expect(mine.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fmine");
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav, // The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
// but the gated Shifts leaf is filtered out. // but the gated Shifts leaf is filtered out.
await page.goto("/dashboard"); await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
await expect(page.locator('.sidebar a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough
// No shifts upstream on this stack, so this also pins the degraded page: the reason, never a 500
// and never a claim about what is assigned.
await page.goto("/scheduling/mine");
await expect(page.getByRole("heading", { name: "My shifts" })).toBeVisible();
await expect(page.getByText("Couldn't reach the scheduling service")).toBeVisible();
await expect(page.getByText("No shifts are assigned to")).toHaveCount(0);
}); });
+1 -1
View File
@@ -8,4 +8,4 @@ across (or bind-mount your own) and restart.
| [`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/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 `PLUGIN_SETTING_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. |
@@ -1,55 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { PageChrome, PluginSettings } from "@plainpages/plugin-api";
import { buildPluginSettingsModel } from "./admin-plugin-settings.ts";
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
const CATALOG: readonly PluginSettings[] = [
{
pluginId: "scheduling",
settings: [
{ description: "Where shifts come from", envName: "PLUGIN_SETTING_SCHEDULING_UPSTREAM", key: "upstream", required: true, secret: false, source: "env", type: "url", value: "https://shifts.test" },
{ envName: "PLUGIN_SETTING_SCHEDULING_MODE", key: "mode", required: false, secret: false, source: "default", type: "enum", value: "strict", values: ["strict", "lenient"] },
{ envName: "PLUGIN_SETTING_SCHEDULING_NOTE", key: "note", required: false, secret: false, source: "unset", type: "string" },
],
},
{ pluginId: "quiet", settings: [] },
];
test("a row carries the variable to set and where the value came from", () => {
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
const rows = model.groups[0]?.table.rows ?? [];
assert.deepEqual(rows.map((r) => r.name), ["upstream", "mode", "note"]);
assert.deepEqual(rows[0]?.cells, [
{ rowHeader: { text: "upstream" } }, "Where shifts come from", "url", "Yes", "PLUGIN_SETTING_SCHEDULING_UPSTREAM", "Environment", "https://shifts.test",
]);
assert.equal(rows[1]?.cells[2], "enum (strict, lenient)"); // the choices are the useful half of the type
assert.equal(rows[2]?.cells[5], "Not set");
});
test("a plugin declaring nothing still gets a section, so it is visibly installed", () => {
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
assert.deepEqual(model.groups.map((g) => g.pluginId), ["scheduling", "quiet"]);
assert.deepEqual(model.groups[1]?.table.rows, []);
assert.match(model.groups[1]?.emptyText ?? "", /no settings/i);
});
test("a secret renders as set-or-not, never as a value, a mask or a length", () => {
const settings: readonly PluginSettings[] = [{
pluginId: "billing",
settings: [
{ envName: "PLUGIN_SETTING_BILLING_API_KEY", key: "apiKey", required: false, secret: true, source: "env", type: "string" },
{ envName: "PLUGIN_SETTING_BILLING_WEBHOOK_KEY", key: "webhookKey", required: false, secret: true, source: "unset", type: "string" },
],
}];
const rows = buildPluginSettingsModel({ chrome: CHROME, settings }).groups[0]?.table.rows ?? [];
assert.equal(rows[0]?.cells[6], "Secret — set");
assert.equal(rows[1]?.cells[6], "Secret — not set");
});
test("two tables on one page need distinct row-action id stems", () => {
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
const stems = model.groups.map((g) => g.table.actionsId);
assert.equal(new Set(stems).size, stems.length);
});
@@ -1,75 +0,0 @@
// Plugin settings admin screen: what each installed plugin declares it can be configured with, the
// variable that sets it, and how each key resolved. Read-only — the host reads settings from the
// environment at boot, so changing one is a deploy, not a form.
import { type PageChrome, type PluginSettings, type RouteHandler, type SettingSummary, type Translate } from "@plainpages/plugin-api";
import { ADMIN_EN, requirePermission } from "./admin-shared.ts";
interface SettingsGroup {
emptyText: string;
pluginId: string;
table: {
actionsId: string;
caption: string;
columns: { label: string }[];
rows: { cells: (string | { rowHeader: { text: string } })[]; name: string }[];
};
}
// One group per installed plugin, including those declaring nothing — an operator who cannot find
// their plugin here has not installed it, which is the other half of what this screen answers.
export function buildPluginSettingsModel(opts: { chrome: PageChrome; settings: readonly PluginSettings[]; t?: Translate }) {
const t = opts.t ?? ADMIN_EN;
return {
breadcrumbs: [{ label: t("admin.pluginSettings.title") }],
chrome: opts.chrome,
groups: opts.settings.map((plugin): SettingsGroup => ({
emptyText: t("admin.pluginSettings.none"),
pluginId: plugin.pluginId,
table: {
actionsId: `settings-${plugin.pluginId}`, // two tables share this page, so the stem must differ
caption: t("admin.pluginSettings.caption", { plugin: plugin.pluginId }),
columns: [
{ label: t("admin.pluginSettings.column.key") },
{ label: t("admin.pluginSettings.column.description") },
{ label: t("admin.pluginSettings.column.type") },
{ label: t("admin.pluginSettings.column.required") },
{ label: t("admin.pluginSettings.column.variable") },
{ label: t("admin.pluginSettings.column.source") },
{ label: t("admin.pluginSettings.column.value") },
],
rows: plugin.settings.map((setting) => ({
cells: [
{ rowHeader: { text: setting.key } },
setting.description ?? "",
typeLabel(setting),
t(setting.required ? "admin.pluginSettings.yes" : "admin.pluginSettings.no"),
setting.envName,
t(`admin.pluginSettings.source.${setting.source}`),
valueLabel(setting, t),
],
name: setting.key,
})),
},
})),
title: t("admin.pluginSettings.title"),
};
}
// An enum's choices are the useful half of its type — they are what the operator must pick from.
function typeLabel(setting: SettingSummary): string {
return setting.type === "enum" && setting.values ? `${setting.type} (${setting.values.join(", ")})` : setting.type;
}
// A secret never renders its value — not the value, not a mask of it, not its length. Whether it
// resolved and from where is what an operator needs, and the source column already says the rest.
function valueLabel(setting: SettingSummary, t: Translate): string {
if (setting.secret) return t(setting.source === "unset" ? "admin.pluginSettings.secretUnset" : "admin.pluginSettings.secretSet");
return setting.value ?? t("admin.pluginSettings.unset");
}
// GET /admin/plugin-settings
export const pluginSettingsList: RouteHandler = (ctx) => {
requirePermission(ctx, "plugin-settings");
return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" };
};
+7 -7
View File
@@ -19,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, declaredPermissions: [], declaredSettings: [], 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),
}; };
@@ -27,21 +27,21 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
// ---- nav fragment ---- // ---- nav fragment ----
test("ADMIN_NAV: an ungated Admin header whose screens each gate on their own read permission", () => { 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");
// No gate on the header: a user may hold one screen's permission and not another's. composeNav // 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 them hides the section. // 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, // Both halves matter — give the header an `href` and it survives the filter as a visible leaf,
// ungated, for anonymous visitors included. // ungated, for anonymous visitors included.
assert.equal(ADMIN_NAV.permission, undefined); assert.equal(ADMIN_NAV.permission, undefined);
assert.equal(ADMIN_NAV.href, 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/clients", "/admin/plugin-settings"]); 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", "plugin-settings:read"]); 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.clients", "admin.nav.pluginSettings"]); 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", "OAuth2 clients", "Plugin settings"]); 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)); assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
}); });
+5 -7
View File
@@ -12,13 +12,12 @@ export const ADMIN_EN: Translate = englishTranslator(enUS);
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_CLIENTS_BASE = "/admin/clients"; export const ADMIN_CLIENTS_BASE = "/admin/clients";
export const ADMIN_PLUGIN_SETTINGS_BASE = "/admin/plugin-settings";
// One resource per screen — the `<resource>` half of every permission this plugin gates on. // 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. // `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 // 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. // 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" | "plugin-settings" | "users"; export type AdminResource = "groups" | "oauth2-clients" | "users";
export type AdminAction = "read" | "write"; export type AdminAction = "read" | "write";
@@ -35,20 +34,19 @@ export function actionForMethod(method: string): AdminAction {
return verb === "GET" || verb === "HEAD" ? "read" : "write"; return verb === "GET" || verb === "HEAD" ? "read" : "write";
} }
// The plugin's nav fragment: the "Admin" header + its four screens, each gated on its own read // The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own
// permission. composeNav drops a header left with no visible children, so a user holding none of // read permission. The header carries no `permission` because a user may hold one screen's and not
// them never sees the section. The host current-marks the active item — no `current`/`open` here. // 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", permission: permissionName("users", "read") }, { 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", permission: permissionName("groups", "read") }, { href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") },
{ 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", permission: permissionName("oauth2-clients", "read") },
{ href: ADMIN_PLUGIN_SETTINGS_BASE, icon: "i-sliders", id: "plugin-settings", label: "admin.nav.pluginSettings", permission: permissionName("plugin-settings", "read") },
], ],
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
public: true, // the header gates nothing; every child needs a permission, and an empty header is dropped
}; };
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already // The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
-20
View File
@@ -83,29 +83,9 @@ const messages = {
"admin.nav.clients": "OAuth2 clients", "admin.nav.clients": "OAuth2 clients",
"admin.nav.groups": "Groups", "admin.nav.groups": "Groups",
"admin.nav.pluginSettings": "Plugin settings",
"admin.nav.section": "Admin", "admin.nav.section": "Admin",
"admin.nav.users": "Users", "admin.nav.users": "Users",
"admin.pluginSettings.caption": "Settings declared by {{plugin}}",
"admin.pluginSettings.column.description": "Description",
"admin.pluginSettings.column.key": "Key",
"admin.pluginSettings.column.required": "Required",
"admin.pluginSettings.column.source": "Source",
"admin.pluginSettings.column.type": "Type",
"admin.pluginSettings.column.value": "Value",
"admin.pluginSettings.column.variable": "Variable",
"admin.pluginSettings.no": "No",
"admin.pluginSettings.none": "This plugin declares no settings.",
"admin.pluginSettings.secretSet": "Secret — set",
"admin.pluginSettings.secretUnset": "Secret — not set",
"admin.pluginSettings.source.default": "Default",
"admin.pluginSettings.source.env": "Environment",
"admin.pluginSettings.source.unset": "Not set",
"admin.pluginSettings.title": "Plugin settings",
"admin.pluginSettings.unset": "—",
"admin.pluginSettings.yes": "Yes",
"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",
-20
View File
@@ -83,29 +83,9 @@ const messages: AdminMessages = {
"admin.nav.clients": "OAuth2-klienter", "admin.nav.clients": "OAuth2-klienter",
"admin.nav.groups": "Grupper", "admin.nav.groups": "Grupper",
"admin.nav.pluginSettings": "Tilläggsinställningar",
"admin.nav.section": "Administration", "admin.nav.section": "Administration",
"admin.nav.users": "Användare", "admin.nav.users": "Användare",
"admin.pluginSettings.caption": "Inställningar som {{plugin}} deklarerar",
"admin.pluginSettings.column.description": "Beskrivning",
"admin.pluginSettings.column.key": "Nyckel",
"admin.pluginSettings.column.required": "Obligatorisk",
"admin.pluginSettings.column.source": "Källa",
"admin.pluginSettings.column.type": "Typ",
"admin.pluginSettings.column.value": "Värde",
"admin.pluginSettings.column.variable": "Variabel",
"admin.pluginSettings.no": "Nej",
"admin.pluginSettings.none": "Det här tillägget deklarerar inga inställningar.",
"admin.pluginSettings.secretSet": "Hemlighet — satt",
"admin.pluginSettings.secretUnset": "Hemlighet — inte satt",
"admin.pluginSettings.source.default": "Standardvärde",
"admin.pluginSettings.source.env": "Miljövariabel",
"admin.pluginSettings.source.unset": "Inte satt",
"admin.pluginSettings.title": "Tilläggsinställningar",
"admin.pluginSettings.unset": "—",
"admin.pluginSettings.yes": "Ja",
"admin.notFound.message": "Objektet finns inte.", "admin.notFound.message": "Objektet finns inte.",
"admin.notFound.title": "Hittades inte", "admin.notFound.title": "Hittades inte",
+4 -7
View File
@@ -35,20 +35,17 @@ test("every nav permission is one the manifest declares", () => {
} }
}; };
walk(manifest.nav); walk(manifest.nav);
assert.equal(navPermissions.length, 4); assert.equal(navPermissions.length, 3);
for (const name of navPermissions) assert.ok(declared.includes(name), `nav gates on undeclared ${name}`); 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", () => { 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 for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it
// Three CRUD screens × read/write, plus read-only plugin settings — a screen that never writes // Three screens × read/write. There is deliberately no `permissions:` pair: permissions are
// declares no `:write`, since a permission nothing gates on is one an operator can only mis-grant. // declared in plugin code, so holding one is edited on the user or group that holds it.
// There is deliberately no `permissions:` pair either: permissions are declared in plugin code, so
// holding one is edited on the user or group that holds it.
assert.deepEqual([...declared].sort(), [ assert.deepEqual([...declared].sort(), [
"groups:read", "groups:write", "groups:read", "groups:write",
"oauth2-clients:read", "oauth2-clients:write", "oauth2-clients:read", "oauth2-clients:write",
"plugin-settings:read",
"users:read", "users:write", "users:read", "users:write",
]); ]);
}); });
@@ -61,5 +58,5 @@ test("GET routes gate on read and mutations on write, so a reader can open a scr
const action = route.method === "GET" && !writeIntent(route.path) ? "read" : "write"; const action = route.method === "GET" && !writeIntent(route.path) ? "read" : "write";
assert.ok(route.permission?.endsWith(`:${action}`), `${route.method} ${route.path} → ${route.permission}`); 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 CRUD screen; plugin settings has none assert.equal(routes.filter((r) => r.method === "GET" && writeIntent(r.path)).length, 6); // 2 per screen
}); });
+1 -6
View File
@@ -7,7 +7,6 @@
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/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, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts"; import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
import { pluginSettingsList } from "./admin-plugin-settings.ts";
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts"; import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts"; import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
@@ -25,10 +24,9 @@ const on = (resource: AdminResource) => (method: HttpMethod, path: string, handl
const users = on("users"); const users = on("users");
const groups = on("groups"); const groups = on("groups");
const clients = on("oauth2-clients"); const clients = on("oauth2-clients");
const pluginSettings = on("plugin-settings");
export default definePlugin({ export default definePlugin({
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV], nav: [ADMIN_NAV],
@@ -39,7 +37,6 @@ export default definePlugin({
{ description: "Create and delete groups, and change their members and permissions", name: "groups:write" }, { description: "Create and delete groups, and change their members and permissions", name: "groups:write" },
{ description: "View OAuth2 clients", name: "oauth2-clients:read" }, { description: "View OAuth2 clients", name: "oauth2-clients:read" },
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" }, { description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
{ description: "View the settings each installed plugin declares, and how they resolved", name: "plugin-settings:read" },
], ],
routes: [ routes: [
@@ -71,7 +68,5 @@ export default definePlugin({
clients("GET", "/clients/:id", clientsDetail), clients("GET", "/clients/:id", clientsDetail),
clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"), clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"),
clients("POST", "/clients/:id/delete", clientsDelete), clients("POST", "/clients/:id/delete", clientsDelete),
// Plugin settings — read-only, so no :write route and no write-intent GET.
pluginSettings("GET", "/plugin-settings", pluginSettingsList),
], ],
}); });
@@ -1,24 +0,0 @@
<%#
Plugin settings admin list: one section per installed plugin, each a table of what it declares
and how each key resolved (admin-plugin-settings.ts). Read-only — no actions, no forms.
%><%
const nav = include("partials/nav-tree", { nodes: chrome.nav });
let body = "";
for (const group of model.groups) {
// A plugin id is the folder name, which discovery constrains to [a-z0-9-] — no escaping needed.
body += '<h2 class="h2">' + group.pluginId + "</h2>";
body += group.table.rows.length === 0
? '<p class="muted">' + group.emptyText + "</p>"
: include("partials/data-table", group.table);
}
-%>
<%- include("partials/shell", {
body,
brand: chrome.brand,
breadcrumbs: model.breadcrumbs,
csrfToken: chrome.csrfToken,
nav,
theme: chrome.theme,
title: model.title,
user: chrome.user,
}) %>
+7 -18
View File
@@ -15,12 +15,8 @@ What it demonstrates:
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream, `POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`, then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
reusing the core `field` partial. reusing the core `field` partial.
- **All three route gates** — the Overview is `public` (anyone), "My shifts" is `session` (any - **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
signed-in visitor, showing only rows assigned to them), and "Shifts" is gated on `scheduling:read` / `scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
`scheduling:write`; a leaf whose gate a visitor fails is invisible in the menu.
- **Ownership joined on the identity id** — "My shifts" asks the upstream for `assigneeId=ctx.user.id`,
the opaque subject id, and renders the row's separate `assignee` display name. An email address is
user-changeable and can be reassigned to someone else, who would then inherit those rows.
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including - **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
the views carry the visitor's language onto their links with `localeHref()`. the views carry the visitor's language onto their links with `localeHref()`.
@@ -31,7 +27,7 @@ The plugin holds **no state** — data lives upstream (README → *Stateless*).
## Upstream ## Upstream
Set `PLUGIN_SETTING_SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory Set `SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box. mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box.
A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook). A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook).
@@ -42,14 +38,9 @@ Your backend must expose two routes; the plugin treats any non-2xx as a recovera
| Route | Request | Success | Response body | | Route | Request | Success | Response body |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `GET /shifts` | `Accept: application/json`, optional `?assigneeId=<id>` | `200` | JSON array of `{ id, title, assignee, assigneeId, start, end }` (all strings; missing fields coerce to `""`). With `assigneeId`, only that person's rows | | `GET /shifts` | `Accept: application/json` | `200` | JSON array of `{ id, title, assignee, start, end }` (all strings; missing fields coerce to `""`) |
| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) | | `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
`POST /shifts` carries the assignee as a **display name only**, so a shift created through this
plugin's form belongs to nobody and surfaces on no one's "My shifts" — don't go hunting for it
there. Resolving a name to an identity id needs a directory this demo has none of; a real backend
does that join at create time and stores the `assigneeId` alongside the name.
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
form re-renders. The plugin only validates that `title` and `assignee` are non-empty. form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
@@ -59,8 +50,6 @@ cosmetically) — normalise to your backend's format there if it matters.
## Granting access ## Granting access
A user sees the shift list once they hold the `scheduling:read` permission in Keto (and A user sees Scheduling once they hold the `scheduling:read` permission in Keto (and `scheduling:write`
`scheduling:write` to create). The one-command bootstrap grants both to the demo admin, so the seeded to create). The one-command bootstrap grants both to the demo admin, so the seeded
`admin@plainpages.local` can use it immediately. "My shifts" needs no grant at all — signing in is `admin@plainpages.local` can use it immediately.
its whole gate; it lists the rows this demo upstream holds against the signed-in visitor's id, and
the demo's seeded rows belong to three made-up people, so a freshly seeded admin sees it empty.
@@ -13,16 +13,12 @@ const messages = {
"scheduling.filter.searchLabel": "Search shifts", "scheduling.filter.searchLabel": "Search shifts",
"scheduling.filter.searchPlaceholder": "Search title or assignee…", "scheduling.filter.searchPlaceholder": "Search title or assignee…",
"scheduling.form.submit": "Create shift", "scheduling.form.submit": "Create shift",
"scheduling.mine.empty": "No shifts are assigned to {{email}}.",
"scheduling.mine.title": "My shifts",
"scheduling.nav.mine": "My shifts",
"scheduling.nav.overview": "Overview", "scheduling.nav.overview": "Overview",
"scheduling.nav.section": "Scheduling", "scheduling.nav.section": "Scheduling",
"scheduling.nav.shifts": "Shifts", "scheduling.nav.shifts": "Shifts",
"scheduling.new.title": "New shift", "scheduling.new.title": "New shift",
"scheduling.overview.lead": "scheduling.overview.lead":
"Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.", "Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.",
"scheduling.overview.mine": "See my shifts",
"scheduling.overview.signIn": "Sign in to view shifts", "scheduling.overview.signIn": "Sign in to view shifts",
"scheduling.overview.title": "Scheduling", "scheduling.overview.title": "Scheduling",
"scheduling.overview.view": "View shifts", "scheduling.overview.view": "View shifts",
@@ -9,16 +9,12 @@ const messages: SchedulingMessages = {
"scheduling.filter.searchLabel": "Sök pass", "scheduling.filter.searchLabel": "Sök pass",
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…", "scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
"scheduling.form.submit": "Skapa pass", "scheduling.form.submit": "Skapa pass",
"scheduling.mine.empty": "Inga pass är tilldelade {{email}}.",
"scheduling.mine.title": "Mina pass",
"scheduling.nav.mine": "Mina pass",
"scheduling.nav.overview": "Översikt", "scheduling.nav.overview": "Översikt",
"scheduling.nav.section": "Schemaläggning", "scheduling.nav.section": "Schemaläggning",
"scheduling.nav.shifts": "Pass", "scheduling.nav.shifts": "Pass",
"scheduling.new.title": "Nytt pass", "scheduling.new.title": "Nytt pass",
"scheduling.overview.lead": "scheduling.overview.lead":
"Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <code>scheduling:read</code>.", "Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <code>scheduling:read</code>.",
"scheduling.overview.mine": "Visa mina pass",
"scheduling.overview.signIn": "Logga in för att se passen", "scheduling.overview.signIn": "Logga in för att se passen",
"scheduling.overview.title": "Schemaläggning", "scheduling.overview.title": "Schemaläggning",
"scheduling.overview.view": "Visa pass", "scheduling.overview.view": "Visa pass",
+10 -23
View File
@@ -3,20 +3,19 @@
// 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 "@plainpages/plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
import { createShift, createUpstream, listShifts, MINE_PATH, myShifts, 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
// stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it // stateless). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream).
// over — which is after this manifest is built, hence the getter. const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000";
let upstreamUrl = ""; const upstream = createUpstream(upstreamUrl);
const upstream = createUpstream(() => upstreamUrl);
export default definePlugin({ export default definePlugin({
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved // onBoot runs after discovery, before the server listens: validate the plugin's own config so a
// settings. A malformed URL already failed the boot by then; the host validated the declared type. // typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
hooks: { onBoot: ({ settings }) => { upstreamUrl = settings.upstream; } }, hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") },
// Merged into the global menu + filtered per user. Labels are keys in this plugin's own catalog // Merged into the global menu + filtered per user. Labels are keys in this plugin's own catalog
// (i18n/<locale>.ts) — a plain string works too, it just isn't translated. "Overview" is `public`, so the "Scheduling" // (i18n/<locale>.ts) — a plain string works too, it just isn't translated. "Overview" is `public`, so the "Scheduling"
@@ -25,13 +24,11 @@ export default definePlugin({
nav: [{ nav: [{
children: [ children: [
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true }, { href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true },
{ href: MINE_PATH, id: "scheduling:mine", label: "scheduling.nav.mine", session: true },
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ }, { href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ },
], ],
icon: "i-cal", icon: "i-cal",
id: "scheduling", id: "scheduling",
label: "scheduling.nav.section", label: "scheduling.nav.section",
public: true, // the header gates nothing; each child names its own gate, and an empty header is dropped
}], }],
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`. // Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
@@ -40,22 +37,12 @@ export default definePlugin({
{ description: "Create and edit shifts", name: WRITE }, { description: "Create and edit shifts", name: WRITE },
], ],
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public`
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
routes: [ routes: [
{ handler: overview(), method: "GET", path: "/", public: true }, { handler: overview(), method: "GET", path: "/", public: true },
{ handler: myShifts(upstream), method: "GET", path: "/mine", session: true },
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ }, { handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE }, { handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE }, { handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
], ],
// Operator-supplied config: one PLUGIN_SETTING_SCHEDULING_UPSTREAM variable, validated as a URL at
// boot. The default points at the mock backend the dev compose runs (examples/shifts-upstream).
settings: [
{
default: "http://shifts-upstream:4000",
description: "Base URL of the backend this plugin reads shifts from and writes them to",
key: "upstream",
type: "url",
},
],
}); });
+30 -70
View File
@@ -4,29 +4,29 @@ import { Readable } from "node:stream";
import test from "node:test"; import test from "node:test";
// Import only from the @plainpages/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, type User } from "@plainpages/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 {
buildFormModel, createShift, createUpstream, listShifts, myShifts, newShiftForm, overview, readInput, assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate, SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
} from "./shifts.ts"; } from "./shifts.ts";
const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } }; const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; user?: User; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext { function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
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, declaredPermissions: [], declaredSettings: [], user: opts.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),
}; };
} }
const SHIFTS: Shift[] = [ const SHIFTS: Shift[] = [
{ assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "1", start: "08:00", title: "Morning desk" }, { assignee: "Avery Kline", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
{ assignee: "Blair Mora", assigneeId: "019bdc1a-4a83-7de2-8f05-6c93a71be4d8", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" }, { assignee: "Blair Mora", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" },
]; ];
const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over }); const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over });
@@ -35,25 +35,27 @@ const asView = (r: RouteResult | void) => {
return r as { data: Record<string, unknown>; status?: number; view: string }; return r as { data: Record<string, unknown>; status?: number; view: string };
}; };
// ---- the upstream URL as a declared setting ---- // ---- upstream config validation (the onBoot hook) ----
test("the manifest declares its upstream as a URL setting the host validates", async () => { test("assertHttpUrl accepts http(s) and fails loud on a malformed or non-http upstream URL", () => {
const manifest = (await import("./plugin.ts")).default; assert.doesNotThrow(() => assertHttpUrl("http://shifts-upstream:4000", "SCHEDULING_UPSTREAM"));
assert.deepEqual(manifest.settings?.map((s) => s.key), ["upstream"]); assert.doesNotThrow(() => assertHttpUrl("https://api.example.com/v1", "SCHEDULING_UPSTREAM"));
assert.equal(manifest.settings?.[0]?.type, "url"); // so a typo'd URL fails the boot, not every request assert.throws(() => assertHttpUrl("not a url", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*valid URL/); // unparseable
assert.equal(manifest.settings?.[0]?.default, "http://shifts-upstream:4000"); // the dev compose's mock assert.throws(() => assertHttpUrl("shifts-upstream:4000", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // missing // → parsed as a bogus scheme
assert.equal(typeof manifest.hooks?.onBoot, "function"); // without it the resolved value never arrives assert.throws(() => assertHttpUrl("ftp://host/x", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // wrong scheme
}); });
test("the client re-reads its base URL, so onBoot can bind it after the manifest is built", async () => { test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not just the helper)", async () => {
let baseUrl = "http://first:4000"; const prev = process.env["SCHEDULING_UPSTREAM"];
const seen: string[] = []; process.env["SCHEDULING_UPSTREAM"] = "nope://bad"; // read at import time below
const http = (async (url) => { seen.push(String(url)); return new Response("[]", { status: 200 }); }) as typeof fetch; try {
const upstream = createUpstream(() => baseUrl, http); const manifest = (await import("./plugin.ts")).default;
await upstream.list(); assert.equal(typeof manifest.hooks?.onBoot, "function");
baseUrl = "http://second:4000"; assert.throws(() => manifest.hooks!.onBoot!({}), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud
await upstream.list(); } finally {
assert.deepEqual(seen, ["http://first:4000/shifts", "http://second:4000/shifts"]); if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"];
else process.env["SCHEDULING_UPSTREAM"] = prev;
}
}); });
// ---- upstream client (fetch injected) ---- // ---- upstream client (fetch injected) ----
@@ -63,23 +65,23 @@ test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", as
const http = (async (url, init) => { const http = (async (url, init) => {
seen = String(url); seen = String(url);
assert.equal((init?.headers as Record<string, string>).accept, "application/json"); assert.equal((init?.headers as Record<string, string>).accept, "application/json");
return new Response(JSON.stringify([{ assignee: "A", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 }); return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
}) as typeof fetch; }) as typeof fetch;
const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed const shifts = await createUpstream("http://up:4000/", http).list(); // trailing slash trimmed
assert.equal(seen, "http://up:4000/shifts"); assert.equal(seen, "http://up:4000/shifts");
assert.deepEqual(shifts, [{ assignee: "A", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", end: "2", id: "x", start: "1", title: "T" }]); assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]);
}); });
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => { test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
const http = (async () => new Response("nope", { status: 503 })) as typeof fetch; const http = (async () => new Response("nope", { status: 503 })) as typeof fetch;
await assert.rejects(createUpstream(() => "http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503); await assert.rejects(createUpstream("http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
}); });
test("createUpstream.create POSTs the input as JSON", async () => { test("createUpstream.create POSTs the input as JSON", async () => {
let body: unknown, method = ""; let body: unknown, method = "";
const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch; const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch;
const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" }; const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" };
await createUpstream(() => "http://up:4000", http).create(input); await createUpstream("http://up:4000", http).create(input);
assert.equal(method, "POST"); assert.equal(method, "POST");
assert.deepEqual(body, input); assert.deepEqual(body, input);
}); });
@@ -115,21 +117,14 @@ test("listShifts degrades to a recoverable error page when the upstream is down
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ---- // ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
test("overview renders a public page for anyone, and its CTA names the best gate the visitor passes", async () => { test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
const anon = asView(await overview()(fakeCtx())); // user null, no permissions const anon = asView(await overview()(fakeCtx())); // user null, no permissions
assert.equal(anon.view, "overview"); assert.equal(anon.view, "overview");
assert.equal(anon.data["chrome"], CHROME); assert.equal(anon.data["chrome"], CHROME);
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
assert.equal(anon.data["signedIn"], false);
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] }))); const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
// Signed in but ungranted: the page must not invite them to sign in again.
const member = asView(await overview()(fakeCtx({ user: { email: "m@example.test", id: "01a06091-baa3-7a1f-9c62-0e3ab6d2f5c1", permissions: [] } })));
assert.equal(member.data["canRead"], false);
assert.equal(member.data["signedIn"], true);
assert.equal(member.data["mineHref"], "/scheduling/mine");
}); });
// ---- create handler ---- // ---- create handler ----
@@ -178,38 +173,3 @@ test("buildFormModel marks title/assignee required and attaches field errors", (
assert.equal(title.error, "needed"); assert.equal(title.error, "needed");
assert.equal(fields.find((f) => f.name === "start")!.required, undefined); assert.equal(fields.find((f) => f.name === "start")!.required, undefined);
}); });
// ---- the session-gated page: the visitor's own rows ----
test("my shifts scopes the upstream read by the visitor's id, and names them in the empty state", async () => {
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
const mine: Shift = { assignee: "Blair Mora", assigneeId: user.id, end: "22:00", id: "3", start: "17:00", title: "Evening on-call" };
let asked: { assigneeId?: string } | undefined;
const upstream = fakeUpstream({ list: async (opts) => { asked = opts; return [mine]; } });
const r = asView(await myShifts(upstream)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
assert.equal(r.view, "mine");
assert.deepEqual(asked, { assigneeId: "01a06091-baa3-71f4-a068-4879972979ff" }); // the id, never the address
const table = r.data["table"] as { emptyText: string; rows: { name: string }[] };
assert.deepEqual(table.rows.map((row) => row.name), ["Evening on-call"]);
assert.match(table.emptyText, /Blair\.Mora@example\.test/); // an empty page still says whose it is
// `requireSession` narrows `ctx.user` from `User | null` to `User` — the one part of the route's
// `session: true` guarantee the contract cannot state in the handler's type.
await assert.rejects(async () => { await myShifts(fakeUpstream())(fakeCtx()); }, GuardError);
});
test("my shifts degrades to the reason alone when the upstream is down, claiming nothing about what is assigned", async () => {
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
const down = fakeUpstream({ list: async () => { throw new UpstreamError("down", 503); } });
const r = asView(await myShifts(down)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
assert.match(String(r.data["error"]), /scheduling service/i);
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); // mine.ejs drops the count + table while `error` is set
});
test("my shifts drops a row the upstream returned that is not the visitor's", async () => {
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
const theirs: Shift = { assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "9", start: "08:00", title: "Not mine" };
const r = asView(await myShifts(fakeUpstream({ list: async () => [theirs] }))(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); // a backend ignoring the scope must not leak through this page
});
+21 -53
View File
@@ -6,7 +6,7 @@
// 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 @plainpages/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, requireSession, type RouteHandler, type Translate, tracedFetch } from "@plainpages/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:
@@ -16,14 +16,12 @@ const EN: Translate = englishTranslator(enUS);
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
export const SHIFTS_PATH = "/scheduling/shifts"; export const SHIFTS_PATH = "/scheduling/shifts";
export const MINE_PATH = "/scheduling/mine"; // the visitor's own shifts — a session is the whole gate
export const READ = "scheduling:read"; // the permission gating the list + nav export const READ = "scheduling:read"; // the permission gating the list + nav
export const WRITE = "scheduling:write"; // the permission gating create export const WRITE = "scheduling:write"; // the permission gating create
export interface Shift { export interface Shift {
id: string; id: string;
assignee: string; // display name, rendered in the table assignee: string;
assigneeId: string; // who the shift belongs to — an opaque id, the same one `ctx.user.id` carries
end: string; end: string;
start: string; start: string;
title: string; title: string;
@@ -48,30 +46,37 @@ export class UpstreamError extends Error {
export interface ShiftsUpstream { export interface ShiftsUpstream {
create(input: ShiftInput): Promise<void>; create(input: ShiftInput): Promise<void>;
// `assigneeId` scopes the read at the source, which is where an ownership rule belongs (README → list(): Promise<Shift[]>;
// Three tiers of "may I?"); without it the caller would hold everyone's rows to render one page. }
list(opts?: { assigneeId?: string }): Promise<Shift[]>;
// Fail loud at boot (the plugin's onBoot hook) on a malformed/non-http upstream URL — a config
// typo surfaces at startup, not as a degraded page later. Reachability stays a runtime concern.
export function assertHttpUrl(value: string, name: string): void {
let url: URL;
try {
url = new URL(value);
} catch {
throw new Error(`${name} is not a valid URL: ${JSON.stringify(value)}`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${name} must be an http(s) URL: ${JSON.stringify(value)}`);
} }
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch` // REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
// defaults to the host's tracedFetch, so each upstream call joins the request's trace (a client // defaults to the host's tracedFetch, so each upstream call joins the request's trace (a client
// span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network. // span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network.
// `baseUrl` is read per call: the plugin's settings arrive on onBoot, after the manifest that binds export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
// these handlers has already been built. const base = baseUrl.replace(/\/+$/, "");
export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
const base = (): string => baseUrl().replace(/\/+$/, "");
return { return {
async create(input) { async create(input) {
const res = await fetchImpl(`${base()}/shifts`, { const res = await fetchImpl(`${base}/shifts`, {
body: JSON.stringify(input), body: JSON.stringify(input),
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
method: "POST", method: "POST",
}); });
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status); if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
}, },
async list(opts = {}) { async list() {
const query = opts.assigneeId == null ? "" : `?${new URLSearchParams({ assigneeId: opts.assigneeId })}`; const res = await fetchImpl(`${base}/shifts`, { headers: { accept: "application/json" } });
const res = await fetchImpl(`${base()}/shifts${query}`, { headers: { accept: "application/json" } });
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status); if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
const data: unknown = await res.json(); const data: unknown = await res.json();
return Array.isArray(data) ? data.map(toShift) : []; return Array.isArray(data) ? data.map(toShift) : [];
@@ -83,7 +88,7 @@ const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? ""
function toShift(raw: unknown): Shift { function toShift(raw: unknown): Shift {
const r = (raw ?? {}) as Record<string, unknown>; const r = (raw ?? {}) as Record<string, unknown>;
return { assignee: str(r["assignee"]), assigneeId: str(r["assigneeId"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) }; return { assignee: str(r["assignee"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) };
} }
// ---- view models (pure; the EJS views read these) ----------------------------------- // ---- view models (pure; the EJS views read these) -----------------------------------
@@ -191,41 +196,6 @@ export function newShiftForm(): RouteHandler {
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" }); return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" });
} }
export function myShifts(upstream: ShiftsUpstream): RouteHandler {
return async (ctx) => {
const user = requireSession(ctx);
let shifts: Shift[] = [];
let error: string | undefined;
try {
// Join on the id, never the email: an address is user-changeable and can be reassigned to
// someone else, which would hand them the previous holder's rows. The re-filter is
// defence-in-depth: a backend that ignores an unknown query param would answer with everyone.
shifts = (await upstream.list({ assigneeId: user.id })).filter((s) => s.assigneeId === user.id);
} catch (err) {
ctx.log.warn("scheduling upstream unreachable", { error: String(err) });
error = ctx.t("scheduling.upstream.list");
}
return { data: buildMineModel({ chrome: ctx.chrome, email: user.email, ...(error ? { error } : {}), shifts, t: ctx.t }), view: "mine" };
};
}
export function buildMineModel(opts: { chrome: PageChrome; email: string; error?: string; shifts: Shift[]; t?: Translate }) {
const t = opts.t ?? EN;
return {
breadcrumbs: [{ label: t("scheduling.mine.title") }],
chrome: opts.chrome,
count: t("scheduling.shifts.count", { count: opts.shifts.length }),
...(opts.error ? { error: opts.error } : {}),
table: {
caption: t("scheduling.mine.title"),
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
emptyText: t("scheduling.mine.empty", { email: opts.email }),
rows: opts.shifts.map((s) => ({ cells: [{ rowHeader: { text: s.title } }, s.start, s.end], name: s.title })),
},
title: t("scheduling.mine.title"),
};
}
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the // Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data // gate lets an anonymous visitor through and the menu option shows for everyone. The real data
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone // (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
@@ -236,9 +206,7 @@ export function overview(): RouteHandler {
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }], breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
canRead: can(ctx, READ), canRead: can(ctx, READ),
chrome: ctx.chrome, chrome: ctx.chrome,
mineHref: ctx.localeHref(MINE_PATH),
shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links
signedIn: ctx.user !== null,
signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`), signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`),
title: ctx.t("scheduling.overview.title"), title: ctx.t("scheduling.overview.title"),
}, },
@@ -1,19 +0,0 @@
<%#
Scheduling · the visitor's own shifts (reference plugin).
Data: chrome, title, breadcrumbs, count, table, error?
%><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
const tableHtml = include("partials/data-table", table);
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
-%>
<%- include("partials/shell", {
body: '<div class="scheduling-page">' + alertHtml + (locals.error ? '' : '<p class="shift-count">' + count + '</p>' + tableHtml) + '</div>',
brand: chrome.brand,
breadcrumbs,
csrfToken: chrome.csrfToken,
nav: navHtml,
styles: ["/public/scheduling/scheduling.css"],
theme: chrome.theme,
title,
user: chrome.user,
}) %>
@@ -3,15 +3,11 @@
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome. it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
Data: chrome, title, breadcrumbs, canRead, mineHref, shiftsHref, signedIn, signInHref Data: chrome, title, breadcrumbs, canRead, shiftsHref, signInHref
%><% %><%
const navHtml = include("partials/nav-tree", { nodes: chrome.nav }); const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
// One CTA per gate the visitor passes: the list needs the permission, "My shifts" only a session,
// and sign-in is offered to nobody who already has one.
const cta = canRead const cta = canRead
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>' ? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
: signedIn
? '<a class="btn btn-primary" href="' + mineHref + '">' + t("scheduling.overview.mine") + '</a>'
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>'; : '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
+7 -13
View File
@@ -1,9 +1,9 @@
// Dev-only mock upstream for the reference plugin (examples/plugins/scheduling) — a stand-in for the // Dev-only mock upstream for the reference plugin (examples/plugins/scheduling) — a stand-in for the
// customer's real backend, ready for when you copy the reference plugin into plugins/. NOT part // customer's real backend, ready for when you copy the reference plugin into plugins/. NOT part
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM // of the app: stdlib only, in-memory (state resets on restart), no auth. Point SCHEDULING_UPSTREAM
// at your real service in production. // at your real service in production.
// //
// GET /shifts → 200 [ { id, title, assigneeId, assignee, start, end }, … ] (?assigneeId=<id> → only theirs) // GET /shifts → 200 [ { id, title, assignee, start, end }, … ]
// POST /shifts → 201 { id, … } (body: { title, assignee, start, end }) // POST /shifts → 201 { id, … } (body: { title, assignee, start, end })
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
@@ -11,12 +11,10 @@ import { createServer } from "node:http";
const PORT = Number(process.env.PORT ?? 4000); const PORT = Number(process.env.PORT ?? 4000);
// `assigneeId` is the identity the rows are owned by — an opaque, stable subject id, which is what
// `ctx.user.id` carries. These are this demo's own people; a real backend joins on your IdP's ids.
const shifts = [ const shifts = [
{ id: randomUUID(), title: "Morning — Front desk", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", assignee: "Avery Kline", start: "2026-06-22 08:00", end: "2026-06-22 12:00" }, { id: randomUUID(), title: "Morning — Front desk", assignee: "Avery Kline", start: "2026-06-22 08:00", end: "2026-06-22 12:00" },
{ id: randomUUID(), title: "Afternoon — Support", assigneeId: "019bdc1a-4a83-7de2-8f05-6c93a71be4d8", assignee: "Blair Mora", start: "2026-06-22 12:00", end: "2026-06-22 17:00" }, { id: randomUUID(), title: "Afternoon — Support", assignee: "Blair Mora", start: "2026-06-22 12:00", end: "2026-06-22 17:00" },
{ id: randomUUID(), title: "Evening — On-call", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", assignee: "Casey Nguyen", start: "2026-06-22 17:00", end: "2026-06-22 22:00" }, { id: randomUUID(), title: "Evening — On-call", assignee: "Casey Nguyen", start: "2026-06-22 17:00", end: "2026-06-22 22:00" },
]; ];
const json = (res, status, body) => { const json = (res, status, body) => {
@@ -35,14 +33,10 @@ const readBody = (req) =>
createServer(async (req, res) => { createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost"); const url = new URL(req.url ?? "/", "http://localhost");
if (url.pathname === "/shifts" && req.method === "GET") { if (url.pathname === "/shifts" && req.method === "GET") return json(res, 200, shifts);
const assigneeId = url.searchParams.get("assigneeId");
if (assigneeId === null) return json(res, 200, shifts);
return json(res, 200, shifts.filter((s) => s.assigneeId === assigneeId));
}
if (url.pathname === "/shifts" && req.method === "POST") { if (url.pathname === "/shifts" && req.method === "POST") {
const b = await readBody(req); const b = await readBody(req);
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), assigneeId: "", end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") }; const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
shifts.push(shift); shifts.push(shift);
return json(res, 201, shift); return json(res, 201, shift);
} }
+12 -12
View File
@@ -8,12 +8,12 @@
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "6.0.1", "ejs": "6.0.1",
"lucide-static": "1.58.0", "lucide-static": "1.33.0",
"postgres": "3.4.9" "postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
"@types/node": "24.19.2", "@types/node": "24.13.3",
"typescript": "7.0.2" "typescript": "7.0.2"
}, },
"engines": { "engines": {
@@ -37,13 +37,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": { "node_modules/@types/node": {
"version": "24.19.2", "version": "24.13.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==", "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": ">=7.24.0 <7.24.7" "undici-types": "~7.18.0"
} }
}, },
"node_modules/@typescript/typescript-aix-ppc64": { "node_modules/@typescript/typescript-aix-ppc64": {
@@ -399,9 +399,9 @@
} }
}, },
"node_modules/lucide-static": { "node_modules/lucide-static": {
"version": "1.58.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.58.0.tgz", "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.33.0.tgz",
"integrity": "sha512-VFE2ZEq3yEwjBH0El9CA9lHgDWKKXPKgKxFzX+/JN1FPa4maQbtl/pT4B62aJkUEpODT5octDsyGy7tPVE2svQ==", "integrity": "sha512-jNGgvTNcLUfVRX4N9PH9pVVTJzoph/BmYmgU838bYBQodkUJL4nAThkuymFz1x3OUYMhJxPndC7rdg1sxOPYKg==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/postgres": { "node_modules/postgres": {
@@ -453,9 +453,9 @@
} }
}, },
"node_modules/undici-types": { "node_modules/undici-types": {
"version": "7.24.6", "version": "7.18.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
} }
+3 -3
View File
@@ -13,17 +13,17 @@
"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\" \"release-tooling/**/*.test.ts\"" "test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"auto-release/**/*.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.58.0", "lucide-static": "1.33.0",
"postgres": "3.4.9" "postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
"@types/node": "24.19.2", "@types/node": "24.13.3",
"typescript": "7.0.2" "typescript": "7.0.2"
} }
} }
+10 -13
View File
@@ -111,6 +111,7 @@ html:has(#theme-light:checked) {
/* ---------- 2. RESET ---------------------------------------- */ /* ---------- 2. RESET ---------------------------------------- */
*, *::before, *::after { box-sizing: border-box; } *, *::before, *::after { box-sizing: border-box; }
html, body { height: 100%; }
body { margin: 0; background: var(--bg); color: var(--text); body { margin: 0; background: var(--bg); color: var(--text);
-webkit-font-smoothing: antialiased; } -webkit-font-smoothing: antialiased; }
button { font: inherit; color: inherit; } button { font: inherit; color: inherit; }
@@ -150,7 +151,8 @@ summary { list-style: none; cursor: pointer; }
.app { .app {
display: grid; display: grid;
grid-template-columns: var(--nav-w) minmax(0, 1fr); grid-template-columns: var(--nav-w) minmax(0, 1fr);
min-height: 100dvh; height: 100dvh;
overflow: hidden;
} }
/* ---------- 4. SIDEBAR -------------------------------------- */ /* ---------- 4. SIDEBAR -------------------------------------- */
@@ -158,6 +160,7 @@ summary { list-style: none; cursor: pointer; }
grid-column: 1; grid-column: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 0;
background: var(--surface); background: var(--surface);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
} }
@@ -313,11 +316,6 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.btn-primary:hover { filter: brightness(1.06); } .btn-primary:hover { filter: brightness(1.06); }
.btn-ghost { background: transparent; border-color: transparent; } .btn-ghost { background: transparent; border-color: transparent; }
.btn-ghost:hover { background: var(--surface-2); } .btn-ghost:hover { background: var(--surface-2); }
.btn-menu::after {
content: ""; width: 7px; height: 7px; margin: -2px 1px 0 1px;
border-right: 1.5px solid var(--text-faint); border-bottom: 1.5px solid var(--text-faint);
transform: rotate(45deg);
}
.icon-btn { .icon-btn {
width: 30px; height: 30px; padding: 0; justify-content: center; width: 30px; height: 30px; padding: 0; justify-content: center;
color: var(--text-muted); color: var(--text-muted);
@@ -328,7 +326,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.content { .content {
grid-column: 2; grid-column: 2;
display: flex; flex-direction: column; display: flex; flex-direction: column;
min-width: 0; min-width: 0; min-height: 0;
background: var(--bg); background: var(--bg);
} }
@@ -496,8 +494,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
position-anchor: auto; position-anchor: auto;
position-try-fallbacks: flip-block, flip-inline; position-try-fallbacks: flip-block, flip-inline;
top: anchor(bottom); right: anchor(right); top: anchor(bottom); right: anchor(right);
min-width: 210px; max-width: min(320px, 92vw); padding: 6px; min-width: 210px; padding: 6px;
max-height: 60vh; overflow-y: auto; overflow-wrap: anywhere;
background: var(--surface); color: var(--text); background: var(--surface); color: var(--text);
border: 1px solid var(--border-2); border-radius: var(--radius); border: 1px solid var(--border-2); border-radius: var(--radius);
box-shadow: 0 8px 28px rgba(0,0,0,.16); box-shadow: 0 8px 28px rgba(0,0,0,.16);
@@ -556,12 +553,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.pill-clear:hover { text-decoration: underline; } .pill-clear:hover { text-decoration: underline; }
/* ---------- 9. TABLE --------------------------------------- */ /* ---------- 9. TABLE --------------------------------------- */
.table-wrap { overflow-x: auto; } .table-wrap { flex: 1 1 auto; min-height: 0; overflow: auto; }
table.table { table.table {
width: 100%; border-collapse: separate; border-spacing: 0; width: 100%; border-collapse: separate; border-spacing: 0;
font-size: var(--fz); font-variant-numeric: tabular-nums; font-size: var(--fz); font-variant-numeric: tabular-nums;
} }
.table thead th { .table thead th {
position: sticky; top: 0; z-index: 10;
background: var(--surface-3); background: var(--surface-3);
border-bottom: 1px solid var(--border-2); border-bottom: 1px solid var(--border-2);
color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs); color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs);
@@ -693,7 +691,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
} }
/* the nav-toggle checkbox itself is visually hidden but focusable */ /* the nav-toggle checkbox itself is visually hidden but focusable */
#nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; } #nav-toggle { position: absolute; opacity: 0; pointer-events: none; }
/* admin forms: create/edit user, account actions */ /* admin forms: create/edit user, account actions */
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; } .form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
@@ -718,6 +716,5 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
/* Chromeless shell: a page may drop the sidebar for a focused single column. */ /* Chromeless shell: a page may drop the sidebar for a focused single column. */
.app-bare { grid-template-columns: minmax(0, 1fr); } .app-bare { grid-template-columns: minmax(0, 1fr); }
.app-bare .content { grid-column: 1; } .app-bare .content { grid-column: 1; }
/* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */ /* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */
.shell-auth { flex: 1 1 auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; } .shell-auth { flex: 1 1 auto; overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
-58
View File
@@ -1,58 +0,0 @@
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.4.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
@@ -1,49 +0,0 @@
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`);
}
@@ -1,48 +0,0 @@
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
@@ -1,104 +0,0 @@
// 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();
}
+3 -16
View File
@@ -6,7 +6,7 @@
"automerge": true, "automerge": true,
"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", "description": "The host's own runtime deps. Release-Bump is opt-in per surface (README → CI/CD) because updateType rates the dependency's own jump, not its effect here — unscoped, the bot's self-update bumped the product",
"matchDepTypes": ["dependencies"], "matchDepTypes": ["dependencies"],
"matchFileNames": ["package.json"], "matchFileNames": ["package.json"],
"matchManagers": ["npm"], "matchManagers": ["npm"],
@@ -24,12 +24,6 @@
"matchManagers": ["docker-compose"], "matchManagers": ["docker-compose"],
"commitBody": "Release-Bump: {{{updateType}}}" "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", "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"], "matchDatasources": ["docker"],
@@ -59,15 +53,8 @@
}, },
{ {
"customType": "regex", "customType": "regex",
"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", "description": "Pin the node image workflow run-steps invoke (registry-cleanup, auto-release)",
"managerFilePatterns": ["release-tooling/dockerhub-overview.md.tmpl"], "managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/renovate.yml"],
"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"
-28
View File
@@ -1,28 +0,0 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import type { User } from "../http/context.ts";
import { allows, gatesSet } from "./gate.ts";
const holder: User = { email: "holder@example.test", id: "01a06091-ba9f-765f-abf4-b5144c314bc7", permissions: ["x:read"] };
const stranger: User = { email: "stranger@example.test", id: "01a06091-baa3-7b4d-810a-c9ee7e559d98", permissions: [] };
test("allows: ungated and public are open to anyone; session needs a user; permission needs the token", () => {
assert.equal(allows({}, null), true);
assert.equal(allows({ public: true }, null), true);
assert.equal(allows({ session: true }, null), false);
assert.equal(allows({ session: true }, stranger), true); // signed in is the whole gate — no grant
assert.equal(allows({ permission: "x:read" }, null), false);
assert.equal(allows({ permission: "x:read" }, stranger), false);
assert.equal(allows({ permission: "x:read" }, holder), true);
});
test("gatesSet names the gates a declaration sets, so discovery can refuse more than one", () => {
assert.deepEqual(gatesSet({}), []);
assert.deepEqual(gatesSet({ session: true }), ["session"]);
assert.deepEqual(gatesSet({ permission: "x:read", public: true }), ["public", "permission"]);
assert.deepEqual(gatesSet({ permission: "x:read", public: true, session: true }), ["public", "session", "permission"]);
// Only `true` sets a gate, so a manifest spelling one `false` names none — which discovery refuses.
assert.deepEqual(gatesSet({ public: false, session: false }), []);
});
-22
View File
@@ -1,22 +0,0 @@
// One home for the gate rule, so the router and the menu can never disagree about what a visitor
// may reach. README → Public pages & menu items.
import type { User } from "../http/context.ts";
const GATES = ["public", "session", "permission"] as const;
export interface Gate {
permission?: string; // the Keto Permission the caller must hold, `<resource>:<action>`
public?: boolean; // anyone, signed in or not
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
}
export function allows(gate: Gate, user: User | null): boolean {
if (gate.public === true) return true;
if (gate.session === true) return user !== null;
return gate.permission == null || (user?.permissions.includes(gate.permission) ?? false);
}
export function gatesSet(gate: Gate | null | undefined): string[] {
if (gate == null) return [];
return GATES.filter((name) => (name === "permission" ? gate.permission != null : gate[name] === true));
}
+2 -14
View File
@@ -92,24 +92,12 @@ test("completeLogin returns null and touches nothing when there is no active ses
assert.equal(touched, false); assert.equal(touched, false);
}); });
test("completeLogin throws if the tokenizer yields no JWT", async () => { test("completeLogin maps a missing email trait to null and throws if the tokenizer yields no JWT", async () => {
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } }; const identity: Identity = { id: ID, traits: {} };
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity }) as Session }); // never returns a tokenized JWT const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity }) as Session }); // never returns a tokenized JWT
await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/); await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/);
}); });
// An identity with no email is no session, decided here so /auth/complete and remintSession cannot
// disagree: `claimsToUser` reads a token carrying none as anonymous, so minting one would hand the
// browser a cookie every later request refuses.
test("completeLogin refuses an identity carrying no email, before it mints anything", async () => {
const identity: Identity = { id: ID, traits: {} };
let touched = false;
const kratosAdmin = adminStub({ updateMetadataPublic: async () => { touched = true; return { id: ID }; } });
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity, tokenized: "h.p.s" }) as Session });
assert.equal(await completeLogin({ keto: ketoStub(), kratosAdmin, kratosPublic }, "c"), null);
assert.equal(touched, false); // no Keto read, no metadata write, no JWT
});
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => { test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } }; const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session }); const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
+3 -9
View File
@@ -31,7 +31,7 @@ export interface LoginDeps {
} }
export interface CompletedLogin { export interface CompletedLogin {
email: string; email: string | null;
userId: string; userId: string;
jwt: string; jwt: string;
permissions: string[]; permissions: string[];
@@ -61,13 +61,7 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
if (!session?.identity) return null; if (!session?.identity) return null;
const userId = session.identity.id; const userId = session.identity.id;
const emailTrait = session.identity.traits?.["email"]; const emailTrait = session.identity.traits?.["email"];
const email = typeof emailTrait === "string" ? emailTrait : ""; const email = typeof emailTrait === "string" ? emailTrait : null;
// No email is no session: `claimsToUser` reads a token carrying none as anonymous, so minting one
// would hand the browser a cookie every later request refuses.
if (!email) {
currentLog()?.warn("session dropped: identity has no email", { sub: userId });
return null;
}
const permissions = await readPermissions(deps.keto, userId); const permissions = await readPermissions(deps.keto, userId);
await deps.kratosAdmin.updateMetadataPublic(userId, { permissions }); await deps.kratosAdmin.updateMetadataPublic(userId, { permissions });
@@ -93,7 +87,7 @@ export interface Reminted {
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> { export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
const completed = await completeLogin(deps, cookie); const completed = await completeLogin(deps, cookie);
if (!completed) return { setCookie: clearSessionCookie(options), user: null }; if (!completed) return { setCookie: clearSessionCookie(options), user: null };
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email, id: completed.userId, permissions: completed.permissions } }; return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.userId, permissions: completed.permissions } };
} }
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is // Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
+1 -6
View File
@@ -4,7 +4,6 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { AUTH_FLOWS } from "./flow-view.ts"; import { AUTH_FLOWS } from "./flow-view.ts";
import { gatesSet } from "./gate.ts";
import type { HydraAdmin } from "./hydra-admin.ts"; import type { HydraAdmin } from "./hydra-admin.ts";
import type { KetoClient } from "./keto-client.ts"; import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts"; import type { KratosAdmin } from "./kratos-admin.ts";
@@ -40,12 +39,8 @@ test("hydra alone ⇒ only RP-initiated logout of the OAuth2 group (login/consen
}); });
test("everything wired ⇒ the full group: OAuth2 challenges, consent GET+POST, /auth/complete", () => { test("everything wired ⇒ the full group: OAuth2 challenges, consent GET+POST, /auth/complete", () => {
const routes = buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin })); const got = keys(buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin })));
const got = keys(routes);
for (const key of ["GET /auth/complete", "GET /login", "GET /oauth2/consent", "GET /oauth2/login", "GET /oauth2/logout", "POST /logout", "POST /oauth2/consent"]) { for (const key of ["GET /auth/complete", "GET /login", "GET /oauth2/consent", "GET /oauth2/login", "GET /oauth2/logout", "POST /logout", "POST /oauth2/consent"]) {
assert.ok(got.includes(key), key); assert.ok(got.includes(key), key);
} }
// Discovery enforces exactly one gate per plugin declaration; nothing checks the host's own table
// at boot, so a route added here without a gate would be silently public.
for (const route of routes) assert.deepEqual(gatesSet(route), ["public"], `${route.method} ${route.path}`);
}); });
+8 -8
View File
@@ -240,20 +240,20 @@ export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secure
const routes: BuiltinRoute[] = []; const routes: BuiltinRoute[] = [];
if (kratos) { if (kratos) {
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) { for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path, public: true }); routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path });
} }
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout", public: true }); routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout" });
} }
if (hydra && kratos) { if (hydra && kratos) {
const provider = { hydra, kratos }; const provider = { hydra, kratos };
routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login", public: true }); routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login" });
routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent", public: true }); routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent" });
routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent", public: true }); routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent" });
} }
if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout", public: true }); if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout" });
if (kratos && kratosAdmin && keto) { if (kratos && kratosAdmin && keto) {
routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete", public: true }); routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete" });
} }
routes.push({ handler: errorSink, method: "GET", path: "/error", public: true }); routes.push({ handler: errorSink, method: "GET", path: "/error" });
return routes; return routes;
} }
-2
View File
@@ -56,7 +56,6 @@ export interface Config {
pluginDbSecret: string; // derives each plugin's database password (src/plugin-host/storage.ts) 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 pluginDbUrl: string | undefined; // credential-free Postgres base URL; unset ⇒ plugin storage is off
port: number; port: number;
requireSecureSecrets: boolean; // enforce real secrets — the host's own, and every plugin's declared `secret`
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
secureCookies: boolean; secureCookies: boolean;
@@ -189,7 +188,6 @@ export function loadConfig(env: Env = process.env): Config {
pluginDbSecret: resolvePluginDbSecret(env, requireSecure && Boolean(env["PLUGIN_DB_URL"])), pluginDbSecret: resolvePluginDbSecret(env, requireSecure && Boolean(env["PLUGIN_DB_URL"])),
pluginDbUrl: readCredentiallessUrl(env, "PLUGIN_DB_URL"), pluginDbUrl: readCredentiallessUrl(env, "PLUGIN_DB_URL"),
port: readPort(env), port: readPort(env),
requireSecureSecrets: requireSecure,
// 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
// tokenizer TTL + skew, so it outlasts any pre-revoke token). // tokenizer TTL + skew, so it outlasts any pre-revoke token).
-7
View File
@@ -609,7 +609,6 @@ test("guards map to responses: requireSession → /login, a failed can/check →
{ handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" }, { handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" },
{ handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" }, { handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" },
{ handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate { handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate
{ handler: () => ({ html: "mine" }), method: "GET", path: "/mine", session: true }, // declarative session gate
], ],
}; };
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] }); const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] });
@@ -643,12 +642,6 @@ test("guards map to responses: requireSession → /login, a failed can/check →
assert.equal(gDenied.status, 403); assert.equal(gDenied.status, 403);
assert.match(await gDenied.text(), /403/); // the rendered 403.ejs over HTTP assert.match(await gDenied.text(), /403/); // the rendered 403.ejs over HTTP
assert.equal((await fetch(url + "/guarded/gated", auth(["secret:read"]))).status, 200); assert.equal((await fetch(url + "/guarded/gated", auth(["secret:read"]))).status, 200);
// declarative `session` gate: anonymous → sign in, and any signed-in user through, grant or none.
const sAnon = await fetch(url + "/guarded/mine", { redirect: "manual" });
assert.equal(sAnon.status, 303);
assert.equal(sAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fmine");
assert.equal((await fetch(url + "/guarded/mine", auth([]))).status, 200);
}); });
test("plugin hooks: onRequest can short-circuit a request and onResponse observes the handler result", async (t) => { test("plugin hooks: onRequest can short-circuit a request and onResponse observes the handler result", async (t) => {
+12 -20
View File
@@ -26,10 +26,8 @@ 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 { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts"; import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
import type { PluginSettings } from "../plugin-host/settings.ts";
import type { SystemCapabilities } from "../plugin-host/system.ts"; import type { SystemCapabilities } from "../plugin-host/system.ts";
import { allows, type Gate } from "../auth/gate.ts"; import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
import { allowedMethods, matchRoute } from "../plugin-host/router.ts";
import { buildAuthRoutes } from "../auth/routes.ts"; import { buildAuthRoutes } from "../auth/routes.ts";
import { securityHeaders } from "./security-headers.ts"; import { securityHeaders } from "./security-headers.ts";
import { localPath } from "./safe-url.ts"; import { localPath } from "./safe-url.ts";
@@ -56,7 +54,6 @@ export interface AppOptions {
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/ pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
publicDir?: string; publicDir?: string;
secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http) secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http)
settingsCatalog?: readonly PluginSettings[]; // resolved at boot (server.ts, needs the env); → ctx.declaredSettings
viewsDir?: string; viewsDir?: string;
} }
@@ -91,7 +88,6 @@ export function createApp(options: AppOptions = {}): Server {
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); const permissionCatalog = declaredPermissions(plugins);
const settingsCatalog = options.settingsCatalog ?? [];
// 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);
@@ -157,6 +153,7 @@ export function createApp(options: AppOptions = {}): Server {
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in // "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
// starter page. // starter page.
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => { const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
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.
csrf.setCookie(); csrf.setCookie();
if (dashboardPlugin) { if (dashboardPlugin) {
@@ -173,8 +170,8 @@ export function createApp(options: AppOptions = {}): Server {
// routes.ts, capability-gated on the wired clients) plus the two landing slots above. // routes.ts, capability-gated on the wired clients) plus the two landing slots above.
const builtinRoutes: BuiltinRoute[] = [ const builtinRoutes: BuiltinRoute[] = [
...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }), ...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }),
{ handler: serveHome, method: "GET", path: "/", public: true }, { handler: serveHome, method: "GET", path: "/" },
{ handler: serveDashboard, method: "GET", path: "/dashboard", session: true }, { handler: serveDashboard, method: "GET", path: "/dashboard" },
]; ];
// The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every // The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every
@@ -262,9 +259,9 @@ export function createApp(options: AppOptions = {}): Server {
// Base context (no route params), for the built-in routes. Every plugin-owned render — a // Base context (no route params), for the built-in routes. Every plugin-owned render — a
// landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead. // landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead.
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) }); const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, 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, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, 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.
@@ -278,19 +275,15 @@ export function createApp(options: AppOptions = {}): Server {
} }
} }
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply lacks
// the permission gets the 403 page.
const refuse = async (gate: Gate, gateCtx: RequestContext): Promise<void> => {
if (!gateCtx.user) { res.writeHead(303, { location: carryLocale(loginRedirect(gateCtx)) }).end(); return; }
reqLog.warn("forbidden: missing permission", { path: pathname, required: gate.permission ?? "", sub: gateCtx.user.id });
sendHtml(res, 403, await renderPage("403", {}));
};
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 (!allows(match.route, routeCtx.user)) { if (!isAuthorized(match.route, routeCtx.permissions)) {
await refuse(match.route, routeCtx); // Anonymous → sign in, remembering the page as return_to; a signed-in user who simply
// lacks the permission gets the 403 page.
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 });
sendHtml(res, 403, await renderPage("403", {}));
return; return;
} }
csrfMint.setCookie(); csrfMint.setCookie();
@@ -304,7 +297,6 @@ export function createApp(options: AppOptions = {}): Server {
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname); const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
if (builtin) { if (builtin) {
if (!allows(builtin, ctx.user)) { await refuse(builtin, ctx); return; }
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale); await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
return; return;
} }
+1 -2
View File
@@ -3,7 +3,6 @@
// mint (host-only — a plugin reads the token via ctx.chrome instead). app.ts matches this table // mint (host-only — a plugin reads the token via ctx.chrome instead). app.ts matches this table
// after plugin routes — exact path, a GET route also answering HEAD like the plugin router — and // after plugin routes — exact path, a GET route also answering HEAD like the plugin router — and
// pipes the result through sendResult against the core views. // pipes the result through sendResult against the core views.
import type { Gate } from "../auth/gate.ts";
import type { RequestContext } from "./context.ts"; import type { RequestContext } from "./context.ts";
import type { RouteResult } from "../plugin-host/plugin.ts"; import type { RouteResult } from "../plugin-host/plugin.ts";
@@ -20,7 +19,7 @@ export interface RequestCsrf {
// own context — otherwise the plugin's keys render as bare keys on the pages it owns. // own context — otherwise the plugin's keys render as bare keys on the pages it owns.
export type PluginContextFactory = (pluginId: string) => RequestContext; export type PluginContextFactory = (pluginId: string) => RequestContext;
export interface BuiltinRoute extends Gate { export interface BuiltinRoute {
// Returns a RouteResult, or null when the handler wrote to ctx.res itself // Returns a RouteResult, or null when the handler wrote to ctx.res itself
// (the landing slots dispatch a plugin's own result against that plugin's views). // (the landing slots dispatch a plugin's own result against that plugin's views).
handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null; handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null;
-6
View File
@@ -1,7 +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 { PermissionDecl } from "../plugin-host/plugin.ts"; // type-only
import type { PluginSettings } from "../plugin-host/settings.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";
@@ -43,9 +42,6 @@ export interface RequestContext {
// screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is // 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. // what *this user holds*. Empty when no installed plugin declares any.
declaredPermissions: readonly PermissionDecl[]; declaredPermissions: readonly PermissionDecl[];
// What each installed plugin declares it can be configured with, and how each key resolved — one
// entry per plugin, including those declaring nothing. A secret's value is never carried here.
declaredSettings: readonly PluginSettings[];
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")
@@ -71,7 +67,6 @@ export interface BuildContextOptions {
// 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[]; declaredPermissions?: readonly PermissionDecl[];
declaredSettings?: readonly PluginSettings[];
user?: User | null; user?: User | null;
locale?: string; locale?: string;
localeHref?: (href: string) => string; localeHref?: (href: string) => string;
@@ -101,7 +96,6 @@ export function buildContext(
return { return {
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); }, get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
declaredPermissions: options.declaredPermissions ?? [], declaredPermissions: options.declaredPermissions ?? [],
declaredSettings: options.declaredSettings ?? [],
user, user,
locale: options.locale ?? DEFAULT_LOCALE, locale: options.locale ?? DEFAULT_LOCALE,
localeHref: options.localeHref ?? ((href) => href), localeHref: options.localeHref ?? ((href) => href),
-1
View File
@@ -100,7 +100,6 @@ const messages = {
"filter.remove": "Remove {{label}} filter", "filter.remove": "Remove {{label}} filter",
"filter.reset": "Reset", "filter.reset": "Reset",
"filter.search": "Search", "filter.search": "Search",
"filter.selected": "{{label}}, {{count}} selected",
"filter.to": "To", "filter.to": "To",
"filter.toSeparator": "to", "filter.toSeparator": "to",
-1
View File
@@ -91,7 +91,6 @@ const messages: CoreMessages = {
"filter.remove": "Ta bort filtret {{label}}", "filter.remove": "Ta bort filtret {{label}}",
"filter.reset": "Återställ", "filter.reset": "Återställ",
"filter.search": "Sök", "filter.search": "Sök",
"filter.selected": "{{label}}, {{count}} valda",
"filter.to": "Till", "filter.to": "Till",
"filter.toSeparator": "till", "filter.toSeparator": "till",
+25 -42
View File
@@ -4,7 +4,6 @@ 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.
@@ -20,8 +19,8 @@ function scaffold(t: TestContext, files: Record<string, string>): string {
} }
const full = (id: string): string => const full = (id: string): string =>
`export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}", public: true }], ` + `export default { apiVersion: "1.0.0", nav: [{ id: "${id}:root", label: "${id}" }], ` +
`routes: [{ method: "GET", path: "/", public: true, 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 () => {
assert.deepEqual(await discoverPlugins({ dir: join(tmpdir(), "pp-does-not-exist-xyz") }), []); assert.deepEqual(await discoverPlugins({ dir: join(tmpdir(), "pp-does-not-exist-xyz") }), []);
@@ -31,12 +30,12 @@ test("discovers each folder's manifest, sorted, id derived from the folder name"
const dir = scaffold(t, { const dir = scaffold(t, {
"beta/plugin.ts": full("beta"), "beta/plugin.ts": full("beta"),
"alpha/plugin.ts": full("alpha"), "alpha/plugin.ts": full("alpha"),
"gamma/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", storage: true };`, "gamma/plugin.ts": `export default { apiVersion: "1.0.0", storage: true };`,
}); });
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta", "gamma"]); // deterministic order assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta", "gamma"]); // deterministic order
assert.equal(plugins[0]?.apiVersion, HOST_API_VERSION); assert.equal(plugins[0]?.apiVersion, "1.0.0");
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[0]?.storage, undefined); // storage is opt-in, never assumed
@@ -52,30 +51,21 @@ 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: "${HOST_API_VERSION}", routes: "nope" };` }, match: /weird.*routes.*array/s }, { name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "1.0.0", routes: "nope" };` }, match: /weird.*routes.*array/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 home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "1.0.0", home: "nope" };` }, match: /weirdhome.*home.*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-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", 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 }, { name: "non-boolean storage", files: { "weirdstore/plugin.ts": `export default { apiVersion: "1.0.0", storage: "postgres://db" };` }, match: /weirdstore.*storage.*boolean/s },
// The folder name becomes a Postgres identifier, which truncates past 63 bytes. // 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: "a storage plugin whose folder name overflows a Postgres identifier", files: { [`${"a".repeat(57)}/plugin.ts`]: `export default { apiVersion: "1.0.0", 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: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s }, { 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: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: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*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:read" }] };` }, match: /contranav.*public.*permission/s },
{ name: "a route marked session AND permission is contradictory", files: { "contrasess/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contrasess.*session.*permission/s },
{ name: "a route marked public AND session is contradictory", files: { "contrapub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, session: true, handler: () => ({ html: "x" }) }] };` }, match: /contrapub.*public.*session/s },
{ name: "a route whose session flag is a truthy non-boolean is refused, not read as ungated", files: { "truthy/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: "yes", handler: () => ({ html: "x" }) }] };` }, match: /truthy.*session.*true/s },
{ name: "a nav node whose public flag is a truthy non-boolean is refused too", files: { "truthynav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: 1 }] };` }, match: /truthynav.*public.*true/s },
{ name: "a nav node marked session AND permission is contradictory", files: { "contrasessnav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", session: true, permission: "x:read" }] };` }, match: /contrasessnav.*session.*permission/s },
// A gate is named, never forgotten: a route or node without one would be an open page nobody chose.
{ name: "a route naming no gate at all is refused, not served to everyone", files: { "nogate/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", handler: () => ({ html: "x" }) }] };` }, match: /nogate.*names no gate/s },
{ name: "a nav node naming no gate at all is refused too — a section header says `public` outright", files: { "nogatenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N" }] };` }, match: /nogatenav.*names no gate/s },
{ name: "a gate set to false is refused — it reads as a gate but sets none", files: { "falsegate/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: false, handler: () => ({ html: "x" }) }] };` }, match: /falsegate.*public.*true/s },
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not // A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
// only in the admin GUI, so it holds for a plugin installed without that GUI. // 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 route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "1.0.0", 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 nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "1.0.0", 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 declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "1.0.0", 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 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 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 that is not valid JSON", files: { "bent/package.json": `{`, "bent/plugin.ts": full("bent") }, match: /bent.*package\.json.*JSON/s },
@@ -83,8 +73,8 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
// `npm install --prefix plugins` — the documented command with one path segment dropped. // `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 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: "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 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/ },
{ 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/ }, { 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/ },
]; ];
for (const c of badCases) { for (const c of badCases) {
@@ -97,7 +87,7 @@ for (const c of badCases) {
// upgrade, not the author of the manifest — so the message has to carry the remedy, not just the // 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. // 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) => { 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" }) }] };` }); const dir = scaffold(t, { "admin/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/users", permission: "admin", handler: () => ({ html: "x" }) }] };` });
await assert.rejects(discoverPlugins({ dir }), (err: Error) => { await assert.rejects(discoverPlugins({ dir }), (err: Error) => {
assert.match(err.message, /gates on "admin"/); // what is wrong assert.match(err.message, /gates on "admin"/); // what is wrong
assert.match(err.message, /re-copy it/); // …and what to do about it assert.match(err.message, /re-copy it/); // …and what to do about it
@@ -105,19 +95,12 @@ test("a discovery failure tells the operator their plugins/ copy may just be out
}); });
}); });
test("a route + nav node may be marked public, or session, and load fine", async (t) => { test("a route + nav node may be marked public and load fine", async (t) => {
const dir = scaffold(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" }) }] };` });
"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" }) }] };`,
"sess/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/sess", id: "s", label: "S", session: true }], routes: [{ method: "GET", path: "/", session: true, handler: () => ({ html: "x" }) }] };`,
});
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
assert.equal(plugins.length, 2); assert.equal(plugins.length, 1);
const pub = plugins.find((p) => p.id === "pub"); assert.equal(plugins[0]?.routes?.[0]?.public, true);
const sess = plugins.find((p) => p.id === "sess"); assert.equal(plugins[0]?.nav?.[0]?.public, true);
assert.equal(pub?.routes?.[0]?.public, true);
assert.equal(pub?.nav?.[0]?.public, true);
assert.equal(sess?.routes?.[0]?.session, true);
assert.equal(sess?.nav?.[0]?.session, true);
}); });
test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => { test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => {
@@ -128,7 +111,7 @@ 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: "${HOST_API_VERSION}", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` }); const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", 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");
@@ -143,7 +126,7 @@ test("a plugin may carry its own package.json, node_modules and dependencies", a
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`, "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/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` + "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: "/", public: true, handler: () => ({ html: price(20) }) }] });`, `export default definePlugin({ apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`,
}); });
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
@@ -170,7 +153,7 @@ test("a dangling plugin symlink fails loud rather than vanishing", async (t) =>
}); });
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: "${HOST_API_VERSION}", permissions: [{ name: "shared:read" }] };`; const shared = `export default { apiVersion: "1.0.0", 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)) } });
+29 -32
View File
@@ -7,9 +7,7 @@
import { existsSync, readdirSync, readFileSync } 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 { type Gate, gatesSet } from "../auth/gate.ts";
import { checkApiVersion, findConflicts, isValidPermissionName, 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 { settingsDeclError } from "./settings.ts";
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.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)), "..", "..");
@@ -143,47 +141,46 @@ function shapeError(manifest: PluginManifest): string | null {
} }
// A truthy non-boolean (a DSN, say) must not quietly read as "provision me one". // 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`; if (manifest.storage !== undefined && typeof manifest.storage !== "boolean") return `"storage" must be a boolean`;
if (manifest.settings !== undefined) { // `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
const settings = settingsDeclError(manifest.settings); // "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
if (settings) return settings;
}
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) { for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
const gate = gateError(`route "${route?.method} ${route?.path}"`, route); if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`;
if (gate) return gate; }
const navContradiction = findPublicNavContradiction(manifest.nav);
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"`;
}
} }
const navGate = findNavGateError(manifest.nav);
if (navGate) return navGate;
for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) { for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) {
if (decl?.name == null || !isValidPermissionName(decl.name)) { if (decl?.name == null || !isValidPermissionName(decl.name)) {
return `declared permission "${decl?.name}" is not <resource>:<action>, e.g. "things:read"`; 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;
} }
// Every rule a declaration's gate must satisfy. Exactly one gate, always: a missing one would be an // Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory.
// open page nobody chose, and anything but `true` (a `false`, a `"yes"`) sets no gate while looking function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null {
// like it does. A permission name is `<resource>:<action>` because a bare word names a role, and
// roles are groups here (README → Naming a permission).
function gateError(what: string, gate: Gate | null | undefined): string | null {
for (const flag of ["public", "session"] as const) {
const value = gate?.[flag];
if (value !== undefined && value !== true) return `${what} sets ${flag} to ${JSON.stringify(value)}; a gate is declared with \`true\``;
}
const gates = gatesSet(gate);
if (gates.length === 0) return `${what} names no gate; name exactly one — public, session or permission`;
if (gates.length > 1) return `${what} sets ${gates.join(" and ")}; name exactly one — public, session or permission`;
if (gate?.permission != null && !isValidPermissionName(gate.permission)) {
return `${what} gates on "${gate.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
}
return null;
}
function findNavGateError(nodes: PluginManifest["nav"]): string | null {
for (const node of Array.isArray(nodes) ? nodes : []) { for (const node of Array.isArray(nodes) ? nodes : []) {
const err = gateError(`nav node "${node?.label ?? node?.id ?? "?"}"`, node); if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`;
if (err) return err; const inChild = findPublicNavContradiction(node?.children);
const inChild = findNavGateError(node?.children); if (inChild) return inChild;
}
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; if (inChild) return inChild;
} }
return null; return null;
-3
View File
@@ -8,14 +8,11 @@ export { definePlugin, isValidPermissionName } from "./plugin.ts";
export type { BootContext, HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts"; export type { BootContext, HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
// A plugin's own database, handed to onBoot when the manifest sets `storage`. Credentials, not a // A plugin's own database, handed to onBoot when the manifest sets `storage`. Credentials, not a
// client — the plugin depends on whichever driver it prefers (README → Plugin storage). // client — the plugin depends on whichever driver it prefers (README → Plugin storage).
export type { PluginSettings, SettingDecl, SettingSummary, SettingType, SettingValue } from "./settings.ts";
export type { StorageCredentials } from "./storage.ts"; export type { StorageCredentials } from "./storage.ts";
export type { RequestContext, User } from "../http/context.ts"; export type { RequestContext, User } from "../http/context.ts";
export type { PageChrome } from "../ui/chrome.ts"; export type { PageChrome } from "../ui/chrome.ts";
export type { NavNode } from "../ui/nav.ts"; export type { NavNode } from "../ui/nav.ts";
export { can, check, GuardError, requireSession } from "../auth/guards.ts"; export { can, check, GuardError, requireSession } from "../auth/guards.ts";
// The three coarse gates a route or nav node may declare — `Route` and `NavNode` both extend it.
export type { Gate } from "../auth/gate.ts";
// Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for // Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for
// authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator // authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator
// in a unit test. `PluralMessage` types a message that varies with a count. // in a unit test. `PluralMessage` types a message that varies with a count.
+1 -4
View File
@@ -80,15 +80,12 @@ test("parseSemver follows the semver core, rejecting ranges, prefixes, leading z
}); });
test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newer-minor/major-mismatch/malformed refuse", () => { test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newer-minor/major-mismatch/malformed refuse", () => {
assert.equal(checkApiVersion(HOST_API_VERSION).level, "ok"); // the host always accepts its own version assert.equal(checkApiVersion(HOST_API_VERSION).level, "ok"); // "1.0.0" vs "1.0.0"
assert.equal(checkApiVersion("1.0.5", "1.0.0").level, "ok"); // patch never affects compatibility assert.equal(checkApiVersion("1.0.5", "1.0.0").level, "ok"); // patch never affects compatibility
assert.equal(checkApiVersion("1.0.0", "1.2.0").level, "warn"); // older minor still runs (additive), nudge to update assert.equal(checkApiVersion("1.0.0", "1.2.0").level, "warn"); // older minor still runs (additive), nudge to update
assert.equal(checkApiVersion("1.3.0", "1.2.0").level, "refuse"); // needs features a newer host has assert.equal(checkApiVersion("1.3.0", "1.2.0").level, "refuse"); // needs features a newer host has
assert.equal(checkApiVersion("2.0.0", "1.5.0").level, "refuse"); // incompatible major (newer) assert.equal(checkApiVersion("2.0.0", "1.5.0").level, "refuse"); // incompatible major (newer)
assert.equal(checkApiVersion("1.0.0", "2.0.0").level, "refuse"); // incompatible major (older) assert.equal(checkApiVersion("1.0.0", "2.0.0").level, "refuse"); // incompatible major (older)
assert.equal(checkApiVersion("0.1.0", "0.1.9").level, "ok"); // pre-1.0 patch is still ignored
assert.equal(checkApiVersion("0.1.0", "0.2.0").level, "refuse"); // pre-1.0 the minor IS the breaking slot
assert.match(checkApiVersion("0.2.0", "0.1.0").message, /upgrade the host/); // ahead of the host, even pre-1.0
for (const bad of ["1", "1.2", "v1.2.3", "01.2.3", "1.2.x", "", 1, undefined, null]) { for (const bad of ["1", "1.2", "v1.2.3", "01.2.3", "1.2.x", "", 1, undefined, null]) {
assert.equal(checkApiVersion(bad).level, "refuse", `${String(bad)} must refuse`); assert.equal(checkApiVersion(bad).level, "refuse", `${String(bad)} must refuse`);
} }
+16 -37
View File
@@ -4,14 +4,12 @@
// A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount = // A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount =
// `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice. // `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice.
import type { Gate } from "../auth/gate.ts";
import type { RequestContext } from "../http/context.ts"; import type { RequestContext } from "../http/context.ts";
import type { NavNode } from "../ui/nav.ts"; import type { NavNode } from "../ui/nav.ts";
import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
import type { StorageCredentials } from "./storage.ts"; import type { StorageCredentials } from "./storage.ts";
// The Plainpages release this contract ships in — see README → Contract versioning. // Bump major on a breaking manifest/handler change, minor on an additive one.
export const HOST_API_VERSION = "0.4.0"; export const HOST_API_VERSION = "1.0.0";
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
@@ -25,10 +23,14 @@ export type RouteResult =
export type RouteHandler = (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void; export type RouteHandler = (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void;
export interface Route extends Gate { export interface Route {
handler: RouteHandler; handler: RouteHandler;
method: HttpMethod; method: HttpMethod;
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
permission?: string; // coarse gate — the Keto Permission the caller must hold; checked before the handler runs
// Same as omitting `permission`, but stated outright so public is a deliberate choice rather than
// a forgotten gate. Mutually exclusive with `permission` (discovery refuses both).
public?: boolean;
} }
// A Keto Permission this plugin gates on — declared for docs/seeding. Names are a shared global // A Keto Permission this plugin gates on — declared for docs/seeding. Names are a shared global
@@ -60,39 +62,30 @@ export function declaredPermissions(plugins: Plugin[]): PermissionDecl[] {
} }
// What onBoot receives. A hook declaring no parameter stays valid, so this may grow additively. // What onBoot receives. A hook declaring no parameter stays valid, so this may grow additively.
export type BootContext<S extends readonly SettingDecl[] = readonly SettingDecl[]> = { export interface BootContext {
storage?: StorageCredentials; // this plugin's own database; present iff the manifest declared `storage` storage?: StorageCredentials; // this plugin's own database; present iff the manifest declared `storage`
} & SettingsSlot<S>; }
// Required once the manifest declares settings, so that plugin reads `settings.key` without a guard
// for the case it just ruled out; optional for a manifest that declared none.
type SettingsSlot<S extends readonly SettingDecl[]> = readonly [] extends S
? { settings?: SettingsOf<S> }
: { settings: SettingsOf<S> };
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud. // Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
export interface PluginHooks<S extends readonly SettingDecl[] = readonly SettingDecl[]> { export interface PluginHooks {
onBoot?: (host: BootContext<S>) => Promise<void> | void; // after discovery, before the server listens onBoot?: (host: BootContext) => Promise<void> | void; // after discovery, before the server listens
onRequest?: (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void; // may short-circuit onRequest?: (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void; // may short-circuit
onResponse?: (ctx: RequestContext, result: RouteResult | null) => Promise<void> | void; onResponse?: (ctx: RequestContext, result: RouteResult | null) => Promise<void> | void;
} }
// The authored manifest — a plugin's `plugin.ts` default-exports this. No `id`/mount path: the // The authored manifest — a plugin's `plugin.ts` default-exports this. No `id`/mount path: the
// host derives them from the folder name at discovery (see Plugin). // host derives them from the folder name at discovery (see Plugin).
export interface PluginManifest<S extends readonly SettingDecl[] = readonly SettingDecl[]> { export interface PluginManifest {
apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs) apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs)
// Take over "/dashboard"; the host gates it to a signed-in session first. At most one plugin may // Take over "/dashboard"; the host gates it to a signed-in session first. At most one plugin may
// declare it (findConflicts → error, never last-write-wins). // declare it (findConflicts → error, never last-write-wins).
dashboard?: RouteHandler; dashboard?: RouteHandler;
// Take over the ungated public landing "/". At most one plugin may declare it. // Take over the ungated public landing "/". At most one plugin may declare it.
home?: RouteHandler; home?: RouteHandler;
hooks?: PluginHooks<S>; hooks?: PluginHooks;
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
permissions?: PermissionDecl[]; permissions?: PermissionDecl[];
routes?: Route[]; routes?: Route[];
// Operator-supplied configuration, one PLUGIN_SETTING_<ID>_<KEY> variable per key; the resolved
// values arrive on onBoot's BootContext, typed from these declarations (settings.ts).
settings?: S;
// Ask for a Postgres database of this plugin's own; its credentials arrive on onBoot's BootContext. // Ask for a Postgres database of this plugin's own; its credentials arrive on onBoot's BootContext.
// The host provisions and locks it down but owns no schema inside it, and never drops it. // The host provisions and locks it down but owns no schema inside it, and never drops it.
storage?: boolean; storage?: boolean;
@@ -106,9 +99,7 @@ export interface Plugin extends PluginManifest {
// Types the manifest and returns it unchanged; validation happens at discovery, so a plugin may // Types the manifest and returns it unchanged; validation happens at discovery, so a plugin may
// equally be a plain typed object. // equally be a plain typed object.
// The `const` parameter captures the literal `settings`, so onBoot receives each key at its declared export function definePlugin(manifest: PluginManifest): PluginManifest {
// type instead of a union every plugin author would have to narrow with a cast.
export function definePlugin<const S extends readonly SettingDecl[]>(manifest: PluginManifest<S>): PluginManifest<S> {
return manifest; return manifest;
} }
@@ -167,17 +158,13 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` }; return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` };
} }
if (plugin.minor < host.minor) { if (plugin.minor < host.minor) {
// Pre-1.0 the major is pinned at 0, so a minor is the only slot a breaking change can use. return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — newer features available` };
if (host.major === 0) {
return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — pre-1.0 a minor is a contract break, rebuild against ${hostVersion}` };
}
return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — built against an older release` };
} }
return { level: "ok", message: `apiVersion ${pluginVersion}` }; return { level: "ok", message: `apiVersion ${pluginVersion}` };
} }
export interface PluginConflict { export interface PluginConflict {
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route" | "setting"; kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route";
level: "error" | "warn"; level: "error" | "warn";
message: string; message: string;
plugins: string[]; // unique ids involved plugins: string[]; // unique ids involved
@@ -218,14 +205,6 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "<resource>" unless shared on purpose`, plugins: uniq(owners) }); if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "<resource>" unless shared on purpose`, plugins: uniq(owners) });
}); });
// Both the id's dashes and the key's camel humps become underscores, so plugin "a-b" key "c" and
// plugin "a" key "bC" name one variable — one plugin would silently read the other's value.
collect(plugins, (plugin, push) => {
for (const decl of plugin.settings ?? []) push(envName(plugin.id, decl.key));
}).forEach((owners, name) => {
if (owners.length > 1) out.push({ kind: "setting", level: "error", message: `${owners.length} settings resolve to "${name}"; rename a key or a plugin folder`, plugins: uniq(owners) });
});
return out; return out;
} }
+12 -1
View File
@@ -1,7 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import type { Plugin, Route } from "./plugin.ts"; import type { Plugin, Route } from "./plugin.ts";
import { allowedMethods, matchRoute } from "./router.ts"; import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
const noop: Route["handler"] = () => ({ html: "x" }); const noop: Route["handler"] = () => ({ html: "x" });
@@ -54,3 +54,14 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the
assert.deepEqual(allowedMethods(plugins, "/x/a"), ["GET", "HEAD", "POST"]); assert.deepEqual(allowedMethods(plugins, "/x/a"), ["GET", "HEAD", "POST"]);
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []); assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
}); });
test("isAuthorized: open routes pass; gated routes require the permission token; public is explicitly open", () => {
const open: Route = { handler: noop, method: "GET", path: "/" };
const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" };
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias
assert.equal(isAuthorized(open, []), true);
assert.equal(isAuthorized(gated, []), false);
assert.equal(isAuthorized(gated, ["x:read"]), true);
assert.equal(isAuthorized(gated, ["other"]), false);
assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting permission — but stated outright
});
+7
View File
@@ -73,3 +73,10 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
} }
return [...methods].sort(); return [...methods].sort();
} }
// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise
// the user's permissions (from the session JWT) must include the token. The same rule composeNav uses
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
export function isAuthorized(route: Route, permissions: string[]): boolean {
return route.public === true || route.permission == null || permissions.includes(route.permission);
}
-177
View File
@@ -1,177 +0,0 @@
// Guards the plugin-settings rules: the env name a declaration maps to, per-type coercion, the
// required/default/secret resolution, and what the admin catalog is allowed to carry.
import { test } from "node:test";
import assert from "node:assert/strict";
import type { Plugin } from "./plugin.ts";
import {
ENV_PREFIX,
envName,
isValidSettingKey,
resolveSettings,
settingsDeclError,
settingsEnvNames,
strayNames,
type SettingDecl,
} from "./settings.ts";
function pluginWith(id: string, settings: SettingDecl[]): Plugin {
return { apiVersion: "0.2.0", id, settings };
}
test("a key becomes one SCREAMING_SNAKE segment under the plugin's own", () => {
assert.equal(envName("scheduling", "timezone"), "PLUGIN_SETTING_SCHEDULING_TIMEZONE");
assert.equal(envName("scheduling", "maxShiftHours"), "PLUGIN_SETTING_SCHEDULING_MAX_SHIFT_HOURS");
assert.equal(envName("my-plugin", "apiBase"), "PLUGIN_SETTING_MY_PLUGIN_API_BASE");
assert.equal(ENV_PREFIX, "PLUGIN_SETTING_"); // never bare PLUGIN_ — the host owns PLUGIN_DB_*
});
test("the host's own PLUGIN_DB_* variables are unreachable from a declaration", () => {
// A plugin id "db" with key "url" is exactly the collision the longer prefix rules out.
assert.notEqual(envName("db", "url"), "PLUGIN_DB_URL");
assert.equal(envName("db", "url"), "PLUGIN_SETTING_DB_URL");
});
test("a key is camelCase, so the env name is derivable and no two keys collide", () => {
assert.ok(isValidSettingKey("timezone"));
assert.ok(isValidSettingKey("maxShiftHours"));
assert.ok(!isValidSettingKey("max_shift_hours")); // would collide with maxShiftHours
assert.ok(!isValidSettingKey("MaxShiftHours"));
assert.ok(!isValidSettingKey("2fa"));
assert.ok(!isValidSettingKey(""));
});
test("a declaration is refused when it cannot mean what it says", () => {
const why = (settings: unknown): string => settingsDeclError(settings) ?? "";
assert.equal(settingsDeclError([{ key: "a", type: "string" }]), null);
assert.match(why("nope"), /must be an array/);
assert.match(why([{ key: "max_hours", type: "number" }]), /max_hours.*camelCase/);
assert.match(why([{ key: "a", type: "date" }]), /"date".*string, number, boolean, enum, url/);
assert.match(why([{ key: "a", type: "string" }, { key: "a", type: "number" }]), /declared twice/);
// required means "boot fails without it", so a default would make the flag a lie.
assert.match(why([{ default: "x", key: "a", required: true, type: "string" }]), /required.*default.*mutually exclusive/);
assert.match(why([{ default: 8, key: "a", type: "string" }]), /default.*string/);
assert.match(why([{ key: "a", type: "enum" }]), /enum.*values/);
assert.match(why([{ key: "a", type: "enum", values: [] }]), /enum.*values/);
assert.match(why([{ default: "c", key: "a", type: "enum", values: ["a", "b"] }]), /default "c".*a, b/);
assert.match(why([{ key: "a", type: "string", values: ["a"] }]), /values.*only.*enum/);
});
test("an unset optional setting resolves to undefined, not to a stand-in", () => {
const result = resolveSettings([pluginWith("p", [{ key: "a", type: "string" }])], {});
assert.deepEqual(result.errors, []);
assert.equal(result.values.get("p")?.["a"], undefined);
});
test("a default fills in, and an env value overrides it", () => {
const plugins = [pluginWith("p", [{ default: 8, key: "maxHours", type: "number" }])];
assert.equal(resolveSettings(plugins, {}).values.get("p")?.["maxHours"], 8);
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_MAX_HOURS: "12" }).values.get("p")?.["maxHours"], 12);
});
test("an empty variable reads as unset — compose passes an unset one through as \"\"", () => {
const plugins = [pluginWith("p", [{ default: "fallback", key: "a", type: "string" }])];
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_A: "" }).values.get("p")?.["a"], "fallback");
const required = [pluginWith("p", [{ key: "a", required: true, type: "string" }])];
assert.match(resolveSettings(required, { PLUGIN_SETTING_P_A: "" }).errors.join("\n"), /must be set/);
});
test("a missing required setting is an error naming the plugin, the key and the variable", () => {
const result = resolveSettings([pluginWith("scheduling", [{ key: "timezone", required: true, type: "string" }])], {});
assert.equal(result.errors.length, 1);
assert.match(result.errors[0] ?? "", /scheduling/);
assert.match(result.errors[0] ?? "", /timezone/);
assert.match(result.errors[0] ?? "", /PLUGIN_SETTING_SCHEDULING_TIMEZONE/);
});
test("each type coerces from the environment, and a bad value fails loud", () => {
const decls: SettingDecl[] = [
{ key: "text", type: "string" },
{ key: "count", type: "number" },
{ key: "flag", type: "boolean" },
{ key: "mode", type: "enum", values: ["strict", "lenient"] },
{ key: "base", type: "url" },
];
const ok = resolveSettings([pluginWith("p", decls)], {
PLUGIN_SETTING_P_BASE: "https://example.com/v1",
PLUGIN_SETTING_P_COUNT: "42",
PLUGIN_SETTING_P_FLAG: "true",
PLUGIN_SETTING_P_MODE: "strict",
PLUGIN_SETTING_P_TEXT: "hello",
});
assert.deepEqual(ok.errors, []);
assert.deepEqual(ok.values.get("p"), { base: "https://example.com/v1", count: 42, flag: true, mode: "strict", text: "hello" });
const bad = resolveSettings([pluginWith("p", decls)], {
PLUGIN_SETTING_P_BASE: "not a url",
PLUGIN_SETTING_P_COUNT: "twelve",
PLUGIN_SETTING_P_FLAG: "yes",
PLUGIN_SETTING_P_MODE: "loose",
});
assert.equal(bad.errors.length, 4);
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_COUNT.*number/);
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_FLAG.*"true".*"false"/);
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_MODE.*strict, lenient/);
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_BASE.*URL/);
});
test("a boolean is only \"true\"/\"false\" — a typo never degrades to false", () => {
const plugins = [pluginWith("p", [{ default: true, key: "flag", type: "boolean" }])];
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_FLAG: "false" }).values.get("p")?.["flag"], false);
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_FLAG: "0" }).errors.length, 1);
});
test("REQUIRE_SECURE_SECRETS refuses an unset secret and one still on its dev default", () => {
const decls: SettingDecl[] = [{ default: "dev-insecure", key: "apiKey", secret: true, type: "string" }];
const plugins = [pluginWith("p", decls)];
assert.deepEqual(resolveSettings(plugins, {}).errors, []); // off: the dev default boots a clean clone
assert.match(resolveSettings(plugins, {}, { requireSecureSecrets: true }).errors.join("\n"), /apiKey.*must be set/);
assert.match(
resolveSettings(plugins, { PLUGIN_SETTING_P_API_KEY: "dev-insecure" }, { requireSecureSecrets: true }).errors.join("\n"),
/apiKey.*dev/,
);
assert.deepEqual(resolveSettings(plugins, { PLUGIN_SETTING_P_API_KEY: "real" }, { requireSecureSecrets: true }).errors, []);
});
test("a secret's value reaches the plugin but never the catalog", () => {
const plugins = [pluginWith("p", [{ key: "apiKey", secret: true, type: "string" }])];
const result = resolveSettings(plugins, { PLUGIN_SETTING_P_API_KEY: "s3cr3t" });
assert.equal(result.values.get("p")?.["apiKey"], "s3cr3t");
const entry = result.catalog[0]?.settings[0];
assert.equal(entry?.secret, true);
assert.equal(entry?.source, "env");
assert.equal(entry?.value, undefined); // not the value, not its length, not a mask of it
assert.ok(!JSON.stringify(result.catalog).includes("s3cr3t"));
});
test("the catalog carries every installed plugin, so \"declares none\" is not \"not installed\"", () => {
const plugins = [pluginWith("with", [{ default: "x", key: "a", type: "string" }]), { apiVersion: "0.2.0", id: "without" }];
const catalog = resolveSettings(plugins, {}).catalog;
assert.deepEqual(catalog.map((entry) => entry.pluginId), ["with", "without"]);
assert.deepEqual(catalog[1]?.settings, []);
});
test("a catalog entry carries the variable to set and where the value came from", () => {
const decls: SettingDecl[] = [
{ description: "Where shifts come from", key: "upstream", required: true, type: "url" },
{ default: 8, key: "maxHours", type: "number" },
{ key: "note", type: "string" },
];
const catalog = resolveSettings([pluginWith("scheduling", decls)], { PLUGIN_SETTING_SCHEDULING_UPSTREAM: "https://x.test" }).catalog;
assert.deepEqual(catalog[0]?.settings, [
{ description: "Where shifts come from", envName: "PLUGIN_SETTING_SCHEDULING_UPSTREAM", key: "upstream", required: true, secret: false, source: "env", type: "url", value: "https://x.test" },
{ envName: "PLUGIN_SETTING_SCHEDULING_MAX_HOURS", key: "maxHours", required: false, secret: false, source: "default", type: "number", value: "8" },
{ envName: "PLUGIN_SETTING_SCHEDULING_NOTE", key: "note", required: false, secret: false, source: "unset", type: "string" },
]);
});
test("a variable no plugin declares is reported, never acted on", () => {
const declared = settingsEnvNames([pluginWith("scheduling", [{ key: "timezone", type: "string" }])]);
const strays = strayNames(
{ PATH: "/usr/bin", PLUGIN_DB_URL: "postgres://x", PLUGIN_SETTING_GONE_KEY: "x", PLUGIN_SETTING_SCHEDULING_TIMEZOME: "UTC", PLUGIN_SETTING_SCHEDULING_TIMEZONE: "UTC" },
declared,
);
assert.deepEqual(strays, ["PLUGIN_SETTING_GONE_KEY", "PLUGIN_SETTING_SCHEDULING_TIMEZOME"]); // sorted; the host's own untouched
});
-259
View File
@@ -1,259 +0,0 @@
// Per-plugin settings: the declaration shape, the env name it maps to, and the resolution rules
// (README → Plugin settings). Pure — server.ts passes `process.env` in, so the whole matrix is
// unit-testable without a stack.
import type { Plugin } from "./plugin.ts";
// `PLUGIN_` alone would let a plugin id "db" with key "url" produce the host's own PLUGIN_DB_URL.
export const ENV_PREFIX = "PLUGIN_SETTING_";
export const SETTING_TYPES = ["string", "number", "boolean", "enum", "url"] as const;
export type SettingType = (typeof SETTING_TYPES)[number];
export type SettingValue = boolean | number | string;
// What a manifest declares. `required` and `default` are mutually exclusive: a default means the
// setting can never fail resolution, which is the opposite of what required asserts.
export interface SettingDecl {
default?: SettingValue;
description?: string;
key: string;
required?: boolean;
secret?: boolean; // value reaches the plugin, never a log, an error or the catalog
type: SettingType;
values?: readonly string[]; // enum only — the accepted choices
}
interface SettingTypeMap {
boolean: boolean;
enum: string;
number: number;
string: string;
url: string;
}
type ValueOfDecl<D> = D extends { type: "enum"; values: readonly (infer V extends string)[] }
? V
: D extends { type: infer T extends keyof SettingTypeMap }
? SettingTypeMap[T]
: never;
// The resolved shape a plugin's onBoot receives, inferred from its own declarations so no caller
// narrows with a cast. Only a required or defaulted setting is guaranteed present.
export type SettingsOf<D extends readonly SettingDecl[]> = {
[K in D[number] as K["key"]]: K extends { required: true }
? ValueOfDecl<K>
: K extends { default: SettingValue }
? ValueOfDecl<K>
: ValueOfDecl<K> | undefined;
};
export type SettingsValues = Record<string, SettingValue | undefined>;
// One row of the admin catalog. `value` is a display string and is absent for a secret and for an
// unset setting — a secret's length is a disclosure too, so nothing stands in for it.
export interface SettingSummary {
description?: string;
envName: string;
key: string;
required: boolean;
secret: boolean;
source: "default" | "env" | "unset";
type: SettingType;
value?: string;
values?: readonly string[];
}
export interface PluginSettings {
pluginId: string;
settings: SettingSummary[];
}
export interface ResolveResult {
catalog: PluginSettings[];
errors: string[];
values: Map<string, SettingsValues>;
}
export interface ResolveOptions {
requireSecureSecrets?: boolean;
}
type Env = Record<string, string | undefined>;
const SETTING_KEY = /^[a-z][a-zA-Z0-9]*$/;
export function isValidSettingKey(key: unknown): boolean {
return typeof key === "string" && SETTING_KEY.test(key);
}
export function envName(pluginId: string, key: string): string {
const plugin = pluginId.replaceAll("-", "_").toUpperCase();
return `${ENV_PREFIX}${plugin}_${camelToSnake(key)}`;
}
function camelToSnake(key: string): string {
return key.replaceAll(/(?<=[a-z0-9])(?=[A-Z])/g, "_").toUpperCase();
}
// Discovery-time shape check: the author's mistakes, refused before any value is read.
export function settingsDeclError(settings: unknown): string | null {
if (!Array.isArray(settings)) return `"settings" must be an array`;
const seen = new Set<string>();
for (const decl of settings as SettingDecl[]) {
const key = decl?.key;
if (!isValidSettingKey(key)) {
return `setting "${String(key)}" — a key must be camelCase (${SETTING_KEY.source}) so its variable name is derivable`;
}
if (seen.has(key)) return `setting "${key}" is declared twice`;
seen.add(key);
if (!(SETTING_TYPES as readonly string[]).includes(decl.type)) {
return `setting "${key}" has type "${String(decl.type)}"; one of ${SETTING_TYPES.join(", ")}`;
}
if (decl.required === true && decl.default !== undefined) {
return `setting "${key}" sets both required and default — they are mutually exclusive, a default means it can never fail`;
}
if (decl.type === "enum") {
if (!Array.isArray(decl.values) || decl.values.length === 0 || decl.values.some((v) => typeof v !== "string")) {
return `setting "${key}" has type enum, so it must declare a non-empty values array of strings`;
}
} else if (decl.values !== undefined) {
return `setting "${key}" declares values, which only an enum type may do`;
}
const typeError = defaultTypeError(decl);
if (typeError) return typeError;
}
return null;
}
function defaultTypeError(decl: SettingDecl): string | null {
if (decl.default === undefined) return null;
if (decl.type === "enum") {
const values = decl.values ?? [];
return values.includes(String(decl.default))
? null
: `setting "${decl.key}" has default "${String(decl.default)}", which is not one of ${values.join(", ")}`;
}
const expected = decl.type === "number" ? "number" : decl.type === "boolean" ? "boolean" : "string";
return typeof decl.default === expected
? null
: `setting "${decl.key}": default must be a ${expected} (type ${decl.type}), got ${typeof decl.default}`;
}
// Every variable the installed plugins answer to — the set a stray is measured against.
export function settingsEnvNames(plugins: Plugin[]): Set<string> {
const names = new Set<string>();
for (const plugin of plugins) {
for (const decl of plugin.settings ?? []) names.add(envName(plugin.id, decl.key));
}
return names;
}
// A PLUGIN_SETTING_ variable no installed plugin declares — usually a typo in the one the operator
// meant to set, or a plugin they removed. Reported, never acted on (the orphan-database precedent).
export function strayNames(env: Env, declared: ReadonlySet<string>): string[] {
return Object.keys(env)
.filter((name) => name.startsWith(ENV_PREFIX) && !declared.has(name))
.sort();
}
export function resolveSettings(plugins: Plugin[], env: Env, options: ResolveOptions = {}): ResolveResult {
const catalog: PluginSettings[] = [];
const errors: string[] = [];
const values = new Map<string, SettingsValues>();
for (const plugin of plugins) {
const decls = plugin.settings ?? [];
const resolved: SettingsValues = {};
const summaries: SettingSummary[] = [];
for (const decl of decls) {
const name = envName(plugin.id, decl.key);
const raw = env[name] || undefined; // compose passes an unset variable through as ""
const fail = (message: string): void => void errors.push(`plugin "${plugin.id}": ${message}`);
let value: SettingValue | undefined;
let source: SettingSummary["source"] = "unset";
if (raw !== undefined) {
const coerced = coerce(decl, raw, name);
if (typeof coerced === "string") fail(coerced);
else {
value = coerced.value;
source = "env";
}
} else if (decl.default !== undefined) {
value = decl.default;
source = "default";
} else if (decl.required === true) {
fail(`setting "${decl.key}" must be set — ${name} (type ${decl.type}, no default)`);
}
const secretError = secretPolicyError(decl, raw, options.requireSecureSecrets === true, name);
if (secretError) fail(secretError);
resolved[decl.key] = value;
summaries.push(summarize(decl, name, source, value));
}
if (decls.length > 0) values.set(plugin.id, resolved);
catalog.push({ pluginId: plugin.id, settings: summaries });
}
return { catalog, errors, values };
}
// The host's own rule for a secret (readSecret), reaching plugins: enforced, neither unset nor the
// declared dev throwaway is accepted.
function secretPolicyError(decl: SettingDecl, raw: string | undefined, enforce: boolean, name: string): string | null {
if (!enforce || decl.secret !== true) return null;
if (raw === undefined) return `setting "${decl.key}" must be set when REQUIRE_SECURE_SECRETS=true — ${name}`;
if (decl.default !== undefined && raw === String(decl.default)) {
return `setting "${decl.key}" must not be its dev default when REQUIRE_SECURE_SECRETS=true — ${name}`;
}
return null;
}
function summarize(decl: SettingDecl, name: string, source: SettingSummary["source"], value: SettingValue | undefined): SettingSummary {
const showValue = decl.secret !== true && value !== undefined;
return {
...(decl.description !== undefined ? { description: decl.description } : {}),
envName: name,
key: decl.key,
required: decl.required === true,
secret: decl.secret === true,
source,
type: decl.type,
...(showValue ? { value: String(value) } : {}),
...(decl.values !== undefined ? { values: decl.values } : {}),
};
}
// A coerced value, or the boot error naming the variable and what it accepts.
function coerce(decl: SettingDecl, raw: string, name: string): { value: SettingValue } | string {
switch (decl.type) {
case "boolean":
if (raw === "true") return { value: true };
if (raw === "false") return { value: false };
return `${name} must be "true" or "false", got "${raw}"`;
case "enum":
return (decl.values ?? []).includes(raw)
? { value: raw }
: `${name} must be one of ${(decl.values ?? []).join(", ")}, got "${raw}"`;
case "number": {
const value = Number(raw);
return Number.isFinite(value) ? { value } : `${name} must be a number, got "${raw}"`;
}
case "url":
try {
new URL(raw);
} catch {
return `${name} is not a valid URL: ${raw}`;
}
return { value: raw };
case "string":
return { value: raw };
}
}
+6 -23
View File
@@ -2,7 +2,6 @@ import { createApp } from "./http/app.ts";
import { loadConfig } from "./config.ts"; import { loadConfig } from "./config.ts";
import { createDenylist } from "./auth/denylist.ts"; import { createDenylist } from "./auth/denylist.ts";
import { discoverPlugins } from "./plugin-host/discovery.ts"; import { discoverPlugins } from "./plugin-host/discovery.ts";
import { HOST_API_VERSION } from "./plugin-host/plugin.ts";
import { withTimeout } from "./auth/fetch-timeout.ts"; import { withTimeout } from "./auth/fetch-timeout.ts";
import { runBootHooks } from "./plugin-host/hooks.ts"; import { runBootHooks } from "./plugin-host/hooks.ts";
import { createHydraAdmin } from "./auth/hydra-admin.ts"; import { createHydraAdmin } from "./auth/hydra-admin.ts";
@@ -14,7 +13,6 @@ import { createKratosAdmin } from "./auth/kratos-admin.ts";
import { createKratosPublic } from "./auth/kratos-public.ts"; import { createKratosPublic } from "./auth/kratos-public.ts";
import { createLogger, tracedFetch } from "./logger.ts"; import { createLogger, tracedFetch } from "./logger.ts";
import { loadMenuConfig } from "./ui/menu-config.ts"; import { loadMenuConfig } from "./ui/menu-config.ts";
import { resolveSettings, settingsEnvNames, strayNames } from "./plugin-host/settings.ts";
import { buildCredentials, storagePluginIds, type StorageCredentials } from "./plugin-host/storage.ts"; import { buildCredentials, storagePluginIds, type StorageCredentials } from "./plugin-host/storage.ts";
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
@@ -63,29 +61,15 @@ const storageCredentials = new Map<string, StorageCredentials>();
if (pluginDbUrl !== undefined) { if (pluginDbUrl !== undefined) {
for (const id of declaresStorage) storageCredentials.set(id, buildCredentials(pluginDbUrl, id, config.pluginDbSecret)); for (const id of declaresStorage) storageCredentials.set(id, buildCredentials(pluginDbUrl, id, config.pluginDbSecret));
} }
// Operator-supplied plugin settings, resolved against the environment the manifests declared. A bad // onBoot is the only way credentials are handed over, so without one the database is provisioned
// or missing value is refused here rather than at that plugin's first use, hours later. // and unreachable. A warning, not a refusal — the plugin still works, it just cannot store anything.
const settings = resolveSettings(plugins, process.env, { requireSecureSecrets: config.requireSecureSecrets }); const unreachable = plugins.filter((plugin) => plugin.storage && !plugin.hooks?.onBoot).map((plugin) => plugin.id);
if (settings.errors.length > 0) throw new Error(`Plugin settings:\n${settings.errors.map((e) => ` - ${e}`).join("\n")}`); if (unreachable.length > 0) log.warn("plugins declare storage but have no onBoot to receive it", { plugins: unreachable.join(", ") });
// A stray is usually a typo in the very variable the operator meant to set — naming it turns two
// unrelated-looking errors into one. Reported, never acted on.
const strays = strayNames(process.env, settingsEnvNames(plugins));
if (strays.length > 0) log.warn("settings variables no installed plugin declares", { variables: strays.join(", ") });
// onBoot is the only way storage credentials and settings are handed over, so without one they are
// resolved and undeliverable. A warning, not a refusal — the plugin still works, it just gets neither.
for (const [what, ids] of [
["settings", plugins.filter((plugin) => plugin.settings?.length && !plugin.hooks?.onBoot)],
["storage", plugins.filter((plugin) => plugin.storage && !plugin.hooks?.onBoot)],
] as const) {
if (ids.length > 0) log.warn(`plugins declare ${what} but have no onBoot to receive it`, { plugins: ids.map((plugin) => plugin.id).join(", ") });
}
// plugin onBoot — after discovery, before listen; a throw aborts boot. // plugin onBoot — after discovery, before listen; a throw aborts boot.
await runBootHooks(plugins, (plugin) => { await runBootHooks(plugins, (plugin) => {
const storage = storageCredentials.get(plugin.id); const storage = storageCredentials.get(plugin.id);
const values = settings.values.get(plugin.id); return storage ? { storage } : {};
return { ...(values ? { settings: values } : {}), ...(storage ? { storage } : {}) };
}); });
const server = createApp({ const server = createApp({
@@ -106,9 +90,8 @@ const server = createApp({
menu, menu,
plugins, plugins,
secureCookies: config.secureCookies, secureCookies: config.secureCookies,
settingsCatalog: settings.catalog,
}).listen(config.port, () => { }).listen(config.port, () => {
log.info("listening", { apiVersion: HOST_API_VERSION, port: config.port, url: config.appUrl ?? `http://localhost:${config.port}` }); log.info("listening", { port: config.port, url: config.appUrl ?? `http://localhost:${config.port}` });
}); });
// Drain in-flight requests on container stop instead of cutting them mid-response, then flush any // Drain in-flight requests on container stop instead of cutting them mid-response, then flush any
+5 -3
View File
@@ -10,7 +10,7 @@ import { composeNav, type NavNode } from "./nav.ts";
import type { Plugin } from "../plugin-host/plugin.ts"; import type { Plugin } from "../plugin-host/plugin.ts";
import { branding, shellUser, type ShellUser } from "./shell-context.ts"; import { branding, shellUser, type ShellUser } from "./shell-context.ts";
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard", session: true }; const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard" };
export interface PageChrome { export interface PageChrome {
brand: { logo?: string; name: string; sub?: string }; brand: { logo?: string; name: string; sub?: string };
@@ -35,7 +35,8 @@ export interface ChromeOptions {
export function buildPluginChrome(opts: ChromeOptions): PageChrome { export function buildPluginChrome(opts: ChromeOptions): PageChrome {
const t = opts.t ?? ENGLISH; const t = opts.t ?? ENGLISH;
const carryLocale = opts.localeHref ?? ((href: string) => href); const carryLocale = opts.localeHref ?? ((href: string) => href);
const fragments: NavNode[][] = [[DASHBOARD_NAV]]; // Dashboard is gated, so an anonymous click would only dead-end at /login.
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's // A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
// translator before merging. composeNav then runs the core one over the result; already-translated // translator before merging. composeNav then runs the core one over the result; already-translated
// text passes through it. // text passes through it.
@@ -43,7 +44,8 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t)); if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
} }
const nav = composeNav(fragments, opts.menu.override, opts.user ?? null, t); const permissions = opts.user?.permissions ?? [];
const nav = composeNav(fragments, opts.menu.override, permissions, t);
if (opts.currentPath) { if (opts.currentPath) {
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like // Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard. // /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
-17
View File
@@ -37,15 +37,6 @@ const config = {
options: [{ value: "engineering", label: "Engineering" }, { value: "design", label: "Design" }, { value: "oncall", label: "On-call" }], options: [{ value: "engineering", label: "Engineering" }, { value: "design", label: "Design" }, { value: "oncall", label: "On-call" }],
}, },
{ type: "daterange", legend: "Joined", from: { name: "joined_from", value: "2026-01-01", label: "Joined from" }, to: { name: "joined_to", value: "2026-06-14", label: "Joined to" } }, { type: "daterange", legend: "Joined", from: { name: "joined_from", value: "2026-01-01", label: "Joined from" }, to: { name: "joined_to", value: "2026-06-14", label: "Joined to" } },
{
type: "multiselect",
name: "owner",
legend: "Owner",
note: "30 of 45",
value: ["ann"],
options: [{ value: "ann", label: "Ann Berg" }, { value: "bo", label: "Bo Falk" }],
},
{ type: "multiselect", name: "room", legend: "Room", options: [{ value: "lab", label: "Lab" }] },
], ],
], ],
pills: [{ label: "Team", value: "Engineering", remove: "?tag=oncall" }], pills: [{ label: "Team", value: "Engineering", remove: "?tag=oncall" }],
@@ -73,14 +64,6 @@ test("filter-bar renders a GET form with every control type, reflecting current
assert.match(html, /<input type="checkbox" name="tag" value="oncall" checked>On-call/); assert.match(html, /<input type="checkbox" name="tag" value="oncall" checked>On-call/);
assert.match(html, /<input type="checkbox" name="tag" value="design">Design/); assert.match(html, /<input type="checkbox" name="tag" value="design">Design/);
// multiselect — a disclosure: the trigger names the filter and counts what is chosen, the options
// are checkboxes in a fieldset the popover holds, so a long list costs one line of the bar.
assert.match(html, /<div class="menu"><button class="btn btn-menu" type="button" popovertarget="f-owner-menu" aria-label="Owner, 1 selected">Owner<span class="badge">1<\/span><\/button><div id="f-owner-menu" class="menu-pop left" popover>/);
assert.match(html, /popover><div class="menu-head">30 of 45<\/div><fieldset class="menu-field"><legend class="menu-head">Owner<\/legend><label class="menu-check"><input type="checkbox" name="owner" value="ann" checked>Ann Berg<\/label><label class="menu-check"><input type="checkbox" name="owner" value="bo">Bo Falk<\/label><\/fieldset>/);
// Nothing chosen — no badge and no count in the accessible name.
assert.match(html, /<button class="btn btn-menu" type="button" popovertarget="f-room-menu">Room<\/button>/);
// daterange — calendar icon + two date inputs with values. // daterange — calendar icon + two date inputs with values.
assert.match(html, /<div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"\s*\/?><\/svg>.*?<input type="date" id="f-joined_from" name="joined_from" value="2026-01-01">.*?<span class="to" aria-hidden="true">to<\/span>.*?<input type="date" id="f-joined_to" name="joined_to" value="2026-06-14">/); assert.match(html, /<div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"\s*\/?><\/svg>.*?<input type="date" id="f-joined_from" name="joined_from" value="2026-01-01">.*?<span class="to" aria-hidden="true">to<\/span>.*?<input type="date" id="f-joined_to" name="joined_to" value="2026-06-14">/);
+2 -2
View File
@@ -12,7 +12,7 @@ const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, "
test("menu renders trigger, positioning, the item matrix and check groups", async () => { test("menu renders trigger, positioning, the item matrix and check groups", async () => {
const html = flat(await render({ const html = flat(await render({
id: "cols-menu", id: "cols-menu",
trigger: { count: 3, icon: "i-cols", text: "Columns", label: "Column settings" }, trigger: { icon: "i-cols", text: "Columns", label: "Column settings" },
align: "left", up: true, width: 240, align: "left", up: true, width: 240,
items: [ items: [
{ head: "Actions" }, { head: "Actions" },
@@ -30,7 +30,7 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
// Trigger: icon + text + aria-label, wired to the panel by id; popover carries align/up + width. // Trigger: icon + text + aria-label, wired to the panel by id; popover carries align/up + width.
// The panel is the trigger's next sibling inside the wrapper — the CSS open state reads that. // The panel is the trigger's next sibling inside the wrapper — the CSS open state reads that.
assert.match(html, /<div class="menu"><button class="btn" type="button" popovertarget="cols-menu" aria-label="Column settings"><svg class="ico ico-sm"><use href="#i-cols"\s*\/?><\/svg>Columns<span class="badge">3<\/span><\/button><div id="cols-menu" class="menu-pop left up" popover style="min-width:240px">/); assert.match(html, /<div class="menu"><button class="btn" type="button" popovertarget="cols-menu" aria-label="Column settings"><svg class="ico ico-sm"><use href="#i-cols"\s*\/?><\/svg>Columns<\/button><div id="cols-menu" class="menu-pop left up" popover style="min-width:240px">/);
// Item matrix: head, button-with-icon, link, separator, danger button. // Item matrix: head, button-with-icon, link, separator, danger button.
assert.match(html, /<div class="menu-head">Actions<\/div>/); assert.match(html, /<div class="menu-head">Actions<\/div>/);
+9 -19
View File
@@ -1,12 +1,7 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import type { User } from "../http/context.ts";
import { composeNav, type NavNode } from "./nav.ts"; import { composeNav, type NavNode } from "./nav.ts";
function viewer(...permissions: string[]): User {
return { email: "viewer@example.test", id: "01a06091-ba9f-765f-abf4-b5144c314bc7", permissions };
}
// Two plugin fragments; ids let the override target nodes, `permission` gates per permission. // Two plugin fragments; ids let the override target nodes, `permission` gates per permission.
const fragments: NavNode[][] = [ const fragments: NavNode[][] = [
[{ [{
@@ -20,7 +15,7 @@ const fragments: NavNode[][] = [
]; ];
test("composeNav merges fragments, filters by permission, and emits clean render nodes", () => { test("composeNav merges fragments, filters by permission, and emits clean render nodes", () => {
const tree = composeNav(fragments, {}, viewer("scheduling:read")); const tree = composeNav(fragments, {}, ["scheduling:read"]);
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts. // Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs. // Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
@@ -35,7 +30,7 @@ test("composeNav drops gated subtrees, empty headers, and (with no permissions)
{ id: "admin", label: "Admin", permission: "users:read", children: [{ href: "/u", id: "u", label: "Users" }] }, { id: "admin", label: "Admin", permission: "users:read", children: [{ href: "/u", id: "u", label: "Users" }] },
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] }, { id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
]]; ]];
assert.deepEqual(composeNav(gatedHeader, {}, viewer()), [ assert.deepEqual(composeNav(gatedHeader, {}, []), [
{ label: "Free", children: [{ href: "/d", label: "Docs" }] }, { label: "Free", children: [{ href: "/d", label: "Docs" }] },
]); ]);
@@ -44,31 +39,26 @@ test("composeNav drops gated subtrees, empty headers, and (with no permissions)
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x:read" }] }, { id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x:read" }] },
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y:read" }] }, { href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y:read" }] },
]]; ]];
assert.deepEqual(composeNav(emptyHeader, {}, viewer()), [{ href: "/hub", label: "Hub" }]); assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
// No fragments / no permissions → empty tree, never throws. // No fragments / no permissions → empty tree, never throws.
assert.deepEqual(composeNav(), []); assert.deepEqual(composeNav(), []);
}); });
test("composeNav shows a public node to everyone and a session node to any signed-in user", () => { test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
// A header with a public child, a session child and a gated child: the public child keeps the // A header with one public child + one gated child: with no permissions, the public child keeps the
// header alive for an anonymous visitor — so a plugin can show a menu option to all. // header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
const frag: NavNode[][] = [[{ const frag: NavNode[][] = [[{
icon: "i-cal", id: "sched", label: "Scheduling", icon: "i-cal", id: "sched", label: "Scheduling",
children: [ children: [
{ href: "/scheduling", id: "overview", label: "Overview", public: true }, { href: "/scheduling", id: "overview", label: "Overview", public: true },
{ href: "/scheduling/mine", id: "mine", label: "Mine", session: true },
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" }, { href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
], ],
}]]; }]];
// `public`/`session` are filter-only (like id/permission) — never rendered into the output node. // `public` is filter-only (like id/permission) — never rendered into the output node.
assert.deepEqual(composeNav(frag, {}, null), [ assert.deepEqual(composeNav(frag, {}, []), [
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] }, { icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
]); ]);
// Signed in with no permission at all: the session node appears, the permission-gated one does not.
assert.deepEqual(composeNav(frag, {}, viewer()), [
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }, { href: "/scheduling/mine", label: "Mine" }] },
]);
}); });
test("composeNav applies the override: rename, group, order, hide (then filters)", () => { test("composeNav applies the override: rename, group, order, hide (then filters)", () => {
@@ -84,7 +74,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
groups: [{ icon: "i-box", id: "grp", label: "Group", open: true, children: ["b", "c"] }], // wrap b+c groups: [{ icon: "i-box", id: "grp", label: "Group", open: true, children: ["b", "c"] }], // wrap b+c
order: ["grp", "a"], // grp before the lone a order: ["grp", "a"], // grp before the lone a
hide: ["c"], // remove c from inside the group hide: ["c"], // remove c from inside the group
}, viewer("secrets:read")); }, ["secrets:read"]);
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "secrets:read" is present. // grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "secrets:read" is present.
assert.deepEqual(tree, [ assert.deepEqual(tree, [
+13 -13
View File
@@ -1,14 +1,12 @@
// composeNav: merge each plugin's nav fragment into one tree, apply the central override, then // composeNav: merge each plugin's nav fragment into one tree, apply the central override, then
// filter per user. Pure and I/O-free — menu gating reads the JWT `permissions` claim, never Keto. // permission-filter per user. Pure and I/O-free — menu gating reads the JWT `permissions` claim,
// A node is visible iff `allows` passes its gate; a gated header hides its whole subtree, and a pure // never Keto. A node is visible iff it is `public`, declares no `permission`, or the user holds that
// header left with no children is dropped. // name; a gated header hides its whole subtree, and a pure header left with no children is dropped.
import { allows, type Gate } from "../auth/gate.ts";
import type { User } from "../http/context.ts";
import { ENGLISH } from "../i18n/english.ts"; import { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts"; import type { Translate } from "../i18n/translate.ts";
export interface NavNode extends Gate { export interface NavNode {
id?: string; // stable key for override targeting; stripped from the rendered tree id?: string; // stable key for override targeting; stripped from the rendered tree
children?: NavNode[]; children?: NavNode[];
count?: number; count?: number;
@@ -17,10 +15,12 @@ export interface NavNode extends Gate {
icon?: string; icon?: string;
label: string; label: string;
open?: boolean; open?: boolean;
permission?: string; // required permission token; consumed by the filter, never rendered
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both).
} }
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group → // Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
// order → hide, then the per-user gate filter runs last. // order → hide, then the per-user permission filter runs last.
export interface NavOverride { export interface NavOverride {
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id) hide?: string[]; // remove nodes by id, at any depth (incl. a group's id)
@@ -39,7 +39,7 @@ export interface NavGroupSpec {
export function composeNav( export function composeNav(
fragments: NavNode[][] = [], fragments: NavNode[][] = [],
override: NavOverride = {}, override: NavOverride = {},
user: User | null = null, permissions: string[] = [],
t: Translate = ENGLISH, t: Translate = ENGLISH,
): NavNode[] { ): NavNode[] {
let nodes: NavNode[] = fragments.flat(); let nodes: NavNode[] = fragments.flat();
@@ -47,7 +47,7 @@ export function composeNav(
if (override.groups?.length) nodes = applyGroups(nodes, override.groups); if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
if (override.order?.length) nodes = applyOrder(nodes, override.order); if (override.order?.length) nodes = applyOrder(nodes, override.order);
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide)); if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
return filterByGate(nodes, user).map((node) => toRenderNode(node, t)); return filterByRoles(nodes, new Set(permissions)).map((node) => toRenderNode(node, t));
} }
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] { function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
@@ -104,19 +104,19 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
return out; return out;
} }
function filterByGate(nodes: NavNode[], user: User | null): NavNode[] { function filterByRoles(nodes: NavNode[], permissions: Set<string>): NavNode[] {
const out: NavNode[] = []; const out: NavNode[] = [];
for (const n of nodes) { for (const n of nodes) {
if (!allows(n, user)) continue; // gated → drop node + subtree if (n.public !== true && n.permission != null && !permissions.has(n.permission)) continue; // gated → drop node + subtree (public always shows)
if (!n.children) { out.push(n); continue; } if (!n.children) { out.push(n); continue; }
const children = filterByGate(n.children, user); const children = filterByRoles(n.children, permissions);
if (children.length === 0 && n.href == null) continue; // empty pure header → drop if (children.length === 0 && n.href == null) continue; // empty pure header → drop
out.push({ ...n, children }); out.push({ ...n, children });
} }
return out; return out;
} }
// Strip the helper-only fields (id and the gate) and drop absent ones, so the tree is exactly // Strip the helper-only fields (id/permission) and drop absent ones, so the tree is exactly
// what nav-tree.ejs reads. Labels (a manifest's, or the central override's rename) pass through // what nav-tree.ejs reads. Labels (a manifest's, or the central override's rename) pass through
// `t` on the way out: a label that names a catalog key is translated, any other renders as written. // `t` on the way out: a label that names a catalog key is translated, any other renders as written.
function toRenderNode(n: NavNode, t: Translate): NavNode { function toRenderNode(n: NavNode, t: Translate): NavNode {
+1 -2
View File
@@ -2,6 +2,7 @@
## Unfinnished work ## Unfinnished work
- [ ] Add a way to configure plugins directly when installing. **Decided: the manifest declares it, not an `.env`** — a declared schema is validatable at boot, so a missing or mistyped setting fails loud and named the way a stray `package.json` now does, and the picker/docs can be generated from the declaration. Open: where the operator *supplies* the values (env var per key, a `config/` file, or both), and whether a secret may be declared at all.
- [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin". - [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin".
- [ ] Guard the group paths against self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query. - [ ] Guard the group paths against self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query.
- [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change. Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying. - [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change. Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying.
@@ -20,7 +21,6 @@
- [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression. - [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression.
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md and README → Security model; not accepted ⇒ bind the nonce to `sub`. - [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md and README → Security model; not accepted ⇒ bind the nonce to `sub`.
- [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host**, but nothing here has been run on one. Two suspects, both from the `--user "$(id -u):$(id -g)"` idiom: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer. The same question covers rootless Docker, where README already says to *drop* the flag. - [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host**, but nothing here has been run on one. Two suspects, both from the `--user "$(id -u):$(id -g)"` idiom: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer. The same question covers rootless Docker, where README already says to *drop* the flag.
- [ ] Map Kratos' 401 on a self-service flow init, so an anonymous `GET /settings` with no `?flow` renders instead of 500ing. `flowPage` (`src/auth/routes.ts`) maps 403/404/410 → restart the flow, 400 `session_already_available` → `/auth/complete`, and ≥500 → the themed 503, then rethrows everything else — and Kratos answers the settings-flow init with 401 when there is no session. `/settings` is correctly `public` (the recovery flow lands there with a live Kratos session but no app JWT), so the gate is not the fix: a 401 should redirect to `/login` with the page as `return_to`. No E2E covers an anonymous hit on a flow page that needs a session.
### Architectural review findings (2026-07-02) ### Architectural review findings (2026-07-02)
@@ -36,7 +36,6 @@ Prioritized. Overall verdict: architecture is sound; these are refinements.
## Finnished work ## Finnished work
- [x] Configure a plugin at install time: the manifest declares `settings`, the operator sets one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, and the resolved values arrive on `onBoot` typed from the declaration. Read-only admin screen at `/admin/plugin-settings`.
- [x] Give a plugin persistent storage: `storage: true` provisions a Postgres database + login role named `plugin_<id>`, credentials arrive on `onBoot`, passwords are derived from `PLUGIN_DB_SECRET` rather than stored. - [x] Give a plugin persistent storage: `storage: true` provisions a Postgres database + login role named `plugin_<id>`, credentials arrive on `onBoot`, passwords are derived from `PLUGIN_DB_SECRET` rather than stored.
- [x] Refuse a stray `package.json`/`node_modules` in `config/` by name, as plugin folders already are. - [x] Refuse a stray `package.json`/`node_modules` in `config/` by name, as plugin folders already are.
- [x] Let Renovate reach the example plugins' manifests (`ignorePaths` overrides `config:recommended`). - [x] Let Renovate reach the example plugins' manifests (`ignorePaths` overrides `config:recommended`).
+1 -1
View File
@@ -24,5 +24,5 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["config", "examples/config", "examples/plugins", "plugin-api", "plugins", "registry-cleanup", "release-tooling", "src"] "include": ["auto-release", "config", "examples/config", "examples/plugins", "plugin-api", "plugins", "registry-cleanup", "src"]
} }
+1 -1
View File
@@ -14,7 +14,7 @@
<p>${t("dashboard.starter.intro")}</p> <p>${t("dashboard.starter.intro")}</p>
<p>${t("dashboard.starter.replace")}</p> <p>${t("dashboard.starter.replace")}</p>
<pre class="code-block"><code>export default definePlugin({ <pre class="code-block"><code>export default definePlugin({
apiVersion: "0.4.0", apiVersion: "1.0.0",
// view names plugins/&lt;id&gt;/views/&lt;view&gt;.ejs, rendered in this same shell // view names plugins/&lt;id&gt;/views/&lt;view&gt;.ejs, rendered in this same shell
dashboard: (ctx) =&gt; ({ view: "dashboard", data: { /* … */ } }), dashboard: (ctx) =&gt; ({ view: "dashboard", data: { /* … */ } }),
});</code></pre> });</code></pre>
+1 -14
View File
@@ -2,15 +2,12 @@
Filter bar: a real GET form so filtering is server-side and zero-JS. Config: Filter bar: a real GET form so filtering is server-side and zero-JS. Config:
rows: Control[][] rows of controls, laid out left→right rows: Control[][] rows of controls, laid out left→right
pills, clearHref, label, action, applyLabel pills, clearHref, label, action, applyLabel
Control.type ∈ search | segmented | select | chips | multiselect | daterange | spacer. Control.type ∈ search | segmented | select | chips | daterange | spacer.
search { name, placeholder?, value?, label? } search { name, placeholder?, value?, label? }
segmented { name, legend?, value?, options:{value,label,count?}[] } (radios) segmented { name, legend?, value?, options:{value,label,count?}[] } (radios)
select { name, label, value?, options:{value,label}[] } select { name, label, value?, options:{value,label}[] }
chips { name, legend?, value?:string[], options:{value,label}[] } (checkboxes) chips { name, legend?, value?:string[], options:{value,label}[] } (checkboxes)
multiselect { name, legend?, note?, value?:string[], options:{value,label}[] } (checkboxes in a popover)
daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} } daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} }
chips and multiselect are the same checkboxes on the same parameter: on the bar, or behind a
button once the list is too long to lay there.
The form is a GET, which replaces the whole query string — so the visitor's chosen language rides The form is a GET, which replaces the whole query string — so the visitor's chosen language rides
along as a hidden input, and every href here (pills, clear) is run through localeHref. along as a hidden input, and every href here (pills, clear) is run through localeHref.
%><% %><%
@@ -37,16 +34,6 @@
<span class="filter"><label class="sr-only" for="f-<%= c.name %>"><%= c.label %></label><span class="select"><select id="f-<%= c.name %>" name="<%= c.name %>"><% c.options.forEach((o) => { %><option value="<%= o.value %>"<% if (eq(c.value, o.value)) { %> selected<% } %>><%= o.label %></option><% }) %></select></span></span> <span class="filter"><label class="sr-only" for="f-<%= c.name %>"><%= c.label %></label><span class="select"><select id="f-<%= c.name %>" name="<%= c.name %>"><% c.options.forEach((o) => { %><option value="<%= o.value %>"<% if (eq(c.value, o.value)) { %> selected<% } %>><%= o.label %></option><% }) %></select></span></span>
<% } else if (c.type === "chips") { -%> <% } else if (c.type === "chips") { -%>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || c.name %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || c.name %></span><div class="chips"><% (c.options).forEach((o) => { const on = (c.value || []).map(String).includes(String(o.value)); %><label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="<%= c.name %>" value="<%= o.value %>"<% if (on) { %> checked<% } %>><%= o.label %></label><% }) %></div></fieldset> <fieldset class="filter-field"><legend class="sr-only"><%= c.legend || c.name %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || c.name %></span><div class="chips"><% (c.options).forEach((o) => { const on = (c.value || []).map(String).includes(String(o.value)); %><label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="<%= c.name %>" value="<%= o.value %>"<% if (on) { %> checked<% } %>><%= o.label %></label><% }) %></div></fieldset>
<% } else if (c.type === "multiselect") { const legend = c.legend || c.name; const on = (c.value || []).map(String); -%>
<%- include("menu", {
id: "f-" + c.name + "-menu",
align: "left", kebab: false, up: false, width: null, // explicit: EJS would otherwise inherit the page's own
trigger: { class: "btn btn-menu", count: on.length || null, label: on.length ? t("filter.selected", { count: on.length, label: legend }) : null, text: legend },
items: [
...(c.note ? [{ head: c.note }] : []),
{ group: { legend, name: c.name, options: c.options.map((o) => ({ checked: on.includes(String(o.value)), label: o.label, value: o.value })) } },
],
}) %>
<% } else if (c.type === "daterange") { -%> <% } else if (c.type === "daterange") { -%>
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || t("filter.dateRange") %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || t("filter.dateRange") %></span><div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"/></svg><label class="sr-only" for="f-<%= c.from.name %>"><%= c.from.label || t("filter.from") %></label><input type="date" id="f-<%= c.from.name %>" name="<%= c.from.name %>"<% if (c.from.value) { %> value="<%= c.from.value %>"<% } %>><span class="to" aria-hidden="true"><%= t("filter.toSeparator") %></span><label class="sr-only" for="f-<%= c.to.name %>"><%= c.to.label || t("filter.to") %></label><input type="date" id="f-<%= c.to.name %>" name="<%= c.to.name %>"<% if (c.to.value) { %> value="<%= c.to.value %>"<% } %>></div></fieldset> <fieldset class="filter-field"><legend class="sr-only"><%= c.legend || t("filter.dateRange") %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || t("filter.dateRange") %></span><div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"/></svg><label class="sr-only" for="f-<%= c.from.name %>"><%= c.from.label || t("filter.from") %></label><input type="date" id="f-<%= c.from.name %>" name="<%= c.from.name %>"<% if (c.from.value) { %> value="<%= c.from.value %>"<% } %>><span class="to" aria-hidden="true"><%= t("filter.toSeparator") %></span><label class="sr-only" for="f-<%= c.to.name %>"><%= c.to.label || t("filter.to") %></label><input type="date" id="f-<%= c.to.name %>" name="<%= c.to.name %>"<% if (c.to.value) { %> value="<%= c.to.value %>"<% } %>></div></fieldset>
<% } else if (c.type === "spacer") { -%> <% } else if (c.type === "spacer") { -%>
+1 -1
View File
@@ -6,7 +6,7 @@
<symbol id="i-box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></symbol> <symbol id="i-box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></symbol>
<symbol id="i-cal" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /></symbol> <symbol id="i-cal" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /></symbol>
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M5 21v-6" /><path d="M12 21V3" /><path d="M19 21V9" /></symbol> <symbol id="i-chart" viewBox="0 0 24 24"><path d="M5 21v-6" /><path d="M12 21V3" /><path d="M19 21V9" /></symbol>
<symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m16 9-5.5 5.5L8 12" /></symbol> <symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></symbol>
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol> <symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>
<symbol id="i-cols" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="M15 3v18" /></symbol> <symbol id="i-cols" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="M15 3v18" /></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></symbol> <symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></symbol>
+2 -2
View File
@@ -7,7 +7,7 @@
Config: Config:
id string REQUIRED — the panel's id and the trigger's popovertarget. Name it for what id string REQUIRED — the panel's id and the trigger's popovertarget. Name it for what
the menu is (`locale-menu`); it must be unique on the page. the menu is (`locale-menu`); it must be unique on the page.
trigger { class?(="btn", "" ⇒ none) · label?(aria-label) · icon? · text? · count?(badge) · html?(raw inner, wins) } trigger { class?(="btn", "" ⇒ none) · label?(aria-label) · icon? · text? · html?(raw inner, wins) }
align? "left" left-align the popover (default right) align? "left" left-align the popover (default right)
up? boolean open upward (footer menus) up? boolean open upward (footer menus)
kebab? boolean bare kebab trigger (adds .kebab) kebab? boolean bare kebab trigger (adds .kebab)
@@ -26,7 +26,7 @@
const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : ""); const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : "");
const width = locals.width; const width = locals.width;
-%> -%>
<div class="menu"><button<% if (btnCls) { %> class="<%= btnCls %>"<% } %> type="button" popovertarget="<%= locals.id %>"<% if (trigger.label) { %> aria-label="<%= trigger.label %>"<% } %>><% if (trigger.html != null) { %><%- trigger.html %><% } else { if (trigger.icon) { %><svg class="ico ico-sm"><use href="#<%= trigger.icon %>"/></svg><% } if (trigger.text) { %><%= trigger.text %><% } if (trigger.count != null) { %><span class="badge"><%= trigger.count %></span><% } } %></button><div id="<%= locals.id %>" class="<%= popCls %>" popover<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>> <div class="menu"><button<% if (btnCls) { %> class="<%= btnCls %>"<% } %> type="button" popovertarget="<%= locals.id %>"<% if (trigger.label) { %> aria-label="<%= trigger.label %>"<% } %>><% if (trigger.html != null) { %><%- trigger.html %><% } else { if (trigger.icon) { %><svg class="ico ico-sm"><use href="#<%= trigger.icon %>"/></svg><% } if (trigger.text) { %><%= trigger.text %><% } } %></button><div id="<%= locals.id %>" class="<%= popCls %>" popover<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>>
<% items.forEach((it) => { -%> <% items.forEach((it) => { -%>
<% if (it.head != null) { -%> <% if (it.head != null) { -%>
<div class="menu-head"><%= it.head %></div> <div class="menu-head"><%= it.head %></div>