52 Commits

Author SHA1 Message Date
lilleman 549d1313e2 Merge branch 'main' into plugin-settings
CI / full-gate (push) Successful in 2m52s
Mirror / github-mirror (push) Successful in 6s
Release / retag-image (push) Successful in 16s
Release / publish-overview (push) Successful in 6s
2026-08-23 16:23:14 +02:00
lilleman ea4777c099 Declare plugin settings in the manifest, resolve them from the environment
CI / full-gate (push) Successful in 2m52s
2026-08-23 13:17:03 +02:00
renovate-bot 265b0398e4 Update renovate/renovate Docker tag to v44.39.2
CI / full-gate (push) Successful in 2m53s
Mirror / github-mirror (push) Successful in 5s
2026-08-23 04:18:02 +00:00
lilleman ba4503b4d1 Merge branch 'main' into auto-release-only-when-affected
CI / full-gate (push) Successful in 2m52s
Mirror / github-mirror (push) Successful in 4s
Release / retag-image (push) Successful in 17s
Release / publish-overview (push) Successful in 5s
2026-08-22 22:16:22 +02:00
lilleman 4849a7a3c5 Follow mailpit to v1.31.0 in the published quick start
CI / full-gate (push) Successful in 2m51s
2026-08-22 21:58:14 +02:00
lilleman 5b73ec6fb7 Merge branch 'main' into renovate/renovate-renovate-44.x
CI / full-gate (push) Successful in 2m54s
Mirror / github-mirror (push) Successful in 6s
2026-08-22 21:54:19 +02:00
lilleman 8932392ab8 Merge branch 'main' into auto-release-only-when-affected
CI / full-gate (push) Failing after 10s
2026-08-22 21:53:53 +02:00
lilleman 42af07c255 Use one Docker Hub credential for images and the overview
CI / full-gate (push) Successful in 2m50s
2026-08-22 14:27:31 +02:00
lilleman 734cdf6892 Name the scope the overview actually needs
CI / full-gate (push) Successful in 2m51s
2026-08-22 14:21:14 +02:00
lilleman 716e0f50a7 Give each version mismatch its own remedy, and stop publishing a moving bare-major tag
CI / full-gate (push) Successful in 2m51s
2026-08-22 12:27:43 +02:00
lilleman 2d62553a10 Refuse a 0.x minor mismatch, and republish the overview from the named release's tree
CI / full-gate (push) Successful in 2m50s
2026-08-22 12:13:41 +02:00
lilleman d8b7a2d64a Keep the proven always() guard on the overview job
CI / full-gate (push) Successful in 2m51s
2026-08-22 12:03:18 +02:00
lilleman d62c7faabd Scope the sidecar trailer by package so a mixed branch cannot lose it
CI / full-gate (push) Successful in 2m50s
2026-08-22 12:01:35 +02:00
lilleman 6c07856159 Accept the README-only gate skip, and say why it is safe
CI / full-gate (push) Successful in 2m48s
2026-08-22 11:58:28 +02:00
lilleman 41084441ff Manage the published quick start's pins, and fail closed on every release-tooling edge
CI / full-gate (push) Successful in 2m50s
2026-08-22 11:45:46 +02:00
lilleman 08d4f6271d Gate the overview's manual trigger on the same contract check as a tag
CI / full-gate (push) Successful in 2m51s
2026-08-22 11:27:35 +02:00
lilleman 5820264885 Trim the prose to what is true now
CI / full-gate (push) Successful in 2m51s
2026-08-22 11:24:16 +02:00
lilleman b708aab981 Keep the release at v0.1.0 — nothing consumed the old contract
CI / full-gate (push) Successful in 3m2s
2026-08-22 11:21:42 +02:00
renovate-bot 86c9e8e4ca Update renovate/renovate Docker tag to v44.39.1
CI / full-gate (push) Successful in 2m49s
2026-08-22 04:18:35 +00:00
renovate-bot 33c42b2c65 Update axllent/mailpit Docker tag to v1.31.0
CI / full-gate (push) Successful in 3m48s
Mirror / github-mirror (push) Successful in 6s
2026-08-22 04:18:30 +00:00
renovate-bot 452beeb0e1 Update renovate/renovate Docker tag to v44.37.1
CI / full-gate (push) Successful in 2m58s
Mirror / github-mirror (push) Successful in 7s
2026-08-21 04:18:10 +00:00
lilleman 99e77ebabf Release this as v0.2.0, and give the Hub overview its own job, token and template
CI / full-gate (push) Successful in 3m8s
2026-08-20 23:32:10 +02:00
lilleman 2148822dad Make the plugin contract version the release version, and publish the Docker Hub overview from CI
CI / full-gate (push) Successful in 3m8s
2026-08-20 23:16:11 +02:00
lilleman 7f839afb32 Release nothing when no Renovate commit reached the app
CI / full-gate (push) Successful in 2m58s
2026-08-20 23:02:21 +02:00
lilleman 1f61235e88 Scope Release-Bump to the surfaces that ship, and drop the inert package.json versions
CI / full-gate (push) Successful in 3m7s
Mirror / github-mirror (push) Successful in 6s
Release / retag-image (push) Successful in 21s
2026-08-20 22:43:36 +02:00
lilleman 24007b15f3 Rewrap the auto-release paragraph
CI / full-gate (push) Successful in 2m58s
2026-08-20 22:04:23 +02:00
lilleman 3eceee0cf1 Lift the pre-release freeze — releases resume and the plugin contract goes live
CI / full-gate (push) Successful in 2m59s
2026-08-20 22:02:29 +02:00
lilleman 9fa0f60f79 Merge branch 'main' into renovate/renovate-renovate-44.x
CI / full-gate (push) Successful in 2m59s
Mirror / github-mirror (push) Successful in 6s
2026-08-20 21:52:27 +02:00
lilleman 4f791d8f11 Merge remote-tracking branch 'origin/main' into plugin-storage
CI / full-gate (push) Successful in 3m11s
Mirror / github-mirror (push) Successful in 6s
# Conflicts:
#	package-lock.json
#	package.json
2026-08-20 21:47:22 +02:00
renovate-bot 94e9e8bc60 Update dependency lucide-static to v1.33.0
CI / full-gate (push) Successful in 2m59s
Mirror / github-mirror (push) Successful in 6s
Release-Bump: minor
2026-08-20 04:17:56 +00:00
lilleman 03d14b1a20 Merge remote-tracking branch 'origin/main' into plugin-storage
CI / full-gate (push) Successful in 3m10s
# Conflicts:
#	package-lock.json
#	package.json
2026-08-19 20:34:30 +02:00
lilleman 3552b85d63 Merge branch 'main' into renovate/renovate-renovate-44.x
CI / full-gate (push) Successful in 2m50s
2026-08-19 20:29:09 +02:00
renovate-bot dde6fccb35 Update renovate/renovate Docker tag to v44.33.2
CI / full-gate (push) Successful in 2m45s
Release-Bump: minor
2026-08-19 04:18:20 +00:00
renovate-bot 19b3fbc802 Update dependency lucide-static to v1.32.0
CI / full-gate (push) Successful in 3m9s
Mirror / github-mirror (push) Successful in 6s
Release-Bump: minor
2026-08-19 04:18:11 +00:00
lilleman 47541ae97b Warn rather than refuse on a storage URL mismatch, and scrub the provisioning DSN before discovery
CI / full-gate (push) Successful in 2m59s
2026-08-19 01:03:40 +02:00
lilleman c5c9cce2b6 Keep role re-assertion within a non-superuser provisioner's rights, and test the second boot
CI / full-gate (push) Successful in 2m58s
2026-08-19 00:44:49 +02:00
lilleman 6db14a2205 Isolate the storage CI stack, prove least-privilege provisioning, drop the secret before discovery
CI / full-gate (push) Successful in 2m58s
2026-08-19 00:20:17 +02:00
lilleman ae8f105360 Confine the Postgres driver to bootstrap, bound plugin connections, and gate the storage DDL
CI / full-gate (push) Failing after 23s
2026-08-19 00:08:35 +02:00
lilleman bba048e38f Refuse a throwaway plugin storage secret in bootstrap, before any role is created
CI / full-gate (push) Successful in 2m56s
2026-08-18 23:24:34 +02:00
lilleman bf638dfb19 Give a plugin a Postgres database of its own
CI / full-gate (push) Successful in 2m58s
2026-08-18 23:12:13 +02:00
lilleman 5cc6c3d93e Todo: note the code-field hint landed, the paste fix did not
CI / full-gate (push) Successful in 2m49s
Mirror / github-mirror (push) Successful in 6s
2026-08-18 22:07:21 +02:00
lilleman 04af61a5e5 Hint the code field's digits-only rule, so the browser's refusal isn't bare 2026-08-18 22:07:12 +02:00
lilleman a64a60644d Todo: record the flow-POST proxy findings for the verification-code fix
CI / full-gate (push) Successful in 2m44s
2026-08-18 22:00:31 +02:00
lilleman 950eb5a911 Todo: record the manifest-over-.env decision for plugin config
CI / full-gate (push) Successful in 2m47s
2026-08-18 21:55:35 +02:00
lilleman 091011cfe5 Let Renovate reach the example plugins' manifests 2026-08-18 21:55:35 +02:00
lilleman d55898eb8c Refuse a stray package.json or node_modules in config/ by name 2026-08-18 21:55:32 +02:00
lilleman f992cb6b2c Merge branch 'main' into plugin-dependencies
CI / full-gate (push) Successful in 2m45s
Mirror / github-mirror (push) Successful in 7s
2026-08-18 18:33:24 +02:00
lilleman 7d1f7750d3 Refuse a node_modules at the plugins/ root, where it outranks the host's
CI / full-gate (push) Successful in 2m43s
2026-08-18 08:14:21 +02:00
lilleman 3f9787df48 Follow symlinked plugin folders, and keep a plugin .npmrc out of the image
CI / full-gate (push) Successful in 2m44s
2026-08-18 07:49:51 +02:00
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
80 changed files with 2579 additions and 342 deletions
+5 -1
View File
@@ -1,10 +1,14 @@
.git .git
# Load-bearing: a stray copy would bake in at /app/node_modules and shadow /node_modules. # Load-bearing both ways: a stray copy would bake in at /app/node_modules and shadow /node_modules,
# and matching only the root one is what lets a baked plugin keep its own deps. Never `**/node_modules`.
node_modules node_modules
npm-debug.log npm-debug.log
*.log *.log
.DS_Store .DS_Store
# A plugin's .npmrc is where a private-registry token would sit — never in a shipped image.
plugins/**/.npmrc
e2e-tests/artifacts e2e-tests/artifacts
# Orchestration, not test code — keep them out of the runner image (COPY e2e-tests/ ./) # Orchestration, not test code — keep them out of the runner image (COPY e2e-tests/ ./)
e2e-tests/Dockerfile e2e-tests/Dockerfile
+65 -7
View File
@@ -2,12 +2,27 @@ name: Release
on: on:
push: push:
tags: ['v[0-9]+.[0-9]+.[0-9]+'] tags: ['v[0-9]+.[0-9]+.[0-9]+']
workflow_dispatch:
inputs:
overview_version:
description: 'Released version to republish the overview for, without the leading v (e.g. 0.1.0)'
required: true
jobs: jobs:
retag-image: retag-image:
if: github.event_name == 'push'
runs-on: docker-host runs-on: docker-host
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
# Before anything is published: the contract version IS the release version, so a tag that
# disagrees would ship a host misreporting itself to every plugin's compatibility check.
- name: Refuse a tag that disagrees with HOST_API_VERSION
env:
GIT_TAG: ${{ github.ref_name }}
run: |
set -euo pipefail
docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
- name: Promote the commit-hash image to semver + latest - name: Promote the commit-hash image to semver + latest
env: env:
GIT_TAG: ${{ github.ref_name }} GIT_TAG: ${{ github.ref_name }}
@@ -15,34 +30,77 @@ jobs:
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }} REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
REPO: gitea.larvit.se/${{ github.repository }} REPO: gitea.larvit.se/${{ github.repository }}
run: | run: |
set -euo pipefail
COMMIT=$(git rev-parse 'HEAD^{commit}') COMMIT=$(git rev-parse 'HEAD^{commit}')
VERSION=${GIT_TAG#v} VERSION=${GIT_TAG#v}
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
docker pull "$REPO:$COMMIT" \ docker pull "$REPO:$COMMIT" \
|| { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; } || { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; }
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do # No bare-major tag while major is 0: a 0.x minor is a contract break, so `:0` would move
# across one and abort boot for everything tracking it. `:0.1` only moves across patches.
TAGS="$VERSION ${VERSION%.*} latest"
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
for TAG in $TAGS; do
docker tag "$REPO:$COMMIT" "$REPO:$TAG" docker tag "$REPO:$COMMIT" "$REPO:$TAG"
docker push "$REPO:$TAG" docker push "$REPO:$TAG"
done done
- name: Sync the release tags to Docker Hub - name: Sync the release tags to Docker Hub
env: env:
DOCKERHUB_REPO: docker.io/${{ github.repository }} DOCKERHUB_IMAGE: docker.io/${{ github.repository }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }} DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
GIT_TAG: ${{ github.ref_name }} GIT_TAG: ${{ github.ref_name }}
REPO: gitea.larvit.se/${{ github.repository }} REPO: gitea.larvit.se/${{ github.repository }}
run: | run: |
set -euo pipefail
COMMIT=$(git rev-parse 'HEAD^{commit}') COMMIT=$(git rev-parse 'HEAD^{commit}')
VERSION=${GIT_TAG#v} VERSION=${GIT_TAG#v}
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \ [ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; } || { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; }
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do TAGS="$VERSION ${VERSION%.*} latest"
docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG" if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
docker push "$DOCKERHUB_REPO:$TAG" for TAG in $TAGS; do
docker tag "$REPO:$COMMIT" "$DOCKERHUB_IMAGE:$TAG"
docker push "$DOCKERHUB_IMAGE:$TAG"
done done
- name: Log out of the registries - name: Log out of the registries
if: always() if: always()
run: | run: |
docker logout gitea.larvit.se set -uo pipefail
docker logout docker.io # Cleanup, and the runner's Docker config is shared (AGENTS.md) — a lost race here must not
# fail a release that published, nor skip the overview job that follows.
docker logout gitea.larvit.se || true
docker logout docker.io || true
publish-overview:
if: always() && (github.event_name == 'workflow_dispatch' || needs.retag-image.result == 'success')
needs: [retag-image]
runs-on: docker-host
steps:
- uses: actions/checkout@v7.0.1
if: github.event_name == 'push'
# Publish the named release's own tree, so the page never pairs one Plainpages tag with another
# release's sidecar pins. A version that was never released fails here.
- uses: actions/checkout@v7.0.1
if: github.event_name == 'workflow_dispatch'
with:
ref: refs/tags/v${{ inputs.overview_version }}
- name: Publish the Docker Hub overview
env:
DOCKERHUB_REPO: ${{ github.repository }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
GIT_TAG: ${{ github.ref_name }}
INPUT_VERSION: ${{ inputs.overview_version }}
run: |
set -euo pipefail
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
VERSION=${VERSION#v}
# An empty dispatch input falls back to the branch name, so gate this like a tag.
docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
docker run --rm -v "$PWD:/repo" -w /repo \
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
node:24.19.0-alpine3.24 \
node release-tooling/dockerhub-overview.ts "$VERSION"
+12 -7
View File
@@ -21,21 +21,19 @@ jobs:
-e RENOVATE_PLATFORM=gitea \ -e RENOVATE_PLATFORM=gitea \
-e RENOVATE_REPOSITORIES=${{ github.repository }} \ -e RENOVATE_REPOSITORIES=${{ github.repository }} \
-e RENOVATE_TOKEN \ -e RENOVATE_TOKEN \
renovate/renovate:44.32.6 renovate/renovate:44.39.2
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the # After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the # last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
# trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit # trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit
# (a human owns that release) or nothing new merged. ff-only merges keep the renovate commit's # (a human owns that release), nothing new merged, or nothing that merged carried a `Release-Bump:`
# trailer — a release nobody can observe is noise. ff-only merges keep the renovate commit's
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer; # authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
# pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the # pre-1.0 shifts down (release-tooling/next-version.ts). Tag-only — release.yml promotes the
# already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't). # already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't).
# Off until the Actions variable AUTO_RELEASE is set to 'true': Plainpages is pre-announcement and
# deliberately carries no tags, so an automated bump would only invent a version nobody consumes.
auto-release: auto-release:
runs-on: docker-host runs-on: docker-host
needs: renovate needs: renovate
if: vars.AUTO_RELEASE == 'true'
steps: steps:
- uses: actions/checkout@v7.0.1 - uses: actions/checkout@v7.0.1
with: with:
@@ -57,8 +55,15 @@ jobs:
fi fi
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \ BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; }) --format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
if [ -z "$BUMPS" ]; then
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
fi
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \ NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
node auto-release/next-version.ts "$LATEST" $BUMPS) node release-tooling/next-version.ts "$LATEST" $BUMPS)
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
git show origin/main:src/plugin-host/plugin.ts \
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
node release-tooling/contract-version.ts "$NEXT" -
echo "Releasing $LATEST -> $NEXT" echo "Releasing $LATEST -> $NEXT"
git tag "$NEXT" origin/main git tag "$NEXT" origin/main
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT" git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
+111 -44
View File
@@ -36,11 +36,13 @@ branch, create a PR and merge it when the CI/CD turns green.
## Project priorities (do not erode) ## Project priorities (do not erode)
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable. 1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`). 2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
Prefer the Node standard library; justify any new dependency; do not add frameworks. The app is `postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
**stateless — no database**. Auth/identity/OAuth are **Ory sidecar services** reached over their The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
REST APIs with built-in `fetch` — no SDK. New capabilities ship as **plugin folders** under Postgres database, which the host provisions but never reads or writes inside. Auth/identity/OAuth are
`plugins/` that fetch their data from upstream services, not as core code. **Ory sidecar services** reached over their REST APIs with built-in `fetch` — no SDK. New
capabilities ship as **plugin folders** under `plugins/` that get their data from an upstream
service or their own database, not as core code.
3. **Strict TypeScript**`tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`, 3. **Strict TypeScript**`tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types; `exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types;
limit nullable and multi-option types. limit nullable and multi-option types.
@@ -67,20 +69,73 @@ Revisit only if the stated reason stops holding.
with `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` at the root; tests are with `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` at the root; tests are
co-located. Add a new module to the folder owning its concern. The core ships **no domain co-located. Add a new module to the folder owning its concern. The core ships **no domain
screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`). screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
- **Plugins and config import the host only via package.json `imports`** — `#plugin-api` - **Plugins and config import the host only through a barrel** — `@plainpages/plugin-api`
`src/plugin-host/plugin-api.ts`, `#menu-config``src/ui/menu-config.ts`, never a relative `plugin-api/index.ts``src/plugin-host/plugin-api.ts`, `#menu-config``src/ui/menu-config.ts`,
`../../src/*` path. These two barrels are the whole contract surface; don't "fix" a `#`-import never a relative `../../src/*` path. These two barrels are the whole contract surface; don't "fix"
back to a relative path. Two consequences: either back to a relative path. Three consequences:
- `#plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their - `@plainpages/plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their
DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major
`apiVersion` bump, not a free refactor. `apiVersion` bump, not a free refactor.
- **A plugin/config folder must stay a plain folder — no `package.json` of its own**, which would - **The barrel is a package, not a `#`-import, so a plugin folder may carry its own
become its own scope and stop `#`-specifiers resolving. A plugin kept in its own repo therefore `package.json`** and depend on npm packages (README → Plugin dependencies). The Dockerfile links
typechecks against the barrel only when mounted under the host tree (or with a vendored stub). it into `/node_modules`, above every plugin scope. Never let a copy reach a plugin's own
`node_modules`: two instances of the barrel break `instanceof` across the boundary, which
`plugin-api.test.ts` guards by asserting both paths reach one module.
- **Plugin storage hands over credentials, not a client** (README → Plugin storage). The host takes
`postgres` to run the provisioning DDL, and `storage-provisioning.ts` is the only module importing
it — `storage.ts` beside it stays pure so `web` never loads a driver (`src/postgres.test.ts` guards
both halves, because one value imported from the wrong module breaks it invisibly). It is never
re-exported through the barrel, so no driver shape enters the contract. Three properties hold the design together, so
don't trade one away in isolation: passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than
stored, which is what keeps the host stateless — whoever holds that secret holds every plugin
database, so it ranks with the DB password itself; the provisioning DSN reaches `bootstrap` only
(`src/compose.test.ts` guards the split); and provisioning never drops anything, so uninstalling a
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
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
*accidents*, not hostile plugins: `PLUGIN_DB_SECRET` is in `web`'s environment during `onBoot`, and
a plugin already holds `ctx.system`'s Ory admin clients — so cross-plugin DB isolation is
containment, and README says so rather than implying a sandbox. Consistent with priority #7
(crash-isolation is a non-goal). `server.ts` still deletes the secret from `process.env` right
after `loadConfig`, which is before discovery imports any plugin module — the ordering is the whole
point, so move it earlier if anything, **never later**. **Valid while plugins are
operator-installed code, not third-party uploads.**
- **`ory/postgres/init/init.sql` is the only home for the Ory databases' ACL** — don't re-assert the
`REVOKE CONNECT` from `bootstrap`. `REVOKE` only *warns* when the caller doesn't own the database,
so under the least-privilege provisioning account the README recommends it would report success
while changing nothing, and it hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a server with no
`kratos`. It runs only on **first init**, so a revoke added to it later never reaches a volume that
already exists — `docker compose down -v` is the dev remedy, a deployed install needs a migration.
- **`bootstrap.ts` stays under `src/auth/`** even though it now provisions plugin databases as well
as seeding Ory. It is the one-shot service's entrypoint, not an auth module; moving it to
`src/bootstrap.ts` would edit `compose.yml`, five e2e compose files and `src/compose.test.ts` for a
rename. Reconsider when a third seeding concern lands.
- **`BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook.** Adding
to the context costs a minor bump and removing one a major, so the shape errs small elsewhere. Pools
handed to a plugin are reaped on process exit — revisit if a plugin ever needs an orderly drain.
- **`config/` is still a plain dir — no `package.json` of its own**, or `#menu-config` resolves
against that instead and boot fails loud. An operator's menu override has no use for
dependencies; if that changes, it needs the same package treatment.
- **A plugin `package.json` without `"type": "module"` is refused, not warned.** Allowing it costs a
warning and a re-parse per file, not a break — Node detects module syntax, so even a `.js` helper
loads — and an operator on a read-only third-party mount cannot apply the remedy. Refused anyway
because the direction is safe: refuse→warn relaxes freely, warn→refuse breaks installed plugins.
**Valid while nothing is installed in the wild.**
- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to - **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to
`plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in `plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in
`tsconfig.include` and resolve the host via `#`-imports, so each typechecks in place *and* copies `tsconfig.include` and resolve the host through the barrels, so each typechecks in place *and*
across unchanged. Never commit real plugins/config into the root mount dirs — they ship empty. copies across unchanged. Never commit real plugins/config into the root mount dirs — they ship empty.
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the base request - **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the base request
context. It protects the I/O-free hot path on the public, bot-hit landing (`/`). context. It protects the I/O-free hot path on the public, bot-hit landing (`/`).
- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`, - **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
@@ -123,7 +178,7 @@ Revisit only if the stated reason stops holding.
with the e2e stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a with the e2e stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a
read-only parent is EROFS and the container never starts). Valid while bootstrap is the only read-only parent is EROFS and the container never starts). Valid while bootstrap is the only
writer of grants. writer of grants.
- **`actionForMethod` is plugin-local and must not migrate into `#plugin-api`.** Inside the admin - **`actionForMethod` is plugin-local and must not migrate into `@plainpages/plugin-api`.** Inside the admin
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.
@@ -205,7 +260,7 @@ Revisit only if the stated reason stops holding.
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.
- **`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 `#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
unknown sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test unknown sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test
catches anything reaching the nav). catches anything reaching the nav).
@@ -242,31 +297,23 @@ 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** — no test, build step or - **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** Both git channels in
workflow reads a markdown file. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`: `ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the destination, so
rename detection names only the destination, so `git mv src/app.ts notes.md` would otherwise read as `git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a source file that
docs and skip the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a was gone. `src/ci-gate.test.ts` locks the flags as a
*text* guard — the test image ships neither `git` nor `bash`. Revisit if a `.md` ever becomes *text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
load-bearing. `release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard
skipped. `README.md` is the one markdown a test reads — `release-tooling/contract-version.test.ts`
checks its `apiVersion` samples — and a README-only change skips that check; accepted, because
those samples are illustrative and the copies that matter (`examples/`, `views/`, the template) are
gated. **Valid while no markdown file is rendered or executed.**
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so - **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
file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's
web-image build races another run's container creation on the `<project>-web` tag. Accepted for a web-image build races another run's container creation on the `<project>-web` tag. Accepted for a
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites. single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
- **Plainpages is pre-announcement: no tags, no releases.** `auto-release` is gated behind the
`AUTO_RELEASE` Actions variable (unset ⇒ skipped, the fail-safe direction on every unknown-`vars`
path) — a version only communicates to consumers and there are none. Two couplings:
`registry-cleanup` keeps a hash image only while its commit is a branch head *or* release-tagged, so
with zero tags a hand-cut tag must sit on `main`'s tip; and `mirror.yml` pushes tags with `--prune`
(its `fetch-tags: true` is load-bearing), so a tag or Release created on GitHub is swept away and
releases are cut on Gitea only. Valid until the maintainer says Plainpages is ready to show people.
- **A stricter manifest rule breaks already-copied plugins**, and while `HOST_API_VERSION` is frozen
the failure names a symptom rather than the cause — `checkApiVersion` would refuse a stale plugin by
*version*, but only once the freeze lifts. Until then a stricter rule ships with a README →
Upgrading entry and a re-copy hint in the discovery error. Fail-loud stays right either way: the
alternative is a route gating on a name nobody can be granted, i.e. a permanent silent 403.
**Valid while `HOST_API_VERSION` stays frozen.**
## Docker only — no host tooling ## Docker only — no host tooling
@@ -315,14 +362,34 @@ 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.
- **`HOST_API_VERSION` is frozen at 1.0.0 until the first external install**, even for additive - **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist*: its
contract changes. With no third-party plugin in the wild a bump can only produce noise. The rules name exactly what carries the trailer, so a dependency outside them never escalates the
promotion trigger is the first external plugin — from then on follow the versioning table in release version and nothing fails to say so. A new manifest, compose file, custom manager or dep
README → Contract versioning. **The frozen surface includes `views/partials/*.ejs`**: the view type is a decision: can it reach a running Plainpages? If yes it needs a rule; if no, record nothing
resolver makes every core partial an `include()` root for a plugin's views, so their option names and let it ride the next patch.
and emitted markup are author-visible. Know the hole that leaves — discovery fails loud on a bad - **`HOST_API_VERSION` *is* the release version.** Its `major.minor` must equal the release tag's, and
`apiVersion`, but `include("menu", { open: true })` silently ignores a dropped option. Promotion both release paths refuse a tag that disagrees (`release-tooling/contract-version.ts`). The patch
must cover the partial vocabulary too. digit may lag on purpose: `checkApiVersion`
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
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 })`
silently ignores a dropped option, so the partial vocabulary is a surface the version check cannot
police for you.
- **The contract surface also includes the packaging promises** (README → Plugin dependencies): the
barrel is ambient at `/node_modules` with nothing for a plugin to declare, `"type": "module"` is
mandatory, and the host neither upgrades nor dedupes a plugin's dependencies. Same hole as the
partials — move the publish point, rename the package or start hoisting and every installed plugin
breaks with no version signal. Note the promise is deliberately *not* "your deps are yours alone":
build-time dedupe for baked images stays open, module-instance sharing stays unpromised.
- **Publishing `@plainpages/plugin-api` to a registry is deferred, not rejected.** Today it is
`private` and shaped as a shim — `index.ts` re-exports `../src/…`, so `npm pack` would ship a
broken tree. The trigger is the first plugin author outside this repo — the first who cannot
typecheck against a mounted host tree. Whoever does it must first make the artifact self-contained
(types-only `.d.ts`, or move the barrel into `plugin-api/`).
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version it was built - A plugin's `apiVersion` is a **hand-written literal** semver — the host version it was built
against — bumped by hand on rebuild, **never** the host's `HOST_API_VERSION` constant. Importing against — bumped by hand on rebuild, **never** the host's `HOST_API_VERSION` constant. Importing
the constant makes every plugin always equal the host, so `checkApiVersion` can never fire. the constant makes every plugin always equal the host, so `checkApiVersion` can never fire.
+3
View File
@@ -7,6 +7,9 @@ FROM node:24.19.0-alpine3.24
COPY package.json package-lock.json .npmrc /deps/ COPY package.json package-lock.json .npmrc /deps/
RUN cd /deps && npm ci && mv node_modules /node_modules && rm -rf /deps RUN cd /deps && npm ci && mv node_modules /node_modules && rm -rf /deps
# The barrel as a package, so a plugin folder can own a package.json. Linked because it re-exports /app.
RUN mkdir -p /node_modules/@plainpages && ln -s /app/plugin-api /node_modules/@plainpages/plugin-api
WORKDIR /app WORKDIR /app
COPY . . COPY . .
+309 -77
View File
@@ -44,10 +44,10 @@ one-shot `bootstrap` service, and only `up` re-runs it. See
folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`: folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.ts`:
```ts ```ts
import { definePlugin } from "#plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", apiVersion: "0.2.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>" }) },
@@ -88,6 +88,9 @@ From here, render real pages against the app shell and fetch upstream data — s
- [conflict rules](#conflict-rules) - [conflict rules](#conflict-rules)
- [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)
- [settings](#plugin-settings)
- [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)
- [Building blocks](#building-blocks) - [Building blocks](#building-blocks)
@@ -105,7 +108,7 @@ From here, render real pages against the app shell and fetch upstream data — s
- [security model](#security-model) - [security model](#security-model)
- [Email](#email) - [Email](#email)
- [Architecture](#architecture) - [Architecture](#architecture)
- [Stateless](#stateless) - [Stateless core](#stateless-core)
- [Testing](#testing) - [Testing](#testing)
- [end-to-end](#end-to-end-playwright) - [end-to-end](#end-to-end-playwright)
- [the full gate](#the-full-gate-one-command) - [the full gate](#the-full-gate-one-command)
@@ -312,6 +315,8 @@ plugins/things/ # the plugin folder — its name is the id AND the moun
en-US.ts # the baseline; sv-SE.ts et al are written against its type en-US.ts # the baseline; sv-SE.ts et al are written against its type
handlers.ts # your code, any names/layout — host never looks here; plugin.ts imports it handlers.ts # your code, any names/layout — host never looks here; plugin.ts imports it
service.ts # e.g. route handlers, upstream calls, domain helpers — design as you wish service.ts # e.g. route handlers, upstream calls, domain helpers — design as you wish
package.json # optional — only if you depend on npm packages (see Plugin dependencies)
node_modules/ # yours, installed from your own lockfile
``` ```
**Only `plugin.ts` is required.** `views/`, `public/` and `i18n/` are fixed folder *names* the host **Only `plugin.ts` is required.** `views/`, `public/` and `i18n/` are fixed folder *names* the host
@@ -332,19 +337,19 @@ Installing a plugin is "drop the folder, restart"; removing one is "delete the f
### The manifest ### The manifest
A plugin imports its host surface from one module — **`#plugin-api`**, a Node [subpath A plugin imports its host surface from one module — **`@plainpages/plugin-api`** (`definePlugin`, the
import](https://nodejs.org/api/packages.html#subpath-imports) mapped to `src/plugin-host/plugin-api.ts` manifest/handler types, `RequestContext`, the guards, and the body/CSRF/list-query helpers). The host
in the root `package.json` (`definePlugin`, the manifest/handler types, `RequestContext`, the guards, publishes it as a package, so it resolves from any depth and from a plugin folder that has a
and the body/CSRF/list-query helpers). That barrel **is** the contract boundary — never a relative `package.json` of its own ([Plugin dependencies](#plugin-dependencies)). That barrel **is** the
`../../src/...` path; the host refactors everything behind it freely. Keep your plugin a plain folder contract boundary — never a relative `../../src/...` path; the host refactors everything behind it
with no `package.json` of its own, or `#plugin-api` resolves against that instead. freely.
```ts ```ts
import { definePlugin } from "#plugin-api"; 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: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning) apiVersion: "0.2.0", // semver string of the host contract this plugin was built against (see Versioning)
// Nav fragment, merged into the global menu and permission-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).
@@ -377,6 +382,8 @@ folder-derived `id` to produce the loaded `Plugin`.
| `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). |
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.
@@ -412,7 +419,7 @@ type RouteResult =
```ts ```ts
// handlers.ts // handlers.ts
import { parseListQuery, type RequestContext } from "#plugin-api"; import { parseListQuery, type RequestContext } from "@plainpages/plugin-api";
export async function listThings(ctx: RequestContext) { export async function listThings(ctx: RequestContext) {
const q = parseListQuery(ctx.url); const q = parseListQuery(ctx.url);
@@ -425,12 +432,12 @@ export async function listThings(ctx: RequestContext) {
nested names like `"things/edit"` work, out-of-bounds names are refused. The template may nested names like `"things/edit"` work, out-of-bounds names are refused. The template may
`include()` the core building-block partials and its own. To load the plugin's own CSS, pass its `include()` the core building-block partials and its own. To load the plugin's own CSS, pass its
`/public/<id>/x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`. `/public/<id>/x.css` href in the shell's `styles` slot — see the reference's `views/shifts.ejs`.
- **Finer authorization than the route `permission`** uses the guards from `#plugin-api`: - **Finer authorization than the route `permission`** uses the guards from `@plainpages/plugin-api`:
`requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and `requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and
`check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw `check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw
`new GuardError(403, …)` after a failed `can`/`check` to render the 403 page. `new GuardError(403, …)` after a failed `can`/`check` to render the 403 page.
- The handler **fetches its own data** from upstream; plugins hold no state (see - The handler **fetches its own data** from upstream, or from the plugin's own
[Stateless](#stateless)). [storage](#plugin-storage); the host holds none of it (see [Stateless core](#stateless-core)).
- Default status: `200` for `view`/`html`/`json`, `303` for `redirect`. - Default status: `200` for `view`/`html`/`json`, `303` for `redirect`.
#### Escaping & the trust boundary #### Escaping & the trust boundary
@@ -443,9 +450,9 @@ The host does not sandbox plugin output, so a handler **owns the safety of the d
- **Text is auto-escaped; URLs are not scheme-checked.** A URL field — nav `href`, a table cell - **Text is auto-escaped; URLs are not scheme-checked.** A URL field — nav `href`, a table cell
link, a menu item, a breadcrumb, `brand.logo` — is emitted as-is inside the attribute, so a link, a menu item, a breadcrumb, `brand.logo` — is emitted as-is inside the attribute, so a
`javascript:` or `data:` URL from upstream data becomes live XSS. Pass any URL you don't control `javascript:` or `data:` URL from upstream data becomes live XSS. Pass any URL you don't control
through **`safeUrl()`** from `#plugin-api`; it collapses anything but relative/`http(s):` to `"#"`: through **`safeUrl()`** from `@plainpages/plugin-api`; it collapses anything but relative/`http(s):` to `"#"`:
```ts ```ts
import { safeUrl } from "#plugin-api"; import { safeUrl } from "@plainpages/plugin-api";
return { view: "list", data: { rows: rows.map((r) => ({ ...r, href: safeUrl(r.href) })) } }; return { view: "list", data: { rows: rows.map((r) => ({ ...r, href: safeUrl(r.href) })) } };
``` ```
@@ -459,11 +466,11 @@ The host has two replaceable landing slots, and a plugin may own either or both:
| `dashboard` | `/dashboard` | **signed-in session** (anonymous → `/login`, with `/dashboard` as `return_to`) | The built-in mock-data People list. | | `dashboard` | `/dashboard` | **signed-in session** (anonymous → `/login`, with `/dashboard` as `return_to`) | The built-in mock-data People list. |
```ts ```ts
import { definePlugin } from "#plugin-api"; 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: "1.0.0", apiVersion: "0.2.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
}); });
@@ -537,7 +544,7 @@ ones may be added within it. `req`/`res` are the raw Node escape hatch — prefe
Most plugins fetch their own data from an upstream service they configure. A **system plugin** — one Most plugins fetch their own data from an upstream service they configure. A **system plugin** — one
that administers *Plainpages' own* identity stack — needs the host's Ory admin clients and the that administers *Plainpages' own* identity stack — needs the host's Ory admin clients and the
instant-revoke hook instead. The host exposes those on **`ctx.system`**, and re-exports the client instant-revoke hook instead. The host exposes those on **`ctx.system`**, and re-exports the client
types + their error classes from `#plugin-api`: types + their error classes from `@plainpages/plugin-api`:
```ts ```ts
interface SystemCapabilities { // every field optional — present only when the host wired it interface SystemCapabilities { // every field optional — present only when the host wired it
@@ -588,22 +595,29 @@ works without editing host config.
### Contract versioning ### Contract versioning
Each manifest declares `apiVersion` — a **semver** string naming the host contract it was built Each manifest declares `apiVersion` — a **semver** string naming the **Plainpages release** it was
against — against the host's `HOST_API_VERSION`. The host bumps **major** on a breaking built against. The host's `HOST_API_VERSION` *is* its release version, so a plugin author reads one
manifest/handler change and **minor** on an additive one. At discovery it parses both with version off the image they run and writes it down — there is no separate contract number. Both
`parseSemver` (strict: no ranges, `v` prefixes, or leading zeros) and applies provider/consumer release paths refuse a tag whose `major.minor` disagrees with the constant, so the two cannot drift.
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 |
| same major, plugin minor **<** host minor | `warn` | load, log — additive-compatible, newer features exist | | **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 — 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 caret-style compatibility. the compatibility. One digit carries the whole release, so a **minor** means either the plugin
contract changed or a dependency moved far enough to warrant one.
### Conflict rules ### Conflict rules
@@ -629,10 +643,13 @@ Optional, for reacting to system actions. A plugin's `hooks` may implement:
| Hook | When | May | | Hook | When | May |
| --- | --- | --- | | --- | --- | --- |
| `onBoot()` | after discovery, before the server listens | warm caches, validate upstream config | | `onBoot(host)` | after discovery, before the server listens | warm caches, validate upstream config, open a [storage](#plugin-storage) connection |
| `onRequest(ctx)` | before route matching | inspect, or **short-circuit** by returning a `RouteResult` | | `onRequest(ctx)` | before route matching | inspect, or **short-circuit** by returning a `RouteResult` |
| `onResponse(ctx, result)` | after the handler | observe/log; cannot change the response | | `onResponse(ctx, result)` | after the handler | observe/log; cannot change the response |
`onBoot`'s `host` is a `BootContext`, carrying `storage` for a plugin that declared it. A hook
written without a parameter stays valid.
Hooks run in **discovery order** (plugins sorted by id). `onRequest` fires on every request that Hooks run in **discovery order** (plugins sorted by id). `onRequest` fires on every request that
reaches routing (static assets bypass it); the **first** hook to return a `RouteResult` short-circuits reaches routing (static assets bypass it); the **first** hook to return a `RouteResult` short-circuits
— later hooks and the route handler are skipped, and that result renders against its own plugin's — later hooks and the route handler are skipped, and that result renders against its own plugin's
@@ -650,7 +667,7 @@ getting its folder there.
bind-mounts the whole tree (`compose.override.yml`: `.:/app`), so a restart picks it up. bind-mounts the whole tree (`compose.override.yml`: `.:/app`), so a restart picks it up.
**2. A plugin kept in its own repo, or added to a prebuilt image.** Bind-mount the plugin **2. A plugin kept in its own repo, or added to a prebuilt image.** Bind-mount the plugin
folder onto `/app/plugins/<id>` with a small compose override. Plugins are stateless, so folder onto `/app/plugins/<id>` with a small compose override. A plugin folder is code, not data —
mount it read-only: mount it read-only:
```yaml ```yaml
@@ -671,11 +688,187 @@ docker compose -f compose.yml -f compose.plugins.yml up -d
A named volume works the same way (target `/app/plugins/<id>`). For a **baked** production image, A named volume works the same way (target `/app/plugins/<id>`). For a **baked** production image,
keep the plugin in the build context and it is `COPY`'d in at build time. keep the plugin in the build context and it is `COPY`'d in at build time.
`#plugin-api` resolves against the *nearest* `package.json`, which at runtime must be the host's at A plugin kept in its own repo mounts whole, `package.json` and all — see below.
`/app` — so a mounted `plugins/<id>/` must **not** contain a `package.json` of its own, or boot fails
loud. A plugin kept in its own repo therefore mounts as just its subfolder, its `package.json` left ### Plugin dependencies
outside the mount. To typecheck it there, typecheck it mounted under the host tree, or vendor a type
stub of the barrel and map `#plugin-api` to that. A plugin may depend on npm packages. It owns them completely: its `package.json`, its lockfile and
its `node_modules` live in the plugin folder, and nothing about them reaches the host's — installing
a plugin is still just getting its folder to `/app/plugins/<id>`.
Write the manifest yourself — `"type": "module"` is required, and the host refuses a plugin without
it, because that file (not the host's) is what tells Node how to parse everything beside it:
```json
{ "name": "things", "version": "0.0.0", "type": "module" }
```
Add a `plugins/things/.npmrc` too. The root one does not reach a `--prefix`, so without it npm writes
ranges rather than the exact pins this project keeps everywhere:
```ini
save-exact=true
```
Then install into the folder:
```bash
# The uid keeps the files it writes yours rather than root's.
docker compose run --rm --no-deps --user "$(id -u):$(id -g)" web npm install --prefix plugins/things ms
```
A plugin in its own repo runs its own `npm ci` instead and mounts the result — `node_modules`
included, since the plugin folder *is* the repo. A baked image needs no extra step: the plugin's
`node_modules` is part of the build context and is `COPY`'d in with the rest of the folder.
- **Never ship a copy of `@plainpages/plugin-api`.** The host publishes it into `/node_modules`,
above every plugin, and a plugin resolves it from there — nothing to declare, just import it. A
copy inside your plugin's own `node_modules` would shadow it with a *second* instance of the host's
contract, turning a sign-in redirect into a 500, so discovery refuses one there at boot.
- **The host never upgrades or dedupes your dependencies.** Two plugins depending on the same package
each get their own copy at their own version, so neither can break the other by upgrading — and
keeping yours current, and audited, is yours to own. Renovate here watches every manifest in this
repo, the example plugins included — a plugin in its own repo needs its own.
- **Depend on packages that ship JavaScript.** Node refuses to strip types under `node_modules`, so a
dependency whose entry is `.ts` fails at import with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`.
`npm run typecheck` covers `plugins/`, so a dependency shipping no types of its own needs its
`@types/` in your plugin's `devDependencies`. Typechecking a plugin repo standalone still needs the
barrel's types on disk: typecheck it mounted under the host tree, or vendor a type stub **outside
`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.
### 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.2.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
A plugin that needs to keep data sets `storage: true`. The host then provisions a Postgres
**database and login role of its own** — both named `plugin_<id>` — and hands the credentials to
`onBoot`:
```ts
import postgres from "postgres"; // your dependency, not the host's
import { definePlugin } from "@plainpages/plugin-api";
let sql: ReturnType<typeof postgres>;
export default definePlugin({
apiVersion: "0.2.0",
storage: true,
hooks: {
onBoot: async (boot) => {
if (!boot.storage) throw new Error("things: storage was not provisioned");
sql = postgres(boot.storage.url);
// Every web instance runs onBoot, and concurrent CREATE TABLE IF NOT EXISTS is an error in
// Postgres — the lock is released when the transaction ends.
await sql.begin(async (tx) => {
await tx`SELECT pg_advisory_xact_lock(hashtext('things:schema'))`;
await tx`CREATE TABLE IF NOT EXISTS things (id uuid PRIMARY KEY, name text NOT NULL)`;
});
},
},
});
```
`boot.storage` is a `StorageCredentials` — `database`, `host`, `password`, `port`, `user`, and `url`,
the same values pre-assembled as a DSN, which most clients take directly. It is typed optional, so
the guard above is expected of every storage plugin rather than a sign something is wrong.
**Credentials, not a client.** The host has no opinion on how you reach Postgres: depend on
`postgres`, `pg`, a query builder or an ORM ([Plugin dependencies](#plugin-dependencies)). No driver
is part of the contract, so upgrading yours is yours alone to time. The flip side is that pool sizing
is yours too — keep yours under `PLUGIN_DB_CONNECTION_LIMIT` (default 10), the per-role ceiling the
host sets so one plugin cannot exhaust the Postgres this stack shares with Ory.
**The schema is yours, migrations included.** The host creates the database empty, never reads or
writes inside it, and ships no migration machinery — evolving your tables compatibly (expand, then
contract, so a rolled-back version still runs) is yours to own. Create your tables in `onBoot`: it
runs before the server listens, so a failure aborts boot instead of surfacing later as a broken page.
What the host does guarantee:
- **One database and one role per plugin**, with `CONNECT` revoked from `PUBLIC`. This bounds
*accidents* — a wrong database name, a mistyped DSN, a stray query — and it is not a security
boundary: plugins share the `web` process, so a plugin that goes looking can reach another's
credentials. Install plugins you trust ([Security model](#security-model)).
- **Provisioning is idempotent and runs every boot**, so a plugin dropped in later is picked up by
the next `docker compose up -d` — the same rule as permission seeding. Each boot re-applies the
role's password, connection limit, and `NOCREATEDB`/`NOCREATEROLE`.
- **Your data is never dropped.** Removing a plugin folder leaves its database untouched; deleting it
is a deliberate act by an operator. Each boot logs any `plugin_*` database no installed plugin
claims, so what you left behind stays findable — read that list before dropping anything, since a
second Plainpages stack sharing this server will have its databases named there too.
**Passwords are derived, never stored** — each is `HMAC-SHA256(PLUGIN_DB_SECRET, <plugin id>)`, so
`bootstrap` and `web` compute the same value independently and nothing has to be written down.
Rotate every plugin's password by changing `PLUGIN_DB_SECRET` and running `docker compose up -d`,
which re-applies each role's password and leaves the data alone — restart every `web` instance as
part of it, since one still holding the old secret can open no new connections. Treat the secret as
you would a database password: whoever holds it holds every plugin database. Under
`REQUIRE_SECURE_SECRETS` a missing, empty or throwaway secret is refused — in `bootstrap` before it
creates any role, so no database is ever given a password derivable from a constant in this repo.
**Only `bootstrap` holds provisioning credentials.** It alone gets `PLUGIN_DB_ADMIN_URL`, an account
with `CREATEDB` and `CREATEROLE` (superuser works but is more than it needs; the dev stack simply
reuses Ory's). Keep using the same account: Postgres gives a `CREATEROLE` account admin rights only
over the roles it created itself, so if you swap it for a fresh one, grant that one `ADMIN OPTION` on
the existing `plugin_*` roles first, or the next boot cannot re-apply their passwords. `web` gets `PLUGIN_DB_URL`, which names the server and must carry no credentials —
supply one with a username or password and boot fails, rather than leaving a privileged password in
the process that runs plugin code. Set both, plus `PLUGIN_DB_SECRET`
([Configuration](#configuration)); the dev stack sets them for you.
Storage stays off until `PLUGIN_DB_URL` is set, and a plugin declaring it while that is unset
**aborts boot** naming itself — rather than serving pages without its data. One naming limit: a
storage plugin's folder may be at most **56 characters**, so `plugin_<id>` fits Postgres' 63-byte
identifier.
### Local dev & test story ### Local dev & test story
@@ -712,8 +905,7 @@ The menu is **driven entirely by config** and assembled from two sources:
in or bind-mounting your own dir onto `/app/config` (a commented example sits in in or bind-mounting your own dir onto `/app/config` (a commented example sits in
`compose.override.yml`). The file imports its typed builder from **`#menu-config`** (the `compose.override.yml`). The file imports its typed builder from **`#menu-config`** (the
subpath import mapped to `src/ui/menu-config.ts`), so it resolves wherever it's mounted subpath import mapped to `src/ui/menu-config.ts`), so it resolves wherever it's mounted
(keep the mounted `config/` a plain dir — no `package.json` of its own — or `#menu-config` (keep the mounted `config/` a plain dir — no `package.json` of its own):
resolves against that instead and boot fails loud):
```ts ```ts
import { defineMenu } from "#menu-config"; import { defineMenu } from "#menu-config";
export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } }); export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
@@ -801,13 +993,13 @@ points the picker at this path when it answers GET, else the page the form was s
**Writing a catalog.** `en-US.ts` exports the object and its type; every other locale is written **Writing a catalog.** `en-US.ts` exports the object and its type; every other locale is written
against that type, so a missing or misspelled key is a type error before the app ever boots. For a against that type, so a missing or misspelled key is a type error before the app ever boots. For a
language of your own: copy `src/i18n/locales/en-US.ts` into `locales/<tag>.ts`, type it language of your own: copy `src/i18n/locales/en-US.ts` into `locales/<tag>.ts`, type it
`CoreMessages` (from `#plugin-api`), and translate. The `as PluralMessage` cast below is required — `CoreMessages` (from `@plainpages/plugin-api`), and translate. The `as PluralMessage` cast below is required —
without it the inferred type pins the plural forms to English's two, and a locale that selects more without it the inferred type pins the plural forms to English's two, and a locale that selects more
(Polish, Arabic) becomes unwritable: (Polish, Arabic) becomes unwritable:
```ts ```ts
// plugins/shop/i18n/en-US.ts // plugins/shop/i18n/en-US.ts
import type { PluralMessage } from "#plugin-api"; import type { PluralMessage } from "@plainpages/plugin-api";
const messages = { const messages = {
"shop.title": "Shop", "shop.title": "Shop",
@@ -836,7 +1028,7 @@ render in `en-US`), never one the host doesn't have.
return { data: { title: ctx.t("shop.title"), lead: ctx.t("shop.greeting", { name }) }, view: "shop" }; return { data: { title: ctx.t("shop.title"), lead: ctx.t("shop.greeting", { name }) }, view: "shop" };
// a pure view model built outside a request (its unit test) defaults to the plugin's own English: // a pure view model built outside a request (its unit test) defaults to the plugin's own English:
import { englishTranslator, type Translate } from "#plugin-api"; import { englishTranslator, type Translate } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
const EN: Translate = englishTranslator(enUS); // your catalog, then the host's const EN: Translate = englishTranslator(enUS); // your catalog, then the host's
``` ```
@@ -890,7 +1082,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` 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, and every plugin setting declared `secret` — 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 |
@@ -906,6 +1098,11 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant permission/session revoke denylist](#instant-revoke-the-optional-denylist) | | `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant permission/session revoke denylist](#instant-revoke-the-optional-denylist) |
| `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_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_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 |
### Canonical host (one public URL) ### Canonical host (one public URL)
@@ -1036,8 +1233,8 @@ records that subject as revoked-now; the hot path then rejects every token for i
the revoke and forces a re-mint — which re-reads permissions from Keto, or clears a dead session. A the revoke and forces a re-mint — which re-reads permissions from Keto, or clears a dead session. A
fresh re-login passes, so a downgrade lands immediately without locking the account. fresh re-login passes, so a downgrade lands immediately without locking the account.
It is an in-memory, auto-evicting map — no database, so it stays inside the stateless model — and It is an in-memory, auto-evicting map — host-owned state would break the [stateless
the check is pure CPU, keeping Keto off the hot path. Entries self-evict after `REVOCATION_TTL_SEC` core](#stateless-core) — and the check is pure CPU, keeping Keto off the hot path. Entries self-evict after `REVOCATION_TTL_SEC`
(default 900s ≥ the 10m token TTL + skew). Two bounds: it is instant only on the **single instance** (default 900s ≥ the 10m token TTL + skew). Two bounds: it is instant only on the **single instance**
that handled the revoke (elsewhere the guarantee falls back to the token TTL — back it with a shared that handled the revoke (elsewhere the guarantee falls back to the token TTL — back it with a shared
store for hard multi-instance revoke), and a **group** membership change is transitive across many store for hard multi-instance revoke), and a **group** membership change is transitive across many
@@ -1099,6 +1296,11 @@ obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
**Offboarding is not instant by default** — a revoked permission or deactivated identity lands **Offboarding is not instant by default** — a revoked permission or deactivated identity lands
within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on. within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on.
**A plugin, and every package it depends on, runs with the host's full privileges** — in the process
holding the JWT signing key and `ctx.system`'s Ory admin clients, on the network that reaches the
unauthenticated Ory ports. Install only what you trust, and let the plugin's own lockfile
([Plugin dependencies](#plugin-dependencies)) pin the tree you audited.
Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing Hardening a real deploy is `REQUIRE_SECURE_SECRETS=true`, `SECURE_COOKIES=true`, and replacing
**every** committed dev secret ([what you must supply](#what-you-must-supply-the-only-manual-prep)). **every** committed dev secret ([what you must supply](#what-you-must-supply-the-only-manual-prep)).
`REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres' `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`; nothing fails loud if you ship Ory's, Postgres'
@@ -1147,18 +1349,21 @@ of it over their **REST APIs using Node's built-in `fetch`** — no SDK dependen
In **dev** the host-facing Ory ports are published — Kratos public `4433` and Hydra public `4444`; In **dev** the host-facing Ory ports are published — Kratos public `4433` and Hydra public `4444`;
prod keeps them internal. prod keeps them internal.
Runtime deps stay tiny and pinned: **`ejs`**, **`lucide-static`**, and **`@larvit/log`**. Auth, Runtime deps stay tiny and pinned: **`ejs`**, **`lucide-static`**, **`@larvit/log`**, and
sessions, SSO and OAuth2 add *services*, not npm packages. **`postgres`** — the last one has no sub-dependencies of its own and is used in a single module, to
provision [plugin storage](#plugin-storage) at boot. Auth, sessions, SSO and OAuth2 add *services*,
not npm packages.
### Stateless ### Stateless core
Plainpages holds **no state of its own**. The only database in the stack is **Postgres**, used by The host holds **no state of its own**: it owns no schema and keeps nothing between requests. The
Ory; the `web` app never connects to it. stack's **Postgres** backs Ory, and gives every plugin that asks for one a database of its own
([Plugin storage](#plugin-storage)) — which the host provisions but never reads or writes.
A plugin reads and writes state by **calling an upstream service** from its route handler — a REST So a plugin gets its data one of two ways: by **calling an upstream service** from its route handler
API, an ERP, a plant historian, the customer's own backend — and renders the response with the — a REST API, an ERP, a plant historian, the customer's own backend — or from **its own database**.
building blocks. That keeps `web` trivially scalable and crash-safe: any instance can serve any Either keeps `web` trivially scalable and crash-safe: any instance can serve any request, because the
request, because the session lives in Kratos and the data lives upstream. session lives in Kratos and the data lives outside the process.
## Testing ## Testing
@@ -1227,10 +1432,10 @@ 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 | re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`; sync those tags to Docker Hub | | `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 |
| `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; the release-tag job only runs when `AUTO_RELEASE` is `true` | | `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 |
`main` is not re-tested on push — its commits are meant to arrive already green from a `main` is not re-tested on push — its commits are meant to arrive already green from a
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`. gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
@@ -1252,11 +1457,27 @@ 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`, `1`, `latest`; `release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `latest` and —
nothing is rebuilt, so the released image is byte-identical to the gated one. It fails loud if no once the major reaches `1` — `1`; nothing is rebuilt, so the released image is byte-identical to the
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. The Docker Hub tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only.
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.
@@ -1272,18 +1493,19 @@ images, and the Playwright runner + its browser image — and every bump keeps t
exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once
`CI / full-gate (push)` is green; only a red gate needs a human. `CI / full-gate (push)` is green; only a red gate needs a human.
**Releases are paused.** Plainpages is pre-announcement: the repository carries **no tags**, so **Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`) cuts **one**
neither `release.yml` nor Docker Hub has a version to promote. Turn releasing back on by setting the `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and
Actions variable `AUTO_RELEASE` to `true`, or cut a `vX.Y.Z` tag by hand **on `main`'s tip** — with **skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a
nothing tagged the nightly cleanup keeps only branch-head images, so an older commit's image is trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a
already gone and `release.yml` would fail loud. `Release-Bump: <updateType>` trailer onto the updates that reach a running Plainpages — the rules in
[`renovate.json`](renovate.json) name them — and
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`, gated on [`release-tooling/next-version.ts`](release-tooling/next-version.ts) turns the highest one into the next
`AUTO_RELEASE`) cuts **one** `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release
since the last tag, and **skips** when the tip isn't a Renovate commit or nothing new merged. version, an update big enough to reach a **minor** stops the job rather than tagging: bump
Renovate stamps each commit with a `Release-Bump: <updateType>` trailer and `HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since
[`auto-release/next-version.ts`](auto-release/next-version.ts) turns the highest one into the next `nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump,
version — pre-1.0 it never auto-crosses into `1.0.0`. It is **tag-only**: the tag hands off to 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.
@@ -1292,7 +1514,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 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). | | `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. |
| `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. |
@@ -1320,6 +1542,11 @@ the one-shot bootstrap) — and mounts no source. Secrets come from the environm
running insecure. Before going live, supply the production secrets and any SSO credentials — the running insecure. Before going live, supply the production secrets and any SSO credentials — the
**only** manual prep ([What you must supply](#what-you-must-supply-the-only-manual-prep)). **only** manual prep ([What you must supply](#what-you-must-supply-the-only-manual-prep)).
**Back up the `pgdata` volume.** Once a plugin declares [storage](#plugin-storage), Postgres holds
business data that exists nowhere else, alongside Ory's identities — the stack stops being
reproducible from the image and config alone. Snapshot the volume, or `pg_dump` each database on a
schedule, and rehearse the restore.
Every response carries security headers (`src/http/security-headers.ts`): a strict Every response carries security headers (`src/http/security-headers.ts`): a strict
`Content-Security-Policy` (the core is zero-JS — `script-src 'self'`, no inline scripts), `Content-Security-Policy` (the core is zero-JS — `script-src 'self'`, no inline scripts),
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` + `frame-ancestors 'none'`, `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` + `frame-ancestors 'none'`,
@@ -1352,9 +1579,9 @@ docker compose up -d --build
``` ```
Do the same for any other folder you copied out of `examples/`. A plugin you wrote yourself needs the Do the same for any other folder you copied out of `examples/`. A plugin you wrote yourself needs the
manifest change the error names. Once [`HOST_API_VERSION`](#contract-versioning) starts moving a manifest change the error names. A host contract change big enough to move
stale plugin will be refused by **version** instead; it is frozen at `1.0.0` until the first external [`HOST_API_VERSION`](#contract-versioning) shows up earlier and more precisely — discovery refuses the
plugin exists, so for now the error names the rule it tripped. plugin by **version** before any rule gets a chance to trip.
Two paths in the checkout are load-bearing and must stay clear of root-owned leftovers: Two paths in the checkout are load-bearing and must stay clear of root-owned leftovers:
`node_modules/` must not exist (deps live at `/node_modules`, and anything at `/app/node_modules` `node_modules/` must not exist (deps live at `/node_modules`, and anything at `/app/node_modules`
@@ -1469,11 +1696,13 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
fetch-timeout) fetch-timeout)
i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime · i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime ·
english · view-locals · locales/ (the core en-US + sv-SE catalogs) english · view-locals · locales/ (the core en-US + sv-SE catalogs)
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `#plugin-api` barrel) · system.ts plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
(ctx.system) · discovery · router · hooks · view-resolver (ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) ·
storage-provisioning (the DDL; bootstrap-only, holds the driver)
ui/ chrome (the one global menu) · shell-context · dashboard · nav (composeNav) · ui/ chrome (the one global menu) · shell-context · dashboard · nav (composeNav) ·
menu-config (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate menu-config (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules
views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503, views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503,
and partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, and partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card,
alert, menu/popover, theme switch, language picker, icon sprite). Domain screens live alert, menu/popover, theme switch, language picker, icon sprite). Domain screens live
@@ -1488,9 +1717,11 @@ 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)
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
@@ -1498,7 +1729,8 @@ README-dockerhub.md The Docker Hub repository description, pasted over by hand
- **New page in a plugin:** add a route + handler to the plugin manifest and a template in - **New page in a plugin:** add a route + handler to the plugin manifest and a template in
its `views/`. its `views/`.
- **Static asset:** drop it in the plugin's `public/`; served at `/public/<plugin>/<path>`. - **Static asset:** drop it in the plugin's `public/`; served at `/public/<plugin>/<path>`.
- **New dependency:** deps live in the image, so update the manifest + lockfile and rebuild — - **New dependency in a plugin:** the plugin owns it — see [Plugin dependencies](#plugin-dependencies).
- **New dependency in the core:** deps live in the image, so update the manifest + lockfile and rebuild —
`--package-lock-only` writes nothing into the checkout, `--user` keeps the two files yours. `--package-lock-only` writes nothing into the checkout, `--user` keeps the two files yours.
Keep deps minimal — prefer the Node standard library, and an Ory REST call over an SDK. Keep deps minimal — prefer the Node standard library, and an Ory REST call over an SDK.
+27
View File
@@ -60,6 +60,33 @@ echo "$units" | grep -E '^. (tests|pass|fail) ' || true
count=$(echo "$units" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | head -1 || true) count=$(echo "$units" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | head -1 || true)
[ "${count:-0}" -ge 50 ] || { echo "only ${count:-0} unit tests ran — test glob broken?"; exit 1; } [ "${count:-0}" -ge 50 ] || { echo "only ${count:-0} unit tests ran — test glob broken?"; exit 1; }
# Plugin storage against a real Postgres. The step above runs --no-deps, so this suite's integration
# test skips there — and it is the only thing proving the DDL actually grants what it claims, rather
# than that the SQL text is the text we wrote. `node --test` counts a skip, so the floor won't catch it.
step "Plugin storage (real Postgres)"
# Own project name, like every E2E suite below: the default project is the DEV stack, so a bare
# `down -v` here would delete the operator's pgdata — Ory identities and every plugin database.
# --wait, because initdb on a cold volume outlasts the suite's connect timeout.
storage_rc=0
storage_proj=plainpages-storage
storage_files=(-p "$storage_proj" -f compose.yml) # no override merge, like the e2e suites below
storage_dsn="postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/ory"
storage_out=""
docker compose "${storage_files[@]}" up -d --wait postgres >/dev/null || storage_rc=$?
# `if`, not `&&`: a false `&&` returns non-zero, which under `set -e` would exit before teardown.
if [ "$storage_rc" -eq 0 ]; then
# --build like the e2e suites: this stack mounts no source, so without it the step would test
# whatever `web` image that project last baked.
storage_out=$(docker compose "${storage_files[@]}" run --build --rm --no-deps \
-e "PLUGIN_DB_ADMIN_URL=$storage_dsn" \
web node --test src/plugin-host/storage.test.ts 2>&1) || storage_rc=$?
fi
docker compose "${storage_files[@]}" down -v >/dev/null 2>&1 || true # also covers a failed `up`
echo "$storage_out" | grep -E '^. (tests|pass|fail|skipped) ' || true
[ "$storage_rc" -eq 0 ] || { echo "$storage_out"; echo "plugin storage integration tests failed (exit $storage_rc)"; exit "$storage_rc"; }
# A skip here exits 0 and proves nothing — the same trap the unit floor above guards against.
echo "$storage_out" | grep -qE '^. skipped 0$' || { echo "storage integration test skipped — PLUGIN_DB_ADMIN_URL not wired through"; exit 1; }
# Run one E2E suite against its OWN named stack, then always tear it down (even on failure). The # Run one E2E suite against its OWN named stack, then always tear it down (even on failure). The
# per-suite project name keeps a flaky teardown from leaking containers/volumes into the next suite. # per-suite project name keeps a flaky teardown from leaking containers/volumes into the next suite.
# --user: the runner writes screenshots + the report into the checkout, so they must belong to # --user: the runner writes screenshots + the report into the checkout, so they must belong to
+16 -4
View File
@@ -1,5 +1,9 @@
# Development overrides, merged automatically by `docker compose up`. # Development overrides, merged automatically by `docker compose up`.
# Mounts the source for live editing and restarts on change via `node --watch`. # Mounts the source for live editing and restarts on change via `node --watch`.
# web connects with it and bootstrap provisions against it, so the two must agree — one home.
x-plugin-db-url: &plugin-db-url postgres://postgres:5432
services: services:
web: web:
command: node --watch src/server.ts command: node --watch src/server.ts
@@ -13,9 +17,12 @@ services:
CACHE_TEMPLATES: "false" CACHE_TEMPLATES: "false"
LOG_FORMAT: "text" # human-readable logs in dev (base sets json for prod log pipelines) LOG_FORMAT: "text" # human-readable logs in dev (base sets json for prod log pipelines)
LOG_LEVEL: "debug" # verbose by default while developing (base defaults to info) LOG_LEVEL: "debug" # verbose by default while developing (base defaults to info)
# Point plugin storage at the bundled Postgres, so a dropped-in plugin declaring `storage`
# works with no further config; the secret falls back to the dev throwaway (config.ts).
PLUGIN_DB_URL: *plugin-db-url
REQUIRE_SECURE_SECRETS: "false" REQUIRE_SECURE_SECRETS: "false"
SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent
SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/ PLUGIN_SETTING_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):
@@ -29,13 +36,18 @@ services:
# It belongs here and not in the base file, where it would desynchronise prod and collide with the # It belongs here and not in the base file, where it would desynchronise prod and collide with the
# e2e stacks, which bind individual plugins *inside* /app/plugins. # e2e stacks, which bind individual plugins *inside* /app/plugins.
bootstrap: bootstrap:
# Provisions the plugin databases web connects to above, as the dev superuser.
environment:
PLUGIN_DB_ADMIN_URL: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/ory
PLUGIN_DB_URL: *plugin-db-url
REQUIRE_SECURE_SECRETS: "false" # dev derives from the throwaway, as web does
volumes: volumes:
- .:/app - .:/app
# Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so # Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so
# the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this # the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service — # backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
# stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead. # stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
shifts-upstream: shifts-upstream:
image: node:24.19.0-alpine3.24 image: node:24.19.0-alpine3.24
command: node /srv/server.ts command: node /srv/server.ts
@@ -46,7 +58,7 @@ services:
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025). # Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env. # kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
mailpit: mailpit:
image: axllent/mailpit:v1.30.7 image: axllent/mailpit:v1.31.0
ports: ports:
- "8025:8025" - "8025:8025"
restart: unless-stopped restart: unless-stopped
+21 -3
View File
@@ -17,10 +17,16 @@ services:
CACHE_TEMPLATES: "true" CACHE_TEMPLATES: "true"
CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret} CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}
LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector
# Per-plugin Postgres storage. Explicit toggle: unset ⇒ off, and a plugin declaring `storage`
# refuses to boot rather than run without its data. The URL carries no credentials — each
# plugin's own password is derived from the secret (README → Plugin storage).
PLUGIN_DB_SECRET: ${PLUGIN_DB_SECRET:-}
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-}
REQUIRE_SECURE_SECRETS: "true" REQUIRE_SECURE_SECRETS: "true"
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
# Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/ # Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/
# consent handler) + the one-shot bootstrap (admin + JWKS seed). # consent handler) + the one-shot bootstrap (admin + JWKS seed). Postgres too: a plugin that
# declares `storage` opens its connection in onBoot, before the server listens.
depends_on: depends_on:
bootstrap: bootstrap:
condition: service_completed_successfully condition: service_completed_successfully
@@ -30,14 +36,17 @@ services:
condition: service_healthy condition: service_healthy
hydra: hydra:
condition: service_healthy condition: service_healthy
postgres:
condition: service_healthy
# verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL). # verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
# Read-only — bootstrap is the only writer. # Read-only — bootstrap is the only writer.
volumes: volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
restart: unless-stopped restart: unless-stopped
# Ory's storage only (Kratos/Keto/Hydra) — the web app never connects here. # The stack's storage: one database per Ory service (init/init.sql), plus one per plugin that
# init/init.sql creates one database per service. Dev defaults below; supply # declares `storage` — bootstrap creates those at boot, since only it holds superuser credentials.
# A plugin connects as its own role from inside web. Dev defaults below; supply
# POSTGRES_USER/PASSWORD via env in production. # POSTGRES_USER/PASSWORD via env in production.
postgres: postgres:
image: postgres:18.6-alpine3.23 image: postgres:18.6-alpine3.23
@@ -127,6 +136,8 @@ services:
condition: service_healthy condition: service_healthy
keto: keto:
condition: service_healthy condition: service_healthy
postgres:
condition: service_healthy
environment: environment:
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
@@ -137,6 +148,13 @@ services:
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
KETO_WRITE_URL: http://keto:4467 KETO_WRITE_URL: http://keto:4467
KRATOS_ADMIN_URL: http://kratos:4434 KRATOS_ADMIN_URL: http://kratos:4434
# The superuser DSN that creates each plugin's database and role lives ONLY here — never in
# web, so plugin code cannot read it out of its own environment. Unset ⇒ a plugin declaring
# `storage` fails the seed loudly. The secret must match web's; both derive the same passwords.
PLUGIN_DB_ADMIN_URL: ${PLUGIN_DB_ADMIN_URL:-}
PLUGIN_DB_SECRET: ${PLUGIN_DB_SECRET:-}
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-} # only to refuse a mismatch: what bootstrap creates, web connects to
REQUIRE_SECURE_SECRETS: "true" # refuse the throwaway secret here too, before any role is created
volumes: volumes:
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer - ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
command: node src/auth/bootstrap.ts command: node src/auth/bootstrap.ts
+11
View File
@@ -195,6 +195,17 @@ test.describe.serial("authenticated admin journey", () => {
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
}); });
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 () => {
await page.goto("/dashboard"); await page.goto("/dashboard");
await page.locator("button.profile").click(); // open the profile dropdown await page.locator("button.profile").click(); // open the profile dropdown
-2
View File
@@ -1,12 +1,10 @@
{ {
"name": "plainpages-e2e", "name": "plainpages-e2e",
"version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "plainpages-e2e", "name": "plainpages-e2e",
"version": "0.1.0",
"devDependencies": { "devDependencies": {
"@playwright/test": "1.62.1" "@playwright/test": "1.62.1"
} }
-1
View File
@@ -1,6 +1,5 @@
{ {
"name": "plainpages-e2e", "name": "plainpages-e2e",
"version": "0.1.0",
"private": true, "private": true,
"description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).", "description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
"type": "module", "type": "module",
+2 -2
View File
@@ -5,7 +5,7 @@ across (or bind-mount your own) and restart.
| Path | Copy into | Example of | | Path | Copy into | Example of |
| --- | --- | --- | | --- | --- | --- |
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). | | [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). | | [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). | | [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. | | [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `PLUGIN_SETTING_SCHEDULING_UPSTREAM` at the real thing instead. |
+1 -1
View File
@@ -5,7 +5,7 @@
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin // PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded. // per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
import { can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api"; import { can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
+1 -1
View File
@@ -2,7 +2,7 @@
// two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts. // two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import type { PermissionDecl } from "#plugin-api"; import type { PermissionDecl } from "@plainpages/plugin-api";
import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts"; import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts";
const declared: PermissionDecl[] = [ const declared: PermissionDecl[] = [
+1 -1
View File
@@ -6,7 +6,7 @@
// what the installed plugins declare in code. Nothing here invents a name, which is why the old // what the installed plugins declare in code. Nothing here invents a name, which is why the old
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are. // Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "#plugin-api"; import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
const PERMISSION_NS = "Permission"; const PERMISSION_NS = "Permission";
const GRANTED = "granted"; const GRANTED = "granted";
+1 -1
View File
@@ -14,7 +14,7 @@ import {
memberView, memberView,
parseSubject, parseSubject,
} from "./admin-groups.ts"; } from "./admin-groups.ts";
import type { RelationTuple } from "#plugin-api"; import type { RelationTuple } from "@plainpages/plugin-api";
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`; const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
const userTuple = (group: string, n: number): RelationTuple => const userTuple = (group: string, n: number): RelationTuple =>
+1 -1
View File
@@ -6,7 +6,7 @@
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded, // per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
// each returning a RouteResult. // each returning a RouteResult.
import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "#plugin-api"; import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts"; import type { FieldConfig } from "./admin-users.ts";
@@ -0,0 +1,55 @@
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);
});
@@ -0,0 +1,75 @@
// 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" };
};
+9 -9
View File
@@ -1,12 +1,12 @@
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical // Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
// (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the // (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts. // contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
// Import only from the #plugin-api barrel — the same contract boundary the plugin code uses. // Import only from the @plainpages/plugin-api barrel — the same contract boundary the plugin code uses.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { test } from "node:test"; import { test } from "node:test";
import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api"; import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "@plainpages/plugin-api";
import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts"; import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts";
const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] }; const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] };
@@ -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: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {}, chrome: CHROME, declaredPermissions: [], declaredSettings: [], 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 three screens each gate on their own read permission", () => { test("ADMIN_NAV: an ungated Admin header whose 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 the three hides the section. // drops a header left with no visible children, so holding none of them 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"]); 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.permission), ["users:read", "groups:read", "oauth2-clients:read"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read", "plugin-settings: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"]); 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) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients", "Plugin settings"]);
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined)); assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
}); });
+5 -3
View File
@@ -1,8 +1,8 @@
// Shared plumbing for the admin example plugin: the section nav fragment, the screen gate, the // Shared plumbing for the admin example plugin: the section nav fragment, the screen gate, the
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers. // CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers.
// Everything imports the host only through the #plugin-api barrel. // Everything imports the host only through the @plainpages/plugin-api barrel.
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "#plugin-api"; import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
// This plugin's English — its catalog, then the host's — for a view model built outside a request, // This plugin's English — its catalog, then the host's — for a view model built outside a request,
@@ -12,12 +12,13 @@ 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" | "users"; export type AdminResource = "groups" | "oauth2-clients" | "plugin-settings" | "users";
export type AdminAction = "read" | "write"; export type AdminAction = "read" | "write";
@@ -43,6 +44,7 @@ export const ADMIN_NAV: NavNode = {
{ 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",
+1 -1
View File
@@ -2,7 +2,7 @@
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts. // routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import type { Identity } from "#plugin-api"; import type { Identity } from "@plainpages/plugin-api";
import { import {
buildUserFormModel, buildUserFormModel,
buildUsersListModel, buildUsersListModel,
+1 -1
View File
@@ -3,7 +3,7 @@
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over // into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
// a shared `withUser` gate. // a shared `withUser` gate.
import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api"; import { can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
+20
View File
@@ -83,9 +83,29 @@ 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,9 +83,29 @@ 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",
+8 -5
View File
@@ -3,7 +3,7 @@
// with nothing in the logs to explain it. Pin the two halves against each other here. // with nothing in the logs to explain it. Pin the two halves against each other here.
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { isValidPermissionName } from "#plugin-api"; import { isValidPermissionName } from "@plainpages/plugin-api";
import manifest from "./plugin.ts"; import manifest from "./plugin.ts";
const routes = manifest.routes ?? []; const routes = manifest.routes ?? [];
@@ -35,17 +35,20 @@ test("every nav permission is one the manifest declares", () => {
} }
}; };
walk(manifest.nav); walk(manifest.nav);
assert.equal(navPermissions.length, 3); assert.equal(navPermissions.length, 4);
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 screens × read/write. There is deliberately no `permissions:` pair: permissions are // Three CRUD screens × read/write, plus read-only plugin settings — a screen that never writes
// declared in plugin code, so holding one is edited on the user or group that holds it. // declares no `:write`, since a permission nothing gates on is one an operator can only mis-grant.
// 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",
]); ]);
}); });
@@ -58,5 +61,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 screen assert.equal(routes.filter((r) => r.method === "GET" && writeIntent(r.path)).length, 6); // 2 per CRUD screen; plugin settings has none
}); });
+7 -2
View File
@@ -4,9 +4,10 @@
// It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke // It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503. // hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api"; import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api";
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts"; import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, 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";
@@ -24,9 +25,10 @@ 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: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION
nav: [ADMIN_NAV], nav: [ADMIN_NAV],
@@ -37,6 +39,7 @@ 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: [
@@ -68,5 +71,7 @@ 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),
], ],
}); });
@@ -0,0 +1,24 @@
<%#
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,
}) %>
+1 -1
View File
@@ -27,7 +27,7 @@ The plugin holds **no state** — data lives upstream (README → *Stateless*).
## Upstream ## Upstream
Set `SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory Set `PLUGIN_SETTING_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).
+1 -1
View File
@@ -2,7 +2,7 @@
// looked up here first and fall back to the host's, so a plugin owns its words without prefixing // looked up here first and fall back to the host's, so a plugin owns its words without prefixing
// them, and `shifts.count` shows the plural form (host: README → Translating). // them, and `shifts.count` shows the plural form (host: README → Translating).
import type { PluralMessage } from "#plugin-api"; import type { PluralMessage } from "@plainpages/plugin-api";
const messages = { const messages = {
"scheduling.field.assignee": "Assignee", "scheduling.field.assignee": "Assignee",
+21 -9
View File
@@ -2,20 +2,21 @@
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this // data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins. // folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
import { definePlugin } from "#plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts"; import { 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). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream). // stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it
const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000"; // over — which is after this manifest is built, hence the getter.
const upstream = createUpstream(upstreamUrl); let upstreamUrl = "";
const upstream = createUpstream(() => upstreamUrl);
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION
// onBoot runs after discovery, before the server listens: validate the plugin's own config so a // onBoot runs after discovery, before the server listens — where a plugin receives its resolved
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later. // settings. A malformed URL already failed the boot by then; the host validated the declared type.
hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") }, hooks: { onBoot: ({ settings }) => { upstreamUrl = settings.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"
@@ -45,4 +46,15 @@ export default definePlugin({
{ 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",
},
],
}); });
+23 -25
View File
@@ -2,12 +2,12 @@ import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import test from "node:test"; import test from "node:test";
// Import only from the #plugin-api barrel — the same contract boundary shifts.ts uses (the host may // Import only from the @plainpages/plugin-api barrel — the same contract boundary shifts.ts uses (the host may
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches. // refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api"; import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
import { import {
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput, 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";
@@ -18,7 +18,7 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; ve
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts"); const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage; const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
return { return {
chrome: CHROME, declaredPermissions: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {}, chrome: CHROME, declaredPermissions: [], declaredSettings: [], 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),
}; };
@@ -35,27 +35,25 @@ 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 };
}; };
// ---- upstream config validation (the onBoot hook) ---- // ---- the upstream URL as a declared setting ----
test("assertHttpUrl accepts http(s) and fails loud on a malformed or non-http upstream URL", () => { test("the manifest declares its upstream as a URL setting the host validates", async () => {
assert.doesNotThrow(() => assertHttpUrl("http://shifts-upstream:4000", "SCHEDULING_UPSTREAM")); const manifest = (await import("./plugin.ts")).default;
assert.doesNotThrow(() => assertHttpUrl("https://api.example.com/v1", "SCHEDULING_UPSTREAM")); assert.deepEqual(manifest.settings?.map((s) => s.key), ["upstream"]);
assert.throws(() => assertHttpUrl("not a url", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*valid URL/); // unparseable assert.equal(manifest.settings?.[0]?.type, "url"); // so a typo'd URL fails the boot, not every request
assert.throws(() => assertHttpUrl("shifts-upstream:4000", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // missing // → parsed as a bogus scheme assert.equal(manifest.settings?.[0]?.default, "http://shifts-upstream:4000"); // the dev compose's mock
assert.throws(() => assertHttpUrl("ftp://host/x", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // wrong scheme assert.equal(typeof manifest.hooks?.onBoot, "function"); // without it the resolved value never arrives
}); });
test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not just the helper)", async () => { test("the client re-reads its base URL, so onBoot can bind it after the manifest is built", async () => {
const prev = process.env["SCHEDULING_UPSTREAM"]; let baseUrl = "http://first:4000";
process.env["SCHEDULING_UPSTREAM"] = "nope://bad"; // read at import time below const seen: string[] = [];
try { const http = (async (url) => { seen.push(String(url)); return new Response("[]", { status: 200 }); }) as typeof fetch;
const manifest = (await import("./plugin.ts")).default; const upstream = createUpstream(() => baseUrl, http);
assert.equal(typeof manifest.hooks?.onBoot, "function"); await upstream.list();
assert.throws(() => manifest.hooks!.onBoot!(), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud baseUrl = "http://second:4000";
} finally { await upstream.list();
if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"]; assert.deepEqual(seen, ["http://first:4000/shifts", "http://second:4000/shifts"]);
else process.env["SCHEDULING_UPSTREAM"] = prev;
}
}); });
// ---- upstream client (fetch injected) ---- // ---- upstream client (fetch injected) ----
@@ -67,21 +65,21 @@ test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", as
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", 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", 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);
}); });
+8 -18
View File
@@ -5,8 +5,8 @@
// Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as // Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as
// pure functions against a mock upstream with no network (README.md → Local dev & test story). // pure functions against a mock upstream with no network (README.md → Local dev & test story).
// One import from the host's #plugin-api barrel — the stable author surface (see README.md → Building plugins). // One import from the host's @plainpages/plugin-api barrel — the stable author surface (see README.md → Building plugins).
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "#plugin-api"; import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
// The plugin's own English (its catalog, then the host's), for a view model built outside a request: // The plugin's own English (its catalog, then the host's), for a view model built outside a request:
@@ -49,26 +49,16 @@ export interface ShiftsUpstream {
list(): Promise<Shift[]>; list(): 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.
export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream { // `baseUrl` is read per call: the plugin's settings arrive on onBoot, after the manifest that binds
const base = baseUrl.replace(/\/+$/, ""); // these handlers has already been built.
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",
@@ -76,7 +66,7 @@ export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = traced
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() { async list() {
const res = await fetchImpl(`${base}/shifts`, { headers: { accept: "application/json" } }); const res = await fetchImpl(`${base()}/shifts`, { 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) : [];
+1 -1
View File
@@ -1,6 +1,6 @@
// 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 SCHEDULING_UPSTREAM // of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM
// at your real service in production. // at your real service in production.
// //
// GET /shifts → 200 [ { id, title, assignee, start, end }, … ] // GET /shifts → 200 [ { id, title, assignee, start, end }, … ]
+9 -1
View File
@@ -1,6 +1,14 @@
-- Runs once on first boot (docker-entrypoint-initdb.d), as the POSTGRES_USER. -- Runs once on first boot (docker-entrypoint-initdb.d), as the POSTGRES_USER.
-- One database per Ory service: each owns its schema and runs its own migrations, -- One database per Ory service: each owns its schema and runs its own migrations,
-- so they never collide. The web app never connects here (stateless — see README). -- so they never collide. A plugin's database does not belong here: bootstrap provisions those on
-- every boot, so one dropped in later is picked up too (README → Plugin storage).
CREATE DATABASE kratos; CREATE DATABASE kratos;
CREATE DATABASE keto; CREATE DATABASE keto;
CREATE DATABASE hydra; CREATE DATABASE hydra;
-- Postgres grants CONNECT to PUBLIC by default, so every plugin role could otherwise open the auth
-- plane's databases and read pg_catalog; table data stays protected either way. Ory connects as the
-- POSTGRES_USER, which owns these and keeps its access.
REVOKE CONNECT ON DATABASE kratos FROM PUBLIC;
REVOKE CONNECT ON DATABASE keto FROM PUBLIC;
REVOKE CONNECT ON DATABASE hydra FROM PUBLIC;
+18 -6
View File
@@ -1,16 +1,15 @@
{ {
"name": "plainpages", "name": "plainpages",
"version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "plainpages", "name": "plainpages",
"version": "0.1.0",
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "6.0.1", "ejs": "6.0.1",
"lucide-static": "1.31.0" "lucide-static": "1.33.0",
"postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
@@ -400,11 +399,24 @@
} }
}, },
"node_modules/lucide-static": { "node_modules/lucide-static": {
"version": "1.31.0", "version": "1.33.0",
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.31.0.tgz", "resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.33.0.tgz",
"integrity": "sha512-XFX9NO+gcLsOkXISmeQZYGJa2siGWZc/lgT/BK1b83h9RdOiz3cj1eQP5nWhELiB1KhW5ryk0nmMopOgm/CuSA==", "integrity": "sha512-jNGgvTNcLUfVRX4N9PH9pVVTJzoph/BmYmgU838bYBQodkUJL4nAThkuymFz1x3OUYMhJxPndC7rdg1sxOPYKg==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/postgres": {
"version": "3.4.9",
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
"license": "Unlicense",
"engines": {
"node": ">=12"
},
"funding": {
"type": "individual",
"url": "https://github.com/sponsors/porsager"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "7.0.2", "version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
+4 -5
View File
@@ -1,26 +1,25 @@
{ {
"name": "plainpages", "name": "plainpages",
"version": "0.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
"node": ">=24" "node": ">=24"
}, },
"imports": { "imports": {
"#menu-config": "./src/ui/menu-config.ts", "#menu-config": "./src/ui/menu-config.ts"
"#plugin-api": "./src/plugin-host/plugin-api.ts"
}, },
"scripts": { "scripts": {
"start": "node src/server.ts", "start": "node src/server.ts",
"dev": "node --watch src/server.ts", "dev": "node --watch src/server.ts",
"gen-jwks": "node src/auth/gen-jwks.ts", "gen-jwks": "node src/auth/gen-jwks.ts",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"auto-release/**/*.test.ts\"" "test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"release-tooling/**/*.test.ts\""
}, },
"dependencies": { "dependencies": {
"@larvit/log": "2.3.0", "@larvit/log": "2.3.0",
"ejs": "6.0.1", "ejs": "6.0.1",
"lucide-static": "1.31.0" "lucide-static": "1.33.0",
"postgres": "3.4.9"
}, },
"devDependencies": { "devDependencies": {
"@types/ejs": "3.1.5", "@types/ejs": "3.1.5",
+2
View File
@@ -0,0 +1,2 @@
// Re-export rather than the surface itself: a package's `exports` target may not escape its folder.
export * from "../src/plugin-host/plugin-api.ts";
+6
View File
@@ -0,0 +1,6 @@
{
"name": "@plainpages/plugin-api",
"private": true,
"type": "module",
"exports": "./index.ts"
}
+58
View File
@@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { checkTagMatchesContract, readHostApiVersion } from "./contract-version.ts";
test("readHostApiVersion pulls the constant out of the real source, and returns null when absent", () => {
const real = readFileSync("src/plugin-host/plugin.ts", "utf8");
assert.match(readHostApiVersion(real) ?? "", /^\d+\.\d+\.\d+$/);
assert.equal(readHostApiVersion('export const SOMETHING_ELSE = "1.0.0";'), null);
});
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
// makes an accidental edit fail here rather than at release time.
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.2.0");
});
test("every author-facing apiVersion sample matches the shipped contract", () => {
// A plugin author copies these; a stale one produces a boot-aborting refuse on first run. The
// examples deliberately write a literal rather than importing the constant (AGENTS.md), so this
// is the only thing keeping the copies honest.
const host = readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")) ?? "";
const [major, minor] = host.split(".");
for (const file of [
"README.md",
"examples/plugins/admin/plugin.ts",
"examples/plugins/scheduling/plugin.ts",
"release-tooling/dockerhub-overview.md.tmpl",
"views/index.ejs",
]) {
const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]);
assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`);
for (const sample of found) {
const [sMajor, sMinor] = (sample ?? "").split(".");
assert.equal(`${sMajor}.${sMinor}`, `${major}.${minor}`, `${file} samples apiVersion ${sample}, host is ${host}`);
}
}
});
test("checkTagMatchesContract: major.minor must agree, patch may lag", () => {
assert.equal(checkTagMatchesContract("v0.1.0", "0.1.0").ok, true);
assert.equal(checkTagMatchesContract("v0.1.7", "0.1.0").ok, true); // auto-release cut patches
assert.equal(checkTagMatchesContract("0.1.0", "0.1.0").ok, true); // bare tag, no v
assert.equal(checkTagMatchesContract("v0.2.0", "0.1.0").ok, false); // plugin-visible, needs a bump
assert.equal(checkTagMatchesContract("v1.0.0", "0.1.0").ok, false);
});
test("checkTagMatchesContract names what to fix rather than just failing", () => {
const res = checkTagMatchesContract("v0.2.0", "0.1.0");
assert.equal(res.ok, false);
assert.match(res.ok === false ? res.error : "", /HOST_API_VERSION to 0\.2\.0/);
});
test("checkTagMatchesContract rejects junk on either side without throwing", () => {
assert.equal(checkTagMatchesContract("v0.1.0", null).ok, false); // constant not found
assert.equal(checkTagMatchesContract("nope", "0.1.0").ok, false);
assert.equal(checkTagMatchesContract("v0.1.0", "1.0").ok, false);
});
+49
View File
@@ -0,0 +1,49 @@
import { readFileSync } from "node:fs";
export type ContractCheck = { ok: true } | { ok: false; error: string };
const SEMVER = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
export function readHostApiVersion(source: string): string | null {
return /^export const HOST_API_VERSION = "([^"]+)";/m.exec(source)?.[1] ?? null;
}
// Patch is deliberately not compared: checkApiVersion ignores it, and auto-release cuts patch
// releases with no commit to bump the constant in.
export function checkTagMatchesContract(tag: string, hostApiVersion: string | null): ContractCheck {
if (hostApiVersion === null) {
return { error: "HOST_API_VERSION not found", ok: false };
}
const t = SEMVER.exec(tag);
if (!t) return { error: `tag must be vX.Y.Z, got ${JSON.stringify(tag)}`, ok: false };
const h = SEMVER.exec(hostApiVersion);
if (!h) return { error: `HOST_API_VERSION must be X.Y.Z, got ${JSON.stringify(hostApiVersion)}`, ok: false };
if (t[1] === h[1] && t[2] === h[2]) return { ok: true };
return {
error:
`${tag} does not match HOST_API_VERSION ${hostApiVersion} — the contract version IS the release ` +
`version. Set HOST_API_VERSION to ${t[1]}.${t[2]}.0 in src/plugin-host/plugin.ts, merge that, ` +
"then tag.",
ok: false,
};
}
// CLI: node release-tooling/contract-version.ts <tag> <path/to/plugin.ts | -> → exits 1 on
// mismatch. `-` reads the source on stdin, so a caller checking a ref other than its checkout
// (`git show origin/main:… | …`) needs no scratch file in the workspace.
if (process.argv[1]?.endsWith("/contract-version.ts")) {
const [, , tag, pluginPath = "src/plugin-host/plugin.ts"] = process.argv;
let source = "";
try {
source = readFileSync(pluginPath === "-" ? 0 : pluginPath, "utf8");
} catch (err) {
process.stderr.write(`${pluginPath}: ${err instanceof Error ? err.message : String(err)}\n`);
process.exitCode = 1;
}
const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source));
if (!result.ok) {
process.stderr.write(`${pluginPath}: ${result.error}\n`);
process.exit(1);
}
process.stdout.write(`${tag} matches HOST_API_VERSION\n`);
}
@@ -2,14 +2,15 @@
A self-hostable foundation for server-rendered web apps — public or gated pages from a A self-hostable foundation for server-rendered web apps — public or gated pages from a
zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in. zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in.
Every domain feature is a drop-in plugin folder; the app is stateless, no build step. Every domain feature is a drop-in plugin folder, with a Postgres database of its own if it wants
one; the host itself is stateless, and there is no build step.
**Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>** **Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>**
([GitHub mirror](https://github.com/larvit/plainpages)) ([GitHub mirror](https://github.com/larvit/plainpages))
## Tags ## Tags
`X.Y.Z` · `X.Y` · `X` · `latest` — each is a release promoted from a CI-gated build. `X.Y.Z` · `X.Y` · `latest` — each is a release promoted from a CI-gated build.
Pin the exact `X.Y.Z` you deploy. Pin the exact `X.Y.Z` you deploy.
## Quick start ## Quick start
@@ -21,7 +22,7 @@ so there is nothing to clone. In an empty directory, save this as `compose.yml`:
```yaml ```yaml
services: services:
web: web:
image: larvit/plainpages:0.0.2 image: larvit/plainpages:{{VERSION}}
ports: ports:
- "3000:3000" - "3000:3000"
environment: environment:
@@ -40,7 +41,7 @@ services:
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user. # One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
bootstrap: bootstrap:
image: larvit/plainpages:0.0.2 image: larvit/plainpages:{{VERSION}}
command: node src/auth/bootstrap.ts command: node src/auth/bootstrap.ts
depends_on: depends_on:
kratos: kratos:
@@ -53,7 +54,7 @@ services:
restart: "on-failure:5" restart: "on-failure:5"
postgres: postgres:
image: postgres:18.4-alpine3.23 image: postgres:18.6-alpine3.23
environment: environment:
POSTGRES_DB: ory POSTGRES_DB: ory
POSTGRES_PASSWORD: ory POSTGRES_PASSWORD: ory
@@ -130,7 +131,7 @@ services:
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025 # Catches Kratos' recovery/verification emails — UI on http://localhost:8025
mailpit: mailpit:
image: axllent/mailpit:v1.30.1 image: axllent/mailpit:v1.31.0
ports: ports:
- "8025:8025" - "8025:8025"
restart: unless-stopped restart: unless-stopped
@@ -142,7 +143,7 @@ volumes:
Extract the Ory config the image ships, then start: Extract the Ory config the image ships, then start:
```bash ```bash
docker run --rm larvit/plainpages:0.0.2 tar -cf - ory | tar -xf - docker run --rm larvit/plainpages:{{VERSION}} tar -cf - ory | tar -xf -
mkdir -p plugins mkdir -p plugins
docker compose up -d docker compose up -d
``` ```
@@ -178,10 +179,10 @@ Everything domain-specific is a plugin folder — the compose above mounts `./pl
into the app. Create `plugins/hello/plugin.ts`: into the app. Create `plugins/hello/plugin.ts`:
```ts ```ts
import { definePlugin } from "#plugin-api"; import { definePlugin } from "@plainpages/plugin-api";
export default definePlugin({ export default definePlugin({
apiVersion: "1.0.0", apiVersion: "0.2.0",
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }], nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
routes: [ routes: [
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) }, { method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { test } from "node:test";
import { jwtFrom, leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts";
const TEMPLATE = "release-tooling/dockerhub-overview.md.tmpl";
const template = () => readFileSync(TEMPLATE, "utf8");
test("renderOverview substitutes every occurrence, not just the first", () => {
const out = renderOverview("pull a:{{VERSION}} then b:{{VERSION}}", "1.2.3");
assert.equal(out, "pull a:1.2.3 then b:1.2.3");
});
test("leftoverPlaceholders catches a typo'd placeholder, deduped, and passes clean text", () => {
assert.deepEqual(leftoverPlaceholders("a {{VERISON}} b {{VERISON}}"), ["{{VERISON}}"]);
assert.deepEqual(leftoverPlaceholders(renderOverview("x {{VERSION}}", "0.1.0")), []);
});
test("the real template renders clean, and the release owns its own image tag", () => {
const rendered = renderOverview(template(), "9.9.9");
assert.deepEqual(leftoverPlaceholders(rendered), []);
assert.match(rendered, /larvit\/plainpages:9\.9\.9/); // the placeholder actually reaches the examples
assert.doesNotMatch(rendered, /larvit\/plainpages:\d+\.\d+\.\d+(?<!9\.9\.9)/);
});
test("the quick start's sidecars are pinned to the same versions this repo runs", () => {
// The page is published automatically, so a drifted pin here ships a topology CI never tested.
const pins = (source: string) =>
new Map([...source.matchAll(/image: ([^:\s]+):(v?\d\S*)/g)].map((m) => [m[1] ?? "", m[2] ?? ""]));
const ours = new Map([
...pins(readFileSync("compose.override.yml", "utf8")),
...pins(readFileSync("compose.yml", "utf8")), // production wins: the template is the prod quick start
]);
const published = pins(template());
assert.ok(published.size > 0, "the template should pin sidecars");
for (const [image, tag] of published) {
assert.equal(tag, ours.get(image), `${TEMPLATE} pins ${image}:${tag}, this repo runs ${ours.get(image)}`);
}
});
test("jwtFrom accepts only a non-empty string token, never throwing on a hostile body", () => {
assert.equal(jwtFrom({ token: "abc" }), "abc");
assert.equal(jwtFrom(null), null); // valid JSON, and the shape a proxy can return
assert.equal(jwtFrom("<html>rate limited</html>"), null);
assert.equal(jwtFrom({}), null);
assert.equal(jwtFrom({ token: "" }), null);
assert.equal(jwtFrom({ token: 42 }), null);
});
+104
View File
@@ -0,0 +1,104 @@
// Publishes the Docker Hub repository overview from dockerhub-overview.md.tmpl, rendering
// `{{VERSION}}` to the release being published.
import { readFileSync } from "node:fs";
import { join } from "node:path";
const HUB = "https://hub.docker.com/v2";
const TIMEOUT_MS = 30_000;
const VERSION = /^\d+\.\d+\.\d+$/;
export function renderOverview(source: string, version: string): string {
return source.replaceAll("{{VERSION}}", version);
}
// A typo'd placeholder would publish literal braces to a public page, so fail the release instead.
export function leftoverPlaceholders(rendered: string): string[] {
return [...new Set(rendered.match(/\{\{[^}]*\}\}/g) ?? [])];
}
export function jwtFrom(body: unknown): string | null {
if (typeof body !== "object" || body === null || !("token" in body)) return null;
return typeof body.token === "string" && body.token !== "" ? body.token : null;
}
type Fetched = { error: string } | { json: unknown; ok: boolean; status: number; text: string };
// fetch and its body readers throw; this is the one edge that converts that into a value.
async function post(url: string, init: RequestInit): Promise<Fetched> {
try {
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
const text = await res.text();
let json: unknown = null;
try {
json = JSON.parse(text);
} catch {
json = null;
}
return { json, ok: res.ok, status: res.status, text };
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
}
async function main(): Promise<number> {
const fail = (message: string): number => {
process.stderr.write(`${message}\n`);
return 1;
};
const [, , version] = process.argv;
const repo = process.env["DOCKERHUB_REPO"];
const user = process.env["DOCKERHUB_USER"];
const token = process.env["DOCKERHUB_TOKEN"];
if (!version || !repo || !user || !token) {
return fail(
"usage: dockerhub-overview.ts <X.Y.Z>; needs DOCKERHUB_REPO, DOCKERHUB_USER and " +
"DOCKERHUB_TOKEN (README -> CI/CD)",
);
}
// The page is public, so never render a version that resolves to no image.
if (!VERSION.test(version)) return fail(`version must be X.Y.Z, got ${JSON.stringify(version)}`);
const templatePath = join(import.meta.dirname, "dockerhub-overview.md.tmpl");
let template = "";
try {
template = readFileSync(templatePath, "utf8");
} catch (err) {
return fail(`${templatePath}: ${err instanceof Error ? err.message : String(err)}`);
}
const body = renderOverview(template, version);
const leftover = leftoverPlaceholders(body);
if (leftover.length > 0) return fail(`${templatePath} has unrendered placeholders: ${leftover.join(", ")}`);
const login = await post(`${HUB}/users/login`, {
body: JSON.stringify({ password: token, username: user }),
headers: { "content-type": "application/json" },
method: "POST",
});
if ("error" in login) return fail(`Docker Hub login unreachable: ${login.error}`);
if (!login.ok) return fail(`Docker Hub login failed: ${login.status} ${login.text}`);
const jwt = jwtFrom(login.json);
if (!jwt) return fail("Docker Hub login returned no token");
const res = await post(`${HUB}/repositories/${repo}/`, {
body: JSON.stringify({ full_description: body }),
headers: { authorization: `Bearer ${jwt}`, "content-type": "application/json" },
method: "PATCH",
});
if ("error" in res) return fail(`Docker Hub unreachable: ${res.error}`);
if (!res.ok) {
return fail(
`Docker Hub overview PATCH failed: ${res.status} ${res.text}` +
(res.status === 403
? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " +
"read/write/delete, which pushing images alone does not (README -> CI/CD)."
: ""),
);
}
process.stdout.write(`Docker Hub overview updated for ${repo} at ${version}\n`);
return 0;
}
if (process.argv[1]?.endsWith("/dockerhub-overview.ts")) {
process.exitCode = await main();
}
@@ -35,6 +35,8 @@ test("nextVersion at/after 1.0.0: literal semver", () => {
assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0"); assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0");
assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0"); assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0");
assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4"); assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4");
// the whole chain: a major dependency bump releases a major host, once the 0.x shift-down is gone
assert.equal(nextVersion("v1.2.3", maxLevel(["patch", "major"])), "v2.0.0");
}); });
test("nextVersion rejects a tag that is not vX.Y.Z", () => { test("nextVersion rejects a tag that is not vX.Y.Z", () => {
@@ -36,7 +36,7 @@ export function nextVersion(latestTag: string, level: Bump): string {
return `v${major}.${minor}.${patch + 1}`; return `v${major}.${minor}.${patch + 1}`;
} }
// CLI: node auto-release/next-version.ts <latestTag> [updateType...] → prints the next tag. // CLI: node release-tooling/next-version.ts <latestTag> [updateType...] → prints the next tag.
if (process.argv[1]?.endsWith("/next-version.ts")) { if (process.argv[1]?.endsWith("/next-version.ts")) {
const [, , latestTag, ...updateTypes] = process.argv; const [, , latestTag, ...updateTypes] = process.argv;
process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes))); process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes)));
+42 -3
View File
@@ -1,9 +1,41 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"], "extends": ["config:recommended"],
"description": "ignorePaths overrides config:recommended's :ignoreModulesAndTests, which ignores **/examples/** — an example plugin's dependencies get update PRs like any other manifest here",
"ignorePaths": ["**/node_modules/**"],
"automerge": true, "automerge": true,
"commitBody": "Release-Bump: {{{updateType}}}",
"packageRules": [ "packageRules": [
{
"description": "The host's own runtime deps. Release-Bump is opt-in per surface (README → CI/CD): updateType rates the dependency's own jump, not whether it reaches a running Plainpages",
"matchDepTypes": ["dependencies"],
"matchFileNames": ["package.json"],
"matchManagers": ["npm"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "The shipped image's base — e2e-tests/Dockerfile is test-only",
"matchFileNames": ["Dockerfile"],
"matchManagers": ["dockerfile"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "The production topology — compose.override.yml is dev, e2e-tests/compose.*.yml are test",
"matchFileNames": ["compose.yml"],
"matchManagers": ["docker-compose"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "The production sidecars, wherever they are pinned — compose.yml and the published quick start move in one branch, so the trailer must not depend on which upgrade sorts first. mailpit is dev-only and stays out",
"matchDatasources": ["docker"],
"matchPackageNames": ["oryd/hydra", "oryd/keto", "oryd/kratos", "postgres"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{
"description": "node is pinned to one version across Dockerfile, dev, E2E and CI, so Renovate moves them in a single branch whose commitBody would otherwise depend on upgrade order — the Dockerfile copy ships, so any node bump is a product change",
"matchDatasources": ["docker"],
"matchPackageNames": ["node"],
"commitBody": "Release-Bump: {{{updateType}}}"
},
{ {
"description": "Ory services share one release train - update kratos, keto and hydra together", "description": "Ory services share one release train - update kratos, keto and hydra together",
"matchDatasources": ["docker"], "matchDatasources": ["docker"],
@@ -27,8 +59,15 @@
}, },
{ {
"customType": "regex", "customType": "regex",
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, auto-release)", "description": "The published quick start ships a compose file, so its sidecars move with the repo's own pins. The version group starts at a digit, which skips the {{VERSION}} placeholder the release renders",
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/renovate.yml"], "managerFilePatterns": ["release-tooling/dockerhub-overview.md.tmpl"],
"matchStrings": ["image: (?<depName>[^:\\s]+):(?<currentValue>v?\\d[^\\s]*)"],
"datasourceTemplate": "docker"
},
{
"customType": "regex",
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, renovate auto-release, release)",
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/release.yml", ".gitea/workflows/renovate.yml"],
"matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"], "matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"],
"depNameTemplate": "node", "depNameTemplate": "node",
"datasourceTemplate": "docker" "datasourceTemplate": "docker"
+64 -1
View File
@@ -5,7 +5,10 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, seedAdmin, seedPermissions } from "./bootstrap.ts"; import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, provisionPluginStorage, seedAdmin, seedPermissions, serverMismatch } from "./bootstrap.ts";
import { createLogger } from "../logger.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import type { ProvisionOptions, ProvisionResult } from "../plugin-host/storage-provisioning.ts";
const json = (status: number, body?: unknown) => const json = (status: number, body?: unknown) =>
new Response(body === undefined ? null : JSON.stringify(body), { new Response(body === undefined ? null : JSON.stringify(body), {
@@ -151,3 +154,63 @@ test("ensureJwks generates a key only when the file is absent", () => {
assert.equal(ensureJwks(path, { exists: () => true, write }), false); assert.equal(ensureJwks(path, { exists: () => true, write }), false);
assert.equal(writes.length, 1); // present → nothing written assert.equal(writes.length, 1); // present → nothing written
}); });
// --- Plugin storage provisioning -----------------------------------------------------
// The provisioner is injected, so the branch decisions are testable without a Postgres.
const SILENT = createLogger({ level: "none" });
const storagePlugin = (id: string): Plugin => ({ apiVersion: "1.0.0", id, storage: true });
const EMPTY: ProvisionResult = { orphans: [], provisioned: [] };
function recordingProvisioner(result: ProvisionResult = EMPTY) {
const calls: ProvisionOptions[] = [];
return { calls, provision: async (options: ProvisionOptions) => { calls.push(options); return result; } };
}
test("provisioning is skipped entirely when nothing declares storage and none is configured", async () => {
const { calls, provision } = recordingProvisioner();
await provisionPluginStorage({}, [{ apiVersion: "1.0.0", id: "plain" }], SILENT, provision);
assert.deepEqual(calls, []); // no connection attempted, so an unconfigured stack still boots
});
// Uninstalling the last storage plugin is exactly when a left-behind database needs naming.
test("provisioning still runs with nothing to provision, so orphans are reported", async () => {
const { calls, provision } = recordingProvisioner({ orphans: ["plugin_gone"], provisioned: [] });
await provisionPluginStorage({ PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db:5432/ory" }, [], SILENT, provision);
assert.equal(calls.length, 1);
assert.deepEqual(calls[0]?.pluginIds, []);
});
test("a plugin declaring storage without a provisioning DSN fails loud, naming the plugin", async () => {
const { calls, provision } = recordingProvisioner();
await assert.rejects(
provisionPluginStorage({}, [storagePlugin("things")], SILENT, provision),
/PLUGIN_DB_ADMIN_URL.*things/s,
);
assert.deepEqual(calls, []);
});
test("the connection limit and derived secret reach the provisioner", async () => {
const { calls, provision } = recordingProvisioner();
const env = { PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db:5432/ory", PLUGIN_DB_CONNECTION_LIMIT: "25", PLUGIN_DB_SECRET: "real" };
await provisionPluginStorage(env, [storagePlugin("things")], SILENT, provision);
assert.equal(calls[0]?.connectionLimit, 25);
assert.equal(calls[0]?.secret, "real");
assert.deepEqual(calls[0]?.pluginIds, ["things"]);
});
// bootstrap creates the role on one server; web tells the plugin to connect to another. Left
// unsaid it surfaces inside a plugin as "password authentication failed", naming neither. Warned
// rather than refused: web reaching a pooler bootstrap cannot provision through is legitimate.
test("a storage URL mismatch is reported, and provisioning still runs", async () => {
const { calls, provision } = recordingProvisioner();
const env = { PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db-a:5432/ory", PLUGIN_DB_URL: "postgres://db-b:5432" };
await provisionPluginStorage(env, [storagePlugin("things")], SILENT, provision);
assert.equal(calls.length, 1);
});
test("the same server spelled with an implicit port still agrees", () => {
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", "postgres://db"), null); // 5432 is the default
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", undefined), null); // web's own boot error to raise
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", "postgres://db:6543"), "db:5432 vs db:6543");
});
+68 -8
View File
@@ -8,10 +8,15 @@
// Then prints a first-run banner; fails loud on any unexpected upstream error. // Then prints a first-run banner; fails loud on any unexpected upstream error.
import { existsSync, writeFileSync } from "node:fs"; import { existsSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts";
import { discoverPlugins } from "../plugin-host/discovery.ts"; import { discoverPlugins } from "../plugin-host/discovery.ts";
import { declaredPermissions, isValidPermissionName } from "../plugin-host/plugin.ts"; import { declaredPermissions, isValidPermissionName, type Plugin } from "../plugin-host/plugin.ts";
import { provisionStorage } from "../plugin-host/storage-provisioning.ts";
import { storagePluginIds } from "../plugin-host/storage.ts";
import { generateJwks, type JwkSet } from "./gen-jwks.ts"; import { generateJwks, type JwkSet } from "./gen-jwks.ts";
import { createLogger, runWithLog, tracedFetch } from "../logger.ts"; import { createLogger, runWithLog, tracedFetch, type Log } from "../logger.ts";
type Env = Record<string, string | undefined>;
// --- Pure payload builders (the Kratos/Keto request contracts) ----------------------- // --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
@@ -141,7 +146,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
// --- CLI (the bootstrap container entrypoint) ---------------------------------------- // --- CLI (the bootstrap container entrypoint) ----------------------------------------
async function main() { async function main() {
const env = process.env; const env = { ...process.env }; // snapshot: the storage credentials leave process.env before discovery
// Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME. // Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME.
const log = createLogger({ const log = createLogger({
format: env["LOG_FORMAT"] === "json" ? "json" : "text", format: env["LOG_FORMAT"] === "json" ? "json" : "text",
@@ -150,10 +155,67 @@ async function main() {
// runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls. // runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls.
await runWithLog(log, async () => { await runWithLog(log, async () => {
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key"); if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
// Discovery imports every plugin module — and its dependencies — into *this* process, which holds
// the credential that may CREATE DATABASE/ROLE. Same move as server.ts, on the stronger secret.
delete process.env["PLUGIN_DB_ADMIN_URL"];
delete process.env["PLUGIN_DB_SECRET"];
const plugins = await discoverPlugins();
await provisionPluginStorage(env, plugins, log);
await seedAdminAndPermissions(env, plugins, log);
});
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
}
// Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the // A database and login role for each plugin that asked for one. It happens here because bootstrap
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit. // holds the stack's only provisioning credentials — web derives the same password and connects as
const declared = declaredPermissions(await discoverPlugins()).map((decl) => decl.name); // the plugin's own role.
export async function provisionPluginStorage(env: Env, plugins: Plugin[], log: Log, provision = provisionStorage): Promise<void> {
const ids = storagePluginIds(plugins);
const adminUrl = env["PLUGIN_DB_ADMIN_URL"];
// Still connect with nothing to provision, as long as storage is configured: uninstalling the
// last storage plugin is exactly when an orphaned database needs naming.
if (ids.length === 0 && !adminUrl) return;
if (!adminUrl) throw new Error(`bootstrap: PLUGIN_DB_ADMIN_URL must be set — these plugins declare storage: ${ids.join(", ")}`);
// Provisioned here, connected to from web: a different server means the role is created in one
// place and looked for in another, surfacing inside a plugin as "password authentication failed".
// Warned, not refused — web reaching a pooler that cannot run CREATE DATABASE is a legitimate split.
const mismatch = serverMismatch(adminUrl, env["PLUGIN_DB_URL"]);
if (mismatch) log.warn("PLUGIN_DB_ADMIN_URL and PLUGIN_DB_URL name different servers", { servers: mismatch });
const result = await provision({
adminUrl,
connectionLimit: resolvePluginDbConnectionLimit(env),
pluginIds: ids,
secret: resolvePluginDbSecret(env),
});
if (result.provisioned.length > 0) log.info("plugin storage provisioned", { databases: result.provisioned.join(", ") });
// Never dropped, so an uninstalled plugin's data outlives it — say so, or nobody can find it.
if (result.orphans.length > 0) {
log.warn("plugin databases no installed plugin claims", { databases: result.orphans.join(", ") });
}
}
// Describes the disagreement, or null when they agree (or when web's URL is unset — that is web's
// own boot error to raise, naming the plugin that wanted storage).
export function serverMismatch(adminUrl: string, webUrl: string | undefined): string | null {
if (!webUrl) return null;
const [admin, web] = [safeHostPort(adminUrl), safeHostPort(webUrl)];
if (admin === null || web === null || admin === web) return null; // a malformed URL fails in config.ts
return `${admin} vs ${web}`;
}
function safeHostPort(url: string): string | null {
try {
const parsed = new URL(url);
return `${parsed.hostname}:${parsed.port || "5432"}`;
} catch {
return null;
}
}
// Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
async function seedAdminAndPermissions(env: Env, plugins: Plugin[], log: Log): Promise<void> {
const declared = declaredPermissions(plugins).map((decl) => decl.name);
const { ignored, permissions } = seedPermissions(env["ADMIN_PERMISSIONS"], declared); const { ignored, permissions } = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
if (ignored.length > 0) { if (ignored.length > 0) {
log.warn("ignoring ADMIN_PERMISSIONS entries that are not <resource>:<action>", { ignored: ignored.join(", ") }); log.warn("ignoring ADMIN_PERMISSIONS entries that are not <resource>:<action>", { ignored: ignored.join(", ") });
@@ -171,8 +233,6 @@ async function main() {
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") }); log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw. // The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password })); console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
});
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
} }
if (process.argv[1] === fileURLToPath(import.meta.url)) await main(); if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
+1
View File
@@ -113,6 +113,7 @@ test("the code field guards a pasted space: one-time-code autofill + numeric inp
); );
assert.deepEqual(view.fields.find((f) => f.name === "code"), { assert.deepEqual(view.fields.find((f) => f.name === "code"), {
autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill
hint: "Digits only — no spaces.", // the pattern refusal alone reads as a bare "match the requested format"
icon: "i-shield", icon: "i-shield",
id: "field-code", id: "field-code",
inputmode: "numeric", inputmode: "numeric",
+2 -1
View File
@@ -11,6 +11,7 @@ import type { Flow, FlowType, UiNode } from "./kratos-public.ts";
export interface FlowField { export interface FlowField {
autocomplete?: string; autocomplete?: string;
error?: { text: string }; error?: { text: string };
hint?: string; // muted helper text under the input
icon?: string; // Lucide sprite id for the input icon?: string; // Lucide sprite id for the input
id: string; id: string;
inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code) inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code)
@@ -139,7 +140,7 @@ function toField(node: UiNode, name: string, type: string, t: Translate): FlowFi
...(autocomplete ? { autocomplete } : {}), ...(autocomplete ? { autocomplete } : {}),
...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}), ...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}),
...(icon ? { icon } : {}), ...(icon ? { icon } : {}),
...(isCode ? { inputmode: "numeric", pattern: "[0-9]*" } : {}), ...(isCode ? { hint: t("auth.field.code.hint"), inputmode: "numeric", pattern: "[0-9]*" } : {}),
...(node.attributes["required"] === true ? { required: true } : {}), ...(node.attributes["required"] === true ? { required: true } : {}),
...(value ? { value } : {}), ...(value ? { value } : {}),
}; };
+19 -3
View File
@@ -44,10 +44,11 @@ test("long-running Ory services declare readiness healthchecks", () => {
`${svc} probes :${port}/health/ready`); `${svc} probes :${port}/health/ready`);
}); });
test("web waits for kratos, keto and hydra to be healthy before starting", () => { test("web waits for kratos, keto, hydra and postgres to be healthy before starting", () => {
assert.match(webBlock, /depends_on:/, "web declares dependencies"); assert.match(webBlock, /depends_on:/, "web declares dependencies");
// hydra: the OAuth2 login/consent handler talks to its admin API. // hydra: the OAuth2 login/consent handler talks to its admin API. postgres: a plugin declaring
for (const svc of ["kratos", "keto", "hydra"]) // `storage` opens its connection in onBoot, before the server listens.
for (const svc of ["kratos", "keto", "hydra", "postgres"])
assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`), assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`),
`web waits for ${svc} healthy`); `web waits for ${svc} healthy`);
}); });
@@ -78,6 +79,21 @@ test("prod base supplies the app secret via env and mounts no source; dev overri
assert.match(compose, /POSTGRES_PASSWORD:\s*\$\{POSTGRES_PASSWORD\b/, "postgres password via env"); assert.match(compose, /POSTGRES_PASSWORD:\s*\$\{POSTGRES_PASSWORD\b/, "postgres password via env");
}); });
test("the provisioning superuser DSN reaches bootstrap only, never web", () => {
// web runs plugin code, which can read its own environment — so the credentials that may CREATE
// DATABASE/ROLE must never be there. web gets the credential-free base URL and derives each
// plugin's own password from the shared secret instead.
const boot = compose.slice(compose.indexOf("\n bootstrap:"));
const overrideWeb = override.slice(override.indexOf("\n web:"), override.indexOf("\n bootstrap:"));
assert.match(boot, /PLUGIN_DB_ADMIN_URL:/, "bootstrap is given the superuser DSN");
// Reordering the override's services would empty this slice, and every doesNotMatch below would
// then pass against "".
assert.ok(overrideWeb.includes("PLUGIN_DB_URL"), "sliced the dev override's web block");
for (const [name, block] of [["base", webBlock], ["dev override", overrideWeb]] as const)
assert.doesNotMatch(block, /PLUGIN_DB_ADMIN_URL/, `${name} web never sees it`);
assert.match(webBlock, /PLUGIN_DB_URL:\s*\$\{PLUGIN_DB_URL/, "base wires web's base URL from env");
});
test("a one-shot bootstrap seeds the stack before web starts", () => { test("a one-shot bootstrap seeds the stack before web starts", () => {
// MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin + // MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin +
// JWKS, then exits; web waits for it to complete. Live seeding is boot-verified. // JWKS, then exits; web waits for it to complete. Live seeding is boot-verified.
+38 -1
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { test } from "node:test"; import { test } from "node:test";
import { loadConfig } from "./config.ts"; import { loadConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "./config.ts";
// Explicit secure-secret enforcement (no environment sniffing): secrets are the only // Explicit secure-secret enforcement (no environment sniffing): secrets are the only
// thing a hardened deploy must supply. // thing a hardened deploy must supply.
@@ -9,6 +9,43 @@ const secureEnv = {
REQUIRE_SECURE_SECRETS: "true", REQUIRE_SECURE_SECRETS: "true",
}; };
// web reads the secret through loadConfig and bootstrap through resolvePluginDbSecret; the two
// deriving different passwords is invisible until a plugin's connection is refused at boot. Compose
// passes an unset variable through as "", which is the case that actually drifted.
test("web and bootstrap resolve the same plugin storage secret", () => {
for (const env of [{}, { PLUGIN_DB_SECRET: "" }, { PLUGIN_DB_SECRET: "a-real-secret" }]) {
assert.equal(loadConfig(env).pluginDbSecret, resolvePluginDbSecret(env), `for ${JSON.stringify(env)}`);
}
assert.match(resolvePluginDbSecret({ PLUGIN_DB_SECRET: "" }), /dev-insecure/); // empty is unset, not a secret
});
// bootstrap writes these passwords into Postgres, so it must refuse the publicly-known throwaway
// before creating a role with one — not leave web to notice afterwards.
test("bootstrap refuses a missing, empty or throwaway plugin storage secret when hardened", () => {
const hardened = { REQUIRE_SECURE_SECRETS: "true" };
for (const secret of [undefined, "", "dev-insecure-plugin-db-secret"]) {
const env = secret === undefined ? hardened : { ...hardened, PLUGIN_DB_SECRET: secret };
assert.throws(() => resolvePluginDbSecret(env), /PLUGIN_DB_SECRET/, `for ${JSON.stringify(secret)}`);
}
assert.equal(resolvePluginDbSecret({ ...hardened, PLUGIN_DB_SECRET: "a-real-secret" }), "a-real-secret");
});
// buildCredentials overwrites the userinfo, so a pasted admin DSN would *work* — and leave a
// privileged password in the process that runs plugin code. Refusing it is the whole guard.
test("PLUGIN_DB_URL carrying credentials is refused, not silently overwritten", () => {
assert.throws(() => loadConfig({ PLUGIN_DB_URL: "postgres://root:hunter2@db:5432/ory" }), /no username or password/);
assert.throws(() => loadConfig({ PLUGIN_DB_URL: "postgres://root@db:5432" }), /no username or password/);
assert.equal(loadConfig({ PLUGIN_DB_URL: "postgres://db:5432" }).pluginDbUrl, "postgres://db:5432");
assert.equal(loadConfig({}).pluginDbUrl, undefined); // unset ⇒ storage off
});
test("the per-role connection ceiling defaults to 10 and rejects nonsense", () => {
assert.equal(resolvePluginDbConnectionLimit({}), 10);
assert.equal(resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "25" }), 25);
assert.throws(() => resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "0" }), /positive integer/);
assert.throws(() => resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "ten" }), /positive integer/);
});
test("loads dev defaults when the environment is empty", () => { test("loads dev defaults when the environment is empty", () => {
const c = loadConfig({}); const c = loadConfig({});
assert.equal(c.port, 3000); assert.equal(c.port, 3000);
+39
View File
@@ -6,6 +6,35 @@
export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const; export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const;
export type LogLevel = (typeof LOG_LEVELS)[number]; export type LogLevel = (typeof LOG_LEVELS)[number];
const DEV_PLUGIN_DB_SECRET = "dev-insecure-plugin-db-secret";
// The one resolution both processes use — they must agree exactly, or web connects with a password
// the role was never given. Compose passes an unset variable through as "", so empty means unset.
// `enforce` says whether storage is actually in play: web once PLUGIN_DB_URL is configured,
// bootstrap once a plugin declares storage. Enforced, the throwaway is refused — bootstrap is what
// writes these passwords into Postgres, so it must refuse *before* creating a role with one.
export function resolvePluginDbSecret(env: Env, enforce?: boolean): string {
return readSecret(env, "PLUGIN_DB_SECRET", DEV_PLUGIN_DB_SECRET, enforce ?? readBool(env, "REQUIRE_SECURE_SECRETS", false));
}
// Only bootstrap provisions, so only bootstrap reads this; env still gets read in one place.
export function resolvePluginDbConnectionLimit(env: Env): number {
return readPosInt(env, "PLUGIN_DB_CONNECTION_LIMIT", 10);
}
// PLUGIN_DB_URL is web's, and web must never hold credentials that outrank a plugin's own role.
// Pasting the admin DSN here would otherwise work — buildCredentials overwrites the userinfo — and
// leave a superuser password in the environment plugin code can read.
function readCredentiallessUrl(env: Env, key: string): string | undefined {
const value = readOptionalUrl(env, key);
if (value === undefined) return undefined;
const url = new URL(value);
if (url.username || url.password) {
throw new Error(`config: ${key} must carry no username or password — each plugin connects as its own role`);
}
return value;
}
export interface Config { export interface Config {
appUrl: string | undefined; // canonical public URL; set ⇒ off-host visitors are redirected here. Unset ⇒ no redirect (explicit toggle) appUrl: string | undefined; // canonical public URL; set ⇒ off-host visitors are redirected here. Unset ⇒ no redirect (explicit toggle)
cacheTemplates: boolean; cacheTemplates: boolean;
@@ -24,7 +53,10 @@ export interface Config {
oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory) oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory)
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export) otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors) otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
pluginDbSecret: string; // derives each plugin's database password (src/plugin-host/storage.ts)
pluginDbUrl: string | undefined; // credential-free Postgres base URL; unset ⇒ plugin storage is off
port: number; port: number;
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;
@@ -150,7 +182,14 @@ export function loadConfig(env: Env = process.env): Config {
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5), oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"), otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"), otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
// Per-plugin storage. PLUGIN_DB_URL carries the server and its connection parameters but no
// credentials: the superuser DSN that provisions stays in bootstrap, so a plugin cannot read it
// out of web's environment. Unset ⇒ storage is off and a plugin declaring it fails loud at boot,
// which is also why the secret is only enforced once a URL is configured.
pluginDbSecret: resolvePluginDbSecret(env, requireSecure && Boolean(env["PLUGIN_DB_URL"])),
pluginDbUrl: readCredentiallessUrl(env, "PLUGIN_DB_URL"),
port: readPort(env), port: readPort(env),
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).
+5 -2
View File
@@ -26,6 +26,7 @@ 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 { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts"; import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
import { buildAuthRoutes } from "../auth/routes.ts"; import { buildAuthRoutes } from "../auth/routes.ts";
@@ -54,6 +55,7 @@ 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;
} }
@@ -88,6 +90,7 @@ 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);
@@ -259,9 +262,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, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) }); const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, 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, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) }); buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, 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.
+8 -2
View File
@@ -1,6 +1,7 @@
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";
@@ -31,8 +32,8 @@ export interface RequestContext {
// on off-site URLs. The host already does this for the chrome and its own redirects; a plugin // on off-site URLs. The host already does this for the chrome and its own redirects; a plugin
// wraps the hrefs it builds itself. // wraps the hrefs it builds itself.
localeHref(href: string): string; localeHref(href: string): string;
// Every installed locale, sorted. With `localeLabel` (from #plugin-api) it is what a plugin needs // Every installed locale, sorted. With `localeLabel` (from @plainpages/plugin-api) it is what a
// to build its own language picker; the host's own picker is already in the shell. // plugin needs to build its own language picker; the host's own picker is already in the shell.
locales: string[]; locales: string[];
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to // Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by // log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
@@ -42,6 +43,9 @@ 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")
@@ -67,6 +71,7 @@ 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;
@@ -96,6 +101,7 @@ 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
@@ -9,6 +9,7 @@ const messages = {
"auth.continue": "Continue", "auth.continue": "Continue",
// Kratos labels its own form fields; these translate the ones the built-in identity schema uses, // Kratos labels its own form fields; these translate the ones the built-in identity schema uses,
// keyed on the input name. A deployment's extra traits keep Kratos' label until a plugin covers them. // keyed on the input name. A deployment's extra traits keep Kratos' label until a plugin covers them.
"auth.field.code.hint": "Digits only — no spaces.",
"auth.field.email": "Email", "auth.field.email": "Email",
"auth.field.identifier": "Email", "auth.field.identifier": "Email",
"auth.field.password": "Password", "auth.field.password": "Password",
+1
View File
@@ -2,6 +2,7 @@ import type { CoreMessages } from "./en-US.ts";
const messages: CoreMessages = { const messages: CoreMessages = {
"auth.continue": "Fortsätt", "auth.continue": "Fortsätt",
"auth.field.code.hint": "Endast siffror — inga mellanslag.",
"auth.field.email": "E-postadress", "auth.field.email": "E-postadress",
"auth.field.identifier": "E-postadress", "auth.field.identifier": "E-postadress",
"auth.field.password": "Lösenord", "auth.field.password": "Lösenord",
+70 -19
View File
@@ -1,9 +1,10 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { test, type TestContext } from "node:test"; import { test, type TestContext } from "node:test";
import { discoverPlugins } from "./discovery.ts"; import { discoverPlugins } from "./discovery.ts";
import { HOST_API_VERSION } from "./plugin.ts";
// Write a throwaway plugins/ tree of `relpath → source` and clean it up after the test. Fixtures // Write a throwaway plugins/ tree of `relpath → source` and clean it up after the test. Fixtures
// default-export plain objects — definePlugin is identity, so a literal is an equivalent manifest. // default-export plain objects — definePlugin is identity, so a literal is an equivalent manifest.
@@ -19,7 +20,7 @@ function scaffold(t: TestContext, files: Record<string, string>): string {
} }
const full = (id: string): string => const full = (id: string): string =>
`export default { apiVersion: "1.0.0", nav: [{ id: "${id}:root", label: "${id}" }], ` + `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}" }], ` +
`routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`; `routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`;
test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => { test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => {
@@ -27,13 +28,19 @@ test("a missing plugins/ dir means zero plugins, not an error (clean clone)", as
}); });
test("discovers each folder's manifest, sorted, id derived from the folder name", async (t) => { test("discovers each folder's manifest, sorted, id derived from the folder name", async (t) => {
const dir = scaffold(t, { "beta/plugin.ts": full("beta"), "alpha/plugin.ts": full("alpha") }); const dir = scaffold(t, {
"beta/plugin.ts": full("beta"),
"alpha/plugin.ts": full("alpha"),
"gamma/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", storage: true };`,
});
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta"]); // deterministic order assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta", "gamma"]); // deterministic order
assert.equal(plugins[0]?.apiVersion, "1.0.0"); assert.equal(plugins[0]?.apiVersion, HOST_API_VERSION);
assert.equal(plugins[0]?.nav?.[0]?.label, "alpha"); assert.equal(plugins[0]?.nav?.[0]?.label, "alpha");
assert.equal(typeof plugins[0]?.routes?.[0]?.handler, "function"); // handlers survive import assert.equal(typeof plugins[0]?.routes?.[0]?.handler, "function"); // handlers survive import
assert.equal(plugins[0]?.storage, undefined); // storage is opt-in, never assumed
assert.equal(plugins[2]?.storage, true);
}); });
// Every per-plugin problem and every error-level conflict aborts boot with a message naming it. // Every per-plugin problem and every error-level conflict aborts boot with a message naming it.
@@ -45,20 +52,30 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
{ name: "no default export", files: { "named-only/plugin.ts": "export const x = 1;" }, match: /named-only.*default/s }, { name: "no default export", files: { "named-only/plugin.ts": "export const x = 1;" }, match: /named-only.*default/s },
{ name: "import throws", files: { "explodes/plugin.ts": "throw new Error('boom');" }, match: /explodes.*boom/s }, { name: "import throws", files: { "explodes/plugin.ts": "throw new Error('boom');" }, match: /explodes.*boom/s },
{ name: "incompatible apiVersion", files: { "future/plugin.ts": `export default { apiVersion: "2.0.0" };` }, match: /future.*apiVersion/s }, { name: "incompatible apiVersion", files: { "future/plugin.ts": `export default { apiVersion: "2.0.0" };` }, match: /future.*apiVersion/s },
{ name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "1.0.0", routes: "nope" };` }, match: /weird.*routes.*array/s }, { name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: "nope" };` }, match: /weird.*routes.*array/s },
{ name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "1.0.0", home: "nope" };` }, match: /weirdhome.*home.*function/s }, { name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: "nope" };` }, match: /weirdhome.*home.*function/s },
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s }, { name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
{ name: "non-boolean storage", files: { "weirdstore/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", storage: "postgres://db" };` }, match: /weirdstore.*storage.*boolean/s },
// The folder name becomes a Postgres identifier, which truncates past 63 bytes.
{ name: "a storage plugin whose folder name overflows a Postgres identifier", files: { [`${"a".repeat(57)}/plugin.ts`]: `export default { apiVersion: "${HOST_API_VERSION}", storage: true };` }, match: /storage.*56 characters/s },
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s }, { name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ }, { name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x: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: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s }, { name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s },
// 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: "1.0.0", 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: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
{ name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "1.0.0", 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: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s },
{ name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "1.0.0", 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: "${HOST_API_VERSION}", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s },
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ }, { 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: "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/ }, { name: "a plugin package.json that forgets type: module", files: { "cjs/package.json": `{ "name": "cjs" }`, "cjs/plugin.ts": full("cjs") }, match: /cjs.*"type": "module"/s },
{ name: "a plugin package.json that is not valid JSON", files: { "bent/package.json": `{`, "bent/plugin.ts": full("bent") }, match: /bent.*package\.json.*JSON/s },
{ name: "a plugin package.json holding null", files: { "nul/package.json": `null`, "nul/plugin.ts": full("nul") }, match: /nul.*"type": "module"/s },
// `npm install --prefix plugins` — the documented command with one path segment dropped.
{ name: "a package.json in the scan root itself", files: { "package.json": `{ "name": "oops" }`, "ok/plugin.ts": full("ok") }, match: /plugins\/package\.json must not exist/ },
{ name: "a node_modules in the scan root itself", files: { "node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "ok/plugin.ts": full("ok") }, match: /plugins\/node_modules must not exist/ },
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ html: "b" }) };` }, match: /home/ },
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
]; ];
for (const c of badCases) { for (const c of badCases) {
@@ -71,7 +88,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: "1.0.0", routes: [{ method: "GET", path: "/users", permission: "admin", handler: () => ({ html: "x" }) }] };` }); const dir = scaffold(t, { "admin/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/users", permission: "admin", handler: () => ({ html: "x" }) }] };` });
await assert.rejects(discoverPlugins({ dir }), (err: Error) => { 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
@@ -80,7 +97,7 @@ test("a discovery failure tells the operator their plugins/ copy may just be out
}); });
test("a route + nav node may be marked public and load fine", async (t) => { test("a route + nav node may be marked public and load fine", async (t) => {
const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` }); const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` });
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
assert.equal(plugins.length, 1); assert.equal(plugins.length, 1);
assert.equal(plugins[0]?.routes?.[0]?.public, true); assert.equal(plugins[0]?.routes?.[0]?.public, true);
@@ -95,15 +112,49 @@ test("`admin` is not reserved — the admin screens ship as a drop-in plugin mou
}); });
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => { test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` }); const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` });
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
assert.equal(plugins.length, 1); assert.equal(plugins.length, 1);
assert.equal(typeof plugins[0]?.home, "function"); assert.equal(typeof plugins[0]?.home, "function");
assert.equal(typeof plugins[0]?.dashboard, "function"); assert.equal(typeof plugins[0]?.dashboard, "function");
}); });
// Host deps sit at /node_modules, above every plugin scope, so the barrel resolves from a folder
// that has its own package.json (README → Plugin dependencies).
test("a plugin may carry its own package.json, node_modules and dependencies", async (t) => {
const dir = scaffold(t, {
"shop/package.json": `{ "name": "shop", "version": "0.0.0", "type": "module", "dependencies": { "price-tag": "1.0.0" } }`,
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`,
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
`export default definePlugin({ apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`,
});
const plugins = await discoverPlugins({ dir });
assert.deepEqual(plugins.map((p) => p.id), ["shop"]);
assert.deepEqual(await plugins[0]?.routes?.[0]?.handler(null as never), { html: "20 kr" });
});
test("a plugin folder may be a symlink", async (t) => {
const ownRepo = scaffold(t, { "my-plugin/plugin.ts": full("my-plugin") });
const dir = scaffold(t, {});
symlinkSync(join(ownRepo, "my-plugin"), join(dir, "linked"));
const plugins = await discoverPlugins({ dir });
assert.deepEqual(plugins.map((p) => p.id), ["linked"]); // the link name is the id, not the target's
});
test("a dangling plugin symlink fails loud rather than vanishing", async (t) => {
const dir = scaffold(t, {});
symlinkSync(join(dir, "gone"), join(dir, "broken"));
await assert.rejects(discoverPlugins({ dir }), /broken.*plugin\.ts/s);
});
test("a shared permission name only warns — both plugins still load", async (t) => { test("a shared permission name only warns — both plugins still load", async (t) => {
const shared = `export default { apiVersion: "1.0.0", permissions: [{ name: "shared:read" }] };`; const shared = `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "shared:read" }] };`;
const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared }); const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
const warnings: string[] = []; const warnings: string[] = [];
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } }); const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
+50 -4
View File
@@ -4,10 +4,12 @@
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics // error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
// (older-minor apiVersion, shared permission name) log and load continues. Folder name = id. // (older-minor apiVersion, shared permission name) log and load continues. Folder name = id.
import { existsSync, readdirSync } from "node:fs"; import { existsSync, readdirSync, readFileSync } from "node:fs";
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url"; import { fileURLToPath, pathToFileURL } from "node:url";
import { checkApiVersion, findConflicts, 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";
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
@@ -27,6 +29,14 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
const errors: string[] = []; const errors: string[] = [];
const plugins: Plugin[] = []; const plugins: Plugin[] = [];
// `npm install --prefix plugins` instead of `--prefix plugins/<id>`: the package.json becomes the
// scope for every plugin below it, and the node_modules outranks the host's own — barrel included.
for (const stray of ["node_modules", "package.json"]) {
if (existsSync(join(dir, stray))) {
errors.push(`plugins/${stray} must not exist — it sits above every plugin and shadows the host's own; delete plugins/{node_modules,package.json,package-lock.json} and install into plugins/<id>`);
}
}
for (const id of pluginFolders(dir)) { for (const id of pluginFolders(dir)) {
const fail = (msg: string): void => void errors.push(`plugins/${id}: ${msg}`); const fail = (msg: string): void => void errors.push(`plugins/${id}: ${msg}`);
@@ -37,6 +47,8 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
if (RESERVED_PLUGIN_IDS.has(id)) { fail(`"${id}" is a reserved id — it would shadow a built-in host route`); continue; } if (RESERVED_PLUGIN_IDS.has(id)) { fail(`"${id}" is a reserved id — it would shadow a built-in host route`); continue; }
const file = join(dir, id, "plugin.ts"); const file = join(dir, id, "plugin.ts");
if (!existsSync(file)) { fail("no plugin.ts found"); continue; } if (!existsSync(file)) { fail("no plugin.ts found"); continue; }
const packaging = packagingError(join(dir, id));
if (packaging) { fail(packaging); continue; }
let mod: { default?: unknown }; let mod: { default?: unknown };
try { try {
@@ -56,6 +68,13 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
const shape = shapeError(manifest); const shape = shapeError(manifest);
if (shape) { fail(shape); continue; } if (shape) { fail(shape); continue; }
// The folder name becomes a Postgres identifier, which truncates past 63 bytes — two long ids
// would then share one database. Only checked for a plugin that asked for storage.
if (manifest.storage === true && !isValidStoragePluginId(id)) {
fail(`declares storage, so its folder name must be at most ${MAX_STORAGE_PLUGIN_ID_LENGTH} characters`);
continue;
}
plugins.push({ ...manifest, id }); // identity is the folder, not the manifest plugins.push({ ...manifest, id }); // identity is the folder, not the manifest
} }
@@ -77,15 +96,36 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
return plugins; return plugins;
} }
// Subfolders of plugins/, sorted for deterministic load order + stable conflict messages. Hidden // Sorted for deterministic load order + stable conflict messages. A symlink counts as a folder, and
// entries (.git, .DS_Store, …) and non-directories are skipped — only folders are plugins. // one whose target the container cannot see trips "no plugin.ts found" rather than vanishing.
function pluginFolders(dir: string): string[] { function pluginFolders(dir: string): string[] {
return readdirSync(dir, { withFileTypes: true }) return readdirSync(dir, { withFileTypes: true })
.filter((e) => e.isDirectory() && !e.name.startsWith(".")) .filter((e) => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith(".") && e.name !== "node_modules")
.map((e) => e.name) .map((e) => e.name)
.sort(); .sort();
} }
// A barrel copy resolves before the host's, so its GuardError matches no `instanceof` here and a
// sign-in redirect becomes a 500.
function packagingError(folder: string): string | null {
if (existsSync(join(folder, "node_modules", "@plainpages", "plugin-api"))) {
return "ships its own copy of @plainpages/plugin-api — remove it; the host provides the one instance";
}
const file = join(folder, "package.json");
if (!existsSync(file)) return null;
let manifest: { type?: unknown } | null;
try {
manifest = JSON.parse(readFileSync(file, "utf8")) as { type?: unknown } | null;
} catch (err) {
return `package.json could not be read as JSON — ${messageOf(err)}`;
}
return manifest?.type === "module"
? null
: `package.json must set "type": "module" — npm writes no type, and Node then re-parses every file in the folder`;
}
function asManifest(value: unknown): PluginManifest | null { function asManifest(value: unknown): PluginManifest | null {
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as PluginManifest) : null; return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as PluginManifest) : null;
} }
@@ -100,6 +140,12 @@ function shapeError(manifest: PluginManifest): string | null {
for (const slot of ["home", "dashboard"] as const) { for (const slot of ["home", "dashboard"] as const) {
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`; if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
} }
// A truthy non-boolean (a DSN, say) must not quietly read as "provision me one".
if (manifest.storage !== undefined && typeof manifest.storage !== "boolean") return `"storage" must be a boolean`;
if (manifest.settings !== undefined) {
const settings = settingsDeclError(manifest.settings);
if (settings) return settings;
}
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs // `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
// "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous. // "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) { for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
+5 -2
View File
@@ -12,14 +12,17 @@ function plugin(id: string, hooks: PluginHooks): Plugin {
test("runBootHooks runs each onBoot in order, skips plugins without one, and a throw aborts", async () => { test("runBootHooks runs each onBoot in order, skips plugins without one, and a throw aborts", async () => {
const calls: string[] = []; const calls: string[] = [];
const scoped: string[] = []; // each hook is handed a context built for its own plugin
const bootContextFor = (built: Plugin) => { scoped.push(built.id); return {}; };
await runBootHooks([ await runBootHooks([
plugin("a", { onBoot: () => void calls.push("a") }), plugin("a", { onBoot: () => void calls.push("a") }),
plugin("b", {}), // no onBoot → skipped plugin("b", {}), // no onBoot → skipped
plugin("c", { onBoot: async () => void calls.push("c") }), plugin("c", { onBoot: async () => void calls.push("c") }),
]); ], bootContextFor);
assert.deepEqual(calls, ["a", "c"]); assert.deepEqual(calls, ["a", "c"]);
assert.deepEqual(scoped, ["a", "c"]); // and built only for the plugins that have one
await assert.rejects(runBootHooks([plugin("x", { onBoot: () => { throw new Error("boom"); } })]), /boom/); await assert.rejects(runBootHooks([plugin("x", { onBoot: () => { throw new Error("boom"); } })], () => ({})), /boom/);
}); });
test("runRequestHooks short-circuits on the first RouteResult (with its plugin); later hooks skipped", async () => { test("runRequestHooks short-circuits on the first RouteResult (with its plugin); later hooks skipped", async () => {
+8 -4
View File
@@ -4,11 +4,15 @@
// entirely when no plugin declares the hook, so the no-hooks hot path stays free. // entirely when no plugin declares the hook, so the no-hooks hot path stays free.
import type { RequestContext } from "../http/context.ts"; import type { RequestContext } from "../http/context.ts";
import type { Plugin, RouteResult } from "./plugin.ts"; import type { BootContext, Plugin, RouteResult } from "./plugin.ts";
// After discovery, before the server listens. A throw aborts boot. // After discovery, before the server listens. A throw aborts boot. Each hook gets a context built
export async function runBootHooks(plugins: Plugin[]): Promise<void> { // for its own plugin, so one plugin is never handed another's storage credentials.
for (const plugin of plugins) await plugin.hooks?.onBoot?.(); export async function runBootHooks(plugins: Plugin[], bootContextFor: (plugin: Plugin) => BootContext): Promise<void> {
for (const plugin of plugins) {
const onBoot = plugin.hooks?.onBoot;
if (onBoot) await onBoot(bootContextFor(plugin));
}
} }
// Before route matching. The first hook to return a RouteResult short-circuits the request — its // Before route matching. The first hook to return a RouteResult short-circuits the request — its
+8
View File
@@ -5,6 +5,14 @@ import assert from "node:assert/strict";
import test from "node:test"; import test from "node:test";
import * as api from "./plugin-api.ts"; import * as api from "./plugin-api.ts";
// Both specifiers must reach one module instance; the Dockerfile symlink is what makes them.
test("the barrel resolves by package name to this same module", async () => {
const asPackage = await import("@plainpages/plugin-api");
assert.equal(asPackage.GuardError, api.GuardError);
assert.equal(asPackage.definePlugin, api.definePlugin);
});
test("plugin-api re-exports the stable author value surface", () => { test("plugin-api re-exports the stable author value surface", () => {
for (const name of ["definePlugin", "can", "check", "GuardError", "requireSession", "parseListQuery", "readFormBody", "CSRF_FIELD", "tracedFetch", "Log", "safeUrl"]) { for (const name of ["definePlugin", "can", "check", "GuardError", "requireSession", "parseListQuery", "readFormBody", "CSRF_FIELD", "tracedFetch", "Log", "safeUrl"]) {
assert.ok(name in api && api[name as keyof typeof api] !== undefined, `missing export: ${name}`); assert.ok(name in api && api[name as keyof typeof api] !== undefined, `missing export: ${name}`);
+5 -1
View File
@@ -5,7 +5,11 @@
// a plugin should import from here, never reach into deeper modules. See README.md → Building plugins. // a plugin should import from here, never reach into deeper modules. See README.md → Building plugins.
export { definePlugin, isValidPermissionName } from "./plugin.ts"; export { definePlugin, isValidPermissionName } from "./plugin.ts";
export type { 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
// 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 { 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";
+4 -1
View File
@@ -80,12 +80,15 @@ 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"); // "1.0.0" vs "1.0.0" assert.equal(checkApiVersion(HOST_API_VERSION).level, "ok"); // the host always accepts its own version
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`);
} }
+42 -9
View File
@@ -6,9 +6,11 @@
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";
// Bump major on a breaking manifest/handler change, minor on an additive one. // The Plainpages release this contract ships in — see README → Contract versioning.
export const HOST_API_VERSION = "1.0.0"; export const HOST_API_VERSION = "0.2.0";
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT"; export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
@@ -60,26 +62,43 @@ export function declaredPermissions(plugins: Plugin[]): PermissionDecl[] {
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name)); return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
} }
// What onBoot receives. A hook declaring no parameter stays valid, so this may grow additively.
export type BootContext<S extends readonly SettingDecl[] = readonly SettingDecl[]> = {
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 { export interface PluginHooks<S extends readonly SettingDecl[] = readonly SettingDecl[]> {
onBoot?: () => Promise<void> | void; // after discovery, before the server listens onBoot?: (host: BootContext<S>) => 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 { export interface PluginManifest<S extends readonly SettingDecl[] = readonly SettingDecl[]> {
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; hooks?: PluginHooks<S>;
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.
// The host provisions and locks it down but owns no schema inside it, and never drops it.
storage?: boolean;
} }
// A discovered plugin: the manifest plus the `id` the host read from the folder name. Mounted // A discovered plugin: the manifest plus the `id` the host read from the folder name. Mounted
@@ -90,7 +109,9 @@ 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.
export function definePlugin(manifest: PluginManifest): PluginManifest { // The `const` parameter captures the literal `settings`, so onBoot receives each key at its declared
// 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;
} }
@@ -149,13 +170,17 @@ 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) {
return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — newer features available` }; // Pre-1.0 the major is pinned at 0, so a minor is the only slot a breaking change can use.
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"; kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route" | "setting";
level: "error" | "warn"; level: "error" | "warn";
message: string; message: string;
plugins: string[]; // unique ids involved plugins: string[]; // unique ids involved
@@ -196,6 +221,14 @@ 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;
} }
+177
View File
@@ -0,0 +1,177 @@
// 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
@@ -0,0 +1,259 @@
// 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 };
}
}
+44
View File
@@ -0,0 +1,44 @@
// The connecting half of plugin storage: runs the DDL storage.ts plans. Imported by bootstrap
// alone — the only process holding superuser credentials, which is why the driver stops here.
import postgres from "postgres";
import { derivePassword, orphanNames, provisionSql, storageName } from "./storage.ts";
export interface ProvisionOptions {
adminUrl: string; // needs CREATEDB + CREATEROLE, not superuser
connectionLimit: number;
pluginIds: string[];
secret: string;
}
export interface ProvisionResult {
orphans: string[]; // a plugin_ database no installed plugin claims; reported, never dropped
provisioned: string[];
}
export async function provisionStorage(options: ProvisionOptions): Promise<ProvisionResult> {
// Notices are left to surface: a REVOKE the account cannot perform only *warns*, and silencing
// that would mean reporting a locked-down database that is still open to PUBLIC.
const sql = postgres(options.adminUrl, { connect_timeout: 10, max: 1 });
try {
const provisioned: string[] = [];
for (const pluginId of options.pluginIds) {
const name = storageName(pluginId);
const [role] = await sql`SELECT 1 FROM pg_roles WHERE rolname = ${name}`;
const [database] = await sql`SELECT 1 FROM pg_database WHERE datname = ${name}`;
const plan = provisionSql({
connectionLimit: options.connectionLimit,
databaseExists: database !== undefined,
name,
password: derivePassword(options.secret, pluginId),
roleExists: role !== undefined,
});
for (const statement of plan) await sql.unsafe(statement); // provisionSql quotes what it interpolates
provisioned.push(name);
}
const existing = await sql<{ datname: string }[]>`SELECT datname FROM pg_database`;
return { orphans: orphanNames(existing.map((row) => row.datname), provisioned), provisioned };
} finally {
await sql.end({ timeout: 5 }); // a wedged connection would otherwise hang the boot web waits on
}
}
+220
View File
@@ -0,0 +1,220 @@
// Guards the per-plugin storage rules: the shared database/role name, the derived password, the DSN
// a plugin receives and the provisioning statements. The integration test runs only when a superuser
// DSN is supplied, so the unit suite needs no Postgres.
import { test } from "node:test";
import assert from "node:assert/strict";
import postgres from "postgres";
import { provisionStorage } from "./storage-provisioning.ts";
import {
buildCredentials,
derivePassword,
isValidStoragePluginId,
MAX_STORAGE_PLUGIN_ID_LENGTH,
orphanNames,
provisionSql,
quoteIdentifier,
quoteLiteral,
storageName,
storagePluginIds,
} from "./storage.ts";
const SECRET = "a-test-secret";
test("the database and the role share one plugin_-prefixed name", () => {
assert.equal(storageName("things"), "plugin_things");
assert.equal(storageName("my-plugin"), "plugin_my-plugin");
});
test("a storage plugin's id must leave the identifier under Postgres' 63 bytes", () => {
assert.equal(MAX_STORAGE_PLUGIN_ID_LENGTH, 56); // 63 - "plugin_"
assert.ok(isValidStoragePluginId("a".repeat(MAX_STORAGE_PLUGIN_ID_LENGTH)));
assert.ok(!isValidStoragePluginId("a".repeat(MAX_STORAGE_PLUGIN_ID_LENGTH + 1)));
});
test("the password is derived, so the same one is reachable without storing it", () => {
const derived = derivePassword(SECRET, "things");
assert.equal(derived, derivePassword(SECRET, "things"));
assert.notEqual(derived, derivePassword(SECRET, "other"));
assert.notEqual(derived, derivePassword("a-rotated-secret", "things"));
assert.match(derived, /^[A-Za-z0-9_-]{43}$/); // base64url of 32 bytes — needs no escaping in a DSN
});
test("credentials name the plugin's own database, user and password", () => {
const credentials = buildCredentials("postgres://postgres:5432", "things", SECRET);
assert.deepEqual(credentials, {
database: "plugin_things",
host: "postgres",
password: derivePassword(SECRET, "things"),
port: 5432,
url: `postgres://plugin_things:${derivePassword(SECRET, "things")}@postgres:5432/plugin_things`,
user: "plugin_things",
});
});
test("the base URL's connection parameters survive into the DSN", () => {
const credentials = buildCredentials("postgres://db.example?sslmode=require", "things", SECRET);
assert.equal(credentials.port, 5432); // absent ⇒ Postgres' default, never NaN
assert.equal(credentials.host, "db.example");
assert.match(credentials.url, /@db\.example\/plugin_things\?sslmode=require$/);
});
test("quoting doubles an embedded quote", () => {
assert.equal(quoteIdentifier('we"ird'), '"we""ird"');
assert.equal(quoteLiteral("we'ird"), "'we''ird'");
});
const ATTRIBUTES = "LOGIN NOCREATEDB NOCREATEROLE CONNECTION LIMIT 10";
test("only the plugins that asked for storage are provisioned", () => {
assert.deepEqual(
storagePluginIds([{ apiVersion: "1.0.0", id: "a", storage: true }, { apiVersion: "1.0.0", id: "b" }, { apiVersion: "1.0.0", id: "c", storage: true }]),
["a", "c"],
);
});
test("an orphan is a plugin_ database no installed plugin claims", () => {
const existing = ["plugin_gone", "plugin_here", "kratos", "ory"];
assert.deepEqual(orphanNames(existing, ["plugin_here"]), ["plugin_gone"]); // Ory's are not ours to report
assert.deepEqual(orphanNames(existing, ["plugin_here", "plugin_gone"]), []);
});
test("provisioning creates the role and the database when neither exists", () => {
const plan = { connectionLimit: 10, databaseExists: false, name: "plugin_things", password: "pw", roleExists: false };
assert.deepEqual(provisionSql(plan), [
`CREATE ROLE "plugin_things" ${ATTRIBUTES} PASSWORD 'pw'`,
`GRANT "plugin_things" TO CURRENT_USER`, // else a CREATEROLE (non-superuser) account cannot own it
`CREATE DATABASE "plugin_things" OWNER "plugin_things"`,
`REVOKE ALL ON DATABASE "plugin_things" FROM PUBLIC`,
`GRANT ALL PRIVILEGES ON DATABASE "plugin_things" TO "plugin_things"`,
]);
});
// Re-asserting the attributes, not just the password, is what makes "idempotent" mean the role
// cannot drift — a CREATEDB granted by hand out of band is taken back on the next boot.
test("re-provisioning re-asserts every attribute and creates nothing twice", () => {
const plan = { connectionLimit: 10, databaseExists: true, name: "plugin_things", password: "rotated", roleExists: true };
assert.deepEqual(provisionSql(plan), [
`ALTER ROLE "plugin_things" WITH ${ATTRIBUTES} PASSWORD 'rotated'`,
`REVOKE ALL ON DATABASE "plugin_things" FROM PUBLIC`,
`GRANT ALL PRIVILEGES ON DATABASE "plugin_things" TO "plugin_things"`,
]);
});
// The limit is interpolated unquoted, and Postgres reads a negative one as "unlimited".
test("a connection limit that is not a positive integer is refused, not interpolated", () => {
const plan = { databaseExists: false, name: "plugin_things", password: "pw", roleExists: false };
for (const connectionLimit of [1.5, 0, -1, Number.NaN]) {
assert.throws(() => provisionSql({ ...plan, connectionLimit }), /positive integer/, `for ${connectionLimit}`);
}
});
// --- Integration: the statements above, against a real Postgres -----------------------
// Opt-in via PLUGIN_DB_ADMIN_URL (a superuser DSN); the unit gate runs no Postgres. What the unit
// tests cannot prove lives here: the owner may create tables, and a peer role is locked out.
const ADMIN_URL = process.env["PLUGIN_DB_ADMIN_URL"] ?? "";
const integration = ADMIN_URL ? {} : { skip: "set PLUGIN_DB_ADMIN_URL to a superuser DSN to run" };
function baseUrlOf(adminUrl: string): string {
const url = new URL(adminUrl);
url.username = "";
url.password = "";
url.pathname = "";
return url.href;
}
async function queryAs(url: string, statement: string): Promise<unknown> {
const sql = postgres(url, { connect_timeout: 10, max: 1, onnotice: () => {} });
try {
return await sql.unsafe(statement);
} finally {
await sql.end();
}
}
// Drops what a previous run may have left behind: `finally` does not survive a SIGKILL or a
// cancelled CI job, and the leftovers would otherwise fail every later run on the same server.
async function dropStorage(admin: postgres.Sql, ids: string[]): Promise<void> {
for (const id of ids) {
const name = quoteIdentifier(storageName(id));
await admin.unsafe(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`);
await admin.unsafe(`DROP ROLE IF EXISTS ${name}`);
}
}
test("provisions a database its plugin can use and a peer plugin cannot reach", integration, async () => {
const ids = ["storage-itest-a", "storage-itest-b"];
const base = baseUrlOf(ADMIN_URL);
const admin = postgres(ADMIN_URL, { connect_timeout: 10, max: 1, onnotice: () => {} });
try {
await dropStorage(admin, ids);
await provisionStorage({ adminUrl: ADMIN_URL, connectionLimit: 10, pluginIds: ids, secret: SECRET });
const owner = buildCredentials(base, "storage-itest-a", SECRET);
await queryAs(owner.url, "CREATE TABLE IF NOT EXISTS notes (body text)");
await queryAs(owner.url, "INSERT INTO notes (body) VALUES ('persisted')");
const rows = (await queryAs(owner.url, "SELECT body FROM notes")) as { body: string }[];
assert.deepEqual(rows.map((row) => row.body), ["persisted"]);
// A peer holds valid credentials for its OWN database and still cannot reach this one.
const peer = new URL(buildCredentials(base, "storage-itest-b", SECRET).url);
peer.pathname = `/${storageName("storage-itest-a")}`;
await assert.rejects(queryAs(peer.href, "SELECT 1"), /permission denied|not permitted/i);
// Re-running is idempotent, and a rotated secret lands on the existing role.
const rerun = await provisionStorage({ adminUrl: ADMIN_URL, connectionLimit: 10, pluginIds: ids, secret: "a-rotated-secret" });
// Scoped to this test's own ids: another plugin's database on the same server is not this
// test's business, and asserting otherwise would make the suite order-dependent.
for (const id of ids) assert.ok(!rerun.orphans.includes(storageName(id)), `${id} is still installed`);
const rotated = buildCredentials(base, "storage-itest-a", "a-rotated-secret");
const kept = (await queryAs(rotated.url, "SELECT body FROM notes")) as { body: string }[];
assert.deepEqual(kept.map((row) => row.body), ["persisted"]); // rotating the secret keeps the data
await assert.rejects(queryAs(owner.url, "SELECT 1"), /password authentication failed/i);
// Uninstalling drops nothing, so what is left behind must be named — including when the LAST
// storage plugin goes and there is nothing left to provision.
const uninstalled = await provisionStorage({ adminUrl: ADMIN_URL, connectionLimit: 10, pluginIds: [], secret: "a-rotated-secret" });
for (const id of ids) assert.ok(uninstalled.orphans.includes(storageName(id)), `${id}'s database is reported`);
} finally {
try {
await dropStorage(admin, ids);
} finally {
await admin.end({ timeout: 5 }); // its own finally, or a failed DROP leaks the connection
}
}
});
// README tells an operator CREATEDB + CREATEROLE is enough and superuser is more than it needs.
// That is a promise about their production credentials, so prove it rather than assert it.
test("provisions through a CREATEDB + CREATEROLE account, without superuser", integration, async () => {
const pluginId = "storage-itest-lowpriv";
const provisioner = "storage-itest-provisioner";
const admin = postgres(ADMIN_URL, { connect_timeout: 10, max: 1, onnotice: () => {} });
try {
// The fresh provisioner below holds no ADMIN option on a role an earlier run left behind, so a
// leftover would fail the ALTER branch rather than the code being wrong.
await dropStorage(admin, [pluginId]);
await admin.unsafe(`DROP ROLE IF EXISTS ${quoteIdentifier(provisioner)}`);
await admin.unsafe(`CREATE ROLE ${quoteIdentifier(provisioner)} LOGIN CREATEDB CREATEROLE PASSWORD 'itest-provisioner'`);
const asProvisioner = new URL(ADMIN_URL);
asProvisioner.username = provisioner;
asProvisioner.password = "itest-provisioner";
const provision = () => provisionStorage({ adminUrl: asProvisioner.href, connectionLimit: 10, pluginIds: [pluginId], secret: SECRET });
await provision();
// Twice: the second run takes the ALTER branch, where naming a superuser-only attribute would
// fail — i.e. every redeploy after the one that worked.
await provision();
const owner = buildCredentials(baseUrlOf(ADMIN_URL), pluginId, SECRET);
await queryAs(owner.url, "CREATE TABLE IF NOT EXISTS notes (body text)");
const rows = (await queryAs(owner.url, "SELECT 1 AS ok")) as { ok: number }[];
assert.deepEqual(rows.map((row) => row.ok), [1]); // the plugin owns and can use what it was given
} finally {
try {
await dropStorage(admin, [pluginId]);
await admin.unsafe(`DROP ROLE IF EXISTS ${quoteIdentifier(provisioner)}`);
} finally {
await admin.end({ timeout: 5 });
}
}
});
+90
View File
@@ -0,0 +1,90 @@
// Per-plugin Postgres storage: the naming, credential and DDL rules (README → Plugin storage).
// Pure — the connecting half lives in storage-provisioning.ts, so `web` never loads a driver.
import { createHmac } from "node:crypto";
import type { Plugin } from "./plugin.ts";
// Database and role share one name, so reconnecting needs nothing looked up. The prefix also keeps
// a plugin id from ever naming an Ory database.
export const NAME_PREFIX = "plugin_";
// Postgres truncates an identifier at 63 bytes, which would silently collide two long ids.
export const MAX_STORAGE_PLUGIN_ID_LENGTH = 63 - NAME_PREFIX.length;
export interface StorageCredentials {
database: string;
host: string;
password: string;
port: number;
url: string;
user: string;
}
export function storageName(pluginId: string): string {
return `${NAME_PREFIX}${pluginId}`;
}
export function isValidStoragePluginId(pluginId: string): boolean {
return Buffer.byteLength(pluginId) <= MAX_STORAGE_PLUGIN_ID_LENGTH; // Postgres counts bytes, not characters
}
export function storagePluginIds(plugins: Plugin[]): string[] {
return plugins.filter((plugin) => plugin.storage).map((plugin) => plugin.id);
}
// Derived, never stored — which is what keeps the host free of state it would have to persist.
// Whoever holds the secret holds every plugin's database.
export function derivePassword(secret: string, pluginId: string): string {
return createHmac("sha256", secret).update(pluginId).digest("base64url");
}
// `baseUrl` names the server and its connection parameters, and carries no credentials of its own.
export function buildCredentials(baseUrl: string, pluginId: string, secret: string): StorageCredentials {
const name = storageName(pluginId);
const password = derivePassword(secret, pluginId);
const url = new URL(baseUrl);
url.username = name;
url.password = password;
url.pathname = `/${name}`;
return { database: name, host: url.hostname, password, port: Number(url.port) || 5432, url: url.href, user: name };
}
// CREATE ROLE/DATABASE bind no parameters, so the name and password are quoted into the statement.
export function quoteIdentifier(name: string): string {
return `"${name.replaceAll('"', '""')}"`;
}
export function quoteLiteral(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
export function orphanNames(existing: string[], provisioned: string[]): string[] {
return existing.filter((name) => name.startsWith(NAME_PREFIX) && !provisioned.includes(name)).sort();
}
export interface ProvisionPlan {
connectionLimit: number;
databaseExists: boolean;
name: string;
password: string;
roleExists: boolean;
}
export function provisionSql(plan: ProvisionPlan): string[] {
// Interpolated unquoted, and Postgres reads a negative limit as "unlimited" — the opposite of the point.
if (!Number.isSafeInteger(plan.connectionLimit) || plan.connectionLimit < 1) {
throw new Error(`storage: connectionLimit must be a positive integer, got ${plan.connectionLimit}`);
}
const identifier = quoteIdentifier(plan.name);
// No NOSUPERUSER: naming SUPERUSER in an ALTER is superuser-only, and CREATE defaults to it anyway.
const attributes = `LOGIN NOCREATEDB NOCREATEROLE CONNECTION LIMIT ${plan.connectionLimit} PASSWORD ${quoteLiteral(plan.password)}`;
return [
plan.roleExists ? `ALTER ROLE ${identifier} WITH ${attributes}` : `CREATE ROLE ${identifier} ${attributes}`,
// CREATE DATABASE ... OWNER needs SET ROLE on the owner, and PG16+ gives a CREATEROLE account
// ADMIN but *not* SET on the roles it creates — so it grants itself membership first. A
// superuser could skip this; issuing it anyway is what keeps a least-privilege account working.
...(plan.databaseExists ? [] : [`GRANT ${identifier} TO CURRENT_USER`, `CREATE DATABASE ${identifier} OWNER ${identifier}`]),
`REVOKE ALL ON DATABASE ${identifier} FROM PUBLIC`,
`GRANT ALL PRIVILEGES ON DATABASE ${identifier} TO ${identifier}`,
];
}
+1 -1
View File
@@ -1,6 +1,6 @@
// System capabilities: privileged host services a first-party/system plugin (the built-in admin // System capabilities: privileged host services a first-party/system plugin (the built-in admin
// screens are the reference consumer) needs but an ordinary domain plugin does not — the Ory admin // screens are the reference consumer) needs but an ordinary domain plugin does not — the Ory admin
// clients and the instant-revoke hook. Exposed on ctx.system and re-exported via #plugin-api. // clients and the instant-revoke hook. Exposed on ctx.system and re-exported via @plainpages/plugin-api.
// //
// Every field is optional: it is present only when the host wired that dependency (Ory configured, // Every field is optional: it is present only when the host wired that dependency (Ory configured,
// denylist enabled), and ctx.system itself is undefined when the host wired none. A plugin must // denylist enabled), and ctx.system itself is undefined when the host wired none. A plugin must
+34 -2
View File
@@ -3,14 +3,46 @@
// verified by booting postgres in CI/e2e; this catches edits. // verified by booting postgres in CI/e2e; this catches edits.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { readFileSync } from "node:fs"; import { readdirSync, readFileSync } from "node:fs";
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8"); const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
const ORY_DATABASES = ["hydra", "keto", "kratos"]; // one DB per Ory service const ORY_DATABASES = ["hydra", "keto", "kratos"]; // one DB per Ory service
test("init SQL gives each Ory service its own database", () => { function sourceFiles(dir = "src"): string[] {
const out: string[] = [];
for (const entry of readdirSync(new URL(`../${dir}/`, import.meta.url), { withFileTypes: true })) {
if (entry.isDirectory()) out.push(...sourceFiles(`${dir}/${entry.name}`));
else if (entry.name.endsWith(".ts")) out.push(`${dir}/${entry.name}`);
}
return out.sort();
}
test("init SQL gives each Ory service its own database, and leaves plugin databases to bootstrap", () => {
const sql = read("ory/postgres/init/init.sql"); const sql = read("ory/postgres/init/init.sql");
for (const db of ORY_DATABASES) { for (const db of ORY_DATABASES) {
assert.match(sql, new RegExp(`CREATE DATABASE ${db}\\b`, "i"), `creates ${db}`); assert.match(sql, new RegExp(`CREATE DATABASE ${db}\\b`, "i"), `creates ${db}`);
} }
// This file runs once, on an empty data dir — a plugin database added here would never appear for
// a plugin dropped in later. bootstrap provisions them on every boot instead.
assert.doesNotMatch(sql, /plugin_/i, "no plugin database is seeded here");
// PUBLIC keeps CONNECT unless it is revoked, which would put every plugin role on the auth plane.
for (const db of ORY_DATABASES) {
assert.match(sql, new RegExp(`REVOKE CONNECT ON DATABASE ${db} FROM PUBLIC`, "i"), `${db} is closed to PUBLIC`);
}
});
// AGENTS.md records that the driver runs the provisioning DDL in bootstrap and nothing else. A
// single value imported from the wrong module puts it in web's graph without changing behaviour,
// so nothing but this would notice.
test("the Postgres driver reaches bootstrap only, never web's import graph", () => {
const files = sourceFiles();
assert.ok(files.length > 40, "walks the source tree");
assert.deepEqual(
files.filter((f) => /^import .*"postgres"/m.test(read(f))), // an import line, not a mention of one
["src/plugin-host/storage-provisioning.ts", "src/plugin-host/storage.test.ts"],
);
assert.deepEqual(
files.filter((f) => !f.endsWith(".test.ts") && /from "[^"]*storage-provisioning\.ts"/.test(read(f))),
["src/auth/bootstrap.ts"],
);
}); });
+45 -2
View File
@@ -2,6 +2,7 @@ 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";
@@ -13,8 +14,14 @@ 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";
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
// The storage secret is in `config` now, so drop it from the environment before ANY plugin code
// runs: a plugin module's top level evaluates during discovery, long before onBoot. Defence in
// depth, not a boundary (AGENTS.md) — and only ever move this line earlier, never later.
delete process.env["PLUGIN_DB_SECRET"];
// App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it // App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
// per request for access logging + a trace span (src/http/app.ts); console-only otherwise. // per request for access logging + a trace span (src/http/app.ts); console-only otherwise.
const log = createLogger({ format: config.logFormat, level: config.logLevel, otlpEndpoint: config.otlpEndpoint, otlpProtocol: config.otlpProtocol, serviceName: config.serviceName }); const log = createLogger({ format: config.logFormat, level: config.logLevel, otlpEndpoint: config.otlpEndpoint, otlpProtocol: config.otlpProtocol, serviceName: config.serviceName });
@@ -44,7 +51,42 @@ log.info("plugins discovered", { count: plugins.length, ids: plugins.map((p) =>
const i18n = createI18n(await loadI18n({ logger: log, pluginIds: plugins.map((p) => p.id) })); const i18n = createI18n(await loadI18n({ logger: log, pluginIds: plugins.map((p) => p.id) }));
log.info("locales loaded", { locales: i18n.available.join(", ") }); log.info("locales loaded", { locales: i18n.available.join(", ") });
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot // A plugin's database credentials are derived, never stored — so the only thing that can be missing
// is the server itself. Refuse at boot rather than at that plugin's first query, hours later.
const pluginDbUrl = config.pluginDbUrl;
const declaresStorage = storagePluginIds(plugins);
if (declaresStorage.length > 0 && pluginDbUrl === undefined) {
throw new Error(`config: PLUGIN_DB_URL must be set — these plugins declare storage: ${declaresStorage.join(", ")}`);
}
const storageCredentials = new Map<string, StorageCredentials>();
if (pluginDbUrl !== undefined) {
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
// or missing value is refused here rather than at that plugin's first use, hours later.
const settings = resolveSettings(plugins, process.env, { requireSecureSecrets: config.requireSecureSecrets });
if (settings.errors.length > 0) throw new Error(`Plugin settings:\n${settings.errors.map((e) => ` - ${e}`).join("\n")}`);
// 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.
await runBootHooks(plugins, (plugin) => {
const storage = storageCredentials.get(plugin.id);
const values = settings.values.get(plugin.id);
return { ...(values ? { settings: values } : {}), ...(storage ? { storage } : {}) };
});
const server = createApp({ const server = createApp({
// Canonical-host redirect target (off-host GET/HEAD visitors are sent here). Opt-in: omitted unless // Canonical-host redirect target (off-host GET/HEAD visitors are sent here). Opt-in: omitted unless
@@ -64,8 +106,9 @@ 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", { port: config.port, url: config.appUrl ?? `http://localhost:${config.port}` }); log.info("listening", { apiVersion: HOST_API_VERSION, 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
+17 -2
View File
@@ -1,16 +1,20 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
import { join } from "node:path"; import { join } from "node:path";
import { test, type TestContext } from "node:test"; import { test, type TestContext } from "node:test";
import { DEFAULT_MENU, loadMenuConfig } from "./menu-config.ts"; import { DEFAULT_MENU, loadMenuConfig } from "./menu-config.ts";
// Write a throwaway menu.ts (a plain object — defineMenu is identity) and clean it up after. // Write a throwaway menu.ts (a plain object — defineMenu is identity) and clean it up after.
function scaffold(t: TestContext, source: string): string { function scaffold(t: TestContext, source: string, strays: string[] = []): string {
const dir = mkdtempSync(join(tmpdir(), "pp-menu-")); const dir = mkdtempSync(join(tmpdir(), "pp-menu-"));
t.after(() => rmSync(dir, { force: true, recursive: true })); t.after(() => rmSync(dir, { force: true, recursive: true }));
const file = join(dir, "menu.ts"); const file = join(dir, "menu.ts");
writeFileSync(file, source); writeFileSync(file, source);
for (const stray of strays) {
if (stray.endsWith(".json")) writeFileSync(join(dir, stray), "{}");
else mkdirSync(join(dir, stray), { recursive: true });
}
return file; return file;
} }
@@ -37,3 +41,14 @@ test("loadMenuConfig fails loud on a malformed config", async (t) => {
await assert.rejects(loadMenuConfig({ file: scaffold(t, `export default { branding: { theme: "neon" } };`) }), /theme/); await assert.rejects(loadMenuConfig({ file: scaffold(t, `export default { branding: { theme: "neon" } };`) }), /theme/);
await assert.rejects(loadMenuConfig({ file: scaffold(t, `export default { override: { hide: "teams" } };`) }), /hide.*array/s); await assert.rejects(loadMenuConfig({ file: scaffold(t, `export default { override: { hide: "teams" } };`) }), /hide.*array/s);
}); });
test("loadMenuConfig refuses a stray package.json or node_modules beside the config", async (t) => {
const valid = `export default { branding: { name: "Acme Ops" } };`;
for (const stray of ["node_modules", "package.json"]) {
await assert.rejects(
loadMenuConfig({ file: scaffold(t, valid, [stray]) }),
new RegExp(`config/${stray.replace(".", "\\.")} must not exist.*delete`, "s"),
);
}
});
+8
View File
@@ -50,6 +50,14 @@ export async function loadMenuConfig(options: LoadMenuOptions = {}): Promise<Men
const file = options.file ?? MENU_CONFIG_FILE; const file = options.file ?? MENU_CONFIG_FILE;
if (!existsSync(file)) return DEFAULT_MENU; // clean clone: no central override if (!existsSync(file)) return DEFAULT_MENU; // clean clone: no central override
// Guarded before the import: Node's own ERR_PACKAGE_IMPORT_NOT_DEFINED names neither cause nor remedy.
const dir = dirname(file);
for (const stray of ["node_modules", "package.json"]) {
if (existsSync(join(dir, stray))) {
throw new Error(`config/${stray} must not exist — it makes config/ its own package scope, so the #menu-config import in config/menu.ts no longer resolves; delete config/{node_modules,package.json,package-lock.json} and keep config/ a plain dir`);
}
}
let mod: { default?: unknown }; let mod: { default?: unknown };
try { try {
mod = await import(pathToFileURL(file).href); mod = await import(pathToFileURL(file).href);
+8 -5
View File
@@ -2,20 +2,18 @@
## Unfinnished work ## Unfinnished work
- [ ] Add a way to configure plugins directly when installing. Most reasonable is an .env file in the plugin folder, I think, but I am open to suggestions.
- [ ] 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.
- [ ] A grant whose plugin was uninstalled is invisible and unremovable in the GUI. `grantDiff` deliberately never revokes an undeclared name, but nothing *shows* it either — so it can't be audited or cleaned, and reinstalling that plugin silently reactivates access nobody remembers granting. Sketch: a read-only "held, but no installed plugin offers this" list with a remove action. - [ ] A grant whose plugin was uninstalled is invisible and unremovable in the GUI. `grantDiff` deliberately never revokes an undeclared name, but nothing *shows* it either — so it can't be audited or cleaned, and reinstalling that plugin silently reactivates access nobody remembers granting. Sketch: a read-only "held, but no installed plugin offers this" list with a remove action.
- [ ] A plugin may gate a route on a permission it never declares — declaring stays optional on purpose. The cost is a dead end: the picker is built from declarations only, so that route is ungrantable from the GUI with no boot error and a permanent 403 as the operator's only clue. Sketch: a discovery *warning* (not an error) naming the gated-but-undeclared permission. - [ ] A plugin may gate a route on a permission it never declares — declaring stays optional on purpose. The cost is a dead end: the picker is built from declarations only, so that route is ungrantable from the GUI with no boot error and a permanent 403 as the operator's only clue. Sketch: a discovery *warning* (not an error) naming the gated-but-undeclared permission.
- [ ] Saving permissions gives no confirmation, and a partial failure is silent. `applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a half-applied set behind the generic error page; and a successful save is indistinguishable from "nothing changed". The `alert alert-pos` pattern the recovery-code banner uses is already available. - [ ] Saving permissions gives no confirmation, and a partial failure is silent. `applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a half-applied set behind the generic error page; and a successful save is indistinguishable from "nothing changed". The `alert alert-pos` pattern the recovery-code banner uses is already available.
- [ ] The seeded admin@plainpages.local is assigned twice to the same permission; should only be once.
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone. - [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
- [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks its most logic-bearing file (`console-guard.ts`). Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither. - [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks its most logic-bearing file (`console-guard.ts`). Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither.
- [ ] Decide whether Playwright's `workers` should be pinned. Unset, it sizes the pool from `os.cpus()`, which reports the host's cores regardless of a container CPU quota — and with `retries: 0` a starved runner turns a slow test straight into a red gate. Fine on the current act_runner; revisit if CI ever runs constrained. - [ ] Decide whether Playwright's `workers` should be pinned. Unset, it sizes the pool from `os.cpus()`, which reports the host's cores regardless of a container CPU quota — and with `retries: 0` a starved runner turns a slow test straight into a red gate. Fine on the current act_runner; revisit if CI ever runs constrained.
- [ ] Record the browser floor Plainpages requires, and whether the fallback is the contract or a courtesy. The stylesheet needs `:has()` (Dec 2023); the menus need the popover API (Safari 17) and CSS anchor positioning for placement (newer still, and unguarded — the `@supports` test covers popover only). An iPadOS 16 tablet, capped at Safari 16, therefore gets panels flowing inline rather than working menus. Either state a supported floor or accept the fallback for those devices; nobody has rendered that path on real hardware. - [ ] Record the browser floor Plainpages requires, and whether the fallback is the contract or a courtesy. The stylesheet needs `:has()` (Dec 2023); the menus need the popover API (Safari 17) and CSS anchor positioning for placement (newer still, and unguarded — the `@supports` test covers popover only). An iPadOS 16 tablet, capped at Safari 16, therefore gets panels flowing inline rather than working menus. Either state a supported floor or accept the fallback for those devices; nobody has rendered that path on real hardware.
- [ ] Decide whether the profile dropdown still earns a dropdown. It holds one item, Sign out, behind a click, and its "Signed in as X" head repeats what the trigger already shows. - [ ] Decide whether the profile dropdown still earns a dropdown. It holds one item, Sign out, behind a click, and its "Signed in as X" head repeats what the trigger already shows.
- [ ] Trim whitespace around the verification code in the form — a copy+pasted code from the email currently fails. - [ ] Trim whitespace around the verification code — a copy+pasted code from the email fails, today as a browser `pattern` refusal rather than a Kratos rejection, now carrying a digits-only hint so that refusal isn't bare. The paste still fails. **Own session.** No zero-JS fix exists: `pattern` is rejective and cannot transform, so loosening it only lets the untrimmed value through. The real fix is a host-side proxy of the flow POST, which is feasible and half-built — `submitFlow` (`src/auth/kratos-public.ts:132`) already relays cookies and normalises a 422 `redirect_browser_to`, and has no callers outside tests. CSRF improves rather than breaks: the host already writes Kratos' cookie onto its own origin (`src/auth/routes.ts:77`), so the POST becomes same-origin, and `form-action 'self'` could finally join the CSP. Two risks: one `flow.ui.action` serves all five flows, so this rewrites the sign-in path, not just code entry; and Go's nosurf checks Referer only over https, which no test here covers. **Check first:** `kratos-admin.ts:75` returns a `recovery_link` alongside the code — if the stock courier mail carries one (unverified), a template change fixes the UX with no host code. Code entry has no E2E coverage today either way.
- [ ] Guard against the double-clicked submit, without client-side JavaScript. A non-technical user clicks a button twice when nothing happens fast enough, so a second identical POST is an expected event, not misuse — today it creates two users, mints two recovery codes, or registers two OAuth2 clients. Constraints: HTML/CSS only, and it must not break an action that is *legitimately* repeatable. Sketch: a CSS-only affordance so the second click has nothing to hit, paired with the host recognising a duplicate on the server — same session, route and payload within a short window — then logging and dropping it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form beats hashing the payload, and the CSRF plumbing already mints per-request tokens), the window length, where the record lives given the app is stateless, and how a plugin declares a route repeatable. - [ ] Guard against the double-clicked submit, without client-side JavaScript. A non-technical user clicks a button twice when nothing happens fast enough, so a second identical POST is an expected event, not misuse — today it creates two users, mints two recovery codes, or registers two OAuth2 clients. Constraints: HTML/CSS only, and it must not break an action that is *legitimately* repeatable. Sketch: a CSS-only affordance so the second click has nothing to hit, paired with the host recognising a duplicate on the server — same session, route and payload within a short window — then logging and dropping it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form beats hashing the payload, and the CSRF plumbing already mints per-request tokens), the window length, where the record lives given the app is stateless, and how a plugin declares a route repeatable.
- [ ] Decide the caching contract for rendered pages. Responses carry `Vary: Accept-Language` but nothing sets `Cache-Control`, so a shared cache has no instruction and a signed-in page is not marked `private`. Either set the headers deliberately (public cacheable, gated `private, no-store`) or record in AGENTS.md that the reverse proxy owns this. - [ ] Decide the caching contract for rendered pages. Responses carry `Vary: Accept-Language` but nothing sets `Cache-Control`, so a shared cache has no instruction and a signed-in page is not marked `private`. Either set the headers deliberately (public cacheable, gated `private, no-store`) or record in AGENTS.md that the reverse proxy owns this.
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule applied to namespaces. A design question, not a naming one. - [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule applied to namespaces. A design question, not a naming one.
@@ -32,11 +30,16 @@ Prioritized. Overall verdict: architecture is sound; these are refinements.
- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block. - [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block.
- [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive. - [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive.
- [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`. - [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`.
- [ ] **LOW — Decide (once) on a `ctx.system` facade.** `#plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear. - [ ] **LOW — Decide (once) on a `ctx.system` facade.** `@plainpages/plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear.
- [ ] **LOW — README/AGENTS.md gaps:** state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population. - [ ] **LOW — README/AGENTS.md gaps:** state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population and plugin count — per-plugin dependency isolation prices N dependency trees on disk and in RSS, and that number is what says whether the trade needs revisiting (build-time dedupe for baked images stays open).
## 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] 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] The seeded admin is granted each permission once — `seedPermissions` dedupes and the grant PUT is idempotent.
- [x] Run the E2E runner as the invoking user so its artifacts aren't root-owned. - [x] Run the E2E runner as the invoking user so its artifacts aren't root-owned.
- [x] Install node_modules above `WORKDIR /app` so no mount leaves a root-owned dir in the checkout. - [x] Install node_modules above `WORKDIR /app` so no mount leaves a root-owned dir in the checkout.
- [x] Enforce `<resource>:<action>` permission names at discovery; split `admin` per screen. - [x] Enforce `<resource>:<action>` permission names at discovery; split `admin` per screen.
+1 -1
View File
@@ -24,5 +24,5 @@
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"skipLibCheck": true "skipLibCheck": true
}, },
"include": ["auto-release", "config", "examples/config", "examples/plugins", "plugins", "registry-cleanup", "src"] "include": ["config", "examples/config", "examples/plugins", "plugin-api", "plugins", "registry-cleanup", "release-tooling", "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: "1.0.0", apiVersion: "0.2.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>