Compare commits
15 Commits
main
..
3552b85d63
| Author | SHA1 | Date | |
|---|---|---|---|
| 3552b85d63 | |||
| dde6fccb35 | |||
| 19b3fbc802 | |||
| 5cc6c3d93e | |||
| 04af61a5e5 | |||
| a64a60644d | |||
| 950eb5a911 | |||
| 091011cfe5 | |||
| d55898eb8c | |||
| f992cb6b2c | |||
| 7d1f7750d3 | |||
| 3f9787df48 | |||
| 77343e859a | |||
| 616040fda6 | |||
| fee4fe632b |
@@ -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.3
|
renovate/renovate:44.33.2
|
||||||
|
|
||||||
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
|
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
|
||||||
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
|
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
|
||||||
# trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit
|
# trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit
|
||||||
# (a human owns that release), 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"
|
||||||
|
|||||||
@@ -36,13 +36,11 @@ 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** reached over their
|
||||||
Postgres database, which the host provisions but never reads or writes inside. Auth/identity/OAuth are
|
REST APIs with built-in `fetch` — no SDK. New capabilities ship as **plugin folders** under
|
||||||
**Ory sidecar services** reached over their REST APIs with built-in `fetch` — no SDK. New
|
`plugins/` that fetch their data from upstream services, not as core code.
|
||||||
capabilities ship as **plugin folders** under `plugins/` that get their data from an upstream
|
|
||||||
service or their own database, not as core code.
|
|
||||||
3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
|
3. **Strict TypeScript** — `tsconfig.json` is strict (incl. `noUncheckedIndexedAccess`,
|
||||||
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types;
|
`exactOptionalPropertyTypes`, `verbatimModuleSyntax`). Keep it that way. Prefer exact types;
|
||||||
limit nullable and multi-option types.
|
limit nullable and multi-option types.
|
||||||
@@ -81,50 +79,7 @@ Revisit only if the stated reason stops holding.
|
|||||||
it into `/node_modules`, above every plugin scope. Never let a copy reach a plugin's own
|
it into `/node_modules`, above every plugin scope. Never let a copy reach a plugin's own
|
||||||
`node_modules`: two instances of the barrel break `instanceof` across the boundary, which
|
`node_modules`: two instances of the barrel break `instanceof` across the boundary, which
|
||||||
`plugin-api.test.ts` guards by asserting both paths reach one module.
|
`plugin-api.test.ts` guards by asserting both paths reach one module.
|
||||||
- **Plugin storage hands over credentials, not a client** (README → Plugin storage). The host takes
|
- **`config/` is still a plain dir — no `package.json` of its own**, or `#menu-config` resolves
|
||||||
`postgres` to run the provisioning DDL, and `storage-provisioning.ts` is the only module importing
|
|
||||||
it — `storage.ts` beside it stays pure so `web` never loads a driver (`src/postgres.test.ts` guards
|
|
||||||
both halves, because one value imported from the wrong module breaks it invisibly). It is never
|
|
||||||
re-exported through the barrel, so no driver shape enters the contract. Three properties hold the design together, so
|
|
||||||
don't trade one away in isolation: passwords are `HMAC-SHA256(PLUGIN_DB_SECRET, id)` rather than
|
|
||||||
stored, which is what keeps the host stateless — whoever holds that secret holds every plugin
|
|
||||||
database, so it ranks with the DB password itself; the provisioning DSN reaches `bootstrap` only
|
|
||||||
(`src/compose.test.ts` guards the split); and provisioning never drops anything, so uninstalling a
|
|
||||||
plugin cannot destroy data — boot logs the orphans instead. Because the host's copy sits in the
|
|
||||||
ambient `/node_modules`, a plugin can `import "postgres"` without declaring it — incidental, not a
|
|
||||||
packaging promise, and a plugin must still depend on its own driver.
|
|
||||||
- **Plugin settings are declared, not discovered** (README → Plugin settings). `settings.ts` is pure and
|
|
||||||
takes the env as an argument, so the whole matrix unit-tests without a stack. Four rules carry the
|
|
||||||
design: the prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`, because a plugin id `db` with key
|
|
||||||
`url` would otherwise name the host's own `PLUGIN_DB_URL`; keys are camelCase so the
|
|
||||||
`camelCase → SNAKE_CASE` mapping is total and no two keys collide, with the residual cross-plugin
|
|
||||||
collision caught by `findConflicts`; `required` and `default` are mutually exclusive, which is what
|
|
||||||
lets `SettingsOf` type a declared key as present rather than `T | undefined`, so no plugin author
|
|
||||||
casts; and a secret's value reaches the plugin but never a log, an error or `ctx.declaredSettings`
|
|
||||||
— not even as a mask or a length. An author mistake is refused at discovery, a bad operator value
|
|
||||||
refuses the boot, and a stray `PLUGIN_SETTING_` variable only warns (the orphan-database precedent).
|
|
||||||
- **The trust boundary is the `web` process, not the plugin.** Per-plugin databases and roles bound
|
|
||||||
*accidents*, not hostile plugins: `PLUGIN_DB_SECRET` is in `web`'s environment during `onBoot`, and
|
|
||||||
a plugin already holds `ctx.system`'s Ory admin clients — so cross-plugin DB isolation is
|
|
||||||
containment, and README says so rather than implying a sandbox. Consistent with priority #7
|
|
||||||
(crash-isolation is a non-goal). `server.ts` still deletes the secret from `process.env` right
|
|
||||||
after `loadConfig`, which is before discovery imports any plugin module — the ordering is the whole
|
|
||||||
point, so move it earlier if anything, **never later**. **Valid while plugins are
|
|
||||||
operator-installed code, not third-party uploads.**
|
|
||||||
- **`ory/postgres/init/init.sql` is the only home for the Ory databases' ACL** — don't re-assert the
|
|
||||||
`REVOKE CONNECT` from `bootstrap`. `REVOKE` only *warns* when the caller doesn't own the database,
|
|
||||||
so under the least-privilege provisioning account the README recommends it would report success
|
|
||||||
while changing nothing, and it hard-fails whenever `PLUGIN_DB_ADMIN_URL` names a server with no
|
|
||||||
`kratos`. It runs only on **first init**, so a revoke added to it later never reaches a volume that
|
|
||||||
already exists — `docker compose down -v` is the dev remedy, a deployed install needs a migration.
|
|
||||||
- **`bootstrap.ts` stays under `src/auth/`** even though it now provisions plugin databases as well
|
|
||||||
as seeding Ory. It is the one-shot service's entrypoint, not an auth module; moving it to
|
|
||||||
`src/bootstrap.ts` would edit `compose.yml`, five e2e compose files and `src/compose.test.ts` for a
|
|
||||||
rename. Reconsider when a third seeding concern lands.
|
|
||||||
- **`BootContext.storage` keeps all six credential fields, and there is no `onShutdown` hook.** Adding
|
|
||||||
to the context costs a minor bump and removing one a major, so the shape errs small elsewhere. Pools
|
|
||||||
handed to a plugin are reaped on process exit — revisit if a plugin ever needs an orderly drain.
|
|
||||||
- **`config/` is still a plain dir — no `package.json` of its own**, or `#menu-config` resolves
|
|
||||||
against that instead and boot fails loud. An operator's menu override has no use for
|
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.
|
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
|
- **A plugin `package.json` without `"type": "module"` is refused, not warned.** Allowing it costs a
|
||||||
@@ -297,23 +252,31 @@ Revisit only if the stated reason stops holding.
|
|||||||
console message only appears in the engine that renders the page (`ORY_FREE` in
|
console message only appears in the engine that renders the page (`ORY_FREE` in
|
||||||
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
|
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
|
||||||
so widening them means a stack per engine.
|
so widening them means a stack per engine.
|
||||||
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** Both git channels in
|
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root** — no test, build step or
|
||||||
`ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the destination, so
|
workflow reads a markdown file. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`:
|
||||||
`git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a source file that
|
rename detection names only the destination, so `git mv src/app.ts notes.md` would otherwise read as
|
||||||
was gone. `src/ci-gate.test.ts` locks the flags as a
|
docs and skip the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a
|
||||||
*text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
|
*text* guard — the test image ships neither `git` nor `bash`. Revisit if a `.md` ever becomes
|
||||||
`release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
|
load-bearing.
|
||||||
guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard
|
|
||||||
skipped. `README.md` is the one markdown a test reads — `release-tooling/contract-version.test.ts`
|
|
||||||
checks its `apiVersion` samples — and a README-only change skips that check; accepted, because
|
|
||||||
those samples are illustrative and the copies that matter (`examples/`, `views/`, the template) are
|
|
||||||
gated. **Valid while no markdown file is rendered or executed.**
|
|
||||||
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
||||||
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs
|
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs
|
||||||
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
|
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
|
||||||
file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's
|
file between login and logout. Same class: concurrent runs share the workspace dir, so ci.sh's
|
||||||
web-image build races another run's container creation on the `<project>-web` tag. Accepted for a
|
web-image build races another run's container creation on the `<project>-web` tag. Accepted for a
|
||||||
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
|
single-maintainer cadence; serialize with a workflow `concurrency` group if it ever bites.
|
||||||
|
- **Plainpages is pre-announcement: no tags, no releases.** `auto-release` is gated behind the
|
||||||
|
`AUTO_RELEASE` Actions variable (unset ⇒ skipped, the fail-safe direction on every unknown-`vars`
|
||||||
|
path) — a version only communicates to consumers and there are none. Two couplings:
|
||||||
|
`registry-cleanup` keeps a hash image only while its commit is a branch head *or* release-tagged, so
|
||||||
|
with zero tags a hand-cut tag must sit on `main`'s tip; and `mirror.yml` pushes tags with `--prune`
|
||||||
|
(its `fetch-tags: true` is load-bearing), so a tag or Release created on GitHub is swept away and
|
||||||
|
releases are cut on Gitea only. Valid until the maintainer says Plainpages is ready to show people.
|
||||||
|
- **A stricter manifest rule breaks already-copied plugins**, and while `HOST_API_VERSION` is frozen
|
||||||
|
the failure names a symptom rather than the cause — `checkApiVersion` would refuse a stale plugin by
|
||||||
|
*version*, but only once the freeze lifts. Until then a stricter rule ships with a README →
|
||||||
|
Upgrading entry and a re-copy hint in the discovery error. Fail-loud stays right either way: the
|
||||||
|
alternative is a route gating on a name nobody can be granted, i.e. a permanent silent 403.
|
||||||
|
**Valid while `HOST_API_VERSION` stays frozen.**
|
||||||
|
|
||||||
## Docker only — no host tooling
|
## Docker only — no host tooling
|
||||||
|
|
||||||
@@ -362,24 +325,15 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
|
|||||||
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
|
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
|
||||||
ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images
|
ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images
|
||||||
by tag.
|
by tag.
|
||||||
- **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist*: its
|
- **`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
|
- **The frozen surface also includes the packaging promises** (README → Plugin dependencies): the
|
||||||
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
|
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
|
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
|
partials — move the publish point, rename the package or start hoisting and every installed plugin
|
||||||
@@ -387,9 +341,9 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
|
|||||||
build-time dedupe for baked images stays open, module-instance sharing stays unpromised.
|
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
|
- **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
|
`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
|
broken tree. The trigger is the same as the freeze's: the first external plugin, which is also the
|
||||||
typecheck against a mounted host tree. Whoever does it must first make the artifact self-contained
|
first author who cannot typecheck against a mounted host tree. Whoever does it must first make the
|
||||||
(types-only `.d.ts`, or move the barrel into `plugin-api/`).
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
@@ -182,7 +181,7 @@ into the app. Create `plugins/hello/plugin.ts`:
|
|||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "@plainpages/plugin-api";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.2.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>" }) },
|
||||||
@@ -47,7 +47,7 @@ folder under `plugins/` goes live after a restart. Create `plugins/hello/plugin.
|
|||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "@plainpages/plugin-api";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.2.0",
|
apiVersion: "1.0.0",
|
||||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||||
routes: [
|
routes: [
|
||||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||||
@@ -89,8 +89,6 @@ From here, render real pages against the app shell and fetch upstream data — s
|
|||||||
- [hooks](#hooks)
|
- [hooks](#hooks)
|
||||||
- [where they live & mounting](#where-plugins-live-and-how-to-mount-them)
|
- [where they live & mounting](#where-plugins-live-and-how-to-mount-them)
|
||||||
- [dependencies](#plugin-dependencies)
|
- [dependencies](#plugin-dependencies)
|
||||||
- [settings](#plugin-settings)
|
|
||||||
- [storage](#plugin-storage)
|
|
||||||
- [local dev & test](#local-dev--test-story)
|
- [local dev & test](#local-dev--test-story)
|
||||||
- [The menu system](#the-menu-system)
|
- [The menu system](#the-menu-system)
|
||||||
- [Building blocks](#building-blocks)
|
- [Building blocks](#building-blocks)
|
||||||
@@ -108,7 +106,7 @@ From here, render real pages against the app shell and fetch upstream data — s
|
|||||||
- [security model](#security-model)
|
- [security model](#security-model)
|
||||||
- [Email](#email)
|
- [Email](#email)
|
||||||
- [Architecture](#architecture)
|
- [Architecture](#architecture)
|
||||||
- [Stateless core](#stateless-core)
|
- [Stateless](#stateless)
|
||||||
- [Testing](#testing)
|
- [Testing](#testing)
|
||||||
- [end-to-end](#end-to-end-playwright)
|
- [end-to-end](#end-to-end-playwright)
|
||||||
- [the full gate](#the-full-gate-one-command)
|
- [the full gate](#the-full-gate-one-command)
|
||||||
@@ -349,7 +347,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
|||||||
import { listThings, createThings } from "./handlers.ts";
|
import { listThings, createThings } from "./handlers.ts";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.2.0", // semver string of the host contract this plugin was built against (see Versioning)
|
apiVersion: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning)
|
||||||
|
|
||||||
// Nav fragment, merged into the global menu and permission-filtered per user.
|
// Nav fragment, merged into the global menu and permission-filtered per user.
|
||||||
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
|
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
|
||||||
@@ -382,8 +380,6 @@ folder-derived `id` to produce the loaded `Plugin`.
|
|||||||
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
|
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
|
||||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||||
| `hooks` | no | See [Hooks](#hooks). |
|
| `hooks` | no | See [Hooks](#hooks). |
|
||||||
| `settings` | no | Configuration this plugin accepts, one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, resolved and validated at boot and handed to `onBoot`. See [Plugin settings](#plugin-settings). |
|
|
||||||
| `storage` | no | `true` ⇒ the host provisions a Postgres database and login role for this plugin and hands the credentials to `onBoot`. See [Plugin storage](#plugin-storage). |
|
|
||||||
|
|
||||||
A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional.
|
A plugin may be routes-only, nav-only, or hooks-only — every collection field is optional.
|
||||||
|
|
||||||
@@ -436,8 +432,8 @@ export async function listThings(ctx: RequestContext) {
|
|||||||
`requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and
|
`requireSession(ctx)`, `can(ctx, permission)` (coarse JWT-claim check, zero I/O), and
|
||||||
`check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw
|
`check(keto, ctx, {namespace, object, relation})` (a live Keto check; anonymous ⇒ denied). Throw
|
||||||
`new GuardError(403, …)` after a failed `can`/`check` to render the 403 page.
|
`new GuardError(403, …)` after a failed `can`/`check` to render the 403 page.
|
||||||
- The handler **fetches its own data** — from upstream, or from the plugin's own
|
- The handler **fetches its own data** from upstream; plugins hold no state (see
|
||||||
[storage](#plugin-storage); the host holds none of it (see [Stateless core](#stateless-core)).
|
[Stateless](#stateless)).
|
||||||
- Default status: `200` for `view`/`html`/`json`, `303` for `redirect`.
|
- Default status: `200` for `view`/`html`/`json`, `303` for `redirect`.
|
||||||
|
|
||||||
#### Escaping & the trust boundary
|
#### Escaping & the trust boundary
|
||||||
@@ -470,7 +466,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
|||||||
import { landing, board } from "./pages.ts";
|
import { landing, board } from "./pages.ts";
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.2.0",
|
apiVersion: "1.0.0",
|
||||||
home: landing, // owns "/" — the public front page
|
home: landing, // owns "/" — the public front page
|
||||||
dashboard: board, // owns "/dashboard" — the post-login app home
|
dashboard: board, // owns "/dashboard" — the post-login app home
|
||||||
});
|
});
|
||||||
@@ -595,29 +591,22 @@ works without editing host config.
|
|||||||
|
|
||||||
### Contract versioning
|
### Contract versioning
|
||||||
|
|
||||||
Each manifest declares `apiVersion` — a **semver** string naming the **Plainpages release** it was
|
Each manifest declares `apiVersion` — a **semver** string naming the host contract it was built
|
||||||
built against. The host's `HOST_API_VERSION` *is* its release version, so a plugin author reads one
|
against — against the host's `HOST_API_VERSION`. The host bumps **major** on a breaking
|
||||||
version off the image they run and writes it down — there is no separate contract number. Both
|
manifest/handler change and **minor** on an additive one. At discovery it parses both with
|
||||||
release paths refuse a tag whose `major.minor` disagrees with the constant, so the two cannot drift.
|
`parseSemver` (strict: no ranges, `v` prefixes, or leading zeros) and applies provider/consumer
|
||||||
|
semantics in `checkApiVersion`:
|
||||||
Patch releases are invisible here — `checkApiVersion` ignores the patch digit, which is what lets
|
|
||||||
dependency updates ship continuously without touching any plugin. At discovery the host parses both
|
|
||||||
versions with `parseSemver` (strict: no ranges, `v` prefixes, or leading zeros) and applies
|
|
||||||
provider/consumer semantics in `checkApiVersion`:
|
|
||||||
|
|
||||||
| Plugin `apiVersion` vs host | Result | Host action |
|
| Plugin `apiVersion` vs host | Result | Host action |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| same major, same minor (patch ignored) | `ok` | load |
|
| same major, same minor (patch ignored) | `ok` | load |
|
||||||
| **major `0`**, plugin minor **<** host minor | `refuse` | **abort boot** — pre-1.0 the minor is the breaking slot |
|
| same major, plugin minor **<** host minor | `warn` | load, log — additive-compatible, newer features exist |
|
||||||
| same major, plugin minor **<** host minor | `warn` | load, log — built against an older release; check that release's notes |
|
|
||||||
| same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host |
|
| same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host |
|
||||||
| different major | `refuse` | **abort boot** — incompatible contract |
|
| different major | `refuse` | **abort boot** — incompatible contract |
|
||||||
| missing / not a valid semver | `refuse` | **abort boot** — must be declared |
|
| missing / not a valid semver | `refuse` | **abort boot** — must be declared |
|
||||||
|
|
||||||
The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies
|
The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies
|
||||||
the compatibility. One digit carries the whole release, so a **minor** means either the plugin
|
the caret-style compatibility.
|
||||||
contract changed or a dependency moved far enough to warrant one.
|
|
||||||
|
|
||||||
|
|
||||||
### Conflict rules
|
### Conflict rules
|
||||||
|
|
||||||
@@ -643,13 +632,10 @@ Optional, for reacting to system actions. A plugin's `hooks` may implement:
|
|||||||
|
|
||||||
| Hook | When | May |
|
| Hook | When | May |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `onBoot(host)` | after discovery, before the server listens | warm caches, validate upstream config, open a [storage](#plugin-storage) connection |
|
| `onBoot()` | after discovery, before the server listens | warm caches, validate upstream config |
|
||||||
| `onRequest(ctx)` | before route matching | inspect, or **short-circuit** by returning a `RouteResult` |
|
| `onRequest(ctx)` | before route matching | inspect, or **short-circuit** by returning a `RouteResult` |
|
||||||
| `onResponse(ctx, result)` | after the handler | observe/log; cannot change the response |
|
| `onResponse(ctx, result)` | after the handler | observe/log; cannot change the response |
|
||||||
|
|
||||||
`onBoot`'s `host` is a `BootContext`, carrying `storage` for a plugin that declared it. A hook
|
|
||||||
written without a parameter stays valid.
|
|
||||||
|
|
||||||
Hooks run in **discovery order** (plugins sorted by id). `onRequest` fires on every request that
|
Hooks run in **discovery order** (plugins sorted by id). `onRequest` fires on every request that
|
||||||
reaches routing (static assets bypass it); the **first** hook to return a `RouteResult` short-circuits
|
reaches routing (static assets bypass it); the **first** hook to return a `RouteResult` short-circuits
|
||||||
— later hooks and the route handler are skipped, and that result renders against its own plugin's
|
— later hooks and the route handler are skipped, and that result renders against its own plugin's
|
||||||
@@ -667,7 +653,7 @@ getting its folder there.
|
|||||||
bind-mounts the whole tree (`compose.override.yml`: `.:/app`), so a restart picks it up.
|
bind-mounts the whole tree (`compose.override.yml`: `.:/app`), so a restart picks it up.
|
||||||
|
|
||||||
**2. A plugin kept in its own repo, or added to a prebuilt image.** Bind-mount the plugin
|
**2. A plugin kept in its own repo, or added to a prebuilt image.** Bind-mount the plugin
|
||||||
folder onto `/app/plugins/<id>` with a small compose override. A plugin folder is code, not data —
|
folder onto `/app/plugins/<id>` with a small compose override. Plugins are stateless, so
|
||||||
mount it read-only:
|
mount it read-only:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
@@ -738,138 +724,6 @@ barrel's types on disk: typecheck it mounted under the host tree, or vendor a ty
|
|||||||
`node_modules`** and point tsconfig `paths` at it — a stub inside is the shadowing copy discovery
|
`node_modules`** and point tsconfig `paths` at it — a stub inside is the shadowing copy discovery
|
||||||
refuses, and it would travel with the folder you mount.
|
refuses, and it would travel with the folder you mount.
|
||||||
|
|
||||||
### Plugin settings
|
|
||||||
|
|
||||||
A plugin declares the configuration it accepts, and the host resolves it from the environment at
|
|
||||||
boot. Each key becomes one variable — `PLUGIN_SETTING_<ID>_<KEY>`, the id's dashes and the key's
|
|
||||||
camel humps both becoming underscores — so `upstream` on the `scheduling` plugin is set by
|
|
||||||
`PLUGIN_SETTING_SCHEDULING_UPSTREAM`.
|
|
||||||
|
|
||||||
```ts
|
|
||||||
export default definePlugin({
|
|
||||||
apiVersion: "0.2.0",
|
|
||||||
settings: [
|
|
||||||
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
|
|
||||||
{ key: "pageSize", type: "number", default: 25 },
|
|
||||||
{ key: "mode", type: "enum", values: ["strict", "lenient"], default: "strict" },
|
|
||||||
{ key: "apiKey", type: "string", secret: true, default: "dev-insecure-key" },
|
|
||||||
],
|
|
||||||
hooks: {
|
|
||||||
onBoot: ({ settings }) => {
|
|
||||||
settings.upstream; // string — required, so the boot already refused without it
|
|
||||||
settings.pageSize; // number — defaulted, so always present
|
|
||||||
start(settings);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
`type` is one of `string`, `number`, `boolean`, `enum` (with `values`) or `url`. A declared type is
|
|
||||||
coerced and checked at boot, so a mistyped value names the plugin, the key and the variable instead
|
|
||||||
of surfacing later as a broken page.
|
|
||||||
|
|
||||||
**`required` and `default` are mutually exclusive** — a default means the setting can never fail, so
|
|
||||||
declaring both is refused at discovery. That leaves three cases, and the type `onBoot` receives
|
|
||||||
follows them exactly: `required: true` is always present, a `default` is always present, and a
|
|
||||||
setting with neither is `T | undefined`, so the plugin has to handle its absence.
|
|
||||||
|
|
||||||
**Secrets.** `secret: true` marks a value the host reads but never renders — not in a boot log, not
|
|
||||||
in an error, not on the admin screen, which shows only whether it resolved and from where. With
|
|
||||||
`REQUIRE_SECURE_SECRETS=true` a secret that is unset, or still equal to its declared default, refuses
|
|
||||||
the boot — the same rule the host applies to its own secrets.
|
|
||||||
|
|
||||||
**Where it fails, and where it warns.** A malformed declaration is refused at discovery; a missing
|
|
||||||
`required` value or a value that will not coerce refuses the boot. A `PLUGIN_SETTING_` variable no
|
|
||||||
installed plugin declares is only *reported* — it is usually a typo in the one the operator meant to
|
|
||||||
set, and naming it turns two unrelated-looking errors into one. Declaring settings without an
|
|
||||||
`onBoot` warns too: they resolve, but nothing receives them.
|
|
||||||
|
|
||||||
**Reading what a deployment is configured with.** The admin plugin's **Plugin settings** screen
|
|
||||||
(`plugin-settings:read`) lists every installed plugin, its declared keys, the variable that sets
|
|
||||||
each, and whether the value came from the environment or the declared default.
|
|
||||||
|
|
||||||
### Plugin storage
|
|
||||||
|
|
||||||
A plugin that needs to keep data sets `storage: true`. The host then provisions a Postgres
|
|
||||||
**database and login role of its own** — both named `plugin_<id>` — and hands the credentials to
|
|
||||||
`onBoot`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import postgres from "postgres"; // your dependency, not the host's
|
|
||||||
import { definePlugin } from "@plainpages/plugin-api";
|
|
||||||
|
|
||||||
let sql: ReturnType<typeof postgres>;
|
|
||||||
|
|
||||||
export default definePlugin({
|
|
||||||
apiVersion: "0.2.0",
|
|
||||||
storage: true,
|
|
||||||
hooks: {
|
|
||||||
onBoot: async (boot) => {
|
|
||||||
if (!boot.storage) throw new Error("things: storage was not provisioned");
|
|
||||||
sql = postgres(boot.storage.url);
|
|
||||||
// Every web instance runs onBoot, and concurrent CREATE TABLE IF NOT EXISTS is an error in
|
|
||||||
// Postgres — the lock is released when the transaction ends.
|
|
||||||
await sql.begin(async (tx) => {
|
|
||||||
await tx`SELECT pg_advisory_xact_lock(hashtext('things:schema'))`;
|
|
||||||
await tx`CREATE TABLE IF NOT EXISTS things (id uuid PRIMARY KEY, name text NOT NULL)`;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|
||||||
`boot.storage` is a `StorageCredentials` — `database`, `host`, `password`, `port`, `user`, and `url`,
|
|
||||||
the same values pre-assembled as a DSN, which most clients take directly. It is typed optional, so
|
|
||||||
the guard above is expected of every storage plugin rather than a sign something is wrong.
|
|
||||||
|
|
||||||
**Credentials, not a client.** The host has no opinion on how you reach Postgres: depend on
|
|
||||||
`postgres`, `pg`, a query builder or an ORM ([Plugin dependencies](#plugin-dependencies)). No driver
|
|
||||||
is part of the contract, so upgrading yours is yours alone to time. The flip side is that pool sizing
|
|
||||||
is yours too — keep yours under `PLUGIN_DB_CONNECTION_LIMIT` (default 10), the per-role ceiling the
|
|
||||||
host sets so one plugin cannot exhaust the Postgres this stack shares with Ory.
|
|
||||||
|
|
||||||
**The schema is yours, migrations included.** The host creates the database empty, never reads or
|
|
||||||
writes inside it, and ships no migration machinery — evolving your tables compatibly (expand, then
|
|
||||||
contract, so a rolled-back version still runs) is yours to own. Create your tables in `onBoot`: it
|
|
||||||
runs before the server listens, so a failure aborts boot instead of surfacing later as a broken page.
|
|
||||||
|
|
||||||
What the host does guarantee:
|
|
||||||
|
|
||||||
- **One database and one role per plugin**, with `CONNECT` revoked from `PUBLIC`. This bounds
|
|
||||||
*accidents* — a wrong database name, a mistyped DSN, a stray query — and it is not a security
|
|
||||||
boundary: plugins share the `web` process, so a plugin that goes looking can reach another's
|
|
||||||
credentials. Install plugins you trust ([Security model](#security-model)).
|
|
||||||
- **Provisioning is idempotent and runs every boot**, so a plugin dropped in later is picked up by
|
|
||||||
the next `docker compose up -d` — the same rule as permission seeding. Each boot re-applies the
|
|
||||||
role's password, connection limit, and `NOCREATEDB`/`NOCREATEROLE`.
|
|
||||||
- **Your data is never dropped.** Removing a plugin folder leaves its database untouched; deleting it
|
|
||||||
is a deliberate act by an operator. Each boot logs any `plugin_*` database no installed plugin
|
|
||||||
claims, so what you left behind stays findable — read that list before dropping anything, since a
|
|
||||||
second Plainpages stack sharing this server will have its databases named there too.
|
|
||||||
|
|
||||||
**Passwords are derived, never stored** — each is `HMAC-SHA256(PLUGIN_DB_SECRET, <plugin id>)`, so
|
|
||||||
`bootstrap` and `web` compute the same value independently and nothing has to be written down.
|
|
||||||
Rotate every plugin's password by changing `PLUGIN_DB_SECRET` and running `docker compose up -d`,
|
|
||||||
which re-applies each role's password and leaves the data alone — restart every `web` instance as
|
|
||||||
part of it, since one still holding the old secret can open no new connections. Treat the secret as
|
|
||||||
you would a database password: whoever holds it holds every plugin database. Under
|
|
||||||
`REQUIRE_SECURE_SECRETS` a missing, empty or throwaway secret is refused — in `bootstrap` before it
|
|
||||||
creates any role, so no database is ever given a password derivable from a constant in this repo.
|
|
||||||
|
|
||||||
**Only `bootstrap` holds provisioning credentials.** It alone gets `PLUGIN_DB_ADMIN_URL`, an account
|
|
||||||
with `CREATEDB` and `CREATEROLE` (superuser works but is more than it needs; the dev stack simply
|
|
||||||
reuses Ory's). Keep using the same account: Postgres gives a `CREATEROLE` account admin rights only
|
|
||||||
over the roles it created itself, so if you swap it for a fresh one, grant that one `ADMIN OPTION` on
|
|
||||||
the existing `plugin_*` roles first, or the next boot cannot re-apply their passwords. `web` gets `PLUGIN_DB_URL`, which names the server and must carry no credentials —
|
|
||||||
supply one with a username or password and boot fails, rather than leaving a privileged password in
|
|
||||||
the process that runs plugin code. Set both, plus `PLUGIN_DB_SECRET`
|
|
||||||
([Configuration](#configuration)); the dev stack sets them for you.
|
|
||||||
|
|
||||||
Storage stays off until `PLUGIN_DB_URL` is set, and a plugin declaring it while that is unset
|
|
||||||
**aborts boot** naming itself — rather than serving pages without its data. One naming limit: a
|
|
||||||
storage plugin's folder may be at most **56 characters**, so `plugin_<id>` fits Postgres' 63-byte
|
|
||||||
identifier.
|
|
||||||
|
|
||||||
### Local dev & test story
|
### Local dev & test story
|
||||||
|
|
||||||
A plugin is a normal folder of TypeScript, tested the same way the core is — everything in Docker.
|
A plugin is a normal folder of TypeScript, tested the same way the core is — everything in Docker.
|
||||||
@@ -1082,7 +936,7 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
|
|||||||
| `PORT` | `3000` | web listen port |
|
| `PORT` | `3000` | web listen port |
|
||||||
| `CACHE_TEMPLATES` | `false` | cache compiled EJS templates (`true` in prod) |
|
| `CACHE_TEMPLATES` | `false` | cache compiled EJS templates (`true` in prod) |
|
||||||
| `SECURE_COOKIES` | `false` | mark our session/CSRF cookies `Secure` (`true` in prod https; off in dev http) |
|
| `SECURE_COOKIES` | `false` | mark our session/CSRF cookies `Secure` (`true` in prod https; off in dev http) |
|
||||||
| `REQUIRE_SECURE_SECRETS` | `false` | when `true`, `CSRF_SECRET` — and `PLUGIN_DB_SECRET` once storage is configured, and every plugin setting declared `secret` — must be supplied and differ from the dev throwaway |
|
| `REQUIRE_SECURE_SECRETS` | `false` | when `true`, `CSRF_SECRET` must be supplied and differ from the dev throwaway |
|
||||||
| `LOG_LEVEL` | `info` | min severity logged: `error`/`warn`/`info`/`verbose`/`debug`/`silly`/`none` |
|
| `LOG_LEVEL` | `info` | min severity logged: `error`/`warn`/`info`/`verbose`/`debug`/`silly`/`none` |
|
||||||
| `LOG_FORMAT` | `text` | log line format: `text` (human-readable, dev) or `json` (structured, prod) |
|
| `LOG_FORMAT` | `text` | log line format: `text` (human-readable, dev) or `json` (structured, prod) |
|
||||||
| `SERVICE_NAME` | `plainpages` | OTLP `service.name` on every log + span — brand it as your own deployment |
|
| `SERVICE_NAME` | `plainpages` | OTLP `service.name` on every log + span — brand it as your own deployment |
|
||||||
@@ -1098,11 +952,6 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
|
|||||||
| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant permission/session revoke denylist](#instant-revoke-the-optional-denylist) |
|
| `REVOCATION_DENYLIST` | `false` | when `true`, enable the optional [instant permission/session revoke denylist](#instant-revoke-the-optional-denylist) |
|
||||||
| `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
|
| `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
|
||||||
| `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` |
|
| `CSRF_SECRET` | dev throwaway | signs our double-submit CSRF token; enforced by `REQUIRE_SECURE_SECRETS` |
|
||||||
| `PLUGIN_DB_URL` | _unset_ (dev: `postgres://postgres:5432`) | credential-free Postgres base URL for [plugin storage](#plugin-storage); unset ⇒ storage off, and a plugin declaring it aborts boot |
|
|
||||||
| `PLUGIN_SETTING_<ID>_<KEY>` | per declaration | one variable per key a plugin declares in `settings`; see [Plugin settings](#plugin-settings) |
|
|
||||||
| `PLUGIN_DB_ADMIN_URL` | _unset_ (dev: the bundled superuser) | the DSN that provisions each plugin's database and role — read by the one-shot `bootstrap` service **only**, never by `web` |
|
|
||||||
| `PLUGIN_DB_SECRET` | dev throwaway | derives each plugin's database password; `REQUIRE_SECURE_SECRETS` enforces it in `web` once `PLUGIN_DB_URL` is set, and in `bootstrap` whenever a plugin declares storage |
|
|
||||||
| `PLUGIN_DB_CONNECTION_LIMIT` | `10` | per-role Postgres connection ceiling, so one plugin's pools cannot exhaust the server Ory shares; read by `bootstrap` when provisioning |
|
|
||||||
|
|
||||||
### Canonical host (one public URL)
|
### Canonical host (one public URL)
|
||||||
|
|
||||||
@@ -1233,8 +1082,8 @@ records that subject as revoked-now; the hot path then rejects every token for i
|
|||||||
the revoke and forces a re-mint — which re-reads permissions from Keto, or clears a dead session. A
|
the revoke and forces a re-mint — which re-reads permissions from Keto, or clears a dead session. A
|
||||||
fresh re-login passes, so a downgrade lands immediately without locking the account.
|
fresh re-login passes, so a downgrade lands immediately without locking the account.
|
||||||
|
|
||||||
It is an in-memory, auto-evicting map — host-owned state would break the [stateless
|
It is an in-memory, auto-evicting map — no database, so it stays inside the stateless model — and
|
||||||
core](#stateless-core) — and the check is pure CPU, keeping Keto off the hot path. Entries self-evict after `REVOCATION_TTL_SEC`
|
the check is pure CPU, keeping Keto off the hot path. Entries self-evict after `REVOCATION_TTL_SEC`
|
||||||
(default 900s ≥ the 10m token TTL + skew). Two bounds: it is instant only on the **single instance**
|
(default 900s ≥ the 10m token TTL + skew). Two bounds: it is instant only on the **single instance**
|
||||||
that handled the revoke (elsewhere the guarantee falls back to the token TTL — back it with a shared
|
that handled the revoke (elsewhere the guarantee falls back to the token TTL — back it with a shared
|
||||||
store for hard multi-instance revoke), and a **group** membership change is transitive across many
|
store for hard multi-instance revoke), and a **group** membership change is transitive across many
|
||||||
@@ -1349,21 +1198,18 @@ of it over their **REST APIs using Node's built-in `fetch`** — no SDK dependen
|
|||||||
In **dev** the host-facing Ory ports are published — Kratos public `4433` and Hydra public `4444`;
|
In **dev** the host-facing Ory ports are published — Kratos public `4433` and Hydra public `4444`;
|
||||||
prod keeps them internal.
|
prod keeps them internal.
|
||||||
|
|
||||||
Runtime deps stay tiny and pinned: **`ejs`**, **`lucide-static`**, **`@larvit/log`**, and
|
Runtime deps stay tiny and pinned: **`ejs`**, **`lucide-static`**, and **`@larvit/log`**. Auth,
|
||||||
**`postgres`** — the last one has no sub-dependencies of its own and is used in a single module, to
|
sessions, SSO and OAuth2 add *services*, not npm packages.
|
||||||
provision [plugin storage](#plugin-storage) at boot. Auth, sessions, SSO and OAuth2 add *services*,
|
|
||||||
not npm packages.
|
|
||||||
|
|
||||||
### Stateless core
|
### Stateless
|
||||||
|
|
||||||
The host holds **no state of its own**: it owns no schema and keeps nothing between requests. The
|
Plainpages holds **no state of its own**. The only database in the stack is **Postgres**, used by
|
||||||
stack's **Postgres** backs Ory, and gives every plugin that asks for one a database of its own
|
Ory; the `web` app never connects to it.
|
||||||
([Plugin storage](#plugin-storage)) — which the host provisions but never reads or writes.
|
|
||||||
|
|
||||||
So a plugin gets its data one of two ways: by **calling an upstream service** from its route handler
|
A plugin reads and writes state by **calling an upstream service** from its route handler — a REST
|
||||||
— a REST API, an ERP, a plant historian, the customer's own backend — or from **its own database**.
|
API, an ERP, a plant historian, the customer's own backend — and renders the response with the
|
||||||
Either keeps `web` trivially scalable and crash-safe: any instance can serve any request, because the
|
building blocks. That keeps `web` trivially scalable and crash-safe: any instance can serve any
|
||||||
session lives in Kratos and the data lives outside the process.
|
request, because the session lives in Kratos and the data lives upstream.
|
||||||
|
|
||||||
## Testing
|
## Testing
|
||||||
|
|
||||||
@@ -1432,10 +1278,10 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
|
|||||||
| Workflow | Trigger | Does |
|
| Workflow | Trigger | Does |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image |
|
| `ci.yml` | push, any branch except `main` | the full gate (`bash ci.sh`, a no-op on a docs-only branch), then build + push the app image |
|
||||||
| `release.yml` | push of a `vX.Y.Z` tag, or manual | check the tag against `HOST_API_VERSION`, re-tag that commit's image as `X.Y.Z`, `X.Y`, `latest` (plus `X` once major ≥ 1), sync those tags to Docker Hub; a second job publishes the Hub overview, and runs alone on a manual trigger |
|
| `release.yml` | push of a `vX.Y.Z` tag | re-tag that commit's image as `X.Y.Z`, `X.Y`, `X`, `latest`; sync those tags to Docker Hub |
|
||||||
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) |
|
| `mirror.yml` | push to `main` or any tag, or manual | force-push `main` + tags (pruning deleted ones) to the [GitHub mirror](https://github.com/larvit/plainpages) |
|
||||||
| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
|
| `registry-cleanup.yml` | nightly cron, or manual | delete registry images that are neither release-tagged nor a branch head |
|
||||||
| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green, then cut a release tag for what merged |
|
| `renovate.yml` | nightly cron, or manual | open dependency-update PRs, automerge them once the gate is green; the release-tag job only runs when `AUTO_RELEASE` is `true` |
|
||||||
|
|
||||||
`main` is not re-tested on push — its commits are meant to arrive already green from a
|
`main` is not re-tested on push — its commits are meant to arrive already green from a
|
||||||
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
|
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
|
||||||
@@ -1457,27 +1303,11 @@ pattern-based org cleanup rule for this package — its age/count heuristics can
|
|||||||
release tags and would delete images the workflow protects.
|
release tags and would delete images the workflow protects.
|
||||||
|
|
||||||
**Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs
|
**Releases** — pushing a semver git tag (`git tag v1.2.3 && git push origin v1.2.3`) runs
|
||||||
`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `latest` and —
|
`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `1`, `latest`;
|
||||||
once the major reaches `1` — `1`; nothing is rebuilt, so the released image is byte-identical to the
|
nothing is rebuilt, so the released image is byte-identical to the gated one. It fails loud if no
|
||||||
gated one. While the major is `0` the bare-major tag is skipped, because a `0.x` minor is a contract
|
|
||||||
break and a moving `:0` would carry one. It fails loud if no
|
|
||||||
hash image exists — release tags must point at a commit that went through the gate. The same four
|
hash image exists — release tags must point at a commit that went through the gate. The same four
|
||||||
tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only.
|
tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only. The Docker Hub
|
||||||
|
repository **description** is maintained by hand from [`README-dockerhub.md`](README-dockerhub.md).
|
||||||
The [contract check](#contract-versioning) guards the tag before anything is published, refusing one
|
|
||||||
whose `major.minor` disagrees with `HOST_API_VERSION` and naming the value to set.
|
|
||||||
|
|
||||||
**The Docker Hub overview** is published by a separate `publish-overview` job from
|
|
||||||
[`release-tooling/dockerhub-overview.md.tmpl`](release-tooling/dockerhub-overview.md.tmpl), with
|
|
||||||
`{{VERSION}}` rendered to the release, so the Plainpages tag it tells adopters to pull cannot go
|
|
||||||
stale. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the
|
|
||||||
quick start stays a topology CI has actually run.
|
|
||||||
It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
|
|
||||||
outage leaves the promotion green and the images untouched; and the page has its own door — run the
|
|
||||||
workflow manually with an `overview_version` input to republish it without cutting a release. That
|
|
||||||
input goes through the same contract check as a tag: a non-semver value, or one whose `major.minor`
|
|
||||||
disagrees with the tree being published, is refused. It uses
|
|
||||||
the same `DOCKERHUB_TOKEN` the image push uses, which is why that token needs the **delete** scope.
|
|
||||||
|
|
||||||
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is
|
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is
|
||||||
read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift.
|
read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift.
|
||||||
@@ -1493,19 +1323,18 @@ images, and the Playwright runner + its browser image — and every bump keeps t
|
|||||||
exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once
|
exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once
|
||||||
`CI / full-gate (push)` is green; only a red gate needs a human.
|
`CI / full-gate (push)` is green; only a red gate needs a human.
|
||||||
|
|
||||||
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`) cuts **one**
|
**Releases are paused.** Plainpages is pre-announcement: the repository carries **no tags**, so
|
||||||
`vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and
|
neither `release.yml` nor Docker Hub has a version to promote. Turn releasing back on by setting the
|
||||||
**skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a
|
Actions variable `AUTO_RELEASE` to `true`, or cut a `vX.Y.Z` tag by hand **on `main`'s tip** — with
|
||||||
trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a
|
nothing tagged the nightly cleanup keeps only branch-head images, so an older commit's image is
|
||||||
`Release-Bump: <updateType>` trailer onto the updates that reach a running Plainpages — the rules in
|
already gone and `release.yml` would fail loud.
|
||||||
[`renovate.json`](renovate.json) name them — and
|
|
||||||
[`release-tooling/next-version.ts`](release-tooling/next-version.ts) turns the highest one into the next
|
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`, gated on
|
||||||
version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release
|
`AUTO_RELEASE`) cuts **one** `vX.Y.Z` tag per run covering the renovate-bot commits merged to `main`
|
||||||
version, an update big enough to reach a **minor** stops the job rather than tagging: bump
|
since the last tag, and **skips** when the tip isn't a Renovate commit or nothing new merged.
|
||||||
`HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since
|
Renovate stamps each commit with a `Release-Bump: <updateType>` trailer and
|
||||||
`nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump,
|
[`auto-release/next-version.ts`](auto-release/next-version.ts) turns the highest one into the next
|
||||||
so the trailer is an allowlist: an update outside those rules carries none and rides the next patch
|
version — pre-1.0 it never auto-crosses into `1.0.0`. It is **tag-only**: the tag hands off to
|
||||||
release instead of escalating it. It is **tag-only**: the tag hands off to
|
|
||||||
`release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by
|
`release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by
|
||||||
the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here.
|
the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here.
|
||||||
|
|
||||||
@@ -1514,7 +1343,7 @@ the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never tou
|
|||||||
| Actions var / secret | Value |
|
| Actions var / secret | Value |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. |
|
| `DOCKER_REGISTRY_USER` (var) + `DOCKER_REGISTRY_TOKEN` (secret) | A Gitea account with package write in the `larvit` org, and its access token with `read:package` + `write:package`. Reused by `registry-cleanup.yml`. |
|
||||||
| `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a **read/write/delete** token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT reaches every repo under it, and delete is destructive). Delete is what publishing the overview needs; pushing images alone would not. |
|
| `DOCKERHUB_USER` (var) + `DOCKERHUB_TOKEN` (secret) | The public `larvit/plainpages` Docker Hub repo, and a read/write token **scoped to that repository** (an org access token, or one on a dedicated account — an account-wide PAT can push to every repo under it). |
|
||||||
| `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. |
|
| `MIRROR_GITHUB_TOKEN` (secret) | A fine-grained PAT (Contents: read & write) for a GitHub machine account with write access to the mirror. Its `main` must not block force-pushes and must carry no tag protection, which would reject the prune. |
|
||||||
| `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. |
|
| `RENOVATE_TOKEN` (secret) | The shared `renovate@larvit.se` bot's Gitea PAT, with write access to this repo. |
|
||||||
| `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. |
|
| `RENOVATE_GITHUB_TOKEN` (secret) | A **scopeless** (read-only) github.com PAT, so Renovate's lookups of github.com-hosted deps run authenticated instead of tripping the anonymous 60-req/hour limit. |
|
||||||
@@ -1542,11 +1371,6 @@ the one-shot bootstrap) — and mounts no source. Secrets come from the environm
|
|||||||
running insecure. Before going live, supply the production secrets and any SSO credentials — the
|
running insecure. Before going live, supply the production secrets and any SSO credentials — the
|
||||||
**only** manual prep ([What you must supply](#what-you-must-supply-the-only-manual-prep)).
|
**only** manual prep ([What you must supply](#what-you-must-supply-the-only-manual-prep)).
|
||||||
|
|
||||||
**Back up the `pgdata` volume.** Once a plugin declares [storage](#plugin-storage), Postgres holds
|
|
||||||
business data that exists nowhere else, alongside Ory's identities — the stack stops being
|
|
||||||
reproducible from the image and config alone. Snapshot the volume, or `pg_dump` each database on a
|
|
||||||
schedule, and rehearse the restore.
|
|
||||||
|
|
||||||
Every response carries security headers (`src/http/security-headers.ts`): a strict
|
Every response carries security headers (`src/http/security-headers.ts`): a strict
|
||||||
`Content-Security-Policy` (the core is zero-JS — `script-src 'self'`, no inline scripts),
|
`Content-Security-Policy` (the core is zero-JS — `script-src 'self'`, no inline scripts),
|
||||||
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` + `frame-ancestors 'none'`,
|
`X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY` + `frame-ancestors 'none'`,
|
||||||
@@ -1579,9 +1403,9 @@ docker compose up -d --build
|
|||||||
```
|
```
|
||||||
|
|
||||||
Do the same for any other folder you copied out of `examples/`. A plugin you wrote yourself needs the
|
Do the same for any other folder you copied out of `examples/`. A plugin you wrote yourself needs the
|
||||||
manifest change the error names. A host contract change big enough to move
|
manifest change the error names. Once [`HOST_API_VERSION`](#contract-versioning) starts moving a
|
||||||
[`HOST_API_VERSION`](#contract-versioning) shows up earlier and more precisely — discovery refuses the
|
stale plugin will be refused by **version** instead; it is frozen at `1.0.0` until the first external
|
||||||
plugin by **version** before any rule gets a chance to trip.
|
plugin exists, so for now the error names the rule it tripped.
|
||||||
|
|
||||||
Two paths in the checkout are load-bearing and must stay clear of root-owned leftovers:
|
Two paths in the checkout are load-bearing and must stay clear of root-owned leftovers:
|
||||||
`node_modules/` must not exist (deps live at `/node_modules`, and anything at `/app/node_modules`
|
`node_modules/` must not exist (deps live at `/node_modules`, and anything at `/app/node_modules`
|
||||||
@@ -1697,8 +1521,7 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
|
|||||||
i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime ·
|
i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime ·
|
||||||
english · view-locals · locales/ (the core en-US + sv-SE catalogs)
|
english · view-locals · locales/ (the core en-US + sv-SE catalogs)
|
||||||
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
|
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
|
||||||
(ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) ·
|
(ctx.system) · discovery · router · hooks · view-resolver
|
||||||
storage-provisioning (the DDL; bootstrap-only, holds the driver)
|
|
||||||
ui/ chrome (the one global menu) · shell-context · dashboard · nav (composeNav) ·
|
ui/ chrome (the one global menu) · shell-context · dashboard · nav (composeNav) ·
|
||||||
menu-config (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
|
menu-config (`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
|
||||||
|
|
||||||
@@ -1717,11 +1540,9 @@ examples/ Copy-in reference mirroring the mount dirs: plugins/schedul
|
|||||||
config/menu.ts, and shifts-upstream/ (the dev mock backend)
|
config/menu.ts, and shifts-upstream/ (the dev mock backend)
|
||||||
e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml;
|
e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,auth,oauth,full,devstack}.yml;
|
||||||
proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow
|
proxy.ts (same-origin gateway) and mock-oidc.ts back full-flow
|
||||||
release-tooling/ Everything the release runs: next-version (the bump math), contract-version
|
|
||||||
(the HOST_API_VERSION↔tag gate), dockerhub-overview (+ its .md.tmpl)
|
|
||||||
registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts)
|
|
||||||
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
|
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
|
||||||
.gitea/workflows/ Gitea Actions — see CI/CD
|
.gitea/workflows/ Gitea Actions — see CI/CD
|
||||||
|
README-dockerhub.md The Docker Hub repository description, pasted over by hand when it changes
|
||||||
```
|
```
|
||||||
|
|
||||||
## Extending the core
|
## Extending the core
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+4
-16
@@ -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,12 +13,9 @@ 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
|
||||||
PLUGIN_SETTING_SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/
|
SCHEDULING_UPSTREAM: "http://shifts-upstream:4000" # backs the reference plugin once you copy it into plugins/
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
# Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise):
|
# Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise):
|
||||||
@@ -36,18 +29,13 @@ services:
|
|||||||
# It belongs here and not in the base file, where it would desynchronise prod and collide with the
|
# It belongs here and not in the base file, where it would desynchronise prod and collide with the
|
||||||
# e2e stacks, which bind individual plugins *inside* /app/plugins.
|
# e2e stacks, which bind individual plugins *inside* /app/plugins.
|
||||||
bootstrap:
|
bootstrap:
|
||||||
# Provisions the plugin databases web connects to above, as the dev superuser.
|
|
||||||
environment:
|
|
||||||
PLUGIN_DB_ADMIN_URL: postgres://${POSTGRES_USER:-ory}:${POSTGRES_PASSWORD:-ory}@postgres:5432/ory
|
|
||||||
PLUGIN_DB_URL: *plugin-db-url
|
|
||||||
REQUIRE_SECURE_SECRETS: "false" # dev derives from the throwaway, as web does
|
|
||||||
volumes:
|
volumes:
|
||||||
- .:/app
|
- .:/app
|
||||||
|
|
||||||
# Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so
|
# Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so
|
||||||
# the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this
|
# the plugin is opt-in — `cp -r examples/plugins/scheduling plugins/scheduling`, restart, and this
|
||||||
# backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
||||||
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
|
# stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead.
|
||||||
shifts-upstream:
|
shifts-upstream:
|
||||||
image: node:24.19.0-alpine3.24
|
image: node:24.19.0-alpine3.24
|
||||||
command: node /srv/server.ts
|
command: node /srv/server.ts
|
||||||
@@ -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
|
||||||
|
|||||||
+3
-21
@@ -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,17 +30,14 @@ 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.6-alpine3.23
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -195,17 +195,6 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
|
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
|
||||||
});
|
});
|
||||||
|
|
||||||
test("plugin settings: the screen names the variable that sets each declared key", async () => {
|
|
||||||
await page.goto("/admin/plugin-settings");
|
|
||||||
await expect(page.locator("h1")).toHaveText("Plugin settings");
|
|
||||||
// The reference plugin's one declared setting, and the variable an operator would set for it.
|
|
||||||
const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" });
|
|
||||||
await expect(scheduling).toContainText("upstream");
|
|
||||||
await expect(scheduling).toContainText("http://shifts-upstream:4000"); // resolved, and its source shown
|
|
||||||
// Every installed plugin gets a section, so "declares none" is distinguishable from "not installed".
|
|
||||||
await expect(page.locator("h2", { hasText: "admin" })).toHaveCount(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("logout: signing out ends the session and returns to the login page", async () => {
|
test("logout: signing out ends the session and returns to the login page", async () => {
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await page.locator("button.profile").click(); // open the profile dropdown
|
await page.locator("button.profile").click(); // open the profile dropdown
|
||||||
|
|||||||
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",
|
||||||
|
|||||||
+1
-1
@@ -8,4 +8,4 @@ across (or bind-mount your own) and restart.
|
|||||||
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
|
| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `@plainpages/plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). |
|
||||||
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
|
| [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
|
||||||
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
|
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
|
||||||
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `PLUGIN_SETTING_SCHEDULING_UPSTREAM` at the real thing instead. |
|
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. |
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
import assert from "node:assert/strict";
|
|
||||||
import test from "node:test";
|
|
||||||
import type { PageChrome, PluginSettings } from "@plainpages/plugin-api";
|
|
||||||
import { buildPluginSettingsModel } from "./admin-plugin-settings.ts";
|
|
||||||
|
|
||||||
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
|
||||||
|
|
||||||
const CATALOG: readonly PluginSettings[] = [
|
|
||||||
{
|
|
||||||
pluginId: "scheduling",
|
|
||||||
settings: [
|
|
||||||
{ description: "Where shifts come from", envName: "PLUGIN_SETTING_SCHEDULING_UPSTREAM", key: "upstream", required: true, secret: false, source: "env", type: "url", value: "https://shifts.test" },
|
|
||||||
{ envName: "PLUGIN_SETTING_SCHEDULING_MODE", key: "mode", required: false, secret: false, source: "default", type: "enum", value: "strict", values: ["strict", "lenient"] },
|
|
||||||
{ envName: "PLUGIN_SETTING_SCHEDULING_NOTE", key: "note", required: false, secret: false, source: "unset", type: "string" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{ pluginId: "quiet", settings: [] },
|
|
||||||
];
|
|
||||||
|
|
||||||
test("a row carries the variable to set and where the value came from", () => {
|
|
||||||
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
|
|
||||||
const rows = model.groups[0]?.table.rows ?? [];
|
|
||||||
assert.deepEqual(rows.map((r) => r.name), ["upstream", "mode", "note"]);
|
|
||||||
assert.deepEqual(rows[0]?.cells, [
|
|
||||||
{ rowHeader: { text: "upstream" } }, "Where shifts come from", "url", "Yes", "PLUGIN_SETTING_SCHEDULING_UPSTREAM", "Environment", "https://shifts.test",
|
|
||||||
]);
|
|
||||||
assert.equal(rows[1]?.cells[2], "enum (strict, lenient)"); // the choices are the useful half of the type
|
|
||||||
assert.equal(rows[2]?.cells[5], "Not set");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a plugin declaring nothing still gets a section, so it is visibly installed", () => {
|
|
||||||
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
|
|
||||||
assert.deepEqual(model.groups.map((g) => g.pluginId), ["scheduling", "quiet"]);
|
|
||||||
assert.deepEqual(model.groups[1]?.table.rows, []);
|
|
||||||
assert.match(model.groups[1]?.emptyText ?? "", /no settings/i);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a secret renders as set-or-not, never as a value, a mask or a length", () => {
|
|
||||||
const settings: readonly PluginSettings[] = [{
|
|
||||||
pluginId: "billing",
|
|
||||||
settings: [
|
|
||||||
{ envName: "PLUGIN_SETTING_BILLING_API_KEY", key: "apiKey", required: false, secret: true, source: "env", type: "string" },
|
|
||||||
{ envName: "PLUGIN_SETTING_BILLING_WEBHOOK_KEY", key: "webhookKey", required: false, secret: true, source: "unset", type: "string" },
|
|
||||||
],
|
|
||||||
}];
|
|
||||||
const rows = buildPluginSettingsModel({ chrome: CHROME, settings }).groups[0]?.table.rows ?? [];
|
|
||||||
assert.equal(rows[0]?.cells[6], "Secret — set");
|
|
||||||
assert.equal(rows[1]?.cells[6], "Secret — not set");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("two tables on one page need distinct row-action id stems", () => {
|
|
||||||
const model = buildPluginSettingsModel({ chrome: CHROME, settings: CATALOG });
|
|
||||||
const stems = model.groups.map((g) => g.table.actionsId);
|
|
||||||
assert.equal(new Set(stems).size, stems.length);
|
|
||||||
});
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
// Plugin settings admin screen: what each installed plugin declares it can be configured with, the
|
|
||||||
// variable that sets it, and how each key resolved. Read-only — the host reads settings from the
|
|
||||||
// environment at boot, so changing one is a deploy, not a form.
|
|
||||||
|
|
||||||
import { type PageChrome, type PluginSettings, type RouteHandler, type SettingSummary, type Translate } from "@plainpages/plugin-api";
|
|
||||||
import { ADMIN_EN, requirePermission } from "./admin-shared.ts";
|
|
||||||
|
|
||||||
interface SettingsGroup {
|
|
||||||
emptyText: string;
|
|
||||||
pluginId: string;
|
|
||||||
table: {
|
|
||||||
actionsId: string;
|
|
||||||
caption: string;
|
|
||||||
columns: { label: string }[];
|
|
||||||
rows: { cells: (string | { rowHeader: { text: string } })[]; name: string }[];
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// One group per installed plugin, including those declaring nothing — an operator who cannot find
|
|
||||||
// their plugin here has not installed it, which is the other half of what this screen answers.
|
|
||||||
export function buildPluginSettingsModel(opts: { chrome: PageChrome; settings: readonly PluginSettings[]; t?: Translate }) {
|
|
||||||
const t = opts.t ?? ADMIN_EN;
|
|
||||||
return {
|
|
||||||
breadcrumbs: [{ label: t("admin.pluginSettings.title") }],
|
|
||||||
chrome: opts.chrome,
|
|
||||||
groups: opts.settings.map((plugin): SettingsGroup => ({
|
|
||||||
emptyText: t("admin.pluginSettings.none"),
|
|
||||||
pluginId: plugin.pluginId,
|
|
||||||
table: {
|
|
||||||
actionsId: `settings-${plugin.pluginId}`, // two tables share this page, so the stem must differ
|
|
||||||
caption: t("admin.pluginSettings.caption", { plugin: plugin.pluginId }),
|
|
||||||
columns: [
|
|
||||||
{ label: t("admin.pluginSettings.column.key") },
|
|
||||||
{ label: t("admin.pluginSettings.column.description") },
|
|
||||||
{ label: t("admin.pluginSettings.column.type") },
|
|
||||||
{ label: t("admin.pluginSettings.column.required") },
|
|
||||||
{ label: t("admin.pluginSettings.column.variable") },
|
|
||||||
{ label: t("admin.pluginSettings.column.source") },
|
|
||||||
{ label: t("admin.pluginSettings.column.value") },
|
|
||||||
],
|
|
||||||
rows: plugin.settings.map((setting) => ({
|
|
||||||
cells: [
|
|
||||||
{ rowHeader: { text: setting.key } },
|
|
||||||
setting.description ?? "",
|
|
||||||
typeLabel(setting),
|
|
||||||
t(setting.required ? "admin.pluginSettings.yes" : "admin.pluginSettings.no"),
|
|
||||||
setting.envName,
|
|
||||||
t(`admin.pluginSettings.source.${setting.source}`),
|
|
||||||
valueLabel(setting, t),
|
|
||||||
],
|
|
||||||
name: setting.key,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
title: t("admin.pluginSettings.title"),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// An enum's choices are the useful half of its type — they are what the operator must pick from.
|
|
||||||
function typeLabel(setting: SettingSummary): string {
|
|
||||||
return setting.type === "enum" && setting.values ? `${setting.type} (${setting.values.join(", ")})` : setting.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A secret never renders its value — not the value, not a mask of it, not its length. Whether it
|
|
||||||
// resolved and from where is what an operator needs, and the source column already says the rest.
|
|
||||||
function valueLabel(setting: SettingSummary, t: Translate): string {
|
|
||||||
if (setting.secret) return t(setting.source === "unset" ? "admin.pluginSettings.secretUnset" : "admin.pluginSettings.secretSet");
|
|
||||||
return setting.value ?? t("admin.pluginSettings.unset");
|
|
||||||
}
|
|
||||||
|
|
||||||
// GET /admin/plugin-settings
|
|
||||||
export const pluginSettingsList: RouteHandler = (ctx) => {
|
|
||||||
requirePermission(ctx, "plugin-settings");
|
|
||||||
return { data: { chrome: ctx.chrome, model: buildPluginSettingsModel({ chrome: ctx.chrome, settings: ctx.declaredSettings, t: ctx.t }) }, view: "plugin-settings" };
|
|
||||||
};
|
|
||||||
@@ -19,7 +19,7 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
|
|||||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||||
req.method = opts.method ?? "GET";
|
req.method = opts.method ?? "GET";
|
||||||
return {
|
return {
|
||||||
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
|
chrome: CHROME, declaredPermissions: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: {} as Log, params: {},
|
||||||
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
|
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.user?.permissions ?? [], t: ADMIN_EN, url,
|
||||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||||
};
|
};
|
||||||
@@ -27,21 +27,21 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
|
|||||||
|
|
||||||
// ---- nav fragment ----
|
// ---- nav fragment ----
|
||||||
|
|
||||||
test("ADMIN_NAV: an ungated Admin header whose screens each gate on their own read permission", () => {
|
test("ADMIN_NAV: an ungated Admin header whose three screens each gate on their own read permission", () => {
|
||||||
assert.equal(ADMIN_NAV.id, "admin");
|
assert.equal(ADMIN_NAV.id, "admin");
|
||||||
// No gate on the header: a user may hold one screen's permission and not another's. composeNav
|
// No gate on the header: a user may hold one screen's permission and not another's. composeNav
|
||||||
// drops a header left with no visible children, so holding none of them hides the section.
|
// drops a header left with no visible children, so holding none of the three hides the section.
|
||||||
// Both halves matter — give the header an `href` and it survives the filter as a visible leaf,
|
// Both halves matter — give the header an `href` and it survives the filter as a visible leaf,
|
||||||
// ungated, for anonymous visitors included.
|
// ungated, for anonymous visitors included.
|
||||||
assert.equal(ADMIN_NAV.permission, undefined);
|
assert.equal(ADMIN_NAV.permission, undefined);
|
||||||
assert.equal(ADMIN_NAV.href, undefined);
|
assert.equal(ADMIN_NAV.href, undefined);
|
||||||
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
|
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
|
||||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients", "/admin/plugin-settings"]);
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients"]);
|
||||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read", "plugin-settings:read"]);
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.permission), ["users:read", "groups:read", "oauth2-clients:read"]);
|
||||||
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes
|
// Labels are catalog keys; the host translates them with this plugin's catalog when it composes
|
||||||
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
|
// the menu, so what a visitor sees is the en-US (or sv-SE …) wording behind these keys.
|
||||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients", "admin.nav.pluginSettings"]);
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients"]);
|
||||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients", "Plugin settings"]);
|
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients"]);
|
||||||
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
|
assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -12,13 +12,12 @@ export const ADMIN_EN: Translate = englishTranslator(enUS);
|
|||||||
export const ADMIN_USERS_BASE = "/admin/users";
|
export const ADMIN_USERS_BASE = "/admin/users";
|
||||||
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
export const ADMIN_GROUPS_BASE = "/admin/groups";
|
||||||
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||||
export const ADMIN_PLUGIN_SETTINGS_BASE = "/admin/plugin-settings";
|
|
||||||
|
|
||||||
// One resource per screen — the `<resource>` half of every permission this plugin gates on.
|
// One resource per screen — the `<resource>` half of every permission this plugin gates on.
|
||||||
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
|
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
|
||||||
// There is no `permissions` resource: permissions are declared in plugin code, not created here, so
|
// There is no `permissions` resource: permissions are declared in plugin code, not created here, so
|
||||||
// holding a grant is a property of a user or a group and is edited on those two screens.
|
// holding a grant is a property of a user or a group and is edited on those two screens.
|
||||||
export type AdminResource = "groups" | "oauth2-clients" | "plugin-settings" | "users";
|
export type AdminResource = "groups" | "oauth2-clients" | "users";
|
||||||
|
|
||||||
export type AdminAction = "read" | "write";
|
export type AdminAction = "read" | "write";
|
||||||
|
|
||||||
@@ -44,7 +43,6 @@ export const ADMIN_NAV: NavNode = {
|
|||||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
|
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
|
||||||
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") },
|
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") },
|
||||||
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") },
|
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") },
|
||||||
{ href: ADMIN_PLUGIN_SETTINGS_BASE, icon: "i-sliders", id: "plugin-settings", label: "admin.nav.pluginSettings", permission: permissionName("plugin-settings", "read") },
|
|
||||||
],
|
],
|
||||||
icon: "i-shield",
|
icon: "i-shield",
|
||||||
id: "admin",
|
id: "admin",
|
||||||
|
|||||||
@@ -83,29 +83,9 @@ const messages = {
|
|||||||
|
|
||||||
"admin.nav.clients": "OAuth2 clients",
|
"admin.nav.clients": "OAuth2 clients",
|
||||||
"admin.nav.groups": "Groups",
|
"admin.nav.groups": "Groups",
|
||||||
"admin.nav.pluginSettings": "Plugin settings",
|
|
||||||
"admin.nav.section": "Admin",
|
"admin.nav.section": "Admin",
|
||||||
"admin.nav.users": "Users",
|
"admin.nav.users": "Users",
|
||||||
|
|
||||||
"admin.pluginSettings.caption": "Settings declared by {{plugin}}",
|
|
||||||
"admin.pluginSettings.column.description": "Description",
|
|
||||||
"admin.pluginSettings.column.key": "Key",
|
|
||||||
"admin.pluginSettings.column.required": "Required",
|
|
||||||
"admin.pluginSettings.column.source": "Source",
|
|
||||||
"admin.pluginSettings.column.type": "Type",
|
|
||||||
"admin.pluginSettings.column.value": "Value",
|
|
||||||
"admin.pluginSettings.column.variable": "Variable",
|
|
||||||
"admin.pluginSettings.no": "No",
|
|
||||||
"admin.pluginSettings.none": "This plugin declares no settings.",
|
|
||||||
"admin.pluginSettings.secretSet": "Secret — set",
|
|
||||||
"admin.pluginSettings.secretUnset": "Secret — not set",
|
|
||||||
"admin.pluginSettings.source.default": "Default",
|
|
||||||
"admin.pluginSettings.source.env": "Environment",
|
|
||||||
"admin.pluginSettings.source.unset": "Not set",
|
|
||||||
"admin.pluginSettings.title": "Plugin settings",
|
|
||||||
"admin.pluginSettings.unset": "—",
|
|
||||||
"admin.pluginSettings.yes": "Yes",
|
|
||||||
|
|
||||||
"admin.notFound.message": "That item doesn't exist.",
|
"admin.notFound.message": "That item doesn't exist.",
|
||||||
"admin.notFound.title": "Not found",
|
"admin.notFound.title": "Not found",
|
||||||
|
|
||||||
|
|||||||
@@ -83,29 +83,9 @@ const messages: AdminMessages = {
|
|||||||
|
|
||||||
"admin.nav.clients": "OAuth2-klienter",
|
"admin.nav.clients": "OAuth2-klienter",
|
||||||
"admin.nav.groups": "Grupper",
|
"admin.nav.groups": "Grupper",
|
||||||
"admin.nav.pluginSettings": "Tilläggsinställningar",
|
|
||||||
"admin.nav.section": "Administration",
|
"admin.nav.section": "Administration",
|
||||||
"admin.nav.users": "Användare",
|
"admin.nav.users": "Användare",
|
||||||
|
|
||||||
"admin.pluginSettings.caption": "Inställningar som {{plugin}} deklarerar",
|
|
||||||
"admin.pluginSettings.column.description": "Beskrivning",
|
|
||||||
"admin.pluginSettings.column.key": "Nyckel",
|
|
||||||
"admin.pluginSettings.column.required": "Obligatorisk",
|
|
||||||
"admin.pluginSettings.column.source": "Källa",
|
|
||||||
"admin.pluginSettings.column.type": "Typ",
|
|
||||||
"admin.pluginSettings.column.value": "Värde",
|
|
||||||
"admin.pluginSettings.column.variable": "Variabel",
|
|
||||||
"admin.pluginSettings.no": "Nej",
|
|
||||||
"admin.pluginSettings.none": "Det här tillägget deklarerar inga inställningar.",
|
|
||||||
"admin.pluginSettings.secretSet": "Hemlighet — satt",
|
|
||||||
"admin.pluginSettings.secretUnset": "Hemlighet — inte satt",
|
|
||||||
"admin.pluginSettings.source.default": "Standardvärde",
|
|
||||||
"admin.pluginSettings.source.env": "Miljövariabel",
|
|
||||||
"admin.pluginSettings.source.unset": "Inte satt",
|
|
||||||
"admin.pluginSettings.title": "Tilläggsinställningar",
|
|
||||||
"admin.pluginSettings.unset": "—",
|
|
||||||
"admin.pluginSettings.yes": "Ja",
|
|
||||||
|
|
||||||
"admin.notFound.message": "Objektet finns inte.",
|
"admin.notFound.message": "Objektet finns inte.",
|
||||||
"admin.notFound.title": "Hittades inte",
|
"admin.notFound.title": "Hittades inte",
|
||||||
|
|
||||||
|
|||||||
@@ -35,20 +35,17 @@ test("every nav permission is one the manifest declares", () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
walk(manifest.nav);
|
walk(manifest.nav);
|
||||||
assert.equal(navPermissions.length, 4);
|
assert.equal(navPermissions.length, 3);
|
||||||
for (const name of navPermissions) assert.ok(declared.includes(name), `nav gates on undeclared ${name}`);
|
for (const name of navPermissions) assert.ok(declared.includes(name), `nav gates on undeclared ${name}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => {
|
test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => {
|
||||||
for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it
|
for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it
|
||||||
// Three CRUD screens × read/write, plus read-only plugin settings — a screen that never writes
|
// Three screens × read/write. There is deliberately no `permissions:` pair: permissions are
|
||||||
// declares no `:write`, since a permission nothing gates on is one an operator can only mis-grant.
|
// declared in plugin code, so holding one is edited on the user or group that holds it.
|
||||||
// There is deliberately no `permissions:` pair either: permissions are declared in plugin code, so
|
|
||||||
// holding one is edited on the user or group that holds it.
|
|
||||||
assert.deepEqual([...declared].sort(), [
|
assert.deepEqual([...declared].sort(), [
|
||||||
"groups:read", "groups:write",
|
"groups:read", "groups:write",
|
||||||
"oauth2-clients:read", "oauth2-clients:write",
|
"oauth2-clients:read", "oauth2-clients:write",
|
||||||
"plugin-settings:read",
|
|
||||||
"users:read", "users:write",
|
"users:read", "users:write",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -61,5 +58,5 @@ test("GET routes gate on read and mutations on write, so a reader can open a scr
|
|||||||
const action = route.method === "GET" && !writeIntent(route.path) ? "read" : "write";
|
const action = route.method === "GET" && !writeIntent(route.path) ? "read" : "write";
|
||||||
assert.ok(route.permission?.endsWith(`:${action}`), `${route.method} ${route.path} → ${route.permission}`);
|
assert.ok(route.permission?.endsWith(`:${action}`), `${route.method} ${route.path} → ${route.permission}`);
|
||||||
}
|
}
|
||||||
assert.equal(routes.filter((r) => r.method === "GET" && writeIntent(r.path)).length, 6); // 2 per CRUD screen; plugin settings has none
|
assert.equal(routes.filter((r) => r.method === "GET" && writeIntent(r.path)).length, 6); // 2 per screen
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,6 @@
|
|||||||
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api";
|
import { definePlugin, type HttpMethod, type Route, type RouteHandler } from "@plainpages/plugin-api";
|
||||||
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
||||||
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
|
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
|
||||||
import { pluginSettingsList } from "./admin-plugin-settings.ts";
|
|
||||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||||
import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
|
import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
|
||||||
|
|
||||||
@@ -25,10 +24,9 @@ const on = (resource: AdminResource) => (method: HttpMethod, path: string, handl
|
|||||||
const users = on("users");
|
const users = on("users");
|
||||||
const groups = on("groups");
|
const groups = on("groups");
|
||||||
const clients = on("oauth2-clients");
|
const clients = on("oauth2-clients");
|
||||||
const pluginSettings = on("plugin-settings");
|
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||||
|
|
||||||
nav: [ADMIN_NAV],
|
nav: [ADMIN_NAV],
|
||||||
|
|
||||||
@@ -39,7 +37,6 @@ export default definePlugin({
|
|||||||
{ description: "Create and delete groups, and change their members and permissions", name: "groups:write" },
|
{ description: "Create and delete groups, and change their members and permissions", name: "groups:write" },
|
||||||
{ description: "View OAuth2 clients", name: "oauth2-clients:read" },
|
{ description: "View OAuth2 clients", name: "oauth2-clients:read" },
|
||||||
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
|
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
|
||||||
{ description: "View the settings each installed plugin declares, and how they resolved", name: "plugin-settings:read" },
|
|
||||||
],
|
],
|
||||||
|
|
||||||
routes: [
|
routes: [
|
||||||
@@ -71,7 +68,5 @@ export default definePlugin({
|
|||||||
clients("GET", "/clients/:id", clientsDetail),
|
clients("GET", "/clients/:id", clientsDetail),
|
||||||
clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"),
|
clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"),
|
||||||
clients("POST", "/clients/:id/delete", clientsDelete),
|
clients("POST", "/clients/:id/delete", clientsDelete),
|
||||||
// Plugin settings — read-only, so no :write route and no write-intent GET.
|
|
||||||
pluginSettings("GET", "/plugin-settings", pluginSettingsList),
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
<%#
|
|
||||||
Plugin settings admin list: one section per installed plugin, each a table of what it declares
|
|
||||||
and how each key resolved (admin-plugin-settings.ts). Read-only — no actions, no forms.
|
|
||||||
%><%
|
|
||||||
const nav = include("partials/nav-tree", { nodes: chrome.nav });
|
|
||||||
let body = "";
|
|
||||||
for (const group of model.groups) {
|
|
||||||
// A plugin id is the folder name, which discovery constrains to [a-z0-9-] — no escaping needed.
|
|
||||||
body += '<h2 class="h2">' + group.pluginId + "</h2>";
|
|
||||||
body += group.table.rows.length === 0
|
|
||||||
? '<p class="muted">' + group.emptyText + "</p>"
|
|
||||||
: include("partials/data-table", group.table);
|
|
||||||
}
|
|
||||||
-%>
|
|
||||||
<%- include("partials/shell", {
|
|
||||||
body,
|
|
||||||
brand: chrome.brand,
|
|
||||||
breadcrumbs: model.breadcrumbs,
|
|
||||||
csrfToken: chrome.csrfToken,
|
|
||||||
nav,
|
|
||||||
theme: chrome.theme,
|
|
||||||
title: model.title,
|
|
||||||
user: chrome.user,
|
|
||||||
}) %>
|
|
||||||
@@ -27,7 +27,7 @@ The plugin holds **no state** — data lives upstream (README → *Stateless*).
|
|||||||
|
|
||||||
## Upstream
|
## Upstream
|
||||||
|
|
||||||
Set `PLUGIN_SETTING_SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
|
Set `SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
|
||||||
mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box.
|
mock (`examples/shifts-upstream/`) so `docker compose up` shows the plugin working out of the box.
|
||||||
A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook).
|
A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook).
|
||||||
|
|
||||||
|
|||||||
@@ -3,20 +3,19 @@
|
|||||||
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
|
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
|
||||||
|
|
||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "@plainpages/plugin-api";
|
||||||
import { createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
||||||
|
|
||||||
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
|
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
|
||||||
// stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it
|
// stateless). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream).
|
||||||
// over — which is after this manifest is built, hence the getter.
|
const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000";
|
||||||
let upstreamUrl = "";
|
const upstream = createUpstream(upstreamUrl);
|
||||||
const upstream = createUpstream(() => upstreamUrl);
|
|
||||||
|
|
||||||
export default definePlugin({
|
export default definePlugin({
|
||||||
apiVersion: "0.2.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||||
|
|
||||||
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
|
// onBoot runs after discovery, before the server listens: validate the plugin's own config so a
|
||||||
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
|
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
|
||||||
hooks: { onBoot: ({ settings }) => { upstreamUrl = settings.upstream; } },
|
hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") },
|
||||||
|
|
||||||
// Merged into the global menu + filtered per user. Labels are keys in this plugin's own catalog
|
// Merged into the global menu + filtered per user. Labels are keys in this plugin's own catalog
|
||||||
// (i18n/<locale>.ts) — a plain string works too, it just isn't translated. "Overview" is `public`, so the "Scheduling"
|
// (i18n/<locale>.ts) — a plain string works too, it just isn't translated. "Overview" is `public`, so the "Scheduling"
|
||||||
@@ -46,15 +45,4 @@ export default definePlugin({
|
|||||||
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
|
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
|
||||||
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
|
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
|
||||||
],
|
],
|
||||||
|
|
||||||
// Operator-supplied config: one PLUGIN_SETTING_SCHEDULING_UPSTREAM variable, validated as a URL at
|
|
||||||
// boot. The default points at the mock backend the dev compose runs (examples/shifts-upstream).
|
|
||||||
settings: [
|
|
||||||
{
|
|
||||||
default: "http://shifts-upstream:4000",
|
|
||||||
description: "Base URL of the backend this plugin reads shifts from and writes them to",
|
|
||||||
key: "upstream",
|
|
||||||
type: "url",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import test from "node:test";
|
|||||||
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 "@plainpages/plugin-api";
|
||||||
import enUS from "./i18n/en-US.ts";
|
import enUS from "./i18n/en-US.ts";
|
||||||
import {
|
import {
|
||||||
buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
||||||
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
||||||
} from "./shifts.ts";
|
} from "./shifts.ts";
|
||||||
|
|
||||||
@@ -18,7 +18,7 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; ve
|
|||||||
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||||
return {
|
return {
|
||||||
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
chrome: CHROME, declaredPermissions: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
||||||
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
|
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
|
||||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||||
};
|
};
|
||||||
@@ -35,25 +35,27 @@ const asView = (r: RouteResult | void) => {
|
|||||||
return r as { data: Record<string, unknown>; status?: number; view: string };
|
return r as { data: Record<string, unknown>; status?: number; view: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---- the upstream URL as a declared setting ----
|
// ---- upstream config validation (the onBoot hook) ----
|
||||||
|
|
||||||
test("the manifest declares its upstream as a URL setting the host validates", async () => {
|
test("assertHttpUrl accepts http(s) and fails loud on a malformed or non-http upstream URL", () => {
|
||||||
const manifest = (await import("./plugin.ts")).default;
|
assert.doesNotThrow(() => assertHttpUrl("http://shifts-upstream:4000", "SCHEDULING_UPSTREAM"));
|
||||||
assert.deepEqual(manifest.settings?.map((s) => s.key), ["upstream"]);
|
assert.doesNotThrow(() => assertHttpUrl("https://api.example.com/v1", "SCHEDULING_UPSTREAM"));
|
||||||
assert.equal(manifest.settings?.[0]?.type, "url"); // so a typo'd URL fails the boot, not every request
|
assert.throws(() => assertHttpUrl("not a url", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*valid URL/); // unparseable
|
||||||
assert.equal(manifest.settings?.[0]?.default, "http://shifts-upstream:4000"); // the dev compose's mock
|
assert.throws(() => assertHttpUrl("shifts-upstream:4000", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // missing // → parsed as a bogus scheme
|
||||||
assert.equal(typeof manifest.hooks?.onBoot, "function"); // without it the resolved value never arrives
|
assert.throws(() => assertHttpUrl("ftp://host/x", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // wrong scheme
|
||||||
});
|
});
|
||||||
|
|
||||||
test("the client re-reads its base URL, so onBoot can bind it after the manifest is built", async () => {
|
test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not just the helper)", async () => {
|
||||||
let baseUrl = "http://first:4000";
|
const prev = process.env["SCHEDULING_UPSTREAM"];
|
||||||
const seen: string[] = [];
|
process.env["SCHEDULING_UPSTREAM"] = "nope://bad"; // read at import time below
|
||||||
const http = (async (url) => { seen.push(String(url)); return new Response("[]", { status: 200 }); }) as typeof fetch;
|
try {
|
||||||
const upstream = createUpstream(() => baseUrl, http);
|
const manifest = (await import("./plugin.ts")).default;
|
||||||
await upstream.list();
|
assert.equal(typeof manifest.hooks?.onBoot, "function");
|
||||||
baseUrl = "http://second:4000";
|
assert.throws(() => manifest.hooks!.onBoot!(), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud
|
||||||
await upstream.list();
|
} finally {
|
||||||
assert.deepEqual(seen, ["http://first:4000/shifts", "http://second:4000/shifts"]);
|
if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"];
|
||||||
|
else process.env["SCHEDULING_UPSTREAM"] = prev;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- upstream client (fetch injected) ----
|
// ---- upstream client (fetch injected) ----
|
||||||
@@ -65,21 +67,21 @@ test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", as
|
|||||||
assert.equal((init?.headers as Record<string, string>).accept, "application/json");
|
assert.equal((init?.headers as Record<string, string>).accept, "application/json");
|
||||||
return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
|
return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
|
||||||
}) as typeof fetch;
|
}) as typeof fetch;
|
||||||
const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed
|
const shifts = await createUpstream("http://up:4000/", http).list(); // trailing slash trimmed
|
||||||
assert.equal(seen, "http://up:4000/shifts");
|
assert.equal(seen, "http://up:4000/shifts");
|
||||||
assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]);
|
assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
|
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
|
||||||
const http = (async () => new Response("nope", { status: 503 })) as typeof fetch;
|
const http = (async () => new Response("nope", { status: 503 })) as typeof fetch;
|
||||||
await assert.rejects(createUpstream(() => "http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
|
await assert.rejects(createUpstream("http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("createUpstream.create POSTs the input as JSON", async () => {
|
test("createUpstream.create POSTs the input as JSON", async () => {
|
||||||
let body: unknown, method = "";
|
let body: unknown, method = "";
|
||||||
const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch;
|
const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch;
|
||||||
const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" };
|
const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" };
|
||||||
await createUpstream(() => "http://up:4000", http).create(input);
|
await createUpstream("http://up:4000", http).create(input);
|
||||||
assert.equal(method, "POST");
|
assert.equal(method, "POST");
|
||||||
assert.deepEqual(body, input);
|
assert.deepEqual(body, input);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -49,16 +49,26 @@ export interface ShiftsUpstream {
|
|||||||
list(): Promise<Shift[]>;
|
list(): Promise<Shift[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fail loud at boot (the plugin's onBoot hook) on a malformed/non-http upstream URL — a config
|
||||||
|
// typo surfaces at startup, not as a degraded page later. Reachability stays a runtime concern.
|
||||||
|
export function assertHttpUrl(value: string, name: string): void {
|
||||||
|
let url: URL;
|
||||||
|
try {
|
||||||
|
url = new URL(value);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${name} is not a valid URL: ${JSON.stringify(value)}`);
|
||||||
|
}
|
||||||
|
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error(`${name} must be an http(s) URL: ${JSON.stringify(value)}`);
|
||||||
|
}
|
||||||
|
|
||||||
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
|
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
|
||||||
// defaults to the host's tracedFetch, so each upstream call joins the request's trace (a client
|
// defaults to the host's tracedFetch, so each upstream call joins the request's trace (a client
|
||||||
// span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network.
|
// span + a propagated traceparent); it's injectable so handlers unit-test against a mock, no network.
|
||||||
// `baseUrl` is read per call: the plugin's settings arrive on onBoot, after the manifest that binds
|
export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
|
||||||
// these handlers has already been built.
|
const base = baseUrl.replace(/\/+$/, "");
|
||||||
export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
|
|
||||||
const base = (): string => baseUrl().replace(/\/+$/, "");
|
|
||||||
return {
|
return {
|
||||||
async create(input) {
|
async create(input) {
|
||||||
const res = await fetchImpl(`${base()}/shifts`, {
|
const res = await fetchImpl(`${base}/shifts`, {
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -66,7 +76,7 @@ export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch =
|
|||||||
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
|
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
|
||||||
},
|
},
|
||||||
async list() {
|
async list() {
|
||||||
const res = await fetchImpl(`${base()}/shifts`, { headers: { accept: "application/json" } });
|
const res = await fetchImpl(`${base}/shifts`, { headers: { accept: "application/json" } });
|
||||||
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
|
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
|
||||||
const data: unknown = await res.json();
|
const data: unknown = await res.json();
|
||||||
return Array.isArray(data) ? data.map(toShift) : [];
|
return Array.isArray(data) ? data.map(toShift) : [];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Dev-only mock upstream for the reference plugin (examples/plugins/scheduling) — a stand-in for the
|
// Dev-only mock upstream for the reference plugin (examples/plugins/scheduling) — a stand-in for the
|
||||||
// customer's real backend, ready for when you copy the reference plugin into plugins/. NOT part
|
// customer's real backend, ready for when you copy the reference plugin into plugins/. NOT part
|
||||||
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM
|
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point SCHEDULING_UPSTREAM
|
||||||
// at your real service in production.
|
// at your real service in production.
|
||||||
//
|
//
|
||||||
// GET /shifts → 200 [ { id, title, assignee, start, end }, … ]
|
// GET /shifts → 200 [ { id, title, assignee, start, end }, … ]
|
||||||
|
|||||||
@@ -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.32.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.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.33.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.32.0.tgz",
|
||||||
"integrity": "sha512-jNGgvTNcLUfVRX4N9PH9pVVTJzoph/BmYmgU838bYBQodkUJL4nAThkuymFz1x3OUYMhJxPndC7rdg1sxOPYKg==",
|
"integrity": "sha512-lLIg/D57X3QDqAhk0Hoz7Yc36tU9XYgWLXWL21x4TOIulwWyiC9wlHDswCHMbm5O3fFBLsBUWJuDs9rEpm70rg==",
|
||||||
"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",
|
||||||
|
|||||||
+3
-3
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "plainpages",
|
"name": "plainpages",
|
||||||
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -13,13 +14,12 @@
|
|||||||
"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.32.0"
|
||||||
"postgres": "3.4.9"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/ejs": "3.1.5",
|
"@types/ejs": "3.1.5",
|
||||||
|
|||||||
@@ -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.2.0");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("every author-facing apiVersion sample matches the shipped contract", () => {
|
|
||||||
// A plugin author copies these; a stale one produces a boot-aborting refuse on first run. The
|
|
||||||
// examples deliberately write a literal rather than importing the constant (AGENTS.md), so this
|
|
||||||
// is the only thing keeping the copies honest.
|
|
||||||
const host = readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")) ?? "";
|
|
||||||
const [major, minor] = host.split(".");
|
|
||||||
for (const file of [
|
|
||||||
"README.md",
|
|
||||||
"examples/plugins/admin/plugin.ts",
|
|
||||||
"examples/plugins/scheduling/plugin.ts",
|
|
||||||
"release-tooling/dockerhub-overview.md.tmpl",
|
|
||||||
"views/index.ejs",
|
|
||||||
]) {
|
|
||||||
const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]);
|
|
||||||
assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`);
|
|
||||||
for (const sample of found) {
|
|
||||||
const [sMajor, sMinor] = (sample ?? "").split(".");
|
|
||||||
assert.equal(`${sMajor}.${sMinor}`, `${major}.${minor}`, `${file} samples apiVersion ${sample}, host is ${host}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("checkTagMatchesContract: major.minor must agree, patch may lag", () => {
|
|
||||||
assert.equal(checkTagMatchesContract("v0.1.0", "0.1.0").ok, true);
|
|
||||||
assert.equal(checkTagMatchesContract("v0.1.7", "0.1.0").ok, true); // auto-release cut patches
|
|
||||||
assert.equal(checkTagMatchesContract("0.1.0", "0.1.0").ok, true); // bare tag, no v
|
|
||||||
assert.equal(checkTagMatchesContract("v0.2.0", "0.1.0").ok, false); // plugin-visible, needs a bump
|
|
||||||
assert.equal(checkTagMatchesContract("v1.0.0", "0.1.0").ok, false);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("checkTagMatchesContract names what to fix rather than just failing", () => {
|
|
||||||
const res = checkTagMatchesContract("v0.2.0", "0.1.0");
|
|
||||||
assert.equal(res.ok, false);
|
|
||||||
assert.match(res.ok === false ? res.error : "", /HOST_API_VERSION to 0\.2\.0/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("checkTagMatchesContract rejects junk on either side without throwing", () => {
|
|
||||||
assert.equal(checkTagMatchesContract("v0.1.0", null).ok, false); // constant not found
|
|
||||||
assert.equal(checkTagMatchesContract("nope", "0.1.0").ok, false);
|
|
||||||
assert.equal(checkTagMatchesContract("v0.1.0", "1.0").ok, false);
|
|
||||||
});
|
|
||||||
@@ -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
-40
@@ -4,38 +4,8 @@
|
|||||||
"description": "ignorePaths overrides config:recommended's :ignoreModulesAndTests, which ignores **/examples/** — an example plugin's dependencies get update PRs like any other manifest here",
|
"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/**"],
|
"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 +29,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), {
|
||||||
@@ -154,63 +151,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");
|
|
||||||
});
|
|
||||||
|
|||||||
+24
-84
@@ -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) -----------------------
|
||||||
|
|
||||||
@@ -146,7 +141,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 +150,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();
|
||||||
|
|||||||
+3
-19
@@ -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.
|
||||||
|
|||||||
+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);
|
||||||
|
|||||||
@@ -6,35 +6,6 @@
|
|||||||
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,10 +24,7 @@ export interface Config {
|
|||||||
oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory)
|
oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory)
|
||||||
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
|
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
|
||||||
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
|
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
|
||||||
pluginDbSecret: string; // derives each plugin's database password (src/plugin-host/storage.ts)
|
|
||||||
pluginDbUrl: string | undefined; // credential-free Postgres base URL; unset ⇒ plugin storage is off
|
|
||||||
port: number;
|
port: number;
|
||||||
requireSecureSecrets: boolean; // enforce real secrets — the host's own, and every plugin's declared `secret`
|
|
||||||
revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
|
revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
|
||||||
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
|
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
|
||||||
secureCookies: boolean;
|
secureCookies: boolean;
|
||||||
@@ -182,14 +150,7 @@ export function loadConfig(env: Env = process.env): Config {
|
|||||||
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
|
oryTimeoutSec: readPosInt(env, "ORY_TIMEOUT_SEC", 5),
|
||||||
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
|
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
|
||||||
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
|
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
|
||||||
// Per-plugin storage. PLUGIN_DB_URL carries the server and its connection parameters but no
|
|
||||||
// credentials: the superuser DSN that provisions stays in bootstrap, so a plugin cannot read it
|
|
||||||
// out of web's environment. Unset ⇒ storage is off and a plugin declaring it fails loud at boot,
|
|
||||||
// which is also why the secret is only enforced once a URL is configured.
|
|
||||||
pluginDbSecret: resolvePluginDbSecret(env, requireSecure && Boolean(env["PLUGIN_DB_URL"])),
|
|
||||||
pluginDbUrl: readCredentiallessUrl(env, "PLUGIN_DB_URL"),
|
|
||||||
port: readPort(env),
|
port: readPort(env),
|
||||||
requireSecureSecrets: requireSecure,
|
|
||||||
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
|
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
|
||||||
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
|
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
|
||||||
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
|
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
|
||||||
|
|||||||
+2
-5
@@ -26,7 +26,6 @@ import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts"
|
|||||||
import { remintSession } from "../auth/login.ts";
|
import { remintSession } from "../auth/login.ts";
|
||||||
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
||||||
import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
|
import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
|
||||||
import type { PluginSettings } from "../plugin-host/settings.ts";
|
|
||||||
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
||||||
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
||||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||||
@@ -55,7 +54,6 @@ export interface AppOptions {
|
|||||||
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
|
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
|
||||||
publicDir?: string;
|
publicDir?: string;
|
||||||
secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http)
|
secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http)
|
||||||
settingsCatalog?: readonly PluginSettings[]; // resolved at boot (server.ts, needs the env); → ctx.declaredSettings
|
|
||||||
viewsDir?: string;
|
viewsDir?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,7 +88,6 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
|
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
|
||||||
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
|
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
|
||||||
const permissionCatalog = declaredPermissions(plugins);
|
const permissionCatalog = declaredPermissions(plugins);
|
||||||
const settingsCatalog = options.settingsCatalog ?? [];
|
|
||||||
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
||||||
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
|
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
|
||||||
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
||||||
@@ -262,9 +259,9 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
|
|
||||||
// Base context (no route params), for the built-in routes. Every plugin-owned render — a
|
// Base context (no route params), for the built-in routes. Every plugin-owned render — a
|
||||||
// landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead.
|
// landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead.
|
||||||
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||||
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
|
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
|
||||||
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
||||||
renderPage = viewsFor(ctx);
|
renderPage = viewsFor(ctx);
|
||||||
|
|
||||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||||
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
|
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
|
||||||
import type { PermissionDecl } from "../plugin-host/plugin.ts"; // type-only
|
import type { PermissionDecl } from "../plugin-host/plugin.ts"; // type-only
|
||||||
import type { PluginSettings } from "../plugin-host/settings.ts"; // type-only
|
|
||||||
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
|
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
|
||||||
import { DEFAULT_LOCALE } from "../i18n/catalog.ts";
|
import { DEFAULT_LOCALE } from "../i18n/catalog.ts";
|
||||||
import { ENGLISH } from "../i18n/english.ts";
|
import { ENGLISH } from "../i18n/english.ts";
|
||||||
@@ -43,9 +42,6 @@ export interface RequestContext {
|
|||||||
// screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is
|
// screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is
|
||||||
// what *this user holds*. Empty when no installed plugin declares any.
|
// what *this user holds*. Empty when no installed plugin declares any.
|
||||||
declaredPermissions: readonly PermissionDecl[];
|
declaredPermissions: readonly PermissionDecl[];
|
||||||
// What each installed plugin declares it can be configured with, and how each key resolved — one
|
|
||||||
// entry per plugin, including those declaring nothing. A secret's value is never carried here.
|
|
||||||
declaredSettings: readonly PluginSettings[];
|
|
||||||
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
||||||
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
|
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
|
||||||
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
||||||
@@ -71,7 +67,6 @@ export interface BuildContextOptions {
|
|||||||
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
||||||
chrome?: () => PageChrome;
|
chrome?: () => PageChrome;
|
||||||
declaredPermissions?: readonly PermissionDecl[];
|
declaredPermissions?: readonly PermissionDecl[];
|
||||||
declaredSettings?: readonly PluginSettings[];
|
|
||||||
user?: User | null;
|
user?: User | null;
|
||||||
locale?: string;
|
locale?: string;
|
||||||
localeHref?: (href: string) => string;
|
localeHref?: (href: string) => string;
|
||||||
@@ -101,7 +96,6 @@ export function buildContext(
|
|||||||
return {
|
return {
|
||||||
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
||||||
declaredPermissions: options.declaredPermissions ?? [],
|
declaredPermissions: options.declaredPermissions ?? [],
|
||||||
declaredSettings: options.declaredSettings ?? [],
|
|
||||||
user,
|
user,
|
||||||
locale: options.locale ?? DEFAULT_LOCALE,
|
locale: options.locale ?? DEFAULT_LOCALE,
|
||||||
localeHref: options.localeHref ?? ((href) => href),
|
localeHref: options.localeHref ?? ((href) => href),
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { tmpdir } from "node:os";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { test, type TestContext } from "node:test";
|
import { test, type TestContext } from "node:test";
|
||||||
import { discoverPlugins } from "./discovery.ts";
|
import { discoverPlugins } from "./discovery.ts";
|
||||||
import { HOST_API_VERSION } from "./plugin.ts";
|
|
||||||
|
|
||||||
// Write a throwaway plugins/ tree of `relpath → source` and clean it up after the test. Fixtures
|
// Write a throwaway plugins/ tree of `relpath → source` and clean it up after the test. Fixtures
|
||||||
// default-export plain objects — definePlugin is identity, so a literal is an equivalent manifest.
|
// default-export plain objects — definePlugin is identity, so a literal is an equivalent manifest.
|
||||||
@@ -20,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,21 +45,18 @@ 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: "a plugin shipping its own copy of the barrel", files: { "shadow/node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "shadow/plugin.ts": full("shadow") }, match: /shadow.*@plainpages\/plugin-api/s },
|
||||||
{ name: "a plugin package.json that forgets type: module", files: { "cjs/package.json": `{ "name": "cjs" }`, "cjs/plugin.ts": full("cjs") }, match: /cjs.*"type": "module"/s },
|
{ name: "a plugin package.json that forgets type: module", files: { "cjs/package.json": `{ "name": "cjs" }`, "cjs/plugin.ts": full("cjs") }, match: /cjs.*"type": "module"/s },
|
||||||
{ name: "a plugin package.json that is not valid JSON", files: { "bent/package.json": `{`, "bent/plugin.ts": full("bent") }, match: /bent.*package\.json.*JSON/s },
|
{ name: "a plugin package.json that is not valid JSON", files: { "bent/package.json": `{`, "bent/plugin.ts": full("bent") }, match: /bent.*package\.json.*JSON/s },
|
||||||
@@ -74,8 +64,8 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
|||||||
// `npm install --prefix plugins` — the documented command with one path segment dropped.
|
// `npm install --prefix plugins` — the documented command with one path segment dropped.
|
||||||
{ name: "a package.json in the scan root itself", files: { "package.json": `{ "name": "oops" }`, "ok/plugin.ts": full("ok") }, match: /plugins\/package\.json must not exist/ },
|
{ name: "a package.json in the scan root itself", files: { "package.json": `{ "name": "oops" }`, "ok/plugin.ts": full("ok") }, match: /plugins\/package\.json must not exist/ },
|
||||||
{ name: "a node_modules in the scan root itself", files: { "node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "ok/plugin.ts": full("ok") }, match: /plugins\/node_modules must not exist/ },
|
{ name: "a node_modules in the scan root itself", files: { "node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "ok/plugin.ts": full("ok") }, match: /plugins\/node_modules must not exist/ },
|
||||||
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ html: "b" }) };` }, match: /home/ },
|
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
|
||||||
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
|
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const c of badCases) {
|
for (const c of badCases) {
|
||||||
@@ -88,7 +78,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 +87,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,7 +102,7 @@ test("`admin` is not reserved — the admin screens ship as a drop-in plugin mou
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
|
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
|
||||||
const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` });
|
const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` });
|
||||||
const plugins = await discoverPlugins({ dir });
|
const plugins = await discoverPlugins({ dir });
|
||||||
assert.equal(plugins.length, 1);
|
assert.equal(plugins.length, 1);
|
||||||
assert.equal(typeof plugins[0]?.home, "function");
|
assert.equal(typeof plugins[0]?.home, "function");
|
||||||
@@ -127,7 +117,7 @@ test("a plugin may carry its own package.json, node_modules and dependencies", a
|
|||||||
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`,
|
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`,
|
||||||
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
|
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
|
||||||
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
|
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
|
||||||
`export default definePlugin({ apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`,
|
`export default definePlugin({ apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`,
|
||||||
});
|
});
|
||||||
|
|
||||||
const plugins = await discoverPlugins({ dir });
|
const plugins = await discoverPlugins({ dir });
|
||||||
@@ -154,7 +144,7 @@ test("a dangling plugin symlink fails loud rather than vanishing", async (t) =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("a shared permission name only warns — both plugins still load", async (t) => {
|
test("a shared permission name only warns — both plugins still load", async (t) => {
|
||||||
const shared = `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "shared:read" }] };`;
|
const shared = `export default { apiVersion: "1.0.0", permissions: [{ name: "shared:read" }] };`;
|
||||||
const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
|
const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared });
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
|
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
|
||||||
|
|||||||
@@ -8,8 +8,6 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
|
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
|
||||||
import { settingsDeclError } from "./settings.ts";
|
|
||||||
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts";
|
|
||||||
|
|
||||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||||
|
|
||||||
@@ -68,13 +66,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,12 +131,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`;
|
|
||||||
if (manifest.settings !== undefined) {
|
|
||||||
const settings = settingsDeclError(manifest.settings);
|
|
||||||
if (settings) return settings;
|
|
||||||
}
|
|
||||||
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
|
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
|
||||||
// "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
|
// "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
|
||||||
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
||||||
|
|||||||
@@ -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,11 +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 { PluginSettings, SettingDecl, SettingSummary, SettingType, SettingValue } from "./settings.ts";
|
|
||||||
export type { StorageCredentials } from "./storage.ts";
|
|
||||||
export type { RequestContext, User } from "../http/context.ts";
|
export type { RequestContext, User } from "../http/context.ts";
|
||||||
export type { PageChrome } from "../ui/chrome.ts";
|
export type { PageChrome } from "../ui/chrome.ts";
|
||||||
export type { NavNode } from "../ui/nav.ts";
|
export type { NavNode } from "../ui/nav.ts";
|
||||||
|
|||||||
@@ -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`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,9 @@
|
|||||||
|
|
||||||
import type { RequestContext } from "../http/context.ts";
|
import type { RequestContext } from "../http/context.ts";
|
||||||
import type { NavNode } from "../ui/nav.ts";
|
import type { NavNode } from "../ui/nav.ts";
|
||||||
import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
|
|
||||||
import type { StorageCredentials } from "./storage.ts";
|
|
||||||
|
|
||||||
// The Plainpages release this contract ships in — see README → Contract versioning.
|
// Bump major on a breaking manifest/handler change, minor on an additive one.
|
||||||
export const HOST_API_VERSION = "0.2.0";
|
export const HOST_API_VERSION = "1.0.0";
|
||||||
|
|
||||||
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
||||||
|
|
||||||
@@ -62,43 +60,26 @@ export function declaredPermissions(plugins: Plugin[]): PermissionDecl[] {
|
|||||||
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
}
|
}
|
||||||
|
|
||||||
// What onBoot receives. A hook declaring no parameter stays valid, so this may grow additively.
|
|
||||||
export type BootContext<S extends readonly SettingDecl[] = readonly SettingDecl[]> = {
|
|
||||||
storage?: StorageCredentials; // this plugin's own database; present iff the manifest declared `storage`
|
|
||||||
} & SettingsSlot<S>;
|
|
||||||
|
|
||||||
// Required once the manifest declares settings, so that plugin reads `settings.key` without a guard
|
|
||||||
// for the case it just ruled out; optional for a manifest that declared none.
|
|
||||||
type SettingsSlot<S extends readonly SettingDecl[]> = readonly [] extends S
|
|
||||||
? { settings?: SettingsOf<S> }
|
|
||||||
: { settings: SettingsOf<S> };
|
|
||||||
|
|
||||||
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
|
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
|
||||||
export interface PluginHooks<S extends readonly SettingDecl[] = readonly SettingDecl[]> {
|
export interface PluginHooks {
|
||||||
onBoot?: (host: BootContext<S>) => 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// The authored manifest — a plugin's `plugin.ts` default-exports this. No `id`/mount path: the
|
// The authored manifest — a plugin's `plugin.ts` default-exports this. No `id`/mount path: the
|
||||||
// host derives them from the folder name at discovery (see Plugin).
|
// host derives them from the folder name at discovery (see Plugin).
|
||||||
export interface PluginManifest<S extends readonly SettingDecl[] = readonly SettingDecl[]> {
|
export interface PluginManifest {
|
||||||
apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs)
|
apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs)
|
||||||
// Take over "/dashboard"; the host gates it to a signed-in session first. At most one plugin may
|
// Take over "/dashboard"; the host gates it to a signed-in session first. At most one plugin may
|
||||||
// declare it (findConflicts → error, never last-write-wins).
|
// declare it (findConflicts → error, never last-write-wins).
|
||||||
dashboard?: RouteHandler;
|
dashboard?: RouteHandler;
|
||||||
// Take over the ungated public landing "/". At most one plugin may declare it.
|
// Take over the ungated public landing "/". At most one plugin may declare it.
|
||||||
home?: RouteHandler;
|
home?: RouteHandler;
|
||||||
hooks?: PluginHooks<S>;
|
hooks?: PluginHooks;
|
||||||
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
|
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique
|
||||||
permissions?: PermissionDecl[];
|
permissions?: PermissionDecl[];
|
||||||
routes?: Route[];
|
routes?: Route[];
|
||||||
// Operator-supplied configuration, one PLUGIN_SETTING_<ID>_<KEY> variable per key; the resolved
|
|
||||||
// values arrive on onBoot's BootContext, typed from these declarations (settings.ts).
|
|
||||||
settings?: S;
|
|
||||||
// Ask for a Postgres database of this plugin's own; its credentials arrive on onBoot's BootContext.
|
|
||||||
// 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
|
||||||
@@ -109,9 +90,7 @@ export interface Plugin extends PluginManifest {
|
|||||||
|
|
||||||
// Types the manifest and returns it unchanged; validation happens at discovery, so a plugin may
|
// Types the manifest and returns it unchanged; validation happens at discovery, so a plugin may
|
||||||
// equally be a plain typed object.
|
// equally be a plain typed object.
|
||||||
// The `const` parameter captures the literal `settings`, so onBoot receives each key at its declared
|
export function definePlugin(manifest: PluginManifest): PluginManifest {
|
||||||
// type instead of a union every plugin author would have to narrow with a cast.
|
|
||||||
export function definePlugin<const S extends readonly SettingDecl[]>(manifest: PluginManifest<S>): PluginManifest<S> {
|
|
||||||
return manifest;
|
return manifest;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,17 +149,13 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
|
|||||||
return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` };
|
return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` };
|
||||||
}
|
}
|
||||||
if (plugin.minor < host.minor) {
|
if (plugin.minor < host.minor) {
|
||||||
// Pre-1.0 the major is pinned at 0, so a minor is the only slot a breaking change can use.
|
return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — newer features available` };
|
||||||
if (host.major === 0) {
|
|
||||||
return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — pre-1.0 a minor is a contract break, rebuild against ${hostVersion}` };
|
|
||||||
}
|
|
||||||
return { level: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — built against an older release` };
|
|
||||||
}
|
}
|
||||||
return { level: "ok", message: `apiVersion ${pluginVersion}` };
|
return { level: "ok", message: `apiVersion ${pluginVersion}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginConflict {
|
export interface PluginConflict {
|
||||||
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route" | "setting";
|
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route";
|
||||||
level: "error" | "warn";
|
level: "error" | "warn";
|
||||||
message: string;
|
message: string;
|
||||||
plugins: string[]; // unique ids involved
|
plugins: string[]; // unique ids involved
|
||||||
@@ -221,14 +196,6 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
|||||||
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "<resource>" unless shared on purpose`, plugins: uniq(owners) });
|
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "<resource>" unless shared on purpose`, plugins: uniq(owners) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Both the id's dashes and the key's camel humps become underscores, so plugin "a-b" key "c" and
|
|
||||||
// plugin "a" key "bC" name one variable — one plugin would silently read the other's value.
|
|
||||||
collect(plugins, (plugin, push) => {
|
|
||||||
for (const decl of plugin.settings ?? []) push(envName(plugin.id, decl.key));
|
|
||||||
}).forEach((owners, name) => {
|
|
||||||
if (owners.length > 1) out.push({ kind: "setting", level: "error", message: `${owners.length} settings resolve to "${name}"; rename a key or a plugin folder`, plugins: uniq(owners) });
|
|
||||||
});
|
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
// Guards the plugin-settings rules: the env name a declaration maps to, per-type coercion, the
|
|
||||||
// required/default/secret resolution, and what the admin catalog is allowed to carry.
|
|
||||||
import { test } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import type { Plugin } from "./plugin.ts";
|
|
||||||
import {
|
|
||||||
ENV_PREFIX,
|
|
||||||
envName,
|
|
||||||
isValidSettingKey,
|
|
||||||
resolveSettings,
|
|
||||||
settingsDeclError,
|
|
||||||
settingsEnvNames,
|
|
||||||
strayNames,
|
|
||||||
type SettingDecl,
|
|
||||||
} from "./settings.ts";
|
|
||||||
|
|
||||||
function pluginWith(id: string, settings: SettingDecl[]): Plugin {
|
|
||||||
return { apiVersion: "0.2.0", id, settings };
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a key becomes one SCREAMING_SNAKE segment under the plugin's own", () => {
|
|
||||||
assert.equal(envName("scheduling", "timezone"), "PLUGIN_SETTING_SCHEDULING_TIMEZONE");
|
|
||||||
assert.equal(envName("scheduling", "maxShiftHours"), "PLUGIN_SETTING_SCHEDULING_MAX_SHIFT_HOURS");
|
|
||||||
assert.equal(envName("my-plugin", "apiBase"), "PLUGIN_SETTING_MY_PLUGIN_API_BASE");
|
|
||||||
assert.equal(ENV_PREFIX, "PLUGIN_SETTING_"); // never bare PLUGIN_ — the host owns PLUGIN_DB_*
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the host's own PLUGIN_DB_* variables are unreachable from a declaration", () => {
|
|
||||||
// A plugin id "db" with key "url" is exactly the collision the longer prefix rules out.
|
|
||||||
assert.notEqual(envName("db", "url"), "PLUGIN_DB_URL");
|
|
||||||
assert.equal(envName("db", "url"), "PLUGIN_SETTING_DB_URL");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a key is camelCase, so the env name is derivable and no two keys collide", () => {
|
|
||||||
assert.ok(isValidSettingKey("timezone"));
|
|
||||||
assert.ok(isValidSettingKey("maxShiftHours"));
|
|
||||||
assert.ok(!isValidSettingKey("max_shift_hours")); // would collide with maxShiftHours
|
|
||||||
assert.ok(!isValidSettingKey("MaxShiftHours"));
|
|
||||||
assert.ok(!isValidSettingKey("2fa"));
|
|
||||||
assert.ok(!isValidSettingKey(""));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a declaration is refused when it cannot mean what it says", () => {
|
|
||||||
const why = (settings: unknown): string => settingsDeclError(settings) ?? "";
|
|
||||||
|
|
||||||
assert.equal(settingsDeclError([{ key: "a", type: "string" }]), null);
|
|
||||||
assert.match(why("nope"), /must be an array/);
|
|
||||||
assert.match(why([{ key: "max_hours", type: "number" }]), /max_hours.*camelCase/);
|
|
||||||
assert.match(why([{ key: "a", type: "date" }]), /"date".*string, number, boolean, enum, url/);
|
|
||||||
assert.match(why([{ key: "a", type: "string" }, { key: "a", type: "number" }]), /declared twice/);
|
|
||||||
// required means "boot fails without it", so a default would make the flag a lie.
|
|
||||||
assert.match(why([{ default: "x", key: "a", required: true, type: "string" }]), /required.*default.*mutually exclusive/);
|
|
||||||
assert.match(why([{ default: 8, key: "a", type: "string" }]), /default.*string/);
|
|
||||||
assert.match(why([{ key: "a", type: "enum" }]), /enum.*values/);
|
|
||||||
assert.match(why([{ key: "a", type: "enum", values: [] }]), /enum.*values/);
|
|
||||||
assert.match(why([{ default: "c", key: "a", type: "enum", values: ["a", "b"] }]), /default "c".*a, b/);
|
|
||||||
assert.match(why([{ key: "a", type: "string", values: ["a"] }]), /values.*only.*enum/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an unset optional setting resolves to undefined, not to a stand-in", () => {
|
|
||||||
const result = resolveSettings([pluginWith("p", [{ key: "a", type: "string" }])], {});
|
|
||||||
assert.deepEqual(result.errors, []);
|
|
||||||
assert.equal(result.values.get("p")?.["a"], undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a default fills in, and an env value overrides it", () => {
|
|
||||||
const plugins = [pluginWith("p", [{ default: 8, key: "maxHours", type: "number" }])];
|
|
||||||
assert.equal(resolveSettings(plugins, {}).values.get("p")?.["maxHours"], 8);
|
|
||||||
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_MAX_HOURS: "12" }).values.get("p")?.["maxHours"], 12);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an empty variable reads as unset — compose passes an unset one through as \"\"", () => {
|
|
||||||
const plugins = [pluginWith("p", [{ default: "fallback", key: "a", type: "string" }])];
|
|
||||||
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_A: "" }).values.get("p")?.["a"], "fallback");
|
|
||||||
const required = [pluginWith("p", [{ key: "a", required: true, type: "string" }])];
|
|
||||||
assert.match(resolveSettings(required, { PLUGIN_SETTING_P_A: "" }).errors.join("\n"), /must be set/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a missing required setting is an error naming the plugin, the key and the variable", () => {
|
|
||||||
const result = resolveSettings([pluginWith("scheduling", [{ key: "timezone", required: true, type: "string" }])], {});
|
|
||||||
assert.equal(result.errors.length, 1);
|
|
||||||
assert.match(result.errors[0] ?? "", /scheduling/);
|
|
||||||
assert.match(result.errors[0] ?? "", /timezone/);
|
|
||||||
assert.match(result.errors[0] ?? "", /PLUGIN_SETTING_SCHEDULING_TIMEZONE/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("each type coerces from the environment, and a bad value fails loud", () => {
|
|
||||||
const decls: SettingDecl[] = [
|
|
||||||
{ key: "text", type: "string" },
|
|
||||||
{ key: "count", type: "number" },
|
|
||||||
{ key: "flag", type: "boolean" },
|
|
||||||
{ key: "mode", type: "enum", values: ["strict", "lenient"] },
|
|
||||||
{ key: "base", type: "url" },
|
|
||||||
];
|
|
||||||
const ok = resolveSettings([pluginWith("p", decls)], {
|
|
||||||
PLUGIN_SETTING_P_BASE: "https://example.com/v1",
|
|
||||||
PLUGIN_SETTING_P_COUNT: "42",
|
|
||||||
PLUGIN_SETTING_P_FLAG: "true",
|
|
||||||
PLUGIN_SETTING_P_MODE: "strict",
|
|
||||||
PLUGIN_SETTING_P_TEXT: "hello",
|
|
||||||
});
|
|
||||||
assert.deepEqual(ok.errors, []);
|
|
||||||
assert.deepEqual(ok.values.get("p"), { base: "https://example.com/v1", count: 42, flag: true, mode: "strict", text: "hello" });
|
|
||||||
|
|
||||||
const bad = resolveSettings([pluginWith("p", decls)], {
|
|
||||||
PLUGIN_SETTING_P_BASE: "not a url",
|
|
||||||
PLUGIN_SETTING_P_COUNT: "twelve",
|
|
||||||
PLUGIN_SETTING_P_FLAG: "yes",
|
|
||||||
PLUGIN_SETTING_P_MODE: "loose",
|
|
||||||
});
|
|
||||||
assert.equal(bad.errors.length, 4);
|
|
||||||
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_COUNT.*number/);
|
|
||||||
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_FLAG.*"true".*"false"/);
|
|
||||||
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_MODE.*strict, lenient/);
|
|
||||||
assert.match(bad.errors.join("\n"), /PLUGIN_SETTING_P_BASE.*URL/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a boolean is only \"true\"/\"false\" — a typo never degrades to false", () => {
|
|
||||||
const plugins = [pluginWith("p", [{ default: true, key: "flag", type: "boolean" }])];
|
|
||||||
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_FLAG: "false" }).values.get("p")?.["flag"], false);
|
|
||||||
assert.equal(resolveSettings(plugins, { PLUGIN_SETTING_P_FLAG: "0" }).errors.length, 1);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("REQUIRE_SECURE_SECRETS refuses an unset secret and one still on its dev default", () => {
|
|
||||||
const decls: SettingDecl[] = [{ default: "dev-insecure", key: "apiKey", secret: true, type: "string" }];
|
|
||||||
const plugins = [pluginWith("p", decls)];
|
|
||||||
assert.deepEqual(resolveSettings(plugins, {}).errors, []); // off: the dev default boots a clean clone
|
|
||||||
|
|
||||||
assert.match(resolveSettings(plugins, {}, { requireSecureSecrets: true }).errors.join("\n"), /apiKey.*must be set/);
|
|
||||||
assert.match(
|
|
||||||
resolveSettings(plugins, { PLUGIN_SETTING_P_API_KEY: "dev-insecure" }, { requireSecureSecrets: true }).errors.join("\n"),
|
|
||||||
/apiKey.*dev/,
|
|
||||||
);
|
|
||||||
assert.deepEqual(resolveSettings(plugins, { PLUGIN_SETTING_P_API_KEY: "real" }, { requireSecureSecrets: true }).errors, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a secret's value reaches the plugin but never the catalog", () => {
|
|
||||||
const plugins = [pluginWith("p", [{ key: "apiKey", secret: true, type: "string" }])];
|
|
||||||
const result = resolveSettings(plugins, { PLUGIN_SETTING_P_API_KEY: "s3cr3t" });
|
|
||||||
assert.equal(result.values.get("p")?.["apiKey"], "s3cr3t");
|
|
||||||
|
|
||||||
const entry = result.catalog[0]?.settings[0];
|
|
||||||
assert.equal(entry?.secret, true);
|
|
||||||
assert.equal(entry?.source, "env");
|
|
||||||
assert.equal(entry?.value, undefined); // not the value, not its length, not a mask of it
|
|
||||||
assert.ok(!JSON.stringify(result.catalog).includes("s3cr3t"));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("the catalog carries every installed plugin, so \"declares none\" is not \"not installed\"", () => {
|
|
||||||
const plugins = [pluginWith("with", [{ default: "x", key: "a", type: "string" }]), { apiVersion: "0.2.0", id: "without" }];
|
|
||||||
const catalog = resolveSettings(plugins, {}).catalog;
|
|
||||||
assert.deepEqual(catalog.map((entry) => entry.pluginId), ["with", "without"]);
|
|
||||||
assert.deepEqual(catalog[1]?.settings, []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a catalog entry carries the variable to set and where the value came from", () => {
|
|
||||||
const decls: SettingDecl[] = [
|
|
||||||
{ description: "Where shifts come from", key: "upstream", required: true, type: "url" },
|
|
||||||
{ default: 8, key: "maxHours", type: "number" },
|
|
||||||
{ key: "note", type: "string" },
|
|
||||||
];
|
|
||||||
const catalog = resolveSettings([pluginWith("scheduling", decls)], { PLUGIN_SETTING_SCHEDULING_UPSTREAM: "https://x.test" }).catalog;
|
|
||||||
assert.deepEqual(catalog[0]?.settings, [
|
|
||||||
{ description: "Where shifts come from", envName: "PLUGIN_SETTING_SCHEDULING_UPSTREAM", key: "upstream", required: true, secret: false, source: "env", type: "url", value: "https://x.test" },
|
|
||||||
{ envName: "PLUGIN_SETTING_SCHEDULING_MAX_HOURS", key: "maxHours", required: false, secret: false, source: "default", type: "number", value: "8" },
|
|
||||||
{ envName: "PLUGIN_SETTING_SCHEDULING_NOTE", key: "note", required: false, secret: false, source: "unset", type: "string" },
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a variable no plugin declares is reported, never acted on", () => {
|
|
||||||
const declared = settingsEnvNames([pluginWith("scheduling", [{ key: "timezone", type: "string" }])]);
|
|
||||||
const strays = strayNames(
|
|
||||||
{ PATH: "/usr/bin", PLUGIN_DB_URL: "postgres://x", PLUGIN_SETTING_GONE_KEY: "x", PLUGIN_SETTING_SCHEDULING_TIMEZOME: "UTC", PLUGIN_SETTING_SCHEDULING_TIMEZONE: "UTC" },
|
|
||||||
declared,
|
|
||||||
);
|
|
||||||
assert.deepEqual(strays, ["PLUGIN_SETTING_GONE_KEY", "PLUGIN_SETTING_SCHEDULING_TIMEZOME"]); // sorted; the host's own untouched
|
|
||||||
});
|
|
||||||
@@ -1,259 +0,0 @@
|
|||||||
// Per-plugin settings: the declaration shape, the env name it maps to, and the resolution rules
|
|
||||||
// (README → Plugin settings). Pure — server.ts passes `process.env` in, so the whole matrix is
|
|
||||||
// unit-testable without a stack.
|
|
||||||
|
|
||||||
import type { Plugin } from "./plugin.ts";
|
|
||||||
|
|
||||||
// `PLUGIN_` alone would let a plugin id "db" with key "url" produce the host's own PLUGIN_DB_URL.
|
|
||||||
export const ENV_PREFIX = "PLUGIN_SETTING_";
|
|
||||||
|
|
||||||
export const SETTING_TYPES = ["string", "number", "boolean", "enum", "url"] as const;
|
|
||||||
export type SettingType = (typeof SETTING_TYPES)[number];
|
|
||||||
|
|
||||||
export type SettingValue = boolean | number | string;
|
|
||||||
|
|
||||||
// What a manifest declares. `required` and `default` are mutually exclusive: a default means the
|
|
||||||
// setting can never fail resolution, which is the opposite of what required asserts.
|
|
||||||
export interface SettingDecl {
|
|
||||||
default?: SettingValue;
|
|
||||||
description?: string;
|
|
||||||
key: string;
|
|
||||||
required?: boolean;
|
|
||||||
secret?: boolean; // value reaches the plugin, never a log, an error or the catalog
|
|
||||||
type: SettingType;
|
|
||||||
values?: readonly string[]; // enum only — the accepted choices
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SettingTypeMap {
|
|
||||||
boolean: boolean;
|
|
||||||
enum: string;
|
|
||||||
number: number;
|
|
||||||
string: string;
|
|
||||||
url: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type ValueOfDecl<D> = D extends { type: "enum"; values: readonly (infer V extends string)[] }
|
|
||||||
? V
|
|
||||||
: D extends { type: infer T extends keyof SettingTypeMap }
|
|
||||||
? SettingTypeMap[T]
|
|
||||||
: never;
|
|
||||||
|
|
||||||
// The resolved shape a plugin's onBoot receives, inferred from its own declarations so no caller
|
|
||||||
// narrows with a cast. Only a required or defaulted setting is guaranteed present.
|
|
||||||
export type SettingsOf<D extends readonly SettingDecl[]> = {
|
|
||||||
[K in D[number] as K["key"]]: K extends { required: true }
|
|
||||||
? ValueOfDecl<K>
|
|
||||||
: K extends { default: SettingValue }
|
|
||||||
? ValueOfDecl<K>
|
|
||||||
: ValueOfDecl<K> | undefined;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type SettingsValues = Record<string, SettingValue | undefined>;
|
|
||||||
|
|
||||||
// One row of the admin catalog. `value` is a display string and is absent for a secret and for an
|
|
||||||
// unset setting — a secret's length is a disclosure too, so nothing stands in for it.
|
|
||||||
export interface SettingSummary {
|
|
||||||
description?: string;
|
|
||||||
envName: string;
|
|
||||||
key: string;
|
|
||||||
required: boolean;
|
|
||||||
secret: boolean;
|
|
||||||
source: "default" | "env" | "unset";
|
|
||||||
type: SettingType;
|
|
||||||
value?: string;
|
|
||||||
values?: readonly string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PluginSettings {
|
|
||||||
pluginId: string;
|
|
||||||
settings: SettingSummary[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResolveResult {
|
|
||||||
catalog: PluginSettings[];
|
|
||||||
errors: string[];
|
|
||||||
values: Map<string, SettingsValues>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ResolveOptions {
|
|
||||||
requireSecureSecrets?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
type Env = Record<string, string | undefined>;
|
|
||||||
|
|
||||||
const SETTING_KEY = /^[a-z][a-zA-Z0-9]*$/;
|
|
||||||
|
|
||||||
export function isValidSettingKey(key: unknown): boolean {
|
|
||||||
return typeof key === "string" && SETTING_KEY.test(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function envName(pluginId: string, key: string): string {
|
|
||||||
const plugin = pluginId.replaceAll("-", "_").toUpperCase();
|
|
||||||
return `${ENV_PREFIX}${plugin}_${camelToSnake(key)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function camelToSnake(key: string): string {
|
|
||||||
return key.replaceAll(/(?<=[a-z0-9])(?=[A-Z])/g, "_").toUpperCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discovery-time shape check: the author's mistakes, refused before any value is read.
|
|
||||||
export function settingsDeclError(settings: unknown): string | null {
|
|
||||||
if (!Array.isArray(settings)) return `"settings" must be an array`;
|
|
||||||
|
|
||||||
const seen = new Set<string>();
|
|
||||||
for (const decl of settings as SettingDecl[]) {
|
|
||||||
const key = decl?.key;
|
|
||||||
if (!isValidSettingKey(key)) {
|
|
||||||
return `setting "${String(key)}" — a key must be camelCase (${SETTING_KEY.source}) so its variable name is derivable`;
|
|
||||||
}
|
|
||||||
if (seen.has(key)) return `setting "${key}" is declared twice`;
|
|
||||||
seen.add(key);
|
|
||||||
|
|
||||||
if (!(SETTING_TYPES as readonly string[]).includes(decl.type)) {
|
|
||||||
return `setting "${key}" has type "${String(decl.type)}"; one of ${SETTING_TYPES.join(", ")}`;
|
|
||||||
}
|
|
||||||
if (decl.required === true && decl.default !== undefined) {
|
|
||||||
return `setting "${key}" sets both required and default — they are mutually exclusive, a default means it can never fail`;
|
|
||||||
}
|
|
||||||
if (decl.type === "enum") {
|
|
||||||
if (!Array.isArray(decl.values) || decl.values.length === 0 || decl.values.some((v) => typeof v !== "string")) {
|
|
||||||
return `setting "${key}" has type enum, so it must declare a non-empty values array of strings`;
|
|
||||||
}
|
|
||||||
} else if (decl.values !== undefined) {
|
|
||||||
return `setting "${key}" declares values, which only an enum type may do`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const typeError = defaultTypeError(decl);
|
|
||||||
if (typeError) return typeError;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultTypeError(decl: SettingDecl): string | null {
|
|
||||||
if (decl.default === undefined) return null;
|
|
||||||
if (decl.type === "enum") {
|
|
||||||
const values = decl.values ?? [];
|
|
||||||
return values.includes(String(decl.default))
|
|
||||||
? null
|
|
||||||
: `setting "${decl.key}" has default "${String(decl.default)}", which is not one of ${values.join(", ")}`;
|
|
||||||
}
|
|
||||||
const expected = decl.type === "number" ? "number" : decl.type === "boolean" ? "boolean" : "string";
|
|
||||||
return typeof decl.default === expected
|
|
||||||
? null
|
|
||||||
: `setting "${decl.key}": default must be a ${expected} (type ${decl.type}), got ${typeof decl.default}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Every variable the installed plugins answer to — the set a stray is measured against.
|
|
||||||
export function settingsEnvNames(plugins: Plugin[]): Set<string> {
|
|
||||||
const names = new Set<string>();
|
|
||||||
for (const plugin of plugins) {
|
|
||||||
for (const decl of plugin.settings ?? []) names.add(envName(plugin.id, decl.key));
|
|
||||||
}
|
|
||||||
return names;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A PLUGIN_SETTING_ variable no installed plugin declares — usually a typo in the one the operator
|
|
||||||
// meant to set, or a plugin they removed. Reported, never acted on (the orphan-database precedent).
|
|
||||||
export function strayNames(env: Env, declared: ReadonlySet<string>): string[] {
|
|
||||||
return Object.keys(env)
|
|
||||||
.filter((name) => name.startsWith(ENV_PREFIX) && !declared.has(name))
|
|
||||||
.sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resolveSettings(plugins: Plugin[], env: Env, options: ResolveOptions = {}): ResolveResult {
|
|
||||||
const catalog: PluginSettings[] = [];
|
|
||||||
const errors: string[] = [];
|
|
||||||
const values = new Map<string, SettingsValues>();
|
|
||||||
|
|
||||||
for (const plugin of plugins) {
|
|
||||||
const decls = plugin.settings ?? [];
|
|
||||||
const resolved: SettingsValues = {};
|
|
||||||
const summaries: SettingSummary[] = [];
|
|
||||||
|
|
||||||
for (const decl of decls) {
|
|
||||||
const name = envName(plugin.id, decl.key);
|
|
||||||
const raw = env[name] || undefined; // compose passes an unset variable through as ""
|
|
||||||
const fail = (message: string): void => void errors.push(`plugin "${plugin.id}": ${message}`);
|
|
||||||
|
|
||||||
let value: SettingValue | undefined;
|
|
||||||
let source: SettingSummary["source"] = "unset";
|
|
||||||
if (raw !== undefined) {
|
|
||||||
const coerced = coerce(decl, raw, name);
|
|
||||||
if (typeof coerced === "string") fail(coerced);
|
|
||||||
else {
|
|
||||||
value = coerced.value;
|
|
||||||
source = "env";
|
|
||||||
}
|
|
||||||
} else if (decl.default !== undefined) {
|
|
||||||
value = decl.default;
|
|
||||||
source = "default";
|
|
||||||
} else if (decl.required === true) {
|
|
||||||
fail(`setting "${decl.key}" must be set — ${name} (type ${decl.type}, no default)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const secretError = secretPolicyError(decl, raw, options.requireSecureSecrets === true, name);
|
|
||||||
if (secretError) fail(secretError);
|
|
||||||
|
|
||||||
resolved[decl.key] = value;
|
|
||||||
summaries.push(summarize(decl, name, source, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (decls.length > 0) values.set(plugin.id, resolved);
|
|
||||||
catalog.push({ pluginId: plugin.id, settings: summaries });
|
|
||||||
}
|
|
||||||
|
|
||||||
return { catalog, errors, values };
|
|
||||||
}
|
|
||||||
|
|
||||||
// The host's own rule for a secret (readSecret), reaching plugins: enforced, neither unset nor the
|
|
||||||
// declared dev throwaway is accepted.
|
|
||||||
function secretPolicyError(decl: SettingDecl, raw: string | undefined, enforce: boolean, name: string): string | null {
|
|
||||||
if (!enforce || decl.secret !== true) return null;
|
|
||||||
if (raw === undefined) return `setting "${decl.key}" must be set when REQUIRE_SECURE_SECRETS=true — ${name}`;
|
|
||||||
if (decl.default !== undefined && raw === String(decl.default)) {
|
|
||||||
return `setting "${decl.key}" must not be its dev default when REQUIRE_SECURE_SECRETS=true — ${name}`;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function summarize(decl: SettingDecl, name: string, source: SettingSummary["source"], value: SettingValue | undefined): SettingSummary {
|
|
||||||
const showValue = decl.secret !== true && value !== undefined;
|
|
||||||
return {
|
|
||||||
...(decl.description !== undefined ? { description: decl.description } : {}),
|
|
||||||
envName: name,
|
|
||||||
key: decl.key,
|
|
||||||
required: decl.required === true,
|
|
||||||
secret: decl.secret === true,
|
|
||||||
source,
|
|
||||||
type: decl.type,
|
|
||||||
...(showValue ? { value: String(value) } : {}),
|
|
||||||
...(decl.values !== undefined ? { values: decl.values } : {}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// A coerced value, or the boot error naming the variable and what it accepts.
|
|
||||||
function coerce(decl: SettingDecl, raw: string, name: string): { value: SettingValue } | string {
|
|
||||||
switch (decl.type) {
|
|
||||||
case "boolean":
|
|
||||||
if (raw === "true") return { value: true };
|
|
||||||
if (raw === "false") return { value: false };
|
|
||||||
return `${name} must be "true" or "false", got "${raw}"`;
|
|
||||||
case "enum":
|
|
||||||
return (decl.values ?? []).includes(raw)
|
|
||||||
? { value: raw }
|
|
||||||
: `${name} must be one of ${(decl.values ?? []).join(", ")}, got "${raw}"`;
|
|
||||||
case "number": {
|
|
||||||
const value = Number(raw);
|
|
||||||
return Number.isFinite(value) ? { value } : `${name} must be a number, got "${raw}"`;
|
|
||||||
}
|
|
||||||
case "url":
|
|
||||||
try {
|
|
||||||
new URL(raw);
|
|
||||||
} catch {
|
|
||||||
return `${name} is not a valid URL: ${raw}`;
|
|
||||||
}
|
|
||||||
return { value: raw };
|
|
||||||
case "string":
|
|
||||||
return { value: raw };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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}`,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
+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
-45
@@ -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,14 +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 { resolveSettings, settingsEnvNames, strayNames } from "./plugin-host/settings.ts";
|
|
||||||
import { buildCredentials, storagePluginIds, type StorageCredentials } from "./plugin-host/storage.ts";
|
|
||||||
|
|
||||||
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
||||||
// The storage secret is in `config` now, so drop it from the environment before ANY plugin code
|
|
||||||
// runs: a plugin module's top level evaluates during discovery, long before onBoot. Defence in
|
|
||||||
// depth, not a boundary (AGENTS.md) — and only ever move this line earlier, never later.
|
|
||||||
delete process.env["PLUGIN_DB_SECRET"];
|
|
||||||
// App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
|
// App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
|
||||||
// per request for access logging + a trace span (src/http/app.ts); console-only otherwise.
|
// per request for access logging + a trace span (src/http/app.ts); console-only otherwise.
|
||||||
const log = createLogger({ format: config.logFormat, level: config.logLevel, otlpEndpoint: config.otlpEndpoint, otlpProtocol: config.otlpProtocol, serviceName: config.serviceName });
|
const log = createLogger({ format: config.logFormat, level: config.logLevel, otlpEndpoint: config.otlpEndpoint, otlpProtocol: config.otlpProtocol, serviceName: config.serviceName });
|
||||||
@@ -51,42 +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));
|
|
||||||
}
|
|
||||||
// Operator-supplied plugin settings, resolved against the environment the manifests declared. A bad
|
|
||||||
// or missing value is refused here rather than at that plugin's first use, hours later.
|
|
||||||
const settings = resolveSettings(plugins, process.env, { requireSecureSecrets: config.requireSecureSecrets });
|
|
||||||
if (settings.errors.length > 0) throw new Error(`Plugin settings:\n${settings.errors.map((e) => ` - ${e}`).join("\n")}`);
|
|
||||||
// A stray is usually a typo in the very variable the operator meant to set — naming it turns two
|
|
||||||
// unrelated-looking errors into one. Reported, never acted on.
|
|
||||||
const strays = strayNames(process.env, settingsEnvNames(plugins));
|
|
||||||
if (strays.length > 0) log.warn("settings variables no installed plugin declares", { variables: strays.join(", ") });
|
|
||||||
|
|
||||||
// onBoot is the only way storage credentials and settings are handed over, so without one they are
|
|
||||||
// resolved and undeliverable. A warning, not a refusal — the plugin still works, it just gets neither.
|
|
||||||
for (const [what, ids] of [
|
|
||||||
["settings", plugins.filter((plugin) => plugin.settings?.length && !plugin.hooks?.onBoot)],
|
|
||||||
["storage", plugins.filter((plugin) => plugin.storage && !plugin.hooks?.onBoot)],
|
|
||||||
] as const) {
|
|
||||||
if (ids.length > 0) log.warn(`plugins declare ${what} but have no onBoot to receive it`, { plugins: ids.map((plugin) => plugin.id).join(", ") });
|
|
||||||
}
|
|
||||||
|
|
||||||
// plugin onBoot — after discovery, before listen; a throw aborts boot.
|
|
||||||
await runBootHooks(plugins, (plugin) => {
|
|
||||||
const storage = storageCredentials.get(plugin.id);
|
|
||||||
const values = settings.values.get(plugin.id);
|
|
||||||
return { ...(values ? { settings: values } : {}), ...(storage ? { storage } : {}) };
|
|
||||||
});
|
|
||||||
|
|
||||||
const server = createApp({
|
const server = createApp({
|
||||||
// Canonical-host redirect target (off-host GET/HEAD visitors are sent here). Opt-in: omitted unless
|
// Canonical-host redirect target (off-host GET/HEAD visitors are sent here). Opt-in: omitted unless
|
||||||
@@ -106,9 +64,8 @@ const server = createApp({
|
|||||||
menu,
|
menu,
|
||||||
plugins,
|
plugins,
|
||||||
secureCookies: config.secureCookies,
|
secureCookies: config.secureCookies,
|
||||||
settingsCatalog: settings.catalog,
|
|
||||||
}).listen(config.port, () => {
|
}).listen(config.port, () => {
|
||||||
log.info("listening", { apiVersion: HOST_API_VERSION, port: config.port, url: config.appUrl ?? `http://localhost:${config.port}` });
|
log.info("listening", { port: config.port, url: config.appUrl ?? `http://localhost:${config.port}` });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drain in-flight requests on container stop instead of cutting them mid-response, then flush any
|
// Drain in-flight requests on container stop instead of cutting them mid-response, then flush any
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
## Unfinnished work
|
## Unfinnished work
|
||||||
|
|
||||||
|
- [ ] Add a way to configure plugins directly when installing. **Decided: the manifest declares it, not an `.env`** — a declared schema is validatable at boot, so a missing or mistyped setting fails loud and named the way a stray `package.json` now does, and the picker/docs can be generated from the declaration. Open: where the operator *supplies* the values (env var per key, a `config/` file, or both), and whether a secret may be declared at all.
|
||||||
- [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin".
|
- [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin".
|
||||||
- [ ] Guard the group paths against self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query.
|
- [ ] Guard the group paths against self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query.
|
||||||
- [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change. Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying.
|
- [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change. Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying.
|
||||||
@@ -35,8 +36,6 @@ Prioritized. Overall verdict: architecture is sound; these are refinements.
|
|||||||
|
|
||||||
## Finnished work
|
## Finnished work
|
||||||
|
|
||||||
- [x] Configure a plugin at install time: the manifest declares `settings`, the operator sets one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, and the resolved values arrive on `onBoot` typed from the declaration. Read-only admin screen at `/admin/plugin-settings`.
|
|
||||||
- [x] Give a plugin persistent storage: `storage: true` provisions a Postgres database + login role named `plugin_<id>`, credentials arrive on `onBoot`, passwords are derived from `PLUGIN_DB_SECRET` rather than stored.
|
|
||||||
- [x] Refuse a stray `package.json`/`node_modules` in `config/` by name, as plugin folders already are.
|
- [x] Refuse a stray `package.json`/`node_modules` in `config/` by name, as plugin folders already are.
|
||||||
- [x] Let Renovate reach the example plugins' manifests (`ignorePaths` overrides `config:recommended`).
|
- [x] Let Renovate reach the example plugins' manifests (`ignorePaths` overrides `config:recommended`).
|
||||||
- [x] The seeded admin is granted each permission once — `seedPermissions` dedupes and the grant PUT is idempotent.
|
- [x] The seeded admin is granted each permission once — `seedPermissions` dedupes and the grant PUT is idempotent.
|
||||||
|
|||||||
+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", "plugin-api", "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.2.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