Compare commits
1 Commits
v0.1.0
..
4265e88aaf
| Author | SHA1 | Date | |
|---|---|---|---|
| 4265e88aaf |
+1
-5
@@ -1,14 +1,10 @@
|
|||||||
.git
|
.git
|
||||||
# Load-bearing both ways: a stray copy would bake in at /app/node_modules and shadow /node_modules,
|
# Load-bearing: a stray copy would bake in at /app/node_modules and shadow /node_modules.
|
||||||
# and matching only the root one is what lets a baked plugin keep its own deps. Never `**/node_modules`.
|
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# A plugin's .npmrc is where a private-registry token would sit — never in a shipped image.
|
|
||||||
plugins/**/.npmrc
|
|
||||||
|
|
||||||
e2e-tests/artifacts
|
e2e-tests/artifacts
|
||||||
# Orchestration, not test code — keep them out of the runner image (COPY e2e-tests/ ./)
|
# Orchestration, not test code — keep them out of the runner image (COPY e2e-tests/ ./)
|
||||||
e2e-tests/Dockerfile
|
e2e-tests/Dockerfile
|
||||||
|
|||||||
@@ -2,27 +2,12 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags: ['v[0-9]+.[0-9]+.[0-9]+']
|
tags: ['v[0-9]+.[0-9]+.[0-9]+']
|
||||||
workflow_dispatch:
|
|
||||||
inputs:
|
|
||||||
overview_version:
|
|
||||||
description: 'Released version to republish the overview for, without the leading v (e.g. 0.1.0)'
|
|
||||||
required: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
retag-image:
|
retag-image:
|
||||||
if: github.event_name == 'push'
|
|
||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7.0.1
|
- uses: actions/checkout@v7.0.1
|
||||||
# Before anything is published: the contract version IS the release version, so a tag that
|
|
||||||
# disagrees would ship a host misreporting itself to every plugin's compatibility check.
|
|
||||||
- name: Refuse a tag that disagrees with HOST_API_VERSION
|
|
||||||
env:
|
|
||||||
GIT_TAG: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
|
||||||
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
|
|
||||||
- name: Promote the commit-hash image to semver + latest
|
- name: Promote the commit-hash image to semver + latest
|
||||||
env:
|
env:
|
||||||
GIT_TAG: ${{ github.ref_name }}
|
GIT_TAG: ${{ github.ref_name }}
|
||||||
@@ -30,77 +15,34 @@ jobs:
|
|||||||
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
|
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
|
||||||
REPO: gitea.larvit.se/${{ github.repository }}
|
REPO: gitea.larvit.se/${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
|
||||||
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||||
VERSION=${GIT_TAG#v}
|
VERSION=${GIT_TAG#v}
|
||||||
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
|
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
|
||||||
docker pull "$REPO:$COMMIT" \
|
docker pull "$REPO:$COMMIT" \
|
||||||
|| { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; }
|
|| { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; }
|
||||||
# No bare-major tag while major is 0: a 0.x minor is a contract break, so `:0` would move
|
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
|
||||||
# across one and abort boot for everything tracking it. `:0.1` only moves across patches.
|
|
||||||
TAGS="$VERSION ${VERSION%.*} latest"
|
|
||||||
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
|
|
||||||
for TAG in $TAGS; do
|
|
||||||
docker tag "$REPO:$COMMIT" "$REPO:$TAG"
|
docker tag "$REPO:$COMMIT" "$REPO:$TAG"
|
||||||
docker push "$REPO:$TAG"
|
docker push "$REPO:$TAG"
|
||||||
done
|
done
|
||||||
- name: Sync the release tags to Docker Hub
|
- name: Sync the release tags to Docker Hub
|
||||||
env:
|
env:
|
||||||
DOCKERHUB_IMAGE: docker.io/${{ github.repository }}
|
DOCKERHUB_REPO: docker.io/${{ github.repository }}
|
||||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
|
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
|
||||||
GIT_TAG: ${{ github.ref_name }}
|
GIT_TAG: ${{ github.ref_name }}
|
||||||
REPO: gitea.larvit.se/${{ github.repository }}
|
REPO: gitea.larvit.se/${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
|
||||||
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||||
VERSION=${GIT_TAG#v}
|
VERSION=${GIT_TAG#v}
|
||||||
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|
||||||
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; }
|
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; }
|
||||||
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
|
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
|
||||||
TAGS="$VERSION ${VERSION%.*} latest"
|
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
|
||||||
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
|
docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG"
|
||||||
for TAG in $TAGS; do
|
docker push "$DOCKERHUB_REPO:$TAG"
|
||||||
docker tag "$REPO:$COMMIT" "$DOCKERHUB_IMAGE:$TAG"
|
|
||||||
docker push "$DOCKERHUB_IMAGE:$TAG"
|
|
||||||
done
|
done
|
||||||
- name: Log out of the registries
|
- name: Log out of the registries
|
||||||
if: always()
|
if: always()
|
||||||
run: |
|
run: |
|
||||||
set -uo pipefail
|
docker logout gitea.larvit.se
|
||||||
# Cleanup, and the runner's Docker config is shared (AGENTS.md) — a lost race here must not
|
docker logout docker.io
|
||||||
# fail a release that published, nor skip the overview job that follows.
|
|
||||||
docker logout gitea.larvit.se || true
|
|
||||||
docker logout docker.io || true
|
|
||||||
|
|
||||||
publish-overview:
|
|
||||||
if: always() && (github.event_name == 'workflow_dispatch' || needs.retag-image.result == 'success')
|
|
||||||
needs: [retag-image]
|
|
||||||
runs-on: docker-host
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v7.0.1
|
|
||||||
if: github.event_name == 'push'
|
|
||||||
# Publish the named release's own tree, so the page never pairs one Plainpages tag with another
|
|
||||||
# release's sidecar pins. A version that was never released fails here.
|
|
||||||
- uses: actions/checkout@v7.0.1
|
|
||||||
if: github.event_name == 'workflow_dispatch'
|
|
||||||
with:
|
|
||||||
ref: refs/tags/v${{ inputs.overview_version }}
|
|
||||||
- name: Publish the Docker Hub overview
|
|
||||||
env:
|
|
||||||
DOCKERHUB_REPO: ${{ github.repository }}
|
|
||||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
|
|
||||||
GIT_TAG: ${{ github.ref_name }}
|
|
||||||
INPUT_VERSION: ${{ inputs.overview_version }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
|
|
||||||
VERSION=${VERSION#v}
|
|
||||||
# An empty dispatch input falls back to the branch name, so gate this like a tag.
|
|
||||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.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,19 +21,21 @@ jobs:
|
|||||||
-e RENOVATE_PLATFORM=gitea \
|
-e RENOVATE_PLATFORM=gitea \
|
||||||
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||||
-e RENOVATE_TOKEN \
|
-e RENOVATE_TOKEN \
|
||||||
renovate/renovate:44.39.1
|
renovate/renovate:44.17.1
|
||||||
|
|
||||||
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
|
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
|
||||||
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
|
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
|
||||||
# trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit
|
# trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit
|
||||||
# (a human owns that release), nothing new merged, or nothing that merged carried a `Release-Bump:`
|
# (a human owns that release) or nothing new merged. ff-only merges keep the renovate commit's
|
||||||
# trailer — a release nobody can observe is noise. ff-only merges keep the renovate commit's
|
|
||||||
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
|
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
|
||||||
# pre-1.0 shifts down (release-tooling/next-version.ts). Tag-only — release.yml promotes the
|
# pre-1.0 shifts down (auto-release/next-version.ts). Tag-only — release.yml promotes the
|
||||||
# already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't).
|
# already-built image; pushed with renovate-bot's PAT so release.yml fires (the built-in token won't).
|
||||||
|
# Off until the Actions variable AUTO_RELEASE is set to 'true': Plainpages is pre-announcement and
|
||||||
|
# deliberately carries no tags, so an automated bump would only invent a version nobody consumes.
|
||||||
auto-release:
|
auto-release:
|
||||||
runs-on: docker-host
|
runs-on: docker-host
|
||||||
needs: renovate
|
needs: renovate
|
||||||
|
if: vars.AUTO_RELEASE == 'true'
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v7.0.1
|
- uses: actions/checkout@v7.0.1
|
||||||
with:
|
with:
|
||||||
@@ -55,15 +57,8 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
|
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
|
||||||
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
|
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
|
||||||
if [ -z "$BUMPS" ]; then
|
|
||||||
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
|
|
||||||
fi
|
|
||||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
||||||
node release-tooling/next-version.ts "$LATEST" $BUMPS)
|
node auto-release/next-version.ts "$LATEST" $BUMPS)
|
||||||
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
|
|
||||||
git show origin/main:src/plugin-host/plugin.ts \
|
|
||||||
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
|
||||||
node release-tooling/contract-version.ts "$NEXT" -
|
|
||||||
echo "Releasing $LATEST -> $NEXT"
|
echo "Releasing $LATEST -> $NEXT"
|
||||||
git tag "$NEXT" origin/main
|
git tag "$NEXT" origin/main
|
||||||
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
|
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
|
||||||
|
|||||||
@@ -3,27 +3,20 @@
|
|||||||
Guidance for AI agents and contributors working in this repo. Read `README.md` for
|
Guidance for AI agents and contributors working in this repo. Read `README.md` for
|
||||||
commands and layout.
|
commands and layout.
|
||||||
|
|
||||||
## Prose discipline
|
## Maintaining this file
|
||||||
|
|
||||||
Every word in this repo is read again on every future task, so prose is a recurring cost. On **any**
|
Every agent session reads this file in full, so its length is a cost paid on every task.
|
||||||
change, sweep the prose you touched — this file, `README.md`, the example READMEs, and code
|
Keep it the shortest thing that still changes what someone does.
|
||||||
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
|
- **Trim as you add.** After any edit, re-read the whole file and compress: merge overlapping
|
||||||
rejected", "(declined twice)", dated changelog entries, or the symptom that prompted a fix. Record
|
entries, cut prose that restates a rule, drop what the code or `README.md` already says.
|
||||||
the decision and the reason it *currently* turns on, nothing else.
|
Question each section — same information, fewer words.
|
||||||
- **Delete restatement.** A comment that says what the adjacent line says, a doc paragraph that
|
- **Record the decision and the reason it turns on, nothing else.** Not the investigation, not
|
||||||
re-explains a table above it, a file-map entry that expands the filename. The fix is deletion,
|
what was tried first, not how it was verified — that belongs in the PR that made the change.
|
||||||
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
|
- **Give every accepted risk an expiry** ("valid while X"), and delete the entry once X stops
|
||||||
holding.
|
holding.
|
||||||
- **Keep** the surprising why, the footgun, the invariant, the external constraint, and the one-time
|
- **One home per fact.** Link to it rather than restating it — the same sentence in five files
|
||||||
setup a reader cannot dig out of the code. Once a line has earned its place, make it short and
|
is five things to update and five chances to drift.
|
||||||
information-dense.
|
|
||||||
|
|
||||||
Trimming is not a separate task to schedule — do it in the same change, every time.
|
|
||||||
|
|
||||||
## How to work with tasks
|
## How to work with tasks
|
||||||
|
|
||||||
@@ -36,27 +29,31 @@ branch, create a PR and merge it when the CI/CD turns green.
|
|||||||
## Project priorities (do not erode)
|
## Project priorities (do not erode)
|
||||||
|
|
||||||
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
|
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
|
||||||
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
|
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`).
|
||||||
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
|
Prefer the Node standard library; justify any new dependency; do not add frameworks. The app is
|
||||||
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
|
**stateless — no database**. Auth/identity/OAuth are **Ory sidecar services** (Kratos/Keto/Hydra,
|
||||||
Postgres database, which the host provisions but never reads or writes inside. Auth/identity/OAuth are
|
backed by Postgres), reached over their REST APIs with built-in `fetch` — no SDK. New
|
||||||
**Ory sidecar services** reached over their REST APIs with built-in `fetch` — no SDK. New
|
capabilities ship as **plugin folders** under `plugins/` that fetch their data from upstream
|
||||||
capabilities ship as **plugin folders** under `plugins/` that get their data from an upstream
|
services, not as core code.
|
||||||
service or their own database, not as core code.
|
|
||||||
3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
|
3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
|
||||||
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types;
|
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types;
|
||||||
limit nullable and multi-option types.
|
limit nullable and multi-option types.
|
||||||
4. **Environment-agnostic** — no `NODE_ENV` branching. Every behaviour is an **explicit config
|
4. **Environment-agnostic** — the app never asks *which environment* it runs in; no `NODE_ENV`
|
||||||
toggle** read once in `src/config.ts`; compose files set them per deployment.
|
branching. Every behaviour is an **explicit config toggle** (e.g. `CACHE_TEMPLATES`,
|
||||||
5. **Semantic, accessible DOM** — the right element for the job (landmarks, one `<h1>` per page +
|
`REQUIRE_SECURE_SECRETS`), read once in `src/config.ts`. Compose files set them per deployment.
|
||||||
sane heading order, lists, `<table>` with row/column headers, `<fieldset>`/`<legend>`, `<button>`
|
5. **Semantic, accessible DOM** — use the right element for the job (landmarks, one `<h1>` per page
|
||||||
vs `<a>`); ARIA only to fill real gaps. Classes/ids name *meaning*, not looks.
|
+ sane heading order, lists, `<table>` with row/column headers, `<fieldset>`/`<legend>`,
|
||||||
6. **Full, parallel E2E** — every user-facing flow has a Playwright test, shipped in the same change
|
`<button>` vs `<a>`); add ARIA only to fill real gaps (`aria-current`, `aria-sort`, labels).
|
||||||
as the surface. Tests stay independent and side-effect-free so the suite runs `fullyParallel`.
|
Classes/ids name *meaning*, not looks. Prefer native semantics over `div` + ARIA. New views and
|
||||||
7. **Powerful, fail-loud plugins** — the plugin API is the product's main surface and the only way to
|
partials keep this bar.
|
||||||
add domain features. It optimises for being powerful, predictable and overloadable, and the host
|
6. **Full, parallel E2E** — every user-facing flow (each page, form, guard, plugin route) has a
|
||||||
**fails loud at boot/discovery** rather than sandboxing at runtime. Runtime crash-isolation is a
|
Playwright E2E test, shipped in the same change as the surface. Tests stay independent and
|
||||||
deliberate **non-goal**.
|
side-effect-free so the suite runs `fullyParallel` — never serialise on shared state.
|
||||||
|
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, conflict) rather than sandboxing at runtime. Runtime
|
||||||
|
crash-isolation is a deliberate **non-goal** — diagnose at deploy time, not in production.
|
||||||
|
|
||||||
## Deliberate architectural deviations (don't re-flag)
|
## Deliberate architectural deviations (don't re-flag)
|
||||||
|
|
||||||
@@ -65,75 +62,36 @@ Revisit only if the stated reason stops holding.
|
|||||||
|
|
||||||
### Structure & contracts
|
### Structure & contracts
|
||||||
|
|
||||||
- **`src/` is grouped by concern**, not flat — `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`,
|
- **`src/` is grouped by concern**, not flat — `http/` (request pipeline), `auth/` (session-JWT hot
|
||||||
with `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` at the root; tests are
|
path, guards, Ory REST clients), `i18n/` (locale resolution + catalogs), `plugin-host/`
|
||||||
co-located. Add a new module to the folder owning its concern. The core ships **no domain
|
(discovery/router/hooks/view-resolver + the `plugin-api.ts` author barrel + `system.ts` behind
|
||||||
screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
|
`ctx.system`), `ui/` (design-system view-models + menu/chrome). `server.ts`/`config.ts`/`logger.ts`
|
||||||
- **Plugins and config import the host only through a barrel** — `@plainpages/plugin-api` →
|
and the topology-guard `*.test.ts` stay at the root; tests are co-located. Add a new module to the
|
||||||
`plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts`,
|
folder owning its concern; don't reintroduce a flat tree. The core ships **no domain screens** —
|
||||||
never a relative `../../src/*` path. These two barrels are the whole contract surface; don't "fix"
|
even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
|
||||||
either back to a relative path. Three consequences:
|
- **Plugins and config import the host only via package.json `imports`** — `#plugin-api` →
|
||||||
- `@plainpages/plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their
|
`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; the `src/*` behind them may
|
||||||
|
be refactored freely. Don't "fix" a `#`-import back to a relative path. Two consequences:
|
||||||
|
- `#plugin-api` re-exports the Ory client types (`KratosAdmin`/`KetoClient`/`HydraAdmin` + their
|
||||||
DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major
|
DTOs and error classes), so those shapes are **contract-visible** — changing them needs a major
|
||||||
`apiVersion` bump, not a free refactor.
|
`apiVersion` bump, not a free refactor.
|
||||||
- **The barrel is a package, not a `#`-import, so a plugin folder may carry its own
|
- **A plugin/config folder must stay a plain folder — no `package.json` of its own**, which would
|
||||||
`package.json`** and depend on npm packages (README → Plugin dependencies). The Dockerfile links
|
become its own scope and stop `#`-specifiers resolving. A plugin kept in its own repo therefore
|
||||||
it into `/node_modules`, above every plugin scope. Never let a copy reach a plugin's own
|
typechecks against the barrel only when mounted under the host tree (or with a vendored stub).
|
||||||
`node_modules`: two instances of the barrel break `instanceof` across the boundary, which
|
- **`examples/` mirrors the drop-in mount dirs** — `examples/plugins/<id>/` copies to `plugins/<id>/`,
|
||||||
`plugin-api.test.ts` guards by asserting both paths reach one module.
|
`examples/config/menu.ts` to `config/menu.ts`. Both mirrors are in `tsconfig.include` and resolve
|
||||||
- **Plugin storage hands over credentials, not a client** (README → Plugin storage). The host takes
|
the host via `#`-imports, so each typechecks in place *and* copies across unchanged. Never commit
|
||||||
`postgres` to run the provisioning DDL, and `storage-provisioning.ts` is the only module importing
|
real plugins/config into the root mount dirs — they ship empty.
|
||||||
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.
|
|
||||||
- **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 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
|
- **`ctx.chrome` is lazily memoized — do not make it unconditional** or move it into the base request
|
||||||
context. It protects the I/O-free hot path on the public, bot-hit landing (`/`).
|
context. It protects the I/O-free hot path on the public, bot-hit landing (`/`). (Declined twice.)
|
||||||
- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
|
- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
|
||||||
`dashboard`) and an `onRequest` short-circuit build their context with `contextFor(pluginId)`
|
`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
|
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.
|
render as bare keys on the pages it owns.
|
||||||
- **Email is delegated to Kratos** (it renders + sends recovery/verification mail); `web` never
|
- **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.
|
touches SMTP. Customization is Kratos' `courier.template_override_path`, not app code — keeping
|
||||||
|
`web` stateless and dependency-light.
|
||||||
|
|
||||||
### Authorization
|
### Authorization
|
||||||
|
|
||||||
@@ -142,10 +100,10 @@ Revisit only if the stated reason stops holding.
|
|||||||
a role is a *bundle*, which here is just a group with several grants (groups nest). Ory's own
|
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.
|
"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
|
- **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**
|
word names *who someone is* (a role), and roles are groups here; the old catch-all `admin` was
|
||||||
(`isValidPermissionName` in `plugin-host/plugin.ts`, checked by `shapeError` over every route/nav
|
exactly that mistake. **Enforced at discovery** (`isValidPermissionName` in `plugin-host/plugin.ts`,
|
||||||
`permission` and every declared name), fail-loud like any other manifest rule — not only in the
|
checked by `shapeError` over every route/nav `permission` and every declared name), fail-loud like
|
||||||
admin GUI, which an operator removes by not copying it in.
|
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
|
- **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
|
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
|
that catalog *is* the list the admin screens offer. Hence **no Permissions admin screen**: nothing
|
||||||
@@ -158,37 +116,38 @@ Revisit only if the stated reason stops holding.
|
|||||||
- **Declaring a permission stays optional.** Mandatory declaration would let `findConflicts` see all
|
- **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;
|
overlaps, but would then warn on exactly that legitimate sharing case. Shape is enforced;
|
||||||
declaration is not.
|
declaration is not.
|
||||||
- `ADMIN_PERMISSIONS` **defaults to empty**, and **an unusable value is dropped with a warning,
|
- `ADMIN_PERMISSIONS` **defaults to empty** (every permission is owned by the plugin gating on it),
|
||||||
never fatal** — fail-loud belongs at the manifest boundary where a developer authored the
|
and **an unusable value is dropped with a warning, never fatal** — fail-loud belongs at the
|
||||||
mistake, whereas `bootstrap` gates `web`, so refusing operator env takes the whole stack down
|
manifest boundary where a developer authored the mistake, whereas `bootstrap` gates `web`, so
|
||||||
(`e2e-tests/compose.auth.yml` seeds a bad value to prove the container survives one). The seed is
|
refusing operator env takes the whole stack down. `e2e-tests/compose.auth.yml` seeds a bad value
|
||||||
a function of what `bootstrap` discovers, so a plugin dropped in after first boot needs
|
so the container proves it survives one. The seed is a function of what `bootstrap` discovers, so
|
||||||
`docker compose up -d`, not `restart web`. `bootstrap`'s matching `./plugins` mount belongs in
|
a plugin dropped in after first boot needs `docker compose up -d` (re-runs the one-shot), not
|
||||||
`compose.override.yml` and nowhere else: in the base file it would desynchronise prod and collide
|
`restart web`. `bootstrap`'s matching `./plugins` mount belongs in `compose.override.yml` and
|
||||||
with the e2e stacks, which bind individual plugins *inside* `/app/plugins` (a nested mount into a
|
nowhere else: in the base file it would desynchronise prod and collide with the e2e stacks, which
|
||||||
read-only parent is EROFS and the container never starts). Valid while bootstrap is the only
|
bind individual plugins *inside* `/app/plugins` (a nested mount into a read-only parent is EROFS
|
||||||
writer of grants.
|
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
|
- **`actionForMethod` is plugin-local and must not migrate into `#plugin-api`.** Inside the admin
|
||||||
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
||||||
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
||||||
verb — a route table must answer "what does this need?" on its own.
|
verb — a route table must answer "what does this need?" on its own.
|
||||||
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
|
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
|
||||||
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
|
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
|
||||||
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
|
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
|
||||||
form, a delete-confirm page) is the exception to `actionForMethod` and gates on `:write`. Two
|
form, a delete-confirm page) is the exception to `actionForMethod` and gates on `:write`, since a
|
||||||
grant-specific guards go with it: you cannot revoke your own **direct** grants (self-lockout would
|
page whose only purpose is to start a write should refuse a reader rather than render a form whose
|
||||||
need a `curl` against Keto to undo), and a permission held *through a group* renders
|
submit 403s. Two grant-specific guards go with it: you cannot revoke your own **direct** grants
|
||||||
ticked-but-disabled, because unticked stated the opposite of the truth. **Known gap:** the group
|
(self-lockout would need a `curl` against Keto to undo), and a permission held *through a group*
|
||||||
paths are unguarded — unticking a permission on a group you belong to, leaving it, or deleting it
|
renders ticked-but-disabled, because unticked stated the opposite of the truth. **Known gap:** the
|
||||||
can still strip your own access. The robust "last effective holder" check needs a reverse Keto
|
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.
|
query and is deferred.
|
||||||
- **`users:write` and `groups:write` are equivalent to full administrative access**: `groups:write`
|
- **`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
|
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
|
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.
|
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
|
- **Plainpages says "user" everywhere; Ory's word is "identity".** Ory's own docs use the terms
|
||||||
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
|
interchangeably, so this is house style, not a renamed concept. The single exception is the
|
||||||
Kratos' wire shape — don't rename it.
|
`Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors Kratos' wire shape — don't rename it.
|
||||||
|
|
||||||
### i18n
|
### i18n
|
||||||
|
|
||||||
@@ -197,14 +156,15 @@ Revisit only if the stated reason stops holding.
|
|||||||
page's language invisible in its address and unshareable; the cost is that a plugin wraps its own
|
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
|
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.
|
`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`,
|
- **The core building blocks carry the locale; a plugin doesn't have to.** The shell (breadcrumbs),
|
||||||
`filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every href in
|
`pagination`, `filter-bar`, `data-table`, `auth-card`, `flow-body`, `field` and `menu` wrap every
|
||||||
`localeHref`; nav and sign-in are wrapped in `chrome.ts`; the two GET forms carry it as a hidden
|
href in `localeHref`; nav and sign-in are wrapped in `chrome.ts`; the two GET forms carry it as a
|
||||||
`locale` input, since a GET submit replaces the whole query string. **A form's `action` counts as a
|
hidden `locale` input, since a GET submit replaces the whole query string. **A form's `action`
|
||||||
link** — sign-out, consent and auth-card forms carry it too, or picking a language and then saving
|
counts as a link** — sign-out, consent and auth-card forms carry it too, or picking a language and
|
||||||
anything drops back to `Accept-Language`. The obligation stays on the building block, never on each
|
then saving anything drops back to `Accept-Language`. Putting the obligation on each call site was
|
||||||
call site. `ctx.localeHref` remains for hrefs a plugin's own markup emits. The one round-trip that
|
tried and missed five of eight sites in one commit. `ctx.localeHref` remains for hrefs a plugin's
|
||||||
cannot carry it is the Kratos sign-in POST (absolute off-site URL).
|
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
|
- **`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`,
|
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
|
`localeHref`, `localeParam`, `localeSwitch`, `dir`) are likewise reserved, merged after a handler's
|
||||||
@@ -232,28 +192,30 @@ Revisit only if the stated reason stops holding.
|
|||||||
escaping into `t()` — every other value in a view would become the odd one out.
|
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
|
- **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
|
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.
|
locale needs those moved to logical ones first. Don't convert the CSS or file findings about it on
|
||||||
|
spec. Valid while no deployment needs an RTL language.
|
||||||
|
|
||||||
### UI
|
### UI
|
||||||
|
|
||||||
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
|
- **A dropdown is a `<button popovertarget>` + `[popover]`, never a `<details>`.** The browser then
|
||||||
owns open/close — the only zero-JS way to dismiss by clicking outside — and the panel sits in the
|
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
|
top layer, so a row kebab is no longer clipped by `.table-wrap`'s `overflow`. Four rules hold it
|
||||||
together: the panel carries **`position-anchor: auto`** (a bare `anchor()` resolves to nothing in
|
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
|
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
|
open-state style and the old-browser fallback both read; the partial **requires a caller-named `id`**
|
||||||
`id`** and fails loud without one, since that is the `popovertarget` idref (never generate one —
|
and fails loud without one, since that is the `popovertarget` idref (generated ids were tried and
|
||||||
nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
|
rejected — 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
|
`aria-haspopup` is written**, because a zero-JS invoker cannot keep the first truthful and the second
|
||||||
second would promise `role="menu"` semantics these panels don't implement. `<details>` stays where
|
would promise `role="menu"` semantics these panels don't implement. `<details>` stays where it means
|
||||||
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the profile
|
||||||
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
|
menu (its trigger composes escaped user values and its one item is a CSRF POST form, neither of which
|
||||||
the two in step.
|
the partial's `Item` shapes cover) — keep the two in step.
|
||||||
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
|
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract.** It is
|
||||||
is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
|
deliberately not re-exported from `#plugin-api`; README → Nav & permission gates tells an author that
|
||||||
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
|
a new icon means registering it there. So the palette may narrow when the last reference to an id
|
||||||
unknown sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test
|
goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an unknown
|
||||||
catches anything reaching the nav).
|
sprite id renders blank instead of failing loud (the `every icon <use> resolves` e2e test catches
|
||||||
|
anything reaching the nav). Removing an id is a core edit — weigh it per icon rather than sweeping.
|
||||||
|
|
||||||
### Build, test & release
|
### Build, test & release
|
||||||
|
|
||||||
@@ -264,14 +226,16 @@ Revisit only if the stated reason stops holding.
|
|||||||
files, `.dockerignore` the image).
|
files, `.dockerignore` the image).
|
||||||
- **A container whose output a human then edits or deletes runs as `--user "$(id -u):$(id -g)"`** —
|
- **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
|
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
|
a dev box may not have at all. Not universal: `bootstrap` writes `jwks.json` as root when it is
|
||||||
first boot; the committed dev key makes that rare, and when it happens the rotation runbook's
|
absent on first boot — the committed dev key makes that rare, and when it happens the rotation
|
||||||
host-side `>` needs the file re-owned first (valid while the dev key ships committed). Three
|
runbook's host-side `>` needs the file re-owned first. Valid while the dev key ships committed.
|
||||||
consequences: `e2e-tests/artifacts/` is *tracked* (`.gitkeep`), since an absent bind-mount source is
|
Three consequences.
|
||||||
daemon-created as root and that uid then cannot write it (README → Upgrading); the runner image sets
|
`e2e-tests/artifacts/` is *tracked* (`.gitkeep`), since an absent bind-mount source is
|
||||||
`HOME=/tmp`, since an arbitrary uid has no passwd entry and would land on an unwritable `/`; and
|
daemon-created as root and that uid then cannot write it — which also makes a root-owned leftover
|
||||||
rootless Docker wants the flag *dropped*, container root already being the invoking user. Baking a
|
an upgrade hazard (README → Breaking changes). The runner image sets `HOME=/tmp`, since an
|
||||||
`USER` in instead does not work — the image's `pwuser` is 1001 and no fixed uid matches every host.
|
arbitrary uid has no passwd entry and would land on an unwritable `/`. And rootless Docker wants
|
||||||
|
the flag *dropped* — container root is already the invoking user there. 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.
|
`src/compose.test.ts` guards every documented command, `src/ci-gate.test.ts` the gate's own.
|
||||||
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
|
- **Anything the browser logs fails the E2E test that provoked it.** Every spec takes its `test` from
|
||||||
`e2e-tests/console-guard.ts`, which fails a test on a console error/warning or uncaught exception on
|
`e2e-tests/console-guard.ts`, which fails a test on a console error/warning or uncaught exception on
|
||||||
@@ -280,30 +244,42 @@ Revisit only if the stated reason stops holding.
|
|||||||
Chromium drops (the e2e stacks serve plain http over container hostnames), and `allowConsole(re)` for
|
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
|
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
|
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
|
a raw `newPage()` instead of `watchedPage()` — would run unwatched and green. The buffer clears at
|
||||||
outliving its test can log late and fail the next one.
|
teardown so a `beforeAll` is watched too; accepted cost is that 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.**
|
- **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 parallel runs don't collide, and a
|
`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
|
console message only appears in the engine that renders the page (`ORY_FREE` in
|
||||||
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
|
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
|
||||||
so widening them means a stack per engine.
|
so widening them means a stack per engine. Screenshots are written per project name.
|
||||||
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** Both git channels in
|
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** No test, build step or
|
||||||
`ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the destination, so
|
workflow reads a markdown file, so a nested `examples/plugins/admin/README.md` edit is as safe to
|
||||||
`git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a source file that
|
skip as `README.md`. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`: rename
|
||||||
was gone. `src/ci-gate.test.ts` locks the flags as a
|
detection names only the destination, so `git mv src/app.ts notes.md` otherwise read as docs and
|
||||||
*text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
|
skipped the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a *text*
|
||||||
`release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
|
guard — the test image ships neither `git` nor `bash`. Revisit if a `.md` ever becomes load-bearing.
|
||||||
guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard
|
|
||||||
skipped. `README.md` is the one markdown a test reads — `release-tooling/contract-version.test.ts`
|
|
||||||
checks its `apiVersion` samples — and a README-only change skips that check; accepted, because
|
|
||||||
those samples are illustrative and the copies that matter (`examples/`, `views/`, the template) are
|
|
||||||
gated. **Valid while no markdown file is rendered or executed.**
|
|
||||||
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
||||||
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs
|
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs
|
||||||
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
|
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
|
||||||
file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's
|
file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's
|
||||||
web-image build races another run's container creation on the `<project>-web` tag. Accepted for a
|
web-image build races another run's container creation on the `<project>-web` tag. Accepted for a
|
||||||
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
|
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
|
||||||
|
- **Plainpages is pre-announcement: no tags, no releases.** All tags and semver container tags were
|
||||||
|
deleted, and `auto-release` is gated behind the `AUTO_RELEASE` Actions variable (unset ⇒ skipped,
|
||||||
|
the fail-safe direction on every unknown-`vars` path). A version only communicates to consumers and
|
||||||
|
there are none — the same reasoning that freezes `HOST_API_VERSION`. Two couplings: `registry-cleanup`
|
||||||
|
keeps a hash image only while its commit is a branch head *or* release-tagged, so with zero tags a
|
||||||
|
hand-cut tag must sit on `main`'s tip; and `mirror.yml` pushes tags with `--prune` (so its
|
||||||
|
`fetch-tags: true` is load-bearing), meaning a tag or Release created on GitHub is swept away and
|
||||||
|
releases are cut on Gitea only. Valid until the
|
||||||
|
maintainer says Plainpages is ready to show people.
|
||||||
|
- **A stricter manifest rule breaks already-copied plugins, and while `HOST_API_VERSION` is frozen the
|
||||||
|
failure names a symptom rather than the cause.** `plugins/` is an operator-owned drop-in mount, so an
|
||||||
|
operator's copy is whatever version they took. `checkApiVersion` is the right mechanism — a breaking
|
||||||
|
manifest change bumps the major and a stale plugin is refused by *version* — but that only works once
|
||||||
|
the freeze lifts. Until then a stricter rule ships with a README → Upgrading entry and a re-copy hint
|
||||||
|
in the discovery error. Fail-loud stays right either way: the alternative is a route gating on a name
|
||||||
|
nobody can be granted, i.e. a permanent silent 403. **Valid while `HOST_API_VERSION` stays frozen.**
|
||||||
|
|
||||||
## Docker only — no host tooling
|
## Docker only — no host tooling
|
||||||
|
|
||||||
@@ -322,8 +298,8 @@ docker compose -f compose.yml up --build -d # production
|
|||||||
`README.md` serves two readers, in this order — preserve it when editing:
|
`README.md` serves two readers, in this order — preserve it when editing:
|
||||||
|
|
||||||
1. **First-time reader (top).** A one/two-sentence tagline, then a **Quick start** that gets the
|
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
|
stack up and a *minimal* plugin live. Nothing comes before Quick start — no philosophy, no
|
||||||
copy-pasteable; deeper detail lives in its own section, linked.
|
rationale. 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
|
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
|
by **what an adopter reaches for first**, not by architectural layering: Overview → Users, groups
|
||||||
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
||||||
@@ -332,54 +308,40 @@ docker compose -f compose.yml up --build -d # production
|
|||||||
permissions precedes Building plugins** because a manifest's `permission:` gate is unreadable
|
permissions precedes Building plugins** because a manifest's `permission:` gate is unreadable
|
||||||
without the model, and it is the one home for that model.
|
without the model, and it is the one home for that model.
|
||||||
|
|
||||||
Keep the ToC in sync when you add/rename/remove an `H2`/`H3`. **Don't document internals** — how a
|
When editing: put content in the section it belongs to; keep the ToC in sync when you add/rename/
|
||||||
script reaches a decision, what a function guards; a developer reads that off the code in seconds.
|
remove an `H2`/`H3`; state each fact in one home and link to it.
|
||||||
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.
|
**Don't document internals here.** How a script reaches a decision, what a function guards — a
|
||||||
|
developer can read that off the code in seconds, and it only makes the README longer for humans and
|
||||||
|
machines alike. It belongs in the code, 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. Same test
|
||||||
|
before adding a row to a table or the file map — a clause, not a paragraph.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
|
- Node 24 runs `.ts` directly (type stripping). Keep all TypeScript **erasable**
|
||||||
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or decorators. Import
|
(`erasableSyntaxOnly` is on): no `enum`, `namespace`, parameter properties, or decorators. Import
|
||||||
local modules with their `.ts` extension.
|
local modules with their `.ts` extension.
|
||||||
- **No `.mjs`.** Write modules as `.ts` — even standalone scripts run in bare `node:24` containers.
|
- **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
|
(the e2e mock servers, `examples/shifts-upstream/server.ts`). If a file genuinely must be plain
|
||||||
`package.json`s, so `.js` is ESM.
|
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.
|
- **No build step** and no compiled artifacts — do not add a bundler or `tsc` emit.
|
||||||
- Before finishing a change, run the typecheck and tests above; both must pass.
|
- Before finishing a change, run the typecheck and tests above; both must pass.
|
||||||
- Tests use the built-in `node --test` runner — no test framework dependency.
|
- Tests use the built-in `node --test` runner — no test framework dependency.
|
||||||
- English everywhere.
|
- English everywhere. Keep code comments short and information-dense; self-explained code with no
|
||||||
|
comment at all is preferred.
|
||||||
|
- Do not comment about history ("this moved from X"), or about the absence of things.
|
||||||
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
|
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
|
||||||
ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images
|
ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images
|
||||||
by tag.
|
by tag.
|
||||||
- **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist*: its
|
- **`HOST_API_VERSION` is frozen at 1.0.0 until the first external install**, even for additive
|
||||||
rules name exactly what carries the trailer, so a dependency outside them never escalates the
|
contract changes. With no third-party plugin in the wild a bump can only produce noise. The
|
||||||
release version and nothing fails to say so. A new manifest, compose file, custom manager or dep
|
promotion trigger is the first external plugin — from then on follow the versioning table in
|
||||||
type is a decision: can it reach a running Plainpages? If yes it needs a rule; if no, record nothing
|
README → Contract versioning. **The frozen surface includes `views/partials/*.ejs`**: the view
|
||||||
and let it ride the next patch.
|
resolver makes every core partial an `include()` root for a plugin's views, so their option names
|
||||||
- **`HOST_API_VERSION` *is* the release version.** Its `major.minor` must equal the release tag's, and
|
and emitted markup are author-visible. Know the hole that leaves — discovery fails loud on a bad
|
||||||
both release paths refuse a tag that disagrees (`release-tooling/contract-version.ts`). The patch
|
`apiVersion`, but `include("menu", { open: true })` silently ignores a dropped option. Promotion
|
||||||
digit may lag on purpose: `checkApiVersion`
|
must cover the partial vocabulary too.
|
||||||
ignores patch, and auto-release cuts patch releases with no commit to bump a constant in. So a
|
|
||||||
dependency update big enough to force a **minor** is plugin-visible by definition — `auto-release`
|
|
||||||
stops rather than tagging, and the fix is to bump `HOST_API_VERSION` to that `X.Y.0` in a PR, merge
|
|
||||||
it, then tag. Never bump it to "catch up" with a patch release. **The contract surface
|
|
||||||
includes `views/partials/*.ejs`** — the view resolver makes every core partial an `include()` root
|
|
||||||
for a plugin's views, so their option names and emitted markup are author-visible. Know the hole
|
|
||||||
that leaves: discovery fails loud on a bad `apiVersion`, but `include("menu", { open: true })`
|
|
||||||
silently ignores a dropped option, so the partial vocabulary is a surface the version check cannot
|
|
||||||
police for you.
|
|
||||||
- **The contract surface also includes the packaging promises** (README → Plugin dependencies): the
|
|
||||||
barrel is ambient at `/node_modules` with nothing for a plugin to declare, `"type": "module"` is
|
|
||||||
mandatory, and the host neither upgrades nor dedupes a plugin's dependencies. Same hole as the
|
|
||||||
partials — move the publish point, rename the package or start hoisting and every installed plugin
|
|
||||||
breaks with no version signal. Note the promise is deliberately *not* "your deps are yours alone":
|
|
||||||
build-time dedupe for baked images stays open, module-instance sharing stays unpromised.
|
|
||||||
- **Publishing `@plainpages/plugin-api` to a registry is deferred, not rejected.** Today it is
|
|
||||||
`private` and shaped as a shim — `index.ts` re-exports `../src/…`, so `npm pack` would ship a
|
|
||||||
broken tree. The trigger is the first plugin author outside this repo — the first who cannot
|
|
||||||
typecheck against a mounted host tree. Whoever does it must first make the artifact self-contained
|
|
||||||
(types-only `.d.ts`, or move the barrel into `plugin-api/`).
|
|
||||||
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version it was built
|
- A plugin's `apiVersion` is a **hand-written literal** semver — the host version it was built
|
||||||
against — bumped by hand on rebuild, **never** the host's `HOST_API_VERSION` constant. Importing
|
against — bumped by hand on rebuild, **never** the host's `HOST_API_VERSION` constant. Importing
|
||||||
the constant makes every plugin always equal the host, so `checkApiVersion` can never fire.
|
the constant makes every plugin always equal the host, so `checkApiVersion` can never fire.
|
||||||
@@ -408,10 +370,3 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
|
|||||||
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POSTing it, for
|
- 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
|
example on list pages with filters and pagination. Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not
|
||||||
`ids=x,y`.
|
`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).
|
|
||||||
|
|||||||
@@ -7,9 +7,6 @@ FROM node:24.19.0-alpine3.24
|
|||||||
COPY package.json package-lock.json .npmrc /deps/
|
COPY package.json package-lock.json .npmrc /deps/
|
||||||
RUN cd /deps && npm ci && mv node_modules /node_modules && rm -rf /deps
|
RUN cd /deps && npm ci && mv node_modules /node_modules && rm -rf /deps
|
||||||
|
|
||||||
# The barrel as a package, so a plugin folder can own a package.json. Linked because it re-exports /app.
|
|
||||||
RUN mkdir -p /node_modules/@plainpages && ln -s /app/plugin-api /node_modules/@plainpages/plugin-api
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,14 @@
|
|||||||
|
|
||||||
A self-hostable foundation for server-rendered web apps — public or gated pages from a
|
A self-hostable foundation for server-rendered web apps — public or gated pages from a
|
||||||
zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in.
|
zero-JS design system, with a config-driven menu and auth/permissions (Ory) baked in.
|
||||||
Every domain feature is a drop-in plugin folder, with a Postgres database of its own if it wants
|
Every domain feature is a drop-in plugin folder; the app is stateless, no build step.
|
||||||
one; the host itself is stateless, and there is no build step.
|
|
||||||
|
|
||||||
**Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>**
|
**Source, docs & issues: <https://gitea.larvit.se/larvit/plainpages>**
|
||||||
([GitHub mirror](https://github.com/larvit/plainpages))
|
([GitHub mirror](https://github.com/larvit/plainpages))
|
||||||
|
|
||||||
## Tags
|
## Tags
|
||||||
|
|
||||||
`X.Y.Z` · `X.Y` · `latest` — each is a release promoted from a CI-gated build.
|
`X.Y.Z` · `X.Y` · `X` · `latest` — each is a release promoted from a CI-gated build.
|
||||||
Pin the exact `X.Y.Z` you deploy.
|
Pin the exact `X.Y.Z` you deploy.
|
||||||
|
|
||||||
## Quick start
|
## Quick start
|
||||||
@@ -22,7 +21,7 @@ so there is nothing to clone. In an empty directory, save this as `compose.yml`:
|
|||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
image: larvit/plainpages:{{VERSION}}
|
image: larvit/plainpages:0.0.2
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "3000:3000"
|
||||||
environment:
|
environment:
|
||||||
@@ -41,7 +40,7 @@ services:
|
|||||||
|
|
||||||
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
|
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
|
||||||
bootstrap:
|
bootstrap:
|
||||||
image: larvit/plainpages:{{VERSION}}
|
image: larvit/plainpages:0.0.2
|
||||||
command: node src/auth/bootstrap.ts
|
command: node src/auth/bootstrap.ts
|
||||||
depends_on:
|
depends_on:
|
||||||
kratos:
|
kratos:
|
||||||
@@ -54,7 +53,7 @@ services:
|
|||||||
restart: "on-failure:5"
|
restart: "on-failure:5"
|
||||||
|
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18.6-alpine3.23
|
image: postgres:18.4-alpine3.23
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: ory
|
POSTGRES_DB: ory
|
||||||
POSTGRES_PASSWORD: ory
|
POSTGRES_PASSWORD: ory
|
||||||
@@ -131,7 +130,7 @@ services:
|
|||||||
|
|
||||||
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:v1.31.0
|
image: axllent/mailpit:v1.30.1
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -143,7 +142,7 @@ volumes:
|
|||||||
Extract the Ory config the image ships, then start:
|
Extract the Ory config the image ships, then start:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm larvit/plainpages:{{VERSION}} tar -cf - ory | tar -xf -
|
docker run --rm larvit/plainpages:0.0.2 tar -cf - ory | tar -xf -
|
||||||
mkdir -p plugins
|
mkdir -p plugins
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
@@ -179,10 +178,10 @@ Everything domain-specific is a plugin folder — the compose above mounts `./pl
|
|||||||
into the app. Create `plugins/hello/plugin.ts`:
|
into the app. Create `plugins/hello/plugin.ts`:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "#plugin-api";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.1.0",
|
apiVersion: "1.0.0",
|
||||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||||
@@ -35,8 +35,6 @@ test("nextVersion at/after 1.0.0: literal semver", () => {
|
|||||||
assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0");
|
assert.equal(nextVersion("v1.2.3", "major"), "v2.0.0");
|
||||||
assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0");
|
assert.equal(nextVersion("v1.2.3", "minor"), "v1.3.0");
|
||||||
assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4");
|
assert.equal(nextVersion("v1.2.3", "patch"), "v1.2.4");
|
||||||
// the whole chain: a major dependency bump releases a major host, once the 0.x shift-down is gone
|
|
||||||
assert.equal(nextVersion("v1.2.3", maxLevel(["patch", "major"])), "v2.0.0");
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("nextVersion rejects a tag that is not vX.Y.Z", () => {
|
test("nextVersion rejects a tag that is not vX.Y.Z", () => {
|
||||||
@@ -36,7 +36,7 @@ export function nextVersion(latestTag: string, level: Bump): string {
|
|||||||
return `v${major}.${minor}.${patch + 1}`;
|
return `v${major}.${minor}.${patch + 1}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CLI: node release-tooling/next-version.ts <latestTag> [updateType...] → prints the next tag.
|
// CLI: node auto-release/next-version.ts <latestTag> [updateType...] → prints the next tag.
|
||||||
if (process.argv[1]?.endsWith("/next-version.ts")) {
|
if (process.argv[1]?.endsWith("/next-version.ts")) {
|
||||||
const [, , latestTag, ...updateTypes] = process.argv;
|
const [, , latestTag, ...updateTypes] = process.argv;
|
||||||
process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes)));
|
process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes)));
|
||||||
@@ -60,33 +60,6 @@ echo "$units" | grep -E '^. (tests|pass|fail) ' || true
|
|||||||
count=$(echo "$units" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | head -1 || true)
|
count=$(echo "$units" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | head -1 || true)
|
||||||
[ "${count:-0}" -ge 50 ] || { echo "only ${count:-0} unit tests ran — test glob broken?"; exit 1; }
|
[ "${count:-0}" -ge 50 ] || { echo "only ${count:-0} unit tests ran — test glob broken?"; exit 1; }
|
||||||
|
|
||||||
# Plugin storage against a real Postgres. The step above runs --no-deps, so this suite's integration
|
|
||||||
# test skips there — and it is the only thing proving the DDL actually grants what it claims, rather
|
|
||||||
# than that the SQL text is the text we wrote. `node --test` counts a skip, so the floor won't catch it.
|
|
||||||
step "Plugin storage (real Postgres)"
|
|
||||||
# Own project name, like every E2E suite below: the default project is the DEV stack, so a bare
|
|
||||||
# `down -v` here would delete the operator's pgdata — Ory identities and every plugin database.
|
|
||||||
# --wait, because initdb on a cold volume outlasts the suite's connect timeout.
|
|
||||||
storage_rc=0
|
|
||||||
storage_proj=plainpages-storage
|
|
||||||
storage_files=(-p "$storage_proj" -f compose.yml) # no override merge, like the e2e suites below
|
|
||||||
storage_dsn="postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/ory"
|
|
||||||
storage_out=""
|
|
||||||
docker compose "${storage_files[@]}" up -d --wait postgres >/dev/null || storage_rc=$?
|
|
||||||
# `if`, not `&&`: a false `&&` returns non-zero, which under `set -e` would exit before teardown.
|
|
||||||
if [ "$storage_rc" -eq 0 ]; then
|
|
||||||
# --build like the e2e suites: this stack mounts no source, so without it the step would test
|
|
||||||
# whatever `web` image that project last baked.
|
|
||||||
storage_out=$(docker compose "${storage_files[@]}" run --build --rm --no-deps \
|
|
||||||
-e "PLUGIN_DB_ADMIN_URL=$storage_dsn" \
|
|
||||||
web node --test src/plugin-host/storage.test.ts 2>&1) || storage_rc=$?
|
|
||||||
fi
|
|
||||||
docker compose "${storage_files[@]}" down -v >/dev/null 2>&1 || true # also covers a failed `up`
|
|
||||||
echo "$storage_out" | grep -E '^. (tests|pass|fail|skipped) ' || true
|
|
||||||
[ "$storage_rc" -eq 0 ] || { echo "$storage_out"; echo "plugin storage integration tests failed (exit $storage_rc)"; exit "$storage_rc"; }
|
|
||||||
# A skip here exits 0 and proves nothing — the same trap the unit floor above guards against.
|
|
||||||
echo "$storage_out" | grep -qE '^. skipped 0$' || { echo "storage integration test skipped — PLUGIN_DB_ADMIN_URL not wired through"; exit 1; }
|
|
||||||
|
|
||||||
# Run one E2E suite against its OWN named stack, then always tear it down (even on failure). The
|
# Run one E2E suite against its OWN named stack, then always tear it down (even on failure). The
|
||||||
# per-suite project name keeps a flaky teardown from leaking containers/volumes into the next suite.
|
# per-suite project name keeps a flaky teardown from leaking containers/volumes into the next suite.
|
||||||
# --user: the runner writes screenshots + the report into the checkout, so they must belong to
|
# --user: the runner writes screenshots + the report into the checkout, so they must belong to
|
||||||
|
|||||||
+1
-13
@@ -1,9 +1,5 @@
|
|||||||
# Development overrides, merged automatically by `docker compose up`.
|
# Development overrides, merged automatically by `docker compose up`.
|
||||||
# Mounts the source for live editing and restarts on change via `node --watch`.
|
# Mounts the source for live editing and restarts on change via `node --watch`.
|
||||||
|
|
||||||
# web connects with it and bootstrap provisions against it, so the two must agree — one home.
|
|
||||||
x-plugin-db-url: &plugin-db-url postgres://postgres:5432
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
web:
|
web:
|
||||||
command: node --watch src/server.ts
|
command: node --watch src/server.ts
|
||||||
@@ -17,9 +13,6 @@ services:
|
|||||||
CACHE_TEMPLATES: "false"
|
CACHE_TEMPLATES: "false"
|
||||||
LOG_FORMAT: "text" # human-readable logs in dev (base sets json for prod log pipelines)
|
LOG_FORMAT: "text" # human-readable logs in dev (base sets json for prod log pipelines)
|
||||||
LOG_LEVEL: "debug" # verbose by default while developing (base defaults to info)
|
LOG_LEVEL: "debug" # verbose by default while developing (base defaults to info)
|
||||||
# Point plugin storage at the bundled Postgres, so a dropped-in plugin declaring `storage`
|
|
||||||
# works with no further config; the secret falls back to the dev throwaway (config.ts).
|
|
||||||
PLUGIN_DB_URL: *plugin-db-url
|
|
||||||
REQUIRE_SECURE_SECRETS: "false"
|
REQUIRE_SECURE_SECRETS: "false"
|
||||||
SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent
|
SECURE_COOKIES: "false" # dev serves http — Secure cookies wouldn't be sent
|
||||||
SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/
|
SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/
|
||||||
@@ -36,11 +29,6 @@ services:
|
|||||||
# It belongs here and not in the base file, where it would desynchronise prod and collide with the
|
# It belongs here and not in the base file, where it would desynchronise prod and collide with the
|
||||||
# e2e stacks, which bind individual plugins *inside* /app/plugins.
|
# e2e stacks, which bind individual plugins *inside* /app/plugins.
|
||||||
bootstrap:
|
bootstrap:
|
||||||
# Provisions the plugin databases web connects to above, as the dev superuser.
|
|
||||||
environment:
|
|
||||||
PLUGIN_DB_ADMIN_URL: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/ory
|
|
||||||
PLUGIN_DB_URL: *plugin-db-url
|
|
||||||
REQUIRE_SECURE_SECRETS: "false" # dev derives from the throwaway, as web does
|
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
|
||||||
@@ -58,7 +46,7 @@ services:
|
|||||||
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
||||||
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
||||||
mailpit:
|
mailpit:
|
||||||
image: axllent/mailpit:v1.31.0
|
image: axllent/mailpit:v1.30.7
|
||||||
ports:
|
ports:
|
||||||
- "8025:8025"
|
- "8025:8025"
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
+4
-22
@@ -17,16 +17,10 @@ services:
|
|||||||
CACHE_TEMPLATES: "true"
|
CACHE_TEMPLATES: "true"
|
||||||
CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}
|
CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret}
|
||||||
LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector
|
LOG_FORMAT: "json" # structured logs for prod pipelines; set OTLP_ENDPOINT to also export to a collector
|
||||||
# Per-plugin Postgres storage. Explicit toggle: unset ⇒ off, and a plugin declaring `storage`
|
|
||||||
# refuses to boot rather than run without its data. The URL carries no credentials — each
|
|
||||||
# plugin's own password is derived from the secret (README → Plugin storage).
|
|
||||||
PLUGIN_DB_SECRET: ${PLUGIN_DB_SECRET:-}
|
|
||||||
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-}
|
|
||||||
REQUIRE_SECURE_SECRETS: "true"
|
REQUIRE_SECURE_SECRETS: "true"
|
||||||
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
|
SECURE_COOKIES: "true" # prod serves https — mark session/CSRF cookies Secure
|
||||||
# Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/
|
# Wait for the services the app talks to (kratos + keto + hydra for the OAuth2 login/
|
||||||
# consent handler) + the one-shot bootstrap (admin + JWKS seed). Postgres too: a plugin that
|
# consent handler) + the one-shot bootstrap (admin + JWKS seed).
|
||||||
# declares `storage` opens its connection in onBoot, before the server listens.
|
|
||||||
depends_on:
|
depends_on:
|
||||||
bootstrap:
|
bootstrap:
|
||||||
condition: service_completed_successfully
|
condition: service_completed_successfully
|
||||||
@@ -36,20 +30,17 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
hydra:
|
hydra:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
# verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
|
# verifier reads the same tokenizer JWKS Kratos signs with (config.ts JWKS_URL).
|
||||||
# Read-only — bootstrap is the only writer.
|
# Read-only — bootstrap is the only writer.
|
||||||
volumes:
|
volumes:
|
||||||
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
|
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer:ro
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
# The stack's storage: one database per Ory service (init/init.sql), plus one per plugin that
|
# Ory's storage only (Kratos/Keto/Hydra) — the web app never connects here.
|
||||||
# declares `storage` — bootstrap creates those at boot, since only it holds superuser credentials.
|
# init/init.sql creates one database per service. Dev defaults below; supply
|
||||||
# A plugin connects as its own role from inside web. Dev defaults below; supply
|
|
||||||
# POSTGRES_USER/PASSWORD via env in production.
|
# POSTGRES_USER/PASSWORD via env in production.
|
||||||
postgres:
|
postgres:
|
||||||
image: postgres:18.6-alpine3.23
|
image: postgres:18.4-alpine3.23
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: ${POSTGRES_USER:-ory}
|
POSTGRES_USER: ${POSTGRES_USER:-ory}
|
||||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ory}
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ory}
|
||||||
@@ -136,8 +127,6 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
keto:
|
keto:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
environment:
|
environment:
|
||||||
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
|
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local}
|
||||||
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin}
|
||||||
@@ -148,13 +137,6 @@ services:
|
|||||||
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
|
JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json
|
||||||
KETO_WRITE_URL: http://keto:4467
|
KETO_WRITE_URL: http://keto:4467
|
||||||
KRATOS_ADMIN_URL: http://kratos:4434
|
KRATOS_ADMIN_URL: http://kratos:4434
|
||||||
# The superuser DSN that creates each plugin's database and role lives ONLY here — never in
|
|
||||||
# web, so plugin code cannot read it out of its own environment. Unset ⇒ a plugin declaring
|
|
||||||
# `storage` fails the seed loudly. The secret must match web's; both derive the same passwords.
|
|
||||||
PLUGIN_DB_ADMIN_URL: ${PLUGIN_DB_ADMIN_URL:-}
|
|
||||||
PLUGIN_DB_SECRET: ${PLUGIN_DB_SECRET:-}
|
|
||||||
PLUGIN_DB_URL: ${PLUGIN_DB_URL:-} # only to refuse a mismatch: what bootstrap creates, web connects to
|
|
||||||
REQUIRE_SECURE_SECRETS: "true" # refuse the throwaway secret here too, before any role is created
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
|
- ./ory/kratos/tokenizer:/etc/config/kratos/tokenizer
|
||||||
command: node src/auth/bootstrap.ts
|
command: node src/auth/bootstrap.ts
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { expect, test } from "./console-guard.ts";
|
import { expect, test } from "./console-guard.ts";
|
||||||
|
|
||||||
// The from-scratch dev experience the banner advertises: `docker compose up`, open the printed
|
// Regression: the from-scratch dev experience the README/banner advertises must work. `docker compose
|
||||||
// login URL, sign in as the seeded admin, land on the dashboard. A host-scoped Kratos CSRF cookie
|
// up`, open the printed login URL (http://localhost:3000), sign in as the seeded admin → you land on
|
||||||
// cannot cross `localhost`↔`127.0.0.1`, so a cross-host login POST loses it and Kratos redirects to
|
// the dashboard, signed in. Originally this dumped the user on http://127.0.0.1:3000/error?id=…
|
||||||
// its error sink; APP_URL canonicalises every off-host visitor onto one cookie host instead.
|
// ("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 runner is on the host network against the plain `docker compose up` topology, so it sees
|
// The fix makes APP_URL the single source for the public host: the web app canonicalises every
|
||||||
// http://localhost:3000 and http://127.0.0.1:4433 exactly as a host browser does. The proxied
|
// off-host visitor onto it (so localhost / 127.0.0.1 / any alias funnel to one cookie host), Kratos'
|
||||||
// full-flow suite cannot catch this — it fronts web + Kratos on one origin.
|
// 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.
|
||||||
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
|
const ADMIN_EMAIL = "admin@plainpages.local"; // seeded by bootstrap
|
||||||
const ADMIN_PASSWORD = "admin";
|
const ADMIN_PASSWORD = "admin";
|
||||||
|
|
||||||
|
|||||||
@@ -50,8 +50,8 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
});
|
});
|
||||||
test.afterAll(async () => { await page.context().close(); });
|
test.afterAll(async () => { await page.context().close(); });
|
||||||
|
|
||||||
// The list screens rebuild their query from the list state (sort/page/filter), so they are where a
|
// The list screens rebuild their query from the list state (sort/page/filter), so they are where
|
||||||
// chosen language is most easily dropped; the core building blocks carry it through.
|
// a chosen language used to get dropped — the core building blocks carry it now.
|
||||||
test("a sorted, paged admin list keeps the visitor's language", async () => {
|
test("a sorted, paged admin list keeps the visitor's language", async () => {
|
||||||
await page.goto("/admin/users?locale=sv-SE");
|
await page.goto("/admin/users?locale=sv-SE");
|
||||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||||
|
|||||||
Generated
+2
@@ -1,10 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "plainpages-e2e",
|
"name": "plainpages-e2e",
|
||||||
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "plainpages-e2e",
|
"name": "plainpages-e2e",
|
||||||
|
"version": "0.1.0",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "1.62.1"
|
"@playwright/test": "1.62.1"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "plainpages-e2e",
|
"name": "plainpages-e2e",
|
||||||
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
|
"description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ test("every icon <use> resolves to a defined <symbol> (no broken graphics)", asy
|
|||||||
expect(missing).toEqual([]);
|
expect(missing).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
// The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component and
|
// (The zero-JS URL-driven list — sortable headers, ?q search — is unit-tested per component
|
||||||
// exercised live by the full-flow E2E's admin Users list, so it has no Ory-free counterpart here.
|
// (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.)
|
||||||
|
|
||||||
test("theme switch flips the palette with no JavaScript", async ({ page }) => {
|
test("theme switch flips the palette with no JavaScript", async ({ page }) => {
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ across (or bind-mount your own) and restart.
|
|||||||
|
|
||||||
| Path | Copy into | Example of |
|
| Path | Copy into | Example of |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
|
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
|
||||||
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
|
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
|
||||||
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
|
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
|
||||||
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
|
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
// Reference config/menu.ts — copy into the empty config/ mount at the repo root:
|
// Reference config/menu.ts — copy into the (empty) config/ mount at the repo root:
|
||||||
// cp examples/config/menu.ts config/menu.ts
|
// cp examples/config/menu.ts config/menu.ts
|
||||||
// Absent config = built-in defaults.
|
// config/ ships empty; mount your own or copy this in. Absent config = built-in defaults.
|
||||||
//
|
//
|
||||||
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — the
|
// Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins —
|
||||||
// override always wins, applied before the per-user permission filter. Every field is optional.
|
// the override always wins, applied before the per-user permission filter. Every field is
|
||||||
// See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README → The menu system.
|
// 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).
|
||||||
|
|
||||||
import { defineMenu } from "#menu-config";
|
import { defineMenu } from "#menu-config";
|
||||||
|
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
# Admin — the system-administration plugin
|
# Admin — the system-administration plugin
|
||||||
|
|
||||||
The Users / Groups / OAuth2-clients screens for running Plainpages itself, shipped as a **drop-in
|
The Users / Groups / OAuth2-clients screens for running Plainpages itself. These used to be
|
||||||
example plugin** so a fresh clone has no admin GUI until you opt in. Copy this folder into `plugins/`
|
built into the core; they now ship as a **drop-in example plugin** so a fresh clone has no admin GUI
|
||||||
(it keeps the id and mount path `admin`, so the screens live at `/admin/*`) and restart:
|
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
|
```bash
|
||||||
cp -r examples/plugins/admin plugins/admin
|
cp -r examples/plugins/admin plugins/admin
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
```
|
```
|
||||||
|
|
||||||
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so
|
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so the
|
||||||
the section appears in the menu and the screens work immediately. An older copy already in
|
section appears in the menu and the screens work immediately.
|
||||||
`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
|
> **Already have `plugins/admin` from an earlier version?** Re-copy it. Your copy is yours — the host
|
||||||
labels included. Each pure view-model builder takes an optional `t` defaulting to the plugin's own
|
> never updates it — and this plugin's permissions changed on 2026-08-05 (`admin` → `users:`/`groups:`/
|
||||||
English, so a unit test reads in words rather than keys.
|
> `oauth2-clients:` × `read`/`write`). A stale copy stops the boot with a message naming it; see
|
||||||
|
> [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).)
|
||||||
|
|
||||||
## What it demonstrates — a *system* plugin
|
## What it demonstrates — a *system* plugin
|
||||||
|
|
||||||
@@ -30,14 +35,15 @@ 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
|
- **`ctx.system.revoke(sub)`** — the optional instant-revoke hook: a deactivate/delete or a
|
||||||
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
|
user's permission change kills that subject's live tokens at once instead of waiting out the JWT TTL.
|
||||||
|
|
||||||
`ctx.system` is populated only when the host wired those services. Where a capability is absent the
|
`ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto,
|
||||||
screen degrades to a themed 503 rather than crashing. Everything else is an ordinary plugin:
|
and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather
|
||||||
folder-discovered, gated per route by its screen's `<resource>:<action>` permission, rendering the
|
than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered,
|
||||||
core building blocks in `views/`.
|
gated per route by its screen's `<resource>:<action>` permission, rendering the core building blocks
|
||||||
|
in `views/`.
|
||||||
|
|
||||||
Each screen is its own resource — `users`, `groups`, `oauth2-clients` — split into `:read` and
|
Each screen is its own resource — `users`, `groups`, `oauth2-clients` — and each splits into `:read`
|
||||||
`:write`, so a helpdesk account can be given `users:read` alone. Holding none of the six hides the
|
and `:write`, so a helpdesk account can be given `users:read` alone. The nav is filtered by the same
|
||||||
Admin section entirely.
|
permissions: holding none of the three hides the Admin section entirely.
|
||||||
|
|
||||||
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a
|
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
|
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
|
// PRG redirect (mirrors the Users "trigger recovery" one-time code). Below the builders are thin
|
||||||
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
|
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
|
||||||
|
|
||||||
import { can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
import { can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "#plugin-api";
|
||||||
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts.
|
// two screens render from it. The Keto writes and the HTTP round trip are covered in app.test.ts.
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import type { PermissionDecl } from "@plainpages/plugin-api";
|
import type { PermissionDecl } from "#plugin-api";
|
||||||
import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts";
|
import { buildPermissionPicker, grantDiff, grantTuple, groupSubject, userSubject } from "./admin-grants.ts";
|
||||||
|
|
||||||
const declared: PermissionDecl[] = [
|
const declared: PermissionDecl[] = [
|
||||||
@@ -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
|
assert.equal(picker.inheritedNote, undefined); // nothing is group-held here
|
||||||
});
|
});
|
||||||
|
|
||||||
// An inherited permission rendered unticked would say "not held" about a grant that reaches the JWT,
|
// The failure this prevents: a permission held through a group used to render unticked, so the page
|
||||||
// and unticking it writes nothing, reading as a successful revoke. So inherited rows are ticked,
|
// said "not held" about a grant that reaches the JWT — and unticking it wrote nothing, which read as
|
||||||
// disabled, and never posted.
|
// a successful revoke. Inherited rows are ticked, disabled, and never posted.
|
||||||
test("buildPermissionPicker distinguishes a direct grant from one inherited through a group", () => {
|
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"] });
|
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]), [
|
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
|
// what the installed plugins declare in code. Nothing here invents a name, which is why the old
|
||||||
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
|
// Permissions screen is gone: a grant is a property of a user or a group, edited where they are.
|
||||||
|
|
||||||
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
|
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "#plugin-api";
|
||||||
|
|
||||||
const PERMISSION_NS = "Permission";
|
const PERMISSION_NS = "Permission";
|
||||||
const GRANTED = "granted";
|
const GRANTED = "granted";
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
memberView,
|
memberView,
|
||||||
parseSubject,
|
parseSubject,
|
||||||
} from "./admin-groups.ts";
|
} from "./admin-groups.ts";
|
||||||
import type { RelationTuple } from "@plainpages/plugin-api";
|
import type { RelationTuple } from "#plugin-api";
|
||||||
|
|
||||||
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
const uid = (n: number) => `01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b${String(n).padStart(2, "0")}`;
|
||||||
const userTuple = (group: string, n: number): RelationTuple =>
|
const userTuple = (group: string, n: number): RelationTuple =>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
|
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
|
||||||
// each returning a RouteResult.
|
// each returning a RouteResult.
|
||||||
|
|
||||||
import { can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/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 "#plugin-api";
|
||||||
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts";
|
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD } from "./admin-grants.ts";
|
||||||
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||||
import type { FieldConfig } from "./admin-users.ts";
|
import type { FieldConfig } from "./admin-users.ts";
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
|
// Direct units for the admin plugin's shared nav + auth helpers. They're security-critical
|
||||||
// (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the
|
// (requirePermission/guardedForm gate every admin write) and reused across all three screens, so pin the
|
||||||
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
|
// contract here in isolation; the HTTP routing/gate/CSRF is exercised end-to-end in src/http/app.test.ts.
|
||||||
// Import only from the @plainpages/plugin-api barrel — the same contract boundary the plugin code uses.
|
// Import only from the #plugin-api barrel — the same contract boundary the plugin code uses.
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "@plainpages/plugin-api";
|
import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api";
|
||||||
import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts";
|
import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts";
|
||||||
|
|
||||||
const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] };
|
const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] };
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
// Shared plumbing for the admin example plugin: the section nav fragment, the screen gate, the
|
// Shared plumbing for the admin example plugin: the section nav fragment, the admin-only gate, the
|
||||||
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers.
|
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers
|
||||||
// Everything imports the host only through the @plainpages/plugin-api barrel.
|
// (themed not-found / capability-unavailable). Ported from the former built-in admin screens;
|
||||||
|
// everything imports the host only through the #plugin-api barrel.
|
||||||
|
|
||||||
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
|
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "#plugin-api";
|
||||||
import enUS from "./i18n/en-US.ts";
|
import enUS from "./i18n/en-US.ts";
|
||||||
|
|
||||||
// This plugin's English — its catalog, then the host's — for a view model built outside a request,
|
// This plugin's English (its catalog, then the host's — the screens reuse core words like Cancel and
|
||||||
// i.e. its unit tests. At runtime the handlers pass ctx.t instead.
|
// 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.
|
||||||
export const ADMIN_EN: Translate = englishTranslator(enUS);
|
export const ADMIN_EN: Translate = englishTranslator(enUS);
|
||||||
|
|
||||||
export const ADMIN_USERS_BASE = "/admin/users";
|
export const ADMIN_USERS_BASE = "/admin/users";
|
||||||
@@ -26,9 +28,11 @@ export function permissionName(resource: AdminResource, action: AdminAction): st
|
|||||||
return `${resource}:${action}`;
|
return `${resource}:${action}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Every screen reads on GET/HEAD and mutates on POST. The route table and the in-handler guard both
|
// This plugin's mapping from method to action: every screen reads on GET/HEAD and mutates on POST.
|
||||||
// go through this rather than each spelling the permission out, so they cannot drift. Deliberately
|
// The manifest's route table and the in-handler guard both go through it rather than each spelling
|
||||||
// local: generalised, it would make authorization a function of the transport verb (AGENTS.md).
|
// 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).
|
||||||
export function actionForMethod(method: string): AdminAction {
|
export function actionForMethod(method: string): AdminAction {
|
||||||
const verb = method.toUpperCase();
|
const verb = method.toUpperCase();
|
||||||
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
||||||
@@ -50,10 +54,13 @@ export const ADMIN_NAV: NavNode = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
|
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
|
||||||
// declares the same permission, so this is defence-in-depth and what a direct unit test relies on.
|
// declares the same permission, so the host enforces it before the handler runs; this is
|
||||||
// `action` defaults to the method's, and is passed explicitly by a *write-intent GET* — a create
|
// defence-in-depth and what a direct unit test relies on. Returns the (non-null) user for the
|
||||||
// form or a delete-confirm page — which refuses a reader rather than rendering a form whose submit
|
// handler to thread on. GuardError → /login or 403.
|
||||||
// would 403. The route table declares the same override, so the two cannot disagree.
|
// `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.
|
||||||
export function requirePermission(ctx: RequestContext, resource: AdminResource, action?: AdminAction): User {
|
export function requirePermission(ctx: RequestContext, resource: AdminResource, action?: AdminAction): User {
|
||||||
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept)
|
||||||
const permission = permissionName(resource, action ?? actionForMethod(ctx.req.method ?? "GET"));
|
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.
|
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in src/http/app.test.ts.
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import type { Identity } from "@plainpages/plugin-api";
|
import type { Identity } from "#plugin-api";
|
||||||
import {
|
import {
|
||||||
buildUserFormModel,
|
buildUserFormModel,
|
||||||
buildUsersListModel,
|
buildUsersListModel,
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
// Users admin screen: list Kratos identities (filter/sort/paginate) +
|
// Users admin screen: list Kratos identities (filter/sort/paginate) +
|
||||||
// create/edit/deactivate/delete/trigger-recovery. Pure builders turn identities + the request URL
|
// create/edit/deactivate/delete/trigger-recovery. Writes go only to Kratos via the admin client
|
||||||
// into building-block view models; below them are thin per-route handlers keyed on ctx.params, over
|
// (README "stateless"). Pure builders turn identities + the request URL into building-block view
|
||||||
// a shared `withUser` gate.
|
// 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).
|
||||||
|
|
||||||
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 { 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 { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
|
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, type PermissionPicker, PERMISSIONS_FIELD, userSubject } from "./admin-grants.ts";
|
||||||
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
|
||||||
|
|
||||||
@@ -369,7 +370,7 @@ export const usersPermissions = withTarget(async (deps, identity, id) => {
|
|||||||
const diff = grantDiff(ctx.declaredPermissions, await heldPermissions(keto, subject), form.getAll(PERMISSIONS_FIELD));
|
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
|
// 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
|
// remove the last `users:write` on the deployment, and the instant-revoke hook lands it on the very
|
||||||
// next request — leaving a `curl` against Keto as the only way back in.
|
// next request. Recovery would be a curl against Keto — not something the operator persona can do.
|
||||||
if (id === user.id && diff.revoke.length > 0) {
|
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(",") });
|
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"));
|
const permissions = await userPermissionPicker(deps, id, ctx.t("admin.grants.selfRevoke"));
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// with nothing in the logs to explain it. Pin the two halves against each other here.
|
// with nothing in the logs to explain it. Pin the two halves against each other here.
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { isValidPermissionName } from "@plainpages/plugin-api";
|
import { isValidPermissionName } from "#plugin-api";
|
||||||
import manifest from "./plugin.ts";
|
import manifest from "./plugin.ts";
|
||||||
|
|
||||||
const routes = manifest.routes ?? [];
|
const routes = manifest.routes ?? [];
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system. Copy
|
// Admin example plugin: the Users / Groups / OAuth2-clients screens for running the system.
|
||||||
// this folder to plugins/admin (then restart) to enable it — see README → Quick start.
|
// 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.
|
||||||
//
|
//
|
||||||
// It is a *system* plugin: its handlers reach the host's Ory admin clients and the instant-revoke
|
// It is a *system* plugin: its handlers reach the host's Ory admin clients (Kratos/Keto/Hydra) and the
|
||||||
// hook via ctx.system. Where a capability is absent the screen degrades to a themed 503.
|
// 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.
|
||||||
|
|
||||||
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api";
|
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "#plugin-api";
|
||||||
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
||||||
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
|
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
|
||||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||||
@@ -26,7 +28,7 @@ const groups = on("groups");
|
|||||||
const clients = on("oauth2-clients");
|
const clients = on("oauth2-clients");
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||||
|
|
||||||
nav: [ADMIN_NAV],
|
nav: [ADMIN_NAV],
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// looked up here first and fall back to the host's, so a plugin owns its words without prefixing
|
// looked up here first and fall back to the host's, so a plugin owns its words without prefixing
|
||||||
// them, and `shifts.count` shows the plural form (host: README → Translating).
|
// them, and `shifts.count` shows the plural form (host: README → Translating).
|
||||||
|
|
||||||
import type { PluralMessage } from "@plainpages/plugin-api";
|
import type { PluralMessage } from "#plugin-api";
|
||||||
|
|
||||||
const messages = {
|
const messages = {
|
||||||
"scheduling.field.assignee": "Assignee",
|
"scheduling.field.assignee": "Assignee",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
|
// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this
|
||||||
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
|
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
|
||||||
|
|
||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "#plugin-api";
|
||||||
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
||||||
|
|
||||||
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
|
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
|
||||||
@@ -11,7 +11,7 @@ const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstrea
|
|||||||
const upstream = createUpstream(upstreamUrl);
|
const upstream = createUpstream(upstreamUrl);
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.1.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||||
|
|
||||||
// onBoot runs after discovery, before the server listens: validate the plugin's own config so a
|
// onBoot runs after discovery, before the server listens: validate the plugin's own config so a
|
||||||
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
|
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ import assert from "node:assert/strict";
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import { Readable } from "node:stream";
|
import { Readable } from "node:stream";
|
||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
// Import only from the @plainpages/plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
// Import only from the #plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
||||||
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
||||||
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "@plainpages/plugin-api";
|
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "#plugin-api";
|
||||||
import enUS from "./i18n/en-US.ts";
|
import enUS from "./i18n/en-US.ts";
|
||||||
import {
|
import {
|
||||||
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
||||||
@@ -51,7 +51,7 @@ test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not
|
|||||||
try {
|
try {
|
||||||
const manifest = (await import("./plugin.ts")).default;
|
const manifest = (await import("./plugin.ts")).default;
|
||||||
assert.equal(typeof manifest.hooks?.onBoot, "function");
|
assert.equal(typeof manifest.hooks?.onBoot, "function");
|
||||||
assert.throws(() => manifest.hooks!.onBoot!({}), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud
|
assert.throws(() => manifest.hooks!.onBoot!(), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud
|
||||||
} finally {
|
} finally {
|
||||||
if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"];
|
if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"];
|
||||||
else process.env["SCHEDULING_UPSTREAM"] = prev;
|
else process.env["SCHEDULING_UPSTREAM"] = prev;
|
||||||
|
|||||||
@@ -5,8 +5,8 @@
|
|||||||
// Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as
|
// Handlers are factories bound to a ShiftsUpstream, and `fetch` is injectable, so they unit-test as
|
||||||
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
|
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
|
||||||
|
|
||||||
// One import from the host's @plainpages/plugin-api barrel — the stable author surface (see README.md → Building plugins).
|
// 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 "@plainpages/plugin-api";
|
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "#plugin-api";
|
||||||
import enUS from "./i18n/en-US.ts";
|
import enUS from "./i18n/en-US.ts";
|
||||||
|
|
||||||
// The plugin's own English (its catalog, then the host's), for a view model built outside a request:
|
// The plugin's own English (its catalog, then the host's), for a view model built outside a request:
|
||||||
|
|||||||
@@ -1,14 +1,6 @@
|
|||||||
-- Runs once on first boot (docker-entrypoint-initdb.d), as the POSTGRES_USER.
|
-- Runs once on first boot (docker-entrypoint-initdb.d), as the POSTGRES_USER.
|
||||||
-- One database per Ory service: each owns its schema and runs its own migrations,
|
-- One database per Ory service: each owns its schema and runs its own migrations,
|
||||||
-- so they never collide. A plugin's database does not belong here: bootstrap provisions those on
|
-- so they never collide. The web app never connects here (stateless — see README).
|
||||||
-- every boot, so one dropped in later is picked up too (README → Plugin storage).
|
|
||||||
CREATE DATABASE kratos;
|
CREATE DATABASE kratos;
|
||||||
CREATE DATABASE keto;
|
CREATE DATABASE keto;
|
||||||
CREATE DATABASE hydra;
|
CREATE DATABASE hydra;
|
||||||
|
|
||||||
-- Postgres grants CONNECT to PUBLIC by default, so every plugin role could otherwise open the auth
|
|
||||||
-- plane's databases and read pg_catalog; table data stays protected either way. Ory connects as the
|
|
||||||
-- POSTGRES_USER, which owns these and keeps its access.
|
|
||||||
REVOKE CONNECT ON DATABASE kratos FROM PUBLIC;
|
|
||||||
REVOKE CONNECT ON DATABASE keto FROM PUBLIC;
|
|
||||||
REVOKE CONNECT ON DATABASE hydra FROM PUBLIC;
|
|
||||||
|
|||||||
Generated
+6
-18
@@ -1,15 +1,16 @@
|
|||||||
{
|
{
|
||||||
"name": "plainpages",
|
"name": "plainpages",
|
||||||
|
"version": "0.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "plainpages",
|
"name": "plainpages",
|
||||||
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
"ejs": "6.0.1",
|
"ejs": "6.0.1",
|
||||||
"lucide-static": "1.33.0",
|
"lucide-static": "1.28.0"
|
||||||
"postgres": "3.4.9"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
@@ -399,24 +400,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-static": {
|
"node_modules/lucide-static": {
|
||||||
"version": "1.33.0",
|
"version": "1.28.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.33.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.28.0.tgz",
|
||||||
"integrity": "sha512-jNGgvTNcLUfVRX4N9PH9pVVTJzoph/BmYmgU838bYBQodkUJL4nAThkuymFz1x3OUYMhJxPndC7rdg1sxOPYKg==",
|
"integrity": "sha512-dC3VJwRFsjEVX7Iaq4rY88pm7Fi2OmOb8P0WRzXsUMgbt7sCmFX8bLhaDBeNW6JdRjuele+jKqqFaam4yr+Ygg==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/postgres": {
|
|
||||||
"version": "3.4.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
|
|
||||||
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
|
|
||||||
"license": "Unlicense",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "individual",
|
|
||||||
"url": "https://github.com/sponsors/porsager"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
"version": "7.0.2",
|
"version": "7.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||||
|
|||||||
+5
-4
@@ -1,25 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "plainpages",
|
"name": "plainpages",
|
||||||
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=24"
|
"node": ">=24"
|
||||||
},
|
},
|
||||||
"imports": {
|
"imports": {
|
||||||
"#menu-config": "./src/ui/menu-config.ts"
|
"#menu-config": "./src/ui/menu-config.ts",
|
||||||
|
"#plugin-api": "./src/plugin-host/plugin-api.ts"
|
||||||
},
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/server.ts",
|
"start": "node src/server.ts",
|
||||||
"dev": "node --watch src/server.ts",
|
"dev": "node --watch src/server.ts",
|
||||||
"gen-jwks": "node src/auth/gen-jwks.ts",
|
"gen-jwks": "node src/auth/gen-jwks.ts",
|
||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"release-tooling/**/*.test.ts\""
|
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"auto-release/**/*.test.ts\""
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@larvit/log": "2.3.0",
|
"@larvit/log": "2.3.0",
|
||||||
"ejs": "6.0.1",
|
"ejs": "6.0.1",
|
||||||
"lucide-static": "1.33.0",
|
"lucide-static": "1.28.0"
|
||||||
"postgres": "3.4.9"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
// 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";
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "@plainpages/plugin-api",
|
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"exports": "./index.ts"
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { test } from "node:test";
|
|
||||||
import { checkTagMatchesContract, readHostApiVersion } from "./contract-version.ts";
|
|
||||||
|
|
||||||
test("readHostApiVersion pulls the constant out of the real source, and returns null when absent", () => {
|
|
||||||
const real = readFileSync("src/plugin-host/plugin.ts", "utf8");
|
|
||||||
assert.match(readHostApiVersion(real) ?? "", /^\d+\.\d+\.\d+$/);
|
|
||||||
assert.equal(readHostApiVersion('export const SOMETHING_ELSE = "1.0.0";'), null);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
|
|
||||||
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
|
|
||||||
// makes an accidental edit fail here rather than at release time.
|
|
||||||
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.1.0");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("every author-facing apiVersion sample matches the shipped contract", () => {
|
|
||||||
// A plugin author copies these; a stale one produces a boot-aborting refuse on first run. The
|
|
||||||
// examples deliberately write a literal rather than importing the constant (AGENTS.md), so this
|
|
||||||
// is the only thing keeping the copies honest.
|
|
||||||
const host = readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")) ?? "";
|
|
||||||
const [major, minor] = host.split(".");
|
|
||||||
for (const file of [
|
|
||||||
"README.md",
|
|
||||||
"examples/plugins/admin/plugin.ts",
|
|
||||||
"examples/plugins/scheduling/plugin.ts",
|
|
||||||
"release-tooling/dockerhub-overview.md.tmpl",
|
|
||||||
"views/index.ejs",
|
|
||||||
]) {
|
|
||||||
const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]);
|
|
||||||
assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`);
|
|
||||||
for (const sample of found) {
|
|
||||||
const [sMajor, sMinor] = (sample ?? "").split(".");
|
|
||||||
assert.equal(`${sMajor}.${sMinor}`, `${major}.${minor}`, `${file} samples apiVersion ${sample}, host is ${host}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("checkTagMatchesContract: major.minor must agree, patch may lag", () => {
|
|
||||||
assert.equal(checkTagMatchesContract("v0.1.0", "0.1.0").ok, true);
|
|
||||||
assert.equal(checkTagMatchesContract("v0.1.7", "0.1.0").ok, true); // auto-release cut patches
|
|
||||||
assert.equal(checkTagMatchesContract("0.1.0", "0.1.0").ok, true); // bare tag, no v
|
|
||||||
assert.equal(checkTagMatchesContract("v0.2.0", "0.1.0").ok, false); // plugin-visible, needs a bump
|
|
||||||
assert.equal(checkTagMatchesContract("v1.0.0", "0.1.0").ok, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("checkTagMatchesContract names what to fix rather than just failing", () => {
|
|
||||||
const res = checkTagMatchesContract("v0.2.0", "0.1.0");
|
|
||||||
assert.equal(res.ok, false);
|
|
||||||
assert.match(res.ok === false ? res.error : "", /HOST_API_VERSION to 0\.2\.0/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("checkTagMatchesContract rejects junk on either side without throwing", () => {
|
|
||||||
assert.equal(checkTagMatchesContract("v0.1.0", null).ok, false); // constant not found
|
|
||||||
assert.equal(checkTagMatchesContract("nope", "0.1.0").ok, false);
|
|
||||||
assert.equal(checkTagMatchesContract("v0.1.0", "1.0").ok, false);
|
|
||||||
});
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import { readFileSync } from "node:fs";
|
|
||||||
|
|
||||||
export type ContractCheck = { ok: true } | { ok: false; error: string };
|
|
||||||
|
|
||||||
const SEMVER = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
||||||
|
|
||||||
export function readHostApiVersion(source: string): string | null {
|
|
||||||
return /^export const HOST_API_VERSION = "([^"]+)";/m.exec(source)?.[1] ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Patch is deliberately not compared: checkApiVersion ignores it, and auto-release cuts patch
|
|
||||||
// releases with no commit to bump the constant in.
|
|
||||||
export function checkTagMatchesContract(tag: string, hostApiVersion: string | null): ContractCheck {
|
|
||||||
if (hostApiVersion === null) {
|
|
||||||
return { error: "HOST_API_VERSION not found", ok: false };
|
|
||||||
}
|
|
||||||
const t = SEMVER.exec(tag);
|
|
||||||
if (!t) return { error: `tag must be vX.Y.Z, got ${JSON.stringify(tag)}`, ok: false };
|
|
||||||
const h = SEMVER.exec(hostApiVersion);
|
|
||||||
if (!h) return { error: `HOST_API_VERSION must be X.Y.Z, got ${JSON.stringify(hostApiVersion)}`, ok: false };
|
|
||||||
if (t[1] === h[1] && t[2] === h[2]) return { ok: true };
|
|
||||||
return {
|
|
||||||
error:
|
|
||||||
`${tag} does not match HOST_API_VERSION ${hostApiVersion} — the contract version IS the release ` +
|
|
||||||
`version. Set HOST_API_VERSION to ${t[1]}.${t[2]}.0 in src/plugin-host/plugin.ts, merge that, ` +
|
|
||||||
"then tag.",
|
|
||||||
ok: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// CLI: node release-tooling/contract-version.ts <tag> <path/to/plugin.ts | -> → exits 1 on
|
|
||||||
// mismatch. `-` reads the source on stdin, so a caller checking a ref other than its checkout
|
|
||||||
// (`git show origin/main:… | …`) needs no scratch file in the workspace.
|
|
||||||
if (process.argv[1]?.endsWith("/contract-version.ts")) {
|
|
||||||
const [, , tag, pluginPath = "src/plugin-host/plugin.ts"] = process.argv;
|
|
||||||
let source = "";
|
|
||||||
try {
|
|
||||||
source = readFileSync(pluginPath === "-" ? 0 : pluginPath, "utf8");
|
|
||||||
} catch (err) {
|
|
||||||
process.stderr.write(`${pluginPath}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
||||||
process.exitCode = 1;
|
|
||||||
}
|
|
||||||
const result = checkTagMatchesContract(tag ?? "", readHostApiVersion(source));
|
|
||||||
if (!result.ok) {
|
|
||||||
process.stderr.write(`${pluginPath}: ${result.error}\n`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
process.stdout.write(`${tag} matches HOST_API_VERSION\n`);
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { test } from "node:test";
|
|
||||||
import { jwtFrom, leftoverPlaceholders, renderOverview } from "./dockerhub-overview.ts";
|
|
||||||
|
|
||||||
const TEMPLATE = "release-tooling/dockerhub-overview.md.tmpl";
|
|
||||||
const template = () => readFileSync(TEMPLATE, "utf8");
|
|
||||||
|
|
||||||
test("renderOverview substitutes every occurrence, not just the first", () => {
|
|
||||||
const out = renderOverview("pull a:{{VERSION}} then b:{{VERSION}}", "1.2.3");
|
|
||||||
assert.equal(out, "pull a:1.2.3 then b:1.2.3");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("leftoverPlaceholders catches a typo'd placeholder, deduped, and passes clean text", () => {
|
|
||||||
assert.deepEqual(leftoverPlaceholders("a {{VERISON}} b {{VERISON}}"), ["{{VERISON}}"]);
|
|
||||||
assert.deepEqual(leftoverPlaceholders(renderOverview("x {{VERSION}}", "0.1.0")), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the real template renders clean, and the release owns its own image tag", () => {
|
|
||||||
const rendered = renderOverview(template(), "9.9.9");
|
|
||||||
assert.deepEqual(leftoverPlaceholders(rendered), []);
|
|
||||||
assert.match(rendered, /larvit\/plainpages:9\.9\.9/); // the placeholder actually reaches the examples
|
|
||||||
assert.doesNotMatch(rendered, /larvit\/plainpages:\d+\.\d+\.\d+(?<!9\.9\.9)/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the quick start's sidecars are pinned to the same versions this repo runs", () => {
|
|
||||||
// The page is published automatically, so a drifted pin here ships a topology CI never tested.
|
|
||||||
const pins = (source: string) =>
|
|
||||||
new Map([...source.matchAll(/image: ([^:\s]+):(v?\d\S*)/g)].map((m) => [m[1] ?? "", m[2] ?? ""]));
|
|
||||||
const ours = new Map([
|
|
||||||
...pins(readFileSync("compose.override.yml", "utf8")),
|
|
||||||
...pins(readFileSync("compose.yml", "utf8")), // production wins: the template is the prod quick start
|
|
||||||
]);
|
|
||||||
const published = pins(template());
|
|
||||||
assert.ok(published.size > 0, "the template should pin sidecars");
|
|
||||||
for (const [image, tag] of published) {
|
|
||||||
assert.equal(tag, ours.get(image), `${TEMPLATE} pins ${image}:${tag}, this repo runs ${ours.get(image)}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("jwtFrom accepts only a non-empty string token, never throwing on a hostile body", () => {
|
|
||||||
assert.equal(jwtFrom({ token: "abc" }), "abc");
|
|
||||||
assert.equal(jwtFrom(null), null); // valid JSON, and the shape a proxy can return
|
|
||||||
assert.equal(jwtFrom("<html>rate limited</html>"), null);
|
|
||||||
assert.equal(jwtFrom({}), null);
|
|
||||||
assert.equal(jwtFrom({ token: "" }), null);
|
|
||||||
assert.equal(jwtFrom({ token: 42 }), null);
|
|
||||||
});
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
// Publishes the Docker Hub repository overview from dockerhub-overview.md.tmpl, rendering
|
|
||||||
// `{{VERSION}}` to the release being published.
|
|
||||||
|
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
const HUB = "https://hub.docker.com/v2";
|
|
||||||
const TIMEOUT_MS = 30_000;
|
|
||||||
const VERSION = /^\d+\.\d+\.\d+$/;
|
|
||||||
|
|
||||||
export function renderOverview(source: string, version: string): string {
|
|
||||||
return source.replaceAll("{{VERSION}}", version);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A typo'd placeholder would publish literal braces to a public page, so fail the release instead.
|
|
||||||
export function leftoverPlaceholders(rendered: string): string[] {
|
|
||||||
return [...new Set(rendered.match(/\{\{[^}]*\}\}/g) ?? [])];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function jwtFrom(body: unknown): string | null {
|
|
||||||
if (typeof body !== "object" || body === null || !("token" in body)) return null;
|
|
||||||
return typeof body.token === "string" && body.token !== "" ? body.token : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Fetched = { error: string } | { json: unknown; ok: boolean; status: number; text: string };
|
|
||||||
|
|
||||||
// fetch and its body readers throw; this is the one edge that converts that into a value.
|
|
||||||
async function post(url: string, init: RequestInit): Promise<Fetched> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, { ...init, signal: AbortSignal.timeout(TIMEOUT_MS) });
|
|
||||||
const text = await res.text();
|
|
||||||
let json: unknown = null;
|
|
||||||
try {
|
|
||||||
json = JSON.parse(text);
|
|
||||||
} catch {
|
|
||||||
json = null;
|
|
||||||
}
|
|
||||||
return { json, ok: res.ok, status: res.status, text };
|
|
||||||
} catch (err) {
|
|
||||||
return { error: err instanceof Error ? err.message : String(err) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<number> {
|
|
||||||
const fail = (message: string): number => {
|
|
||||||
process.stderr.write(`${message}\n`);
|
|
||||||
return 1;
|
|
||||||
};
|
|
||||||
const [, , version] = process.argv;
|
|
||||||
const repo = process.env["DOCKERHUB_REPO"];
|
|
||||||
const user = process.env["DOCKERHUB_USER"];
|
|
||||||
const token = process.env["DOCKERHUB_TOKEN"];
|
|
||||||
if (!version || !repo || !user || !token) {
|
|
||||||
return fail(
|
|
||||||
"usage: dockerhub-overview.ts <X.Y.Z>; needs DOCKERHUB_REPO, DOCKERHUB_USER and " +
|
|
||||||
"DOCKERHUB_TOKEN (README -> CI/CD)",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// The page is public, so never render a version that resolves to no image.
|
|
||||||
if (!VERSION.test(version)) return fail(`version must be X.Y.Z, got ${JSON.stringify(version)}`);
|
|
||||||
|
|
||||||
const templatePath = join(import.meta.dirname, "dockerhub-overview.md.tmpl");
|
|
||||||
let template = "";
|
|
||||||
try {
|
|
||||||
template = readFileSync(templatePath, "utf8");
|
|
||||||
} catch (err) {
|
|
||||||
return fail(`${templatePath}: ${err instanceof Error ? err.message : String(err)}`);
|
|
||||||
}
|
|
||||||
const body = renderOverview(template, version);
|
|
||||||
const leftover = leftoverPlaceholders(body);
|
|
||||||
if (leftover.length > 0) return fail(`${templatePath} has unrendered placeholders: ${leftover.join(", ")}`);
|
|
||||||
|
|
||||||
const login = await post(`${HUB}/users/login`, {
|
|
||||||
body: JSON.stringify({ password: token, username: user }),
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
method: "POST",
|
|
||||||
});
|
|
||||||
if ("error" in login) return fail(`Docker Hub login unreachable: ${login.error}`);
|
|
||||||
if (!login.ok) return fail(`Docker Hub login failed: ${login.status} ${login.text}`);
|
|
||||||
const jwt = jwtFrom(login.json);
|
|
||||||
if (!jwt) return fail("Docker Hub login returned no token");
|
|
||||||
|
|
||||||
const res = await post(`${HUB}/repositories/${repo}/`, {
|
|
||||||
body: JSON.stringify({ full_description: body }),
|
|
||||||
headers: { authorization: `Bearer ${jwt}`, "content-type": "application/json" },
|
|
||||||
method: "PATCH",
|
|
||||||
});
|
|
||||||
if ("error" in res) return fail(`Docker Hub unreachable: ${res.error}`);
|
|
||||||
if (!res.ok) {
|
|
||||||
return fail(
|
|
||||||
`Docker Hub overview PATCH failed: ${res.status} ${res.text}` +
|
|
||||||
(res.status === 403
|
|
||||||
? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " +
|
|
||||||
"read/write/delete, which pushing images alone does not (README -> CI/CD)."
|
|
||||||
: ""),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
process.stdout.write(`Docker Hub overview updated for ${repo} at ${version}\n`);
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (process.argv[1]?.endsWith("/dockerhub-overview.ts")) {
|
|
||||||
process.exitCode = await main();
|
|
||||||
}
|
|
||||||
+3
-42
@@ -1,41 +1,9 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": ["config:recommended"],
|
"extends": ["config:recommended"],
|
||||||
"description": "ignorePaths overrides config:recommended's :ignoreModulesAndTests, which ignores **/examples/** — an example plugin's dependencies get update PRs like any other manifest here",
|
|
||||||
"ignorePaths": ["**/node_modules/**"],
|
|
||||||
"automerge": true,
|
"automerge": true,
|
||||||
|
"commitBody": "Release-Bump: {{{updateType}}}",
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
|
||||||
"description": "The host's own runtime deps. Release-Bump is opt-in per surface (README → CI/CD): updateType rates the dependency's own jump, not whether it reaches a running Plainpages",
|
|
||||||
"matchDepTypes": ["dependencies"],
|
|
||||||
"matchFileNames": ["package.json"],
|
|
||||||
"matchManagers": ["npm"],
|
|
||||||
"commitBody": "Release-Bump: {{{updateType}}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "The shipped image's base — e2e-tests/Dockerfile is test-only",
|
|
||||||
"matchFileNames": ["Dockerfile"],
|
|
||||||
"matchManagers": ["dockerfile"],
|
|
||||||
"commitBody": "Release-Bump: {{{updateType}}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "The production topology — compose.override.yml is dev, e2e-tests/compose.*.yml are test",
|
|
||||||
"matchFileNames": ["compose.yml"],
|
|
||||||
"matchManagers": ["docker-compose"],
|
|
||||||
"commitBody": "Release-Bump: {{{updateType}}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "The production sidecars, wherever they are pinned — compose.yml and the published quick start move in one branch, so the trailer must not depend on which upgrade sorts first. mailpit is dev-only and stays out",
|
|
||||||
"matchDatasources": ["docker"],
|
|
||||||
"matchPackageNames": ["oryd/hydra", "oryd/keto", "oryd/kratos", "postgres"],
|
|
||||||
"commitBody": "Release-Bump: {{{updateType}}}"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"description": "node is pinned to one version across Dockerfile, dev, E2E and CI, so Renovate moves them in a single branch whose commitBody would otherwise depend on upgrade order — the Dockerfile copy ships, so any node bump is a product change",
|
|
||||||
"matchDatasources": ["docker"],
|
|
||||||
"matchPackageNames": ["node"],
|
|
||||||
"commitBody": "Release-Bump: {{{updateType}}}"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"description": "Ory services share one release train - update kratos, keto and hydra together",
|
"description": "Ory services share one release train - update kratos, keto and hydra together",
|
||||||
"matchDatasources": ["docker"],
|
"matchDatasources": ["docker"],
|
||||||
@@ -59,15 +27,8 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"customType": "regex",
|
"customType": "regex",
|
||||||
"description": "The published quick start ships a compose file, so its sidecars move with the repo's own pins. The version group starts at a digit, which skips the {{VERSION}} placeholder the release renders",
|
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, auto-release)",
|
||||||
"managerFilePatterns": ["release-tooling/dockerhub-overview.md.tmpl"],
|
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/renovate.yml"],
|
||||||
"matchStrings": ["image: (?<depName>[^:\\s]+):(?<currentValue>v?\\d[^\\s]*)"],
|
|
||||||
"datasourceTemplate": "docker"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"customType": "regex",
|
|
||||||
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, renovate auto-release, release)",
|
|
||||||
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/release.yml", ".gitea/workflows/renovate.yml"],
|
|
||||||
"matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"],
|
"matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||||
"depNameTemplate": "node",
|
"depNameTemplate": "node",
|
||||||
"datasourceTemplate": "docker"
|
"datasourceTemplate": "docker"
|
||||||
|
|||||||
@@ -5,10 +5,7 @@
|
|||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, provisionPluginStorage, seedAdmin, seedPermissions, serverMismatch } from "./bootstrap.ts";
|
import { ensureJwks, firstRunBanner, identityPayload, permissionTuple, seedAdmin, seedPermissions } from "./bootstrap.ts";
|
||||||
import { createLogger } from "../logger.ts";
|
|
||||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
|
||||||
import type { ProvisionOptions, ProvisionResult } from "../plugin-host/storage-provisioning.ts";
|
|
||||||
|
|
||||||
const json = (status: number, body?: unknown) =>
|
const json = (status: number, body?: unknown) =>
|
||||||
new Response(body === undefined ? null : JSON.stringify(body), {
|
new Response(body === undefined ? null : JSON.stringify(body), {
|
||||||
@@ -45,9 +42,10 @@ test("seedPermissions unions ADMIN_PERMISSIONS (empty by default) with the disco
|
|||||||
assert.deepEqual(names(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
|
assert.deepEqual(names(",, ", [" scheduling:read ", ""]), ["scheduling:read"]); // blanks dropped, names trimmed (both sides)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Bootstrap gates `web`, so it must never refuse to start over operator env — a leftover
|
// The regression this pins: an earlier revision *threw* here, so `ADMIN_PERMISSIONS=admin` — this
|
||||||
// ADMIN_PERMISSIONS would otherwise brick the whole stack. Drop what it can't use, report it, seed
|
// setting's own default until 2026-08-05 — exited bootstrap 1, and bootstrap gates `web`, so a
|
||||||
// the rest.
|
// leftover variable bricked the whole stack on upgrade. Bootstrap must never refuse to start over
|
||||||
|
// operator env: 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", () => {
|
test("seedPermissions drops an ADMIN_PERMISSIONS name that isn't <resource>:<action>, and never throws", () => {
|
||||||
const legacy = seedPermissions("admin", ["users:read"]);
|
const legacy = seedPermissions("admin", ["users:read"]);
|
||||||
assert.deepEqual(legacy, { ignored: ["admin"], permissions: ["users:read"] });
|
assert.deepEqual(legacy, { ignored: ["admin"], permissions: ["users:read"] });
|
||||||
@@ -154,63 +152,3 @@ test("ensureJwks generates a key only when the file is absent", () => {
|
|||||||
assert.equal(ensureJwks(path, { exists: () => true, write }), false);
|
assert.equal(ensureJwks(path, { exists: () => true, write }), false);
|
||||||
assert.equal(writes.length, 1); // present → nothing written
|
assert.equal(writes.length, 1); // present → nothing written
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- Plugin storage provisioning -----------------------------------------------------
|
|
||||||
// The provisioner is injected, so the branch decisions are testable without a Postgres.
|
|
||||||
|
|
||||||
const SILENT = createLogger({ level: "none" });
|
|
||||||
const storagePlugin = (id: string): Plugin => ({ apiVersion: "1.0.0", id, storage: true });
|
|
||||||
const EMPTY: ProvisionResult = { orphans: [], provisioned: [] };
|
|
||||||
|
|
||||||
function recordingProvisioner(result: ProvisionResult = EMPTY) {
|
|
||||||
const calls: ProvisionOptions[] = [];
|
|
||||||
return { calls, provision: async (options: ProvisionOptions) => { calls.push(options); return result; } };
|
|
||||||
}
|
|
||||||
|
|
||||||
test("provisioning is skipped entirely when nothing declares storage and none is configured", async () => {
|
|
||||||
const { calls, provision } = recordingProvisioner();
|
|
||||||
await provisionPluginStorage({}, [{ apiVersion: "1.0.0", id: "plain" }], SILENT, provision);
|
|
||||||
assert.deepEqual(calls, []); // no connection attempted, so an unconfigured stack still boots
|
|
||||||
});
|
|
||||||
|
|
||||||
// Uninstalling the last storage plugin is exactly when a left-behind database needs naming.
|
|
||||||
test("provisioning still runs with nothing to provision, so orphans are reported", async () => {
|
|
||||||
const { calls, provision } = recordingProvisioner({ orphans: ["plugin_gone"], provisioned: [] });
|
|
||||||
await provisionPluginStorage({ PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db:5432/ory" }, [], SILENT, provision);
|
|
||||||
assert.equal(calls.length, 1);
|
|
||||||
assert.deepEqual(calls[0]?.pluginIds, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a plugin declaring storage without a provisioning DSN fails loud, naming the plugin", async () => {
|
|
||||||
const { calls, provision } = recordingProvisioner();
|
|
||||||
await assert.rejects(
|
|
||||||
provisionPluginStorage({}, [storagePlugin("things")], SILENT, provision),
|
|
||||||
/PLUGIN_DB_ADMIN_URL.*things/s,
|
|
||||||
);
|
|
||||||
assert.deepEqual(calls, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the connection limit and derived secret reach the provisioner", async () => {
|
|
||||||
const { calls, provision } = recordingProvisioner();
|
|
||||||
const env = { PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db:5432/ory", PLUGIN_DB_CONNECTION_LIMIT: "25", PLUGIN_DB_SECRET: "real" };
|
|
||||||
await provisionPluginStorage(env, [storagePlugin("things")], SILENT, provision);
|
|
||||||
assert.equal(calls[0]?.connectionLimit, 25);
|
|
||||||
assert.equal(calls[0]?.secret, "real");
|
|
||||||
assert.deepEqual(calls[0]?.pluginIds, ["things"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// bootstrap creates the role on one server; web tells the plugin to connect to another. Left
|
|
||||||
// unsaid it surfaces inside a plugin as "password authentication failed", naming neither. Warned
|
|
||||||
// rather than refused: web reaching a pooler bootstrap cannot provision through is legitimate.
|
|
||||||
test("a storage URL mismatch is reported, and provisioning still runs", async () => {
|
|
||||||
const { calls, provision } = recordingProvisioner();
|
|
||||||
const env = { PLUGIN_DB_ADMIN_URL: "postgres://ory:ory@db-a:5432/ory", PLUGIN_DB_URL: "postgres://db-b:5432" };
|
|
||||||
await provisionPluginStorage(env, [storagePlugin("things")], SILENT, provision);
|
|
||||||
assert.equal(calls.length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the same server spelled with an implicit port still agrees", () => {
|
|
||||||
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", "postgres://db"), null); // 5432 is the default
|
|
||||||
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", undefined), null); // web's own boot error to raise
|
|
||||||
assert.equal(serverMismatch("postgres://ory:ory@db:5432/ory", "postgres://db:6543"), "db:5432 vs db:6543");
|
|
||||||
});
|
|
||||||
|
|||||||
+36
-91
@@ -8,15 +8,10 @@
|
|||||||
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
// Then prints a first-run banner; fails loud on any unexpected upstream error.
|
||||||
import { existsSync, writeFileSync } from "node:fs";
|
import { existsSync, writeFileSync } from "node:fs";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "../config.ts";
|
|
||||||
import { discoverPlugins } from "../plugin-host/discovery.ts";
|
import { discoverPlugins } from "../plugin-host/discovery.ts";
|
||||||
import { declaredPermissions, isValidPermissionName, type Plugin } from "../plugin-host/plugin.ts";
|
import { declaredPermissions, isValidPermissionName } from "../plugin-host/plugin.ts";
|
||||||
import { provisionStorage } from "../plugin-host/storage-provisioning.ts";
|
|
||||||
import { storagePluginIds } from "../plugin-host/storage.ts";
|
|
||||||
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
|
import { generateJwks, type JwkSet } from "./gen-jwks.ts";
|
||||||
import { createLogger, runWithLog, tracedFetch, type Log } from "../logger.ts";
|
import { createLogger, runWithLog, tracedFetch } from "../logger.ts";
|
||||||
|
|
||||||
type Env = Record<string, string | undefined>;
|
|
||||||
|
|
||||||
// --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
|
// --- Pure payload builders (the Kratos/Keto request contracts) -----------------------
|
||||||
|
|
||||||
@@ -34,14 +29,19 @@ export function permissionTuple(userId: string, permission: string) {
|
|||||||
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
|
return { namespace: "Permission", object: permission, relation: "granted", subject_id: `user:${userId}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ADMIN_PERMISSIONS (empty by default) unioned with every discovered plugin's declared names, so
|
// The permissions to grant the demo admin = the configured base (ADMIN_PERMISSIONS, empty by default)
|
||||||
// the host names no plugin yet a dropped-in one is seeded out of the box.
|
// 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 is the one place an operator names a permission by hand, so it is held to the
|
// ADMIN_PERMISSIONS is the one place an operator names a permission by hand, so it is held to the
|
||||||
// same `<resource>:<action>` rule as a manifest — but *dropped with a warning*, never fatal:
|
// same `<resource>:<action>` rule discovery applies to a manifest — but *dropped with a warning*,
|
||||||
// fail-loud belongs at the manifest boundary where a developer authored the mistake, whereas this
|
// never fatal. Fail-loud belongs at the manifest boundary, where a developer authored the mistake
|
||||||
// is operator env and bootstrap gates `web`, so the whole stack must not refuse to start over a
|
// and can fix it; this is operator env, bootstrap gates `web`, and the whole stack must not refuse
|
||||||
// stale variable. The name it would have written gates nothing anyway.
|
// to start over a stale variable. `admin` was this setting's own default before 2026-08-05, so a
|
||||||
|
// value that bricks the boot is the *expected* leftover on any upgrade. The name it would have
|
||||||
|
// written gates nothing anyway. Declared names already passed the check at discovery.
|
||||||
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): { ignored: string[]; permissions: string[] } {
|
export function seedPermissions(adminPermissionsEnv: string | undefined, declaredNames: string[]): { ignored: string[]; permissions: string[] } {
|
||||||
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
const clean = (xs: string[]): string[] => xs.map((r) => r.trim()).filter(Boolean);
|
||||||
const configured = clean((adminPermissionsEnv ?? "").split(","));
|
const configured = clean((adminPermissionsEnv ?? "").split(","));
|
||||||
@@ -146,7 +146,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
|
|||||||
// --- CLI (the bootstrap container entrypoint) ----------------------------------------
|
// --- CLI (the bootstrap container entrypoint) ----------------------------------------
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
const env = { ...process.env }; // snapshot: the storage credentials leave process.env before discovery
|
const env = process.env;
|
||||||
// Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME.
|
// Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME.
|
||||||
const log = createLogger({
|
const log = createLogger({
|
||||||
format: env["LOG_FORMAT"] === "json" ? "json" : "text",
|
format: env["LOG_FORMAT"] === "json" ? "json" : "text",
|
||||||
@@ -155,84 +155,29 @@ async function main() {
|
|||||||
// runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls.
|
// runWithLog makes `log` ambient so seedAdmin's tracedFetch traces the Kratos/Keto seed calls.
|
||||||
await runWithLog(log, async () => {
|
await runWithLog(log, async () => {
|
||||||
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
|
if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key");
|
||||||
// Discovery imports every plugin module — and its dependencies — into *this* process, which holds
|
|
||||||
// the credential that may CREATE DATABASE/ROLE. Same move as server.ts, on the stronger secret.
|
// Seed every discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so the
|
||||||
delete process.env["PLUGIN_DB_ADMIN_URL"];
|
// shipped example — and any dropped-in plugin — works for the demo admin without a host edit.
|
||||||
delete process.env["PLUGIN_DB_SECRET"];
|
const declared = declaredPermissions(await discoverPlugins()).map((decl) => decl.name);
|
||||||
const plugins = await discoverPlugins();
|
const { ignored, permissions } = seedPermissions(env["ADMIN_PERMISSIONS"], declared);
|
||||||
await provisionPluginStorage(env, plugins, log);
|
if (ignored.length > 0) {
|
||||||
await seedAdminAndPermissions(env, plugins, log);
|
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 }));
|
||||||
});
|
});
|
||||||
await log.end(); // flush any pending OTLP spans/logs before the one-shot exits
|
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();
|
if (process.argv[1] === fileURLToPath(import.meta.url)) await main();
|
||||||
|
|||||||
+16
-8
@@ -1,12 +1,20 @@
|
|||||||
// Optional revocation denylist: instant permission/session revoke without putting Keto back on the
|
// Optional revocation denylist: instant permission/session revoke without putting Keto
|
||||||
// hot path. Off by default — enable with REVOCATION_DENYLIST=true. An admin action records the
|
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
|
||||||
// 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).
|
|
||||||
//
|
//
|
||||||
// An in-memory, auto-evicting Map — no database, so it stays inside the stateless model. Entries
|
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked permission or a
|
||||||
// self-evict after one token TTL, by which point any pre-revoke token has expired anyway.
|
// killed session only takes effect when the token is next minted (re-login / TTL refresh) —
|
||||||
// Single-process: instant on the instance that handled the revoke, elsewhere the guarantee falls
|
// up to one token TTL of lag. For security-critical revoke (offboarding, a compromised
|
||||||
// back to the token TTL. Back it with a shared store for hard multi-instance instant-revoke.
|
// 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.
|
||||||
|
|
||||||
export interface Denylist {
|
export interface Denylist {
|
||||||
// Hot-path check: is a token for `sub`, issued at `iat` (unix sec), revoked? A token minted
|
// Hot-path check: is a token for `sub`, issued at `iat` (unix sec), revoked? A token minted
|
||||||
|
|||||||
@@ -113,7 +113,6 @@ test("the code field guards a pasted space: one-time-code autofill + numeric inp
|
|||||||
);
|
);
|
||||||
assert.deepEqual(view.fields.find((f) => f.name === "code"), {
|
assert.deepEqual(view.fields.find((f) => f.name === "code"), {
|
||||||
autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill
|
autocomplete: "one-time-code", // Kratos sends none for the OTP node — enable OS/email autofill
|
||||||
hint: "Digits only — no spaces.", // the pattern refusal alone reads as a bare "match the requested format"
|
|
||||||
icon: "i-shield",
|
icon: "i-shield",
|
||||||
id: "field-code",
|
id: "field-code",
|
||||||
inputmode: "numeric",
|
inputmode: "numeric",
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import type { Flow, FlowType, UiNode } from "./kratos-public.ts";
|
|||||||
export interface FlowField {
|
export interface FlowField {
|
||||||
autocomplete?: string;
|
autocomplete?: string;
|
||||||
error?: { text: string };
|
error?: { text: string };
|
||||||
hint?: string; // muted helper text under the input
|
|
||||||
icon?: string; // Lucide sprite id for the input
|
icon?: string; // Lucide sprite id for the input
|
||||||
id: string;
|
id: string;
|
||||||
inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code)
|
inputmode?: string; // virtual-keyboard hint (e.g. "numeric" for the OTP code)
|
||||||
@@ -140,7 +139,7 @@ function toField(node: UiNode, name: string, type: string, t: Translate): FlowFi
|
|||||||
...(autocomplete ? { autocomplete } : {}),
|
...(autocomplete ? { autocomplete } : {}),
|
||||||
...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}),
|
...(errorMsg ? { error: { text: kratosText(t, errorMsg.text, idKey(errorMsg.id)) } } : {}),
|
||||||
...(icon ? { icon } : {}),
|
...(icon ? { icon } : {}),
|
||||||
...(isCode ? { hint: t("auth.field.code.hint"), inputmode: "numeric", pattern: "[0-9]*" } : {}),
|
...(isCode ? { inputmode: "numeric", pattern: "[0-9]*" } : {}),
|
||||||
...(node.attributes["required"] === true ? { required: true } : {}),
|
...(node.attributes["required"] === true ? { required: true } : {}),
|
||||||
...(value ? { value } : {}),
|
...(value ? { value } : {}),
|
||||||
};
|
};
|
||||||
|
|||||||
+5
-3
@@ -1,6 +1,8 @@
|
|||||||
// In-handler authorization, the imperative counterpart to the declarative route `permission` gate.
|
// Auth guards: in-handler authorization, the imperative counterpart to the
|
||||||
// `requireSession` asserts (throws GuardError, which app.ts maps to a response); `can`/`check` are
|
// declarative route `permission` gate. The middleware already verified the session JWT and put
|
||||||
// predicates a handler branches on. `check` is the one live Keto call, for relationship rules.
|
// 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.
|
||||||
import type { RequestContext, User } from "../http/context.ts";
|
import type { RequestContext, User } from "../http/context.ts";
|
||||||
import type { KetoClient } from "./keto-client.ts";
|
import type { KetoClient } from "./keto-client.ts";
|
||||||
import { localPath } from "../http/safe-url.ts";
|
import { localPath } from "../http/safe-url.ts";
|
||||||
|
|||||||
+4
-2
@@ -231,8 +231,10 @@ function logout(kratos: KratosPublic, secureCookies: boolean): BuiltinRoute["han
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Kratos' self-service error sink (kratos.yml flows.error.ui_url → /error). A flow that fails a
|
// Kratos' self-service error sink (kratos.yml flows.error.ui_url → /error). A flow that fails a
|
||||||
// security/expiry check redirects the browser here with ?id=<uuid>; render a themed page with a
|
// security/expiry check redirects the browser here with ?id=<uuid>. Render a themed page with a
|
||||||
// path back into sign-in rather than the catch-all 404. The id is shown for support reference only.
|
// 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.
|
||||||
const errorSink = (ctx: RequestContext): RouteResult =>
|
const errorSink = (ctx: RequestContext): RouteResult =>
|
||||||
({ data: { id: ctx.url.searchParams.get("id") }, view: "error" });
|
({ data: { id: ctx.url.searchParams.get("id") }, view: "error" });
|
||||||
|
|
||||||
|
|||||||
+4
-20
@@ -44,11 +44,10 @@ test("long-running Ory services declare readiness healthchecks", () => {
|
|||||||
`${svc} probes :${port}/health/ready`);
|
`${svc} probes :${port}/health/ready`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("web waits for kratos, keto, hydra and postgres to be healthy before starting", () => {
|
test("web waits for kratos, keto and hydra to be healthy before starting", () => {
|
||||||
assert.match(webBlock, /depends_on:/, "web declares dependencies");
|
assert.match(webBlock, /depends_on:/, "web declares dependencies");
|
||||||
// hydra: the OAuth2 login/consent handler talks to its admin API. postgres: a plugin declaring
|
// hydra: the OAuth2 login/consent handler talks to its admin API.
|
||||||
// `storage` opens its connection in onBoot, before the server listens.
|
for (const svc of ["kratos", "keto", "hydra"])
|
||||||
for (const svc of ["kratos", "keto", "hydra", "postgres"])
|
|
||||||
assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`),
|
assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`),
|
||||||
`web waits for ${svc} healthy`);
|
`web waits for ${svc} healthy`);
|
||||||
});
|
});
|
||||||
@@ -79,21 +78,6 @@ test("prod base supplies the app secret via env and mounts no source; dev overri
|
|||||||
assert.match(compose, /POSTGRES_PASSWORD:\s*\$\{POSTGRES_PASSWORD\b/, "postgres password via env");
|
assert.match(compose, /POSTGRES_PASSWORD:\s*\$\{POSTGRES_PASSWORD\b/, "postgres password via env");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the provisioning superuser DSN reaches bootstrap only, never web", () => {
|
|
||||||
// web runs plugin code, which can read its own environment — so the credentials that may CREATE
|
|
||||||
// DATABASE/ROLE must never be there. web gets the credential-free base URL and derives each
|
|
||||||
// plugin's own password from the shared secret instead.
|
|
||||||
const boot = compose.slice(compose.indexOf("\n bootstrap:"));
|
|
||||||
const overrideWeb = override.slice(override.indexOf("\n web:"), override.indexOf("\n bootstrap:"));
|
|
||||||
assert.match(boot, /PLUGIN_DB_ADMIN_URL:/, "bootstrap is given the superuser DSN");
|
|
||||||
// Reordering the override's services would empty this slice, and every doesNotMatch below would
|
|
||||||
// then pass against "".
|
|
||||||
assert.ok(overrideWeb.includes("PLUGIN_DB_URL"), "sliced the dev override's web block");
|
|
||||||
for (const [name, block] of [["base", webBlock], ["dev override", overrideWeb]] as const)
|
|
||||||
assert.doesNotMatch(block, /PLUGIN_DB_ADMIN_URL/, `${name} web never sees it`);
|
|
||||||
assert.match(webBlock, /PLUGIN_DB_URL:\s*\$\{PLUGIN_DB_URL/, "base wires web's base URL from env");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a one-shot bootstrap seeds the stack before web starts", () => {
|
test("a one-shot bootstrap seeds the stack before web starts", () => {
|
||||||
// MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin +
|
// MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin +
|
||||||
// JWKS, then exits; web waits for it to complete. Live seeding is boot-verified.
|
// JWKS, then exits; web waits for it to complete. Live seeding is boot-verified.
|
||||||
@@ -129,7 +113,7 @@ test("the E2E runner writes its artifacts as the invoking user, never as root",
|
|||||||
// filter would otherwise leave that command silently unguarded.
|
// filter would otherwise leave that command silently unguarded.
|
||||||
const documented = [read("README.md"), ...composeFiles("e2e-tests/").map(read)]
|
const documented = [read("README.md"), ...composeFiles("e2e-tests/").map(read)]
|
||||||
.join("\n").split("\n").filter((l) => /docker compose .*\brun\b.*\be2e\b/.test(l));
|
.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");
|
assert.equal(documented.length, 10, "5 compose headers + 5 README blocks");
|
||||||
for (const l of documented)
|
for (const l of documented)
|
||||||
assert.match(l, /--user "\$\(id -u\):\$\(id -g\)"/, `passes the uid: ${l.trim()}`);
|
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.
|
// An absent mount source is daemon-created as root, and then that uid can't write it at all.
|
||||||
|
|||||||
+1
-38
@@ -1,6 +1,6 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import { loadConfig, resolvePluginDbConnectionLimit, resolvePluginDbSecret } from "./config.ts";
|
import { loadConfig } from "./config.ts";
|
||||||
|
|
||||||
// Explicit secure-secret enforcement (no environment sniffing): secrets are the only
|
// Explicit secure-secret enforcement (no environment sniffing): secrets are the only
|
||||||
// thing a hardened deploy must supply.
|
// thing a hardened deploy must supply.
|
||||||
@@ -9,43 +9,6 @@ const secureEnv = {
|
|||||||
REQUIRE_SECURE_SECRETS: "true",
|
REQUIRE_SECURE_SECRETS: "true",
|
||||||
};
|
};
|
||||||
|
|
||||||
// web reads the secret through loadConfig and bootstrap through resolvePluginDbSecret; the two
|
|
||||||
// deriving different passwords is invisible until a plugin's connection is refused at boot. Compose
|
|
||||||
// passes an unset variable through as "", which is the case that actually drifted.
|
|
||||||
test("web and bootstrap resolve the same plugin storage secret", () => {
|
|
||||||
for (const env of [{}, { PLUGIN_DB_SECRET: "" }, { PLUGIN_DB_SECRET: "a-real-secret" }]) {
|
|
||||||
assert.equal(loadConfig(env).pluginDbSecret, resolvePluginDbSecret(env), `for ${JSON.stringify(env)}`);
|
|
||||||
}
|
|
||||||
assert.match(resolvePluginDbSecret({ PLUGIN_DB_SECRET: "" }), /dev-insecure/); // empty is unset, not a secret
|
|
||||||
});
|
|
||||||
|
|
||||||
// bootstrap writes these passwords into Postgres, so it must refuse the publicly-known throwaway
|
|
||||||
// before creating a role with one — not leave web to notice afterwards.
|
|
||||||
test("bootstrap refuses a missing, empty or throwaway plugin storage secret when hardened", () => {
|
|
||||||
const hardened = { REQUIRE_SECURE_SECRETS: "true" };
|
|
||||||
for (const secret of [undefined, "", "dev-insecure-plugin-db-secret"]) {
|
|
||||||
const env = secret === undefined ? hardened : { ...hardened, PLUGIN_DB_SECRET: secret };
|
|
||||||
assert.throws(() => resolvePluginDbSecret(env), /PLUGIN_DB_SECRET/, `for ${JSON.stringify(secret)}`);
|
|
||||||
}
|
|
||||||
assert.equal(resolvePluginDbSecret({ ...hardened, PLUGIN_DB_SECRET: "a-real-secret" }), "a-real-secret");
|
|
||||||
});
|
|
||||||
|
|
||||||
// buildCredentials overwrites the userinfo, so a pasted admin DSN would *work* — and leave a
|
|
||||||
// privileged password in the process that runs plugin code. Refusing it is the whole guard.
|
|
||||||
test("PLUGIN_DB_URL carrying credentials is refused, not silently overwritten", () => {
|
|
||||||
assert.throws(() => loadConfig({ PLUGIN_DB_URL: "postgres://root:hunter2@db:5432/ory" }), /no username or password/);
|
|
||||||
assert.throws(() => loadConfig({ PLUGIN_DB_URL: "postgres://root@db:5432" }), /no username or password/);
|
|
||||||
assert.equal(loadConfig({ PLUGIN_DB_URL: "postgres://db:5432" }).pluginDbUrl, "postgres://db:5432");
|
|
||||||
assert.equal(loadConfig({}).pluginDbUrl, undefined); // unset ⇒ storage off
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the per-role connection ceiling defaults to 10 and rejects nonsense", () => {
|
|
||||||
assert.equal(resolvePluginDbConnectionLimit({}), 10);
|
|
||||||
assert.equal(resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "25" }), 25);
|
|
||||||
assert.throws(() => resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "0" }), /positive integer/);
|
|
||||||
assert.throws(() => resolvePluginDbConnectionLimit({ PLUGIN_DB_CONNECTION_LIMIT: "ten" }), /positive integer/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("loads dev defaults when the environment is empty", () => {
|
test("loads dev defaults when the environment is empty", () => {
|
||||||
const c = loadConfig({});
|
const c = loadConfig({});
|
||||||
assert.equal(c.port, 3000);
|
assert.equal(c.port, 3000);
|
||||||
|
|||||||
+9
-40
@@ -1,40 +1,17 @@
|
|||||||
// Config loaded once from the environment at boot. Fail-loud — a bad value, a missing enforced
|
// Config loaded once from the environment at boot: Ory endpoints, cookie/CSRF
|
||||||
// secret, a bad URL or an out-of-range port throws here, never at request time. Every value has a
|
// secrets, JWKS location, listen port, behaviour toggles. Fail-loud — a bad value, a
|
||||||
// working dev default, so `docker compose up` runs with zero config.
|
// 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.
|
||||||
|
|
||||||
// Log verbosity, most→least severe; "none" silences everything (matches @larvit/log's levels).
|
// Log verbosity, most→least severe; "none" silences everything (matches @larvit/log's levels).
|
||||||
export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const;
|
export const LOG_LEVELS = ["error", "warn", "info", "verbose", "debug", "silly", "none"] as const;
|
||||||
export type LogLevel = (typeof LOG_LEVELS)[number];
|
export type LogLevel = (typeof LOG_LEVELS)[number];
|
||||||
|
|
||||||
const DEV_PLUGIN_DB_SECRET = "dev-insecure-plugin-db-secret";
|
|
||||||
|
|
||||||
// The one resolution both processes use — they must agree exactly, or web connects with a password
|
|
||||||
// the role was never given. Compose passes an unset variable through as "", so empty means unset.
|
|
||||||
// `enforce` says whether storage is actually in play: web once PLUGIN_DB_URL is configured,
|
|
||||||
// bootstrap once a plugin declares storage. Enforced, the throwaway is refused — bootstrap is what
|
|
||||||
// writes these passwords into Postgres, so it must refuse *before* creating a role with one.
|
|
||||||
export function resolvePluginDbSecret(env: Env, enforce?: boolean): string {
|
|
||||||
return readSecret(env, "PLUGIN_DB_SECRET", DEV_PLUGIN_DB_SECRET, enforce ?? readBool(env, "REQUIRE_SECURE_SECRETS", false));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only bootstrap provisions, so only bootstrap reads this; env still gets read in one place.
|
|
||||||
export function resolvePluginDbConnectionLimit(env: Env): number {
|
|
||||||
return readPosInt(env, "PLUGIN_DB_CONNECTION_LIMIT", 10);
|
|
||||||
}
|
|
||||||
|
|
||||||
// PLUGIN_DB_URL is web's, and web must never hold credentials that outrank a plugin's own role.
|
|
||||||
// Pasting the admin DSN here would otherwise work — buildCredentials overwrites the userinfo — and
|
|
||||||
// leave a superuser password in the environment plugin code can read.
|
|
||||||
function readCredentiallessUrl(env: Env, key: string): string | undefined {
|
|
||||||
const value = readOptionalUrl(env, key);
|
|
||||||
if (value === undefined) return undefined;
|
|
||||||
const url = new URL(value);
|
|
||||||
if (url.username || url.password) {
|
|
||||||
throw new Error(`config: ${key} must carry no username or password — each plugin connects as its own role`);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Config {
|
export interface Config {
|
||||||
appUrl: string | undefined; // canonical public URL; set ⇒ off-host visitors are redirected here. Unset ⇒ no redirect (explicit toggle)
|
appUrl: string | undefined; // canonical public URL; set ⇒ off-host visitors are redirected here. Unset ⇒ no redirect (explicit toggle)
|
||||||
cacheTemplates: boolean;
|
cacheTemplates: boolean;
|
||||||
@@ -53,8 +30,6 @@ export interface Config {
|
|||||||
oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory)
|
oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory)
|
||||||
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
|
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
|
||||||
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
|
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
|
||||||
pluginDbSecret: string; // derives each plugin's database password (src/plugin-host/storage.ts)
|
|
||||||
pluginDbUrl: string | undefined; // credential-free Postgres base URL; unset ⇒ plugin storage is off
|
|
||||||
port: number;
|
port: number;
|
||||||
revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
|
revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
|
||||||
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
|
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
|
||||||
@@ -181,12 +156,6 @@ export function loadConfig(env: Env = process.env): Config {
|
|||||||
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
|
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
|
||||||
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
|
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
|
||||||
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
|
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
|
||||||
// Per-plugin storage. PLUGIN_DB_URL carries the server and its connection parameters but no
|
|
||||||
// credentials: the superuser DSN that provisions stays in bootstrap, so a plugin cannot read it
|
|
||||||
// out of web's environment. Unset ⇒ storage is off and a plugin declaring it fails loud at boot,
|
|
||||||
// which is also why the secret is only enforced once a URL is configured.
|
|
||||||
pluginDbSecret: resolvePluginDbSecret(env, requireSecure && Boolean(env["PLUGIN_DB_URL"])),
|
|
||||||
pluginDbUrl: readCredentiallessUrl(env, "PLUGIN_DB_URL"),
|
|
||||||
port: readPort(env),
|
port: readPort(env),
|
||||||
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
|
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
|
||||||
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
|
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
|
||||||
|
|||||||
+17
-11
@@ -27,12 +27,15 @@ import type { MenuConfig } from "../ui/menu-config.ts";
|
|||||||
import { loadI18n } from "../i18n/load.ts";
|
import { loadI18n } from "../i18n/load.ts";
|
||||||
|
|
||||||
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
|
||||||
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
|
// The admin screens ship as a drop-in example plugin; the HTTP-level admin tests mount it via
|
||||||
// ctx.system, views from examples/plugins — exactly as an operator would after copying it in.
|
// createApp (with stub Ory clients on ctx.system + views from examples/plugins) exactly as an
|
||||||
|
// operator would after copying it into plugins/.
|
||||||
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
|
const examplesPluginsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "examples", "plugins");
|
||||||
const adminPlugin: Plugin = { ...adminManifest, id: "admin" };
|
const adminPlugin: Plugin = { ...adminManifest, id: "admin" };
|
||||||
|
|
||||||
// A session JWT signed with a throwaway test key; `staticJwks([ecJwk])` is the matching verify side.
|
// 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.
|
||||||
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
|
||||||
const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
|
const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
|
||||||
const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url");
|
const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url");
|
||||||
@@ -98,7 +101,8 @@ test("plugins replace either landing: `home` owns the public /, `dashboard` owns
|
|||||||
const dir = mkdtempSync(join(tmpdir(), "pp-home-"));
|
const dir = mkdtempSync(join(tmpdir(), "pp-home-"));
|
||||||
mkdirSync(join(dir, "portal", "views"), { recursive: true });
|
mkdirSync(join(dir, "portal", "views"), { recursive: true });
|
||||||
writeFileSync(join(dir, "portal", "views", "welcome.ejs"), `<h1>Welcome to <%= brand %></h1><a href="/login">Sign in</a>`);
|
writeFileSync(join(dir, "portal", "views", "welcome.ejs"), `<h1>Welcome to <%= brand %></h1><a href="/login">Sign in</a>`);
|
||||||
// The dashboard view renders the native app shell from ctx.chrome.
|
// The 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.
|
||||||
writeFileSync(join(dir, "portal", "views", "board.ejs"),
|
writeFileSync(join(dir, "portal", "views", "board.ejs"),
|
||||||
`<%- include("partials/shell", { body: "<p>Hi " + user.email + "</p>", brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), theme: chrome.theme, title: "My Portal", user: chrome.user }) %>`);
|
`<%- include("partials/shell", { body: "<p>Hi " + user.email + "</p>", brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), theme: chrome.theme, title: "My Portal", user: chrome.user }) %>`);
|
||||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||||
@@ -322,8 +326,9 @@ function rawGet(port: number, path: string, host: string, method = "GET"): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the configured origin (path+query kept)", async (t) => {
|
test("APP_URL canonical-host redirect: an off-host visitor is 308'd to the configured origin (path+query kept)", async (t) => {
|
||||||
// Reach the app on any host and it sends you to APP_URL's, so the browser, the themed form and the
|
// The fix for the localhost-vs-127.0.0.1 / multi-domain trap: reach the app on any host and it
|
||||||
// cross-origin Kratos POST share ONE cookie host. Same-host requests pass straight through.
|
// 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.
|
||||||
const app = createApp({ jwks: staticJwks([ecJwk]), appUrl: "http://canonical.example:3000" });
|
const app = createApp({ jwks: staticJwks([ecJwk]), appUrl: "http://canonical.example:3000" });
|
||||||
await new Promise<void>((r) => app.listen(0, r));
|
await new Promise<void>((r) => app.listen(0, r));
|
||||||
t.after(() => app.close());
|
t.after(() => app.close());
|
||||||
@@ -354,8 +359,8 @@ test("no APP_URL configured ⇒ no canonical redirect (unit-test apps and host-a
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("/error renders a themed sign-in error page (Kratos' flow error sink), not the 404", async () => {
|
test("/error renders a themed sign-in error page (Kratos' flow error sink), not the 404", async () => {
|
||||||
// Kratos' flows.error.ui_url points here; a flow error redirects to /error?id=<uuid>, which must
|
// Kratos' flows.error.ui_url points here; a flow error redirects to /error?id=<uuid>. Without a
|
||||||
// land on a real themed page rather than the catch-all 404.
|
// handler it 404'd as "Page not found" (confusing). It must be a real, themed page now.
|
||||||
const res = await fetch(base + `/error?id=${randomUUID()}`, { redirect: "manual" });
|
const res = await fetch(base + `/error?id=${randomUUID()}`, { redirect: "manual" });
|
||||||
assert.equal(res.status, 200);
|
assert.equal(res.status, 200);
|
||||||
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
||||||
@@ -1252,8 +1257,9 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
|
|||||||
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Granting permissions over HTTP. The offered set is the host's catalog (ctx.declaredPermissions),
|
// Granting permissions over HTTP, on the two screens that replaced the deleted Permissions screen.
|
||||||
// so the checkboxes are a fixed list and the POST is the desired state.
|
// 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.
|
||||||
test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
|
test("admin permission grants: the picker offers the declared catalog, and a save is the desired set", async (t) => {
|
||||||
const ada = randomUUID();
|
const ada = randomUUID();
|
||||||
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
|
||||||
@@ -1342,7 +1348,7 @@ test("admin screens render no write affordance for a read-only holder", async (t
|
|||||||
assert.doesNotMatch(group, /Delete group/);
|
assert.doesNotMatch(group, /Delete group/);
|
||||||
assert.doesNotMatch(group, /Save permissions/);
|
assert.doesNotMatch(group, /Save permissions/);
|
||||||
|
|
||||||
// The OAuth2-clients screen is held to the same rule.
|
// The OAuth2-clients screen is held to the same rule (it was the one this test was written to catch).
|
||||||
const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
|
const clientsRes = await get("/admin/clients", ["oauth2-clients:read"]);
|
||||||
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
|
assert.equal(clientsRes.status, 200); // a real render, not the capability-missing 503
|
||||||
const clients = await clientsRes.text();
|
const clients = await clientsRes.text();
|
||||||
|
|||||||
+96
-43
@@ -39,11 +39,15 @@ const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
|||||||
export interface AppOptions {
|
export interface AppOptions {
|
||||||
appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect
|
appUrl?: string; // canonical public URL (config.appUrl); off-host GET/HEAD visitors are 308'd here. Omitted ⇒ no redirect
|
||||||
auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks
|
auth?: VerifyOptions; // expected JWT issuer/audience + clock skew (config); used with jwks
|
||||||
cache?: boolean; // cache compiled EJS templates (config.cacheTemplates); off ⇒ edits show live
|
// 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;
|
||||||
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
|
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
|
||||||
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
|
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
|
||||||
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
|
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
|
||||||
i18n?: I18n; // discovered catalogs; omitted ⇒ the built-in en-US only, so an unwired app still renders English
|
// 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;
|
||||||
jwks?: JwksProvider; // verify the session JWT → ctx.user/permissions; absent ⇒ always anonymous
|
jwks?: JwksProvider; // verify the session JWT → ctx.user/permissions; absent ⇒ always anonymous
|
||||||
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
|
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
|
||||||
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
|
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
|
||||||
@@ -58,12 +62,15 @@ export interface AppOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createApp(options: AppOptions = {}): Server {
|
export function createApp(options: AppOptions = {}): Server {
|
||||||
// The denylist rides in the verify options so resolveSession rejects a revoked subject on the hot
|
// The denylist (when enabled) rides in the verify options so resolveSession rejects a revoked
|
||||||
// path; the bound `revoke` goes to the admin handlers. Both absent ⇒ the feature is fully off.
|
// 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).
|
||||||
const denylist = options.denylist;
|
const denylist = options.denylist;
|
||||||
const authOptions: VerifyOptions = denylist ? { ...(options.auth ?? {}), denylist } : (options.auth ?? {});
|
const authOptions: VerifyOptions = denylist ? { ...(options.auth ?? {}), denylist } : (options.auth ?? {});
|
||||||
const revoke = denylist ? (sub: string): void => denylist.revoke(sub) : undefined;
|
const revoke = denylist ? (sub: string): void => denylist.revoke(sub) : undefined;
|
||||||
const cache = options.cache ?? false;
|
const cache = options.cache ?? false;
|
||||||
|
// Canonical public host (APP_URL): when set, an off-host GET/HEAD visitor is redirected here so
|
||||||
|
// every cookie (esp. Kratos' cross-origin CSRF cookie) shares one host. Omitted ⇒ feature off.
|
||||||
const canonical = options.appUrl ? new URL(options.appUrl) : undefined;
|
const canonical = options.appUrl ? new URL(options.appUrl) : undefined;
|
||||||
const canonicalHost = canonical?.host; // host[:port], default ports omitted — matches the Host header
|
const canonicalHost = canonical?.host; // host[:port], default ports omitted — matches the Host header
|
||||||
const canonicalOrigin = canonical?.origin; // scheme + host[:port], no trailing slash
|
const canonicalOrigin = canonical?.origin; // scheme + host[:port], no trailing slash
|
||||||
@@ -75,7 +82,9 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const keto = options.keto;
|
const keto = options.keto;
|
||||||
const kratos = options.kratos;
|
const kratos = options.kratos;
|
||||||
const kratosAdmin = options.kratosAdmin;
|
const kratosAdmin = options.kratosAdmin;
|
||||||
// Only the wired capabilities are present; with none wired ctx.system stays undefined.
|
// 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.
|
||||||
const system: SystemCapabilities | undefined = kratosAdmin || keto || hydra || revoke
|
const system: SystemCapabilities | undefined = kratosAdmin || keto || hydra || revoke
|
||||||
? { ...(hydra ? { hydra } : {}), ...(keto ? { keto } : {}), ...(kratosAdmin ? { kratosAdmin } : {}), ...(revoke ? { revoke } : {}) }
|
? { ...(hydra ? { hydra } : {}), ...(keto ? { keto } : {}), ...(kratosAdmin ? { kratosAdmin } : {}), ...(revoke ? { revoke } : {}) }
|
||||||
: undefined;
|
: undefined;
|
||||||
@@ -84,11 +93,15 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const menu = options.menu ?? DEFAULT_MENU;
|
const menu = options.menu ?? DEFAULT_MENU;
|
||||||
const plugins = options.plugins ?? [];
|
const plugins = options.plugins ?? [];
|
||||||
const pluginIds = new Set(plugins.map((p) => p.id));
|
const pluginIds = new Set(plugins.map((p) => p.id));
|
||||||
// `find` is unambiguous: findConflicts guarantees at most one owner of each landing slot.
|
// 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.
|
||||||
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
|
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
|
||||||
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
|
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
|
||||||
const permissionCatalog = declaredPermissions(plugins);
|
|
||||||
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
||||||
|
// 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 anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
|
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
|
||||||
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
||||||
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
const pluginsDir = options.pluginsDir ?? PLUGINS_DIR;
|
||||||
@@ -102,11 +115,19 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
|
const render = (view: string, data: Record<string, unknown>): Promise<string> =>
|
||||||
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache, views: [viewsDir] });
|
ejs.renderFile(join(viewsDir, `${view}.ejs`), data, { cache, views: [viewsDir] });
|
||||||
|
|
||||||
|
// A `view` RouteResult renders plugins/<id>/views/<view>.ejs; such views may include() the core
|
||||||
|
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
|
||||||
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
|
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
|
||||||
|
|
||||||
// Where the language picker points. Normally the page itself; after a POST that URL may answer no
|
// Every view renders with its context's i18n locals (t/locale/dir/localeSwitch/localeParam) merged
|
||||||
// GET (POST /admin/users/:id/delete has no GET sibling), so fall back to the page the form was
|
// in, so a view — core or plugin, at any include depth — calls `t(...)` without its handler passing
|
||||||
// submitted from, then to the front page — the picker is on every page, so every link must land.
|
// 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.
|
||||||
const switchBase = (req: IncomingMessage, url: URL): string => {
|
const switchBase = (req: IncomingMessage, url: URL): string => {
|
||||||
const method = (req.method ?? "GET").toUpperCase();
|
const method = (req.method ?? "GET").toUpperCase();
|
||||||
if (method === "GET" || method === "HEAD") return `${url.pathname}${url.search}`;
|
if (method === "GET" || method === "HEAD") return `${url.pathname}${url.search}`;
|
||||||
@@ -126,8 +147,6 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
t: ctx.t,
|
t: ctx.t,
|
||||||
url: ctx.url,
|
url: ctx.url,
|
||||||
});
|
});
|
||||||
// i18n locals go last: their names are reserved, so a handler's colliding key loses instead of
|
|
||||||
// breaking the shell around it.
|
|
||||||
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
||||||
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
||||||
|
|
||||||
@@ -136,7 +155,10 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
res.end(html);
|
res.end(html);
|
||||||
};
|
};
|
||||||
|
|
||||||
// The public landing "/", ungated. A plugin may own it via `home`; else the built-in intro page.
|
// 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).
|
||||||
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
||||||
csrf.setCookie();
|
csrf.setCookie();
|
||||||
if (homePlugin) {
|
if (homePlugin) {
|
||||||
@@ -150,8 +172,10 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
|
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
|
||||||
};
|
};
|
||||||
|
|
||||||
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
|
// The post-login app home "/dashboard", gated to a signed-in user: anonymous bounces to sign
|
||||||
// starter page.
|
// 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.
|
||||||
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
||||||
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
|
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
|
||||||
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
||||||
@@ -190,21 +214,26 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
|
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
|
||||||
for (const [name, value] of secHeaderEntries) res.setHeader(name, value);
|
for (const [name, value] of secHeaderEntries) res.setHeader(name, value);
|
||||||
|
|
||||||
// Before auth: assets don't need a verified user, and the JWT cookie rides every request.
|
|
||||||
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
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);
|
const { dir, subPath } = routePublic(pathname.slice("/public/".length), publicDir, pluginsDir, pluginIds);
|
||||||
await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) }));
|
await serveStatic(dir, subPath, res, method === "HEAD", (err) => reqLog.error("static stream error", { error: String(err) }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A cache in front of us must key on the language. Set after the static branch: an asset is
|
// Rendered pages content-negotiate on Accept-Language, so a cache in front of us must key on
|
||||||
// the same bytes in every language, and a Vary there fragments its entry per raw header.
|
// 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.
|
||||||
res.setHeader("vary", "accept-language");
|
res.setHeader("vary", "accept-language");
|
||||||
|
|
||||||
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
|
// Canonical host (APP_URL): a visitor who reached us on a different host (localhost vs
|
||||||
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
|
// 127.0.0.1, a secondary domain) is sent to the configured origin, path + query preserved, so
|
||||||
// host-scoped Kratos CSRF cookie is lost and login dumps onto /error. GET/HEAD only: a 308
|
// the browser, the themed forms, and the cross-origin Kratos POST all share one cookie host —
|
||||||
// must not replay a cross-host POST.
|
// 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.
|
||||||
if (canonicalHost && (method === "GET" || method === "HEAD")) {
|
if (canonicalHost && (method === "GET" || method === "HEAD")) {
|
||||||
const host = req.headers.host;
|
const host = req.headers.host;
|
||||||
if (host !== undefined && host !== canonicalHost) {
|
if (host !== undefined && host !== canonicalHost) {
|
||||||
@@ -213,14 +242,18 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// `explicit` (the URL asked for a locale) is what makes the choice travel: the chrome, this
|
// Which language this request is served in: ?locale wins, else Accept-Language, else en-US.
|
||||||
// request's redirects and ctx.localeHref then carry ?locale onto the links they emit.
|
// `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.
|
||||||
const { explicit, locale } = i18n.resolve({ acceptLanguage: req.headers["accept-language"], param: url.searchParams.get("locale") });
|
const { explicit, locale } = i18n.resolve({ acceptLanguage: req.headers["accept-language"], param: url.searchParams.get("locale") });
|
||||||
const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null);
|
const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null);
|
||||||
const t = i18n.translator(locale);
|
const t = i18n.translator(locale);
|
||||||
|
|
||||||
// A lapsed token still backed by a live Kratos session is silently re-minted — "stay signed
|
// Verify the session JWT once (cached JWKS) → ctx.user/permissions; none/invalid ⇒ anonymous.
|
||||||
// in". The only place the hot path touches Ory.
|
// 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.
|
||||||
let user: User | null = null;
|
let user: User | null = null;
|
||||||
if (jwks) {
|
if (jwks) {
|
||||||
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
|
const auth = await resolveSession(req.headers.cookie, jwks, authOptions);
|
||||||
@@ -231,25 +264,32 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
user = reminted.user;
|
user = reminted.user;
|
||||||
res.appendHeader("set-cookie", reminted.setCookie);
|
res.appendHeader("set-cookie", reminted.setCookie);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Ory unreachable — degrade to anonymous instead of 500ing every lapsed request. Leave
|
// Ory unreachable (Kratos/Keto 5xx, refused, timeout) — degrade to anonymous instead of
|
||||||
// the cookie alone: it can re-mint once Ory recovers.
|
// 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) });
|
reqLog.warn("session re-mint failed (Ory unreachable?)", { error: String(err) });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// CSRF token for this request's first-party forms: reuse a genuine cookie token, else mint
|
||||||
|
// one (a page-emitting handler Set-Cookies it via csrfMint). Verified on our own
|
||||||
|
// state-changing routes.
|
||||||
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
|
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
|
||||||
const csrfMint: RequestCsrf = {
|
const csrfMint: RequestCsrf = {
|
||||||
setCookie: (): void => { if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); },
|
setCookie: (): void => { if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); },
|
||||||
token: csrf.token,
|
token: csrf.token,
|
||||||
};
|
};
|
||||||
|
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret).
|
||||||
const verifyCsrf = (submitted: string | null | undefined): boolean =>
|
const verifyCsrf = (submitted: string | null | undefined): boolean =>
|
||||||
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
|
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
|
||||||
// Chrome composes the whole menu, so it is memoized and resolved lazily — a json/redirect
|
// Chrome (brand/global-nav/user/theme/csrf) composes the whole menu, so it's resolved lazily and
|
||||||
// handler, or the public "/" with a standalone home, never pays for it.
|
// 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).
|
||||||
let chromeMemo: PageChrome | undefined;
|
let chromeMemo: PageChrome | undefined;
|
||||||
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, localeHref: carryLocale, menu, plugins, t, translatorFor: (id) => i18n.translator(locale, id), user }));
|
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, localeHref: carryLocale, menu, plugins, t, translatorFor: (id) => i18n.translator(locale, id), user }));
|
||||||
|
|
||||||
// A plugin's context gets the plugin's own translator — its catalog first, then core.
|
// 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).
|
||||||
const i18nFor = (pluginId?: string) => ({
|
const i18nFor = (pluginId?: string) => ({
|
||||||
locale,
|
locale,
|
||||||
localeHref: carryLocale,
|
localeHref: carryLocale,
|
||||||
@@ -257,8 +297,9 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
t: pluginId === undefined ? t : i18n.translator(locale, pluginId),
|
t: pluginId === undefined ? t : i18n.translator(locale, pluginId),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Base context (no route params), for the built-in routes. Every plugin-owned render — a
|
// base context (no route params yet); reused for the built-in routes. A plugin-owned render
|
||||||
// landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead.
|
// (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 } : {}) });
|
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||||
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
|
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
|
||||||
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
||||||
@@ -268,19 +309,23 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
if (anyRequestHooks) {
|
if (anyRequestHooks) {
|
||||||
const short = await runRequestHooks(plugins, contextFor);
|
const short = await runRequestHooks(plugins, contextFor);
|
||||||
if (short) {
|
if (short) {
|
||||||
// Like every other page-emitting path, so a form the hook renders has its matching cookie.
|
// 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.
|
||||||
csrfMint.setCookie();
|
csrfMint.setCookie();
|
||||||
await sendResult(res, short.result, pluginViewsFor(short.ctx, short.plugin.id), carryLocale);
|
await sendResult(res, short.result, pluginViewsFor(short.ctx, short.plugin.id), carryLocale);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plugin routes (any method): gate on the route's permission, then run the handler. The
|
||||||
|
// handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh
|
||||||
|
// CSRF cookie is set so those forms have a valid double-submit token.
|
||||||
const match = matchRoute(plugins, method, pathname);
|
const match = matchRoute(plugins, method, pathname);
|
||||||
if (match) {
|
if (match) {
|
||||||
const routeCtx = contextFor(match.plugin.id, match.params);
|
const routeCtx = contextFor(match.plugin.id, match.params);
|
||||||
if (!isAuthorized(match.route, routeCtx.permissions)) {
|
if (!isAuthorized(match.route, routeCtx.permissions)) {
|
||||||
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply
|
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
|
||||||
// lacks the permission gets the 403 page.
|
// 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; }
|
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
||||||
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
|
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
|
||||||
sendHtml(res, 403, await renderPage("403", {}));
|
sendHtml(res, 403, await renderPage("403", {}));
|
||||||
@@ -295,6 +340,9 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Built-in endpoints (the auth/OAuth2 group, the landing slots, /error) from the internal
|
||||||
|
// route table — same handler shape as plugin routes; a `view` result renders the core views,
|
||||||
|
// null means the handler wrote to ctx.res itself.
|
||||||
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
||||||
if (builtin) {
|
if (builtin) {
|
||||||
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
||||||
@@ -337,16 +385,20 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return createServer((req, res) => {
|
return createServer((req, res) => {
|
||||||
// "close" (not "finish") fires on both a completed response and a premature disconnect, so an
|
// Per-request log + trace span: a "request" span, continuing an upstream W3C traceparent
|
||||||
// aborted request is still logged and its span flushed.
|
// 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.
|
||||||
const startMs = Date.now();
|
const startMs = Date.now();
|
||||||
const reqLog = requestLogger(log, {
|
const reqLog = requestLogger(log, {
|
||||||
requestId: randomUUID(),
|
requestId: randomUUID(),
|
||||||
...(typeof req.headers.traceparent === "string" ? { traceparent: req.headers.traceparent } : {}),
|
...(typeof req.headers.traceparent === "string" ? { traceparent: req.headers.traceparent } : {}),
|
||||||
});
|
});
|
||||||
// end() must run exactly once, after BOTH the handler has unwound AND the response has closed.
|
// end() must run exactly once, after BOTH the handler has fully unwound (settled) AND the
|
||||||
// Earlier would throw "already ended" from a still-running handler's ctx.log on a client abort,
|
// response has closed (the access line is then emitted with the final status). Ending earlier
|
||||||
// or drop the access line on the happy path (the handler settles before close).
|
// 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.
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let closed = false;
|
let closed = false;
|
||||||
const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); };
|
const finalize = (): void => { if (settled && closed) void reqLog.end().catch(() => {}); };
|
||||||
@@ -358,8 +410,9 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
} catch { /* never let logging crash a served request */ }
|
} catch { /* never let logging crash a served request */ }
|
||||||
finalize();
|
finalize();
|
||||||
});
|
});
|
||||||
// Make reqLog ambient for the whole handler so all outbound fetch is traced. The .catch logs a
|
// Make reqLog ambient for the whole handler (sync body + every await) so all outbound fetch is
|
||||||
// pathological escape via the app logger — not reqLog, which may be the thing that broke.
|
// 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.
|
||||||
void runWithLog(reqLog, () => handleRequest(req, res, reqLog))
|
void runWithLog(reqLog, () => handleRequest(req, res, reqLog))
|
||||||
.catch((err) => log.error("request handler escaped its try/catch", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) }))
|
.catch((err) => log.error("request handler escaped its try/catch", { error: err instanceof Error ? (err.stack ?? err.message) : String(err) }))
|
||||||
.finally(() => { settled = true; finalize(); });
|
.finally(() => { settled = true; finalize(); });
|
||||||
|
|||||||
+2
-2
@@ -31,8 +31,8 @@ export interface RequestContext {
|
|||||||
// on off-site URLs. The host already does this for the chrome and its own redirects; a plugin
|
// on off-site URLs. The host already does this for the chrome and its own redirects; a plugin
|
||||||
// wraps the hrefs it builds itself.
|
// wraps the hrefs it builds itself.
|
||||||
localeHref(href: string): string;
|
localeHref(href: string): string;
|
||||||
// Every installed locale, sorted. With `localeLabel` (from @plainpages/plugin-api) it is what a
|
// Every installed locale, sorted. With `localeLabel` (from #plugin-api) it is what a plugin needs
|
||||||
// plugin needs to build its own language picker; the host's own picker is already in the shell.
|
// to build its own language picker; the host's own picker is already in the shell.
|
||||||
locales: string[];
|
locales: string[];
|
||||||
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
|
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
|
||||||
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
|
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
|
||||||
|
|||||||
+12
-5
@@ -1,8 +1,15 @@
|
|||||||
// safeUrl(value) — a URL field is emitted verbatim into an href/src, so a `javascript:`/`data:`
|
// URL safety helpers. Two pure, dependency-free guards:
|
||||||
// URL from untrusted data would be live XSS. Relative or http(s) passes,
|
//
|
||||||
// anything else collapses to "#". Exported to plugins via plugin-api.ts.
|
// safeUrl(value) — sanitise an untrusted URL before rendering it in an href/src attribute.
|
||||||
// localPath(value) — the redirect-URI allowlist for `return_to`: host-relative passes, absolute
|
// Partials escape *text*, but a URL field is emitted verbatim, so a
|
||||||
// or protocol-relative is rejected, so a crafted value can't open-redirect.
|
// `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.
|
||||||
|
|
||||||
// ASCII control chars + space that browsers strip/ignore when resolving a URL — strip them before
|
// ASCII control chars + space that browsers strip/ignore when resolving a URL — strip them before
|
||||||
// the scheme check so "java\tscript:" / a leading space can't masquerade as relative.
|
// the scheme check so "java\tscript:" / a leading space can't masquerade as relative.
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
// Set once per request in app.ts, so every response carries them (writeHead merges with setHeader).
|
// Response security headers: set once per request in app.ts so every response — page,
|
||||||
// A plugin route may override any per-response via RouteResult.headers.
|
// 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).
|
||||||
|
|
||||||
// The non-obvious parts of the CSP:
|
// Strict default CSP for the zero-JS, server-rendered core:
|
||||||
// - script-src 'self' with no 'unsafe-inline' ⇒ an injected <script> can't run. A plugin may still
|
// - script-src 'self' : the core ships no JS; a plugin may still serve its own /public/<id>/*.js for
|
||||||
// serve its own /public/<id>/*.js for opt-in progressive enhancement.
|
// opt-in progressive enhancement. No 'unsafe-inline' ⇒ an injected <script>
|
||||||
// - style-src adds 'unsafe-inline': a few partials carry inline style= attributes.
|
// can't run (the main XSS sink).
|
||||||
// - no form-action: the themed login form posts to Kratos' (often cross-origin) action URL.
|
// - 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).
|
||||||
const CSP = [
|
const CSP = [
|
||||||
"base-uri 'self'",
|
"base-uri 'self'",
|
||||||
"default-src 'self'",
|
"default-src 'self'",
|
||||||
|
|||||||
+8
-6
@@ -1,10 +1,12 @@
|
|||||||
// Catalog discovery: import src/i18n/locales/<tag>.ts and plugins/<id>/i18n/<tag>.ts, then check
|
// Catalog discovery: import src/i18n/locales/<tag>.ts and plugins/<id>/i18n/<tag>.ts, then
|
||||||
// every one against its set's en-US baseline. The imperative shell over catalog.ts's pure rules,
|
// check every one against its set's en-US baseline. The imperative shell over catalog.ts's pure
|
||||||
// with plugin discovery's contract: one boot-stopping Error listing every problem, so a
|
// rules — the same contract as plugin discovery: one boot-stopping Error listing every problem,
|
||||||
// half-translated deploy is caught at startup rather than as a stray English word in production.
|
// so a half-translated deploy is caught at startup rather than as a stray English word in production.
|
||||||
//
|
//
|
||||||
// A plugin may translate fewer locales than the core holds (its strings then render in en-US) but
|
// Installed locales are whatever the core folder holds; a plugin may translate fewer of them (its
|
||||||
// never one the host lacks. The operator's `locales/` mount extends both sides.
|
// 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.
|
||||||
|
|
||||||
import { existsSync, readdirSync } from "node:fs";
|
import { existsSync, readdirSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ const messages = {
|
|||||||
"auth.continue": "Continue",
|
"auth.continue": "Continue",
|
||||||
// Kratos labels its own form fields; these translate the ones the built-in identity schema uses,
|
// Kratos labels its own form fields; these translate the ones the built-in identity schema uses,
|
||||||
// keyed on the input name. A deployment's extra traits keep Kratos' label until a plugin covers them.
|
// keyed on the input name. A deployment's extra traits keep Kratos' label until a plugin covers them.
|
||||||
"auth.field.code.hint": "Digits only — no spaces.",
|
|
||||||
"auth.field.email": "Email",
|
"auth.field.email": "Email",
|
||||||
"auth.field.identifier": "Email",
|
"auth.field.identifier": "Email",
|
||||||
"auth.field.password": "Password",
|
"auth.field.password": "Password",
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { CoreMessages } from "./en-US.ts";
|
|||||||
|
|
||||||
const messages: CoreMessages = {
|
const messages: CoreMessages = {
|
||||||
"auth.continue": "Fortsätt",
|
"auth.continue": "Fortsätt",
|
||||||
"auth.field.code.hint": "Endast siffror — inga mellanslag.",
|
|
||||||
"auth.field.email": "E-postadress",
|
"auth.field.email": "E-postadress",
|
||||||
"auth.field.identifier": "E-postadress",
|
"auth.field.identifier": "E-postadress",
|
||||||
"auth.field.password": "Lösenord",
|
"auth.field.password": "Lösenord",
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
// The translator: a key + vars → the string to render. Two rules the rest of the app leans on:
|
// The translator: a key + vars → the string to render. Pure and synchronous — views call it
|
||||||
// · the lookup walks a catalog chain (plugin locale → plugin en-US → core locale → core en-US)
|
// as `t("shell.signOut")` and handlers as `ctx.t(...)`.
|
||||||
// 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.
|
// Two rules the rest of the app leans on:
|
||||||
// · the result is raw text, escaped by the view with <%= %> like any other value, so a
|
// · the lookup walks a catalog chain (plugin locale → plugin en-US → core locale → core en-US) and,
|
||||||
// translation is never double-escaped and one carrying markup is rendered with <%- %>.
|
// 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 <%- %>.
|
||||||
|
|
||||||
import { isPluralMessage, type Catalog, type PluralMessage } from "./catalog.ts";
|
import { isPluralMessage, type Catalog, type PluralMessage } from "./catalog.ts";
|
||||||
|
|
||||||
|
|||||||
+17
-10
@@ -21,9 +21,11 @@ export interface LoggerOptions {
|
|||||||
stdout?: (msg: string) => void;
|
stdout?: (msg: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The app-level logger, tagged service.name. With otlpEndpoint set, logs + spans also export to that
|
// The app-level logger: a Log tagged service.name so every console line, OTLP log record and span is
|
||||||
// OTLP/HTTP collector; unset ⇒ console only, at zero export cost. The conditional spreads keep
|
// attributed to the service. Level + format + name are explicit toggles (LOG_LEVEL/LOG_FORMAT/
|
||||||
// exactOptionalPropertyTypes happy (no `key: undefined`).
|
// 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`).
|
||||||
export function createLogger(opts: LoggerOptions = {}): Log {
|
export function createLogger(opts: LoggerOptions = {}): Log {
|
||||||
return new Log({
|
return new Log({
|
||||||
context: { "service.name": opts.serviceName || SERVICE_NAME },
|
context: { "service.name": opts.serviceName || SERVICE_NAME },
|
||||||
@@ -47,10 +49,13 @@ export function currentLog(): Log | undefined {
|
|||||||
return requestStore.getStore();
|
return requestStore.getStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// A drop-in `fetch` that traces through the active request log — a client span under the request
|
// A drop-in `fetch` that traces through the active request log — a client span nested under the
|
||||||
// span, with a W3C `traceparent` injected so the downstream service continues the same trace.
|
// request span, with a W3C `traceparent` injected so the downstream service continues the same
|
||||||
// Outside a request, or for a non-string/URL input, it is a plain `fetch`. Note log.fetch throws
|
// trace. Outside a request (no ambient log) or for a non-string/URL input it's a plain `fetch`.
|
||||||
// synchronously once the request log has ended; app.ts ends it only after the handler unwinds.
|
// 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).
|
||||||
export const tracedFetch: typeof fetch = (input, init) => {
|
export const tracedFetch: typeof fetch = (input, init) => {
|
||||||
const log = currentLog();
|
const log = currentLog();
|
||||||
if (log && (typeof input === "string" || input instanceof URL)) return log.fetch(input, init);
|
if (log && (typeof input === "string" || input instanceof URL)) return log.fetch(input, init);
|
||||||
@@ -58,9 +63,11 @@ export const tracedFetch: typeof fetch = (input, init) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// A per-request child logger holding a "request" trace span. `clone` (not parentLog) gives the
|
// A per-request child logger holding a "request" trace span. `clone` (not parentLog) gives the
|
||||||
// request its own root trace, so requests aren't all nested under one app-lifetime span, while
|
// request its own root trace — so requests aren't all nested under one app-lifetime span — while
|
||||||
// inheriting the parent's level/format/streams/OTLP. A valid upstream `traceparent` is adopted;
|
// inheriting the parent's level/format/streams/OTLP. A valid upstream W3C `traceparent` is adopted
|
||||||
// malformed ⇒ ignored, a fresh trace starts. `end()` on response finish exports the span.
|
// (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.
|
||||||
export function requestLogger(appLog: Log, opts: { requestId: string; traceparent?: string | undefined }): Log {
|
export function requestLogger(appLog: Log, opts: { requestId: string; traceparent?: string | undefined }): Log {
|
||||||
return appLog.clone({
|
return appLog.clone({
|
||||||
context: { ...appLog.context, requestId: opts.requestId },
|
context: { ...appLog.context, requestId: opts.requestId },
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test, type TestContext } from "node:test";
|
import { test, type TestContext } from "node:test";
|
||||||
import { discoverPlugins } from "./discovery.ts";
|
import { discoverPlugins } from "./discovery.ts";
|
||||||
import { HOST_API_VERSION } from "./plugin.ts";
|
|
||||||
|
|
||||||
// Write a throwaway plugins/ tree of `relpath → source` and clean it up after the test. Fixtures
|
// Write a throwaway plugins/ tree of `relpath → source` and clean it up after the test. Fixtures
|
||||||
// default-export plain objects — definePlugin is identity, so a literal is an equivalent manifest.
|
// default-export plain objects — definePlugin is identity, so a literal is an equivalent manifest.
|
||||||
@@ -20,7 +19,7 @@ function scaffold(t: TestContext, files: Record<string, string>): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const full = (id: string): string =>
|
const full = (id: string): string =>
|
||||||
`export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}" }], ` +
|
`export default { apiVersion: "1.0.0", nav: [{ id: "${id}:root", label: "${id}" }], ` +
|
||||||
`routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`;
|
`routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`;
|
||||||
|
|
||||||
test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => {
|
test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => {
|
||||||
@@ -28,19 +27,13 @@ test("a missing plugins/ dir means zero plugins, not an error (clean clone)", as
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("discovers each folder's manifest, sorted, id derived from the folder name", async (t) => {
|
test("discovers each folder's manifest, sorted, id derived from the folder name", async (t) => {
|
||||||
const dir = scaffold(t, {
|
const dir = scaffold(t, { "beta/plugin.ts": full("beta"), "alpha/plugin.ts": full("alpha") });
|
||||||
"beta/plugin.ts": full("beta"),
|
|
||||||
"alpha/plugin.ts": full("alpha"),
|
|
||||||
"gamma/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", storage: true };`,
|
|
||||||
});
|
|
||||||
const plugins = await discoverPlugins({ dir });
|
const plugins = await discoverPlugins({ dir });
|
||||||
|
|
||||||
assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta", "gamma"]); // deterministic order
|
assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta"]); // deterministic order
|
||||||
assert.equal(plugins[0]?.apiVersion, HOST_API_VERSION);
|
assert.equal(plugins[0]?.apiVersion, "1.0.0");
|
||||||
assert.equal(plugins[0]?.nav?.[0]?.label, "alpha");
|
assert.equal(plugins[0]?.nav?.[0]?.label, "alpha");
|
||||||
assert.equal(typeof plugins[0]?.routes?.[0]?.handler, "function"); // handlers survive import
|
assert.equal(typeof plugins[0]?.routes?.[0]?.handler, "function"); // handlers survive import
|
||||||
assert.equal(plugins[0]?.storage, undefined); // storage is opt-in, never assumed
|
|
||||||
assert.equal(plugins[2]?.storage, true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Every per-plugin problem and every error-level conflict aborts boot with a message naming it.
|
// Every per-plugin problem and every error-level conflict aborts boot with a message naming it.
|
||||||
@@ -52,30 +45,20 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
|||||||
{ name: "no default export", files: { "named-only/plugin.ts": "export const x = 1;" }, match: /named-only.*default/s },
|
{ name: "no default export", files: { "named-only/plugin.ts": "export const x = 1;" }, match: /named-only.*default/s },
|
||||||
{ name: "import throws", files: { "explodes/plugin.ts": "throw new Error('boom');" }, match: /explodes.*boom/s },
|
{ name: "import throws", files: { "explodes/plugin.ts": "throw new Error('boom');" }, match: /explodes.*boom/s },
|
||||||
{ name: "incompatible apiVersion", files: { "future/plugin.ts": `export default { apiVersion: "2.0.0" };` }, match: /future.*apiVersion/s },
|
{ name: "incompatible apiVersion", files: { "future/plugin.ts": `export default { apiVersion: "2.0.0" };` }, match: /future.*apiVersion/s },
|
||||||
{ name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: "nope" };` }, match: /weird.*routes.*array/s },
|
{ name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "1.0.0", routes: "nope" };` }, match: /weird.*routes.*array/s },
|
||||||
{ name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: "nope" };` }, match: /weirdhome.*home.*function/s },
|
{ name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "1.0.0", home: "nope" };` }, match: /weirdhome.*home.*function/s },
|
||||||
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
|
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
|
||||||
{ name: "non-boolean storage", files: { "weirdstore/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", storage: "postgres://db" };` }, match: /weirdstore.*storage.*boolean/s },
|
|
||||||
// The folder name becomes a Postgres identifier, which truncates past 63 bytes.
|
|
||||||
{ name: "a storage plugin whose folder name overflows a Postgres identifier", files: { [`${"a".repeat(57)}/plugin.ts`]: `export default { apiVersion: "${HOST_API_VERSION}", storage: true };` }, match: /storage.*56 characters/s },
|
|
||||||
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
|
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
|
||||||
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
|
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
|
||||||
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
|
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
|
||||||
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s },
|
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s },
|
||||||
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
|
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
|
||||||
// only in the admin GUI, so it holds for a plugin installed without that GUI.
|
// only in the admin GUI, so it holds for a plugin installed without that GUI.
|
||||||
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
|
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
|
||||||
{ name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s },
|
{ name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s },
|
||||||
{ name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s },
|
{ name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "1.0.0", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s },
|
||||||
{ name: "a plugin shipping its own copy of the barrel", files: { "shadow/node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "shadow/plugin.ts": full("shadow") }, match: /shadow.*@plainpages\/plugin-api/s },
|
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
|
||||||
{ name: "a plugin package.json that forgets type: module", files: { "cjs/package.json": `{ "name": "cjs" }`, "cjs/plugin.ts": full("cjs") }, match: /cjs.*"type": "module"/s },
|
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
|
||||||
{ name: "a plugin package.json that is not valid JSON", files: { "bent/package.json": `{`, "bent/plugin.ts": full("bent") }, match: /bent.*package\.json.*JSON/s },
|
|
||||||
{ name: "a plugin package.json holding null", files: { "nul/package.json": `null`, "nul/plugin.ts": full("nul") }, match: /nul.*"type": "module"/s },
|
|
||||||
// `npm install --prefix plugins` — the documented command with one path segment dropped.
|
|
||||||
{ name: "a package.json in the scan root itself", files: { "package.json": `{ "name": "oops" }`, "ok/plugin.ts": full("ok") }, match: /plugins\/package\.json must not exist/ },
|
|
||||||
{ name: "a node_modules in the scan root itself", files: { "node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "ok/plugin.ts": full("ok") }, match: /plugins\/node_modules must not exist/ },
|
|
||||||
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ html: "b" }) };` }, match: /home/ },
|
|
||||||
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const c of badCases) {
|
for (const c of badCases) {
|
||||||
@@ -88,7 +71,7 @@ for (const c of badCases) {
|
|||||||
// upgrade, not the author of the manifest — so the message has to carry the remedy, not just the
|
// upgrade, not the author of the manifest — so the message has to carry the remedy, not just the
|
||||||
// rule. A pre-existing `plugins/admin` gating on the old `admin` permission is exactly this case.
|
// rule. A pre-existing `plugins/admin` gating on the old `admin` permission is exactly this case.
|
||||||
test("a discovery failure tells the operator their plugins/ copy may just be out of date", async (t) => {
|
test("a discovery failure tells the operator their plugins/ copy may just be out of date", async (t) => {
|
||||||
const dir = scaffold(t, { "admin/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/users", permission: "admin", handler: () => ({ html: "x" }) }] };` });
|
const dir = scaffold(t, { "admin/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/users", permission: "admin", handler: () => ({ html: "x" }) }] };` });
|
||||||
await assert.rejects(discoverPlugins({ dir }), (err: Error) => {
|
await assert.rejects(discoverPlugins({ dir }), (err: Error) => {
|
||||||
assert.match(err.message, /gates on "admin"/); // what is wrong
|
assert.match(err.message, /gates on "admin"/); // what is wrong
|
||||||
assert.match(err.message, /re-copy it/); // …and what to do about it
|
assert.match(err.message, /re-copy it/); // …and what to do about it
|
||||||
@@ -97,7 +80,7 @@ test("a discovery failure tells the operator their plugins/ copy may just be out
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("a route + nav node may be marked public and load fine", async (t) => {
|
test("a route + nav node may be marked public and load fine", async (t) => {
|
||||||
const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", 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: "1.0.0", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` });
|
||||||
const plugins = await discoverPlugins({ dir });
|
const plugins = await discoverPlugins({ dir });
|
||||||
assert.equal(plugins.length, 1);
|
assert.equal(plugins.length, 1);
|
||||||
assert.equal(plugins[0]?.routes?.[0]?.public, true);
|
assert.equal(plugins[0]?.routes?.[0]?.public, true);
|
||||||
@@ -112,49 +95,15 @@ test("`admin` is not reserved — the admin screens ship as a drop-in plugin mou
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
|
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
|
||||||
const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` });
|
const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` });
|
||||||
const plugins = await discoverPlugins({ dir });
|
const plugins = await discoverPlugins({ dir });
|
||||||
assert.equal(plugins.length, 1);
|
assert.equal(plugins.length, 1);
|
||||||
assert.equal(typeof plugins[0]?.home, "function");
|
assert.equal(typeof plugins[0]?.home, "function");
|
||||||
assert.equal(typeof plugins[0]?.dashboard, "function");
|
assert.equal(typeof plugins[0]?.dashboard, "function");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Host deps sit at /node_modules, above every plugin scope, so the barrel resolves from a folder
|
|
||||||
// that has its own package.json (README → Plugin dependencies).
|
|
||||||
test("a plugin may carry its own package.json, node_modules and dependencies", async (t) => {
|
|
||||||
const dir = scaffold(t, {
|
|
||||||
"shop/package.json": `{ "name": "shop", "version": "0.0.0", "type": "module", "dependencies": { "price-tag": "1.0.0" } }`,
|
|
||||||
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`,
|
|
||||||
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
|
|
||||||
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
|
|
||||||
`export default definePlugin({ apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`,
|
|
||||||
});
|
|
||||||
|
|
||||||
const plugins = await discoverPlugins({ dir });
|
|
||||||
|
|
||||||
assert.deepEqual(plugins.map((p) => p.id), ["shop"]);
|
|
||||||
assert.deepEqual(await plugins[0]?.routes?.[0]?.handler(null as never), { html: "20 kr" });
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a plugin folder may be a symlink", async (t) => {
|
|
||||||
const ownRepo = scaffold(t, { "my-plugin/plugin.ts": full("my-plugin") });
|
|
||||||
const dir = scaffold(t, {});
|
|
||||||
symlinkSync(join(ownRepo, "my-plugin"), join(dir, "linked"));
|
|
||||||
|
|
||||||
const plugins = await discoverPlugins({ dir });
|
|
||||||
|
|
||||||
assert.deepEqual(plugins.map((p) => p.id), ["linked"]); // the link name is the id, not the target's
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a dangling plugin symlink fails loud rather than vanishing", async (t) => {
|
|
||||||
const dir = scaffold(t, {});
|
|
||||||
symlinkSync(join(dir, "gone"), join(dir, "broken"));
|
|
||||||
|
|
||||||
await assert.rejects(discoverPlugins({ dir }), /broken.*plugin\.ts/s);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a shared permission name only warns — both plugins still load", async (t) => {
|
test("a shared permission name only warns — both plugins still load", async (t) => {
|
||||||
const shared = `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "shared:read" }] };`;
|
const shared = `export default { apiVersion: "1.0.0", permissions: [{ name: "shared:read" }] };`;
|
||||||
const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
|
const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
|
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
|
||||||
|
|||||||
@@ -4,11 +4,10 @@
|
|||||||
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
|
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
|
||||||
// (older-minor apiVersion, shared permission name) log and load continues. Folder name = id.
|
// (older-minor apiVersion, shared permission name) log and load continues. Folder name = id.
|
||||||
|
|
||||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
import { existsSync, readdirSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
|
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
|
||||||
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts";
|
|
||||||
|
|
||||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
|
||||||
@@ -28,14 +27,6 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
|
|||||||
const errors: string[] = [];
|
const errors: string[] = [];
|
||||||
const plugins: Plugin[] = [];
|
const plugins: Plugin[] = [];
|
||||||
|
|
||||||
// `npm install --prefix plugins` instead of `--prefix plugins/<id>`: the package.json becomes the
|
|
||||||
// scope for every plugin below it, and the node_modules outranks the host's own — barrel included.
|
|
||||||
for (const stray of ["node_modules", "package.json"]) {
|
|
||||||
if (existsSync(join(dir, stray))) {
|
|
||||||
errors.push(`plugins/${stray} must not exist — it sits above every plugin and shadows the host's own; delete plugins/{node_modules,package.json,package-lock.json} and install into plugins/<id>`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const id of pluginFolders(dir)) {
|
for (const id of pluginFolders(dir)) {
|
||||||
const fail = (msg: string): void => void errors.push(`plugins/${id}: ${msg}`);
|
const fail = (msg: string): void => void errors.push(`plugins/${id}: ${msg}`);
|
||||||
|
|
||||||
@@ -46,8 +37,6 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
|
|||||||
if (RESERVED_PLUGIN_IDS.has(id)) { fail(`"${id}" is a reserved id — it would shadow a built-in host route`); continue; }
|
if (RESERVED_PLUGIN_IDS.has(id)) { fail(`"${id}" is a reserved id — it would shadow a built-in host route`); continue; }
|
||||||
const file = join(dir, id, "plugin.ts");
|
const file = join(dir, id, "plugin.ts");
|
||||||
if (!existsSync(file)) { fail("no plugin.ts found"); continue; }
|
if (!existsSync(file)) { fail("no plugin.ts found"); continue; }
|
||||||
const packaging = packagingError(join(dir, id));
|
|
||||||
if (packaging) { fail(packaging); continue; }
|
|
||||||
|
|
||||||
let mod: { default?: unknown };
|
let mod: { default?: unknown };
|
||||||
try {
|
try {
|
||||||
@@ -67,13 +56,6 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
|
|||||||
const shape = shapeError(manifest);
|
const shape = shapeError(manifest);
|
||||||
if (shape) { fail(shape); continue; }
|
if (shape) { fail(shape); continue; }
|
||||||
|
|
||||||
// The folder name becomes a Postgres identifier, which truncates past 63 bytes — two long ids
|
|
||||||
// would then share one database. Only checked for a plugin that asked for storage.
|
|
||||||
if (manifest.storage === true && !isValidStoragePluginId(id)) {
|
|
||||||
fail(`declares storage, so its folder name must be at most ${MAX_STORAGE_PLUGIN_ID_LENGTH} characters`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
plugins.push({ ...manifest, id }); // identity is the folder, not the manifest
|
plugins.push({ ...manifest, id }); // identity is the folder, not the manifest
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,36 +77,15 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
|
|||||||
return plugins;
|
return plugins;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sorted for deterministic load order + stable conflict messages. A symlink counts as a folder, and
|
// Subfolders of plugins/, sorted for deterministic load order + stable conflict messages. Hidden
|
||||||
// one whose target the container cannot see trips "no plugin.ts found" rather than vanishing.
|
// entries (.git, .DS_Store, …) and non-directories are skipped — only folders are plugins.
|
||||||
function pluginFolders(dir: string): string[] {
|
function pluginFolders(dir: string): string[] {
|
||||||
return readdirSync(dir, { withFileTypes: true })
|
return readdirSync(dir, { withFileTypes: true })
|
||||||
.filter((e) => (e.isDirectory() || e.isSymbolicLink()) && !e.name.startsWith(".") && e.name !== "node_modules")
|
.filter((e) => e.isDirectory() && !e.name.startsWith("."))
|
||||||
.map((e) => e.name)
|
.map((e) => e.name)
|
||||||
.sort();
|
.sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
// A barrel copy resolves before the host's, so its GuardError matches no `instanceof` here and a
|
|
||||||
// sign-in redirect becomes a 500.
|
|
||||||
function packagingError(folder: string): string | null {
|
|
||||||
if (existsSync(join(folder, "node_modules", "@plainpages", "plugin-api"))) {
|
|
||||||
return "ships its own copy of @plainpages/plugin-api — remove it; the host provides the one instance";
|
|
||||||
}
|
|
||||||
|
|
||||||
const file = join(folder, "package.json");
|
|
||||||
if (!existsSync(file)) return null;
|
|
||||||
|
|
||||||
let manifest: { type?: unknown } | null;
|
|
||||||
try {
|
|
||||||
manifest = JSON.parse(readFileSync(file, "utf8")) as { type?: unknown } | null;
|
|
||||||
} catch (err) {
|
|
||||||
return `package.json could not be read as JSON — ${messageOf(err)}`;
|
|
||||||
}
|
|
||||||
return manifest?.type === "module"
|
|
||||||
? null
|
|
||||||
: `package.json must set "type": "module" — npm writes no type, and Node then re-parses every file in the folder`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asManifest(value: unknown): PluginManifest | null {
|
function asManifest(value: unknown): PluginManifest | null {
|
||||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as PluginManifest) : null;
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as PluginManifest) : null;
|
||||||
}
|
}
|
||||||
@@ -139,8 +100,6 @@ function shapeError(manifest: PluginManifest): string | null {
|
|||||||
for (const slot of ["home", "dashboard"] as const) {
|
for (const slot of ["home", "dashboard"] as const) {
|
||||||
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
|
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
|
||||||
}
|
}
|
||||||
// A truthy non-boolean (a DSN, say) must not quietly read as "provision me one".
|
|
||||||
if (manifest.storage !== undefined && typeof manifest.storage !== "boolean") return `"storage" must be a boolean`;
|
|
||||||
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
|
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
|
||||||
// "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
|
// "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
|
||||||
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
||||||
|
|||||||
@@ -12,17 +12,14 @@ function plugin(id: string, hooks: PluginHooks): Plugin {
|
|||||||
|
|
||||||
test("runBootHooks runs each onBoot in order, skips plugins without one, and a throw aborts", async () => {
|
test("runBootHooks runs each onBoot in order, skips plugins without one, and a throw aborts", async () => {
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const scoped: string[] = []; // each hook is handed a context built for its own plugin
|
|
||||||
const bootContextFor = (built: Plugin) => { scoped.push(built.id); return {}; };
|
|
||||||
await runBootHooks([
|
await runBootHooks([
|
||||||
plugin("a", { onBoot: () => void calls.push("a") }),
|
plugin("a", { onBoot: () => void calls.push("a") }),
|
||||||
plugin("b", {}), // no onBoot → skipped
|
plugin("b", {}), // no onBoot → skipped
|
||||||
plugin("c", { onBoot: async () => void calls.push("c") }),
|
plugin("c", { onBoot: async () => void calls.push("c") }),
|
||||||
], bootContextFor);
|
]);
|
||||||
assert.deepEqual(calls, ["a", "c"]);
|
assert.deepEqual(calls, ["a", "c"]);
|
||||||
assert.deepEqual(scoped, ["a", "c"]); // and built only for the plugins that have one
|
|
||||||
|
|
||||||
await assert.rejects(runBootHooks([plugin("x", { onBoot: () => { throw new Error("boom"); } })], () => ({})), /boom/);
|
await assert.rejects(runBootHooks([plugin("x", { onBoot: () => { throw new Error("boom"); } })]), /boom/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("runRequestHooks short-circuits on the first RouteResult (with its plugin); later hooks skipped", async () => {
|
test("runRequestHooks short-circuits on the first RouteResult (with its plugin); later hooks skipped", async () => {
|
||||||
|
|||||||
@@ -4,15 +4,11 @@
|
|||||||
// entirely when no plugin declares the hook, so the no-hooks hot path stays free.
|
// entirely when no plugin declares the hook, so the no-hooks hot path stays free.
|
||||||
|
|
||||||
import type { RequestContext } from "../http/context.ts";
|
import type { RequestContext } from "../http/context.ts";
|
||||||
import type { BootContext, Plugin, RouteResult } from "./plugin.ts";
|
import type { Plugin, RouteResult } from "./plugin.ts";
|
||||||
|
|
||||||
// After discovery, before the server listens. A throw aborts boot. Each hook gets a context built
|
// After discovery, before the server listens. A throw aborts boot.
|
||||||
// for its own plugin, so one plugin is never handed another's storage credentials.
|
export async function runBootHooks(plugins: Plugin[]): Promise<void> {
|
||||||
export async function runBootHooks(plugins: Plugin[], bootContextFor: (plugin: Plugin) => BootContext): Promise<void> {
|
for (const plugin of plugins) await plugin.hooks?.onBoot?.();
|
||||||
for (const plugin of plugins) {
|
|
||||||
const onBoot = plugin.hooks?.onBoot;
|
|
||||||
if (onBoot) await onBoot(bootContextFor(plugin));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before route matching. The first hook to return a RouteResult short-circuits the request — its
|
// Before route matching. The first hook to return a RouteResult short-circuits the request — its
|
||||||
|
|||||||
@@ -5,14 +5,6 @@ import assert from "node:assert/strict";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
import * as api from "./plugin-api.ts";
|
import * as api from "./plugin-api.ts";
|
||||||
|
|
||||||
// Both specifiers must reach one module instance; the Dockerfile symlink is what makes them.
|
|
||||||
test("the barrel resolves by package name to this same module", async () => {
|
|
||||||
const asPackage = await import("@plainpages/plugin-api");
|
|
||||||
|
|
||||||
assert.equal(asPackage.GuardError, api.GuardError);
|
|
||||||
assert.equal(asPackage.definePlugin, api.definePlugin);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("plugin-api re-exports the stable author value surface", () => {
|
test("plugin-api re-exports the stable author value surface", () => {
|
||||||
for (const name of ["definePlugin", "can", "check", "GuardError", "requireSession", "parseListQuery", "readFormBody", "CSRF_FIELD", "tracedFetch", "Log", "safeUrl"]) {
|
for (const name of ["definePlugin", "can", "check", "GuardError", "requireSession", "parseListQuery", "readFormBody", "CSRF_FIELD", "tracedFetch", "Log", "safeUrl"]) {
|
||||||
assert.ok(name in api && api[name as keyof typeof api] !== undefined, `missing export: ${name}`);
|
assert.ok(name in api && api[name as keyof typeof api] !== undefined, `missing export: ${name}`);
|
||||||
|
|||||||
@@ -5,10 +5,7 @@
|
|||||||
// a plugin should import from here, never reach into deeper modules. See README.md → Building plugins.
|
// a plugin should import from here, never reach into deeper modules. See README.md → Building plugins.
|
||||||
|
|
||||||
export { definePlugin, isValidPermissionName } from "./plugin.ts";
|
export { definePlugin, isValidPermissionName } from "./plugin.ts";
|
||||||
export type { BootContext, HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
|
export type { 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 { StorageCredentials } from "./storage.ts";
|
|
||||||
export type { RequestContext, User } from "../http/context.ts";
|
export type { RequestContext, User } from "../http/context.ts";
|
||||||
export type { PageChrome } from "../ui/chrome.ts";
|
export type { PageChrome } from "../ui/chrome.ts";
|
||||||
export type { NavNode } from "../ui/nav.ts";
|
export type { NavNode } from "../ui/nav.ts";
|
||||||
|
|||||||
@@ -80,15 +80,12 @@ test("parseSemver follows the semver core, rejecting ranges, prefixes, leading z
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newer-minor/major-mismatch/malformed refuse", () => {
|
test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newer-minor/major-mismatch/malformed refuse", () => {
|
||||||
assert.equal(checkApiVersion(HOST_API_VERSION).level, "ok"); // the host always accepts its own version
|
assert.equal(checkApiVersion(HOST_API_VERSION).level, "ok"); // "1.0.0" vs "1.0.0"
|
||||||
assert.equal(checkApiVersion("1.0.5", "1.0.0").level, "ok"); // patch never affects compatibility
|
assert.equal(checkApiVersion("1.0.5", "1.0.0").level, "ok"); // patch never affects compatibility
|
||||||
assert.equal(checkApiVersion("1.0.0", "1.2.0").level, "warn"); // older minor still runs (additive), nudge to update
|
assert.equal(checkApiVersion("1.0.0", "1.2.0").level, "warn"); // older minor still runs (additive), nudge to update
|
||||||
assert.equal(checkApiVersion("1.3.0", "1.2.0").level, "refuse"); // needs features a newer host has
|
assert.equal(checkApiVersion("1.3.0", "1.2.0").level, "refuse"); // needs features a newer host has
|
||||||
assert.equal(checkApiVersion("2.0.0", "1.5.0").level, "refuse"); // incompatible major (newer)
|
assert.equal(checkApiVersion("2.0.0", "1.5.0").level, "refuse"); // incompatible major (newer)
|
||||||
assert.equal(checkApiVersion("1.0.0", "2.0.0").level, "refuse"); // incompatible major (older)
|
assert.equal(checkApiVersion("1.0.0", "2.0.0").level, "refuse"); // incompatible major (older)
|
||||||
assert.equal(checkApiVersion("0.1.0", "0.1.9").level, "ok"); // pre-1.0 patch is still ignored
|
|
||||||
assert.equal(checkApiVersion("0.1.0", "0.2.0").level, "refuse"); // pre-1.0 the minor IS the breaking slot
|
|
||||||
assert.match(checkApiVersion("0.2.0", "0.1.0").message, /upgrade the host/); // ahead of the host, even pre-1.0
|
|
||||||
for (const bad of ["1", "1.2", "v1.2.3", "01.2.3", "1.2.x", "", 1, undefined, null]) {
|
for (const bad of ["1", "1.2", "v1.2.3", "01.2.3", "1.2.x", "", 1, undefined, null]) {
|
||||||
assert.equal(checkApiVersion(bad).level, "refuse", `${String(bad)} must refuse`);
|
assert.equal(checkApiVersion(bad).level, "refuse", `${String(bad)} must refuse`);
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-43
@@ -1,15 +1,17 @@
|
|||||||
// The plugin contract — the product's main API surface: the machine-readable types + pure rules.
|
// The plugin contract — the product's main API surface: the machine-readable types +
|
||||||
// README → Building plugins is the prose reference; discovery/router wire this to FS + HTTP.
|
// 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.
|
||||||
//
|
//
|
||||||
// A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount =
|
// A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount =
|
||||||
// `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice.
|
// `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice.
|
||||||
|
|
||||||
import type { RequestContext } from "../http/context.ts";
|
import type { RequestContext } from "../http/context.ts";
|
||||||
import type { NavNode } from "../ui/nav.ts";
|
import type { NavNode } from "../ui/nav.ts";
|
||||||
import type { StorageCredentials } from "./storage.ts";
|
|
||||||
|
|
||||||
// The Plainpages release this contract ships in — see README → Contract versioning.
|
// Host contract version (semver). Bump major on a breaking manifest/handler change, minor on an
|
||||||
export const HOST_API_VERSION = "0.1.0";
|
// 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";
|
||||||
|
|
||||||
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
||||||
|
|
||||||
@@ -28,21 +30,24 @@ export interface Route {
|
|||||||
method: HttpMethod;
|
method: HttpMethod;
|
||||||
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
||||||
permission?: string; // coarse gate — the Keto Permission the caller must hold; checked before the handler runs
|
permission?: string; // coarse gate — the Keto Permission the caller must hold; checked before the handler runs
|
||||||
// Same as omitting `permission`, but stated outright so public is a deliberate choice rather than
|
// Mark the page reachable by anyone, signed in or not. The same as omitting `permission`
|
||||||
// a forgotten gate. Mutually exclusive with `permission` (discovery refuses both).
|
// — 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).
|
||||||
public?: boolean;
|
public?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A Keto Permission this plugin gates on — declared for docs/seeding. Names are a shared global
|
// A Keto Permission this plugin gates on — declared for docs/seeding. Permission names are a shared
|
||||||
// namespace, so an operator grants them once in Keto. See README → Users, groups & permissions.
|
// 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).
|
||||||
export interface PermissionDecl {
|
export interface PermissionDecl {
|
||||||
description?: string;
|
description?: string;
|
||||||
name: string;
|
name: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// `<resource>:<action>`. The 64-char cap keeps a name usable as a Keto object and a URL path
|
// `<resource>:<action>`, each half lowercase alphanumeric with dashes/underscores inside. The 64-char
|
||||||
// segment. Enforced at discovery like every other manifest rule, so the convention holds for
|
// cap keeps a name usable as a Keto object and a URL path segment. Enforced at discovery like every
|
||||||
// plugins the admin GUI never touches.
|
// 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_-]*$/;
|
const PERMISSION_NAME = /^[a-z0-9][a-z0-9_-]*:[a-z0-9][a-z0-9_-]*$/;
|
||||||
|
|
||||||
export function isValidPermissionName(name: string): boolean {
|
export function isValidPermissionName(name: string): boolean {
|
||||||
@@ -61,14 +66,9 @@ export function declaredPermissions(plugins: Plugin[]): PermissionDecl[] {
|
|||||||
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
// What onBoot receives. A hook declaring no parameter stays valid, so this may grow additively.
|
|
||||||
export interface BootContext {
|
|
||||||
storage?: StorageCredentials; // this plugin's own database; present iff the manifest declared `storage`
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
|
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
|
||||||
export interface PluginHooks {
|
export interface PluginHooks {
|
||||||
onBoot?: (host: BootContext) => Promise<void> | void; // after discovery, before the server listens
|
onBoot?: () => Promise<void> | void; // after discovery, before the server listens
|
||||||
onRequest?: (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void; // may short-circuit
|
onRequest?: (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void; // may short-circuit
|
||||||
onResponse?: (ctx: RequestContext, result: RouteResult | null) => Promise<void> | void;
|
onResponse?: (ctx: RequestContext, result: RouteResult | null) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
@@ -77,18 +77,17 @@ export interface PluginHooks {
|
|||||||
// host derives them from the folder name at discovery (see Plugin).
|
// host derives them from the folder name at discovery (see Plugin).
|
||||||
export interface PluginManifest {
|
export interface PluginManifest {
|
||||||
apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs)
|
apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs)
|
||||||
// Take over "/dashboard"; the host gates it to a signed-in session first. At most one plugin may
|
// Take over the gated dashboard "/dashboard" — the post-login app home. A handler like any
|
||||||
// declare it (findConflicts → error, never last-write-wins).
|
// 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).
|
||||||
dashboard?: RouteHandler;
|
dashboard?: RouteHandler;
|
||||||
// Take over the ungated public landing "/". At most one plugin may declare it.
|
// 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).
|
||||||
home?: RouteHandler;
|
home?: RouteHandler;
|
||||||
hooks?: PluginHooks;
|
hooks?: PluginHooks;
|
||||||
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
|
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
|
||||||
permissions?: PermissionDecl[];
|
permissions?: PermissionDecl[];
|
||||||
routes?: Route[];
|
routes?: Route[];
|
||||||
// Ask for a Postgres database of this plugin's own; its credentials arrive on onBoot's BootContext.
|
|
||||||
// The host provisions and locks it down but owns no schema inside it, and never drops it.
|
|
||||||
storage?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// A discovered plugin: the manifest plus the `id` the host read from the folder name. Mounted
|
// A discovered plugin: the manifest plus the `id` the host read from the folder name. Mounted
|
||||||
@@ -97,23 +96,27 @@ export interface Plugin extends PluginManifest {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Types the manifest and returns it unchanged; validation happens at discovery, so a plugin may
|
// Identity helper: types the manifest, returns it unchanged. Validation happens at discovery
|
||||||
// equally be a plain typed object.
|
//, so a plugin may equally be a plain typed object. Mirrors Vite's `defineConfig`.
|
||||||
export function definePlugin(manifest: PluginManifest): PluginManifest {
|
export function definePlugin(manifest: PluginManifest): PluginManifest {
|
||||||
return manifest;
|
return manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The id forms the mount path `/<id>`, the view/static namespace and the central-override target,
|
// A plugin id (its folder name) — lowercase a–z, digits, and dashes, dashes allowed anywhere.
|
||||||
// so it must stay URL/path-safe: no uppercase, underscores, dots, slashes or spaces.
|
// 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.
|
||||||
const PLUGIN_ID = /^[a-z0-9-]+$/;
|
const PLUGIN_ID = /^[a-z0-9-]+$/;
|
||||||
|
|
||||||
export function isValidPluginId(id: string): boolean {
|
export function isValidPluginId(id: string): boolean {
|
||||||
return PLUGIN_ID.test(id);
|
return PLUGIN_ID.test(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Plugin routes resolve before the built-ins, so a folder named one of these would silently shadow
|
// Ids the host reserves for its own first-party mount segments (the gated /dashboard, the auth flows,
|
||||||
// one — discovery refuses it. "/" is owned by the `home` field, not a route, so it needs no
|
// /auth/complete, /logout, the /oauth2 provider routes, the /public/ static). Plugin routes resolve
|
||||||
// reservation; `admin` is deliberately absent, the admin screens being a drop-in plugin.
|
// 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.
|
||||||
export const RESERVED_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
export const RESERVED_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||||
"auth", "dashboard", "login", "logout", "oauth2", "public", "recovery", "registration", "settings", "verification",
|
"auth", "dashboard", "login", "logout", "oauth2", "public", "recovery", "registration", "settings", "verification",
|
||||||
]);
|
]);
|
||||||
@@ -124,12 +127,14 @@ export interface Semver {
|
|||||||
patch: number;
|
patch: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The official semver.org 2.0.0 core regex. Only major/minor drive compatibility, so the
|
// The official semver.org 2.0.0 core regex (major.minor.patch, optional prerelease/build) — a
|
||||||
// prerelease/build groups are matched to accept valid input but otherwise ignored.
|
// 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.
|
||||||
const SEMVER =
|
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-]+)*)?$/;
|
/^(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-]+)*)?$/;
|
||||||
|
|
||||||
// Rejects ranges/prefixes (`^1.2.3`, `v1`), leading zeros and missing parts — fail loud over coerce.
|
// 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.
|
||||||
export function parseSemver(version: unknown): Semver | null {
|
export function parseSemver(version: unknown): Semver | null {
|
||||||
if (typeof version !== "string") return null;
|
if (typeof version !== "string") return null;
|
||||||
const m = SEMVER.exec(version);
|
const m = SEMVER.exec(version);
|
||||||
@@ -142,8 +147,9 @@ export interface VersionCheck {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider/consumer semver check (full table in README → Contract versioning). Discovery maps
|
// Provider/consumer semver check (full table in README.md → Contract versioning): same major+minor → ok,
|
||||||
// refuse→throw, warn→log.
|
// plugin minor < host → warn, else (newer minor, major mismatch, malformed) → refuse. Patch is
|
||||||
|
// ignored. Discovery maps refuse→throw, warn→log.
|
||||||
export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HOST_API_VERSION): VersionCheck {
|
export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HOST_API_VERSION): VersionCheck {
|
||||||
const plugin = parseSemver(pluginVersion);
|
const plugin = parseSemver(pluginVersion);
|
||||||
const host = parseSemver(hostVersion);
|
const host = parseSemver(hostVersion);
|
||||||
@@ -158,11 +164,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
|
|||||||
return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` };
|
return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` };
|
||||||
}
|
}
|
||||||
if (plugin.minor < host.minor) {
|
if (plugin.minor < host.minor) {
|
||||||
// Pre-1.0 the major is pinned at 0, so a minor is the only slot a breaking change can use.
|
return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — newer features available` };
|
||||||
if (host.major === 0) {
|
|
||||||
return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — pre-1.0 a minor is a contract break, rebuild against ${hostVersion}` };
|
|
||||||
}
|
|
||||||
return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — built against an older release` };
|
|
||||||
}
|
}
|
||||||
return { level: "ok", message: `apiVersion ${pluginVersion}` };
|
return { level: "ok", message: `apiVersion ${pluginVersion}` };
|
||||||
}
|
}
|
||||||
@@ -174,8 +176,9 @@ export interface PluginConflict {
|
|||||||
plugins: string[]; // unique ids involved
|
plugins: string[]; // unique ids involved
|
||||||
}
|
}
|
||||||
|
|
||||||
// Loud resolution, never last-write-wins: discovery throws on any "error" and logs every "warn".
|
// The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered
|
||||||
// Mount-path uniqueness needs no rule of its own — it follows from the id check. Shared permission
|
// 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
|
||||||
// names are the one intentional overlap, so they warn rather than error.
|
// names are the one intentional overlap, so they warn rather than error.
|
||||||
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||||
const out: PluginConflict[] = [];
|
const out: PluginConflict[] = [];
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,220 +0,0 @@
|
|||||||
// 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
// 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
|
// System capabilities: privileged host services a first-party/system plugin (the built-in admin
|
||||||
// screens are the reference consumer) needs but an ordinary domain plugin does not — the Ory admin
|
// screens are the reference consumer) needs but an ordinary domain plugin does not — the Ory admin
|
||||||
// clients and the instant-revoke hook. Exposed on ctx.system and re-exported via @plainpages/plugin-api.
|
// clients and the instant-revoke hook. Exposed on ctx.system and re-exported via #plugin-api.
|
||||||
//
|
//
|
||||||
// Every field is optional: it is present only when the host wired that dependency (Ory configured,
|
// Every field is optional: it is present only when the host wired that dependency (Ory configured,
|
||||||
// denylist enabled), and ctx.system itself is undefined when the host wired none. A plugin must
|
// denylist enabled), and ctx.system itself is undefined when the host wired none. A plugin must
|
||||||
@@ -10,10 +10,11 @@ import type { HydraAdmin } from "../auth/hydra-admin.ts";
|
|||||||
import type { KetoClient } from "../auth/keto-client.ts";
|
import type { KetoClient } from "../auth/keto-client.ts";
|
||||||
import type { KratosAdmin } from "../auth/kratos-admin.ts";
|
import type { KratosAdmin } from "../auth/kratos-admin.ts";
|
||||||
|
|
||||||
// Keep this cohesive — it is a contract, so the "no catch-all bucket" rule applies: every field is a
|
// Grouping criterion (keep this cohesive — it's a contract, so the "no catch-all bucket" rule that
|
||||||
// *privileged, host-owned, wire-dependent* capability for administering Plainpages' own
|
// governs folders governs this bag too): every field is a *privileged, host-owned, wire-dependent*
|
||||||
// identity/permission stack. Add one only when it meets all three; sub-group rather than pile in
|
// capability for administering Plainpages' own identity/permission stack. Add a field only when it
|
||||||
// unrelated privileged concerns (mailer, metrics, flags).
|
// meets all three; if unrelated privileged concerns accrete (mailer, metrics, flags), sub-group
|
||||||
|
// rather than pile them in flat.
|
||||||
export interface SystemCapabilities {
|
export interface SystemCapabilities {
|
||||||
hydra?: HydraAdmin; // OAuth2 client admin (Hydra); present when the Hydra admin client is wired
|
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
|
keto?: KetoClient; // relationship read/write (Keto); present when Keto is wired
|
||||||
|
|||||||
+2
-34
@@ -3,46 +3,14 @@
|
|||||||
// verified by booting postgres in CI/e2e; this catches edits.
|
// verified by booting postgres in CI/e2e; this catches edits.
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { readdirSync, readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
|
|
||||||
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
|
const read = (p: string) => readFileSync(new URL(`../${p}`, import.meta.url), "utf8");
|
||||||
const ORY_DATABASES = ["hydra", "keto", "kratos"]; // one DB per Ory service
|
const ORY_DATABASES = ["hydra", "keto", "kratos"]; // one DB per Ory service
|
||||||
|
|
||||||
function sourceFiles(dir = "src"): string[] {
|
test("init SQL gives each Ory service its own database", () => {
|
||||||
const out: string[] = [];
|
|
||||||
for (const entry of readdirSync(new URL(`../${dir}/`, import.meta.url), { withFileTypes: true })) {
|
|
||||||
if (entry.isDirectory()) out.push(...sourceFiles(`${dir}/${entry.name}`));
|
|
||||||
else if (entry.name.endsWith(".ts")) out.push(`${dir}/${entry.name}`);
|
|
||||||
}
|
|
||||||
return out.sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
test("init SQL gives each Ory service its own database, and leaves plugin databases to bootstrap", () => {
|
|
||||||
const sql = read("ory/postgres/init/init.sql");
|
const sql = read("ory/postgres/init/init.sql");
|
||||||
for (const db of ORY_DATABASES) {
|
for (const db of ORY_DATABASES) {
|
||||||
assert.match(sql, new RegExp(`CREATE DATABASE ${db}\\b`, "i"), `creates ${db}`);
|
assert.match(sql, new RegExp(`CREATE DATABASE ${db}\\b`, "i"), `creates ${db}`);
|
||||||
}
|
}
|
||||||
// This file runs once, on an empty data dir — a plugin database added here would never appear for
|
|
||||||
// a plugin dropped in later. bootstrap provisions them on every boot instead.
|
|
||||||
assert.doesNotMatch(sql, /plugin_/i, "no plugin database is seeded here");
|
|
||||||
// PUBLIC keeps CONNECT unless it is revoked, which would put every plugin role on the auth plane.
|
|
||||||
for (const db of ORY_DATABASES) {
|
|
||||||
assert.match(sql, new RegExp(`REVOKE CONNECT ON DATABASE ${db} FROM PUBLIC`, "i"), `${db} is closed to PUBLIC`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// AGENTS.md records that the driver runs the provisioning DDL in bootstrap and nothing else. A
|
|
||||||
// single value imported from the wrong module puts it in web's graph without changing behaviour,
|
|
||||||
// so nothing but this would notice.
|
|
||||||
test("the Postgres driver reaches bootstrap only, never web's import graph", () => {
|
|
||||||
const files = sourceFiles();
|
|
||||||
assert.ok(files.length > 40, "walks the source tree");
|
|
||||||
assert.deepEqual(
|
|
||||||
files.filter((f) => /^import .*"postgres"/m.test(read(f))), // an import line, not a mention of one
|
|
||||||
["src/plugin-host/storage-provisioning.ts", "src/plugin-host/storage.test.ts"],
|
|
||||||
);
|
|
||||||
assert.deepEqual(
|
|
||||||
files.filter((f) => !f.endsWith(".test.ts") && /from "[^"]*storage-provisioning\.ts"/.test(read(f))),
|
|
||||||
["src/auth/bootstrap.ts"],
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
+2
-29
@@ -2,7 +2,6 @@ import { createApp } from "./http/app.ts";
|
|||||||
import { loadConfig } from "./config.ts";
|
import { loadConfig } from "./config.ts";
|
||||||
import { createDenylist } from "./auth/denylist.ts";
|
import { createDenylist } from "./auth/denylist.ts";
|
||||||
import { discoverPlugins } from "./plugin-host/discovery.ts";
|
import { discoverPlugins } from "./plugin-host/discovery.ts";
|
||||||
import { HOST_API_VERSION } from "./plugin-host/plugin.ts";
|
|
||||||
import { withTimeout } from "./auth/fetch-timeout.ts";
|
import { withTimeout } from "./auth/fetch-timeout.ts";
|
||||||
import { runBootHooks } from "./plugin-host/hooks.ts";
|
import { runBootHooks } from "./plugin-host/hooks.ts";
|
||||||
import { createHydraAdmin } from "./auth/hydra-admin.ts";
|
import { createHydraAdmin } from "./auth/hydra-admin.ts";
|
||||||
@@ -14,13 +13,8 @@ import { createKratosAdmin } from "./auth/kratos-admin.ts";
|
|||||||
import { createKratosPublic } from "./auth/kratos-public.ts";
|
import { createKratosPublic } from "./auth/kratos-public.ts";
|
||||||
import { createLogger, tracedFetch } from "./logger.ts";
|
import { createLogger, tracedFetch } from "./logger.ts";
|
||||||
import { loadMenuConfig } from "./ui/menu-config.ts";
|
import { loadMenuConfig } from "./ui/menu-config.ts";
|
||||||
import { buildCredentials, storagePluginIds, type StorageCredentials } from "./plugin-host/storage.ts";
|
|
||||||
|
|
||||||
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
||||||
// The storage secret is in `config` now, so drop it from the environment before ANY plugin code
|
|
||||||
// runs: a plugin module's top level evaluates during discovery, long before onBoot. Defence in
|
|
||||||
// depth, not a boundary (AGENTS.md) — and only ever move this line earlier, never later.
|
|
||||||
delete process.env["PLUGIN_DB_SECRET"];
|
|
||||||
// App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
|
// App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
|
||||||
// per request for access logging + a trace span (src/http/app.ts); console-only otherwise.
|
// per request for access logging + a trace span (src/http/app.ts); console-only otherwise.
|
||||||
const log = createLogger({ format: config.logFormat, level: config.logLevel, otlpEndpoint: config.otlpEndpoint, otlpProtocol: config.otlpProtocol, serviceName: config.serviceName });
|
const log = createLogger({ format: config.logFormat, level: config.logLevel, otlpEndpoint: config.otlpEndpoint, otlpProtocol: config.otlpProtocol, serviceName: config.serviceName });
|
||||||
@@ -50,28 +44,7 @@ log.info("plugins discovered", { count: plugins.length, ids: plugins.map((p) =>
|
|||||||
const i18n = createI18n(await loadI18n({ logger: log, pluginIds: plugins.map((p) => p.id) }));
|
const i18n = createI18n(await loadI18n({ logger: log, pluginIds: plugins.map((p) => p.id) }));
|
||||||
log.info("locales loaded", { locales: i18n.available.join(", ") });
|
log.info("locales loaded", { locales: i18n.available.join(", ") });
|
||||||
|
|
||||||
// A plugin's database credentials are derived, never stored — so the only thing that can be missing
|
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
|
||||||
// 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));
|
|
||||||
}
|
|
||||||
// onBoot is the only way credentials are handed over, so without one the database is provisioned
|
|
||||||
// and unreachable. A warning, not a refusal — the plugin still works, it just cannot store anything.
|
|
||||||
const unreachable = plugins.filter((plugin) => plugin.storage && !plugin.hooks?.onBoot).map((plugin) => plugin.id);
|
|
||||||
if (unreachable.length > 0) log.warn("plugins declare storage but have no onBoot to receive it", { plugins: unreachable.join(", ") });
|
|
||||||
|
|
||||||
// plugin onBoot — after discovery, before listen; a throw aborts boot.
|
|
||||||
await runBootHooks(plugins, (plugin) => {
|
|
||||||
const storage = storageCredentials.get(plugin.id);
|
|
||||||
return storage ? { storage } : {};
|
|
||||||
});
|
|
||||||
|
|
||||||
const server = createApp({
|
const server = createApp({
|
||||||
// Canonical-host redirect target (off-host GET/HEAD visitors are sent here). Opt-in: omitted unless
|
// Canonical-host redirect target (off-host GET/HEAD visitors are sent here). Opt-in: omitted unless
|
||||||
@@ -92,7 +65,7 @@ const server = createApp({
|
|||||||
plugins,
|
plugins,
|
||||||
secureCookies: config.secureCookies,
|
secureCookies: config.secureCookies,
|
||||||
}).listen(config.port, () => {
|
}).listen(config.port, () => {
|
||||||
log.info("listening", { apiVersion: HOST_API_VERSION, port: config.port, url: config.appUrl ?? `http://localhost:${config.port}` });
|
log.info("listening", { port: config.port, url: config.appUrl ?? `http://localhost:${config.port}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drain in-flight requests on container stop instead of cutting them mid-response, then flush any
|
// Drain in-flight requests on container stop instead of cutting them mid-response, then flush any
|
||||||
|
|||||||
+14
-6
@@ -1,6 +1,9 @@
|
|||||||
// The brand / global-nav / user / theme / csrf block a view hands to partials/shell, exposed on
|
// Page chrome for plugin pages: the brand / global-nav / user / theme / csrf block a
|
||||||
// ctx.chrome. `nav` is the global menu — Dashboard plus every plugin's fragment — run through
|
// plugin view hands to partials/shell so its page looks native — the same shell the dashboard and
|
||||||
// composeNav (override + per-user filter) and current-marked for the request path.
|
// 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.
|
||||||
|
|
||||||
import type { User } from "../http/context.ts";
|
import type { User } from "../http/context.ts";
|
||||||
import { ENGLISH } from "../i18n/english.ts";
|
import { ENGLISH } from "../i18n/english.ts";
|
||||||
@@ -10,6 +13,9 @@ import { composeNav, type NavNode } from "./nav.ts";
|
|||||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||||
import { branding, shellUser, type ShellUser } from "./shell-context.ts";
|
import { branding, shellUser, type ShellUser } from "./shell-context.ts";
|
||||||
|
|
||||||
|
// 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" };
|
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard" };
|
||||||
|
|
||||||
export interface PageChrome {
|
export interface PageChrome {
|
||||||
@@ -35,11 +41,13 @@ export interface ChromeOptions {
|
|||||||
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||||
const t = opts.t ?? ENGLISH;
|
const t = opts.t ?? ENGLISH;
|
||||||
const carryLocale = opts.localeHref ?? ((href: string) => href);
|
const carryLocale = opts.localeHref ?? ((href: string) => href);
|
||||||
// Dashboard is gated, so an anonymous click would only dead-end at /login.
|
// 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).
|
||||||
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
|
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
|
||||||
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
|
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
|
||||||
// translator before merging. composeNav then runs the core one over the result; already-translated
|
// translator before they are merged. composeNav then runs the core one over the result for the
|
||||||
// text passes through it.
|
// built-in nodes and the central override's labels; already-translated text passes through it.
|
||||||
for (const p of opts.plugins ?? []) {
|
for (const p of opts.plugins ?? []) {
|
||||||
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
|
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,16 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { test, type TestContext } from "node:test";
|
import { test, type TestContext } from "node:test";
|
||||||
import { DEFAULT_MENU, loadMenuConfig } from "./menu-config.ts";
|
import { DEFAULT_MENU, loadMenuConfig } from "./menu-config.ts";
|
||||||
|
|
||||||
// Write a throwaway menu.ts (a plain object — defineMenu is identity) and clean it up after.
|
// Write a throwaway menu.ts (a plain object — defineMenu is identity) and clean it up after.
|
||||||
function scaffold(t: TestContext, source: string, strays: string[] = []): string {
|
function scaffold(t: TestContext, source: string): string {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "pp-menu-"));
|
const dir = mkdtempSync(join(tmpdir(), "pp-menu-"));
|
||||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||||
const file = join(dir, "menu.ts");
|
const file = join(dir, "menu.ts");
|
||||||
writeFileSync(file, source);
|
writeFileSync(file, source);
|
||||||
for (const stray of strays) {
|
|
||||||
if (stray.endsWith(".json")) writeFileSync(join(dir, stray), "{}");
|
|
||||||
else mkdirSync(join(dir, stray), { recursive: true });
|
|
||||||
}
|
|
||||||
return file;
|
return file;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,14 +37,3 @@ test("loadMenuConfig fails loud on a malformed config", async (t) => {
|
|||||||
await assert.rejects(loadMenuConfig({ file: scaffold(t, `export default { branding: { theme: "neon" } };`) }), /theme/);
|
await assert.rejects(loadMenuConfig({ file: scaffold(t, `export default { branding: { theme: "neon" } };`) }), /theme/);
|
||||||
await assert.rejects(loadMenuConfig({ file: scaffold(t, `export default { override: { hide: "teams" } };`) }), /hide.*array/s);
|
await assert.rejects(loadMenuConfig({ file: scaffold(t, `export default { override: { hide: "teams" } };`) }), /hide.*array/s);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("loadMenuConfig refuses a stray package.json or node_modules beside the config", async (t) => {
|
|
||||||
const valid = `export default { branding: { name: "Acme Ops" } };`;
|
|
||||||
|
|
||||||
for (const stray of ["node_modules", "package.json"]) {
|
|
||||||
await assert.rejects(
|
|
||||||
loadMenuConfig({ file: scaffold(t, valid, [stray]) }),
|
|
||||||
new RegExp(`config/${stray.replace(".", "\\.")} must not exist.*delete`, "s"),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -50,14 +50,6 @@ export async function loadMenuConfig(options: LoadMenuOptions = {}): Promise<Men
|
|||||||
const file = options.file ?? MENU_CONFIG_FILE;
|
const file = options.file ?? MENU_CONFIG_FILE;
|
||||||
if (!existsSync(file)) return DEFAULT_MENU; // clean clone: no central override
|
if (!existsSync(file)) return DEFAULT_MENU; // clean clone: no central override
|
||||||
|
|
||||||
// Guarded before the import: Node's own ERR_PACKAGE_IMPORT_NOT_DEFINED names neither cause nor remedy.
|
|
||||||
const dir = dirname(file);
|
|
||||||
for (const stray of ["node_modules", "package.json"]) {
|
|
||||||
if (existsSync(join(dir, stray))) {
|
|
||||||
throw new Error(`config/${stray} must not exist — it makes config/ its own package scope, so the #menu-config import in config/menu.ts no longer resolves; delete config/{node_modules,package.json,package-lock.json} and keep config/ a plain dir`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let mod: { default?: unknown };
|
let mod: { default?: unknown };
|
||||||
try {
|
try {
|
||||||
mod = await import(pathToFileURL(file).href);
|
mod = await import(pathToFileURL(file).href);
|
||||||
|
|||||||
+7
-4
@@ -1,7 +1,10 @@
|
|||||||
// composeNav: merge each plugin's nav fragment into one tree, apply the central override, then
|
// composeNav: merge each plugin's nav fragment into one tree, apply the central
|
||||||
// permission-filter per user. Pure and I/O-free — menu gating reads the JWT `permissions` claim,
|
// override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT
|
||||||
// never Keto. A node is visible iff it is `public`, declares no `permission`, or the user holds that
|
// `permissions` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
|
||||||
// name; a gated header hides its whole subtree, and a pure header left with no children is dropped.
|
// declares no `permission`, or `permissions` includes that permission name; a gated header hides its whole
|
||||||
|
// subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
|
||||||
|
// the override (+ branding); this helper only transforms data, so its result is per-deployment
|
||||||
|
// up to the final permission filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
|
||||||
|
|
||||||
import { ENGLISH } from "../i18n/english.ts";
|
import { ENGLISH } from "../i18n/english.ts";
|
||||||
import type { Translate } from "../i18n/translate.ts";
|
import type { Translate } from "../i18n/translate.ts";
|
||||||
|
|||||||
@@ -2,71 +2,73 @@
|
|||||||
|
|
||||||
## Unfinnished work
|
## Unfinnished work
|
||||||
|
|
||||||
- [ ] Add a way to configure plugins directly when installing. **Decided: the manifest declares it, not an `.env`** — a declared schema is validatable at boot, so a missing or mistyped setting fails loud and named the way a stray `package.json` now does, and the picker/docs can be generated from the declaration. Open: where the operator *supplies* the values (env var per key, a `config/` file, or both), and whether a secret may be declared at all.
|
- [ ] Add a way to configure plugins directly when installing. Most reasonable is an .env file in the plugin folder, I think, but I am open to suggestions.
|
||||||
- [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin".
|
- [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin".
|
||||||
- [ ] Guard the group paths against self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query.
|
- [ ] Guard the group paths to self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Same scope the deleted Permissions screen had, and recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query. Raised by the stability review 2026-08-05.
|
||||||
- [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change. Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying.
|
- [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change (standard lost-update on a set-based form — and the natural "two of us are onboarding the new hire" workflow produces exactly it). Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying. Fits the existing "the form is the whole truth" model instead of fighting it. Raised by the product review 2026-08-05.
|
||||||
- [ ] A grant whose plugin was uninstalled is invisible and unremovable in the GUI. `grantDiff` deliberately never revokes an undeclared name, but nothing *shows* it either — so it can't be audited or cleaned, and reinstalling that plugin silently reactivates access nobody remembers granting. Sketch: a read-only "held, but no installed plugin offers this" list with a remove action.
|
- [ ] A grant whose plugin was uninstalled is invisible and unremovable in the GUI. `grantDiff` deliberately never revokes an undeclared name (so an unrelated save can't drop it), but nothing *shows* it either — so it can't be audited or cleaned, and reinstalling that plugin silently reactivates access nobody remembers granting. Sketch: a read-only "held, but no installed plugin offers this" list with a remove action. Raised by the product review 2026-08-05.
|
||||||
- [ ] A plugin may gate a route on a permission it never declares — declaring stays optional on purpose. The cost is a dead end: the picker is built from declarations only, so that route is ungrantable from the GUI with no boot error and a permanent 403 as the operator's only clue. Sketch: a discovery *warning* (not an error) naming the gated-but-undeclared permission.
|
- [ ] A plugin may gate a route on a permission it never declares — declaring stays optional on purpose (mandatory declaration would warn on the legitimate cross-plugin sharing case). The cost is a dead end: the picker is built from declarations only, so that route is ungrantable from the GUI with no boot error, no warning, and a permanent 403 as the operator's only clue. Sketch: a discovery *warning* (not an error) naming the gated-but-undeclared permission. Raised by the product review 2026-08-05.
|
||||||
- [ ] Saving permissions gives no confirmation, and a partial failure is silent. `applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a half-applied set behind the generic error page; and a successful save is indistinguishable from "nothing changed". The `alert alert-pos` pattern the recovery-code banner uses is already available.
|
- [ ] Saving permissions gives no confirmation, and a partial failure is silent. `applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a half-applied set behind the generic error page; and a *successful* save is indistinguishable from "nothing changed" (PRG back to the same page, checkboxes as the only feedback). The `alert alert-pos` pattern the recovery-code banner uses is already available. Raised by the product review 2026-08-05.
|
||||||
|
- [ ] Add the read-only operator to README → Overview's personas. `users:read` now makes a support/helpdesk account possible for the first time, and it is a distinct persona from the three listed (end user, non-technical user, plugin author) — the one whose screens must render without write affordances. Writing it down makes read-only rendering a stated requirement rather than something the next reviewer rediscovers. Raised by the product review 2026-08-05.
|
||||||
|
- [ ] The seeded admin@plainpages.local are assigned twice to the permission "admin", should only be one, right? (the "admin" permission name can be switched after previous todos have been done)
|
||||||
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
|
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
|
||||||
- [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks its most logic-bearing file (`console-guard.ts`). Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither.
|
- [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks the most logic-bearing file in it (`console-guard.ts`) — Playwright strips its types without checking them. Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither. Raised by review 2026-08-05.
|
||||||
- [ ] Decide whether Playwright's `workers` should be pinned. Unset, it sizes the pool from `os.cpus()`, which reports the host's cores regardless of a container CPU quota — and with `retries: 0` a starved runner turns a slow test straight into a red gate. Fine on the current act_runner; revisit if CI ever runs constrained.
|
- [ ] Decide whether Playwright's `workers` should be pinned. It is unset, so Playwright sizes the pool from `os.cpus()`, which reports the host's cores regardless of a container CPU quota — and with `retries: 0` a starved runner turns a slow test straight into a red gate rather than a retry. Fine on the current act_runner; revisit if CI ever runs constrained. Raised by review 2026-08-05.
|
||||||
- [ ] Record the browser floor Plainpages requires, and whether the fallback is the contract or a courtesy. The stylesheet needs `:has()` (Dec 2023); the menus need the popover API (Safari 17) and CSS anchor positioning for placement (newer still, and unguarded — the `@supports` test covers popover only). An iPadOS 16 tablet, capped at Safari 16, therefore gets panels flowing inline rather than working menus. Either state a supported floor or accept the fallback for those devices; nobody has rendered that path on real hardware.
|
- [ ] Record the browser floor Plainpages actually requires, and whether the fallback is the contract or a courtesy. The stylesheet already needs `:has()` (Dec 2023); the menus now need the popover API (Safari 17, Sep 2023) and CSS anchor positioning for placement (newer still, and unguarded — the `@supports` test covers popover only). An iPadOS 16 tablet — capped at Safari 16, and exactly the "tablet on a factory floor, old thin client at a reception desk" README → Overview sells the zero-JS stance on — therefore gets panels flowing inline rather than working menus. Either state a supported floor in the README or accept the fallback as the answer for those devices; nobody has rendered that path on real hardware. Raised by the architecture review 2026-08-05.
|
||||||
- [ ] Decide whether the profile dropdown still earns a dropdown. It holds one item, Sign out, behind a click, and its "Signed in as X" head repeats what the trigger already shows.
|
- [ ] Decide whether the profile dropdown still earns a dropdown. With the dead Profile link gone it holds one item, Sign out, behind a click — and its "Signed in as X" head only repeats the name and email the trigger already shows. Either put Sign out in the footer directly, or give the menu a second reason to exist. Overlaps the outside-click item above. Raised by review 2026-08-05.
|
||||||
- [ ] Trim whitespace around the verification code — a copy+pasted code from the email fails, today as a browser `pattern` refusal rather than a Kratos rejection, now carrying a digits-only hint so that refusal isn't bare. The paste still fails. **Own session.** No zero-JS fix exists: `pattern` is rejective and cannot transform, so loosening it only lets the untrimmed value through. The real fix is a host-side proxy of the flow POST, which is feasible and half-built — `submitFlow` (`src/auth/kratos-public.ts:132`) already relays cookies and normalises a 422 `redirect_browser_to`, and has no callers outside tests. CSRF improves rather than breaks: the host already writes Kratos' cookie onto its own origin (`src/auth/routes.ts:77`), so the POST becomes same-origin, and `form-action 'self'` could finally join the CSP. Two risks: one `flow.ui.action` serves all five flows, so this rewrites the sign-in path, not just code entry; and Go's nosurf checks Referer only over https, which no test here covers. **Check first:** `kratos-admin.ts:75` returns a `recovery_link` alongside the code — if the stock courier mail carries one (unverified), a template change fixes the UX with no host code. Code entry has no E2E coverage today either way.
|
- [ ] When copy+paste the verification code from the email, it doesn't work because it does not trim whitechars around the code in the form. It should trim automatically.
|
||||||
- [ ] Guard against the double-clicked submit, without client-side JavaScript. A non-technical user clicks a button twice when nothing happens fast enough, so a second identical POST is an expected event, not misuse — today it creates two users, mints two recovery codes, or registers two OAuth2 clients. Constraints: HTML/CSS only, and it must not break an action that is *legitimately* repeatable. Sketch: a CSS-only affordance so the second click has nothing to hit, paired with the host recognising a duplicate on the server — same session, route and payload within a short window — then logging and dropping it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form beats hashing the payload, and the CSRF plumbing already mints per-request tokens), the window length, where the record lives given the app is stateless, and how a plugin declares a route repeatable.
|
- [ ] Guard against the double-clicked submit, without client-side JavaScript. The README's non-technical persona double-clicks a button that doesn't respond instantly, so a second identical POST is an expected event, not misuse — today it creates two users, mints two recovery codes, or registers two OAuth2 clients. Constraints: HTML/CSS only (no client JS — priority: zero-JS spine), and it must not break an action that is *legitimately* repeatable (an increase-by-one button is not a duplicate, it is two increments). Sketch to evaluate: a CSS-only affordance so the second click has nothing to hit (`:active`/`:focus` state, or the submit visually and semantically settling), paired with the host recognising a duplicate on the server — same session, same route, same payload, within a short window — and then logging it and dropping the second rather than replaying it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form is stronger than hashing the payload, and the CSRF plumbing already mints per-request tokens), how long the window is, where the record lives given the app is stateless (in-memory like the revoke denylist, or push it to the upstream the plugin already writes to), and how a plugin declares a route as repeatable — an opt-out on the route, or opt-in per form. Raised 2026-08-04 with the personas.
|
||||||
- [ ] Decide the caching contract for rendered pages. Responses carry `Vary: Accept-Language` but nothing sets `Cache-Control`, so a shared cache has no instruction and a signed-in page is not marked `private`. Either set the headers deliberately (public cacheable, gated `private, no-store`) or record in AGENTS.md that the reverse proxy owns this.
|
- [ ] Decide the caching contract for rendered pages. Responses now carry `Vary: Accept-Language` (they content-negotiate), but nothing sets `Cache-Control` — so a shared cache in front of the app has no instruction, and a signed-in page is not marked `private`. Pre-existing, surfaced by the i18n review 2026-08-03: either set the headers deliberately (public pages cacheable, gated pages `private, no-store`) or record in AGENTS.md that the reverse proxy owns this.
|
||||||
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule applied to namespaces. A design question, not a naming one.
|
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule (`utils`, `helpers`, `misc`) applied to namespaces. Raised 2026-08-03; a design question, not a naming one.
|
||||||
- [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression.
|
- [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. Its comment says "the icons the UI actually references", but `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go the way `i-gear` just did, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression (see AGENTS.md → the `ICON_NAMES` deviation). Pre-existing, surfaced by the review 2026-08-05.
|
||||||
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md and README → Security model; not accepted ⇒ bind the nonce to `sub`.
|
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer.
|
||||||
- [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host**, but nothing here has been run on one. Two suspects, both from the `--user "$(id -u):$(id -g)"` idiom: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer. The same question covers rootless Docker, where README already says to *drop* the flag.
|
- [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host** (maintainer, 2026-08-05), but nothing here has been run on one. Two known suspects, both from the `--user "$(id -u):$(id -g)"` idiom the E2E runner and the lockfile edit share: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer, so "the file belongs to you afterwards" may hold for a different reason or not at all. The same question covers rootless Docker, where README already says to *drop* the flag. Raised by the stability review 2026-08-05.
|
||||||
|
|
||||||
### Architectural review findings (2026-07-02)
|
### Architectural review findings (2026-07-02)
|
||||||
|
|
||||||
Prioritized. Overall verdict: architecture is sound; these are refinements.
|
Prioritized. Overall verdict: architecture is sound (contract-first plugin API, functional core/imperative shell, strong test seams); these are refinements.
|
||||||
|
|
||||||
- [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth.
|
- [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth. Also when wiring CI/CD: keep the merge gate fast (typecheck + units + Ory-free `visual` suite; heavy e2e suites required-but-separate) and make the pipeline the only path to a published image (build once at tag, promote).
|
||||||
- [ ] **LOW — The users list offers a pencil "Edit" row action to a `users:read` holder.** The link is harmless (it opens the read-only detail page), but the label contradicts what the reader can do. Needs `canWrite` threaded into `listTable` plus a `common.view` core catalog key and an `i-eye` entry in `ICON_NAMES`.
|
- [ ] **LOW — The users list offers a pencil "Edit" row action to a `users:read` holder.** The link is harmless (it opens the read-only detail page), but the label contradicts what the reader can do. Needs `canWrite` threaded into `listTable` plus a `common.view` core catalog key and an `i-eye` entry in `ICON_NAMES` — a core registry change for a cosmetic fix, so it was left out of the permission-naming branch. Raised by the stability review 2026-08-05.
|
||||||
- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block.
|
- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen (users, groups, clients, shifts) hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block.
|
||||||
- [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive.
|
- [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field, incl. identical brand-assembly in `chrome.ts` and `shell-context.ts`. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive.
|
||||||
- [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`.
|
- [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`.
|
||||||
- [ ] **LOW — Decide (once) on a `ctx.system` facade.** `@plainpages/plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear.
|
- [ ] **LOW — Decide (once) on a `ctx.system` facade.** `#plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear. Record the decision.
|
||||||
- [ ] **LOW — README/AGENTS.md gaps:** state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population and plugin count — per-plugin dependency isolation prices N dependency trees on disk and in RSS, and that number is what says whether the trade needs revisiting (build-time dedupe for baked images stays open).
|
- [ ] **LOW — README/AGENTS.md gaps:** state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population (first-party vs external) to justify the versioning machinery.
|
||||||
|
|
||||||
## Finnished work
|
## Finnished work
|
||||||
|
|
||||||
- [x] Give a plugin persistent storage: `storage: true` provisions a Postgres database + login role named `plugin_<id>`, credentials arrive on `onBoot`, passwords are derived from `PLUGIN_DB_SECRET` rather than stored.
|
- [x] `e2e-tests/artifacts/` is written `root:root` into the checkout and needs `sudo` to delete — the Playwright container runs as root. Unlike the node_modules mountpoint, a container `user:` would fix it, but compose has no `$UID` of its own (needs an `.env` or `id -u` via `ci.sh`) and CI's artifact upload reads that dir. Found 2026-08-05. (Fixed with the idiom README already uses for a lockfile edit — every documented invocation passes `--user "$(id -u):$(id -g)"`: the five compose headers, the five README blocks and `ci.sh`'s two runs — so the runner writes as whoever started it, on a dev box and on a CI runner alike, and compose never needs a `$UID` of its own. Two things had to come with it. `e2e-tests/artifacts/` is now *tracked* (`.gitkeep`), because an absent bind-mount source is created by the daemon as root and an unprivileged runner then cannot write into it at all — the same trap the node_modules mountpoint hit, one layer up. And the runner image sets `HOME=/tmp` + `npm_config_cache=/tmp/.npm`, since an arbitrary uid has no home in the Playwright image. Baking a `USER` into the image was tried first and dropped: `pwuser` is **1001** in the noble image (uid 1000 is `ubuntu`), so it `EACCES`'d on a 1000-owned checkout, and no fixed uid can match every host. One premise turned out stale — no workflow uploads artifacts, so nothing in CI reads that dir. Verified by running the visual suite as uid 1000: 36 tests green across Chromium, Firefox and WebKit, every file written `lilleman:lilleman` and deletable without `sudo`, which this box does not even have. `src/compose.test.ts` guards every documented command plus the tracked mount point; `src/ci-gate.test.ts` guards the gate's own two.)
|
||||||
- [x] Refuse a stray `package.json`/`node_modules` in `config/` by name, as plugin folders already are.
|
- [x] node_modules gets installed straight to the root folder with root permissions, it should at the very least be same owner as the one running the docker process, or built inside the docker image. (It *was* built in the image; the checkout got an empty root-owned dir — the mountpoint for `compose.override.yml`'s `- /app/node_modules` volume. Re-owning it is impossible (the daemon creates mount destinations as root whatever `--user` says), so deps moved to `/node_modules` above `WORKDIR /app` and the volume is gone. See AGENTS.md.)
|
||||||
- [x] Let Renovate reach the example plugins' manifests (`ignorePaths` overrides `config:recommended`).
|
- [x] Document permissions format so it is folled going forward: <resource>:<action>, for example scheduling:read. Permission "admin" does not match this, and needs to be users:read, users:write, groups:read, groups:write. (README → [Naming a permission](README.md#naming-a-permission) is the one home for the rule, and the host *enforces* it at discovery — `isValidPermissionName` in `src/plugin-host/plugin.ts`, checked by `shapeError` over every route/nav `permission` and every declared name — so a badly-named permission stops the boot like any other bad manifest, for every plugin rather than only ones the admin GUI touches. `admin` is gone, split per screen into `users:`, `groups:` and `oauth2-clients:` × `read`/`write`. The read/write split is real, not cosmetic: `users:read` opens the list and is refused on every POST, and the Admin nav header lost its own gate so each screen is filtered by its own `:read` — hold none of the three and `composeNav` drops the emptied header (which needs the header to carry no `href`, now asserted). Two things had to be fixed to get here. The permission path validator was the *group* regex with no colon, so `/admin/permissions/scheduling:read` already 404'd. And `ADMIN_PERMISSIONS` defaulting to empty exposed that `bootstrap` never bind-mounted `plugins/` at all — it discovered only the image's empty copy, so a dropped-in plugin's permissions were never seeded; the mount lives in `compose.override.yml` (dev-only, mirroring `web`'s `.:/app`) because the base file gives both services the same baked copy and a base-file mount would collide with the e2e stacks that bind plugins *inside* that path. Quick start now says `docker compose up -d`, which re-runs the one-shot. Verified end to end on a live stack.)
|
||||||
- [x] The seeded admin is granted each permission once — `seedPermissions` dedupes and the grant PUT is idempotent.
|
- [x] Permissions should be a list in code. Since no permissions exists in the database out of the box, but there are a fixed number of permissions in the plugins that the end consumer and user of plain pages can use, these permissions must surface to the UI somehow. The effects is that the permissions page should be deleted completely, and the users and groups pages should gain the functionality to add permissions to their things instead, provided the user have the right permissiosn to do so, of course. Run the product reviewer agent on this todo also. (The host collects every installed plugin's declarations into one catalog — `declaredPermissions()` → `ctx.declaredPermissions`, deduped and sorted, computed once at wiring — and that catalog *is* the fixed list. The Permissions screen is deleted outright: its module, tests, three views, two partials and 29 catalog keys per locale. Users and Groups each gained a checkbox list of the catalog, ticked where held; the whole set posts back, so what is submitted is the desired state and `grantDiff` turns it into grants + revokes. Two properties earn their tests: a crafted POST cannot grant a name no plugin declares, and a held-but-undeclared name — left over from an uninstalled plugin — is never silently revoked by an unrelated save, since the picker only speaks for what it showed. A user's own change revokes their live tokens; a group's reaches members at their next re-mint, the documented transitive lag. Keto stays optional on the Users screen: without it the page still lists and edits, minus the picker. Maintainer's call 2026-08-05 to keep the OAuth2-clients screen and gate it `oauth2-clients:read/write` — permissions and OAuth2 are orthogonal, scopes say what an *app* may see and permissions what a *user* may do, so the screen only ever needed *a* gate.)
|
||||||
- [x] Run the E2E runner as the invoking user so its artifacts aren't root-owned.
|
- [x] In Playwright tests, check for warnings and errors in all browsers on all the steps. If they exist, that is a failure we need to fix. (Every spec takes its `test` from `e2e-tests/console-guard.ts`, which watches every page a test opens — `console.error`, `console.warning`, and uncaught page errors — and fails the test that provoked one, at whatever step. The bar is zero rather than a curated tolerance list: the app ships no client JavaScript, so a message means a broken sub-resource, a rejected attribute, or an engine refusing a feature. Two narrow escapes, both explicit: the COOP header Chromium drops because the e2e stacks serve plain http over container hostnames (a deployment serves https, where it applies), and per-test `allowConsole(/…/)` — used once, by the 404 spec, whose own navigation Chromium and WebKit log. **All browsers** is now literal for the Ory-free suites: `visual.spec.ts` + `language.spec.ts` run in Chromium, Firefox *and* WebKit — the per-test `@engines` tag is gone, and screenshots are written per project so the three don't fight over one file — which is what makes an engine-specific message visible at all. The Ory-backed suites write users, groups and sessions to one shared backend, so they stay on Chromium; widening them needs a stack per engine. Nothing in the app had to be fixed: the sweep found only the two above. Verified by negative control — an injected `console.warn` failed the test in all three engines and an injected `console.error` failed on full-flow's shared serial page — which also caught the guard registering that page twice. `src/e2e-console-guard.test.ts` locks the wiring in the *unit* gate, since a spec importing `test` straight from Playwright would run unwatched and green.)
|
||||||
- [x] Install node_modules above `WORKDIR /app` so no mount leaves a root-owned dir in the checkout.
|
- [x] Don't run tests when only markdown files in the root have changed. (Already shipped for *any* `*.md`, anywhere in the tree — `ci.sh`'s `docs_only()` no-ops the gate when every path changed since `main` ends in `.md`, and the workflow still pushes the commit-hash image so a merged docs commit stays releasable. Kept wider than "in the root" deliberately: no test reads a markdown file, 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. What was actually broken was rename detection — `git mv src/app.ts notes.md` names only the destination under `git diff --name-only`, and collapses to a single `R src/app.ts -> notes.md` line under `git status --porcelain`, so **moving code onto a `.md` path skipped the gate over a source file that was gone**. Both channels now pass `--no-renames`; verified against a scratch repo across ten scenarios — docs-only, mixed, empty diff, dirty tree, untracked code, deleted doc, and the rename staged *and* committed — the last two failing before the fix and passing after. `src/ci-gate.test.ts` locks both flags; it stays a text guard because the test image is `node:alpine` with neither `git` nor `bash`.)
|
||||||
- [x] Enforce `<resource>:<action>` permission names at discovery; split `admin` per screen.
|
- [x] The little menues, like when choosing language or clicking my username, they do not dissapear when clicking outside them, I must click the original trigger or choose something. See if there are more modern ways of handling this with HTML and CSS. I think there is a modal-thing or something? (The modern thing is the **Popover API**. All three popup menus — language picker, profile, row kebab — are now a `<button popovertarget>` plus a `[popover]` panel instead of `<details>`/`<summary>`, so the browser owns open/close: clicking anywhere outside dismisses one, `Esc` dismisses it and returns focus to the trigger, opening one closes the others, and the panel sits in the top layer where `.table-wrap`'s `overflow` can no longer clip a row kebab. Placement is CSS anchor positioning; the panel needs `position-anchor: auto` to bind to the button that opened it — a bare `anchor()` resolves to nothing in Chromium, Firefox and WebKit alike, measured in all three before picking the approach. `data-table.ejs` stopped hand-rolling its kebab and calls the `menu` partial, so the pattern lives in one file. Each panel is named by its caller (`locale-menu`, `profile-menu`, `row-actions-1`) and the partial fails loud without an `id`, since `popovertarget` is an idref — generated ids were tried first and dropped for being unreadable and nondeterministic. `<details>` stays in the nav tree, where it means disclosure rather than popup. A browser older than the popover API flows each panel inline under its trigger, so Sign out is never stranded behind an inert button. `e2e-tests/visual.spec.ts` drives the whole behaviour — opens, anchored to its trigger, outside-click, Esc — and runs in Firefox and WebKit as well as Chromium, because CSS anchor positioning is the newest thing in the app and every popup rests on it. Decisions recorded in AGENTS.md.)
|
||||||
- [x] Make the declared-permission catalog the fixed list; delete the Permissions screen, move granting onto Users and Groups.
|
- [x] Organize the files in src in to folders so it is easier to understand the structure of the code.
|
||||||
- [x] Fail a Playwright test on any browser console warning, error or uncaught exception, in every engine.
|
|
||||||
- [x] Skip the CI gate when only markdown changed.
|
|
||||||
- [x] Replace the `<details>` popup menus with the Popover API so an outside click dismisses them.
|
|
||||||
- [x] Organize the files in src into folders.
|
|
||||||
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
|
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
|
||||||
- [x] Move the scheduling example out of `plugins/` into `examples/`.
|
- [x] The plugins/scheduling is an example and shouldn't be committed to the plugins directory since that should be empty to be able to be mounted in via docker or other means for the users/develoeprs using this application/framework. Put it in the examples folder instead.
|
||||||
- [x] Make `config/` an empty drop-in mount with the defaults as fallback.
|
- [x] The config folder should be empty and the current settings in the menu.ts should be the fallback default. IF a menu.ts where to appear in that folder, it should override the default settings with whatever is in it. The idea is the folder should be empty by default and you mount it in your docker container with your config.
|
||||||
- [x] Turn the built-in admin pages into a drop-in example plugin.
|
- [x] Make the internal admin pages for users groups etc into a plugin instead in the examples folder and remove them from the internal source. Add a part in the quick start about copying this plugin into the plugins folder to enable GUI user- and group admining.
|
||||||
- [x] CI/CD — test on push to any branch except main.
|
- [x] CI/CD - Test on push to any branch except main. (`.gitea/workflows/ci.yml` runs `bash ci.sh`; the one-time act_runner setup it needs is documented in README → CI/CD.)
|
||||||
- [x] CI/CD — require a PR to main, gated on a green build, fast-forward-only.
|
- [x] CI/CD - Require PR to main and don't allow merge if tests does not pass. Only allow linear history and history that leaves the last commit hash on main the exact same as on the branch we just merged in. (Gitea branch protection on main + fast-forward-only merge style, set via API; documented in README → CI/CD.)
|
||||||
- [x] CI/CD — force-push mirror to GitHub after every merge to main.
|
- [x] CI/CD - Sync up to github after every successful merge to main, URL: git@github.com:larvit/plainpages.git - also note the true home top of the README. Force push to github, it should only ever be a mirror of the gitea.larvit.se repository. (`.gitea/workflows/mirror.yml` force-pushes main + tags over HTTPS with a dedicated account's PAT in the `MIRROR_GITHUB_TOKEN` secret; setup documented in README → CI/CD.)
|
||||||
- [x] CI/CD — build and push the app image, tagged with the commit hash, as part of the gate.
|
- [x] CI/CD - Build docker images as part of the requirements to be able to merge to main. Push them with the git commit hash as docker tag. Push to container registry at Gitea. (`ci.yml` builds + pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` after a green gate — with ff-only merges that is the main commit's image; auth via the `DOCKER_REGISTRY_USER` variable + `DOCKER_REGISTRY_TOKEN` secret, retention via an org cleanup rule; documented in README → CI/CD.)
|
||||||
- [x] CI/CD — re-tag the hash image to semver on a `vX.Y.Z` tag.
|
- [x] CI/CD - Re-tag docker images from git hash to semver when a semver git tag is pushed. (`release.yml` on a `vX.Y.Z` tag pulls the commit-hash image and re-tags it `X.Y.Z`/`X.Y`/`X`/`latest`, failing loud if the gated image is missing; tag pushes also trigger the GitHub mirror; documented in README → CI/CD.)
|
||||||
- [x] CI/CD — sync released tags to Docker Hub.
|
- [x] CI/CD - Sync docker images to docker hub after each re-tag to git tags. (`release.yml` pushes the same `X.Y.Z`/`X.Y`/`X`/`latest` tags to `docker.io/larvit/plainpages` after the Gitea re-tag — releases only, no hash tags; auth via the `DOCKERHUB_USER` variable + `DOCKERHUB_TOKEN` secret; documented in README → CI/CD.)
|
||||||
- [x] Write README-dockerhub.md for the Docker Hub overview.
|
- [x] Write a short text on how to use this docker image to publish on docker hub and save it to README-dockerhub.md (tagline, tags, clone-free quick start — the image ships the Ory config, extracted via `docker run … tar` + a self-contained compose.yml — env table, first plugin; pasted into the Docker Hub overview by hand — noted in README → CI/CD.)
|
||||||
- [x] CI/CD — set up the Renovate bot.
|
- [x] CI/CD - Setup renovate bot. Check how other repos on this Gitea is setup you can get access to, there should be a number of renovate bot activated ones. (`renovate.yml` runs the self-hosted `renovate/renovate` image nightly against `renovate.json` — this repo only, via the shared `renovate@larvit.se` bot + `RENOVATE_TOKEN` secret, mirroring the `pwrpln/core` pattern; standard managers cover npm/Dockerfiles/compose/gitea-action pins, two custom regex managers cover the image tags embedded in workflow `run:` steps, the Ory + Playwright lockstep sets are grouped, every bump stays an exact pin, and each PR automerges once the gate is green; documented in README → CI/CD.)
|
||||||
- [x] CI/CD — give Renovate a read-only `GITHUB_COM_TOKEN` so github.com lookups aren't rate-limited.
|
- [x] CI/CD - Renovate: set a read-only `GITHUB_COM_TOKEN` env in `renovate.yml` so Renovate stops hitting github.com rate limits when resolving github-hosted deps (Playwright, lucide, `actions/checkout`) and can fetch changelogs. Non-blocking refinement; needs a read-only GitHub PAT stored as an Actions secret. (The renovate job forwards the `RENOVATE_GITHUB_TOKEN` secret — a scopeless read-only github.com PAT; Gitea rejects `GITHUB_`-prefixed secret names — into the container as `GITHUB_COM_TOKEN`; documented in README → CI/CD.)
|
||||||
- [x] CI/CD — auto-release on Renovate updates, versioned from the `Release-Bump:` trailer.
|
- [x] CI/CD - When renovate updates a dependency - also release a new version of plainpages based on what got updated with Renovate. Major typescript? New apiVersion + new major. A tiny patch to ejs? Only patch release etc. Before implementing, explain in detail how you will solve this. (`renovate.yml` gains an `auto-release` job (`needs: renovate`) that cuts one `vX.Y.Z` tag per run for what Renovate merged; level = highest `Release-Bump:` trailer Renovate stamps via `commitBody`, any dep's major/minor/patch mapped straight through (default patch). Decoupled from `apiVersion` (tag-only, `HOST_API_VERSION` untouched — a "major" is just a bigger image tag, never a plugin break); pre-1.0 shifts down so nothing auto-crosses into 1.0.0. Pure `auto-release/next-version.ts` + unit tests; tag pushed with renovate-bot's PAT so `release.yml` fires; documented in README → CI/CD.)
|
||||||
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen.
|
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen. The full-flow e2e suite runs without Hydra (compose.full.yml), so /admin/clients register/detail/delete is only unit-covered (src/http/app.test.ts); wire Hydra into an e2e stack and drive the screen in the browser. (compose.full.yml now includes Hydra (`serve all --dev`) and full-flow.spec.ts drives /admin/clients register → one-time secret → list → detail → delete in the browser; documented in README → Testing.)
|
||||||
- [x] Document the auth security model in the README.
|
- [x] Build and publish docker image as CI/CD. (Duplicate of the CI/CD items above: `ci.yml` builds and pushes `gitea.larvit.se/larvit/plainpages:<commit hash>` behind the green gate, `release.yml` re-tags it to semver and syncs those tags to Docker Hub.)
|
||||||
- [x] Add i18n support.
|
- [x] The human developer understands the security model in the auth in this project. (Two README sections. [Users, groups & permissions](README.md#users-groups--permissions) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `permission:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.)
|
||||||
- [x] Settle the identity-vs-user vocabulary.
|
- [x] Add i18n support. (Catalogs are TS modules per locale — `src/i18n/locales/<tag>.ts` for the host, `plugins/<id>/i18n/<tag>.ts` for a plugin, looked up plugin-first then core; en-US + sv-SE ship. A request is served by `?locale=sv-SE` → `Accept-Language` → `en-US`, exact on a full tag but a lone language takes the first regional catalog; no cookie — when the URL asked, the host carries `?locale` onto the links it renders and `ctx.localeHref()` does it for a plugin's. `ctx.t(key, vars)` plus `t`/`locale`/`locales`/`localeHref`/`dir` merged into every view (any include depth); `{{var}}` interpolation, plurals via `Intl.PluralRules`, an unknown key renders as itself — which is what makes a nav label either a key or plain text. Every catalog is checked against its set's en-US at boot (keys, kind, plural categories) and a mismatch stops startup. Kratos' own flow text is mapped by its numeric id (only ids verified against the live stack; its generic trait-label id is deliberately unmapped, field labels key on the input name instead). Zero-JS language picker in the shell + the auth/consent pages, `<html lang dir>` from the locale. Core, both example plugins and their views translated; unit tests + `e2e-tests/language.spec.ts` in the visual gate; documented in README → Languages, decisions in AGENTS.md.)
|
||||||
- [x] Use one uniform verb per action in the English UI (sign in / sign out / create account).
|
- [x] Settle the identity-vs-user vocabulary. (Plainpages says **user** everywhere — Keto namespace `User`, subjects `user:<kratos-id>`, `ctx.user`. Ory calls the record an "identity", but its own docs say it uses that term interchangeably with "users"/"accounts", so this is house style rather than a renamed concept, and "user" is the word readers know (Nielsen heuristic #2). README → Auth carries one note recording the mapping; the only place Ory's spelling survives is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors the Kratos wire shape. Recorded in AGENTS.md.)
|
||||||
- [x] Remove the dead "Profile" link from the sidebar profile menu.
|
- [x] On the first page there is a button saying "Log in" and in the bottom left corner another button says "Sign in". Use a uniform language. (English now says **sign in / sign out / create account** everywhere; Swedish was already uniform. Three outliers went: the landing's `landing.signIn` "Log in", the registration submit `kratos.1040001` "Sign up" — under a "Create account" heading, and sv-SE already said "Skapa konto" — and `oauth.logoutExpired`'s "This logout request", whose sign-in twin said "sign-in request". The first-run banner says "sign in at" too, and the admin example's email hint says "the sign-in identifier". The rule is recorded in AGENTS.md → Rules and held by the author: a unit test asserting the verb shipped first and was dropped on the maintainer's call, since a build that fails on a word removes the judgment a growing UI needs. The two e2e specs that clicked "Log in" now scope to `#main-content`, since the anonymous sidebar carries a "Sign in" link of its own.)
|
||||||
- [x] Remove the unspecified "Settings"/"Preferences" cog from the sidebar footer.
|
- [x] When logged in, there is a "profile" link in the little box when I've clicked my username in the bottom left corner. There is no profile, so the link is dead. Remove it. (The `<button type="button">` in the sidebar's profile menu had no handler and — zero-JS spine — could never get one; gone from `views/partials/shell.ejs` along with the `shell.profile` key in both locales. Sign out is now the menu's only item; the profile block itself (avatar, name, email) is the summary and stays. `src/ui/shell.test.ts` asserts the menu holds no dead `type="button"`, and `e2e-tests/full-flow.spec.ts` asserts Sign out is the only item once the dropdown is open.)
|
||||||
- [x] **HIGH — Split `handleRequest` in `src/http/app.ts`** — extract the built-in endpoints into named handlers on an internal route table.
|
- [x] There is a "Settings" in the bottom left (a little cog) showing a "Preferences" in a little menu when clicked. That is not in any spec, it exists when not even logged in and erh. Just remove. (Dropped from the sidebar footer in `views/partials/shell.ejs`, which now carries the profile menu — or Sign in when anonymous — plus the language picker. The `shell.settings`/`shell.preferences` catalog keys went with it in both locales, as did the then-unreferenced `i-gear` icon: `ICON_NAMES` is by definition the icons the UI references, so `views/partials/icons.ejs` was regenerated from it. Kratos' own `/settings` account flow is a different thing and is untouched. Covered by `src/ui/shell.test.ts` signed-in and anonymous, plus the public-landing case in `e2e-tests/visual.spec.ts`.)
|
||||||
|
|
||||||
|
### Architectural review findings (2026-07-02)
|
||||||
|
|
||||||
|
- [x] **HIGH — Split `handleRequest` in `src/http/app.ts` (~380 lines).** It mixes the request pipeline with inline implementations of ~10 built-in endpoints (Kratos flows, /oauth2/*, /auth/complete, /logout, /, /dashboard, 404/405). Extract each endpoint into a named handler (auth/OAuth2 group → `src/auth/` route module) with the same `(req, res, ctx)` shape plugin routes use; reduce `handleRequest` to pipeline → internal route table → `sendResult`.
|
||||||
|
|||||||
+1
-1
@@ -24,5 +24,5 @@
|
|||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"skipLibCheck": true
|
"skipLibCheck": true
|
||||||
},
|
},
|
||||||
"include": ["config", "examples/config", "examples/plugins", "plugin-api", "plugins", "registry-cleanup", "release-tooling", "src"]
|
"include": ["auto-release", "config", "examples/config", "examples/plugins", "plugins", "registry-cleanup", "src"]
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@
|
|||||||
<p>${t("dashboard.starter.intro")}</p>
|
<p>${t("dashboard.starter.intro")}</p>
|
||||||
<p>${t("dashboard.starter.replace")}</p>
|
<p>${t("dashboard.starter.replace")}</p>
|
||||||
<pre class="code-block"><code>export default definePlugin({
|
<pre class="code-block"><code>export default definePlugin({
|
||||||
apiVersion: "0.1.0",
|
apiVersion: "1.0.0",
|
||||||
// view names plugins/<id>/views/<view>.ejs, rendered in this same shell
|
// view names plugins/<id>/views/<view>.ejs, rendered in this same shell
|
||||||
dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
|
dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
|
||||||
});</code></pre>
|
});</code></pre>
|
||||||
|
|||||||
Reference in New Issue
Block a user