Compare commits
77 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ea4777c099 | |||
| ba4503b4d1 | |||
| 4849a7a3c5 | |||
| 5b73ec6fb7 | |||
| 8932392ab8 | |||
| 42af07c255 | |||
| 734cdf6892 | |||
| 716e0f50a7 | |||
| 2d62553a10 | |||
| d8b7a2d64a | |||
| d62c7faabd | |||
| 6c07856159 | |||
| 41084441ff | |||
| 08d4f6271d | |||
| 5820264885 | |||
| b708aab981 | |||
| 86c9e8e4ca | |||
| 33c42b2c65 | |||
| 452beeb0e1 | |||
| 99e77ebabf | |||
| 2148822dad | |||
| 7f839afb32 | |||
| 1f61235e88 | |||
| 24007b15f3 | |||
| 3eceee0cf1 | |||
| 9fa0f60f79 | |||
| 4f791d8f11 | |||
| 94e9e8bc60 | |||
| 03d14b1a20 | |||
| 3552b85d63 | |||
| dde6fccb35 | |||
| 19b3fbc802 | |||
| 47541ae97b | |||
| c5c9cce2b6 | |||
| 6db14a2205 | |||
| ae8f105360 | |||
| bba048e38f | |||
| bf638dfb19 | |||
| 5cc6c3d93e | |||
| 04af61a5e5 | |||
| a64a60644d | |||
| 950eb5a911 | |||
| 091011cfe5 | |||
| d55898eb8c | |||
| f992cb6b2c | |||
| 7d1f7750d3 | |||
| 3f9787df48 | |||
| a261570796 | |||
| 77343e859a | |||
| 616040fda6 | |||
| fee4fe632b | |||
| 1ba6dbdc51 | |||
| 09ab2fcb85 | |||
| a977ecf1c7 | |||
| 04a508c169 | |||
| d74989c4e0 | |||
| 43d7062d9e | |||
| d5ee0353d4 | |||
| 137c35478d | |||
| 352f2f2794 | |||
| 3b6f2c1ed3 | |||
| f99a029bc5 | |||
| 1daa2b1777 | |||
| 8a5d2dfd6c | |||
| a005acb93d | |||
| f5240ef7f6 | |||
| 78f5f72151 | |||
| 1cf34a0d45 | |||
| 073ec294e9 | |||
| e8b91ecd09 | |||
| ab5c24deb7 | |||
| 3d3313c0ee | |||
| bcf4d7fb1f | |||
| 2852722873 | |||
| 45b16824f1 | |||
| f76cd2a267 | |||
| 38ebe40398 |
@@ -1,9 +1,14 @@
|
||||
.git
|
||||
# Load-bearing both ways: a stray copy would bake in at /app/node_modules and shadow /node_modules,
|
||||
# and matching only the root one is what lets a baked plugin keep its own deps. Never `**/node_modules`.
|
||||
node_modules
|
||||
npm-debug.log
|
||||
*.log
|
||||
.DS_Store
|
||||
|
||||
# A plugin's .npmrc is where a private-registry token would sit — never in a shipped image.
|
||||
plugins/**/.npmrc
|
||||
|
||||
e2e-tests/artifacts
|
||||
# Orchestration, not test code — keep them out of the runner image (COPY e2e-tests/ ./)
|
||||
e2e-tests/Dockerfile
|
||||
|
||||
@@ -2,12 +2,27 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
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:
|
||||
retag-image:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: docker-host
|
||||
steps:
|
||||
- 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
|
||||
env:
|
||||
GIT_TAG: ${{ github.ref_name }}
|
||||
@@ -15,34 +30,77 @@ jobs:
|
||||
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
|
||||
REPO: gitea.larvit.se/${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||
VERSION=${GIT_TAG#v}
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
|
||||
docker pull "$REPO:$COMMIT" \
|
||||
|| { 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 push "$REPO:$TAG"
|
||||
done
|
||||
- name: Sync the release tags to Docker Hub
|
||||
env:
|
||||
DOCKERHUB_REPO: docker.io/${{ github.repository }}
|
||||
DOCKERHUB_IMAGE: docker.io/${{ github.repository }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
|
||||
GIT_TAG: ${{ github.ref_name }}
|
||||
REPO: gitea.larvit.se/${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||
VERSION=${GIT_TAG#v}
|
||||
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|
||||
|| { 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
|
||||
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
|
||||
docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG"
|
||||
docker push "$DOCKERHUB_REPO:$TAG"
|
||||
TAGS="$VERSION ${VERSION%.*} latest"
|
||||
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
|
||||
for TAG in $TAGS; do
|
||||
docker tag "$REPO:$COMMIT" "$DOCKERHUB_IMAGE:$TAG"
|
||||
docker push "$DOCKERHUB_IMAGE:$TAG"
|
||||
done
|
||||
- name: Log out of the registries
|
||||
if: always()
|
||||
run: |
|
||||
docker logout gitea.larvit.se
|
||||
docker logout docker.io
|
||||
set -uo pipefail
|
||||
# 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"
|
||||
|
||||
@@ -21,21 +21,19 @@ jobs:
|
||||
-e RENOVATE_PLATFORM=gitea \
|
||||
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||
-e RENOVATE_TOKEN \
|
||||
renovate/renovate:44.11.7
|
||||
renovate/renovate:44.39.1
|
||||
|
||||
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
|
||||
# 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
|
||||
# (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;
|
||||
# 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).
|
||||
# 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:
|
||||
runs-on: docker-host
|
||||
needs: renovate
|
||||
if: vars.AUTO_RELEASE == 'true'
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
with:
|
||||
@@ -57,8 +55,15 @@ jobs:
|
||||
fi
|
||||
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
|
||||
--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 \
|
||||
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"
|
||||
git tag "$NEXT" origin/main
|
||||
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
|
||||
|
||||
+4
-2
@@ -3,8 +3,10 @@
|
||||
*.log
|
||||
node_modules
|
||||
|
||||
# Playwright E2E outputs (screenshots, html report, traces)
|
||||
e2e-tests/artifacts/
|
||||
# Playwright E2E outputs (screenshots, html report, traces). The dir itself is tracked: an absent
|
||||
# bind-mount source is created by the daemon as root, which the unprivileged runner cannot write.
|
||||
/e2e-tests/artifacts/*
|
||||
!/e2e-tests/artifacts/.gitkeep
|
||||
|
||||
# plugins/ is a drop-in mount point, not committed code — keep it empty (see examples/plugins/ for the reference)
|
||||
/plugins/*
|
||||
|
||||
@@ -3,302 +3,317 @@
|
||||
Guidance for AI agents and contributors working in this repo. Read `README.md` for
|
||||
commands and layout.
|
||||
|
||||
## Prose discipline
|
||||
|
||||
Every word in this repo is read again on every future task, so prose is a recurring cost. On **any**
|
||||
change, sweep the prose you touched — this file, `README.md`, the example READMEs, and code
|
||||
comments — and cut it back to what a competent reader could not infer:
|
||||
|
||||
- **Delete history.** Git holds it. No "this moved from X", "used to be Y", "was tried and
|
||||
rejected", "(declined twice)", dated changelog entries, or the symptom that prompted a fix. Record
|
||||
the decision and the reason it *currently* turns on, nothing else.
|
||||
- **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that
|
||||
re-explains a table above it, a file-map entry that expands the filename. The fix is deletion,
|
||||
not trimming.
|
||||
- **Delete the self-evident** and anything already stated once elsewhere. **One home per fact** —
|
||||
link to it instead of repeating it; the same sentence in five files is five chances to drift.
|
||||
- **Give every accepted risk an expiry** ("valid while X"), and delete the entry once X stops
|
||||
holding.
|
||||
- **Keep** the surprising why, the footgun, the invariant, the external constraint, and the one-time
|
||||
setup a reader cannot dig out of the code. Once a line has earned its place, make it short and
|
||||
information-dense.
|
||||
|
||||
Trimming is not a separate task to schedule — do it in the same change, every time.
|
||||
|
||||
## How to work with tasks
|
||||
|
||||
Use the file `todo.md`.
|
||||
|
||||
For each todo item, interview the user extensively to deeply understand the scope and goal of each. When done, check the completed task in `todo.md`. Commit all changes and push to a new branch, create a PR and merge it when the CI/CD turns green.
|
||||
For each todo item, interview the user extensively to deeply understand the scope and goal of
|
||||
each. When done, check the completed task in `todo.md`. Commit all changes and push to a new
|
||||
branch, create a PR and merge it when the CI/CD turns green.
|
||||
|
||||
## Project priorities (do not erode)
|
||||
|
||||
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` — the last itself zero-dependency, for structured/OTLP logging).
|
||||
Prefer the Node standard library; justify any new dependency; do not add
|
||||
frameworks. The app is
|
||||
**stateless — no database**. Auth/identity/OAuth are **Ory sidecar services**
|
||||
(Kratos/Keto/Hydra, backed by Postgres), reached over their REST APIs with
|
||||
built-in `fetch` — no SDK dependency. New capabilities ship as **plugin
|
||||
folders** under `plugins/` that fetch their data from upstream services, not as
|
||||
core code. See `README.md` for the architecture.
|
||||
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
|
||||
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
|
||||
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
|
||||
Postgres database, which the host provisions but never reads or writes inside. Auth/identity/OAuth are
|
||||
**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`,
|
||||
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer
|
||||
exact types and limit nullable and multi option types when possible. KISS.
|
||||
4. **Environment-agnostic** — the app never asks *which environment* it runs in; there is
|
||||
no `NODE_ENV` (or equivalent) branching. Every behaviour is an **explicit config
|
||||
toggle** (e.g. `CACHE_TEMPLATES`, `REQUIRE_SECURE_SECRETS`, a future "disable email"),
|
||||
read once in `src/config.ts`. Compose files set the toggles per deployment.
|
||||
5. **Semantic, accessible DOM** — markup is a first-class concern. Use the right element
|
||||
for the job (landmarks, one `<h1>` per page + sane heading order, lists, `<table>` with
|
||||
row/column headers, `<fieldset>`/`<legend>`, `<button>` vs `<a>`); add ARIA only to fill
|
||||
real gaps (`aria-current`, `aria-sort`, labels). Classes/ids name *meaning*, not looks.
|
||||
Prefer native semantics over `div` + ARIA. New views and partials keep this bar.
|
||||
6. **Full, parallel E2E** — every user-facing flow (each page, form, guard, plugin route)
|
||||
has a Playwright E2E test, and a new surface ships *with* its E2E in the same change.
|
||||
Tests stay independent and side-effect-free so the suite runs `fullyParallel` — keep it
|
||||
that way as it grows (never serialise on shared state); parallelism is what keeps it
|
||||
fast. E2E runs in Docker against the live stack — see `README.md`.
|
||||
7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the
|
||||
only way to add domain features. It optimises for being **powerful, predictable, and
|
||||
overloadable** (a plugin can take over as much of a page as it wants), and the host
|
||||
**fails loud at boot/discovery** (bad manifest, version mismatch, or conflict stops
|
||||
startup with a clear message) rather than sandboxing at runtime. Runtime crash-isolation
|
||||
is a deliberate **non-goal** — diagnose at deploy time, not in production. Keep this
|
||||
contract stable; see `README.md` → Building plugins.
|
||||
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types;
|
||||
limit nullable and multi-option types.
|
||||
4. **Environment-agnostic** — no `NODE_ENV` branching. Every behaviour is an **explicit config
|
||||
toggle** read once in `src/config.ts`; compose files set them per deployment.
|
||||
5. **Semantic, accessible DOM** — the right element for the job (landmarks, one `<h1>` per page +
|
||||
sane heading order, lists, `<table>` with row/column headers, `<fieldset>`/`<legend>`, `<button>`
|
||||
vs `<a>`); ARIA only to fill real gaps. Classes/ids name *meaning*, not looks.
|
||||
6. **Full, parallel E2E** — every user-facing flow has a Playwright test, shipped in the same change
|
||||
as the surface. Tests stay independent and side-effect-free so the suite runs `fullyParallel`.
|
||||
7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the only way to
|
||||
add domain features. It optimises for being powerful, predictable and overloadable, and the host
|
||||
**fails loud at boot/discovery** rather than sandboxing at runtime. Runtime crash-isolation is a
|
||||
deliberate **non-goal**.
|
||||
|
||||
## Deliberate architectural deviations (don't re-flag)
|
||||
|
||||
Intentional, reasoned choices — an architecture review should honor them, not re-raise
|
||||
them. Revisit only if the stated reason stops holding.
|
||||
Intentional, reasoned choices — an architecture review should honor them, not re-raise them.
|
||||
Revisit only if the stated reason stops holding.
|
||||
|
||||
- **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/`
|
||||
(session-JWT hot path, guards, and the Ory REST clients), `i18n/` (locale resolution + the
|
||||
catalogs, `locales/` holding the data), `plugin-host/`
|
||||
(discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel + `system.ts`, the
|
||||
`ctx.system` capability surface), and `ui/` (design-system view-models + menu/chrome);
|
||||
`server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` stay at the root. Tests
|
||||
are co-located (`foo.test.ts` beside `foo.ts`). Add a new module to the folder that owns its
|
||||
concern rather than to the root; don't reintroduce a flat tree. The core ships **no domain
|
||||
screens** — even the admin GUI (users/groups/permissions) is a drop-in plugin (`examples/plugins/admin/`),
|
||||
not `src/` code.
|
||||
- **`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
|
||||
(`/`). (Declined twice.)
|
||||
- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web`
|
||||
never touches SMTP. Customization is Kratos' built-in `courier.template_override_path`,
|
||||
not app code — keeping `web` stateless and dependency-light (see [Email](README.md#email)).
|
||||
- **Plugins and config import the host only via package.json `imports`** — `#plugin-api`
|
||||
→ `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts` — never a
|
||||
relative `../../src/*` path. These two barrels are the whole author/operator contract
|
||||
surface; the `src/*` behind them may be refactored freely. Depth-independent and
|
||||
refactor-stable by design — don't "fix" a `#`-import back to a relative path.
|
||||
**One caveat:** `#plugin-api` re-exports the Ory client types for the `ctx.system` surface
|
||||
(`KratosAdmin`/`KetoClient`/`HydraAdmin` + their DTOs and error classes). Those shapes are
|
||||
therefore **contract-visible** — changing them is a plugin-API break needing a major
|
||||
`apiVersion` bump, not a free refactor. Keep the Ory clients stable, or bump the version.
|
||||
- **A plugin/config folder must stay a plain folder — no `package.json` of its own.** Node
|
||||
resolves `#`-specifiers against the nearest parent `package.json`; a `package.json` inside
|
||||
the folder becomes its own scope and `#plugin-api`/`#menu-config` stop resolving. Accepted
|
||||
cost of the `#`-import contract (fits the stateless, no-per-plugin-deps ethos). A plugin
|
||||
kept in its own repo typechecks against the barrel only when mounted under the host tree
|
||||
(or by adding a local `imports` map / vendored stub).
|
||||
### Structure & contracts
|
||||
|
||||
- **`src/` is grouped by concern**, not flat — `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`,
|
||||
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
|
||||
screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
|
||||
- **Plugins and config import the host only through a barrel** — `@plainpages/plugin-api` →
|
||||
`plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts`,
|
||||
never a relative `../../src/*` path. These two barrels are the whole contract surface; don't "fix"
|
||||
either back to a relative path. Three consequences:
|
||||
- `@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
|
||||
`apiVersion` bump, not a free refactor.
|
||||
- **The barrel is a package, not a `#`-import, so a plugin folder may carry its own
|
||||
`package.json`** and depend on npm packages (README → Plugin dependencies). The Dockerfile links
|
||||
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
|
||||
`plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirror folders are in
|
||||
`tsconfig.include` and resolve the host surface via `#`-imports, so each example typechecks
|
||||
in place *and* copies across unchanged. Never commit real plugins/config into the root
|
||||
mount dirs (`plugins/`, `config/`) — they ship empty (`.gitkeep`, git-ignored otherwise).
|
||||
- **Authorization vocabulary: `User` → `Group` → `Permission`, and there is no `Role`.** Keto ships
|
||||
no namespaces — all four in `ory/keto/namespaces.keto.ts` are ours. `Permission` follows RBAC,
|
||||
where a permission is one operation ("read shifts") and a role is a *bundle* of them; a route
|
||||
gates on one operation, so it gates on a permission, and a bundle is just a group with several
|
||||
grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the
|
||||
separate per-row tier.
|
||||
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
|
||||
word names *who someone is* — a role — and roles are groups here; the old catch-all `admin`
|
||||
permission was exactly that mistake, split into `users:`/`groups:`/`permissions:`/`oauth2-clients:`
|
||||
× `read`/`write` 2026-08-05. **Enforced at discovery** (`isValidPermissionName` in
|
||||
`plugin-host/plugin.ts`, checked by `shapeError` over every route/nav `permission` and every
|
||||
declared name), fail-loud like every other manifest rule — not only in the admin GUI, which an
|
||||
operator removes by not copying the example in. Decisions around it:
|
||||
- **Names are authored in plugin code; only grants live in Keto.** The host collects every
|
||||
installed plugin's declarations into one catalog (`declaredPermissions` → `ctx.declaredPermissions`),
|
||||
and that catalog *is* the fixed list the admin screens offer. So there is **no Permissions admin
|
||||
screen**: nothing in a GUI invents a name, and holding one is a property of a user or a group,
|
||||
edited as a checkbox list on those two screens. A tuple in Keto naming something no installed
|
||||
plugin declares gates nothing and is not offered — and a save never revokes it, since the picker
|
||||
only speaks for what it showed. Decided with the maintainer 2026-08-05, replacing the CRUD
|
||||
Permissions screen.
|
||||
- `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`). Deliberate
|
||||
cross-plugin sharing is a goal, so the pre-2026-08-05 `<id>:<action>` guidance was wrong: users
|
||||
are the *host's*, not the admin plugin's. Cost: collision-freedom became a convention rather than
|
||||
structural. Accepted — the alternative penalizes the sharing case.
|
||||
- **Declaring a permission stays optional.** Requiring every gated route to declare its permission
|
||||
would make `findConflicts` see all overlaps, but would then warn on exactly the legitimate
|
||||
sharing case above. Shape is enforced; declaration is not.
|
||||
- **There is no name-minting path in the GUI at all**, which is what makes the discovery check the
|
||||
whole story: the only way a name comes into being is a plugin declaring it, and discovery refuses
|
||||
a badly-shaped declaration at boot. An earlier revision of this branch enforced the rule in the
|
||||
Permissions screen's create form instead and needed a second guard for the assign form, which
|
||||
could also mint one — deleting the screen removed both.
|
||||
- `ADMIN_PERMISSIONS` **defaults to empty**: every permission is owned by the plugin that gates on
|
||||
it, and a host-invented default would gate nothing. This makes the seed a function of what
|
||||
`bootstrap` discovers, and a plugin dropped in after first boot therefore needs
|
||||
`docker compose up -d` (which re-runs the one-shot), not `restart web`. The base file gives
|
||||
`bootstrap` and `web` the same baked `plugins/`; only `compose.override.yml`'s dev-only `.:/app`
|
||||
makes `web` diverge onto the host tree, so the matching `./plugins` mount for `bootstrap` lives
|
||||
**there and only there** — in the base file it would desynchronise prod and collide 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 writer of
|
||||
grants.
|
||||
- **`actionForMethod` is plugin-local and must not migrate into `#plugin-api`.** Inside the admin
|
||||
example it buys one thing: the route table and the in-handler guard derive from one function, so
|
||||
29 routes × 2 gate sites cannot drift. As a general mechanism it would make authorization a
|
||||
function of the transport verb, and a route table must answer "what does this need?" on its own.
|
||||
- **A `:read`-only holder must never be shown a write affordance.** The split created a real read-only
|
||||
operator (a helpdesk account with `users:read`), and the host's 403 is the backstop, not the UX: the
|
||||
list/detail models carry `canWrite` and the views drop create/save/delete/add/remove, while the
|
||||
permission picker still renders — disabled — because *seeing* who holds what is the point of `:read`.
|
||||
A *write-intent GET* — a create form or a delete-confirm page — is the exception to
|
||||
`actionForMethod`: it gates on `:write` (declared in the route table and passed to the handler's
|
||||
guard, so the two still agree), because a page whose only purpose is to start a write should refuse
|
||||
a reader rather than render a form whose submit 403s.
|
||||
Two grant-specific guards go with it, both restoring behaviour the deleted Permissions screen had:
|
||||
you cannot revoke your own **direct** grants on the Users screen (self-lockout would need a `curl`
|
||||
against Keto to undo, which the operator persona can't do — same shape as the self-deactivate/
|
||||
self-delete guards), and a permission held *through a group* renders ticked-but-disabled rather than
|
||||
unticked, because showing it unticked stated the opposite of the truth and unticking it wrote
|
||||
nothing while looking like a successful revoke. **Known gap, same scope the deleted screen had:**
|
||||
the group paths are unguarded — unticking a permission on a group you belong to, removing yourself
|
||||
from it, or deleting it can all still strip your own effective access. The robust "last effective
|
||||
holder" check needs a reverse Keto query and is deferred. Raised by the architecture + product +
|
||||
stability reviews 2026-08-05.
|
||||
- **`users:write` and `groups:write` are equivalent to full administrative access**, and the split
|
||||
does not change that: `groups:write` adds you to any group, including one holding every permission;
|
||||
`users:write` mints a recovery code for any account. The containment the split buys is real on the
|
||||
**read** half only (`users:read` is a safe helpdesk grant). Don't let the per-resource naming imply
|
||||
otherwise in docs. Raised by the architecture review 2026-08-05.
|
||||
- **Plainpages says "user" everywhere; Ory's word for it is "identity".** Kratos calls the record
|
||||
an identity, but Ory's own docs state it uses that term *interchangeably* with "users" and
|
||||
"accounts" — so this is house style, not a renamed concept, and "user" is the word readers
|
||||
already know (Nielsen's heuristic #2: match between the system and the real world). One note in
|
||||
README → Auth records the mapping so nobody has to rediscover it. The single exception is the
|
||||
`Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape and keeps Ory's
|
||||
name — don't rename that one.
|
||||
- **The locale lives in the URL, never in a cookie.** `?locale=sv-SE` → `Accept-Language` → `en-US`,
|
||||
and when the URL asked for one the host carries it onto the links it renders (`ctx.localeHref`).
|
||||
A cookie would make a page's language invisible in its address and unshareable; the cost is that a
|
||||
plugin must wrap its own hrefs. Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a
|
||||
lone language from `Accept-Language` takes the first regional catalog for it. Decided 2026-08-03.
|
||||
- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US`
|
||||
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` says that locale needs —
|
||||
and a mismatch stops startup, same fail-loud contract as a bad manifest. A plugin may ship fewer
|
||||
locales than the host (its strings fall back to `en-US` per key), never one the host lacks.
|
||||
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell (breadcrumbs),
|
||||
`pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every
|
||||
href they render in `localeHref`; the nav and the sign-in link are wrapped upstream in `chrome.ts`;
|
||||
and the two GET forms
|
||||
(filter bar, rows-per-page) carry it as a hidden `locale` input, since a GET submit replaces the
|
||||
whole query string and no href wrapper can reach it. Putting the obligation on each call site was
|
||||
tried first and missed five of eight sites inside one commit — including the admin screens.
|
||||
`ctx.localeHref` remains for hrefs a plugin's own markup emits (the admin example's delete links).
|
||||
**A form's `action` counts as a link** — a POST replaces the URL as completely as a GET submit, so
|
||||
the sign-out, consent and auth-card forms carry it too; without that, picking a language and then
|
||||
saving anything drops back to `Accept-Language`. The one round-trip that cannot carry it is the
|
||||
Kratos sign-in POST, whose action is an absolute off-site URL.
|
||||
Decided 2026-08-03 after an architecture review; a second pass then found breadcrumbs still raw,
|
||||
so: when a link renders from the core chrome, it is the chrome's job to carry the locale.
|
||||
- **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set (`list-query.ts`),
|
||||
so a localized list page doesn't hand a plugin a phantom `locale` filter; the i18n view locals (`t`, `locale`, `locales`, `localeHref`,
|
||||
`localeParam`, `localeSwitch`, `dir`) are likewise reserved names, merged after a handler's `data`
|
||||
so a collision loses the key instead of breaking the shell.
|
||||
- **The language picker is on every page, POST-rendered ones included.** Maintainer's call
|
||||
2026-08-04, overriding an earlier decision to hide it there. The problem it was hiding is real: a
|
||||
POST-rendered URL frequently answers no GET (`POST /admin/users/:id/recovery`), so a link back to
|
||||
it dead-ends on a 405. The host therefore resolves the picker's target (`app.ts` → `switchBase`):
|
||||
this path when it answers GET, else the same-origin Referer, else `/`. Accepted cost: switching
|
||||
language on such a page leaves that POST's own result behind (a re-rendered form's input, or a
|
||||
one-time recovery code). Valid while the picker is expected on literally every page — if that ever
|
||||
softens, hiding it after a POST is the simpler answer.
|
||||
`plugins/<id>/`, `examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in
|
||||
`tsconfig.include` and resolve the host through the barrels, so each typechecks in place *and*
|
||||
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
|
||||
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`,
|
||||
`dashboard`) and an `onRequest` short-circuit dispatch a plugin's handler, so they build the
|
||||
context with `contextFor(pluginId)` exactly as a plugin route does — otherwise `ctx.t` is the core
|
||||
translator and the plugin's own keys render as bare keys on the pages it owns. Found by review
|
||||
2026-08-03 after all three paths shipped with the host's context.
|
||||
- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`** — `locales/<tag>.ts`
|
||||
for the core and `locales/plugins/<id>/<tag>.ts` for an installed plugin, each adding a language or
|
||||
replacing that tag's catalog wholesale. Adding a language must never require forking the image or a
|
||||
vendored plugin folder. The SHIPPED `en-US` (core's, or the plugin's own) stays the parity baseline
|
||||
even when the mount replaces it, so a mounted catalog is checked rather than trusted (one compared
|
||||
only against itself would boot green with the whole UI rendering keys), and each half is reported
|
||||
under the folder it actually lives in.
|
||||
- **RTL is out of scope until there is a real use case.** `textDirection` sets `<html dir>` from the
|
||||
locale's script because that is free and correct, but the stylesheet keeps physical `left`/`right`
|
||||
properties — a genuine RTL locale needs those moved to logical ones first. Don't convert the CSS or
|
||||
file findings about it on spec. Maintainer's call 2026-08-04; valid while no deployment needs an
|
||||
RTL language. A catalog there
|
||||
for a new tag adds a language; one for a tag the image ships replaces that catalog wholesale, held
|
||||
to the same parity check. Adding a language must not require forking the image.
|
||||
- **An unknown translation key renders as itself.** That single rule is what lets a nav label,
|
||||
branding, or a menu `rename` be either a key or plain text without a second field or a migration.
|
||||
Don't "fix" it into a loud failure: a manifest with plain labels must keep working.
|
||||
- **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value,
|
||||
so nothing is double-escaped; a message carrying markup uses `<%- %>`, and then its `{{vars}}` are
|
||||
escaped at the call site (see `views/partials/pagination.ejs`). Don't move escaping into `t()` —
|
||||
every other value in a view would then be the odd one out.
|
||||
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
||||
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`:
|
||||
concurrent jobs can race (one job's logout can 401 another's push — recover by re-running),
|
||||
and tokens sit in that file between login and logout. Same class: concurrent runs share the
|
||||
workspace dir, so ci.sh's web-image build races another run's container creation on the
|
||||
`<project>-web` tag. Accepted for a single-maintainer cadence; serialize with a workflow
|
||||
`concurrency` group if it ever bites.
|
||||
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** No test, build step or
|
||||
workflow reads a markdown file (`README-dockerhub.md` is pasted into Docker Hub by hand), so a
|
||||
nested `examples/plugins/admin/README.md` edit is as safe to skip as `README.md`, and narrowing it
|
||||
would spend the full gate on one. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`:
|
||||
rename detection names only the destination, so `git mv src/app.ts notes.md` otherwise read as docs
|
||||
and skipped the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a
|
||||
*text* guard — the test image (`node:24.19.0-alpine3.24`) ships neither `git` nor `bash`, so it
|
||||
cannot exercise the function; behaviour was verified against a scratch repo across ten scenarios.
|
||||
Revisit if a `.md` ever becomes load-bearing. Decided 2026-08-05.
|
||||
- **Plainpages is pre-announcement: no tags, no releases.** The repo carried tags up to `v0.2.2` from
|
||||
the `auto-release` job; all of them — and the semver container tags — were deleted 2026-08-05, and
|
||||
the job 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; same reasoning that freezes `HOST_API_VERSION` at 1.0.0. Note the coupling:
|
||||
`registry-cleanup` keeps a hash image only while its commit is a branch head *or* release-tagged,
|
||||
so with zero tags only branch heads survive the nightly prune — a hand-cut tag must sit on `main`'s
|
||||
tip. `mirror.yml` pushes tags with `--prune` so the deletions actually reach the public GitHub
|
||||
mirror; that makes the runner's tag view load-bearing (hence `fetch-tags: true`) and means a tag
|
||||
or Release created on GitHub is swept away, so releases are cut on Gitea only. Valid until the
|
||||
maintainer says Plainpages is ready to show people.
|
||||
`dashboard`) and an `onRequest` short-circuit build their context with `contextFor(pluginId)`
|
||||
exactly as a plugin route does — otherwise `ctx.t` is the core translator and the plugin's own keys
|
||||
render as bare keys on the pages it owns.
|
||||
- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web` never
|
||||
touches SMTP. Customization is Kratos' `courier.template_override_path`, not app code.
|
||||
|
||||
### Authorization
|
||||
|
||||
- **Vocabulary: `User` → `Group` → `Permission`, and there is no `Role`.** Keto ships no namespaces —
|
||||
all four in `ory/keto/namespaces.keto.ts` are ours. A permission is one operation ("read shifts");
|
||||
a role is a *bundle*, which here is just a group with several grants (groups nest). Ory's own
|
||||
"permission" (the `Resource` `permits`: view/edit/delete) is the separate per-row tier.
|
||||
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
|
||||
word names *who someone is* (a role), and roles are groups here. **Enforced at discovery**
|
||||
(`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route/nav
|
||||
`permission` and every declared name), fail-loud like any other manifest rule — not only in the
|
||||
admin GUI, which an operator removes by not copying it in.
|
||||
- **Names are authored in plugin code; only grants live in Keto.** The host collects every installed
|
||||
plugin's declarations into one catalog (`declaredPermissions` → `ctx.declaredPermissions`), and
|
||||
that catalog *is* the list the admin screens offer. Hence **no Permissions admin screen**: nothing
|
||||
in a GUI invents a name, and holding one is a property of a user or group, edited as a checkbox
|
||||
list there. A Keto tuple naming something no installed plugin declares gates nothing, is not
|
||||
offered, and is never revoked by an unrelated save — the picker only speaks for what it showed.
|
||||
- `<resource>` is **global, not plugin-scoped** (hence `oauth2-clients`, not `clients`): users are
|
||||
the *host's*, and cross-plugin sharing is a goal. Cost: collision-freedom is a convention rather
|
||||
than structural. Accepted — the alternative penalizes the sharing case.
|
||||
- **Declaring a permission stays optional.** Mandatory declaration would let `findConflicts` see all
|
||||
overlaps, but would then warn on exactly that legitimate sharing case. Shape is enforced;
|
||||
declaration is not.
|
||||
- `ADMIN_PERMISSIONS` **defaults to empty**, and **an unusable value is dropped with a warning,
|
||||
never fatal** — fail-loud belongs at the manifest boundary where a developer authored the
|
||||
mistake, whereas `bootstrap` gates `web`, so refusing operator env takes the whole stack down
|
||||
(`e2e-tests/compose.auth.yml` seeds a bad value to prove the container survives one). The seed is
|
||||
a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs
|
||||
`docker compose up -d`, not `restart web`. `bootstrap`'s matching `./plugins` mount belongs in
|
||||
`compose.override.yml` and nowhere else: in the base file it would desynchronise prod and collide
|
||||
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
|
||||
writer of grants.
|
||||
- **`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
|
||||
× 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.
|
||||
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
|
||||
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
|
||||
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
|
||||
form, a delete-confirm page) is the exception to `actionForMethod` and gates on `:write`. Two
|
||||
grant-specific guards go with it: you cannot revoke your own **direct** grants (self-lockout would
|
||||
need a `curl` against Keto to undo), and a permission held *through a group* renders
|
||||
ticked-but-disabled, because unticked stated the opposite of the truth. **Known gap:** the group
|
||||
paths are unguarded — unticking a permission on a group you belong to, leaving it, or deleting it
|
||||
can still strip your own access. The robust "last effective holder" check needs a reverse Keto
|
||||
query and is deferred.
|
||||
- **`users:write` and `groups:write` are equivalent to full administrative access**: `groups:write`
|
||||
adds you to any group, including one holding every permission; `users:write` mints a recovery code
|
||||
for any account. The containment the split buys is real on the **read** half only (`users:read` is
|
||||
a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
|
||||
- **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed
|
||||
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
|
||||
Kratos' wire shape — don't rename it.
|
||||
|
||||
### i18n
|
||||
|
||||
- **The locale lives in the URL, never in a cookie.** `?locale=sv-SE` → `Accept-Language` → `en-US`,
|
||||
and when the URL asked for one the host carries it onto the links it renders. A cookie would make a
|
||||
page's language invisible in its address and unshareable; the cost is that a plugin wraps its own
|
||||
hrefs. Matching is exact on a full tag (`sv-FI` ≠ `sv-SE`), except that a lone language from
|
||||
`Accept-Language` takes the first regional catalog for it.
|
||||
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell, `pagination`,
|
||||
`filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every href in
|
||||
`localeHref`; nav and sign-in are wrapped in `chrome.ts`; the two GET forms carry it as a hidden
|
||||
`locale` input, since a GET submit replaces the whole query string. **A form's `action` counts as a
|
||||
link** — sign-out, consent and auth-card forms carry it too, or picking a language and then saving
|
||||
anything drops back to `Accept-Language`. The obligation stays on the building block, never on each
|
||||
call site. `ctx.localeHref` remains for hrefs a plugin's own markup emits. The one round-trip that
|
||||
cannot carry it is the Kratos sign-in POST (absolute off-site URL).
|
||||
- **`locale` is a host-owned query param** — in `parseListQuery`'s reserved set, so a localized list
|
||||
page doesn't hand a plugin a phantom `locale` filter. The i18n view locals (`t`, `locale`, `locales`,
|
||||
`localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's
|
||||
`data` so a collision loses the key instead of breaking the shell.
|
||||
- **Catalogs are checked at boot, not at render.** Every locale is compared against its set's `en-US`
|
||||
— keys, string-vs-plural kind, and the plural categories `Intl.PluralRules` requires — and a
|
||||
mismatch stops startup. A plugin may ship fewer locales than the host (its strings fall back to
|
||||
`en-US` per key), never one the host lacks.
|
||||
- **`locales/` at the repo root is a drop-in mount**, like `plugins/` and `config/` — `locales/<tag>.ts`
|
||||
for the core, `locales/plugins/<id>/<tag>.ts` for a plugin; a new tag adds a language, an existing
|
||||
one replaces that catalog wholesale. Adding a language must never require forking the image. The
|
||||
SHIPPED `en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is
|
||||
checked rather than trusted (one compared only against itself would boot green with the whole UI
|
||||
rendering keys).
|
||||
- **The language picker is on every page, POST-rendered ones included.** A POST-rendered URL often
|
||||
answers no GET (`POST /admin/users/:id/recovery`), so the host resolves the picker's target
|
||||
(`app.ts` → `switchBase`): this path when it answers GET, else the same-origin Referer, else `/`.
|
||||
Accepted cost: switching language there leaves that POST's own result behind. Valid while the picker
|
||||
is expected on literally every page — if that softens, hiding it after a POST is simpler.
|
||||
- **An unknown translation key renders as itself.** That single rule lets a nav label, branding, or a
|
||||
menu `rename` be either a key or plain text without a second field or a migration. Don't "fix" it
|
||||
into a loud failure: a manifest with plain labels must keep working.
|
||||
- **`t()` returns raw text; the view escapes it.** Messages go through `<%= %>` like any other value;
|
||||
one carrying markup uses `<%- %>`, and then its `{{vars}}` are escaped at the call site. Don't move
|
||||
escaping into `t()` — every other value in a view would become the odd one out.
|
||||
- **RTL is out of scope until there is a real use case.** `textDirection` sets `<html dir>` because
|
||||
that is free and correct, but the stylesheet keeps physical `left`/`right` properties; a genuine RTL
|
||||
locale needs those moved to logical ones first. Valid while no deployment needs an RTL language.
|
||||
|
||||
### UI
|
||||
|
||||
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
|
||||
owns open/close, which is the only zero-JS way to dismiss a menu by clicking outside it (the whole
|
||||
point), and the panel sits in the top layer so a row kebab is no longer clipped by `.table-wrap`'s
|
||||
`overflow`. Four rules hold it together, none of them cosmetic. The panel carries
|
||||
**`position-anchor: auto`** — a bare `anchor()` resolves to nothing in Chromium, Firefox *and*
|
||||
WebKit alike, which is why the popover test in `visual.spec.ts` runs in all three rather
|
||||
than resting on a one-time manual measurement. The panel stays the trigger's **next sibling inside
|
||||
the `.menu` wrapper**, because the open-state style and the old-browser fallback both read that
|
||||
adjacency, and a two-element partial cannot be dropped into an arbitrary layout. The `menu` partial
|
||||
**requires a caller-named `id`** and fails loud without one: it is the `popovertarget` idref, and
|
||||
generated random ids were tried and rejected the same day — nondeterministic HTML forecloses the
|
||||
still-open caching decision and names nothing a reader can use. And **neither `aria-expanded` nor
|
||||
`aria-haspopup` is written**: a zero-JS invoker cannot keep the first truthful, and the second would
|
||||
promise `role="menu"` keyboard semantics these panels do not implement. `<details>` stays where it
|
||||
means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
||||
profile menu because its trigger composes escaped user values and its one item is a CSRF POST form,
|
||||
neither of which the partial's `Item` shapes cover — keep the two in step, or fold it in if
|
||||
`todo.md`'s "does the profile dropdown still earn a dropdown" settles the other way. Decided 2026-08-05.
|
||||
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract.** It is
|
||||
deliberately not re-exported from `#plugin-api`, and README → Nav & permission gates already tells an
|
||||
author that using a new icon means registering it there. So the palette may narrow when the last
|
||||
reference to an id goes — `i-gear` left with the settings menu 2026-08-05 — and a plugin needing one
|
||||
gets it re-registered in the same change. Accepted cost: an unknown sprite id renders a blank icon
|
||||
instead of failing loud; the `every icon <use> resolves to a defined <symbol>` e2e test catches it for
|
||||
anything reaching the nav. Removing an id is a core edit, so weigh it per icon rather than sweeping the
|
||||
registry — a few ids are registered ahead of a caller (see `todo.md`).
|
||||
owns open/close — the only zero-JS way to dismiss by clicking outside — and the panel sits in the
|
||||
top layer, so a row kebab is not clipped by `.table-wrap`'s `overflow`. Four rules hold it
|
||||
together: the panel carries **`position-anchor: auto`** (a bare `anchor()` resolves to nothing in
|
||||
all three engines); it stays the trigger's **next sibling inside the `.menu` wrapper**, which the
|
||||
open-state style and the old-browser fallback both read; the partial **requires a caller-named
|
||||
`id`** and fails loud without one, since that is the `popovertarget` idref (never generate one —
|
||||
nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
|
||||
`aria-haspopup` is written**, because a zero-JS invoker cannot keep the first truthful and the
|
||||
second would promise `role="menu"` semantics these panels don't implement. `<details>` stays where
|
||||
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
||||
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
|
||||
the two in step.
|
||||
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
|
||||
is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
|
||||
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
|
||||
catches anything reaching the nav).
|
||||
|
||||
### Build, test & release
|
||||
|
||||
- **Deps install to `/node_modules`, above `WORKDIR /app`** — Node resolves upward, so dev's `.:/app`
|
||||
bind mount has nothing to shadow. Not a volume at `/app/node_modules`: the daemon creates a mount
|
||||
destination as root whatever `--user` says, leaving a root-owned dir in the checkout. Nothing may
|
||||
sit at that path now — it shadows `/node_modules` silently (`src/compose.test.ts` guards the compose
|
||||
files, `.dockerignore` the image).
|
||||
- **A container whose output a human then edits or deletes runs as `--user "$(id -u):$(id -g)"`** —
|
||||
the E2E runner (artifacts) and a lockfile edit, or the output is root-owned and needs `sudo`, which
|
||||
a dev box may not have. Not universal: `bootstrap` writes `jwks.json` as root when it is absent on
|
||||
first boot; the committed dev key makes that rare, and when it happens the rotation runbook's
|
||||
host-side `>` needs the file re-owned first (valid while the dev key ships committed). Three
|
||||
consequences: `e2e-tests/artifacts/` is *tracked* (`.gitkeep`), since an absent bind-mount source is
|
||||
daemon-created as root and that uid then cannot write it (README → Upgrading); the runner image sets
|
||||
`HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an unwritable `/`; and
|
||||
rootless Docker wants the flag *dropped*, container root already being the invoking user. Baking a
|
||||
`USER` in instead does not work — the image's `pwuser` is 1001 and no fixed uid matches every host.
|
||||
`src/compose.test.ts` guards every documented command, `src/ci-gate.test.ts` the gate's own.
|
||||
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
|
||||
`e2e-tests/console-guard.ts`, which watches every page a test opens: a console error or warning, or
|
||||
an uncaught exception, fails that test. A zero-JS app has nothing to say in the console, so the bar
|
||||
is *zero* rather than a curated list of tolerated noise — and the two exceptions are explicit and
|
||||
narrow: one module-level allowance for the COOP header Chromium drops because the e2e stacks serve
|
||||
plain http over container hostnames (a deployment serves https, where it applies), and
|
||||
`allowConsole(re)` for a test whose own page provokes a message on purpose — the 404 spec, whose
|
||||
navigation Chromium and WebKit log. Each record carries the message's origin URL, so that allowance
|
||||
can name the page under test and still see a sub-resource of it 404. `src/e2e-console-guard.test.ts`
|
||||
locks the wiring in the *unit* gate: a spec importing `test` straight from Playwright — or minting a
|
||||
page with a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. The buffer
|
||||
clears at teardown rather than setup so a `beforeAll` is watched too (full-flow runs a whole login in
|
||||
one); the accepted cost is that a page outliving its test, as a serial describe's does, can log late
|
||||
and fail the next test instead of its own. Verified by negative control in all three engines.
|
||||
`e2e-tests/console-guard.ts`, which fails a test on a console error/warning or uncaught exception on
|
||||
any page it opened. A zero-JS app has nothing to say in the console, so the bar is *zero* rather than
|
||||
a curated tolerance list; the two exceptions are narrow — a module-level allowance for the COOP header
|
||||
Chromium drops (the e2e stacks serve plain http over container hostnames), and `allowConsole(re)` for
|
||||
a test whose own page provokes a message on purpose. `src/e2e-console-guard.test.ts` locks the wiring
|
||||
in the *unit* gate, since a spec importing `test` straight from Playwright — or minting a page with
|
||||
a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. Accepted cost: a page
|
||||
outliving its test can log late and fail the next one.
|
||||
- **The Ory-free specs run in all three engines; the Ory-backed ones stay on Chromium.**
|
||||
`visual.spec.ts` + `language.spec.ts` are side-effect-free, so three parallel runs don't collide,
|
||||
and a console message only appears in the engine that renders the page — the reason the per-test
|
||||
`@engines` tag is gone: the whole Ory-free suite is the engine matrix now (`ORY_FREE` in
|
||||
`visual.spec.ts` + `language.spec.ts` are side-effect-free, so parallel runs don't collide, and a
|
||||
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,
|
||||
where a second engine's run would race the first, so widening them means giving each engine its own
|
||||
stack. Screenshots are written per project name for the same reason. Decided 2026-08-05.
|
||||
so widening them means a stack per engine.
|
||||
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** Both git channels in
|
||||
`ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the destination, so
|
||||
`git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a source file that
|
||||
was gone. `src/ci-gate.test.ts` locks the flags as a
|
||||
*text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
|
||||
`release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
|
||||
guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard
|
||||
skipped. `README.md` is the one markdown a test reads — `release-tooling/contract-version.test.ts`
|
||||
checks its `apiVersion` samples — and a README-only change skips that check; accepted, because
|
||||
those samples are illustrative and the copies that matter (`examples/`, `views/`, the template) are
|
||||
gated. **Valid while no markdown file is rendered or executed.**
|
||||
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
||||
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs
|
||||
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
|
||||
file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's
|
||||
web-image build races another run's container creation on the `<project>-web` tag. Accepted for a
|
||||
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
|
||||
|
||||
## Docker only — no host tooling
|
||||
|
||||
@@ -316,102 +331,97 @@ docker compose -f compose.yml up --build -d # production
|
||||
|
||||
`README.md` serves two readers, in this order — preserve it when editing:
|
||||
|
||||
1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets
|
||||
the stack up (`docker compose up`, sign in) and a *minimal* plugin live. Nothing comes
|
||||
before Quick start — no philosophy, no rationale. Keep its commands copy-pasteable and the
|
||||
example plugin as small as possible; deeper detail lives in its own section, linked.
|
||||
2. **Returning developer (rest).** A **Contents** ToC immediately after Quick start, then
|
||||
sections ordered by **what a developer adopting Plainpages reaches for, in priority
|
||||
order** — not by architectural layering. The value that sets the order: getting up and
|
||||
running **building plugins** comes first, then **configuring and securing** the system
|
||||
(Configuration, Auth); the **inner workings** (Architecture) and ops/runbooks are
|
||||
deliberately deferred — they're not top of mind when starting out. Concretely: Overview →
|
||||
Users, groups & permissions → Building plugins → menu/blocks/interactivity →
|
||||
Configuration → Auth → Email → Architecture → Testing → Production → Observability → the
|
||||
JWT-rotation runbook → the Project-layout file map → Extending. When adding a section, place
|
||||
it by this value (how early an adopter needs it), not by where it sits in the stack.
|
||||
1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets the
|
||||
stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands
|
||||
copy-pasteable; deeper detail lives in its own section, linked.
|
||||
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
|
||||
by **what an adopter reaches for first**, not by architectural layering: Overview → Users, groups
|
||||
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
||||
Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
|
||||
map → Extending. Place a new section by how early an adopter needs it. **Users, groups &
|
||||
permissions precedes Building plugins** because a manifest's `permission:` gate is unreadable
|
||||
without the model, and it is the one home for that model.
|
||||
|
||||
**Users, groups & permissions precedes Building plugins** because a manifest's
|
||||
`permission:` gate is unreadable without the model, and operators need it as much as plugin
|
||||
authors. It is the one home for that model — the plugin and auth sections link to it rather
|
||||
than restating it.
|
||||
|
||||
When editing: put content in the section it belongs to (don't prepend rationale above Quick
|
||||
start); keep the ToC in sync when you add/rename/remove an `H2`/`H3`; and state each fact in
|
||||
one home, linking to it rather than restating (credentials, env vars, rotation steps).
|
||||
|
||||
**Don't document internals here.** How a script reaches a decision, why one run behaved
|
||||
differently from another, what a function guards — a developer doesn't need it day to day and
|
||||
can read it off the code or a run's log in seconds. Prose like that only makes the README
|
||||
longer and harder to consume, for humans and machines alike. It belongs in the code it
|
||||
describes, or nowhere. The README earns its length on what you cannot dig out: how to use and
|
||||
operate Plainpages, the external contracts, and one-time setup (secrets, accounts, tokens).
|
||||
Same test before adding a row to a table or the file map — a clause, not a paragraph.
|
||||
Keep the ToC in sync when you add/rename/remove an `H2`/`H3`. **Don't document internals** — how a
|
||||
script reaches a decision, what a function guards; a developer reads that off the code in seconds.
|
||||
The README earns its length on how to use and operate Plainpages, the external contracts, and
|
||||
one-time setup. A file-map or table row gets a clause, not a paragraph.
|
||||
|
||||
## Rules
|
||||
|
||||
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
|
||||
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or
|
||||
decorators. Import local modules with their `.ts` extension.
|
||||
- **No `.mjs`.** Write modules as `.ts` (Prio 1) — even standalone scripts run in bare
|
||||
`node:24` containers (the e2e mock servers, `examples/shifts-upstream/server.ts`): Node
|
||||
strips types and detects ESM from syntax, no package.json needed. If a file genuinely
|
||||
must be plain JavaScript, use `.js` (Prio 2); `"type": "module"` is already set in both
|
||||
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or decorators. Import
|
||||
local modules with their `.ts` extension.
|
||||
- **No `.mjs`.** Write modules as `.ts` — even standalone scripts run in bare `node:24` containers.
|
||||
If a file genuinely must be plain JavaScript, use `.js`; `"type": "module"` is set in both
|
||||
`package.json`s, so `.js` is ESM.
|
||||
- **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit.
|
||||
- Before finishing a change, run the typecheck and tests above; both must pass.
|
||||
- Tests use the built-in `node --test` runner — no test framework dependency.
|
||||
- English everywhere. Keep code comments short and information-dense. Self explained code
|
||||
without any comment at all is the preferred solution.
|
||||
- Do not comment about history in the code or README. Like "This function included X before,
|
||||
but it moved to Y".
|
||||
- Do not comment about the absence of things, if it is not very unexpected. Banned is things
|
||||
like "This function does not calculate pi, that is done in function Z".
|
||||
- Pin all dependencies and Docker images to exact, human-readable **semantic
|
||||
versions** — never ranges (`^`, `~`) and never digests/hashes. npm deps are kept
|
||||
exact by `.npmrc` (`save-exact=true`) + `npm ci`; the base image by tag (e.g.
|
||||
`node:24.16.0-alpine3.24`).
|
||||
- **`HOST_API_VERSION` is frozen at 1.0.0 until the first external install**, even for additive
|
||||
contract changes (i18n added four `RequestContext` fields and several barrel exports without a
|
||||
minor bump). Valid while nothing is installed against it: with no third-party plugin in the wild,
|
||||
a version bump can only produce noise. The promotion trigger is the first external plugin — from
|
||||
then on, follow the versioning table in README → Contract versioning as written. Decided 2026-08-03.
|
||||
**The frozen surface includes `views/partials/*.ejs`**, not just the manifest and the barrel: 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 (under this freeze the popover change dropped the `menu`
|
||||
partial's `open?` and rewrote its markup). Know the hole that leaves — discovery fails loud on a bad
|
||||
`apiVersion`, but `include("menu", { open: true })` silently ignores the option and a plugin styling
|
||||
`.menu > summary` silently loses it. Promotion must cover the partial vocabulary too. Added 2026-08-05.
|
||||
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version the
|
||||
plugin was built against — bumped by hand on rebuild, **never** the host's
|
||||
`HOST_API_VERSION` constant. Importing the constant makes every plugin always equal the
|
||||
host, so `checkApiVersion` can never fire and a breaking change slips through silently.
|
||||
- **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler
|
||||
per `{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed
|
||||
`%`-encoding — no manual path-slicing/decoding). Don't funnel many routes into one dispatcher
|
||||
that re-parses `ctx.url.pathname`: it duplicates the URL shape, ignores the router's params, and
|
||||
has to re-handle HEAD. Factor shared per-request setup (auth gate, `ctx.system` capability
|
||||
resolution, target fetch) into a small `withX` wrapper — see `examples/plugins/admin/`.
|
||||
- English everywhere.
|
||||
- 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
|
||||
by tag.
|
||||
- **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist*: its
|
||||
rules name exactly what carries the trailer, so a dependency outside them never escalates the
|
||||
release version and nothing fails to say so. A new manifest, compose file, custom manager or dep
|
||||
type is a decision: can it reach a running Plainpages? If yes it needs a rule; if no, record nothing
|
||||
and let it ride the next patch.
|
||||
- **`HOST_API_VERSION` *is* the release version.** Its `major.minor` must equal the release tag's, and
|
||||
both release paths refuse a tag that disagrees (`release-tooling/contract-version.ts`). The patch
|
||||
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
|
||||
against — bumped by hand on rebuild, **never** the host's `HOST_API_VERSION` constant. Importing
|
||||
the constant makes every plugin always equal the host, so `checkApiVersion` can never fire.
|
||||
- **Plugin route handlers are thin and per-route, keyed on `ctx.params`.** Register one handler per
|
||||
`{method, path}` in the manifest (the host extracts `:id`/`:name` and 404s malformed `%`-encoding).
|
||||
Don't funnel many routes into one dispatcher that re-parses `ctx.url.pathname`: it duplicates the
|
||||
URL shape, ignores the router's params, and has to re-handle HEAD. Factor shared per-request setup
|
||||
into a small `withX` wrapper — see `examples/plugins/admin/`.
|
||||
- **`handleRequest` (`src/http/app.ts`) is a known complexity hotspot** — ~160 lines tracking
|
||||
canonical host, static, locale, session + re-mint, CSRF, chrome, hooks, plugin routing, builtin
|
||||
routing, 405/404 and error mapping. The pure parts are already extracted and separately tested; what
|
||||
remains is orchestration. Planned split along those seams; don't grow it further without taking one
|
||||
out. Raised by the architecture review 2026-08-03, deliberately not done inside the i18n change.
|
||||
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer
|
||||
agents. Decided 2026-08-02, replacing the earlier run-after-every-implementation rule.
|
||||
routing, 405/404 and error mapping. The pure parts are already extracted and separately tested;
|
||||
what remains is orchestration. Planned split along those seams; don't grow it further without
|
||||
taking one out.
|
||||
- Reviews are maintainer-triggered (e.g. via the larv-review skill) — never auto-run reviewer agents.
|
||||
- **A user-visible string belongs in a catalog, not in the code or a view.** Core strings go in
|
||||
`src/i18n/locales/en-US.ts` (then every other locale, or the boot fails); a plugin's go in its own
|
||||
`i18n/`. Operator/developer-facing text — boot errors, log messages, guard messages — stays English.
|
||||
A pure view-model builder takes an optional `t` defaulting to its own English, so a unit test reads
|
||||
in words; handlers pass `ctx.t`.
|
||||
- **One verb per action in the English UI: sign in, sign out, create account.** Not "log in",
|
||||
"log out" or "sign up", inflections included — a second spelling for one button reads as a second
|
||||
thing; the noun ("a sign-in error", "the sign-in identifier") is unaffected. A plugin's catalog and
|
||||
every other locale follow the same rule in their own language. An unmapped Kratos id renders
|
||||
Kratos' own wording — map the id when it matters. **Held by the author, never by a test:** as the
|
||||
UI grows, slightly different wording is often the right call, and a check that fails the build on
|
||||
a word takes that judgment away. Maintainer's call 2026-08-05, dropping the guard that shipped
|
||||
with the rule.
|
||||
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POST:ing in for
|
||||
for example list pages with filters and pagination. Do: "ids=x&ids=y" and not "ids[]=x&ids[]=y"
|
||||
and not "ids=x,y".
|
||||
- **One verb per action in the English UI: sign in, sign out, create account.** Not "log in", "log
|
||||
out" or "sign up", inflections included — a second spelling for one button reads as a second thing;
|
||||
the noun ("a sign-in error") is unaffected. A plugin's catalog and every other locale follow the
|
||||
same rule in their own language. An unmapped Kratos id renders Kratos' own wording — map the id when
|
||||
it matters. **Held by the author, never by a test:** slightly different wording is often the right
|
||||
call, and a build-failing check takes that judgment away.
|
||||
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POSTing it, for
|
||||
example on list pages with filters and pagination. Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not
|
||||
`ids=x,y`.
|
||||
|
||||
## Comments
|
||||
|
||||
Default to **no comment**. Delete one that restates the adjacent code, repeats a convention used
|
||||
elsewhere, justifies self-evident code, or records history. Write one only for what a competent
|
||||
reader of *this* codebase could not infer: a surprising why, a footgun, an invariant, an external
|
||||
constraint. See [Prose discipline](#prose-discipline).
|
||||
|
||||
+11
-5
@@ -1,14 +1,20 @@
|
||||
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
||||
FROM node:24.19.0-alpine3.24
|
||||
|
||||
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
|
||||
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
|
||||
# Dev deps kept so typecheck/test run in-image.
|
||||
COPY package.json package-lock.json .npmrc /deps/
|
||||
RUN cd /deps && npm ci && mv node_modules /node_modules && rm -rf /deps
|
||||
|
||||
# The barrel as a package, so a plugin folder can own a package.json. Linked because it re-exports /app.
|
||||
RUN mkdir -p /node_modules/@plainpages && ln -s /app/plugin-api /node_modules/@plainpages/plugin-api
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Reproducible install from the lockfile. Dev deps kept so typecheck/test run in-image.
|
||||
COPY package.json package-lock.json .npmrc ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# The host uid running a lockfile edit has no home here, so npm's cache would land in unwritable /.
|
||||
ENV npm_config_cache=/tmp/.npm
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
CMD ["node", "src/server.ts"]
|
||||
|
||||
@@ -60,13 +60,42 @@ echo "$units" | grep -E '^. (tests|pass|fail) ' || true
|
||||
count=$(echo "$units" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | head -1 || true)
|
||||
[ "${count:-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
|
||||
# per-suite project name keeps a flaky teardown from leaking containers/volumes into the next suite.
|
||||
# --user: the runner writes screenshots + the report into the checkout, so they must belong to
|
||||
# whoever ran the gate — root-owned output needs sudo to delete, and a dev box may have none.
|
||||
e2e() {
|
||||
step "E2E: $1"
|
||||
local proj="plainpages-e2e-$(basename "$1" .yml | tr '.' '-')" # dots aren't valid in a compose project name
|
||||
local rc=0
|
||||
docker compose -p "$proj" -f compose.yml -f "$1" run --build --rm e2e || rc=$?
|
||||
docker compose -p "$proj" -f compose.yml -f "$1" run --user "$(id -u):$(id -g)" --build --rm e2e || rc=$?
|
||||
docker compose -p "$proj" -f compose.yml -f "$1" down -v >/dev/null 2>&1 || true
|
||||
[ "$rc" -eq 0 ] || { echo "E2E suite $1 failed (exit $rc)"; exit "$rc"; }
|
||||
}
|
||||
@@ -82,7 +111,7 @@ e2e e2e-tests/compose.full.yml # full browser flow: login (password + SSO),
|
||||
step "E2E: e2e-tests/compose.devstack.yml (dev-stack login: localhost works + 127.0.0.1 canonicalised)"
|
||||
devstack_files=(-f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml)
|
||||
rc=0
|
||||
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" run --build --rm e2e || rc=$?
|
||||
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" run --user "$(id -u):$(id -g)" --build --rm e2e || rc=$?
|
||||
docker compose -p plainpages-e2e-devstack "${devstack_files[@]}" down -v >/dev/null 2>&1 || true
|
||||
[ "$rc" -eq 0 ] || { echo "E2E suite e2e-tests/compose.devstack.yml failed (exit $rc)"; exit "$rc"; }
|
||||
|
||||
|
||||
+24
-11
@@ -1,5 +1,9 @@
|
||||
# Development overrides, merged automatically by `docker compose up`.
|
||||
# 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:
|
||||
web:
|
||||
command: node --watch src/server.ts
|
||||
@@ -13,28 +17,37 @@ services:
|
||||
CACHE_TEMPLATES: "false"
|
||||
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)
|
||||
# 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"
|
||||
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:
|
||||
- .:/app
|
||||
- /app/node_modules
|
||||
# Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise):
|
||||
# - ./config:/app/config:ro # your config/menu.ts — see examples/config/menu.ts for a template
|
||||
|
||||
# The seed grants what the installed plugins declare, so bootstrap must discover the same plugins
|
||||
# as web. Only dev needs saying: the base file gives both services the image's baked plugins/, and
|
||||
# it is the `.:/app` above — dev-only — that makes web diverge onto the host tree. Mirror it here
|
||||
# rather than in the base file, where it would instead desynchronise them (and collide with the
|
||||
# e2e stacks, which mount individual plugins *inside* this path).
|
||||
# Mirror web's source mount so bootstrap discovers the same plugins *and* runs the same code. Only
|
||||
# dev needs saying: the base file gives both services the image's baked copy, and it is the
|
||||
# `.:/app` above — dev-only — that makes web diverge onto the host tree. Without the mirror,
|
||||
# bootstrap silently runs whatever `src/` was baked at image-build time, so an edit to
|
||||
# bootstrap.ts appears to do nothing until someone remembers `--build`.
|
||||
# It belongs here and not in the base file, where it would desynchronise prod and collide with the
|
||||
# e2e stacks, which bind individual plugins *inside* /app/plugins.
|
||||
bootstrap:
|
||||
# Provisions the plugin databases web connects to above, as the dev superuser.
|
||||
environment:
|
||||
PLUGIN_DB_ADMIN_URL: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/ory
|
||||
PLUGIN_DB_URL: *plugin-db-url
|
||||
REQUIRE_SECURE_SECRETS: "false" # dev derives from the throwaway, as web does
|
||||
volumes:
|
||||
- ./plugins:/app/plugins:ro
|
||||
- .:/app
|
||||
|
||||
# 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
|
||||
# backs it (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.
|
||||
# backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
||||
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
|
||||
shifts-upstream:
|
||||
image: node:24.19.0-alpine3.24
|
||||
command: node /srv/server.ts
|
||||
@@ -45,7 +58,7 @@ services:
|
||||
# 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.
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.30.6
|
||||
image: axllent/mailpit:v1.31.0
|
||||
ports:
|
||||
- "8025:8025"
|
||||
restart: unless-stopped
|
||||
|
||||
+22
-4
@@ -17,10 +17,16 @@ services:
|
||||
CACHE_TEMPLATES: "true"
|
||||
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
|
||||
# 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"
|
||||
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/
|
||||
# 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:
|
||||
bootstrap:
|
||||
condition: service_completed_successfully
|
||||
@@ -30,17 +36,20 @@ services:
|
||||
condition: service_healthy
|
||||
hydra:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
# verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
|
||||
# Read-only — bootstrap is the only writer.
|
||||
volumes:
|
||||
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
|
||||
restart: unless-stopped
|
||||
|
||||
# Ory's storage only (Kratos/Keto/Hydra) — the web app never connects here.
|
||||
# init/init.sql creates one database per service. Dev defaults below; supply
|
||||
# The stack's storage: one database per Ory service (init/init.sql), plus one per plugin that
|
||||
# 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:
|
||||
image: postgres:18.4-alpine3.23
|
||||
image: postgres:18.6-alpine3.23
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-ory}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ory}
|
||||
@@ -127,6 +136,8 @@ services:
|
||||
condition: service_healthy
|
||||
keto:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
|
||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||
@@ -137,6 +148,13 @@ services:
|
||||
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
|
||||
KETO_WRITE_URL: http://keto:4467
|
||||
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:
|
||||
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
|
||||
command: node src/auth/bootstrap.ts
|
||||
|
||||
@@ -9,4 +9,8 @@ RUN npm ci
|
||||
|
||||
COPY e2e-tests/ ./
|
||||
|
||||
# Runs as the invoking `--user` so artifacts land owned by them, not root — and an arbitrary uid has
|
||||
# no passwd entry here, so its home would be the unwritable `/`. npm's cache follows HOME.
|
||||
ENV HOME=/tmp
|
||||
|
||||
CMD ["npx", "playwright", "test"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# visual suite (e2e-tests/compose.visual.yml) covers the design system; this is its full-stack counterpart:
|
||||
# real Postgres + Kratos + Keto + bootstrap + web, with a SHORT tokenizer TTL (ory/kratos/e2e.yml)
|
||||
# and zero clock skew, so the JWT lapses and re-mints within seconds instead of ~10m.
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml run --user "$(id -u):$(id -g)" --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.auth.yml down -v # tear down after
|
||||
services:
|
||||
web:
|
||||
@@ -33,9 +33,14 @@ services:
|
||||
# This stack mounts no plugins, so nothing declares a permission for the bootstrap to seed — and
|
||||
# the suite asserts that Keto's grants reach the JWT claim. Name one explicitly so there is
|
||||
# something to project.
|
||||
#
|
||||
# `admin` rides along on purpose: it was this setting's default until 2026-08-05 and is not a legal
|
||||
# `<resource>:<action>` name, so it is exactly the leftover an upgrading deployment carries. An
|
||||
# earlier revision made that fatal, and bootstrap gates `web` — so if the boot ever refuses operator
|
||||
# env again, `web` never turns healthy and this suite fails instead of CI going green over it.
|
||||
bootstrap:
|
||||
environment:
|
||||
ADMIN_PERMISSIONS: users:read
|
||||
ADMIN_PERMISSIONS: admin,users:read
|
||||
|
||||
# Shorten the session→JWT TTL and expose a network-resolvable base_url (ory/kratos/e2e.yml),
|
||||
# merged after the base config.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
# runs against the *plain* `docker compose up` topology and drives the browser on the HOST network, so
|
||||
# it sees http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host
|
||||
# browser does. Merge the dev override so the live stack is byte-for-byte `docker compose up`:
|
||||
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml run --user "$(id -u):$(id -g)" --build --rm e2e
|
||||
# docker compose -f compose.yml -f compose.override.yml -f e2e-tests/compose.devstack.yml down -v # tear down
|
||||
services:
|
||||
web:
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# login, menu filtering by permission, users/groups/OAuth2-clients CRUD + permission granting, a plugin page, logout. A
|
||||
# tiny same-origin gateway (proxy, e2e-tests/proxy.ts) fronts web + Kratos on one host so the browser's cookies
|
||||
# round-trip (ory/kratos/e2e-proxy.yml points Kratos at it); a mock OIDC provider backs the SSO test.
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.full.yml run --user "$(id -u):$(id -g)" --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.full.yml down -v # tear down after
|
||||
services:
|
||||
web:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
# it via the Kratos session and accepts. Runs against the real stack (Postgres + Kratos + Keto +
|
||||
# Hydra + bootstrap + web). The runner drives the flow over HTTP (fetch, manual cookies), so it
|
||||
# reaches the Ory services by their compose-network names.
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml run --user "$(id -u):$(id -g)" --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.oauth.yml down -v # tear down after
|
||||
services:
|
||||
web:
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Playwright E2E. Brings up the app + a Playwright runner and exercises the live pages (design
|
||||
# system, theme switch, mobile layout, CSRF, landing, 404, plugin gating, language switching) —
|
||||
# Ory-free, so it's fast.
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml run --user "$(id -u):$(id -g)" --build --rm e2e
|
||||
# docker compose -f compose.yml -f e2e-tests/compose.visual.yml down -v # tear down after
|
||||
# --build rebuilds the runner (the image bakes in e2e-tests/) so spec edits are picked up.
|
||||
# Screenshots + HTML report land in ./e2e-tests/artifacts/ (git-ignored).
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
import { expect, test } from "./console-guard.ts";
|
||||
|
||||
// Regression: the from-scratch dev experience the README/banner advertises must work. `docker compose
|
||||
// up`, open the printed login URL (http://localhost:3000), sign in as the seeded admin → you land on
|
||||
// the dashboard, signed in. Originally this dumped the user on http://127.0.0.1:3000/error?id=…
|
||||
// ("Page not found"): the banner printed `localhost` but kratos.yml hard-coded `127.0.0.1`, and a
|
||||
// host-scoped Kratos CSRF cookie can't cross `localhost`↔`127.0.0.1`, so the cross-host login POST
|
||||
// lost it and Kratos redirected to its error sink.
|
||||
// The from-scratch dev experience the banner advertises: `docker compose up`, open the printed
|
||||
// login URL, sign in as the seeded admin, land on the dashboard. A host-scoped Kratos CSRF cookie
|
||||
// cannot cross `localhost`↔`127.0.0.1`, so a cross-host login POST loses it and Kratos redirects to
|
||||
// its error sink; APP_URL canonicalises every off-host visitor onto one cookie host instead.
|
||||
//
|
||||
// The fix makes APP_URL the single source for the public host: the web app canonicalises every
|
||||
// off-host visitor onto it (so localhost / 127.0.0.1 / any alias funnel to one cookie host), Kratos'
|
||||
// browser URLs derive from it, and a real /error page replaces the 404.
|
||||
//
|
||||
// This is faithful to the user's environment: the runner uses the host network
|
||||
// (e2e-tests/compose.devstack.yml) against the plain `docker compose up` topology, so it sees
|
||||
// http://localhost:3000 (web) and http://127.0.0.1:4433 (Kratos public) exactly as a host browser
|
||||
// does. The proxied full-flow suite can't catch this regression — it fronts web + Kratos on one origin.
|
||||
// The runner is on the host network against the plain `docker compose up` topology, so it sees
|
||||
// http://localhost:3000 and http://127.0.0.1:4433 exactly as a host browser does. The proxied
|
||||
// full-flow suite cannot catch this — it fronts web + Kratos on one origin.
|
||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
|
||||
const ADMIN_PASSWORD = "admin";
|
||||
|
||||
|
||||
@@ -50,8 +50,8 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
});
|
||||
test.afterAll(async () => { await page.context().close(); });
|
||||
|
||||
// The list screens rebuild their query from the list state (sort/page/filter), so they are where
|
||||
// a chosen language used to get dropped — the core building blocks carry it now.
|
||||
// The list screens rebuild their query from the list state (sort/page/filter), so they are where a
|
||||
// chosen language is most easily dropped; the core building blocks carry it through.
|
||||
test("a sorted, paged admin list keeps the visitor's language", async () => {
|
||||
await page.goto("/admin/users?locale=sv-SE");
|
||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||
@@ -195,6 +195,17 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
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 () => {
|
||||
await page.goto("/dashboard");
|
||||
await page.locator("button.profile").click(); // open the profile dropdown
|
||||
|
||||
Generated
-2
@@ -1,12 +1,10 @@
|
||||
{
|
||||
"name": "plainpages-e2e",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "plainpages-e2e",
|
||||
"version": "0.1.0",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
{
|
||||
"name": "plainpages-e2e",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
|
||||
"type": "module",
|
||||
|
||||
@@ -56,9 +56,8 @@ test("every icon <use> resolves to a defined <symbol> (no broken graphics)", asy
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
// (The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component
|
||||
// (list-query/data-table/filter-bar) and exercised live with real data by the full-flow E2E's admin
|
||||
// Users list. The mock-data dashboard that used to host it in this Ory-free suite is gone.)
|
||||
// The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component and
|
||||
// exercised live by the full-flow E2E's admin Users list, so it has no Ory-free counterpart here.
|
||||
|
||||
test("theme switch flips the palette with no JavaScript", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
|
||||
+2
-2
@@ -5,7 +5,7 @@ across (or bind-mount your own) and restart.
|
||||
|
||||
| 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). |
|
||||
| [`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,11 +1,10 @@
|
||||
// Reference config/menu.ts — copy into the (empty) config/ mount at the repo root:
|
||||
// Reference config/menu.ts — copy into the empty config/ mount at the repo root:
|
||||
// cp examples/config/menu.ts config/menu.ts
|
||||
// config/ ships empty; mount your own or copy this in. Absent config = built-in defaults.
|
||||
// Absent config = built-in defaults.
|
||||
//
|
||||
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins —
|
||||
// the override always wins, applied before the per-user permission filter. Every field is
|
||||
// optional; delete one to fall back to the default.
|
||||
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README.md (The menu system).
|
||||
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — the
|
||||
// override always wins, applied before the per-user permission filter. Every field is optional.
|
||||
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system.
|
||||
|
||||
import { defineMenu } from "#menu-config";
|
||||
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
# Admin — the system-administration plugin
|
||||
|
||||
The Users / Groups / OAuth2-clients screens for running Plainpages itself. These used to be
|
||||
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
|
||||
until you opt in. Copy this folder into `plugins/` (it keeps the id and mount path `admin`, so the
|
||||
screens live at `/admin/*`) and restart:
|
||||
The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped as a **drop-in
|
||||
example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/`
|
||||
(it keeps the id and mount path `admin`, so the screens live at `/admin/*`) and restart:
|
||||
|
||||
```bash
|
||||
cp -r examples/plugins/admin plugins/admin
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so the
|
||||
section appears in the menu and the screens work immediately.
|
||||
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so
|
||||
the section appears in the menu and the screens work immediately. An older copy already in
|
||||
`plugins/` is yours — the host never updates it — so re-copy after a pull; a stale one stops the boot
|
||||
with a message naming it ([README → Upgrading](../../../README.md#upgrading)).
|
||||
|
||||
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`) — the nav
|
||||
labels included, which are catalog keys in `admin-shared.ts`. Each pure view-model builder takes an
|
||||
optional `t`; the handlers pass `ctx.t`, and the default is the plugin's own English so a unit test
|
||||
reads in words rather than keys. (README → [Languages](../../../README.md#languages-i18n).)
|
||||
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`), the nav
|
||||
labels included. Each pure view-model builder takes an optional `t` defaulting to the plugin's own
|
||||
English, so a unit test reads in words rather than keys.
|
||||
|
||||
## What it demonstrates — a *system* plugin
|
||||
|
||||
@@ -30,15 +30,14 @@ stack**, so they use the privileged **`ctx.system`** surface the host exposes to
|
||||
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
|
||||
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
|
||||
|
||||
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
|
||||
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
|
||||
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
|
||||
gated per route by its screen's `<resource>:<action>` permission, rendering the core building blocks
|
||||
in `views/`.
|
||||
`ctx.system` is populated only when the host wired those services. Where a capability is absent the
|
||||
screen degrades to a themed 503 rather than crashing. Everything else is an ordinary plugin:
|
||||
folder-discovered, gated per route by its screen's `<resource>:<action>` permission, rendering the
|
||||
core building blocks in `views/`.
|
||||
|
||||
Each screen is its own resource — `users`, `groups`, `oauth2-clients` — and each splits into `:read`
|
||||
and `:write`, so a helpdesk account can be given `users:read` alone. The nav is filtered by the same
|
||||
permissions: holding none of the three hides the Admin section entirely.
|
||||
Each screen is its own resource — `users`, `groups`, `oauth2-clients` — split into `:read` and
|
||||
`:write`, so a helpdesk account can be given `users:read` alone. Holding none of the six hides the
|
||||
Admin section entirely.
|
||||
|
||||
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a
|
||||
GUI, so the host's catalog (`ctx.declaredPermissions`) is the fixed list — and holding one is a
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// 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.
|
||||
|
||||
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 type { FieldConfig } from "./admin-users.ts";
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { PermissionDecl } from "#plugin-api";
|
||||
import type { PermissionDecl } from "@plainpages/plugin-api";
|
||||
import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts";
|
||||
|
||||
const declared: PermissionDecl[] = [
|
||||
@@ -46,9 +46,9 @@ test("buildPermissionPicker ticks what is held and carries each declaration's de
|
||||
assert.equal(picker.inheritedNote, undefined); // nothing is group-held here
|
||||
});
|
||||
|
||||
// The failure this prevents: a permission held through a group used to render unticked, so the page
|
||||
// said "not held" about a grant that reaches the JWT — and unticking it wrote nothing, which read as
|
||||
// a successful revoke. Inherited rows are ticked, disabled, and never posted.
|
||||
// An inherited permission rendered unticked would say "not held" about a grant that reaches the JWT,
|
||||
// and unticking it writes nothing, reading as a successful revoke. So inherited rows are ticked,
|
||||
// disabled, and never posted.
|
||||
test("buildPermissionPicker distinguishes a direct grant from one inherited through a group", () => {
|
||||
const picker = buildPermissionPicker({ action: "/x", declared, direct: ["users:write"], effective: ["users:read", "users:write"] });
|
||||
assert.deepEqual(picker.choices.map((c) => [c.name, c.checked, c.inherited]), [
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// what the installed plugins declare in code. Nothing here invents a name, which is why the old
|
||||
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
|
||||
|
||||
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "#plugin-api";
|
||||
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
|
||||
|
||||
const PERMISSION_NS = "Permission";
|
||||
const GRANTED = "granted";
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
memberView,
|
||||
parseSubject,
|
||||
} 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 userTuple = (group: string, n: number): RelationTuple =>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
|
||||
// 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 { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.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" };
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
// 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
|
||||
// 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 type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import { test } from "node:test";
|
||||
import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "#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";
|
||||
|
||||
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;
|
||||
req.method = opts.method ?? "GET";
|
||||
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,
|
||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
};
|
||||
@@ -27,21 +27,21 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
|
||||
|
||||
// ---- 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");
|
||||
// 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,
|
||||
// ungated, for anonymous visitors included.
|
||||
assert.equal(ADMIN_NAV.permission, undefined);
|
||||
assert.equal(ADMIN_NAV.href, undefined);
|
||||
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients"]);
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read"]);
|
||||
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", "plugin-settings:read"]);
|
||||
// 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.
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients"]);
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients"]);
|
||||
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", "Plugin settings"]);
|
||||
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
|
||||
});
|
||||
|
||||
|
||||
@@ -1,25 +1,24 @@
|
||||
// Shared plumbing for the admin example plugin: the section nav fragment, the admin-only gate, the
|
||||
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers
|
||||
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
|
||||
// everything imports the host only through the #plugin-api barrel.
|
||||
// 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.
|
||||
// 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";
|
||||
|
||||
// This plugin's English (its catalog, then the host's — the screens reuse core words like Cancel and
|
||||
// Search), for a view model built outside a request: its unit tests. At runtime the handlers pass
|
||||
// ctx.t, which reads this catalog in the visitor's locale first, then the host's.
|
||||
// This plugin's English — its catalog, then the host's — for a view model built outside a request,
|
||||
// i.e. its unit tests. At runtime the handlers pass ctx.t instead.
|
||||
export const ADMIN_EN: Translate = englishTranslator(enUS);
|
||||
|
||||
export const ADMIN_USERS_BASE = "/admin/users";
|
||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
||||
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.
|
||||
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
|
||||
// There is no `permissions` resource: permissions are declared in plugin code, not created here, so
|
||||
// holding a grant is a property of a user or a group and is edited on those two screens.
|
||||
export type AdminResource = "groups" | "oauth2-clients" | "users";
|
||||
export type AdminResource = "groups" | "oauth2-clients" | "plugin-settings" | "users";
|
||||
|
||||
export type AdminAction = "read" | "write";
|
||||
|
||||
@@ -28,11 +27,9 @@ export function permissionName(resource: AdminResource, action: AdminAction): st
|
||||
return `${resource}:${action}`;
|
||||
}
|
||||
|
||||
// This plugin's mapping from method to action: every screen reads on GET/HEAD and mutates on POST.
|
||||
// The manifest's route table and the in-handler guard both go through it rather than each spelling
|
||||
// the permission out, so they cannot drift into gating on different names. Deliberately local — as
|
||||
// a general mechanism it would make authorization a function of the transport verb, and a route
|
||||
// table should answer "what does this need?" on its own (AGENTS.md).
|
||||
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both
|
||||
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately
|
||||
// local: generalised, it would make authorization a function of the transport verb (AGENTS.md).
|
||||
export function actionForMethod(method: string): AdminAction {
|
||||
const verb = method.toUpperCase();
|
||||
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
||||
@@ -47,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_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_PLUGIN_SETTINGS_BASE, icon: "i-sliders", id: "plugin-settings", label: "admin.nav.pluginSettings", permission: permissionName("plugin-settings", "read") },
|
||||
],
|
||||
icon: "i-shield",
|
||||
id: "admin",
|
||||
@@ -54,13 +52,10 @@ export const ADMIN_NAV: NavNode = {
|
||||
};
|
||||
|
||||
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
|
||||
// declares the same permission, so the host enforces it before the handler runs; this is
|
||||
// defence-in-depth and what a direct unit test relies on. Returns the (non-null) user for the
|
||||
// handler to thread on. GuardError → /login or 403.
|
||||
// `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create form
|
||||
// or a delete-confirm page, whose only purpose is to start a write. Those refuse a reader honestly
|
||||
// instead of rendering a form whose submit would 403; the route table declares the same override, so
|
||||
// the two still cannot disagree.
|
||||
// declares the same permission, so this is defence-in-depth and what a direct unit test relies on.
|
||||
// `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create
|
||||
// form or a delete-confirm page — which refuses a reader rather than rendering a form whose submit
|
||||
// would 403. The route table declares the same override, so the two cannot disagree.
|
||||
export function requirePermission(ctx: RequestContext, resource: AdminResource, action?: AdminAction): User {
|
||||
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
||||
const permission = permissionName(resource, action ?? actionForMethod(ctx.req.method ?? "GET"));
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { Identity } from "#plugin-api";
|
||||
import type { Identity } from "@plainpages/plugin-api";
|
||||
import {
|
||||
buildUserFormModel,
|
||||
buildUsersListModel,
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
// Users admin screen: list Kratos identities (filter/sort/paginate) +
|
||||
// create/edit/deactivate/delete/trigger-recovery. Writes go only to Kratos via the admin client
|
||||
// (README "stateless"). Pure builders turn identities + the request URL into building-block view
|
||||
// models; below them are thin per-route handlers (keyed on ctx.params) over a shared `withUser` gate
|
||||
// — admin-only, CSRF-guarded, each returning a RouteResult (a view, or a redirect after a write — PRG).
|
||||
// create/edit/deactivate/delete/trigger-recovery. Pure builders turn identities + the request URL
|
||||
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
|
||||
// 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 { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||
|
||||
@@ -370,7 +369,7 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
|
||||
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
|
||||
// Self-lockout guard, matching the self-deactivate/self-delete ones: revoking your own grants can
|
||||
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
|
||||
// next request. Recovery would be a curl against Keto — not something the operator persona can do.
|
||||
// next request — leaving a `curl` against Keto as the only way back in.
|
||||
if (id === user.id && diff.revoke.length > 0) {
|
||||
ctx.log.warn("admin: refused a self-revoke of permissions", { actor: user.id, refused: diff.revoke.join(",") });
|
||||
const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke"));
|
||||
|
||||
@@ -83,9 +83,29 @@ const messages = {
|
||||
|
||||
"admin.nav.clients": "OAuth2 clients",
|
||||
"admin.nav.groups": "Groups",
|
||||
"admin.nav.pluginSettings": "Plugin settings",
|
||||
"admin.nav.section": "Admin",
|
||||
"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.title": "Not found",
|
||||
|
||||
|
||||
@@ -83,9 +83,29 @@ const messages: AdminMessages = {
|
||||
|
||||
"admin.nav.clients": "OAuth2-klienter",
|
||||
"admin.nav.groups": "Grupper",
|
||||
"admin.nav.pluginSettings": "Tilläggsinställningar",
|
||||
"admin.nav.section": "Administration",
|
||||
"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.title": "Hittades inte",
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// with nothing in the logs to explain it. Pin the two halves against each other here.
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { isValidPermissionName } from "#plugin-api";
|
||||
import { isValidPermissionName } from "@plainpages/plugin-api";
|
||||
import manifest from "./plugin.ts";
|
||||
|
||||
const routes = manifest.routes ?? [];
|
||||
@@ -35,17 +35,20 @@ test("every nav permission is one the manifest declares", () => {
|
||||
}
|
||||
};
|
||||
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}`);
|
||||
});
|
||||
|
||||
test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => {
|
||||
for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it
|
||||
// Three screens × read/write. There is deliberately no `permissions:` pair: permissions are
|
||||
// declared in plugin code, so holding one is edited on the user or group that holds it.
|
||||
// Three CRUD screens × read/write, plus read-only plugin settings — a screen that never writes
|
||||
// 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(), [
|
||||
"groups:read", "groups:write",
|
||||
"oauth2-clients:read", "oauth2-clients:write",
|
||||
"plugin-settings:read",
|
||||
"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";
|
||||
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
|
||||
});
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system.
|
||||
// These used to ship inside the core; they were extracted here so a fresh clone has no built-in admin
|
||||
// GUI. Copy this folder to plugins/admin (then restart) to enable it — see README → Quick start.
|
||||
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
|
||||
// this folder to plugins/admin (then restart) to enable it — see README → Quick start.
|
||||
//
|
||||
// It is a *system* plugin: its handlers reach the host's Ory admin clients (Kratos/Keto/Hydra) and the
|
||||
// instant-revoke hook via ctx.system, which the host populates when those services are wired (the dev
|
||||
// stack wires all of them). Where a capability is absent the screen degrades to a themed 503.
|
||||
// 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.
|
||||
|
||||
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 { 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 { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
|
||||
|
||||
@@ -26,9 +25,10 @@ const on = (resource: AdminResource) => (method: HttpMethod, path: string, handl
|
||||
const users = on("users");
|
||||
const groups = on("groups");
|
||||
const clients = on("oauth2-clients");
|
||||
const pluginSettings = on("plugin-settings");
|
||||
|
||||
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],
|
||||
|
||||
@@ -39,6 +39,7 @@ export default definePlugin({
|
||||
{ description: "Create and delete groups, and change their members and permissions", name: "groups:write" },
|
||||
{ description: "View OAuth2 clients", name: "oauth2-clients:read" },
|
||||
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
|
||||
{ description: "View the settings each installed plugin declares, and how they resolved", name: "plugin-settings:read" },
|
||||
],
|
||||
|
||||
routes: [
|
||||
@@ -70,5 +71,7 @@ export default definePlugin({
|
||||
clients("GET", "/clients/:id", clientsDetail),
|
||||
clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"),
|
||||
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,
|
||||
}) %>
|
||||
@@ -27,7 +27,7 @@ The plugin holds **no state** — data lives upstream (README → *Stateless*).
|
||||
|
||||
## 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.
|
||||
A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook).
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
// 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).
|
||||
|
||||
import type { PluralMessage } from "#plugin-api";
|
||||
import type { PluralMessage } from "@plainpages/plugin-api";
|
||||
|
||||
const messages = {
|
||||
"scheduling.field.assignee": "Assignee",
|
||||
|
||||
@@ -2,20 +2,21 @@
|
||||
// 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.
|
||||
|
||||
import { definePlugin } from "#plugin-api";
|
||||
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
||||
import { definePlugin } from "@plainpages/plugin-api";
|
||||
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
|
||||
// stateless). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream).
|
||||
const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000";
|
||||
const upstream = createUpstream(upstreamUrl);
|
||||
// stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it
|
||||
// over — which is after this manifest is built, hence the getter.
|
||||
let upstreamUrl = "";
|
||||
const upstream = createUpstream(() => upstreamUrl);
|
||||
|
||||
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
|
||||
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
|
||||
hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") },
|
||||
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
|
||||
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
|
||||
hooks: { onBoot: ({ settings }) => { upstreamUrl = settings.upstream; } },
|
||||
|
||||
// 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"
|
||||
@@ -45,4 +46,15 @@ export default definePlugin({
|
||||
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", 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",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -2,12 +2,12 @@ import assert from "node:assert/strict";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
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.
|
||||
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 {
|
||||
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,
|
||||
} 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 req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||
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,
|
||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
};
|
||||
@@ -35,27 +35,25 @@ const asView = (r: RouteResult | void) => {
|
||||
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", () => {
|
||||
assert.doesNotThrow(() => assertHttpUrl("http://shifts-upstream:4000", "SCHEDULING_UPSTREAM"));
|
||||
assert.doesNotThrow(() => assertHttpUrl("https://api.example.com/v1", "SCHEDULING_UPSTREAM"));
|
||||
assert.throws(() => assertHttpUrl("not a url", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*valid URL/); // unparseable
|
||||
assert.throws(() => assertHttpUrl("shifts-upstream:4000", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // missing // → parsed as a bogus scheme
|
||||
assert.throws(() => assertHttpUrl("ftp://host/x", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // wrong scheme
|
||||
test("the manifest declares its upstream as a URL setting the host validates", async () => {
|
||||
const manifest = (await import("./plugin.ts")).default;
|
||||
assert.deepEqual(manifest.settings?.map((s) => s.key), ["upstream"]);
|
||||
assert.equal(manifest.settings?.[0]?.type, "url"); // so a typo'd URL fails the boot, not every request
|
||||
assert.equal(manifest.settings?.[0]?.default, "http://shifts-upstream:4000"); // the dev compose's mock
|
||||
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 () => {
|
||||
const prev = process.env["SCHEDULING_UPSTREAM"];
|
||||
process.env["SCHEDULING_UPSTREAM"] = "nope://bad"; // read at import time below
|
||||
try {
|
||||
const manifest = (await import("./plugin.ts")).default;
|
||||
assert.equal(typeof manifest.hooks?.onBoot, "function");
|
||||
assert.throws(() => manifest.hooks!.onBoot!(), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"];
|
||||
else process.env["SCHEDULING_UPSTREAM"] = prev;
|
||||
}
|
||||
test("the client re-reads its base URL, so onBoot can bind it after the manifest is built", async () => {
|
||||
let baseUrl = "http://first:4000";
|
||||
const seen: string[] = [];
|
||||
const http = (async (url) => { seen.push(String(url)); return new Response("[]", { status: 200 }); }) as typeof fetch;
|
||||
const upstream = createUpstream(() => baseUrl, http);
|
||||
await upstream.list();
|
||||
baseUrl = "http://second:4000";
|
||||
await upstream.list();
|
||||
assert.deepEqual(seen, ["http://first:4000/shifts", "http://second:4000/shifts"]);
|
||||
});
|
||||
|
||||
// ---- 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");
|
||||
return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
|
||||
}) 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.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]);
|
||||
});
|
||||
|
||||
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
|
||||
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 () => {
|
||||
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 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.deepEqual(body, input);
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
// 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).
|
||||
|
||||
// One import from the host's #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";
|
||||
// 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 "@plainpages/plugin-api";
|
||||
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:
|
||||
@@ -49,26 +49,16 @@ export interface ShiftsUpstream {
|
||||
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`
|
||||
// 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.
|
||||
export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
|
||||
const base = baseUrl.replace(/\/+$/, "");
|
||||
// `baseUrl` is read per call: the plugin's settings arrive on onBoot, after the manifest that binds
|
||||
// these handlers has already been built.
|
||||
export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
|
||||
const base = (): string => baseUrl().replace(/\/+$/, "");
|
||||
return {
|
||||
async create(input) {
|
||||
const res = await fetchImpl(`${base}/shifts`, {
|
||||
const res = await fetchImpl(`${base()}/shifts`, {
|
||||
body: JSON.stringify(input),
|
||||
headers: { "content-type": "application/json" },
|
||||
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);
|
||||
},
|
||||
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);
|
||||
const data: unknown = await res.json();
|
||||
return Array.isArray(data) ? data.map(toShift) : [];
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// GET /shifts → 200 [ { id, title, assignee, start, end }, … ]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{"errors":null,"message":"not found","url":"https://gitea.larvit.se/api/swagger"}
|
||||
@@ -1,6 +1,14 @@
|
||||
-- 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,
|
||||
-- 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 keto;
|
||||
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;
|
||||
|
||||
Generated
+18
-6
@@ -1,16 +1,15 @@
|
||||
{
|
||||
"name": "plainpages",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "plainpages",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@larvit/log": "2.3.0",
|
||||
"ejs": "6.0.1",
|
||||
"lucide-static": "1.28.0"
|
||||
"lucide-static": "1.33.0",
|
||||
"postgres": "3.4.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ejs": "3.1.5",
|
||||
@@ -400,11 +399,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-static": {
|
||||
"version": "1.28.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.28.0.tgz",
|
||||
"integrity": "sha512-dC3VJwRFsjEVX7Iaq4rY88pm7Fi2OmOb8P0WRzXsUMgbt7sCmFX8bLhaDBeNW6JdRjuele+jKqqFaam4yr+Ygg==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.33.0.tgz",
|
||||
"integrity": "sha512-jNGgvTNcLUfVRX4N9PH9pVVTJzoph/BmYmgU838bYBQodkUJL4nAThkuymFz1x3OUYMhJxPndC7rdg1sxOPYKg==",
|
||||
"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": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
|
||||
+4
-5
@@ -1,26 +1,25 @@
|
||||
{
|
||||
"name": "plainpages",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=24"
|
||||
},
|
||||
"imports": {
|
||||
"#menu-config": "./src/ui/menu-config.ts",
|
||||
"#plugin-api": "./src/plugin-host/plugin-api.ts"
|
||||
"#menu-config": "./src/ui/menu-config.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node src/server.ts",
|
||||
"dev": "node --watch src/server.ts",
|
||||
"gen-jwks": "node src/auth/gen-jwks.ts",
|
||||
"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": {
|
||||
"@larvit/log": "2.3.0",
|
||||
"ejs": "6.0.1",
|
||||
"lucide-static": "1.28.0"
|
||||
"lucide-static": "1.33.0",
|
||||
"postgres": "3.4.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ejs": "3.1.5",
|
||||
|
||||
@@ -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";
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "@plainpages/plugin-api",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": "./index.ts"
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
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>**
|
||||
([GitHub mirror](https://github.com/larvit/plainpages))
|
||||
|
||||
## 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.
|
||||
|
||||
## Quick start
|
||||
@@ -21,7 +22,7 @@ so there is nothing to clone. In an empty directory, save this as `compose.yml`:
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
image: larvit/plainpages:0.0.2
|
||||
image: larvit/plainpages:{{VERSION}}
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
@@ -40,7 +41,7 @@ services:
|
||||
|
||||
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
|
||||
bootstrap:
|
||||
image: larvit/plainpages:0.0.2
|
||||
image: larvit/plainpages:{{VERSION}}
|
||||
command: node src/auth/bootstrap.ts
|
||||
depends_on:
|
||||
kratos:
|
||||
@@ -53,7 +54,7 @@ services:
|
||||
restart: "on-failure:5"
|
||||
|
||||
postgres:
|
||||
image: postgres:18.4-alpine3.23
|
||||
image: postgres:18.6-alpine3.23
|
||||
environment:
|
||||
POSTGRES_DB: ory
|
||||
POSTGRES_PASSWORD: ory
|
||||
@@ -130,7 +131,7 @@ services:
|
||||
|
||||
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.30.1
|
||||
image: axllent/mailpit:v1.31.0
|
||||
ports:
|
||||
- "8025:8025"
|
||||
restart: unless-stopped
|
||||
@@ -142,7 +143,7 @@ volumes:
|
||||
Extract the Ory config the image ships, then start:
|
||||
|
||||
```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
|
||||
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`:
|
||||
|
||||
```ts
|
||||
import { definePlugin } from "#plugin-api";
|
||||
import { definePlugin } from "@plainpages/plugin-api";
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "1.0.0",
|
||||
apiVersion: "0.2.0",
|
||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||
routes: [
|
||||
{ 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);
|
||||
});
|
||||
@@ -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", "minor"), "v1.3.0");
|
||||
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", () => {
|
||||
@@ -36,7 +36,7 @@ export function nextVersion(latestTag: string, level: Bump): string {
|
||||
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")) {
|
||||
const [, , latestTag, ...updateTypes] = process.argv;
|
||||
process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes)));
|
||||
+42
-3
@@ -1,9 +1,41 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"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,
|
||||
"commitBody": "Release-Bump: {{{updateType}}}",
|
||||
"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",
|
||||
"matchDatasources": ["docker"],
|
||||
@@ -27,8 +59,15 @@
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, auto-release)",
|
||||
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/renovate.yml"],
|
||||
"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": ["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\"']*)"],
|
||||
"depNameTemplate": "node",
|
||||
"datasourceTemplate": "docker"
|
||||
|
||||
+85
-11
@@ -5,7 +5,10 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
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) =>
|
||||
new Response(body === undefined ? null : JSON.stringify(body), {
|
||||
@@ -33,19 +36,30 @@ test("permissionTuple grants a permission to user:<id> in the Permission namespa
|
||||
test("seedPermissions unions ADMIN_PERMISSIONS (empty by default) with the discovered plugins' declared permissions", () => {
|
||||
// Clean clone: no ADMIN_PERMISSIONS, the scheduling plugin declares its two names → the demo admin
|
||||
// holds exactly what the installed plugins gate on, derived from discovery, not hardcoded here.
|
||||
assert.deepEqual(seedPermissions(undefined, ["scheduling:read", "scheduling:write"]), ["scheduling:read", "scheduling:write"]);
|
||||
const names = (env: string | undefined, declared: string[]): string[] => seedPermissions(env, declared).permissions;
|
||||
assert.deepEqual(names(undefined, ["scheduling:read", "scheduling:write"]), ["scheduling:read", "scheduling:write"]);
|
||||
// No plugins → nothing to grant. A host-invented base would be a permission that gates nothing.
|
||||
assert.deepEqual(seedPermissions(undefined, []), []);
|
||||
assert.deepEqual(seedPermissions("ops:read, ops:write ", ["inventory:read"]), ["ops:read", "ops:write", "inventory:read"]); // env trimmed + extended
|
||||
assert.deepEqual(seedPermissions("scheduling:read", ["scheduling:read"]), ["scheduling:read"]); // dedup, no double grant
|
||||
assert.deepEqual(seedPermissions(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
|
||||
assert.deepEqual(names(undefined, []), []);
|
||||
assert.deepEqual(names("ops:read, ops:write ", ["inventory:read"]), ["ops:read", "ops:write", "inventory:read"]); // env trimmed + extended
|
||||
assert.deepEqual(names("scheduling:read", ["scheduling:read"]), ["scheduling:read"]); // dedup, no double grant
|
||||
assert.deepEqual(names(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
|
||||
});
|
||||
|
||||
test("seedPermissions refuses an ADMIN_PERMISSIONS name that isn't <resource>:<action>", () => {
|
||||
// The operator's env is the one remaining hand-typed path; a manifest's names were checked at
|
||||
// discovery. `admin` would otherwise write a tuple that gates nothing, with no error anywhere.
|
||||
assert.throws(() => seedPermissions("admin", []), /ADMIN_PERMISSIONS.*<resource>:<action>.*admin/s);
|
||||
assert.throws(() => seedPermissions("users:read,Bad Name", []), /Bad Name/);
|
||||
// Bootstrap gates `web`, so it must never refuse to start over operator env — a leftover
|
||||
// ADMIN_PERMISSIONS would otherwise brick the whole stack. Drop what it can't use, report it, seed
|
||||
// the rest.
|
||||
test("seedPermissions drops an ADMIN_PERMISSIONS name that isn't <resource>:<action>, and never throws", () => {
|
||||
const legacy = seedPermissions("admin", ["users:read"]);
|
||||
assert.deepEqual(legacy, { ignored: ["admin"], permissions: ["users:read"] });
|
||||
|
||||
const mixed = seedPermissions("admin, ops:read ,Bad Name", ["users:read"]);
|
||||
assert.deepEqual(mixed, { ignored: ["admin", "Bad Name"], permissions: ["ops:read", "users:read"] });
|
||||
|
||||
// Whatever an operator puts there, the boot survives it — that is the property, not the parsing.
|
||||
for (const value of ["admin", "Bad Name", ":", "::", "a".repeat(200), ",,,", "ADMIN", "1"]) {
|
||||
assert.doesNotThrow(() => seedPermissions(value, ["users:read"]), value);
|
||||
assert.deepEqual(seedPermissions(value, ["users:read"]).permissions.includes("users:read"), true, value);
|
||||
}
|
||||
});
|
||||
|
||||
test("seedAdmin on a fresh stack creates the identity and grants every permission (one tuple each)", async () => {
|
||||
@@ -140,3 +154,63 @@ test("ensureJwks generates a key only when the file is absent", () => {
|
||||
assert.equal(ensureJwks(path, { exists: () => true, write }), false);
|
||||
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");
|
||||
});
|
||||
|
||||
+95
-33
@@ -8,10 +8,15 @@
|
||||
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
||||
import { existsSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.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 { 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) -----------------------
|
||||
|
||||
@@ -29,21 +34,20 @@ export function permissionTuple(userId: string, permission: string) {
|
||||
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
|
||||
}
|
||||
|
||||
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, empty by default)
|
||||
// unioned with every discovered plugin's declared permission names (a route/nav `permission` is a
|
||||
// coarse permission — granted as a Keto `Permission:<name>#granted` tuple). So the host names no plugin, yet a
|
||||
// dropped-in plugin's permissions are seeded out of the box. Deduped, order-stable, blanks dropped.
|
||||
// The base is empty because permissions are `<resource>:<action>` and every one of them is owned by
|
||||
// the plugin that gates on it — a host-invented default would gate nothing.
|
||||
// ADMIN_PERMISSIONS (empty by default) unioned with every discovered plugin's declared names, so
|
||||
// the host names no plugin yet a dropped-in one is seeded out of the box.
|
||||
//
|
||||
// ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the
|
||||
// same `<resource>:<action>` rule discovery applies to a manifest — fail loud rather than write a
|
||||
// tuple that gates nothing. A declared name has already passed that check at discovery.
|
||||
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): string[] {
|
||||
// same `<resource>:<action>` rule as a manifest — but *dropped with a warning*, never fatal:
|
||||
// fail-loud belongs at the manifest boundary where a developer authored the mistake, whereas this
|
||||
// is operator env and bootstrap gates `web`, so the whole stack must not refuse to start over a
|
||||
// stale variable. The name it would have written gates nothing anyway.
|
||||
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): { ignored: string[]; permissions: string[] } {
|
||||
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
||||
const configured = clean((adminPermissionsEnv ?? "").split(","));
|
||||
const bad = configured.filter((name) => !isValidPermissionName(name));
|
||||
if (bad.length > 0) throw new Error(`bootstrap: ADMIN_PERMISSIONS must be <resource>:<action> names, e.g. "things:read"; got ${bad.join(", ")}`);
|
||||
return [...new Set([...configured, ...clean(declaredNames)])];
|
||||
const ignored = configured.filter((name) => !isValidPermissionName(name));
|
||||
const valid = configured.filter((name) => isValidPermissionName(name));
|
||||
return { ignored, permissions: [...new Set([...valid, ...clean(declaredNames)])] };
|
||||
}
|
||||
|
||||
// --- JWKS safety net -----------------------------------------------------------------
|
||||
@@ -142,7 +146,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
|
||||
// --- CLI (the bootstrap container entrypoint) ----------------------------------------
|
||||
|
||||
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.
|
||||
const log = createLogger({
|
||||
format: env["LOG_FORMAT"] === "json" ? "json" : "text",
|
||||
@@ -151,26 +155,84 @@ async function main() {
|
||||
// runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls.
|
||||
await runWithLog(log, async () => {
|
||||
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
|
||||
|
||||
// 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.
|
||||
const declared = declaredPermissions(await discoverPlugins()).map((decl) => decl.name);
|
||||
const permissions = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
|
||||
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
||||
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
||||
const result = await seedAdmin({
|
||||
email,
|
||||
fetchImpl: tracedFetch,
|
||||
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
|
||||
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
|
||||
password,
|
||||
permissions,
|
||||
});
|
||||
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
|
||||
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
|
||||
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
|
||||
// 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
|
||||
}
|
||||
|
||||
// A database and login role for each plugin that asked for one. It happens here because bootstrap
|
||||
// holds the stack's only provisioning credentials — web derives the same password and connects as
|
||||
// the plugin's own role.
|
||||
export async function provisionPluginStorage(env: Env, plugins: Plugin[], log: Log, provision = provisionStorage): Promise<void> {
|
||||
const ids = storagePluginIds(plugins);
|
||||
const adminUrl = env["PLUGIN_DB_ADMIN_URL"];
|
||||
// Still connect with nothing to provision, as long as storage is configured: uninstalling the
|
||||
// last storage plugin is exactly when an orphaned database needs naming.
|
||||
if (ids.length === 0 && !adminUrl) return;
|
||||
if (!adminUrl) throw new Error(`bootstrap: PLUGIN_DB_ADMIN_URL must be set — these plugins declare storage: ${ids.join(", ")}`);
|
||||
// Provisioned here, connected to from web: a different server means the role is created in one
|
||||
// place and looked for in another, surfacing inside a plugin as "password authentication failed".
|
||||
// Warned, not refused — web reaching a pooler that cannot run CREATE DATABASE is a legitimate split.
|
||||
const mismatch = serverMismatch(adminUrl, env["PLUGIN_DB_URL"]);
|
||||
if (mismatch) log.warn("PLUGIN_DB_ADMIN_URL and PLUGIN_DB_URL name different servers", { servers: mismatch });
|
||||
const result = await provision({
|
||||
adminUrl,
|
||||
connectionLimit: resolvePluginDbConnectionLimit(env),
|
||||
pluginIds: ids,
|
||||
secret: resolvePluginDbSecret(env),
|
||||
});
|
||||
if (result.provisioned.length > 0) log.info("plugin storage provisioned", { databases: result.provisioned.join(", ") });
|
||||
// Never dropped, so an uninstalled plugin's data outlives it — say so, or nobody can find it.
|
||||
if (result.orphans.length > 0) {
|
||||
log.warn("plugin databases no installed plugin claims", { databases: result.orphans.join(", ") });
|
||||
}
|
||||
}
|
||||
|
||||
// Describes the disagreement, or null when they agree (or when web's URL is unset — that is web's
|
||||
// own boot error to raise, naming the plugin that wanted storage).
|
||||
export function serverMismatch(adminUrl: string, webUrl: string | undefined): string | null {
|
||||
if (!webUrl) return null;
|
||||
const [admin, web] = [safeHostPort(adminUrl), safeHostPort(webUrl)];
|
||||
if (admin === null || web === null || admin === web) return null; // a malformed URL fails in config.ts
|
||||
return `${admin} vs ${web}`;
|
||||
}
|
||||
|
||||
function safeHostPort(url: string): string | null {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return `${parsed.hostname}:${parsed.port || "5432"}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
|
||||
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
|
||||
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);
|
||||
if (ignored.length > 0) {
|
||||
log.warn("ignoring ADMIN_PERMISSIONS entries that are not <resource>:<action>", { ignored: ignored.join(", ") });
|
||||
}
|
||||
const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local";
|
||||
const password = env["ADMIN_PASSWORD"] ?? "admin";
|
||||
const result = await seedAdmin({
|
||||
email,
|
||||
fetchImpl: tracedFetch,
|
||||
ketoWriteUrl: env["KETO_WRITE_URL"] ?? "http://keto:4467",
|
||||
kratosAdminUrl: env["KRATOS_ADMIN_URL"] ?? "http://kratos:4434",
|
||||
password,
|
||||
permissions,
|
||||
});
|
||||
log.info("admin seeded", { created: result.created, id: result.id, permissions: result.permissions.join(", ") });
|
||||
// The banner is human-facing UX (the first-run "you're ready" block), not a log event — print raw.
|
||||
console.log(firstRunBanner({ appUrl: env["APP_URL"] ?? "http://localhost:3000", email, password }));
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|
||||
|
||||
+8
-16
@@ -1,20 +1,12 @@
|
||||
// Optional revocation denylist: instant permission/session revoke without putting Keto
|
||||
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
|
||||
// Optional revocation denylist: instant permission/session revoke without putting Keto back on the
|
||||
// hot path. Off by default — enable with REVOCATION_DENYLIST=true. An admin action records the
|
||||
// subject as revoked-now; the hot path then rejects that subject's pre-revoke tokens at once,
|
||||
// forcing a re-mint (which re-reads permissions from Keto, or clears a now-dead session).
|
||||
//
|
||||
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked permission or a
|
||||
// killed session only takes effect when the token is next minted (re-login / TTL refresh) —
|
||||
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
|
||||
// account) that lag is too long. An admin action records the subject as revoked-now and the
|
||||
// hot path then rejects that subject's pre-revoke tokens at once, forcing a re-mint (which
|
||||
// re-reads permissions from Keto, or clears a now-dead session).
|
||||
//
|
||||
// Cost & scope: an in-memory, auto-evicting Map — no database, like the JWKS cache, so it
|
||||
// stays inside the stateless model. A token carries `iat`, so a *fresh* re-login (iat after
|
||||
// the revoke) passes while every token minted before the revoke is rejected. Entries self-evict
|
||||
// after one token TTL, by which point any pre-revoke token has expired anyway. Single-process:
|
||||
// instant on the instance that handled the revoke; across replicas/restarts the guarantee
|
||||
// falls back to the token TTL (the gap is just no longer closed early). Back it with a shared
|
||||
// store for hard multi-instance instant-revoke.
|
||||
// An in-memory, auto-evicting Map — no database, so it stays inside the stateless model. Entries
|
||||
// self-evict after one token TTL, by which point any pre-revoke token has expired anyway.
|
||||
// Single-process: instant on the instance that handled the revoke, elsewhere the guarantee falls
|
||||
// back to the token TTL. Back it with a shared store for hard multi-instance instant-revoke.
|
||||
|
||||
export interface Denylist {
|
||||
// Hot-path check: is a token for `sub`, issued at `iat` (unix sec), revoked? A token minted
|
||||
|
||||
@@ -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"), {
|
||||
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",
|
||||
id: "field-code",
|
||||
inputmode: "numeric",
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { Flow, FlowType, UiNode } from "./kratos-public.ts";
|
||||
export interface FlowField {
|
||||
autocomplete?: string;
|
||||
error?: { text: string };
|
||||
hint?: string; // muted helper text under the input
|
||||
icon?: string; // Lucide sprite id for the input
|
||||
id: string;
|
||||
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 } : {}),
|
||||
...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}),
|
||||
...(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 } : {}),
|
||||
...(value ? { value } : {}),
|
||||
};
|
||||
|
||||
+3
-5
@@ -1,8 +1,6 @@
|
||||
// Auth guards: in-handler authorization, the imperative counterpart to the
|
||||
// declarative route `permission` gate. The middleware already verified the session JWT and put
|
||||
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
|
||||
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
|
||||
// Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules.
|
||||
// In-handler authorization, the imperative counterpart to the declarative route `permission` gate.
|
||||
// `requireSession` asserts (throws GuardError, which app.ts maps to a response); `can`/`check` are
|
||||
// predicates a handler branches on. `check` is the one live Keto call, for relationship rules.
|
||||
import type { RequestContext, User } from "../http/context.ts";
|
||||
import type { KetoClient } from "./keto-client.ts";
|
||||
import { localPath } from "../http/safe-url.ts";
|
||||
|
||||
+2
-4
@@ -231,10 +231,8 @@ function logout(kratos: KratosPublic, secureCookies: boolean): BuiltinRoute["han
|
||||
}
|
||||
|
||||
// Kratos' self-service error sink (kratos.yml flows.error.ui_url → /error). A flow that fails a
|
||||
// security/expiry check redirects the browser here with ?id=<uuid>. Render a themed page with a
|
||||
// path back into sign-in instead of the catch-all 404 ("Page not found") it used to hit. The
|
||||
// canonical-host redirect prevents the common cause (a lost cross-host CSRF cookie); this is the
|
||||
// honest fallback for any genuine flow error. The id is shown only for support reference.
|
||||
// security/expiry check redirects the browser here with ?id=<uuid>; render a themed page with a
|
||||
// path back into sign-in rather than the catch-all 404. The id is shown for support reference only.
|
||||
const errorSink = (ctx: RequestContext): RouteResult =>
|
||||
({ data: { id: ctx.url.searchParams.get("id") }, view: "error" });
|
||||
|
||||
|
||||
@@ -29,6 +29,14 @@ test("the commit-hash image is pushed even when the gate no-ops", () => {
|
||||
assert.doesNotMatch(step("docker push"), /^\s*if:/m);
|
||||
});
|
||||
|
||||
test("every E2E suite the gate runs writes its artifacts as the invoking user", () => {
|
||||
// The documented hand-run commands carry the same flag (src/compose.test.ts).
|
||||
const runs = gate.split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
|
||||
assert.equal(runs.length, 2, "the suite helper and the devstack run");
|
||||
for (const line of runs)
|
||||
assert.match(line, /--user "\$\(id -u\):\$\(id -g\)"/, `runs as the caller: ${line.trim()}`);
|
||||
});
|
||||
|
||||
test("only *.md counts as docs; a dirty tree and a rename both count as changed", () => {
|
||||
assert.ok(gate.includes("\\.md$"), "the non-docs match is a *.md suffix test");
|
||||
assert.match(gate, /git status --porcelain --no-renames/, "uncommitted code and a staged rename can never be skipped over");
|
||||
|
||||
+55
-4
@@ -6,9 +6,13 @@
|
||||
// by running the stack; this catches edits.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
|
||||
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
|
||||
const composeFiles = (dir: string) =>
|
||||
readdirSync(new URL(`../${dir}`, import.meta.url))
|
||||
.filter((f) => f.startsWith("compose.") && f.endsWith(".yml"))
|
||||
.map((f) => `${dir}${f}`);
|
||||
const compose = read("compose.yml");
|
||||
const override = read("compose.override.yml");
|
||||
const visual = read("e2e-tests/compose.visual.yml");
|
||||
@@ -40,10 +44,11 @@ test("long-running Ory services declare readiness healthchecks", () => {
|
||||
`${svc} probes :${port}/health/ready`);
|
||||
});
|
||||
|
||||
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");
|
||||
// hydra: the OAuth2 login/consent handler talks to its admin API.
|
||||
for (const svc of ["kratos", "keto", "hydra"])
|
||||
// hydra: the OAuth2 login/consent handler talks to its admin API. postgres: a plugin declaring
|
||||
// `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`),
|
||||
`web waits for ${svc} healthy`);
|
||||
});
|
||||
@@ -74,6 +79,21 @@ test("prod base supplies the app secret via env and mounts no source; dev overri
|
||||
assert.match(compose, /POSTGRES_PASSWORD:\s*\$\{POSTGRES_PASSWORD\b/, "postgres password via env");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
// 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.
|
||||
@@ -89,6 +109,37 @@ test("a one-shot bootstrap seeds the stack before web starts", () => {
|
||||
"web waits for bootstrap to finish");
|
||||
});
|
||||
|
||||
test("deps live above WORKDIR, so no mount creates a root-owned dir in the checkout", () => {
|
||||
// A volume at /app/node_modules would leave a root-owned dir in the checkout (AGENTS.md).
|
||||
const dockerfile = read("Dockerfile");
|
||||
// split() returns the whole file when the marker is missing, widening "before" to "anywhere".
|
||||
assert.ok(dockerfile.includes("WORKDIR /app"), "the app dir is /app");
|
||||
const beforeWorkdir = dockerfile.split("WORKDIR /app")[0]!;
|
||||
assert.match(beforeWorkdir, /npm ci/, "npm ci runs before WORKDIR /app");
|
||||
assert.match(beforeWorkdir, /mv\s+node_modules\s+\/node_modules/, "and its tree lands at /node_modules");
|
||||
|
||||
for (const f of [...composeFiles(""), ...composeFiles("e2e-tests/")])
|
||||
assert.ok(!read(f).includes("/app/node_modules"), `${f} mounts nothing at /app/node_modules`);
|
||||
});
|
||||
|
||||
test("the E2E runner writes its artifacts as the invoking user, never as root", () => {
|
||||
// Same trap as the node_modules mountpoint above, but the runner must write into the checkout,
|
||||
// so the fix is the uid: root-owned output needs sudo to delete, which a dev box may not have.
|
||||
// Matched independently of flag order, and counted: a reordered flag that slips out of the
|
||||
// filter would otherwise leave that command silently unguarded.
|
||||
const documented = [read("README.md"), ...composeFiles("e2e-tests/").map(read)]
|
||||
.join("\n").split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
|
||||
assert.equal(documented.length, 6, "5 compose headers + 1 README block");
|
||||
for (const l of documented)
|
||||
assert.match(l, /--user "\$\(id -u\):\$\(id -g\)"/, `passes the uid: ${l.trim()}`);
|
||||
// An absent mount source is daemon-created as root, and then that uid can't write it at all.
|
||||
assert.ok(existsSync(new URL("../e2e-tests/artifacts/.gitkeep", import.meta.url)),
|
||||
"the mount point exists in the checkout");
|
||||
const gitignore = read(".gitignore");
|
||||
assert.match(gitignore, /^\/e2e-tests\/artifacts\/\*$/m, "its output stays ignored");
|
||||
assert.match(gitignore, /^!\/e2e-tests\/artifacts\/\.gitkeep$/m, "the mount point stays tracked");
|
||||
});
|
||||
|
||||
test("the visual E2E does not drag in the Ory stack", () => {
|
||||
// web's Ory deps are reset for E2E (the dashboard is mock data — no Ory needed).
|
||||
assert.match(visual, /depends_on:\s*!reset\b/, "E2E resets web's depends_on");
|
||||
|
||||
+38
-1
@@ -1,6 +1,6 @@
|
||||
import assert from "node:assert/strict";
|
||||
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
|
||||
// thing a hardened deploy must supply.
|
||||
@@ -9,6 +9,43 @@ const secureEnv = {
|
||||
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", () => {
|
||||
const c = loadConfig({});
|
||||
assert.equal(c.port, 3000);
|
||||
|
||||
+42
-9
@@ -1,17 +1,40 @@
|
||||
// Config loaded once from the environment at boot: Ory endpoints, cookie/CSRF
|
||||
// secrets, JWKS location, listen port, behaviour toggles. Fail-loud — a bad value, a
|
||||
// missing enforced secret, a bad URL, or an out-of-range port throws here, never at
|
||||
// request time.
|
||||
//
|
||||
// Environment-agnostic (AGENTS.md): the app never asks "which environment am I?". Every
|
||||
// behaviour that used to ride on NODE_ENV is its own explicit toggle — `CACHE_TEMPLATES`,
|
||||
// `REQUIRE_SECURE_SECRETS`. Clean-clone (README): every value has a working dev default,
|
||||
// so `docker compose up` runs with zero config; a hardened deploy sets the toggles it wants.
|
||||
// Config loaded once from the environment at boot. Fail-loud — a bad value, a missing enforced
|
||||
// secret, a bad URL or an out-of-range port throws here, never at request time. Every value has a
|
||||
// working dev default, so `docker compose up` runs with zero config.
|
||||
|
||||
// Log verbosity, most→least severe; "none" silences everything (matches @larvit/log's levels).
|
||||
export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const;
|
||||
export 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 {
|
||||
appUrl: string | undefined; // canonical public URL; set ⇒ off-host visitors are redirected here. Unset ⇒ no redirect (explicit toggle)
|
||||
cacheTemplates: boolean;
|
||||
@@ -30,7 +53,10 @@ export interface Config {
|
||||
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)
|
||||
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;
|
||||
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
|
||||
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
|
||||
secureCookies: boolean;
|
||||
@@ -156,7 +182,14 @@ export function loadConfig(env: Env = process.env): Config {
|
||||
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
|
||||
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
|
||||
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),
|
||||
requireSecureSecrets: requireSecure,
|
||||
// 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
|
||||
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
|
||||
|
||||
+11
-17
@@ -27,15 +27,12 @@ import type { MenuConfig } from "../ui/menu-config.ts";
|
||||
import { loadI18n } from "../i18n/load.ts";
|
||||
|
||||
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
||||
// The admin screens ship as a drop-in example plugin; the HTTP-level admin tests mount it via
|
||||
// createApp (with stub Ory clients on ctx.system + views from examples/plugins) exactly as an
|
||||
// operator would after copying it into plugins/.
|
||||
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
|
||||
// ctx.system, views from examples/plugins — exactly as an operator would after copying it in.
|
||||
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
|
||||
const adminPlugin: Plugin = { ...adminManifest, id: "admin" };
|
||||
|
||||
// A session JWT signed with a throwaway test key — the verify path. Wired into the shared
|
||||
// `server` (and the per-test apps) so a request can present a valid session; the dashboard and the
|
||||
// gated routes need one. `staticJwks([ecJwk])` is the matching verify side.
|
||||
// A session JWT signed with a throwaway test key; `staticJwks([ecJwk])` is the matching verify side.
|
||||
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||
const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
|
||||
const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url");
|
||||
@@ -101,8 +98,7 @@ test("plugins replace either landing: `home` owns the public /, `dashboard` owns
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-home-"));
|
||||
mkdirSync(join(dir, "portal", "views"), { recursive: true });
|
||||
writeFileSync(join(dir, "portal", "views", "welcome.ejs"), `<h1>Welcome to <%= brand %></h1><a href="/login">Sign in</a>`);
|
||||
// The dashboard view renders the native app shell from ctx.chrome — the blessed plugin ergonomics:
|
||||
// its own title/body, the global menu (chrome.nav), the signed-in user, the Sign-out CSRF token.
|
||||
// The dashboard view renders the native app shell from ctx.chrome.
|
||||
writeFileSync(join(dir, "portal", "views", "board.ejs"),
|
||||
`<%- include("partials/shell", { body: "<p>Hi " + user.email + "</p>", brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), theme: chrome.theme, title: "My Portal", user: chrome.user }) %>`);
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
@@ -326,9 +322,8 @@ function rawGet(port: number, path: string, host: string, method = "GET"): Promi
|
||||
}
|
||||
|
||||
test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the configured origin (path+query kept)", async (t) => {
|
||||
// The fix for the localhost-vs-127.0.0.1 / multi-domain trap: reach the app on any host and it
|
||||
// sends you to APP_URL's host, so the browser, the themed form, and the cross-origin Kratos POST
|
||||
// all share ONE cookie host. Off-canonical only — same-host requests pass straight through.
|
||||
// Reach the app on any host and it sends you to APP_URL's, so the browser, the themed form and the
|
||||
// cross-origin Kratos POST share ONE cookie host. Same-host requests pass straight through.
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), appUrl: "http://canonical.example:3000" });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
@@ -359,8 +354,8 @@ test("no APP_URL configured ⇒ no canonical redirect (unit-test apps and host-a
|
||||
});
|
||||
|
||||
test("/error renders a themed sign-in error page (Kratos' flow error sink), not the 404", async () => {
|
||||
// Kratos' flows.error.ui_url points here; a flow error redirects to /error?id=<uuid>. Without a
|
||||
// handler it 404'd as "Page not found" (confusing). It must be a real, themed page now.
|
||||
// Kratos' flows.error.ui_url points here; a flow error redirects to /error?id=<uuid>, which must
|
||||
// land on a real themed page rather than the catch-all 404.
|
||||
const res = await fetch(base + `/error?id=${randomUUID()}`, { redirect: "manual" });
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
||||
@@ -1257,9 +1252,8 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
||||
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
||||
});
|
||||
|
||||
// Granting permissions over HTTP, on the two screens that replaced the deleted Permissions screen.
|
||||
// The offered set is the host's catalog (ctx.declaredPermissions, from what the installed plugins
|
||||
// declare), so the checkboxes are a fixed list and the POST is the desired state.
|
||||
// Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
|
||||
// so the checkboxes are a fixed list and the POST is the desired state.
|
||||
test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
|
||||
const ada = randomUUID();
|
||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||
@@ -1348,7 +1342,7 @@ test("admin screens render no write affordance for a read-only holder", async (t
|
||||
assert.doesNotMatch(group, /Delete group/);
|
||||
assert.doesNotMatch(group, /Save permissions/);
|
||||
|
||||
// The OAuth2-clients screen is held to the same rule (it was the one this test was written to catch).
|
||||
// The OAuth2-clients screen is held to the same rule.
|
||||
const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
|
||||
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
|
||||
const clients = await clientsRes.text();
|
||||
|
||||
+48
-98
@@ -26,6 +26,7 @@ import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts"
|
||||
import { remintSession } from "../auth/login.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 type { PluginSettings } from "../plugin-host/settings.ts";
|
||||
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
||||
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||
@@ -39,15 +40,11 @@ const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
export interface AppOptions {
|
||||
appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect
|
||||
auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks
|
||||
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
|
||||
// Off by default so edits show live; the app itself never inspects the environment.
|
||||
cache?: boolean;
|
||||
cache?: boolean; // cache compiled EJS templates (config.cacheTemplates); off ⇒ edits show live
|
||||
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
|
||||
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
|
||||
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
|
||||
// Loaded translation catalogs (server.ts passes the discovered ones). Omitted ⇒ the built-in
|
||||
// en-US catalog only, so an unwired app still renders real English.
|
||||
i18n?: I18n;
|
||||
i18n?: I18n; // discovered catalogs; omitted ⇒ the built-in en-US only, so an unwired app still renders English
|
||||
jwks?: JwksProvider; // verify the session JWT → ctx.user/permissions; absent ⇒ always anonymous
|
||||
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
|
||||
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
|
||||
@@ -58,19 +55,17 @@ export interface AppOptions {
|
||||
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
|
||||
publicDir?: string;
|
||||
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;
|
||||
}
|
||||
|
||||
export function createApp(options: AppOptions = {}): Server {
|
||||
// The denylist (when enabled) rides in the verify options so resolveSession rejects a revoked
|
||||
// subject on the hot path; the bound `revoke` is handed to the admin handlers that should
|
||||
// revoke instantly. Both absent ⇒ the feature is fully off (no cost, no behaviour change).
|
||||
// The denylist rides in the verify options so resolveSession rejects a revoked subject on the hot
|
||||
// path; the bound `revoke` goes to the admin handlers. Both absent ⇒ the feature is fully off.
|
||||
const denylist = options.denylist;
|
||||
const authOptions: VerifyOptions = denylist ? { ...(options.auth ?? {}), denylist } : (options.auth ?? {});
|
||||
const revoke = denylist ? (sub: string): void => denylist.revoke(sub) : undefined;
|
||||
const cache = options.cache ?? false;
|
||||
// Canonical public host (APP_URL): when set, an off-host GET/HEAD visitor is redirected here so
|
||||
// every cookie (esp. Kratos' cross-origin CSRF cookie) shares one host. Omitted ⇒ feature off.
|
||||
const canonical = options.appUrl ? new URL(options.appUrl) : undefined;
|
||||
const canonicalHost = canonical?.host; // host[:port], default ports omitted — matches the Host header
|
||||
const canonicalOrigin = canonical?.origin; // scheme + host[:port], no trailing slash
|
||||
@@ -82,9 +77,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const keto = options.keto;
|
||||
const kratos = options.kratos;
|
||||
const kratosAdmin = options.kratosAdmin;
|
||||
// Privileged host services handed to a system plugin via ctx.system — the Ory admin clients and
|
||||
// the instant-revoke hook. Only the wired capabilities are present; with none wired ctx.system
|
||||
// stays undefined, so an ordinary deployment (no Ory, hence no system plugin) pays nothing.
|
||||
// Only the wired capabilities are present; with none wired ctx.system stays undefined.
|
||||
const system: SystemCapabilities | undefined = kratosAdmin || keto || hydra || revoke
|
||||
? { ...(hydra ? { hydra } : {}), ...(keto ? { keto } : {}), ...(kratosAdmin ? { kratosAdmin } : {}), ...(revoke ? { revoke } : {}) }
|
||||
: undefined;
|
||||
@@ -93,15 +86,12 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const menu = options.menu ?? DEFAULT_MENU;
|
||||
const plugins = options.plugins ?? [];
|
||||
const pluginIds = new Set(plugins.map((p) => p.id));
|
||||
// A plugin may fully replace the public landing "/" (`home`) or the gated dashboard "/dashboard"
|
||||
// (`dashboard`) — Discovery's findConflicts guarantees at most one of each, so `find` is
|
||||
// unambiguous; the predicates narrow the slot to defined.
|
||||
// `find` is unambiguous: findConflicts guarantees at most one owner of each landing slot.
|
||||
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");
|
||||
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
||||
// The permission catalog is a property of the installed plugin set, so it is computed once at
|
||||
// wiring rather than per request.
|
||||
const permissionCatalog = declaredPermissions(plugins);
|
||||
const settingsCatalog = options.settingsCatalog ?? [];
|
||||
// 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 anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
||||
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||
@@ -115,19 +105,11 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
|
||||
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache, views: [viewsDir] });
|
||||
|
||||
// A `view` RouteResult renders plugins/<id>/views/<view>.ejs; such views may include() the core
|
||||
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
|
||||
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
|
||||
|
||||
// Every view renders with its context's i18n locals (t/locale/dir/localeSwitch/localeParam) merged
|
||||
// in, so a view — core or plugin, at any include depth — calls `t(...)` without its handler passing
|
||||
// it. A plugin's context carries that plugin's translator, so its own catalog wins in its own views.
|
||||
// They are merged LAST: these names are reserved (README → Building plugins), and a handler that
|
||||
// happens to use one loses that key rather than breaking the shell that renders around it.
|
||||
// Where the language picker on this page should point. Normally the page itself; after a POST
|
||||
// that URL may answer no GET (POST /admin/users/:id/delete has no GET sibling), so fall back to
|
||||
// the page the form was submitted from, then to the front page — the picker is on every page, so
|
||||
// every one of its links has to land somewhere real.
|
||||
// Where the language picker points. Normally the page itself; after a POST that URL may answer no
|
||||
// GET (POST /admin/users/:id/delete has no GET sibling), so fall back to the page the form was
|
||||
// submitted from, then to the front page — the picker is on every page, so every link must land.
|
||||
const switchBase = (req: IncomingMessage, url: URL): string => {
|
||||
const method = (req.method ?? "GET").toUpperCase();
|
||||
if (method === "GET" || method === "HEAD") return `${url.pathname}${url.search}`;
|
||||
@@ -147,6 +129,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
t: ctx.t,
|
||||
url: ctx.url,
|
||||
});
|
||||
// i18n locals go last: their names are reserved, so a handler's colliding key loses instead of
|
||||
// breaking the shell around it.
|
||||
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
||||
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
||||
|
||||
@@ -155,10 +139,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
res.end(html);
|
||||
};
|
||||
|
||||
// The public landing "/": ungated — anyone may see it. A plugin may fully own it via `home`
|
||||
// (rendered against its own views, native shell via ctx.chrome, with a fresh CSRF cookie for
|
||||
// any form it ships). Else the built-in intro page with prominent sign-in / register links
|
||||
// (`user` picks "go to dashboard" vs sign-in; the shell's Sign-out form needs the CSRF cookie).
|
||||
// The public landing "/", ungated. A plugin may own it via `home`; else the built-in intro page.
|
||||
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
||||
csrf.setCookie();
|
||||
if (homePlugin) {
|
||||
@@ -172,10 +153,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
|
||||
};
|
||||
|
||||
// The post-login app home "/dashboard", gated to a signed-in user: anonymous bounces to sign
|
||||
// in, remembering /dashboard as return_to. A plugin may fully own it via `dashboard` — its
|
||||
// handler renders against its own views, same path as a plugin route. Else the built-in
|
||||
// mock-data People list with the one global menu (ctx.chrome.nav) + branding from config/menu.ts.
|
||||
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
|
||||
// starter page.
|
||||
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
||||
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
|
||||
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
||||
@@ -214,26 +193,21 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
|
||||
for (const [name, value] of secHeaderEntries) res.setHeader(name, value);
|
||||
|
||||
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
|
||||
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
||||
// /public/<id>/… serves a plugin's public/; everything else the core public/.
|
||||
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
|
||||
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
|
||||
await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Rendered pages content-negotiate on Accept-Language, so a cache in front of us must key on
|
||||
// it — otherwise the first visitor's language is served to everyone. Set after the static
|
||||
// branch above: an asset is the same bytes in every language, and a Vary there would fragment
|
||||
// its cache entry per raw header string.
|
||||
// A cache in front of us must key on the language. Set after the static branch: an asset is
|
||||
// the same bytes in every language, and a Vary there fragments its entry per raw header.
|
||||
res.setHeader("vary", "accept-language");
|
||||
|
||||
// Canonical host (APP_URL): a visitor who reached us on a different host (localhost vs
|
||||
// 127.0.0.1, a secondary domain) is sent to the configured origin, path + query preserved, so
|
||||
// the browser, the themed forms, and the cross-origin Kratos POST all share one cookie host —
|
||||
// otherwise the host-scoped Kratos CSRF cookie is lost and login dumps onto /error. Static
|
||||
// assets above are served on any host (health checks). GET/HEAD only — a 308 must not replay a
|
||||
// cross-host POST; first-party forms are always served from a canonical page anyway.
|
||||
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
|
||||
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
|
||||
// host-scoped Kratos CSRF cookie is lost and login dumps onto /error. GET/HEAD only: a 308
|
||||
// must not replay a cross-host POST.
|
||||
if (canonicalHost && (method === "GET" || method === "HEAD")) {
|
||||
const host = req.headers.host;
|
||||
if (host !== undefined && host !== canonicalHost) {
|
||||
@@ -242,18 +216,14 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
}
|
||||
}
|
||||
|
||||
// Which language this request is served in: ?locale wins, else Accept-Language, else en-US.
|
||||
// `explicit` (the URL asked) is what makes the choice travel: the chrome, this request's
|
||||
// redirects and ctx.localeHref then carry ?locale onto the links they emit.
|
||||
// `explicit` (the URL asked for a locale) is what makes the choice travel: the chrome, this
|
||||
// request's redirects and ctx.localeHref then carry ?locale onto the links they emit.
|
||||
const { explicit, locale } = i18n.resolve({ acceptLanguage: req.headers["accept-language"], param: url.searchParams.get("locale") });
|
||||
const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null);
|
||||
const t = i18n.translator(locale);
|
||||
|
||||
// Verify the session JWT once (cached JWKS) → ctx.user/permissions; none/invalid ⇒ anonymous.
|
||||
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
|
||||
// clients), silently re-mint it — "stay signed in": re-read permissions from Keto, re-tokenize,
|
||||
// and set the fresh cookie via setHeader so it rides whatever response this request produces
|
||||
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
|
||||
// A lapsed token still backed by a live Kratos session is silently re-minted — "stay signed
|
||||
// in". The only place the hot path touches Ory.
|
||||
let user: User | null = null;
|
||||
if (jwks) {
|
||||
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
|
||||
@@ -264,32 +234,25 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
user = reminted.user;
|
||||
res.appendHeader("set-cookie", reminted.setCookie);
|
||||
} catch (err) {
|
||||
// Ory unreachable (Kratos/Keto 5xx, refused, timeout) — degrade to anonymous instead of
|
||||
// 500ing every lapsed request. Leave the cookie alone: it can re-mint once Ory recovers.
|
||||
// Ory unreachable — degrade to anonymous instead of 500ing every lapsed request. Leave
|
||||
// the cookie alone: it can re-mint once Ory recovers.
|
||||
reqLog.warn("session re-mint failed (Ory unreachable?)", { error: String(err) });
|
||||
}
|
||||
}
|
||||
}
|
||||
// CSRF token for this request's first-party forms: reuse a genuine cookie token, else mint
|
||||
// one (a page-emitting handler Set-Cookies it via csrfMint). Verified on our own
|
||||
// state-changing routes.
|
||||
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
|
||||
const csrfMint: RequestCsrf = {
|
||||
setCookie: (): void => { if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); },
|
||||
token: csrf.token,
|
||||
};
|
||||
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret).
|
||||
const verifyCsrf = (submitted: string | null | undefined): boolean =>
|
||||
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
|
||||
// Chrome (brand/global-nav/user/theme/csrf) composes the whole menu, so it's resolved lazily and
|
||||
// at most once per request: this app-level memo shares it across the contexts below, and each
|
||||
// ctx.chrome getter only triggers it when a handler actually reads it (a json/redirect handler,
|
||||
// or the public "/" with a standalone home, never composes the menu).
|
||||
// Chrome composes the whole menu, so it is memoized and resolved lazily — a json/redirect
|
||||
// handler, or the public "/" with a standalone home, never pays for it.
|
||||
let chromeMemo: PageChrome | undefined;
|
||||
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, localeHref: carryLocale, menu, plugins, t, translatorFor: (id) => i18n.translator(locale, id), user }));
|
||||
|
||||
// The i18n half of every context: the locale, its translator, and the link carrier. A plugin
|
||||
// route swaps in the plugin's own translator (its catalog first, then core).
|
||||
// A plugin's context gets the plugin's own translator — its catalog first, then core.
|
||||
const i18nFor = (pluginId?: string) => ({
|
||||
locale,
|
||||
localeHref: carryLocale,
|
||||
@@ -297,35 +260,30 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
t: pluginId === undefined ? t : i18n.translator(locale, pluginId),
|
||||
});
|
||||
|
||||
// base context (no route params yet); reused for the built-in routes. A plugin-owned render
|
||||
// (a landing slot, a hook short-circuit, a plugin route) gets `contextFor(id)` instead, so its
|
||||
// own catalog is what `ctx.t` reads.
|
||||
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||
// 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.
|
||||
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 =>
|
||||
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);
|
||||
|
||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||
if (anyRequestHooks) {
|
||||
const short = await runRequestHooks(plugins, contextFor);
|
||||
if (short) {
|
||||
// Set the fresh CSRF cookie like every other page-emitting path, so a form the hook
|
||||
// renders (its token is in ctx.chrome.csrfToken) has the matching double-submit cookie.
|
||||
// Like every other page-emitting path, so a form the hook renders has its matching cookie.
|
||||
csrfMint.setCookie();
|
||||
await sendResult(res, short.result, pluginViewsFor(short.ctx, short.plugin.id), carryLocale);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Plugin routes (any method): gate on the route's permission, then run the handler. The
|
||||
// handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh
|
||||
// CSRF cookie is set so those forms have a valid double-submit token.
|
||||
const match = matchRoute(plugins, method, pathname);
|
||||
if (match) {
|
||||
const routeCtx = contextFor(match.plugin.id, match.params);
|
||||
if (!isAuthorized(match.route, routeCtx.permissions)) {
|
||||
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
|
||||
// return_to; a signed-in user who simply lacks the permission gets the 403 page.
|
||||
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply
|
||||
// lacks the permission gets the 403 page.
|
||||
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
||||
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
|
||||
sendHtml(res, 403, await renderPage("403", {}));
|
||||
@@ -340,9 +298,6 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
return;
|
||||
}
|
||||
|
||||
// Built-in endpoints (the auth/OAuth2 group, the landing slots, /error) from the internal
|
||||
// route table — same handler shape as plugin routes; a `view` result renders the core views,
|
||||
// null means the handler wrote to ctx.res itself.
|
||||
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
||||
if (builtin) {
|
||||
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
||||
@@ -385,20 +340,16 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
};
|
||||
|
||||
return createServer((req, res) => {
|
||||
// Per-request log + trace span: a "request" span, continuing an upstream W3C traceparent
|
||||
// when present (distributed tracing across a proxy). "close" (not "finish") fires on both a
|
||||
// completed response and a premature disconnect/abort, so an aborted/truncated request is still
|
||||
// logged and its span flushed.
|
||||
// "close" (not "finish") fires on both a completed response and a premature disconnect, so an
|
||||
// aborted request is still logged and its span flushed.
|
||||
const startMs = Date.now();
|
||||
const reqLog = requestLogger(log, {
|
||||
requestId: randomUUID(),
|
||||
...(typeof req.headers.traceparent === "string" ? { traceparent: req.headers.traceparent } : {}),
|
||||
});
|
||||
// end() must run exactly once, after BOTH the handler has fully unwound (settled) AND the
|
||||
// response has closed (the access line is then emitted with the final status). Ending earlier
|
||||
// would throw "already ended" from a still-running handler's ctx.log/tracedFetch on a client
|
||||
// abort, or drop the access line on the happy path (handler settles before close). Coordinating
|
||||
// the two signals avoids both. Logging must never crash a served request, so it's all guarded.
|
||||
// end() must run exactly once, after BOTH the handler has unwound AND the response has closed.
|
||||
// Earlier would throw "already ended" from a still-running handler's ctx.log on a client abort,
|
||||
// or drop the access line on the happy path (the handler settles before close).
|
||||
let settled = false;
|
||||
let closed = false;
|
||||
const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); };
|
||||
@@ -410,9 +361,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
} catch { /* never let logging crash a served request */ }
|
||||
finalize();
|
||||
});
|
||||
// Make reqLog ambient for the whole handler (sync body + every await) so all outbound fetch is
|
||||
// traced. handleRequest owns its own try/catch; the .catch logs a pathological escape via the
|
||||
// app logger (not reqLog, which may be the thing that broke), never crashing the request.
|
||||
// Make reqLog ambient for the whole handler so all outbound fetch is traced. The .catch logs a
|
||||
// pathological escape via the app logger — not reqLog, which may be the thing that broke.
|
||||
void runWithLog(reqLog, () => handleRequest(req, res, reqLog))
|
||||
.catch((err) => log.error("request handler escaped its try/catch", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) }))
|
||||
.finally(() => { settled = true; finalize(); });
|
||||
|
||||
+8
-2
@@ -1,6 +1,7 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
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 { PluginSettings } from "../plugin-host/settings.ts"; // type-only
|
||||
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
|
||||
import { DEFAULT_LOCALE } from "../i18n/catalog.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
|
||||
// wraps the hrefs it builds itself.
|
||||
localeHref(href: string): string;
|
||||
// Every installed locale, sorted. With `localeLabel` (from #plugin-api) it is what a plugin needs
|
||||
// to build its own language picker; the host's own picker is already in the shell.
|
||||
// Every installed locale, sorted. With `localeLabel` (from @plainpages/plugin-api) it is what a
|
||||
// plugin needs to build its own language picker; the host's own picker is already in the shell.
|
||||
locales: string[];
|
||||
// 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
|
||||
@@ -42,6 +43,9 @@ export interface RequestContext {
|
||||
// screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is
|
||||
// what *this user holds*. Empty when no installed plugin declares any.
|
||||
declaredPermissions: readonly PermissionDecl[];
|
||||
// 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 }
|
||||
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
|
||||
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.
|
||||
chrome?: () => PageChrome;
|
||||
declaredPermissions?: readonly PermissionDecl[];
|
||||
declaredSettings?: readonly PluginSettings[];
|
||||
user?: User | null;
|
||||
locale?: string;
|
||||
localeHref?: (href: string) => string;
|
||||
@@ -96,6 +101,7 @@ export function buildContext(
|
||||
return {
|
||||
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
||||
declaredPermissions: options.declaredPermissions ?? [],
|
||||
declaredSettings: options.declaredSettings ?? [],
|
||||
user,
|
||||
locale: options.locale ?? DEFAULT_LOCALE,
|
||||
localeHref: options.localeHref ?? ((href) => href),
|
||||
|
||||
+5
-12
@@ -1,15 +1,8 @@
|
||||
// URL safety helpers. Two pure, dependency-free guards:
|
||||
//
|
||||
// safeUrl(value) — sanitise an untrusted URL before rendering it in an href/src attribute.
|
||||
// Partials escape *text*, but a URL field is emitted verbatim, so a
|
||||
// `javascript:`/`data:` URL from upstream/user data would be live XSS. The
|
||||
// contract (README.md → Routes & handlers) is: a relative or http(s) URL is allowed,
|
||||
// anything else collapses to "#". Exported to plugins via plugin-api.ts.
|
||||
//
|
||||
// localPath(value) — validate a redirect target is a *same-origin* path (the redirect-URI
|
||||
// allowlist). Used for `return_to`: a host-relative "/a/b?x=1" passes, an
|
||||
// absolute or protocol-relative ("//evil.com", "https://evil.com") is rejected
|
||||
// so a crafted ?return_to= can't turn login completion into an open redirect.
|
||||
// safeUrl(value) — a URL field is emitted verbatim into an href/src, so a `javascript:`/`data:`
|
||||
// URL from untrusted data would be live XSS. Relative or http(s) passes,
|
||||
// anything else collapses to "#". Exported to plugins via plugin-api.ts.
|
||||
// localPath(value) — the redirect-URI allowlist for `return_to`: host-relative passes, absolute
|
||||
// or protocol-relative is rejected, so a crafted value can't open-redirect.
|
||||
|
||||
// ASCII control chars + space that browsers strip/ignore when resolving a URL — strip them before
|
||||
// the scheme check so "java\tscript:" / a leading space can't masquerade as relative.
|
||||
|
||||
@@ -1,15 +1,11 @@
|
||||
// Response security headers: set once per request in app.ts so every response — page,
|
||||
// JSON, redirect, static, or error — carries them (writeHead merges with setHeader). A plugin route
|
||||
// may override any of them per-response via RouteResult.headers (e.g. relax the CSP to ship its own JS).
|
||||
// Set once per request in app.ts, so every response carries them (writeHead merges with setHeader).
|
||||
// A plugin route may override any per-response via RouteResult.headers.
|
||||
|
||||
// Strict default CSP for the zero-JS, server-rendered core:
|
||||
// - script-src 'self' : the core ships no JS; a plugin may still serve its own /public/<id>/*.js for
|
||||
// opt-in progressive enhancement. No 'unsafe-inline' ⇒ an injected <script>
|
||||
// can't run (the main XSS sink).
|
||||
// - style-src adds 'unsafe-inline' : a few partials carry inline style= attributes.
|
||||
// - img-src adds data: : favicon + inline data URIs.
|
||||
// - no form-action : the themed login form posts to Kratos' (often cross-origin) action URL.
|
||||
// - frame-ancestors 'none' : clickjacking guard (the modern X-Frame-Options).
|
||||
// The non-obvious parts of the CSP:
|
||||
// - script-src 'self' with no 'unsafe-inline' ⇒ an injected <script> can't run. A plugin may still
|
||||
// serve its own /public/<id>/*.js for opt-in progressive enhancement.
|
||||
// - style-src adds 'unsafe-inline': a few partials carry inline style= attributes.
|
||||
// - no form-action: the themed login form posts to Kratos' (often cross-origin) action URL.
|
||||
const CSP = [
|
||||
"base-uri 'self'",
|
||||
"default-src 'self'",
|
||||
|
||||
+6
-8
@@ -1,12 +1,10 @@
|
||||
// Catalog discovery: import src/i18n/locales/<tag>.ts and plugins/<id>/i18n/<tag>.ts, then
|
||||
// check every one against its set's en-US baseline. The imperative shell over catalog.ts's pure
|
||||
// rules — the same contract as plugin discovery: one boot-stopping Error listing every problem,
|
||||
// so a half-translated deploy is caught at startup rather than as a stray English word in production.
|
||||
// Catalog discovery: import src/i18n/locales/<tag>.ts and plugins/<id>/i18n/<tag>.ts, then check
|
||||
// every one against its set's en-US baseline. The imperative shell over catalog.ts's pure rules,
|
||||
// with plugin discovery's contract: one boot-stopping Error listing every problem, so a
|
||||
// half-translated deploy is caught at startup rather than as a stray English word in production.
|
||||
//
|
||||
// Installed locales are whatever the core folder holds; a plugin may translate fewer of them (its
|
||||
// strings then render in en-US on that page) but never one the host does not have. The operator's
|
||||
// `locales/` mount extends both sides — `locales/<tag>.ts` for the core, `locales/plugins/<id>/<tag>.ts`
|
||||
// for a plugin — so adding a language never means forking the image or a vendored plugin.
|
||||
// A plugin may translate fewer locales than the core holds (its strings then render in en-US) but
|
||||
// never one the host lacks. The operator's `locales/` mount extends both sides.
|
||||
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
@@ -9,6 +9,7 @@ const messages = {
|
||||
"auth.continue": "Continue",
|
||||
// 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.
|
||||
"auth.field.code.hint": "Digits only — no spaces.",
|
||||
"auth.field.email": "Email",
|
||||
"auth.field.identifier": "Email",
|
||||
"auth.field.password": "Password",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { CoreMessages } from "./en-US.ts";
|
||||
|
||||
const messages: CoreMessages = {
|
||||
"auth.continue": "Fortsätt",
|
||||
"auth.field.code.hint": "Endast siffror — inga mellanslag.",
|
||||
"auth.field.email": "E-postadress",
|
||||
"auth.field.identifier": "E-postadress",
|
||||
"auth.field.password": "Lösenord",
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
// The translator: a key + vars → the string to render. Pure and synchronous — views call it
|
||||
// as `t("shell.signOut")` and handlers as `ctx.t(...)`.
|
||||
//
|
||||
// Two rules the rest of the app leans on:
|
||||
// · the lookup walks a catalog chain (plugin locale → plugin en-US → core locale → core en-US) and,
|
||||
// when nothing has the key, returns the key itself. That is what makes a plain nav label like
|
||||
// "Shifts" its own fallback — a manifest needs no catalog to keep working.
|
||||
// · the result is raw text. Views escape with <%= %> exactly as they do for any other value, so a
|
||||
// translation is never double-escaped, and a message that carries markup is rendered with <%- %>.
|
||||
// The translator: a key + vars → the string to render. Two rules the rest of the app leans on:
|
||||
// · the lookup walks a catalog chain (plugin locale → plugin en-US → core locale → core en-US)
|
||||
// and returns the key itself when nothing has it — so a plain nav label like "Shifts" is its
|
||||
// own fallback and a manifest needs no catalog to keep working.
|
||||
// · the result is raw text, escaped by the view with <%= %> like any other value, so a
|
||||
// translation is never double-escaped and one carrying markup is rendered with <%- %>.
|
||||
|
||||
import { isPluralMessage, type Catalog, type PluralMessage } from "./catalog.ts";
|
||||
|
||||
|
||||
+10
-17
@@ -21,11 +21,9 @@ export interface LoggerOptions {
|
||||
stdout?: (msg: string) => void;
|
||||
}
|
||||
|
||||
// The app-level logger: a Log tagged service.name so every console line, OTLP log record and span is
|
||||
// attributed to the service. Level + format + name are explicit toggles (LOG_LEVEL/LOG_FORMAT/
|
||||
// SERVICE_NAME — environment-agnostic, AGENTS.md §4). With otlpEndpoint set, logs + spans also export
|
||||
// to that OTLP/HTTP collector (e.g. an OpenTelemetry Collector fronting Tempo/Loki); unset ⇒ console
|
||||
// only, at zero export cost. Conditional spreads keep exactOptionalPropertyTypes happy (no `key: undefined`).
|
||||
// The app-level logger, tagged service.name. With otlpEndpoint set, logs + spans also export to that
|
||||
// OTLP/HTTP collector; unset ⇒ console only, at zero export cost. The conditional spreads keep
|
||||
// exactOptionalPropertyTypes happy (no `key: undefined`).
|
||||
export function createLogger(opts: LoggerOptions = {}): Log {
|
||||
return new Log({
|
||||
context: { "service.name": opts.serviceName || SERVICE_NAME },
|
||||
@@ -49,13 +47,10 @@ export function currentLog(): Log | undefined {
|
||||
return requestStore.getStore();
|
||||
}
|
||||
|
||||
// A drop-in `fetch` that traces through the active request log — a client span nested under the
|
||||
// request span, with a W3C `traceparent` injected so the downstream service continues the same
|
||||
// trace. Outside a request (no ambient log) or for a non-string/URL input it's a plain `fetch`.
|
||||
// server.ts wires this (under the Ory timeout) into every Kratos/Keto/Hydra/JWKS call; a plugin
|
||||
// uses it for its upstream calls (exported via plugin-api.ts). The trace-setup adds no throw of its
|
||||
// own, but log.fetch throws synchronously if the request log has already ended (app.ts ends it only
|
||||
// after the handler unwinds, so a live handler never hits that).
|
||||
// A drop-in `fetch` that traces through the active request log — a client span under the request
|
||||
// span, with a W3C `traceparent` injected so the downstream service continues the same trace.
|
||||
// Outside a request, or for a non-string/URL input, it is a plain `fetch`. Note log.fetch throws
|
||||
// synchronously once the request log has ended; app.ts ends it only after the handler unwinds.
|
||||
export const tracedFetch: typeof fetch = (input, init) => {
|
||||
const log = currentLog();
|
||||
if (log && (typeof input === "string" || input instanceof URL)) return log.fetch(input, init);
|
||||
@@ -63,11 +58,9 @@ export const tracedFetch: typeof fetch = (input, init) => {
|
||||
};
|
||||
|
||||
// A per-request child logger holding a "request" trace span. `clone` (not parentLog) gives the
|
||||
// request its own root trace — so requests aren't all nested under one app-lifetime span — while
|
||||
// inheriting the parent's level/format/streams/OTLP. A valid upstream W3C `traceparent` is adopted
|
||||
// (the span continues that distributed trace across a reverse proxy/gateway; malformed ⇒ ignored, a
|
||||
// fresh trace starts). `requestId` tags every line + the span for log↔trace correlation. Flush with
|
||||
// `end()` on response finish to export the span — a no-op when OTLP is off.
|
||||
// request its own root trace, so requests aren't all nested under one app-lifetime span, while
|
||||
// inheriting the parent's level/format/streams/OTLP. A valid upstream `traceparent` is adopted;
|
||||
// malformed ⇒ ignored, a fresh trace starts. `end()` on response finish exports the span.
|
||||
export function requestLogger(appLog: Log, opts: { requestId: string; traceparent?: string | undefined }): Log {
|
||||
return appLog.clone({
|
||||
context: { ...appLog.context, requestId: opts.requestId },
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 { dirname, join } from "node:path";
|
||||
import { test, type TestContext } from "node:test";
|
||||
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
|
||||
// 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 =>
|
||||
`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}" }) }] };`;
|
||||
|
||||
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) => {
|
||||
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 });
|
||||
|
||||
assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta"]); // deterministic order
|
||||
assert.equal(plugins[0]?.apiVersion, "1.0.0");
|
||||
assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta", "gamma"]); // deterministic order
|
||||
assert.equal(plugins[0]?.apiVersion, HOST_API_VERSION);
|
||||
assert.equal(plugins[0]?.nav?.[0]?.label, "alpha");
|
||||
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.
|
||||
@@ -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: "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: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "1.0.0", routes: "nope" };` }, match: /weird.*routes.*array/s },
|
||||
{ name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "1.0.0", 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-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: "${HOST_API_VERSION}", home: "nope" };` }, match: /weirdhome.*home.*function/s },
|
||||
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
|
||||
{ name: "non-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: "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 nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s },
|
||||
{ name: "a route marked 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: "${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
|
||||
// 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 nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s },
|
||||
{ name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "1.0.0", 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: "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 route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
|
||||
{ name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s },
|
||||
{ name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s },
|
||||
{ name: "a plugin shipping its own copy of the barrel", files: { "shadow/node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "shadow/plugin.ts": full("shadow") }, match: /shadow.*@plainpages\/plugin-api/s },
|
||||
{ name: "a plugin package.json that forgets type: module", files: { "cjs/package.json": `{ "name": "cjs" }`, "cjs/plugin.ts": full("cjs") }, match: /cjs.*"type": "module"/s },
|
||||
{ name: "a plugin package.json that is not valid JSON", files: { "bent/package.json": `{`, "bent/plugin.ts": full("bent") }, match: /bent.*package\.json.*JSON/s },
|
||||
{ name: "a plugin package.json holding null", files: { "nul/package.json": `null`, "nul/plugin.ts": full("nul") }, match: /nul.*"type": "module"/s },
|
||||
// `npm install --prefix plugins` — the documented command with one path segment dropped.
|
||||
{ name: "a package.json in the scan root itself", files: { "package.json": `{ "name": "oops" }`, "ok/plugin.ts": full("ok") }, match: /plugins\/package\.json must not exist/ },
|
||||
{ name: "a node_modules in the scan root itself", files: { "node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "ok/plugin.ts": full("ok") }, match: /plugins\/node_modules must not exist/ },
|
||||
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ html: "b" }) };` }, match: /home/ },
|
||||
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
|
||||
];
|
||||
|
||||
for (const c of badCases) {
|
||||
@@ -67,8 +84,20 @@ for (const c of badCases) {
|
||||
});
|
||||
}
|
||||
|
||||
// The reader of a discovery failure is usually an operator whose plugins/ copy went stale after an
|
||||
// upgrade, not the author of the manifest — so the message has to carry the remedy, not just the
|
||||
// rule. A pre-existing `plugins/admin` gating on the old `admin` permission is exactly this case.
|
||||
test("a discovery failure tells the operator their plugins/ copy may just be out of date", async (t) => {
|
||||
const dir = scaffold(t, { "admin/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/users", permission: "admin", handler: () => ({ html: "x" }) }] };` });
|
||||
await assert.rejects(discoverPlugins({ dir }), (err: Error) => {
|
||||
assert.match(err.message, /gates on "admin"/); // what is wrong
|
||||
assert.match(err.message, /re-copy it/); // …and what to do about it
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test("a route + nav node may be marked public and load fine", async (t) => {
|
||||
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 });
|
||||
assert.equal(plugins.length, 1);
|
||||
assert.equal(plugins[0]?.routes?.[0]?.public, true);
|
||||
@@ -83,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) => {
|
||||
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 });
|
||||
assert.equal(plugins.length, 1);
|
||||
assert.equal(typeof plugins[0]?.home, "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) => {
|
||||
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 warnings: string[] = [];
|
||||
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
// 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.
|
||||
|
||||
import { existsSync, readdirSync } from "node:fs";
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
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)), "..", "..");
|
||||
|
||||
@@ -27,6 +29,14 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
|
||||
const errors: string[] = [];
|
||||
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)) {
|
||||
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; }
|
||||
const file = join(dir, id, "plugin.ts");
|
||||
if (!existsSync(file)) { fail("no plugin.ts found"); continue; }
|
||||
const packaging = packagingError(join(dir, id));
|
||||
if (packaging) { fail(packaging); continue; }
|
||||
|
||||
let mod: { default?: unknown };
|
||||
try {
|
||||
@@ -56,6 +68,13 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
|
||||
const shape = shapeError(manifest);
|
||||
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
|
||||
}
|
||||
|
||||
@@ -65,20 +84,48 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
|
||||
}
|
||||
|
||||
if (errors.length) {
|
||||
throw new Error(`Plugin discovery failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
|
||||
// `plugins/` is a drop-in mount the operator owns, so the reader of this message often didn't
|
||||
// write the manifest — they copied it. Tightening a contract rule breaks those copies at boot,
|
||||
// and the rule alone doesn't tell them the remedy is one command.
|
||||
throw new Error(
|
||||
`Plugin discovery failed:\n${errors.map((e) => ` - ${e}`).join("\n")}\n` +
|
||||
`A plugin under plugins/ is your own copy. If it came from examples/, re-copy it — ` +
|
||||
`the host contract may have changed since (see README → Upgrading).`,
|
||||
);
|
||||
}
|
||||
return plugins;
|
||||
}
|
||||
|
||||
// Subfolders of plugins/, sorted for deterministic load order + stable conflict messages. Hidden
|
||||
// entries (.git, .DS_Store, …) and non-directories are skipped — only folders are plugins.
|
||||
// Sorted for deterministic load order + stable conflict messages. A symlink counts as a folder, and
|
||||
// one whose target the container cannot see trips "no plugin.ts found" rather than vanishing.
|
||||
function pluginFolders(dir: string): string[] {
|
||||
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)
|
||||
.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 {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as PluginManifest) : null;
|
||||
}
|
||||
@@ -93,6 +140,12 @@ function shapeError(manifest: PluginManifest): string | null {
|
||||
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)`;
|
||||
}
|
||||
// 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
|
||||
// "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 : []) {
|
||||
|
||||
@@ -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 () => {
|
||||
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([
|
||||
plugin("a", { onBoot: () => void calls.push("a") }),
|
||||
plugin("b", {}), // no onBoot → skipped
|
||||
plugin("c", { onBoot: async () => void calls.push("c") }),
|
||||
]);
|
||||
], bootContextFor);
|
||||
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 () => {
|
||||
|
||||
@@ -4,11 +4,15 @@
|
||||
// entirely when no plugin declares the hook, so the no-hooks hot path stays free.
|
||||
|
||||
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.
|
||||
export async function runBootHooks(plugins: Plugin[]): Promise<void> {
|
||||
for (const plugin of plugins) await plugin.hooks?.onBoot?.();
|
||||
// After discovery, before the server listens. A throw aborts boot. Each hook gets a context built
|
||||
// for its own plugin, so one plugin is never handed another's storage credentials.
|
||||
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
|
||||
|
||||
@@ -5,6 +5,14 @@ import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
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", () => {
|
||||
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}`);
|
||||
|
||||
@@ -5,7 +5,11 @@
|
||||
// a plugin should import from here, never reach into deeper modules. See README.md → Building plugins.
|
||||
|
||||
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 { PageChrome } from "../ui/chrome.ts";
|
||||
export type { NavNode } from "../ui/nav.ts";
|
||||
|
||||
@@ -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", () => {
|
||||
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.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("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("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]) {
|
||||
assert.equal(checkApiVersion(bad).level, "refuse", `${String(bad)} must refuse`);
|
||||
}
|
||||
|
||||
+68
-51
@@ -1,17 +1,16 @@
|
||||
// The plugin contract — the product's main API surface: the machine-readable types +
|
||||
// pure rules; README.md (Building plugins) is the prose reference, discovery/router wire it to FS+HTTP.
|
||||
// Powerful, predictable, fails loud at boot/discovery rather than sandboxing at runtime.
|
||||
// The plugin contract — the product's main API surface: the machine-readable types + pure rules.
|
||||
// README → Building plugins is the prose reference; discovery/router wire this to FS + HTTP.
|
||||
//
|
||||
// A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount =
|
||||
// `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice.
|
||||
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import type { NavNode } from "../ui/nav.ts";
|
||||
import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
|
||||
import type { StorageCredentials } from "./storage.ts";
|
||||
|
||||
// Host contract version (semver). Bump major on a breaking manifest/handler change, minor on an
|
||||
// additive one. A plugin pins the version it targets via `apiVersion`; the host applies
|
||||
// provider/consumer semver semantics in checkApiVersion (refuse/warn on mismatch).
|
||||
export const HOST_API_VERSION = "1.0.0";
|
||||
// The Plainpages release this contract ships in — see README → Contract versioning.
|
||||
export const HOST_API_VERSION = "0.2.0";
|
||||
|
||||
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
||||
|
||||
@@ -30,24 +29,21 @@ export interface Route {
|
||||
method: HttpMethod;
|
||||
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
||||
permission?: string; // coarse gate — the Keto Permission the caller must hold; checked before the handler runs
|
||||
// Mark the page reachable by anyone, signed in or not. The same as omitting `permission`
|
||||
// — an ungated route is already open — but stated outright, so "public" is a deliberate
|
||||
// choice, not an accident. Mutually exclusive with `permission` (discovery refuses both).
|
||||
// Same as omitting `permission`, but stated outright so public is a deliberate choice rather than
|
||||
// a forgotten gate. Mutually exclusive with `permission` (discovery refuses both).
|
||||
public?: boolean;
|
||||
}
|
||||
|
||||
// A Keto Permission this plugin gates on — declared for docs/seeding. Permission names are a shared
|
||||
// global namespace (so an operator grants them once in Keto) and are always `<resource>:<action>` —
|
||||
// `scheduling:read`, `users:write`. A bare word names who someone is rather than what they may do,
|
||||
// which is a role, and roles are groups here (README → Users, groups & permissions).
|
||||
// A Keto Permission this plugin gates on — declared for docs/seeding. Names are a shared global
|
||||
// namespace, so an operator grants them once in Keto. See README → Users, groups & permissions.
|
||||
export interface PermissionDecl {
|
||||
description?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// `<resource>:<action>`, each half lowercase alphanumeric with dashes/underscores inside. The 64-char
|
||||
// cap keeps a name usable as a Keto object and a URL path segment. Enforced at discovery like every
|
||||
// other manifest rule, so the convention holds for plugins the admin GUI never touches.
|
||||
// `<resource>:<action>`. The 64-char cap keeps a name usable as a Keto object and a URL path
|
||||
// segment. Enforced at discovery like every other manifest rule, so the convention holds for
|
||||
// plugins the admin GUI never touches.
|
||||
const PERMISSION_NAME = /^[a-z0-9][a-z0-9_-]*:[a-z0-9][a-z0-9_-]*$/;
|
||||
|
||||
export function isValidPermissionName(name: string): boolean {
|
||||
@@ -66,28 +62,43 @@ export function declaredPermissions(plugins: Plugin[]): PermissionDecl[] {
|
||||
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.
|
||||
export interface PluginHooks {
|
||||
onBoot?: () => Promise<void> | void; // after discovery, before the server listens
|
||||
export interface PluginHooks<S extends readonly SettingDecl[] = readonly SettingDecl[]> {
|
||||
onBoot?: (host: BootContext<S>) => Promise<void> | void; // after discovery, before the server listens
|
||||
onRequest?: (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void; // may short-circuit
|
||||
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
|
||||
// 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)
|
||||
// Take over the gated dashboard "/dashboard" — the post-login app home. A handler like any
|
||||
// route's; the host gates it to a signed-in session (anonymous → /login), then renders its own view
|
||||
// via ctx.chrome. At most one plugin may declare it (findConflicts → error, never last-write-wins).
|
||||
// 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).
|
||||
dashboard?: RouteHandler;
|
||||
// Take over the public landing "/" — the ungated front page. A handler like any route's,
|
||||
// anyone may reach it. At most one plugin may declare it (findConflicts → error).
|
||||
// Take over the ungated public landing "/". At most one plugin may declare it.
|
||||
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
|
||||
permissions?: PermissionDecl[];
|
||||
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
|
||||
@@ -96,27 +107,25 @@ export interface Plugin extends PluginManifest {
|
||||
id: string;
|
||||
}
|
||||
|
||||
// Identity helper: types the manifest, returns it unchanged. Validation happens at discovery
|
||||
//, so a plugin may equally be a plain typed object. Mirrors Vite's `defineConfig`.
|
||||
export function definePlugin(manifest: PluginManifest): PluginManifest {
|
||||
// Types the manifest and returns it unchanged; validation happens at discovery, so a plugin may
|
||||
// equally be a plain typed object.
|
||||
// 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;
|
||||
}
|
||||
|
||||
// A plugin id (its folder name) — lowercase a–z, digits, and dashes, dashes allowed anywhere.
|
||||
// Rejects uppercase, underscores, dots, slashes, spaces: the id forms the mount path `/<id>`,
|
||||
// the view/static namespace, and the central-override target, so it must stay URL/path-safe.
|
||||
// The id forms the mount path `/<id>`, the view/static namespace and the central-override target,
|
||||
// so it must stay URL/path-safe: no uppercase, underscores, dots, slashes or spaces.
|
||||
const PLUGIN_ID = /^[a-z0-9-]+$/;
|
||||
|
||||
export function isValidPluginId(id: string): boolean {
|
||||
return PLUGIN_ID.test(id);
|
||||
}
|
||||
|
||||
// Ids the host reserves for its own first-party mount segments (the gated /dashboard, the auth flows,
|
||||
// /auth/complete, /logout, the /oauth2 provider routes, the /public/ static). Plugin routes resolve
|
||||
// before these, so a folder named one of them would silently shadow a built-in route — discovery
|
||||
// refuses it, loud like any conflict. ("/" is owned by the `home` field, not a route, so it can't be
|
||||
// shadowed and needs no reservation.) Note `admin` is NOT reserved: the admin screens ship as a
|
||||
// drop-in plugin (examples/plugins/admin, mounted at /admin), not a built-in route.
|
||||
// Plugin routes resolve before the built-ins, so a folder named one of these would silently shadow
|
||||
// one — discovery refuses it. "/" is owned by the `home` field, not a route, so it needs no
|
||||
// reservation; `admin` is deliberately absent, the admin screens being a drop-in plugin.
|
||||
export const RESERVED_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||
"auth", "dashboard", "login", "logout", "oauth2", "public", "recovery", "registration", "settings", "verification",
|
||||
]);
|
||||
@@ -127,14 +136,12 @@ export interface Semver {
|
||||
patch: number;
|
||||
}
|
||||
|
||||
// The official semver.org 2.0.0 core regex (major.minor.patch, optional prerelease/build) — a
|
||||
// standardized parse with no dependency. We compare only major/minor for compatibility, so the
|
||||
// prerelease/build groups are matched (to accept valid input) but otherwise ignored.
|
||||
// The official semver.org 2.0.0 core regex. Only major/minor drive compatibility, so the
|
||||
// prerelease/build groups are matched to accept valid input but otherwise ignored.
|
||||
const SEMVER =
|
||||
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$/;
|
||||
|
||||
// Parse a strict semver string → {major, minor, patch}, or null. Rejects ranges/prefixes
|
||||
// (`^1.2.3`, `v1`), leading zeros, whitespace and missing parts — fail loud over coerce.
|
||||
// Rejects ranges/prefixes (`^1.2.3`, `v1`), leading zeros and missing parts — fail loud over coerce.
|
||||
export function parseSemver(version: unknown): Semver | null {
|
||||
if (typeof version !== "string") return null;
|
||||
const m = SEMVER.exec(version);
|
||||
@@ -147,9 +154,8 @@ export interface VersionCheck {
|
||||
message: string;
|
||||
}
|
||||
|
||||
// Provider/consumer semver check (full table in README.md → Contract versioning): same major+minor → ok,
|
||||
// plugin minor < host → warn, else (newer minor, major mismatch, malformed) → refuse. Patch is
|
||||
// ignored. Discovery maps refuse→throw, warn→log.
|
||||
// Provider/consumer semver check (full table in README → Contract versioning). Discovery maps
|
||||
// refuse→throw, warn→log.
|
||||
export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HOST_API_VERSION): VersionCheck {
|
||||
const plugin = parseSemver(pluginVersion);
|
||||
const host = parseSemver(hostVersion);
|
||||
@@ -164,21 +170,24 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
|
||||
return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` };
|
||||
}
|
||||
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}` };
|
||||
}
|
||||
|
||||
export interface PluginConflict {
|
||||
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route";
|
||||
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route" | "setting";
|
||||
level: "error" | "warn";
|
||||
message: string;
|
||||
plugins: string[]; // unique ids involved
|
||||
}
|
||||
|
||||
// The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered
|
||||
// plugins; discovery throws on any "error" and logs every "warn". Mount-path (`/<id>`) uniqueness
|
||||
// is structural — it follows from the id check, so it needs no rule of its own. Shared permission
|
||||
// Loud resolution, never last-write-wins: discovery throws on any "error" and logs every "warn".
|
||||
// Mount-path uniqueness needs no rule of its own — it follows from the id check. Shared permission
|
||||
// names are the one intentional overlap, so they warn rather than error.
|
||||
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||
const out: PluginConflict[] = [];
|
||||
@@ -212,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) });
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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,6 +1,6 @@
|
||||
// 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
|
||||
// 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,
|
||||
// denylist enabled), and ctx.system itself is undefined when the host wired none. A plugin must
|
||||
@@ -10,11 +10,10 @@ import type { HydraAdmin } from "../auth/hydra-admin.ts";
|
||||
import type { KetoClient } from "../auth/keto-client.ts";
|
||||
import type { KratosAdmin } from "../auth/kratos-admin.ts";
|
||||
|
||||
// Grouping criterion (keep this cohesive — it's a contract, so the "no catch-all bucket" rule that
|
||||
// governs folders governs this bag too): every field is a *privileged, host-owned, wire-dependent*
|
||||
// capability for administering Plainpages' own identity/permission stack. Add a field only when it
|
||||
// meets all three; if unrelated privileged concerns accrete (mailer, metrics, flags), sub-group
|
||||
// rather than pile them in flat.
|
||||
// Keep this cohesive — it is a contract, so the "no catch-all bucket" rule applies: every field is a
|
||||
// *privileged, host-owned, wire-dependent* capability for administering Plainpages' own
|
||||
// identity/permission stack. Add one only when it meets all three; sub-group rather than pile in
|
||||
// unrelated privileged concerns (mailer, metrics, flags).
|
||||
export interface SystemCapabilities {
|
||||
hydra?: HydraAdmin; // OAuth2 client admin (Hydra); present when the Hydra admin client is wired
|
||||
keto?: KetoClient; // relationship read/write (Keto); present when Keto is wired
|
||||
|
||||
+34
-2
@@ -3,14 +3,46 @@
|
||||
// verified by booting postgres in CI/e2e; this catches edits.
|
||||
import { test } from "node:test";
|
||||
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 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");
|
||||
for (const db of ORY_DATABASES) {
|
||||
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
@@ -2,6 +2,7 @@ import { createApp } from "./http/app.ts";
|
||||
import { loadConfig } from "./config.ts";
|
||||
import { createDenylist } from "./auth/denylist.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 { runBootHooks } from "./plugin-host/hooks.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 { createLogger, tracedFetch } from "./logger.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
|
||||
// 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
|
||||
// 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 });
|
||||
@@ -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) }));
|
||||
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({
|
||||
// Canonical-host redirect target (off-host GET/HEAD visitors are sent here). Opt-in: omitted unless
|
||||
@@ -64,8 +106,9 @@ const server = createApp({
|
||||
menu,
|
||||
plugins,
|
||||
secureCookies: config.secureCookies,
|
||||
settingsCatalog: settings.catalog,
|
||||
}).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
|
||||
|
||||
+6
-14
@@ -1,9 +1,6 @@
|
||||
// Page chrome for plugin pages: the brand / global-nav / user / theme / csrf block a
|
||||
// plugin view hands to partials/shell so its page looks native — the same shell the dashboard and
|
||||
// every plugin renders. Pure; the host builds it per plugin request and exposes it on ctx.chrome.
|
||||
// nav is the global menu — Dashboard + every plugin's fragment (admin screens included, when the
|
||||
// admin plugin is installed) — run through composeNav (override + per-user filter) and
|
||||
// current-marked for the request path.
|
||||
// The brand / global-nav / user / theme / csrf block a view hands to partials/shell, exposed on
|
||||
// ctx.chrome. `nav` is the global menu — Dashboard plus every plugin's fragment — run through
|
||||
// composeNav (override + per-user filter) and current-marked for the request path.
|
||||
|
||||
import type { User } from "../http/context.ts";
|
||||
import { ENGLISH } from "../i18n/english.ts";
|
||||
@@ -13,9 +10,6 @@ import { composeNav, type NavNode } from "./nav.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { branding, shellUser, type ShellUser } from "./shell-context.ts";
|
||||
|
||||
// The "Dashboard" link to the gated app home (/dashboard). It targets a gated route, so it's shown
|
||||
// only to a signed-in user (an anonymous click would only dead-end at /login). Its label is a
|
||||
// catalog key — composeNav translates every label, and an unknown one renders as written.
|
||||
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard" };
|
||||
|
||||
export interface PageChrome {
|
||||
@@ -41,13 +35,11 @@ export interface ChromeOptions {
|
||||
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
const t = opts.t ?? ENGLISH;
|
||||
const carryLocale = opts.localeHref ?? ((href: string) => href);
|
||||
// The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an
|
||||
// anonymous visitor (a public page in the shell) it would only dead-end at /login. The admin
|
||||
// section, when present, is just another plugin's nav fragment (examples/plugins/admin).
|
||||
// Dashboard is gated, so an anonymous click would only dead-end at /login.
|
||||
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
|
||||
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
|
||||
// translator before they are merged. composeNav then runs the core one over the result for the
|
||||
// built-in nodes and the central override's labels; already-translated text passes through it.
|
||||
// translator before merging. composeNav then runs the core one over the result; already-translated
|
||||
// text passes through it.
|
||||
for (const p of opts.plugins ?? []) {
|
||||
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ import ejs from "ejs";
|
||||
import { ICON_NAMES, buildIconSprite } from "./icons.ts";
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
const lucideDir = join(rootDir, "node_modules", "lucide-static", "icons");
|
||||
// Resolved by specifier, not by path: the install lives above the app dir, not in it (Dockerfile).
|
||||
const lucideDir = join(dirname(fileURLToPath(import.meta.resolve("lucide-static/package.json"))), "icons");
|
||||
const partial = join(rootDir, "views", "partials", "icons.ejs");
|
||||
|
||||
const symbolInner = (sprite: string, id: string): string =>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user