Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5b73ec6fb7 | |||
| 86c9e8e4ca | |||
| 33c42b2c65 | |||
| 452beeb0e1 | |||
| 1f61235e88 | |||
| 24007b15f3 | |||
| 3eceee0cf1 | |||
| 9fa0f60f79 | |||
| 4f791d8f11 | |||
| 94e9e8bc60 | |||
| 03d14b1a20 | |||
| 3552b85d63 | |||
| dde6fccb35 | |||
| 19b3fbc802 | |||
| 47541ae97b | |||
| c5c9cce2b6 | |||
| 6db14a2205 | |||
| ae8f105360 | |||
| bba048e38f | |||
| bf638dfb19 | |||
| 5cc6c3d93e | |||
| 04af61a5e5 | |||
| a64a60644d | |||
| 950eb5a911 | |||
| 091011cfe5 | |||
| d55898eb8c | |||
| f992cb6b2c | |||
| 7d1f7750d3 | |||
| 3f9787df48 | |||
| 77343e859a | |||
| 616040fda6 | |||
| fee4fe632b |
@@ -19,4 +19,4 @@ jobs:
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
||||
node:24.22.2-alpine3.24 node registry-cleanup/cleanup.ts
|
||||
node:24.19.0-alpine3.24 node registry-cleanup/cleanup.ts
|
||||
|
||||
@@ -2,27 +2,12 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags: ['v[0-9]+.[0-9]+.[0-9]+']
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
overview_version:
|
||||
description: 'Released version to republish the overview for, without the leading v (e.g. 0.1.0)'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
retag-image:
|
||||
if: github.event_name == 'push'
|
||||
runs-on: docker-host
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
# Before anything is published: the contract version IS the release version, so a tag that
|
||||
# disagrees would ship a host misreporting itself to every plugin's compatibility check.
|
||||
- name: Refuse a tag that disagrees with HOST_API_VERSION
|
||||
env:
|
||||
GIT_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
|
||||
- name: Promote the commit-hash image to semver + latest
|
||||
env:
|
||||
GIT_TAG: ${{ github.ref_name }}
|
||||
@@ -30,77 +15,34 @@ jobs:
|
||||
REGISTRY_USER: ${{ vars.DOCKER_REGISTRY_USER }}
|
||||
REPO: gitea.larvit.se/${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||
VERSION=${GIT_TAG#v}
|
||||
printf '%s' "$REGISTRY_TOKEN" | docker login gitea.larvit.se -u "$REGISTRY_USER" --password-stdin
|
||||
docker pull "$REPO:$COMMIT" \
|
||||
|| { echo "No image $REPO:$COMMIT - release tags must point at a commit whose branch passed the CI gate"; exit 1; }
|
||||
# No bare-major tag while major is 0: a 0.x minor is a contract break, so `:0` would move
|
||||
# across one and abort boot for everything tracking it. `:0.1` only moves across patches.
|
||||
TAGS="$VERSION ${VERSION%.*} latest"
|
||||
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
|
||||
for TAG in $TAGS; do
|
||||
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
|
||||
docker tag "$REPO:$COMMIT" "$REPO:$TAG"
|
||||
docker push "$REPO:$TAG"
|
||||
done
|
||||
- name: Sync the release tags to Docker Hub
|
||||
env:
|
||||
DOCKERHUB_IMAGE: docker.io/${{ github.repository }}
|
||||
DOCKERHUB_REPO: docker.io/${{ github.repository }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
|
||||
GIT_TAG: ${{ github.ref_name }}
|
||||
REPO: gitea.larvit.se/${{ github.repository }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||
VERSION=${GIT_TAG#v}
|
||||
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|
||||
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; }
|
||||
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
|
||||
TAGS="$VERSION ${VERSION%.*} latest"
|
||||
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
|
||||
for TAG in $TAGS; do
|
||||
docker tag "$REPO:$COMMIT" "$DOCKERHUB_IMAGE:$TAG"
|
||||
docker push "$DOCKERHUB_IMAGE:$TAG"
|
||||
for TAG in "$VERSION" "${VERSION%.*}" "${VERSION%%.*}" latest; do
|
||||
docker tag "$REPO:$COMMIT" "$DOCKERHUB_REPO:$TAG"
|
||||
docker push "$DOCKERHUB_REPO:$TAG"
|
||||
done
|
||||
- name: Log out of the registries
|
||||
if: always()
|
||||
run: |
|
||||
set -uo pipefail
|
||||
# Cleanup, and the runner's Docker config is shared (AGENTS.md) — a lost race here must not
|
||||
# fail a release that published, nor skip the overview job that follows.
|
||||
docker logout gitea.larvit.se || true
|
||||
docker logout docker.io || true
|
||||
|
||||
publish-overview:
|
||||
if: always() && (github.event_name == 'workflow_dispatch' || needs.retag-image.result == 'success')
|
||||
needs: [retag-image]
|
||||
runs-on: docker-host
|
||||
steps:
|
||||
- uses: actions/checkout@v7.0.1
|
||||
if: github.event_name == 'push'
|
||||
# Publish the named release's own tree, so the page never pairs one Plainpages tag with another
|
||||
# release's sidecar pins. A version that was never released fails here.
|
||||
- uses: actions/checkout@v7.0.1
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
with:
|
||||
ref: refs/tags/v${{ inputs.overview_version }}
|
||||
- name: Publish the Docker Hub overview
|
||||
env:
|
||||
DOCKERHUB_REPO: ${{ github.repository }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
DOCKERHUB_USER: ${{ vars.DOCKERHUB_USER }}
|
||||
GIT_TAG: ${{ github.ref_name }}
|
||||
INPUT_VERSION: ${{ inputs.overview_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
|
||||
VERSION=${VERSION#v}
|
||||
# An empty dispatch input falls back to the branch name, so gate this like a tag.
|
||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-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.22.2-alpine3.24 \
|
||||
node release-tooling/dockerhub-overview.ts "$VERSION"
|
||||
docker logout gitea.larvit.se
|
||||
docker logout docker.io
|
||||
|
||||
@@ -21,15 +21,14 @@ jobs:
|
||||
-e RENOVATE_PLATFORM=gitea \
|
||||
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||
-e RENOVATE_TOKEN \
|
||||
renovate/renovate:44.186.6
|
||||
renovate/renovate:44.39.1
|
||||
|
||||
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
|
||||
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
|
||||
# trigger-time tip and lags the merges this run made. Skips when main's tip isn't a Renovate commit
|
||||
# (a human owns that release), nothing new merged, or nothing that merged carried a `Release-Bump:`
|
||||
# trailer — a release nobody can observe is noise. ff-only merges keep the renovate commit's
|
||||
# (a human owns that release) or nothing new merged. ff-only merges keep the renovate commit's
|
||||
# authorship on the tip, so the author checks are reliable. Level = highest `Release-Bump:` trailer;
|
||||
# pre-1.0 shifts down (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).
|
||||
auto-release:
|
||||
runs-on: docker-host
|
||||
@@ -55,15 +54,8 @@ jobs:
|
||||
fi
|
||||
BUMPS=$(git log "${LATEST}..origin/main" --author='renovate@larvit.se' \
|
||||
--format='%(trailers:key=Release-Bump,valueonly)' | { grep -vx '' || true; })
|
||||
if [ -z "$BUMPS" ]; then
|
||||
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
|
||||
fi
|
||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||
node release-tooling/next-version.ts "$LATEST" $BUMPS)
|
||||
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
|
||||
git show origin/main:src/plugin-host/plugin.ts \
|
||||
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||
node release-tooling/contract-version.ts "$NEXT" -
|
||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.19.0-alpine3.24 \
|
||||
node auto-release/next-version.ts "$LATEST" $BUMPS)
|
||||
echo "Releasing $LATEST -> $NEXT"
|
||||
git tag "$NEXT" origin/main
|
||||
git push "https://renovate-bot:${RENOVATE_TOKEN}@gitea.larvit.se/${REPO}.git" "$NEXT"
|
||||
|
||||
@@ -36,15 +36,6 @@ branch, create a PR and merge it when the CI/CD turns green.
|
||||
## Project priorities (do not erode)
|
||||
|
||||
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
|
||||
**A page is a document**: it scrolls, and the chrome scrolls with it. Nothing may bound the
|
||||
viewport to hold content still — no `height: 100dvh` frame, no `overflow: hidden` on `body`, no
|
||||
`position: sticky` header. Each such box buys an app-like look with CSS the next reader has to
|
||||
reverse-engineer, and is one more thing to undo before the content under it can be reached.
|
||||
Overlays are not this: the skip link, the mobile off-canvas nav and its scrim sit *above* the
|
||||
document rather than holding it still, and have no other spelling — the document keeps scrolling
|
||||
behind the open nav, accepted rather than overlooked. Only the document scroller gets keyboard
|
||||
paging unconditionally and back/forward scroll restoration, and a page a box clips fails silently:
|
||||
nothing in a test or a console says content is unreachable below the fold.
|
||||
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
|
||||
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
|
||||
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
|
||||
@@ -102,16 +93,6 @@ Revisit only if the stated reason stops holding.
|
||||
plugin cannot destroy data — boot logs the orphans instead. Because the host's copy sits in the
|
||||
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
|
||||
@@ -191,22 +172,6 @@ Revisit only if the stated reason stops holding.
|
||||
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
||||
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
||||
verb — a route table must answer "what does this need?" on its own.
|
||||
- **A gate is one of three, named exactly once, and `session` is a first-class one.** A route or nav
|
||||
node names exactly one of `public`, `session`, `permission` — discovery refuses none, two, and a
|
||||
flag spelled anything but `true`, so a forgotten gate fails the boot rather than publishing a page.
|
||||
`src/auth/gate.ts` is the one home of the rule the plugin router, the host's own route table and
|
||||
the menu all read. Exactly-one-gate is a discovery-time rule on manifests, not a runtime
|
||||
invariant: `allows({}, user)` stays open **by design**, because the central override's `groups`
|
||||
builds header nodes that carry no gate. Making `allows` fail closed would hide every
|
||||
operator-grouped section. `session` exists because a plugin whose data is
|
||||
the visitor's own — their upstream account, their own tokens — has no distinction a permission could
|
||||
name; the alternative, granting every newly registered user a permission, couples the identity
|
||||
lifecycle to a Keto write that nothing retries when it fails. A page scoped to "mine" joins on
|
||||
`ctx.user.id`, never the email — an address is user-changeable and can be reassigned to someone
|
||||
who would then inherit the previous holder's rows.
|
||||
- **The reference plugin's two shift pages duplicate a view model and markup on purpose.** An example
|
||||
is read far more often than it is changed, and each page reads top to bottom on its own. **Valid
|
||||
while `examples/plugins/scheduling` stays a teaching artifact rather than a maintained product.**
|
||||
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
|
||||
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
|
||||
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
|
||||
@@ -221,9 +186,6 @@ Revisit only if the stated reason stops holding.
|
||||
adds you to any group, including one holding every permission; `users:write` mints a recovery code
|
||||
for any account. The containment the split buys is real on the **read** half only (`users:read` is
|
||||
a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
|
||||
- **The CSRF token is deliberately unbound to the session** (`src/auth/csrf.ts`; README → Security
|
||||
model): the usual cost of a signed double-submit token, covered by `SameSite=Lax` and HSTS.
|
||||
**Valid while the session cookie stays `SameSite=Lax` or stricter.**
|
||||
- **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed
|
||||
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
|
||||
Kratos' wire shape — don't rename it.
|
||||
@@ -282,18 +244,11 @@ Revisit only if the stated reason stops holding.
|
||||
open-state style and the old-browser fallback both read; the partial **requires a caller-named
|
||||
`id`** and fails loud without one, since that is the `popovertarget` idref (never generate one —
|
||||
nondeterministic HTML forecloses the caching decision); and **neither `aria-expanded` nor
|
||||
`aria-haspopup` is written**: every engine maintains the first itself on a declarative
|
||||
`popovertarget` invoker, so a hand-written one replaces a live state with a static lie, and the
|
||||
second would promise `role="menu"` semantics these panels don't implement. **That guarantee is the
|
||||
declarative attribute's alone** — open a panel from script and no engine applies it, so
|
||||
"enhancing" one of these triggers is what would cost it its accessibility. `<details>` stays where
|
||||
`aria-haspopup` is written**, because a zero-JS invoker cannot keep the first truthful and the
|
||||
second would promise `role="menu"` semantics these panels don't implement. `<details>` stays where
|
||||
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
||||
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
|
||||
the two in step.
|
||||
- **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s
|
||||
`overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame:
|
||||
they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` —
|
||||
a label click focuses it, and a browser scrolls a focused element into view.
|
||||
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
|
||||
is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
|
||||
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
|
||||
@@ -332,17 +287,12 @@ Revisit only if the stated reason stops holding.
|
||||
console message only appears in the engine that renders the page (`ORY_FREE` in
|
||||
`e2e-tests/playwright.config.ts`). The rest write users, groups and sessions to one shared backend,
|
||||
so widening them means a stack per engine.
|
||||
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root.** Both git channels in
|
||||
`ci.sh`'s `docs_only()` pass `--no-renames`: rename detection names only the destination, so
|
||||
`git mv src/app.ts notes.md` would otherwise read as docs and skip the gate over a source file that
|
||||
was gone. `src/ci-gate.test.ts` locks the flags as a
|
||||
*text* guard — the test image ships neither `git` nor `bash`. This is why the Docker Hub overview is
|
||||
`release-tooling/dockerhub-overview.md.tmpl` and not a `.md`: a release reads it and a unit test
|
||||
guards it, so giving it a `.md` name would let a broken `{{VERSION}}` merge with its own guard
|
||||
skipped. `README.md` is the one markdown a test reads — `release-tooling/contract-version.test.ts`
|
||||
checks its `apiVersion` samples — and a README-only change skips that check; accepted, because
|
||||
those samples are illustrative and the copies that matter (`examples/`, `views/`, the template) are
|
||||
gated. **Valid while no markdown file is rendered or executed.**
|
||||
- **The docs-only CI skip is `*.md` anywhere in the tree, not just the root** — no test, build step or
|
||||
workflow reads a markdown file. Both git channels in `ci.sh`'s `docs_only()` pass `--no-renames`:
|
||||
rename detection names only the destination, so `git mv src/app.ts notes.md` would otherwise read as
|
||||
docs and skip the gate over a source file that was gone. `src/ci-gate.test.ts` locks the flags as a
|
||||
*text* guard — the test image ships neither `git` nor `bash`. Revisit if a `.md` ever becomes
|
||||
load-bearing.
|
||||
- **CI docker logins share the runner host's Docker config.** The act_runner is host-mode, so
|
||||
`docker login`/`logout` in the workflows mutate one shared `~/.docker/config.json`: concurrent jobs
|
||||
can race (one job's logout can 401 another's push — recover by re-running), and tokens sit in that
|
||||
@@ -370,7 +320,7 @@ docker compose -f compose.yml up --build -d # production
|
||||
stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands
|
||||
copy-pasteable; deeper detail lives in its own section, linked.
|
||||
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
|
||||
by **what an adopter reaches for first**, not by architectural layering: Goals → Audience → Overview → Users, groups
|
||||
by **what an adopter reaches for first**, not by architectural layering: Overview → Users, groups
|
||||
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
|
||||
Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
|
||||
map → Extending. Place a new section by how early an adopter needs it. **Users, groups &
|
||||
@@ -397,18 +347,14 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
|
||||
- Pin all dependencies and Docker images to exact, human-readable **semantic versions** — never
|
||||
ranges (`^`, `~`) and never digests. npm deps via `.npmrc` (`save-exact=true`) + `npm ci`; images
|
||||
by tag.
|
||||
- **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist*: its
|
||||
rules name exactly what carries the trailer, so a dependency outside them never escalates the
|
||||
release version and nothing fails to say so. A new manifest, compose file, custom manager or dep
|
||||
type is a decision: can it reach a running Plainpages? If yes it needs a rule; if no, record nothing
|
||||
and let it ride the next patch.
|
||||
- **`HOST_API_VERSION` *is* the release version.** Its `major.minor` must equal the release tag's, and
|
||||
both release paths refuse a tag that disagrees (`release-tooling/contract-version.ts`). The patch
|
||||
digit may lag on purpose: `checkApiVersion`
|
||||
ignores patch, and auto-release cuts patch releases with no commit to bump a constant in. So a
|
||||
dependency update big enough to force a **minor** is plugin-visible by definition — `auto-release`
|
||||
stops rather than tagging, and the fix is to bump `HOST_API_VERSION` to that `X.Y.0` in a PR, merge
|
||||
it, then tag. Never bump it to "catch up" with a patch release. **The contract surface
|
||||
- **Touching dependencies means revisiting `renovate.json`.** `Release-Bump` is an *allowlist* — only
|
||||
the root `package.json`'s runtime deps, the `Dockerfile` base and `compose.yml`'s services carry the
|
||||
trailer, so a dependency added anywhere else never escalates the release version and nothing fails to
|
||||
say so. A new manifest, compose file, custom manager or dep type is a decision: can it reach a
|
||||
running Plainpages? If yes it needs a rule; if no, record nothing and let it ride the next patch.
|
||||
- **`HOST_API_VERSION` is a live promise as of the v0.1.0 release** (the app's version and the
|
||||
contract's move independently). Bump it with every contract change, per the table in README →
|
||||
Contract versioning: major on a breaking one, minor on an additive one. **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 })`
|
||||
@@ -450,12 +396,9 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
|
||||
same rule in their own language. An unmapped Kratos id renders Kratos' own wording — map the id when
|
||||
it matters. **Held by the author, never by a test:** slightly different wording is often the right
|
||||
call, and a build-failing check takes that judgment away.
|
||||
- Use well formed, standard compliant, rich URIs. **State lives in the URL or on the server, never in
|
||||
a cookie.** Prefer state in the URL over POSTing it, for example on list pages with filters and
|
||||
pagination. A message for the page a redirect lands on rides its query string — `info-msg`,
|
||||
`warn-msg`, `error-msg` — since a fragment never reaches the server. A cookie carries only what
|
||||
must be bound to the browser: the session (`plainpages_jwt`) and the CSRF token (`plainpages_csrf`).
|
||||
Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not `ids=x,y`.
|
||||
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POSTing it, for
|
||||
example on list pages with filters and pagination. Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not
|
||||
`ids=x,y`.
|
||||
|
||||
## Comments
|
||||
|
||||
@@ -463,13 +406,3 @@ Default to **no comment**. Delete one that restates the adjacent code, repeats a
|
||||
elsewhere, justifies self-evident code, or records history. Write one only for what a competent
|
||||
reader of *this* codebase could not infer: a surprising why, a footgun, an invariant, an external
|
||||
constraint. See [Prose discipline](#prose-discipline).
|
||||
|
||||
## Scoring run
|
||||
|
||||
The comprehension panel's seats fill their prompts from these fields.
|
||||
|
||||
- **Language:** strict TypeScript on Node 24, with EJS views and plain CSS
|
||||
- **Kind:** a server-rendered web GUI host with a plugin system
|
||||
- **Domain:** web authentication, authorization and server-rendered admin GUIs
|
||||
- **Domain docs:** the Ory Kratos, Keto and Hydra documentation
|
||||
- **3am question:** a visitor on an old TV browser reports that the sign-in button is blank. Which file and rule?
|
||||
|
||||
-137
@@ -1,137 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
The release version **is** the plugin contract version (`HOST_API_VERSION`), so a minor is a
|
||||
contract break: a plugin's `apiVersion` must match the host's `major.minor` or discovery refuses it
|
||||
at boot. Entries start at 0.3.0.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### The mobile nav stays reachable on engines without `:has()`
|
||||
|
||||
At 860px and narrower, an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23,
|
||||
and Firefox below 121) hid the sidebar off-canvas with no way to open it, so the pages the nav links
|
||||
to could not be reached. Now the sidebar moves off-canvas, behind the hamburger, only where `:has()`
|
||||
works. Elsewhere it stays in the flow above the content and the hamburger is hidden.
|
||||
|
||||
### The whole nav tree shows on engines without `:has()`
|
||||
|
||||
On an engine without `:has()` (Chromium below 105, such as webOS TV 22 and 23, and Firefox below 121)
|
||||
every nested nav entry was hidden with nothing to open it. Now the tree folds only where `:has()`
|
||||
works and stays fully open elsewhere, with its toggles hidden. The theme switch, which also needs
|
||||
`:has()`, is hidden there.
|
||||
|
||||
### Button and badge text meets 4.5:1 contrast
|
||||
|
||||
The primary button and the current page button take their text colour from the new `--on-accent`
|
||||
token: white in light mode, near-black in dark mode, where white on the accent was 2.7:1. The
|
||||
light-mode success, warning and info badge text is darker. A plugin that overrides `--accent` or
|
||||
`--l-accent` should set `--l-on-accent` and `--d-on-accent` to match.
|
||||
|
||||
### Unused auth stylesheet rules are gone
|
||||
|
||||
`public/css/auth.css` no longer holds the `.auth-view` screen switch, `.tpl-*` preview controls,
|
||||
`.forgot-alert`, `.register-alert`, `.remember` and `.auth-foot`; no core view used them. A plugin
|
||||
view that did must carry its own rules.
|
||||
|
||||
### Colours render on engines without `oklch()`
|
||||
|
||||
The theme palette in `public/css/styles.css` is hex, not `oklch()`. On an engine older than
|
||||
Chromium 111 (Tizen 2024, webOS TV 24) `var(--accent)` was invalid, so the primary button was white
|
||||
text on no background. A plugin that sets a colour custom property, such as `--accent` or `--l-accent`,
|
||||
should use hex, `rgb()` or `hsl()` too.
|
||||
|
||||
### One horizontal inset across the content pane
|
||||
|
||||
The topbar, filter bar, pager, form page and empty-table message inset their content by `--pad-x`
|
||||
(12px), as table cells do, instead of 16px. A plugin that overrides `--pad-x` now moves all of them
|
||||
together.
|
||||
|
||||
### Pages are not kept by a shared cache
|
||||
|
||||
Every page and JSON response now carries `Cache-Control: private, no-store`, so a cache or reverse
|
||||
proxy in front of Plainpages can't store a signed-in page and serve it to the next visitor. A route
|
||||
whose page is the same for everyone can still set its own `cache-control` through
|
||||
`RouteResult.headers`; assets are unchanged.
|
||||
|
||||
## 0.4.0
|
||||
|
||||
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
|
||||
that relied on filling it scrolls the document instead.
|
||||
|
||||
### The document scrolls, and the chrome scrolls with it
|
||||
|
||||
`.app` was a `100dvh` box with `overflow: hidden`, so a page was only reachable below the fold if its
|
||||
own wrapper was a flex child with `overflow-y: auto`. `.table-wrap` and `.shell-auth` were; nothing
|
||||
else was, and a long page in `.form-page` clipped everything past the window in every engine.
|
||||
|
||||
Now the shell is `min-height: 100dvh` and nothing bounds the viewport. The sidebar and topbar scroll
|
||||
with the page, and keyboard paging, back/forward scroll restoration and find-in-page work without a
|
||||
page doing anything.
|
||||
|
||||
The sticky `thead` on `data-table` goes with it: a header only sticks to a scrollport that moves, and
|
||||
there is no longer one. A plugin that wants a full-height pane owns that in its own stylesheet; the
|
||||
shell offers no opt-out, per the simplicity priority in `AGENTS.md`.
|
||||
|
||||
### Upgrading a plugin
|
||||
|
||||
1. Set `apiVersion: "0.4.0"`.
|
||||
2. A page that scrolled the whole window needs no change — it now scrolls the document.
|
||||
3. A page holding a region that filled the content column (`flex: 1 1 auto; min-height: 0` with its
|
||||
own `overflow`) no longer gets a bounded column to fill, so that region grows and the page scrolls.
|
||||
Either let it, or give the region its own height in the plugin's stylesheet.
|
||||
4. A `data-table` no longer scrolls its rows in a bounded region: the page scrolls, and the header
|
||||
scrolls with it.
|
||||
|
||||
The sidebar stretches the whole document, so on a long page its footer — theme, language, profile and
|
||||
**Sign out** — sits at the end of that page rather than the bottom of the screen.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
**Breaking.** Set `apiVersion: "0.3.0"`, and name a gate on every route and nav node.
|
||||
|
||||
### A session is a gate of its own
|
||||
|
||||
`session: true` takes any signed-in user, with no grant to hold — for a page whose data is the
|
||||
visitor's own (their upstream account, their own tokens), where there is no distinction a permission
|
||||
could name. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
|
||||
permission gate does.
|
||||
|
||||
Every route and nav node now names **exactly one** of `public: true`, `session: true` or
|
||||
`permission: "<resource>:<action>"`, and a gate is spelled `true`:
|
||||
|
||||
- Naming **none** is refused. It used to mean public, so a forgotten gate published a page; it now
|
||||
fails the boot instead.
|
||||
- Naming **two** is refused, as before.
|
||||
- Spelling one anything but `true` is refused — `public: false` and `session: "yes"` both set no gate
|
||||
while reading as if they set one.
|
||||
|
||||
A section header gates nothing itself, so it takes `public: true` and lets each child decide; the
|
||||
host still drops a header whose children all filtered out.
|
||||
|
||||
`Gate` is exported from `@plainpages/plugin-api`, and `Route` and `NavNode` extend it.
|
||||
|
||||
### Filter bars take a multi-select
|
||||
|
||||
The `filter-bar` partial gains a `multiselect` control — the same checkboxes on the same query
|
||||
parameter as `chips`, but behind a button once the list is too long to lay on the bar. Config is
|
||||
`{ name, legend?, note?, value?, options }`, and the panel says what a capped list left out.
|
||||
|
||||
### Fixed
|
||||
|
||||
- An identity carrying no email no longer yields a session at all. Login used to mint a JWT for one,
|
||||
which every later request then rejected as anonymous — leaving the browser holding a dead cookie
|
||||
and no way to tell why.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Node 24.20.0.
|
||||
|
||||
### Upgrading a plugin
|
||||
|
||||
1. Set `apiVersion: "0.3.0"`.
|
||||
2. Give every route and nav node a gate. Anything that relied on omitting one was public — say
|
||||
`public: true` outright.
|
||||
|
||||
A page that scopes rows to the signed-in visitor should join on `ctx.user.id`. An email address is
|
||||
user-changeable and can be reassigned to someone else, who would then inherit the previous holder's
|
||||
rows. The reference plugin's new `/scheduling/mine` page shows the shape.
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
||||
FROM node:24.22.2-alpine3.24
|
||||
FROM node:24.19.0-alpine3.24
|
||||
|
||||
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
|
||||
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
|
||||
|
||||
@@ -10,7 +10,7 @@ one; the host itself is stateless, and there is no build step.
|
||||
|
||||
## Tags
|
||||
|
||||
`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.
|
||||
|
||||
## Quick start
|
||||
@@ -22,7 +22,7 @@ so there is nothing to clone. In an empty directory, save this as `compose.yml`:
|
||||
```yaml
|
||||
services:
|
||||
web:
|
||||
image: larvit/plainpages:{{VERSION}}
|
||||
image: larvit/plainpages:0.1.0
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
@@ -41,7 +41,7 @@ services:
|
||||
|
||||
# One-shot, idempotent seed: signing key if absent + the admin@plainpages.local / admin user.
|
||||
bootstrap:
|
||||
image: larvit/plainpages:{{VERSION}}
|
||||
image: larvit/plainpages:0.1.0
|
||||
command: node src/auth/bootstrap.ts
|
||||
depends_on:
|
||||
kratos:
|
||||
@@ -54,7 +54,7 @@ services:
|
||||
restart: "on-failure:5"
|
||||
|
||||
postgres:
|
||||
image: postgres:18.6-alpine3.23
|
||||
image: postgres:18.4-alpine3.23
|
||||
environment:
|
||||
POSTGRES_DB: ory
|
||||
POSTGRES_PASSWORD: ory
|
||||
@@ -131,7 +131,7 @@ services:
|
||||
|
||||
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.32.0
|
||||
image: axllent/mailpit:v1.30.1
|
||||
ports:
|
||||
- "8025:8025"
|
||||
restart: unless-stopped
|
||||
@@ -143,7 +143,7 @@ volumes:
|
||||
Extract the Ory config the image ships, then start:
|
||||
|
||||
```bash
|
||||
docker run --rm larvit/plainpages:{{VERSION}} tar -cf - ory | tar -xf -
|
||||
docker run --rm larvit/plainpages:0.1.0 tar -cf - ory | tar -xf -
|
||||
mkdir -p plugins
|
||||
docker compose up -d
|
||||
```
|
||||
@@ -182,7 +182,7 @@ into the app. Create `plugins/hello/plugin.ts`:
|
||||
import { definePlugin } from "@plainpages/plugin-api";
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0",
|
||||
apiVersion: "1.0.0",
|
||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||
routes: [
|
||||
{ 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";
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0",
|
||||
apiVersion: "1.0.0",
|
||||
nav: [{ href: "/hello", id: "hello", label: "Hello", public: true }],
|
||||
routes: [
|
||||
{ method: "GET", path: "/", public: true, handler: () => ({ html: "<h1>Hello from my plugin</h1>" }) },
|
||||
@@ -70,8 +70,6 @@ From here, render real pages against the app shell and fetch upstream data — s
|
||||
|
||||
## Contents
|
||||
|
||||
- [Goals](#goals)
|
||||
- [Audience](#audience)
|
||||
- [Overview](#overview)
|
||||
- [Users, groups & permissions](#users-groups--permissions)
|
||||
- [naming a permission](#naming-a-permission)
|
||||
@@ -91,7 +89,6 @@ From here, render real pages against the app shell and fetch upstream data — s
|
||||
- [hooks](#hooks)
|
||||
- [where they live & mounting](#where-plugins-live-and-how-to-mount-them)
|
||||
- [dependencies](#plugin-dependencies)
|
||||
- [settings](#plugin-settings)
|
||||
- [storage](#plugin-storage)
|
||||
- [local dev & test](#local-dev--test-story)
|
||||
- [The menu system](#the-menu-system)
|
||||
@@ -123,64 +120,6 @@ From here, render real pages against the app shell and fetch upstream data — s
|
||||
- [Project layout](#project-layout)
|
||||
- [Extending the core](#extending-the-core)
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Achieve:** Less boilerplate for a server-rendered web GUI: a new project starts at
|
||||
its own domain, not at sign-in, a menu or a table.
|
||||
- Signal: a new plugin reaches a working, gated page with a menu, tables, filters and
|
||||
forms built only from the core building blocks.
|
||||
- Counter-signal: the core grows features only one project needs.
|
||||
- Why: boilerplate removed by absorbing a project's domain only moves it into the core.
|
||||
2. **Achieve:** Every building block works for the person using it: a non-technical user
|
||||
gets what they meant on the first try, and can't hurt themselves by accident.
|
||||
- Signal: bugs users hit in a shipped flow get fixed and stay fixed, such as a pasted
|
||||
code being refused, a double click acting twice, or an admin locking themselves out.
|
||||
- Counter-signal: a fix adds client-side JavaScript, or a setting every plugin has to
|
||||
know about.
|
||||
- Why: a fix that breaks zero-JS or simplicity moves the irritation from users to every
|
||||
plugin author.
|
||||
3. **Maintain:** Plainpages solves only the parts every project shares: auth, the GUI
|
||||
building blocks (menu, tables, filters, forms) and the plugin structure. Everything
|
||||
else is a plugin.
|
||||
4. **Maintain:** Every page is a server-rendered document that works with zero JavaScript,
|
||||
on old and embedded browsers too. A newer browser only makes it nicer.
|
||||
5. **Maintain:** State lives in the URL. Only state the URL can't carry lives in the
|
||||
database, never in local storage or JavaScript. A cookie carries only what must be bound
|
||||
to the browser: the session and its CSRF token.
|
||||
6. **Maintain:** The frontend is semantic: the HTML says what each thing is, and the CSS
|
||||
styles meaning, not looks. That keeps every page accessible.
|
||||
7. **Achieve:** Hackable: a plugin can change anything, and Plainpages stays out of its way.
|
||||
- Signal: a plugin replaces any page, partial, menu entry or string without forking the
|
||||
host, and fewer plugins need a host change to get unblocked.
|
||||
- Counter-signal: plugins break on upgrade with no version signal, or a plugin mistake
|
||||
shows up at runtime instead of at boot.
|
||||
- Why: freedom that breaks silently costs more than the boilerplate it saved.
|
||||
8. **Achieve:** Speed: pages render fast and stay small.
|
||||
- Signal: server time per page and bytes sent per page fall.
|
||||
- Counter-signal: a building block loses a feature or accessibility to save bytes.
|
||||
- Why: a fast page that does less only moves the work back into each plugin.
|
||||
9. **Achieve:** LLM-friendly: an LLM builds a correct plugin from the README and the
|
||||
examples alone.
|
||||
- Signal: an agent given only those writes a plugin that passes discovery and its E2E
|
||||
test on the first try.
|
||||
- Counter-signal: the README grows to restate what the code already shows.
|
||||
- Why: docs that pass the test by growing cost every later reader, human or LLM.
|
||||
10. **Maintain:** Every behaviour is an explicit configuration choice, the same in every
|
||||
environment.
|
||||
|
||||
## Audience
|
||||
|
||||
- **Developers starting a web GUI project, and the LLMs that build plugins with them.**
|
||||
They may rely on `@plainpages/plugin-api`, the core partials and the packaging promises,
|
||||
all versioned by `apiVersion`. A change that breaks a plugin takes a major bump.
|
||||
- **Operators** who self-host Plainpages. They may rely on semver-tagged images, on
|
||||
configuration through environment variables and the drop-in mounts (`plugins/`,
|
||||
`config/`, `locales/`), and on upgrades within a minor version that need no change on
|
||||
their side.
|
||||
- **Visitors** to a site built on Plainpages, on any browser, including the older engines in
|
||||
TVs, kiosks, thin clients and embedded panels. They may rely on pages that load fast and
|
||||
work without JavaScript, with a keyboard and a screen reader.
|
||||
|
||||
## Overview
|
||||
|
||||
Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu,
|
||||
@@ -196,9 +135,11 @@ public front with an authenticated area behind it.
|
||||
- **You add:** everything else domain-specific, as **plugins** — built from the same building
|
||||
blocks the admin plugin uses.
|
||||
|
||||
Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar
|
||||
services. The shape doesn't change as it grows: every plugin is the same self-contained folder,
|
||||
the hot path is the same I/O-free JWT check, and the host stores nothing of its own.
|
||||
**Priorities:** simplicity, few dependencies, strict TypeScript, no build step, Docker-only,
|
||||
environment-agnostic (no `NODE_ENV` — every behaviour is an explicit config toggle). Identity,
|
||||
sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar services. The shape
|
||||
doesn't change as it grows: every plugin is the same self-contained folder, the hot path is the
|
||||
same I/O-free JWT check, and there is no app database.
|
||||
|
||||
**Plugins are the extension model.** The plugin API is the product's main surface: powerful,
|
||||
predictable, and overloadable — a plugin can take over as much of a page as it wants. The host
|
||||
@@ -289,7 +230,6 @@ Against the reference plugins' actual routes:
|
||||
| Request | Gate | alice | bob | carol | anonymous |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ |
|
||||
| `GET /scheduling/mine` | `session: true` | ✅ | ✅ | ✅ | → `/login` |
|
||||
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
|
||||
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||
| `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||
@@ -408,9 +348,9 @@ import { definePlugin } from "@plainpages/plugin-api";
|
||||
import { listThings, createThings } from "./handlers.ts";
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0", // semver string of the host contract this plugin was built against (see Versioning)
|
||||
apiVersion: "1.0.0", // semver string of the host contract this plugin was built against (see Versioning)
|
||||
|
||||
// Nav fragment, merged into the global menu and gate-filtered per user.
|
||||
// Nav fragment, merged into the global menu and permission-filtered per user.
|
||||
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
|
||||
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
|
||||
|
||||
@@ -420,7 +360,7 @@ export default definePlugin({
|
||||
{ description: "Create and edit things", name: "things:write" },
|
||||
],
|
||||
|
||||
// Route handlers, mounted under the plugin's path (/things). The gate runs first.
|
||||
// Route handlers, mounted under the plugin's path (/things). `permission` gates first.
|
||||
routes: [
|
||||
{ method: "GET", path: "/", permission: "things:read", handler: listThings },
|
||||
{ method: "POST", path: "/", permission: "things:write", handler: createThings },
|
||||
@@ -437,24 +377,23 @@ folder-derived `id` to produce the loaded `Plugin`.
|
||||
| `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). |
|
||||
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
|
||||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
|
||||
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
|
||||
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
|
||||
| `routes` | no | See [Routes & handlers](#routes--handlers). |
|
||||
| `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.
|
||||
|
||||
### Routes & handlers
|
||||
|
||||
A route is `{ method, path, handler }` plus [exactly one gate](#public-pages--menu-items) —
|
||||
`permission`, `public: true` or `session: true`. `path` is **relative to the plugin's
|
||||
A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's
|
||||
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches
|
||||
`method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the
|
||||
gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with
|
||||
`permission` gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with
|
||||
the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login`
|
||||
with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page.
|
||||
`public: true` means no gate at all (see [Public pages](#public-pages--menu-items)).
|
||||
|
||||
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
|
||||
|
||||
@@ -529,7 +468,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
||||
import { landing, board } from "./pages.ts";
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0",
|
||||
apiVersion: "1.0.0",
|
||||
home: landing, // owns "/" — the public front page
|
||||
dashboard: board, // owns "/dashboard" — the post-login app home
|
||||
});
|
||||
@@ -628,28 +567,20 @@ system plugins you author or vendor. An ordinary domain plugin ignores it.
|
||||
|
||||
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
|
||||
applies the central override and then **filters per user** by the permissions in the session JWT: a
|
||||
node shows iff it is `public`, is `session` and someone is signed in, or names a `permission` the
|
||||
user holds. A node's `icon` is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the
|
||||
available ids are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name
|
||||
there.
|
||||
node shows iff it is `public`, declares no `permission`, or the user holds that name. A node's `icon`
|
||||
is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are
|
||||
`ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
|
||||
|
||||
**Gating a section header.** A `permission` on the header takes the whole subtree with it. When the
|
||||
children need *different* permissions, mark the header `public: true` — it then gates nothing, each
|
||||
child decides, and `composeNav` drops a header whose children all filtered out. That only works while
|
||||
the header carries **no `href`**: give it one and it survives as a leaf, visible to everyone.
|
||||
children need *different* permissions, leave the header ungated and gate each child — `composeNav`
|
||||
drops a header whose children all filtered out. That only works while the header carries **no
|
||||
`href`**: give it one and it survives as an ungated leaf, visible to everyone.
|
||||
|
||||
#### Public pages & menu items
|
||||
|
||||
A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu —
|
||||
open stated outright, so it is a deliberate choice rather than a forgotten gate.
|
||||
|
||||
**`session: true`** takes any signed-in user, with no grant to hold — for a plugin whose data is the
|
||||
visitor's own. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
|
||||
permission gate does.
|
||||
|
||||
Every route and nav node names **exactly one** of the three, spelled `true` (or a permission name).
|
||||
Naming none, naming two, or spelling one `false` is refused at boot — so a forgotten gate fails the
|
||||
plugin instead of publishing a page.
|
||||
A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu.
|
||||
That is the same as omitting `permission`, but stated outright so public is a deliberate choice
|
||||
rather than a forgotten gate. The two are **mutually exclusive** — declaring both is refused at boot.
|
||||
|
||||
A public page still renders in the native shell; for an anonymous visitor `ctx.user` is `null`, the
|
||||
shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden,
|
||||
@@ -662,30 +593,22 @@ works without editing host config.
|
||||
|
||||
### Contract versioning
|
||||
|
||||
Each manifest declares `apiVersion` — a **semver** string naming the **Plainpages release** it was
|
||||
built against. The host's `HOST_API_VERSION` *is* its release version, so a plugin author reads one
|
||||
version off the image they run and writes it down — there is no separate contract number. Both
|
||||
release paths refuse a tag whose `major.minor` disagrees with the constant, so the two cannot drift.
|
||||
|
||||
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`:
|
||||
Each manifest declares `apiVersion` — a **semver** string naming the host contract it was built
|
||||
against — against the host's `HOST_API_VERSION`. The host bumps **major** on a breaking
|
||||
manifest/handler change and **minor** on an additive one. At discovery it parses both with
|
||||
`parseSemver` (strict: no ranges, `v` prefixes, or leading zeros) and applies provider/consumer
|
||||
semantics in `checkApiVersion`:
|
||||
|
||||
| Plugin `apiVersion` vs host | Result | Host action |
|
||||
| --- | --- | --- |
|
||||
| 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 — built against an older release; check that release's notes |
|
||||
| same major, plugin minor **<** host minor | `warn` | load, log — additive-compatible, newer features exist |
|
||||
| same major, plugin minor **>** host minor | `refuse` | **abort boot** — plugin needs a newer host |
|
||||
| different major | `refuse` | **abort boot** — incompatible contract |
|
||||
| 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 compatibility. One digit carries the whole release, so a **minor** means either the plugin
|
||||
contract changed or a dependency moved far enough to warrant one.
|
||||
[`CHANGELOG.md`](CHANGELOG.md) is what a minor sends you to: what broke, and what to change.
|
||||
|
||||
the caret-style compatibility.
|
||||
|
||||
### Conflict rules
|
||||
|
||||
@@ -703,7 +626,7 @@ The host detects collisions across all discovered plugins with `findConflicts` a
|
||||
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
|
||||
rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function
|
||||
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
|
||||
route/nav node that does not name [exactly one gate](#public-pages--menu-items).
|
||||
route/nav node setting both `public` and `permission`.
|
||||
|
||||
### Hooks
|
||||
|
||||
@@ -806,56 +729,6 @@ barrel's types on disk: typecheck it mounted under the host tree, or vendor a ty
|
||||
`node_modules`** and point tsconfig `paths` at it — a stub inside is the shadowing copy discovery
|
||||
refuses, and it would travel with the folder you mount.
|
||||
|
||||
### Plugin settings
|
||||
|
||||
A plugin declares the configuration it accepts, and the host resolves it from the environment at
|
||||
boot. Each key becomes one variable — `PLUGIN_SETTING_<ID>_<KEY>`, the id's dashes and the key's
|
||||
camel humps both becoming underscores — so `upstream` on the `scheduling` plugin is set by
|
||||
`PLUGIN_SETTING_SCHEDULING_UPSTREAM`.
|
||||
|
||||
```ts
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0",
|
||||
settings: [
|
||||
{ key: "upstream", type: "url", required: true, description: "Base URL of the backend" },
|
||||
{ key: "pageSize", type: "number", default: 25 },
|
||||
{ key: "mode", type: "enum", values: ["strict", "lenient"], default: "strict" },
|
||||
{ key: "apiKey", type: "string", secret: true, default: "dev-insecure-key" },
|
||||
],
|
||||
hooks: {
|
||||
onBoot: ({ settings }) => {
|
||||
settings.upstream; // string — required, so the boot already refused without it
|
||||
settings.pageSize; // number — defaulted, so always present
|
||||
start(settings);
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`type` is one of `string`, `number`, `boolean`, `enum` (with `values`) or `url`. A declared type is
|
||||
coerced and checked at boot, so a mistyped value names the plugin, the key and the variable instead
|
||||
of surfacing later as a broken page.
|
||||
|
||||
**`required` and `default` are mutually exclusive** — a default means the setting can never fail, so
|
||||
declaring both is refused at discovery. That leaves three cases, and the type `onBoot` receives
|
||||
follows them exactly: `required: true` is always present, a `default` is always present, and a
|
||||
setting with neither is `T | undefined`, so the plugin has to handle its absence.
|
||||
|
||||
**Secrets.** `secret: true` marks a value the host reads but never renders — not in a boot log, not
|
||||
in an error, not on the admin screen, which shows only whether it resolved and from where. With
|
||||
`REQUIRE_SECURE_SECRETS=true` a secret that is unset, or still equal to its declared default, refuses
|
||||
the boot — the same rule the host applies to its own secrets.
|
||||
|
||||
**Where it fails, and where it warns.** A malformed declaration is refused at discovery; a missing
|
||||
`required` value or a value that will not coerce refuses the boot. A `PLUGIN_SETTING_` variable no
|
||||
installed plugin declares is only *reported* — it is usually a typo in the one the operator meant to
|
||||
set, and naming it turns two unrelated-looking errors into one. Declaring settings without an
|
||||
`onBoot` warns too: they resolve, but nothing receives them.
|
||||
|
||||
**Reading what a deployment is configured with.** The admin plugin's **Plugin settings** screen
|
||||
(`plugin-settings:read`) lists every installed plugin, its declared keys, the variable that sets
|
||||
each, and whether the value came from the environment or the declared default.
|
||||
|
||||
### Plugin storage
|
||||
|
||||
A plugin that needs to keep data sets `storage: true`. The host then provisions a Postgres
|
||||
@@ -869,7 +742,7 @@ import { definePlugin } from "@plainpages/plugin-api";
|
||||
let sql: ReturnType<typeof postgres>;
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0",
|
||||
apiVersion: "1.0.0",
|
||||
storage: true,
|
||||
hooks: {
|
||||
onBoot: async (boot) => {
|
||||
@@ -979,10 +852,9 @@ The menu is **driven entirely by config** and assembled from two sources:
|
||||
export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
|
||||
```
|
||||
|
||||
Every nav node a **plugin** declares names one gate — a `permission`, **`public: true`** (everyone)
|
||||
or **`session: true`** (anyone signed in); a header this override groups takes none, and shows
|
||||
whenever a child does. The rendered tree is **filtered per user** from the session JWT (no
|
||||
per-request authz call), so the menu only shows what that person can reach.
|
||||
Every nav item may carry a `permission`; the rendered tree is **filtered per user** from the session
|
||||
JWT (no per-request authz call), so the menu only shows what that person can reach. An item may
|
||||
instead be **`public: true`** to show it to everyone — mutually exclusive with `permission`.
|
||||
Branding (name, logo, default theme) renders in the app shell.
|
||||
|
||||
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders
|
||||
@@ -991,11 +863,6 @@ recovery / front pages — so it looks identical signed in or out and just shows
|
||||
anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a
|
||||
chrome-free layout opts out with the shell's `menu: false`.
|
||||
|
||||
**The document scrolls, and the chrome scrolls with it.** Nothing bounds the viewport, so a page is
|
||||
reachable below the fold without adding a scroll region of its own, and browser paging, scroll
|
||||
restoration and find-in-page work without a page doing anything. A plugin that wants a full-height
|
||||
pane owns that in its own stylesheet.
|
||||
|
||||
## Building blocks
|
||||
|
||||
Plainpages is a **component library, not a page generator** — reusable EJS partials + TS helpers,
|
||||
@@ -1019,25 +886,6 @@ underneath it — cramped, but nothing is unreachable. List state
|
||||
(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable and shareable; the
|
||||
URL is the only state the UI keeps.
|
||||
|
||||
**Two browser floors** serve goal 4. Every page's content and actions stay reachable on
|
||||
Chromium 87, Safari 15 and Firefox 115, the engines in years-old TVs, kiosks and Qt panels. Only
|
||||
features [Baseline Widely available](https://web-platform-dx.github.io/baseline/) (in every
|
||||
major engine for 30 months) may shape how a page looks; anything newer, like popovers or anchor
|
||||
positioning, only enhances. A feature the floor engines lack, such as `:has()`, may
|
||||
decorate a page but never decide whether something shows or can be read. A colour is hex, `rgb()` or
|
||||
`hsl()`, never `oklch()`: a custom property holding it passes unchecked, and the `var()` reading it
|
||||
then renders transparent.
|
||||
|
||||
**A field whose options depend on another field is asked for in steps, one GET form each**, so every
|
||||
choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made.
|
||||
Once the query names a value, that field renders read-only (`field` with `readonly`, and a `link` back
|
||||
to the URL without it) above the next step's form, which carries the earlier choices as hidden inputs,
|
||||
plus `locale` from `localeParam`. The form that writes comes last and posts to the URL naming every
|
||||
choice, so its handler reads them from `ctx.query`, never from the body; a value the query names that
|
||||
the step does not offer is that step's error, never a silent fallback. Don't redraw a half-filled form
|
||||
through a submit that writes nothing instead: a `required` field blocks it, and the choice never
|
||||
reaches the URL.
|
||||
|
||||
Plugins that genuinely need it — live dashboards, bulk actions, client-side validation — may **opt
|
||||
into progressive enhancement** (htmx, Alpine, vanilla JS) on top of working server-rendered HTML.
|
||||
The baseline never depends on it.
|
||||
@@ -1175,7 +1023,7 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
|
||||
| `PORT` | `3000` | web listen port |
|
||||
| `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) |
|
||||
| `REQUIRE_SECURE_SECRETS` | `false` | when `true`, `CSRF_SECRET` — and `PLUGIN_DB_SECRET` once storage is configured, and every plugin setting declared `secret` — must be supplied and differ from the dev throwaway |
|
||||
| `REQUIRE_SECURE_SECRETS` | `false` | when `true`, `CSRF_SECRET` — and `PLUGIN_DB_SECRET` once storage is configured — must be supplied and differ from the dev throwaway |
|
||||
| `LOG_LEVEL` | `info` | min severity logged: `error`/`warn`/`info`/`verbose`/`debug`/`silly`/`none` |
|
||||
| `LOG_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 |
|
||||
@@ -1192,7 +1040,6 @@ The app is **environment-agnostic**: no `NODE_ENV`, every behaviour its own expl
|
||||
| `REVOCATION_TTL_SEC` | `900` | how long a revoke entry lives; keep ≥ tokenizer TTL (10m) + clock skew |
|
||||
| `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 |
|
||||
@@ -1386,11 +1233,6 @@ authorization graph, so treat it the same. `compose.yml` publishes none of the s
|
||||
so a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our two cookies
|
||||
obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
|
||||
|
||||
**The CSRF token isn't bound to the session.** It is a signed double-submit token, so an attacker who
|
||||
can write cookies on your origin can plant one they know. `SameSite=Lax` keeps a cross-site page
|
||||
out, and HSTS (`SECURE_COOKIES=true`) a network attacker. A hostile page on a sibling subdomain can
|
||||
still set cookies, so host nothing untrusted on one.
|
||||
|
||||
**Offboarding is not instant by default** — a revoked permission or deactivated identity lands
|
||||
within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on.
|
||||
|
||||
@@ -1530,7 +1372,7 @@ Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
|
||||
| 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 |
|
||||
| `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) |
|
||||
| `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 |
|
||||
@@ -1555,27 +1397,11 @@ pattern-based org cleanup rule for this package — its age/count heuristics can
|
||||
release tags and would delete images the workflow protects.
|
||||
|
||||
**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 —
|
||||
once the major reaches `1` — `1`; nothing is rebuilt, so the released image is byte-identical to the
|
||||
gated one. While the major is `0` the bare-major tag is skipped, because a `0.x` minor is a contract
|
||||
break and a moving `:0` would carry one. It fails loud if no
|
||||
`release.yml`, which pulls that commit's hash image and re-tags it `1.2.3`, `1.2`, `1`, `latest`;
|
||||
nothing is rebuilt, so the released image is byte-identical to the gated one. It fails loud if no
|
||||
hash image exists — release tags must point at a commit that went through the gate. The same four
|
||||
tags sync to [Docker Hub](https://hub.docker.com/r/larvit/plainpages), releases only.
|
||||
|
||||
The [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.
|
||||
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).
|
||||
|
||||
**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.
|
||||
@@ -1593,17 +1419,13 @@ exact. Each PR runs the normal gate on its `renovate/*` branch and automerges on
|
||||
|
||||
**Auto-release on dependency updates** — a second job in `renovate.yml` (`auto-release`) cuts **one**
|
||||
`vX.Y.Z` tag per run covering the renovate-bot commits merged to `main` since the last tag, and
|
||||
**skips** when the tip isn't a Renovate commit, nothing new merged, or nothing that merged carried a
|
||||
trailer — a dependency update that cannot reach the app releases nothing. Renovate stamps a
|
||||
`Release-Bump: <updateType>` trailer onto the updates that reach a running Plainpages — the rules in
|
||||
[`renovate.json`](renovate.json) name them — and
|
||||
[`release-tooling/next-version.ts`](release-tooling/next-version.ts) turns the highest one into the next
|
||||
version; pre-1.0 it never auto-crosses into `1.0.0`. Because the contract version *is* the release
|
||||
version, an update big enough to reach a **minor** stops the job rather than tagging: bump
|
||||
`HOST_API_VERSION` in a PR, merge, then tag by hand. Pre-1.0 that covers a dependency *major*, since
|
||||
`nextVersion` shifts it down to a `0.x` minor. `updateType` rates the *dependency's* own jump,
|
||||
so the trailer is an allowlist: an update outside those rules carries none and rides the next patch
|
||||
release instead of escalating it. It is **tag-only**: the tag hands off to
|
||||
**skips** when the tip isn't a Renovate commit or nothing new merged. Renovate stamps a
|
||||
`Release-Bump: <updateType>` trailer onto the updates that reach a running Plainpages — the root
|
||||
`package.json`'s runtime dependencies, the image base, and `compose.yml`'s services — and
|
||||
[`auto-release/next-version.ts`](auto-release/next-version.ts) turns the highest one into the next
|
||||
version; pre-1.0 it never auto-crosses into `1.0.0`. `updateType` rates the *dependency's* own jump,
|
||||
so the trailer is an allowlist in [`renovate.json`](renovate.json): a devDependency, E2E or CI-only
|
||||
bump carries none and rides the next patch release instead of escalating it. It is **tag-only**: the tag hands off to
|
||||
`release.yml`, and is pushed with renovate-bot's PAT so that workflow actually fires (a tag pushed by
|
||||
the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never touched here.
|
||||
|
||||
@@ -1612,7 +1434,7 @@ the built-in Actions token wouldn't trigger it). `HOST_API_VERSION` is never tou
|
||||
| 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`. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
@@ -1652,11 +1474,6 @@ Every response carries security headers (`src/http/security-headers.ts`): a stri
|
||||
so a branding logo must live under `/public/` or be a `data:` URI; a plugin route can override any
|
||||
header per-response via `RouteResult.headers`.
|
||||
|
||||
Every page and JSON response is `Cache-Control: private, no-store`, so a cache in front of `web`
|
||||
never keeps one user's page for the next. A route may override it only for a page that is the same for
|
||||
every visitor: no user, no CSRF token, no `Set-Cookie`, since the host adds a fresh CSRF cookie to
|
||||
every plugin route.
|
||||
|
||||
A deep link reached while signed out — or after the session JWT lapses mid-task — bounces to the
|
||||
themed sign-in and returns to the **page that was requested** (`return_to`, validated host-relative
|
||||
by `localPath` in `src/http/safe-url.ts`, so a crafted value can't turn login completion into an open
|
||||
@@ -1820,12 +1637,9 @@ examples/ Copy-in reference mirroring the mount dirs: plugins/schedul
|
||||
config/menu.ts, and shifts-upstream/ (the dev mock backend)
|
||||
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
|
||||
release-tooling/ Everything the release runs: next-version (the bump math), contract-version
|
||||
(the HOST_API_VERSION↔tag gate), dockerhub-overview (+ its .md.tmpl)
|
||||
registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts)
|
||||
CHANGELOG.md What changed per release, and how to upgrade a plugin across a minor
|
||||
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
|
||||
.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
|
||||
|
||||
@@ -36,7 +36,7 @@ export function nextVersion(latestTag: string, level: Bump): string {
|
||||
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")) {
|
||||
const [, , latestTag, ...updateTypes] = process.argv;
|
||||
process.stdout.write(nextVersion(latestTag ?? "", maxLevel(updateTypes)));
|
||||
@@ -22,7 +22,7 @@ services:
|
||||
PLUGIN_DB_URL: *plugin-db-url
|
||||
REQUIRE_SECURE_SECRETS: "false"
|
||||
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:
|
||||
- .:/app
|
||||
# Mount your own menu/branding override into the empty config/ dir (defaults apply otherwise):
|
||||
@@ -46,10 +46,10 @@ services:
|
||||
|
||||
# Mock backend ready for the reference plugin (examples/plugins/scheduling): plugins/ ships empty, so
|
||||
# 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 —
|
||||
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
|
||||
# backs it (SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
||||
# stdlib-only, in-memory, no auth. Prod points SCHEDULING_UPSTREAM at the real backend instead.
|
||||
shifts-upstream:
|
||||
image: node:24.22.2-alpine3.24
|
||||
image: node:24.19.0-alpine3.24
|
||||
command: node /srv/server.ts
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
@@ -58,7 +58,7 @@ services:
|
||||
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
||||
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.32.0
|
||||
image: axllent/mailpit:v1.31.0
|
||||
ports:
|
||||
- "8025:8025"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
# Comprehension history
|
||||
|
||||
## 2026-10-31T00:16:02Z, PR #181 at 56ff987, against eed1050
|
||||
|
||||
Ruling: better
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Mid A | same | better | same | better | better |
|
||||
| Inherited architect | same | better | same | better | better |
|
||||
|
||||
Mid A, decided by:
|
||||
|
||||
- Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited
|
||||
- Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a colour edit that breaks the browser floor fails a test instead of an old engine
|
||||
|
||||
Inherited architect, decided by:
|
||||
|
||||
- Self-sufficiency, Locality: `public/css/styles.css:24` `:root`: the comment above the hex palette says why a colour must not be oklch(), where a colour is edited
|
||||
- Locality: `src/ui/stylesheet-floor.test.ts:11` `declares no oklch() colour`: a red gate explains the symptom in the file it lands on
|
||||
- Self-sufficiency: `README.md:1027` "Two browser floors": the colour rule now sits beside the floor rule
|
||||
|
||||
## 2026-10-31T00:43:19Z, PR #182 at 19987ba, against 3e70768
|
||||
|
||||
Ruling: better
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Mid A | better | better | better | better | better |
|
||||
| Inherited architect | better | better | better | better | better |
|
||||
|
||||
Mid A, decided by:
|
||||
|
||||
- Navigation, Shape, Self-sufficiency, Overall: `public/css/auth.css:1` `auth.css`: about 120 lines of rules for markup no view renders are gone, so a cold reader no longer rules them out first
|
||||
- Locality, Self-sufficiency, Overall: `public/css/styles.css:31` `--on-accent`: the text colour on the accent is one token where four hard-coded `#fff` stood
|
||||
- Self-sufficiency, Navigation, Overall: `public/css/styles.css:287` `@supports not selector(:has(a))`: the nav comment, the partial's header and the rule all say the tree folds only where `:has()` works
|
||||
- Locality: `public/css/styles.css:241` `@supports selector(:has(a))`: the nav-fold behaviour now sits in two blocks about 50 lines apart, the second order-dependent; the comment names it
|
||||
|
||||
Inherited architect, decided by:
|
||||
|
||||
- Navigation, Shape, Overall: `public/css/auth.css:1` `auth.css`: the false `:target` banner and the phantom state machine are gone, so every selector left names something a view emits
|
||||
- Self-sufficiency, Overall: `views/partials/nav-tree.ejs:4` `nav-tree.ejs`: the header and `public/css/styles.css:194` now say the branch folds only where `:has()` works; the old text was untrue on old engines
|
||||
- Locality, Overall: `public/css/styles.css:31` `--l-on-accent`: one token feeds `.btn-primary`, `.page-btn[aria-current]` and `.brand-mark`
|
||||
- Locality: `public/css/styles.css:287` `@supports not selector(:has(a))`: its `.nav-disc` rule sits about 85 lines from the nav rules it overrides, and `stylesheet-floor.test.ts` guards the order for `.theme-switch` only
|
||||
- Self-sufficiency: `src/ui/stylesheet-contrast.test.ts:29` `stylesheet-contrast.test.ts`: the darker badge colours now have a stated 4.5:1 reason someone can find
|
||||
|
||||
## 2026-10-31T01:09:38Z, PR #183 at 0f20b38, against 364b047
|
||||
|
||||
Ruling: better
|
||||
|
||||
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||
|---|---|---|---|---|---|
|
||||
| Mid A | same | same | same | better | better |
|
||||
| Inherited architect | same | better | same | better | better |
|
||||
|
||||
Mid A, decided by:
|
||||
|
||||
- Self-sufficiency, Overall: `public/css/styles.css:1` `App Shell Template header`: the false `light-dark()` bullet is gone, so no reader has to work out which of two comments is lying
|
||||
- Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment say what an engine without `:has()` gets, without opening the CHANGELOG
|
||||
|
||||
Inherited architect, decided by:
|
||||
|
||||
- Self-sufficiency, Overall: `public/css/styles.css:5` `Architecture` header: the false `light-dark()` bullet is gone, so the header no longer contradicts the TOKENS comment
|
||||
- Locality, Self-sufficiency, Overall: `public/css/styles.css:687` `@supports selector(:has(a))` in `@media (max-width: 860px)`: the guard and its comment put the dependency of the open state on `:has()` where an editor would change it
|
||||
@@ -1,49 +0,0 @@
|
||||
# Reading cost
|
||||
|
||||
## Reading cost, chunk 57e864bce02a, finished 2026-10-30T23:59:00Z
|
||||
|
||||
100624 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `src/http/app.test.ts` 9
|
||||
- `README.md` 7
|
||||
- `src/http/app.ts` 4
|
||||
- `todo.md` 3
|
||||
- `e2e-tests/playwright.config.ts` 2
|
||||
- `AGENTS.md` 1
|
||||
- `CHANGELOG.md` 1
|
||||
- `ci.sh` 1
|
||||
- `examples/plugins/scheduling/views/shifts.ejs` 1
|
||||
- `src/auth/csrf.ts` 1
|
||||
|
||||
## Reading cost, chunk df5e30a4db5f, finished 2026-10-31T00:22:00Z
|
||||
|
||||
80251 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `todo.md` 5
|
||||
- `README.md` 3
|
||||
- `CHANGELOG.md` 2
|
||||
- `public/css/styles.css` 2
|
||||
- `AGENTS.md` 1
|
||||
- `src/ui/stylesheet-floor.test.ts` 1
|
||||
- `src/ui/theme-switch.test.ts` 1
|
||||
|
||||
## Reading cost, chunk 3bf0b1a97d77, finished 2026-10-31T00:48:14Z
|
||||
|
||||
167985 new input tokens before the first commit.
|
||||
|
||||
Read most (opens and searches):
|
||||
|
||||
- `public/css/styles.css` 17
|
||||
- `todo.md` 7
|
||||
- `README.md` 6
|
||||
- `public/css/auth.css` 5
|
||||
- `AGENTS.md` 1
|
||||
- `CHANGELOG.md` 1
|
||||
- `examples/plugins/scheduling/public/scheduling.css` 1
|
||||
- `examples/plugins/scheduling/views/overview.ejs` 1
|
||||
- `examples/plugins/scheduling/views/shifts.ejs` 1
|
||||
- `src/http/app.test.ts` 1
|
||||
@@ -1,6 +1,6 @@
|
||||
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
|
||||
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
|
||||
FROM mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
FROM mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
|
||||
WORKDIR /e2e-tests
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
|
||||
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||
shifts-upstream:
|
||||
image: node:24.22.2-alpine3.24
|
||||
image: node:24.19.0-alpine3.24
|
||||
command: ["node", "/server.ts"]
|
||||
volumes:
|
||||
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
||||
@@ -66,7 +66,7 @@ services:
|
||||
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
||||
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
||||
mock-oidc:
|
||||
image: node:24.22.2-alpine3.24
|
||||
image: node:24.19.0-alpine3.24
|
||||
command: ["node", "/mock-oidc.ts"]
|
||||
environment:
|
||||
ISSUER: http://mock-oidc:9000
|
||||
@@ -81,7 +81,7 @@ services:
|
||||
|
||||
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
||||
proxy:
|
||||
image: node:24.22.2-alpine3.24
|
||||
image: node:24.19.0-alpine3.24
|
||||
command: ["node", "/proxy.ts"]
|
||||
depends_on:
|
||||
web:
|
||||
|
||||
@@ -193,23 +193,6 @@ test.describe.serial("authenticated admin journey", () => {
|
||||
await page.goto("/scheduling/shifts");
|
||||
await expect(page.locator("h1")).toHaveText("Shifts");
|
||||
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
|
||||
|
||||
// The admin owns none of the demo's rows, so an empty page is the no-leak assertion.
|
||||
await page.goto("/scheduling/mine");
|
||||
await expect(page.locator("h1")).toHaveText("My shifts");
|
||||
await expect(page.getByText("No shifts are assigned to admin@plainpages.local")).toBeVisible();
|
||||
await expect(page.locator("table")).not.toContainText("Morning — Front desk");
|
||||
});
|
||||
|
||||
test("plugin settings: the screen names the variable that sets each declared key", async () => {
|
||||
await page.goto("/admin/plugin-settings");
|
||||
await expect(page.locator("h1")).toHaveText("Plugin settings");
|
||||
// The reference plugin's one declared setting, and the variable an operator would set for it.
|
||||
const scheduling = page.locator("table").filter({ hasText: "PLUGIN_SETTING_SCHEDULING_UPSTREAM" });
|
||||
await expect(scheduling).toContainText("upstream");
|
||||
await expect(scheduling).toContainText("http://shifts-upstream:4000"); // resolved, and its source shown
|
||||
// Every installed plugin gets a section, so "declares none" is distinguishable from "not installed".
|
||||
await expect(page.locator("h2", { hasText: "admin" })).toHaveCount(1);
|
||||
});
|
||||
|
||||
test("logout: signing out ends the session and returns to the login page", async () => {
|
||||
|
||||
Generated
+30
-12
@@ -6,17 +6,17 @@
|
||||
"": {
|
||||
"name": "plainpages-e2e",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.64.1"
|
||||
"@playwright/test": "1.62.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.64.1"
|
||||
"playwright": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -25,26 +25,44 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.64.1"
|
||||
"playwright-core": "1.62.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.64.1"
|
||||
"@playwright/test": "1.62.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ export default defineConfig({
|
||||
testDir: ".",
|
||||
outputDir: "artifacts/test-output",
|
||||
fullyParallel: true,
|
||||
workers: 4, // Playwright's default is half the cores, so a run's parallelism would depend on the machine
|
||||
forbidOnly: true,
|
||||
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
|
||||
use: {
|
||||
|
||||
+5
-117
@@ -28,45 +28,6 @@ test.beforeEach(async ({ context }) => {
|
||||
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
||||
});
|
||||
|
||||
// A key press, not scrollIntoView (a script can scroll a box no reader can) and not the wheel
|
||||
// (Firefox's synthetic event never reaches the document).
|
||||
for (const [name, path, tail] of [
|
||||
["the starter dashboard", "/dashboard", ".form-actions .btn"],
|
||||
["the public landing", "/", ".landing-actions .btn"],
|
||||
] as const) {
|
||||
for (const width of [1280, 390]) {
|
||||
test(`${name} scrolls to its end at ${width}px wide`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 200 });
|
||||
await page.goto(path);
|
||||
|
||||
const overflows = await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight);
|
||||
expect(overflows, "the page must overflow, or it proves nothing").toBe(true);
|
||||
await page.keyboard.press("End");
|
||||
await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Green only while #nav-toggle is position: fixed — a label tap focuses it, and focus scrolls into view.
|
||||
test("closing the mobile drawer leaves the reader where the scrim found them", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 200 });
|
||||
await page.goto("/dashboard");
|
||||
|
||||
await page.locator(".hamburger").click();
|
||||
await expect(page.locator("#nav-toggle")).toBeChecked();
|
||||
// Scripted, because a key press with focus on the toggle does not scroll in every engine — and
|
||||
// what is under test is closing the drawer, not how the reader got down the page.
|
||||
await page.evaluate(() => window.scrollTo(0, 120));
|
||||
const at = await page.evaluate(() => window.scrollY);
|
||||
expect(at, "the page must have somewhere to scroll behind the scrim").toBeGreaterThan(0);
|
||||
|
||||
// The exposed strip beside the 264px panel: the scrim spans the viewport, so its centre is under
|
||||
// the drawer and a centre click lands on the panel instead.
|
||||
await page.locator(".scrim").click({ position: { x: 340, y: 100 } });
|
||||
await expect(page.locator("#nav-toggle")).not.toBeChecked();
|
||||
expect(await page.evaluate(() => window.scrollY), "closing the drawer must not move the page").toBe(at);
|
||||
});
|
||||
|
||||
test("captures the live pages for review", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
@@ -133,7 +94,6 @@ test("a popover menu sits on its trigger and closes on an outside click or Esc
|
||||
await expect(panel).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(panel).toBeHidden();
|
||||
await expect(trigger).toBeFocused(); // the browser returns focus, so no trigger needs a tabindex
|
||||
});
|
||||
|
||||
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
|
||||
@@ -148,21 +108,6 @@ test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({
|
||||
expect(offCanvas).toBe(true);
|
||||
});
|
||||
|
||||
test("the hamburger opens the drawer over the scrim, and a link in it navigates", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/dashboard");
|
||||
await page.locator(".hamburger").click();
|
||||
|
||||
const sidebar = page.locator(".sidebar");
|
||||
await expect(sidebar).toBeInViewport({ ratio: 1 });
|
||||
// A click is refused while the scrim sits above the drawer, which a lower z-index would cause.
|
||||
await sidebar.locator("summary").first().click(); // the dashboard is the current page, so its links are in a folded branch
|
||||
const link = sidebar.locator(".nav-children a.nav-self").first();
|
||||
const target = await link.getAttribute("href");
|
||||
await link.click();
|
||||
await expect(page).toHaveURL((url) => url.pathname === target?.split("?")[0]);
|
||||
});
|
||||
|
||||
test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
// The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit).
|
||||
@@ -189,50 +134,6 @@ test("the public landing at / is ungated and links to sign in + register", async
|
||||
await shot(page, "live-05-public-landing");
|
||||
});
|
||||
|
||||
// Measured on the rendered elements, so a theme block that drops --on-accent fails here.
|
||||
const contrastOf = (el: Element): number => {
|
||||
const luminance = (rgb: string): number => {
|
||||
const [r = 0, g = 0, b = 0] = (rgb.match(/[\d.]+/g) ?? []).map((c) => {
|
||||
const v = Number(c) / 255;
|
||||
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
const style = getComputedStyle(el);
|
||||
const [hi = 0, lo = 0] = [luminance(style.color), luminance(style.backgroundColor)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
for (const [name, colorScheme, forceDark] of [["light", "light", false], ["dark", "dark", false], ["forced dark", "light", true]] as const) {
|
||||
test(`text on the accent reaches 4.5:1 in ${name} mode`, async ({ page, context }) => {
|
||||
await context.clearCookies();
|
||||
await page.emulateMedia({ colorScheme });
|
||||
await page.goto("/");
|
||||
if (forceDark) await page.locator("#theme-dark").check({ force: true });
|
||||
// No page of this stack has a second page of results, so add the current-page button.
|
||||
await page.locator("#main-content").evaluate((el) => el.insertAdjacentHTML("beforeend", '<a class="page-btn" aria-current="page" href="#">2</a>'));
|
||||
for (const selector of [".landing .btn-primary", ".brand-mark", ".page-btn"]) {
|
||||
await expect.poll(() => page.locator(selector).first().evaluate(contrastOf), { message: selector }).toBeGreaterThanOrEqual(4.5); // polls past the colour transition
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("a nav branch is folded until its toggle opens it — no JavaScript", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
const child = page.locator('.sidebar a[href="/scheduling"]');
|
||||
await expect(child).toBeHidden();
|
||||
await page.locator('.sidebar summary[aria-label="Toggle Scheduling"]').click();
|
||||
await expect(child).toBeVisible();
|
||||
});
|
||||
|
||||
test("the reference plugin's lead text is inset by --pad-x, like the bars around it", async ({ page }) => {
|
||||
await page.goto("/scheduling");
|
||||
const [inset, padX] = await page.locator(".scheduling-page > p").evaluate((p) => [
|
||||
p.getBoundingClientRect().left - (p.parentElement as HTMLElement).getBoundingClientRect().left,
|
||||
parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--pad-x")),
|
||||
] as const);
|
||||
expect(inset).toBe(padX);
|
||||
});
|
||||
|
||||
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
|
||||
allowConsole(/status of 404 .*\/no-such-page$/); // the navigation under test, which Chromium and WebKit log — not a sub-resource of it
|
||||
const res = await page.goto("/no-such-page");
|
||||
@@ -241,11 +142,11 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to
|
||||
await expect(page.getByRole("link", { name: "Back home" })).toBeVisible();
|
||||
});
|
||||
|
||||
// The reference plugin (plugins/scheduling) ships discovered in the image, and shows all three
|
||||
// gates: the public Overview is reachable by anyone, My shifts takes any session, and the shifts
|
||||
// list needs a permission. The authenticated list/form flow is the full E2E (full-flow.spec).
|
||||
// Side-effect-free.
|
||||
test("the reference plugin: public Overview is open to all, My shifts takes any session, the gated Shifts redirects to /login", async ({ page, request }) => {
|
||||
// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
|
||||
// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated,
|
||||
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
|
||||
// E2E (full-flow.spec). Side-effect-free.
|
||||
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => {
|
||||
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
|
||||
// probes are genuinely anonymous.
|
||||
// The public overview is reachable with no session (200), not bounced to sign in.
|
||||
@@ -264,23 +165,10 @@ test("the reference plugin: public Overview is open to all, My shifts takes any
|
||||
expect(res.status()).toBe(303);
|
||||
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
|
||||
|
||||
// A `session: true` route bounces an anonymous visitor the same way — no permission involved.
|
||||
const mine = await request.get("/scheduling/mine", { maxRedirects: 0 });
|
||||
expect(mine.status()).toBe(303);
|
||||
expect(mine.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fmine");
|
||||
|
||||
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
|
||||
// but the gated Shifts leaf is filtered out.
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
||||
await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
|
||||
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
|
||||
await expect(page.locator('.sidebar a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough
|
||||
|
||||
// No shifts upstream on this stack, so this also pins the degraded page: the reason, never a 500
|
||||
// and never a claim about what is assigned.
|
||||
await page.goto("/scheduling/mine");
|
||||
await expect(page.getByRole("heading", { name: "My shifts" })).toBeVisible();
|
||||
await expect(page.getByText("Couldn't reach the scheduling service")).toBeVisible();
|
||||
await expect(page.getByText("No shifts are assigned to")).toHaveCount(0);
|
||||
});
|
||||
|
||||
+1
-1
@@ -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/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Permissions / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). |
|
||||
| [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). |
|
||||
| [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `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;
|
||||
req.method = opts.method ?? "GET";
|
||||
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,
|
||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
};
|
||||
@@ -27,21 +27,21 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver
|
||||
|
||||
// ---- nav fragment ----
|
||||
|
||||
test("ADMIN_NAV: an ungated Admin header whose 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");
|
||||
// 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,
|
||||
// ungated, for anonymous visitors included.
|
||||
assert.equal(ADMIN_NAV.permission, undefined);
|
||||
assert.equal(ADMIN_NAV.href, undefined);
|
||||
assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/clients", "/admin/plugin-settings"]);
|
||||
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.href), ["/admin/users", "/admin/groups", "/admin/clients"]);
|
||||
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
|
||||
// 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) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients", "Plugin settings"]);
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["admin.nav.users", "admin.nav.groups", "admin.nav.clients"]);
|
||||
assert.deepEqual(ADMIN_NAV.children?.map((c) => ADMIN_EN(c.label)), ["Users", "Groups", "OAuth2 clients"]);
|
||||
assert.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_GROUPS_BASE = "/admin/groups";
|
||||
export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||
export const ADMIN_PLUGIN_SETTINGS_BASE = "/admin/plugin-settings";
|
||||
|
||||
// One resource per screen — the `<resource>` half of every permission this plugin gates on.
|
||||
// `oauth2-clients` rather than `clients` because permission names are one global namespace.
|
||||
// There is no `permissions` resource: permissions are declared in plugin code, not created here, so
|
||||
// holding a grant is a property of a user or a group and is edited on those two screens.
|
||||
export type AdminResource = "groups" | "oauth2-clients" | "plugin-settings" | "users";
|
||||
export type AdminResource = "groups" | "oauth2-clients" | "users";
|
||||
|
||||
export type AdminAction = "read" | "write";
|
||||
|
||||
@@ -35,20 +34,19 @@ export function actionForMethod(method: string): AdminAction {
|
||||
return verb === "GET" || verb === "HEAD" ? "read" : "write";
|
||||
}
|
||||
|
||||
// The plugin's nav fragment: the "Admin" header + its four screens, each gated on its own read
|
||||
// permission. composeNav drops a header left with no visible children, so a user holding none of
|
||||
// them never sees the section. The host current-marks the active item — no `current`/`open` here.
|
||||
// The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own
|
||||
// read permission. The header carries no `permission` because a user may hold one screen's and not
|
||||
// another's; composeNav drops a header left with no visible children, so a user holding none of the
|
||||
// three never sees the section. The host current-marks the active item — no `current`/`open` here.
|
||||
export const ADMIN_NAV: NavNode = {
|
||||
children: [
|
||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
|
||||
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "admin.nav.groups", permission: permissionName("groups", "read") },
|
||||
{ href: ADMIN_CLIENTS_BASE, icon: "i-globe", id: "clients", label: "admin.nav.clients", permission: permissionName("oauth2-clients", "read") },
|
||||
{ href: ADMIN_PLUGIN_SETTINGS_BASE, icon: "i-sliders", id: "plugin-settings", label: "admin.nav.pluginSettings", permission: permissionName("plugin-settings", "read") },
|
||||
],
|
||||
icon: "i-shield",
|
||||
id: "admin",
|
||||
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
|
||||
public: true, // the header gates nothing; every child needs a permission, and an empty header is dropped
|
||||
};
|
||||
|
||||
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
|
||||
|
||||
@@ -83,29 +83,9 @@ const messages = {
|
||||
|
||||
"admin.nav.clients": "OAuth2 clients",
|
||||
"admin.nav.groups": "Groups",
|
||||
"admin.nav.pluginSettings": "Plugin settings",
|
||||
"admin.nav.section": "Admin",
|
||||
"admin.nav.users": "Users",
|
||||
|
||||
"admin.pluginSettings.caption": "Settings declared by {{plugin}}",
|
||||
"admin.pluginSettings.column.description": "Description",
|
||||
"admin.pluginSettings.column.key": "Key",
|
||||
"admin.pluginSettings.column.required": "Required",
|
||||
"admin.pluginSettings.column.source": "Source",
|
||||
"admin.pluginSettings.column.type": "Type",
|
||||
"admin.pluginSettings.column.value": "Value",
|
||||
"admin.pluginSettings.column.variable": "Variable",
|
||||
"admin.pluginSettings.no": "No",
|
||||
"admin.pluginSettings.none": "This plugin declares no settings.",
|
||||
"admin.pluginSettings.secretSet": "Secret — set",
|
||||
"admin.pluginSettings.secretUnset": "Secret — not set",
|
||||
"admin.pluginSettings.source.default": "Default",
|
||||
"admin.pluginSettings.source.env": "Environment",
|
||||
"admin.pluginSettings.source.unset": "Not set",
|
||||
"admin.pluginSettings.title": "Plugin settings",
|
||||
"admin.pluginSettings.unset": "—",
|
||||
"admin.pluginSettings.yes": "Yes",
|
||||
|
||||
"admin.notFound.message": "That item doesn't exist.",
|
||||
"admin.notFound.title": "Not found",
|
||||
|
||||
|
||||
@@ -83,29 +83,9 @@ const messages: AdminMessages = {
|
||||
|
||||
"admin.nav.clients": "OAuth2-klienter",
|
||||
"admin.nav.groups": "Grupper",
|
||||
"admin.nav.pluginSettings": "Tilläggsinställningar",
|
||||
"admin.nav.section": "Administration",
|
||||
"admin.nav.users": "Användare",
|
||||
|
||||
"admin.pluginSettings.caption": "Inställningar som {{plugin}} deklarerar",
|
||||
"admin.pluginSettings.column.description": "Beskrivning",
|
||||
"admin.pluginSettings.column.key": "Nyckel",
|
||||
"admin.pluginSettings.column.required": "Obligatorisk",
|
||||
"admin.pluginSettings.column.source": "Källa",
|
||||
"admin.pluginSettings.column.type": "Typ",
|
||||
"admin.pluginSettings.column.value": "Värde",
|
||||
"admin.pluginSettings.column.variable": "Variabel",
|
||||
"admin.pluginSettings.no": "Nej",
|
||||
"admin.pluginSettings.none": "Det här tillägget deklarerar inga inställningar.",
|
||||
"admin.pluginSettings.secretSet": "Hemlighet — satt",
|
||||
"admin.pluginSettings.secretUnset": "Hemlighet — inte satt",
|
||||
"admin.pluginSettings.source.default": "Standardvärde",
|
||||
"admin.pluginSettings.source.env": "Miljövariabel",
|
||||
"admin.pluginSettings.source.unset": "Inte satt",
|
||||
"admin.pluginSettings.title": "Tilläggsinställningar",
|
||||
"admin.pluginSettings.unset": "—",
|
||||
"admin.pluginSettings.yes": "Ja",
|
||||
|
||||
"admin.notFound.message": "Objektet finns inte.",
|
||||
"admin.notFound.title": "Hittades inte",
|
||||
|
||||
|
||||
@@ -35,20 +35,17 @@ test("every nav permission is one the manifest declares", () => {
|
||||
}
|
||||
};
|
||||
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}`);
|
||||
});
|
||||
|
||||
test("every declared permission is <resource>:<action>, and reads and writes are split per resource", () => {
|
||||
for (const name of declared) assert.ok(isValidPermissionName(name), name); // the host's rule, not a copy of it
|
||||
// Three CRUD screens × read/write, plus read-only plugin settings — a screen that never writes
|
||||
// declares no `:write`, since a permission nothing gates on is one an operator can only mis-grant.
|
||||
// There is deliberately no `permissions:` pair either: permissions are declared in plugin code, so
|
||||
// holding one is edited on the user or group that holds it.
|
||||
// Three screens × read/write. There is deliberately no `permissions:` pair: permissions are
|
||||
// declared in plugin code, so holding one is edited on the user or group that holds it.
|
||||
assert.deepEqual([...declared].sort(), [
|
||||
"groups:read", "groups:write",
|
||||
"oauth2-clients:read", "oauth2-clients:write",
|
||||
"plugin-settings:read",
|
||||
"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";
|
||||
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 { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clientsList, clientsNewForm } from "./admin-clients.ts";
|
||||
import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsPermissions, groupsRemoveMember } from "./admin-groups.ts";
|
||||
import { pluginSettingsList } from "./admin-plugin-settings.ts";
|
||||
import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersPermissions, usersRecovery, usersState, usersUpdate } from "./admin-users.ts";
|
||||
import { ADMIN_NAV, actionForMethod, type AdminAction, type AdminResource, permissionName } from "./admin-shared.ts";
|
||||
|
||||
@@ -25,10 +24,9 @@ const on = (resource: AdminResource) => (method: HttpMethod, path: string, handl
|
||||
const users = on("users");
|
||||
const groups = on("groups");
|
||||
const clients = on("oauth2-clients");
|
||||
const pluginSettings = on("plugin-settings");
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
|
||||
nav: [ADMIN_NAV],
|
||||
|
||||
@@ -39,7 +37,6 @@ export default definePlugin({
|
||||
{ description: "Create and delete groups, and change their members and permissions", name: "groups:write" },
|
||||
{ description: "View OAuth2 clients", name: "oauth2-clients:read" },
|
||||
{ description: "Register and delete OAuth2 clients", name: "oauth2-clients:write" },
|
||||
{ description: "View the settings each installed plugin declares, and how they resolved", name: "plugin-settings:read" },
|
||||
],
|
||||
|
||||
routes: [
|
||||
@@ -71,7 +68,5 @@ export default definePlugin({
|
||||
clients("GET", "/clients/:id", clientsDetail),
|
||||
clients("GET", "/clients/:id/delete", clientsDeleteConfirm, "write"),
|
||||
clients("POST", "/clients/:id/delete", clientsDelete),
|
||||
// Plugin settings — read-only, so no :write route and no write-intent GET.
|
||||
pluginSettings("GET", "/plugin-settings", pluginSettingsList),
|
||||
],
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
}) %>
|
||||
@@ -15,12 +15,8 @@ What it demonstrates:
|
||||
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
|
||||
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
|
||||
reusing the core `field` partial.
|
||||
- **All three route gates** — the Overview is `public` (anyone), "My shifts" is `session` (any
|
||||
signed-in visitor, showing only rows assigned to them), and "Shifts" is gated on `scheduling:read` /
|
||||
`scheduling:write`; a leaf whose gate a visitor fails is invisible in the menu.
|
||||
- **Ownership joined on the identity id** — "My shifts" asks the upstream for `assigneeId=ctx.user.id`,
|
||||
the opaque subject id, and renders the row's separate `assignee` display name. An email address is
|
||||
user-changeable and can be reassigned to someone else, who would then inherit those rows.
|
||||
- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
|
||||
`scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
|
||||
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
|
||||
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
|
||||
the views carry the visitor's language onto their links with `localeHref()`.
|
||||
@@ -31,7 +27,7 @@ The plugin holds **no state** — data lives upstream (README → *Stateless*).
|
||||
|
||||
## Upstream
|
||||
|
||||
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.
|
||||
A malformed/non-http URL fails the boot loudly (the plugin's `onBoot` hook).
|
||||
|
||||
@@ -42,14 +38,9 @@ Your backend must expose two routes; the plugin treats any non-2xx as a recovera
|
||||
|
||||
| Route | Request | Success | Response body |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /shifts` | `Accept: application/json`, optional `?assigneeId=<id>` | `200` | JSON array of `{ id, title, assignee, assigneeId, start, end }` (all strings; missing fields coerce to `""`). With `assigneeId`, only that person's rows |
|
||||
| `GET /shifts` | `Accept: application/json` | `200` | JSON array of `{ id, title, assignee, start, end }` (all strings; missing fields coerce to `""`) |
|
||||
| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
|
||||
|
||||
`POST /shifts` carries the assignee as a **display name only**, so a shift created through this
|
||||
plugin's form belongs to nobody and surfaces on no one's "My shifts" — don't go hunting for it
|
||||
there. Resolving a name to an identity id needs a directory this demo has none of; a real backend
|
||||
does that join at create time and stores the `assigneeId` alongside the name.
|
||||
|
||||
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
|
||||
form re-renders. The plugin only validates that `title` and `assignee` are non-empty.
|
||||
|
||||
@@ -59,8 +50,6 @@ cosmetically) — normalise to your backend's format there if it matters.
|
||||
|
||||
## Granting access
|
||||
|
||||
A user sees the shift list once they hold the `scheduling:read` permission in Keto (and
|
||||
`scheduling:write` to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
||||
`admin@plainpages.local` can use it immediately. "My shifts" needs no grant at all — signing in is
|
||||
its whole gate; it lists the rows this demo upstream holds against the signed-in visitor's id, and
|
||||
the demo's seeded rows belong to three made-up people, so a freshly seeded admin sees it empty.
|
||||
A user sees Scheduling once they hold the `scheduling:read` permission in Keto (and `scheduling:write`
|
||||
to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
||||
`admin@plainpages.local` can use it immediately.
|
||||
|
||||
@@ -13,16 +13,12 @@ const messages = {
|
||||
"scheduling.filter.searchLabel": "Search shifts",
|
||||
"scheduling.filter.searchPlaceholder": "Search title or assignee…",
|
||||
"scheduling.form.submit": "Create shift",
|
||||
"scheduling.mine.empty": "No shifts are assigned to {{email}}.",
|
||||
"scheduling.mine.title": "My shifts",
|
||||
"scheduling.nav.mine": "My shifts",
|
||||
"scheduling.nav.overview": "Overview",
|
||||
"scheduling.nav.section": "Scheduling",
|
||||
"scheduling.nav.shifts": "Shifts",
|
||||
"scheduling.new.title": "New shift",
|
||||
"scheduling.overview.lead":
|
||||
"Scheduling coordinates shifts across your team. Anyone can read this overview; the shift list itself is available to people with the <code>scheduling:read</code> permission.",
|
||||
"scheduling.overview.mine": "See my shifts",
|
||||
"scheduling.overview.signIn": "Sign in to view shifts",
|
||||
"scheduling.overview.title": "Scheduling",
|
||||
"scheduling.overview.view": "View shifts",
|
||||
|
||||
@@ -9,16 +9,12 @@ const messages: SchedulingMessages = {
|
||||
"scheduling.filter.searchLabel": "Sök pass",
|
||||
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
|
||||
"scheduling.form.submit": "Skapa pass",
|
||||
"scheduling.mine.empty": "Inga pass är tilldelade {{email}}.",
|
||||
"scheduling.mine.title": "Mina pass",
|
||||
"scheduling.nav.mine": "Mina pass",
|
||||
"scheduling.nav.overview": "Översikt",
|
||||
"scheduling.nav.section": "Schemaläggning",
|
||||
"scheduling.nav.shifts": "Pass",
|
||||
"scheduling.new.title": "Nytt pass",
|
||||
"scheduling.overview.lead":
|
||||
"Schemaläggningen samordnar teamets pass. Alla kan läsa den här översikten; själva passlistan kräver behörigheten <code>scheduling:read</code>.",
|
||||
"scheduling.overview.mine": "Visa mina pass",
|
||||
"scheduling.overview.signIn": "Logga in för att se passen",
|
||||
"scheduling.overview.title": "Schemaläggning",
|
||||
"scheduling.overview.view": "Visa pass",
|
||||
|
||||
@@ -3,20 +3,19 @@
|
||||
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
|
||||
|
||||
import { definePlugin } from "@plainpages/plugin-api";
|
||||
import { createShift, createUpstream, listShifts, MINE_PATH, myShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
||||
import { assertHttpUrl, createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
||||
|
||||
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
|
||||
// stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it
|
||||
// over — which is after this manifest is built, hence the getter.
|
||||
let upstreamUrl = "";
|
||||
const upstream = createUpstream(() => upstreamUrl);
|
||||
// stateless). Configure via env; the dev compose points it at a tiny mock (examples/shifts-upstream).
|
||||
const upstreamUrl = process.env["SCHEDULING_UPSTREAM"] ?? "http://shifts-upstream:4000";
|
||||
const upstream = createUpstream(upstreamUrl);
|
||||
|
||||
export default definePlugin({
|
||||
apiVersion: "0.4.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION
|
||||
|
||||
// onBoot runs after discovery, before the server listens — where a plugin receives its resolved
|
||||
// settings. A malformed URL already failed the boot by then; the host validated the declared type.
|
||||
hooks: { onBoot: ({ settings }) => { upstreamUrl = settings.upstream; } },
|
||||
// onBoot runs after discovery, before the server listens: validate the plugin's own config so a
|
||||
// typo'd SCHEDULING_UPSTREAM fails the boot loudly instead of degrading every request later.
|
||||
hooks: { onBoot: () => assertHttpUrl(upstreamUrl, "SCHEDULING_UPSTREAM") },
|
||||
|
||||
// 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"
|
||||
@@ -25,13 +24,11 @@ export default definePlugin({
|
||||
nav: [{
|
||||
children: [
|
||||
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true },
|
||||
{ href: MINE_PATH, id: "scheduling:mine", label: "scheduling.nav.mine", session: true },
|
||||
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ },
|
||||
],
|
||||
icon: "i-cal",
|
||||
id: "scheduling",
|
||||
label: "scheduling.nav.section",
|
||||
public: true, // the header gates nothing; each child names its own gate, and an empty header is dropped
|
||||
}],
|
||||
|
||||
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
|
||||
@@ -40,22 +37,12 @@ export default definePlugin({
|
||||
{ description: "Create and edit shifts", name: WRITE },
|
||||
],
|
||||
|
||||
// Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public`
|
||||
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
|
||||
routes: [
|
||||
{ handler: overview(), method: "GET", path: "/", public: true },
|
||||
{ handler: myShifts(upstream), method: "GET", path: "/mine", session: true },
|
||||
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
|
||||
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
|
||||
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
|
||||
],
|
||||
|
||||
// Operator-supplied config: one PLUGIN_SETTING_SCHEDULING_UPSTREAM variable, validated as a URL at
|
||||
// boot. The default points at the mock backend the dev compose runs (examples/shifts-upstream).
|
||||
settings: [
|
||||
{
|
||||
default: "http://shifts-upstream:4000",
|
||||
description: "Base URL of the backend this plugin reads shifts from and writes them to",
|
||||
key: "upstream",
|
||||
type: "url",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -4,9 +4,3 @@
|
||||
.scheduling-page .table-wrap {
|
||||
margin-top: var(--space-3, 0.75rem);
|
||||
}
|
||||
/* Inset what sits directly in the pane by --pad-x, as the filter bar, table and pager already are. */
|
||||
.scheduling-page > p,
|
||||
.scheduling-page > .alert,
|
||||
.scheduling-page > .btn {
|
||||
margin-inline: var(--pad-x);
|
||||
}
|
||||
|
||||
@@ -4,29 +4,29 @@ import { Readable } from "node:stream";
|
||||
import test from "node:test";
|
||||
// Import only from the @plainpages/plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
||||
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
||||
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult, type User } from "@plainpages/plugin-api";
|
||||
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "@plainpages/plugin-api";
|
||||
import enUS from "./i18n/en-US.ts";
|
||||
import {
|
||||
buildFormModel, createShift, createUpstream, listShifts, myShifts, newShiftForm, overview, readInput,
|
||||
assertHttpUrl, buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
||||
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
||||
} from "./shifts.ts";
|
||||
|
||||
const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them
|
||||
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
||||
|
||||
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; user?: User; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||
return {
|
||||
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
||||
chrome: CHROME, declaredPermissions: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
||||
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
|
||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||
};
|
||||
}
|
||||
|
||||
const SHIFTS: Shift[] = [
|
||||
{ assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
|
||||
{ assignee: "Blair Mora", assigneeId: "019bdc1a-4a83-7de2-8f05-6c93a71be4d8", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" },
|
||||
{ assignee: "Avery Kline", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
|
||||
{ assignee: "Blair Mora", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" },
|
||||
];
|
||||
const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over });
|
||||
|
||||
@@ -35,25 +35,27 @@ const asView = (r: RouteResult | void) => {
|
||||
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 () => {
|
||||
const manifest = (await import("./plugin.ts")).default;
|
||||
assert.deepEqual(manifest.settings?.map((s) => s.key), ["upstream"]);
|
||||
assert.equal(manifest.settings?.[0]?.type, "url"); // so a typo'd URL fails the boot, not every request
|
||||
assert.equal(manifest.settings?.[0]?.default, "http://shifts-upstream:4000"); // the dev compose's mock
|
||||
assert.equal(typeof manifest.hooks?.onBoot, "function"); // without it the resolved value never arrives
|
||||
test("assertHttpUrl accepts http(s) and fails loud on a malformed or non-http upstream URL", () => {
|
||||
assert.doesNotThrow(() => assertHttpUrl("http://shifts-upstream:4000", "SCHEDULING_UPSTREAM"));
|
||||
assert.doesNotThrow(() => assertHttpUrl("https://api.example.com/v1", "SCHEDULING_UPSTREAM"));
|
||||
assert.throws(() => assertHttpUrl("not a url", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*valid URL/); // unparseable
|
||||
assert.throws(() => assertHttpUrl("shifts-upstream:4000", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // missing // → parsed as a bogus scheme
|
||||
assert.throws(() => assertHttpUrl("ftp://host/x", "SCHEDULING_UPSTREAM"), /SCHEDULING_UPSTREAM.*http/); // wrong scheme
|
||||
});
|
||||
|
||||
test("the client re-reads its base URL, so onBoot can bind it after the manifest is built", async () => {
|
||||
let baseUrl = "http://first:4000";
|
||||
const seen: string[] = [];
|
||||
const http = (async (url) => { seen.push(String(url)); return new Response("[]", { status: 200 }); }) as typeof fetch;
|
||||
const upstream = createUpstream(() => baseUrl, http);
|
||||
await upstream.list();
|
||||
baseUrl = "http://second:4000";
|
||||
await upstream.list();
|
||||
assert.deepEqual(seen, ["http://first:4000/shifts", "http://second:4000/shifts"]);
|
||||
test("the manifest's onBoot hook validates SCHEDULING_UPSTREAM (the binding, not just the helper)", async () => {
|
||||
const prev = process.env["SCHEDULING_UPSTREAM"];
|
||||
process.env["SCHEDULING_UPSTREAM"] = "nope://bad"; // read at import time below
|
||||
try {
|
||||
const manifest = (await import("./plugin.ts")).default;
|
||||
assert.equal(typeof manifest.hooks?.onBoot, "function");
|
||||
assert.throws(() => manifest.hooks!.onBoot!({}), /SCHEDULING_UPSTREAM/); // bad upstream → boot fails loud
|
||||
} finally {
|
||||
if (prev === undefined) delete process.env["SCHEDULING_UPSTREAM"];
|
||||
else process.env["SCHEDULING_UPSTREAM"] = prev;
|
||||
}
|
||||
});
|
||||
|
||||
// ---- upstream client (fetch injected) ----
|
||||
@@ -63,23 +65,23 @@ test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", as
|
||||
const http = (async (url, init) => {
|
||||
seen = String(url);
|
||||
assert.equal((init?.headers as Record<string, string>).accept, "application/json");
|
||||
return new Response(JSON.stringify([{ assignee: "A", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
|
||||
return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
|
||||
}) as typeof fetch;
|
||||
const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed
|
||||
const shifts = await createUpstream("http://up:4000/", http).list(); // trailing slash trimmed
|
||||
assert.equal(seen, "http://up:4000/shifts");
|
||||
assert.deepEqual(shifts, [{ assignee: "A", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", end: "2", id: "x", start: "1", title: "T" }]);
|
||||
assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]);
|
||||
});
|
||||
|
||||
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
|
||||
const http = (async () => new Response("nope", { status: 503 })) as typeof fetch;
|
||||
await assert.rejects(createUpstream(() => "http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
|
||||
await assert.rejects(createUpstream("http://up:4000", http).list(), (e: unknown) => e instanceof UpstreamError && e.status === 503);
|
||||
});
|
||||
|
||||
test("createUpstream.create POSTs the input as JSON", async () => {
|
||||
let body: unknown, method = "";
|
||||
const http = (async (_url, init) => { method = init?.method ?? ""; body = JSON.parse(String(init?.body)); return new Response(null, { status: 201 }); }) as typeof fetch;
|
||||
const input: ShiftInput = { assignee: "A", end: "2", start: "1", title: "T" };
|
||||
await createUpstream(() => "http://up:4000", http).create(input);
|
||||
await createUpstream("http://up:4000", http).create(input);
|
||||
assert.equal(method, "POST");
|
||||
assert.deepEqual(body, input);
|
||||
});
|
||||
@@ -115,21 +117,14 @@ test("listShifts degrades to a recoverable error page when the upstream is down
|
||||
|
||||
// ---- public overview handler (a page anyone can reach, gated data stays behind the permission) ----
|
||||
|
||||
test("overview renders a public page for anyone, and its CTA names the best gate the visitor passes", async () => {
|
||||
test("overview renders a public page for anyone; it links straight to Shifts only for a reader", async () => {
|
||||
const anon = asView(await overview()(fakeCtx())); // user null, no permissions
|
||||
assert.equal(anon.view, "overview");
|
||||
assert.equal(anon.data["chrome"], CHROME);
|
||||
assert.equal(anon.data["canRead"], false); // anonymous → prompt to sign in, no shifts link
|
||||
assert.equal(anon.data["signedIn"], false);
|
||||
|
||||
const reader = asView(await overview()(fakeCtx({ permissions: ["scheduling:read"] })));
|
||||
assert.equal(reader.data["canRead"], true); // a reader gets a link straight to the shifts list
|
||||
|
||||
// Signed in but ungranted: the page must not invite them to sign in again.
|
||||
const member = asView(await overview()(fakeCtx({ user: { email: "m@example.test", id: "01a06091-baa3-7a1f-9c62-0e3ab6d2f5c1", permissions: [] } })));
|
||||
assert.equal(member.data["canRead"], false);
|
||||
assert.equal(member.data["signedIn"], true);
|
||||
assert.equal(member.data["mineHref"], "/scheduling/mine");
|
||||
});
|
||||
|
||||
// ---- create handler ----
|
||||
@@ -178,38 +173,3 @@ test("buildFormModel marks title/assignee required and attaches field errors", (
|
||||
assert.equal(title.error, "needed");
|
||||
assert.equal(fields.find((f) => f.name === "start")!.required, undefined);
|
||||
});
|
||||
|
||||
// ---- the session-gated page: the visitor's own rows ----
|
||||
|
||||
test("my shifts scopes the upstream read by the visitor's id, and names them in the empty state", async () => {
|
||||
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
|
||||
const mine: Shift = { assignee: "Blair Mora", assigneeId: user.id, end: "22:00", id: "3", start: "17:00", title: "Evening on-call" };
|
||||
let asked: { assigneeId?: string } | undefined;
|
||||
|
||||
const upstream = fakeUpstream({ list: async (opts) => { asked = opts; return [mine]; } });
|
||||
const r = asView(await myShifts(upstream)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
|
||||
assert.equal(r.view, "mine");
|
||||
assert.deepEqual(asked, { assigneeId: "01a06091-baa3-71f4-a068-4879972979ff" }); // the id, never the address
|
||||
const table = r.data["table"] as { emptyText: string; rows: { name: string }[] };
|
||||
assert.deepEqual(table.rows.map((row) => row.name), ["Evening on-call"]);
|
||||
assert.match(table.emptyText, /Blair\.Mora@example\.test/); // an empty page still says whose it is
|
||||
|
||||
// `requireSession` narrows `ctx.user` from `User | null` to `User` — the one part of the route's
|
||||
// `session: true` guarantee the contract cannot state in the handler's type.
|
||||
await assert.rejects(async () => { await myShifts(fakeUpstream())(fakeCtx()); }, GuardError);
|
||||
});
|
||||
|
||||
test("my shifts degrades to the reason alone when the upstream is down, claiming nothing about what is assigned", async () => {
|
||||
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
|
||||
const down = fakeUpstream({ list: async () => { throw new UpstreamError("down", 503); } });
|
||||
const r = asView(await myShifts(down)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
|
||||
assert.match(String(r.data["error"]), /scheduling service/i);
|
||||
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); // mine.ejs drops the count + table while `error` is set
|
||||
});
|
||||
|
||||
test("my shifts drops a row the upstream returned that is not the visitor's", async () => {
|
||||
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
|
||||
const theirs: Shift = { assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "9", start: "08:00", title: "Not mine" };
|
||||
const r = asView(await myShifts(fakeUpstream({ list: async () => [theirs] }))(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
|
||||
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); // a backend ignoring the scope must not leak through this page
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
|
||||
|
||||
// One import from the host's @plainpages/plugin-api barrel — the stable author surface (see README.md → Building plugins).
|
||||
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, requireSession, type RouteHandler, type Translate, tracedFetch } from "@plainpages/plugin-api";
|
||||
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "@plainpages/plugin-api";
|
||||
import enUS from "./i18n/en-US.ts";
|
||||
|
||||
// The plugin's own English (its catalog, then the host's), for a view model built outside a request:
|
||||
@@ -16,14 +16,12 @@ const EN: Translate = englishTranslator(enUS);
|
||||
|
||||
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
||||
export const SHIFTS_PATH = "/scheduling/shifts";
|
||||
export const MINE_PATH = "/scheduling/mine"; // the visitor's own shifts — a session is the whole gate
|
||||
export const READ = "scheduling:read"; // the permission gating the list + nav
|
||||
export const WRITE = "scheduling:write"; // the permission gating create
|
||||
|
||||
export interface Shift {
|
||||
id: string;
|
||||
assignee: string; // display name, rendered in the table
|
||||
assigneeId: string; // who the shift belongs to — an opaque id, the same one `ctx.user.id` carries
|
||||
assignee: string;
|
||||
end: string;
|
||||
start: string;
|
||||
title: string;
|
||||
@@ -48,30 +46,37 @@ export class UpstreamError extends Error {
|
||||
|
||||
export interface ShiftsUpstream {
|
||||
create(input: ShiftInput): Promise<void>;
|
||||
// `assigneeId` scopes the read at the source, which is where an ownership rule belongs (README →
|
||||
// Three tiers of "may I?"); without it the caller would hold everyone's rows to render one page.
|
||||
list(opts?: { assigneeId?: string }): 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`
|
||||
// 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.
|
||||
// `baseUrl` is read per call: the plugin's settings arrive on onBoot, after the manifest that binds
|
||||
// these handlers has already been built.
|
||||
export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
|
||||
const base = (): string => baseUrl().replace(/\/+$/, "");
|
||||
export function createUpstream(baseUrl: string, fetchImpl: typeof fetch = tracedFetch): ShiftsUpstream {
|
||||
const base = baseUrl.replace(/\/+$/, "");
|
||||
return {
|
||||
async create(input) {
|
||||
const res = await fetchImpl(`${base()}/shifts`, {
|
||||
const res = await fetchImpl(`${base}/shifts`, {
|
||||
body: JSON.stringify(input),
|
||||
headers: { "content-type": "application/json" },
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
|
||||
},
|
||||
async list(opts = {}) {
|
||||
const query = opts.assigneeId == null ? "" : `?${new URLSearchParams({ assigneeId: opts.assigneeId })}`;
|
||||
const res = await fetchImpl(`${base()}/shifts${query}`, { headers: { accept: "application/json" } });
|
||||
async list() {
|
||||
const res = await fetchImpl(`${base}/shifts`, { headers: { accept: "application/json" } });
|
||||
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
|
||||
const data: unknown = await res.json();
|
||||
return Array.isArray(data) ? data.map(toShift) : [];
|
||||
@@ -83,7 +88,7 @@ const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? ""
|
||||
|
||||
function toShift(raw: unknown): Shift {
|
||||
const r = (raw ?? {}) as Record<string, unknown>;
|
||||
return { assignee: str(r["assignee"]), assigneeId: str(r["assigneeId"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) };
|
||||
return { assignee: str(r["assignee"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) };
|
||||
}
|
||||
|
||||
// ---- view models (pure; the EJS views read these) -----------------------------------
|
||||
@@ -191,41 +196,6 @@ export function newShiftForm(): RouteHandler {
|
||||
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" });
|
||||
}
|
||||
|
||||
export function myShifts(upstream: ShiftsUpstream): RouteHandler {
|
||||
return async (ctx) => {
|
||||
const user = requireSession(ctx);
|
||||
let shifts: Shift[] = [];
|
||||
let error: string | undefined;
|
||||
try {
|
||||
// Join on the id, never the email: an address is user-changeable and can be reassigned to
|
||||
// someone else, which would hand them the previous holder's rows. The re-filter is
|
||||
// defence-in-depth: a backend that ignores an unknown query param would answer with everyone.
|
||||
shifts = (await upstream.list({ assigneeId: user.id })).filter((s) => s.assigneeId === user.id);
|
||||
} catch (err) {
|
||||
ctx.log.warn("scheduling upstream unreachable", { error: String(err) });
|
||||
error = ctx.t("scheduling.upstream.list");
|
||||
}
|
||||
return { data: buildMineModel({ chrome: ctx.chrome, email: user.email, ...(error ? { error } : {}), shifts, t: ctx.t }), view: "mine" };
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMineModel(opts: { chrome: PageChrome; email: string; error?: string; shifts: Shift[]; t?: Translate }) {
|
||||
const t = opts.t ?? EN;
|
||||
return {
|
||||
breadcrumbs: [{ label: t("scheduling.mine.title") }],
|
||||
chrome: opts.chrome,
|
||||
count: t("scheduling.shifts.count", { count: opts.shifts.length }),
|
||||
...(opts.error ? { error: opts.error } : {}),
|
||||
table: {
|
||||
caption: t("scheduling.mine.title"),
|
||||
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
|
||||
emptyText: t("scheduling.mine.empty", { email: opts.email }),
|
||||
rows: opts.shifts.map((s) => ({ cells: [{ rowHeader: { text: s.title } }, s.start, s.end], name: s.title })),
|
||||
},
|
||||
title: t("scheduling.mine.title"),
|
||||
};
|
||||
}
|
||||
|
||||
// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the
|
||||
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
|
||||
// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone
|
||||
@@ -236,9 +206,7 @@ export function overview(): RouteHandler {
|
||||
breadcrumbs: [{ label: ctx.t("scheduling.nav.overview") }],
|
||||
canRead: can(ctx, READ),
|
||||
chrome: ctx.chrome,
|
||||
mineHref: ctx.localeHref(MINE_PATH),
|
||||
shiftsHref: ctx.localeHref(SHIFTS_PATH), // a plugin carries the visitor's locale onto its own links
|
||||
signedIn: ctx.user !== null,
|
||||
signInHref: ctx.localeHref(`/login?return_to=${encodeURIComponent(ctx.localeHref(SHIFTS_PATH))}`),
|
||||
title: ctx.t("scheduling.overview.title"),
|
||||
},
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
<%#
|
||||
Scheduling · the visitor's own shifts (reference plugin).
|
||||
Data: chrome, title, breadcrumbs, count, table, error?
|
||||
%><%
|
||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
const tableHtml = include("partials/data-table", table);
|
||||
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
body: '<div class="scheduling-page">' + alertHtml + (locals.error ? '' : '<p class="shift-count">' + count + '</p>' + tableHtml) + '</div>',
|
||||
brand: chrome.brand,
|
||||
breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
nav: navHtml,
|
||||
styles: ["/public/scheduling/scheduling.css"],
|
||||
theme: chrome.theme,
|
||||
title,
|
||||
user: chrome.user,
|
||||
}) %>
|
||||
@@ -3,15 +3,11 @@
|
||||
nav node are marked `public`, so an anonymous visitor is let through and the menu option shows for
|
||||
everyone. The actual shifts data stays behind `scheduling:read`: a reader gets a link straight to
|
||||
it, anyone else a prompt to sign in. Rendered in the native shell via ctx.chrome.
|
||||
Data: chrome, title, breadcrumbs, canRead, mineHref, shiftsHref, signedIn, signInHref
|
||||
Data: chrome, title, breadcrumbs, canRead, shiftsHref, signInHref
|
||||
%><%
|
||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
// One CTA per gate the visitor passes: the list needs the permission, "My shifts" only a session,
|
||||
// and sign-in is offered to nobody who already has one.
|
||||
const cta = canRead
|
||||
? '<a class="btn btn-primary" href="' + shiftsHref + '">' + t("scheduling.overview.view") + '</a>'
|
||||
: signedIn
|
||||
? '<a class="btn btn-primary" href="' + mineHref + '">' + t("scheduling.overview.mine") + '</a>'
|
||||
: '<a class="btn btn-primary" href="' + signInHref + '">' + t("scheduling.overview.signIn") + '</a>';
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream
|
||||
service; this view renders them with the core building blocks inside the native app shell
|
||||
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
|
||||
alert) — see the README. Zero-JS: search round-trips the URL.
|
||||
alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL.
|
||||
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
|
||||
%><%
|
||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Dev-only mock upstream for the reference plugin (examples/plugins/scheduling) — a stand-in for the
|
||||
// customer's real backend, ready for when you copy the reference plugin into plugins/. NOT part
|
||||
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point 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.
|
||||
//
|
||||
// GET /shifts → 200 [ { id, title, assigneeId, assignee, start, end }, … ] (?assigneeId=<id> → only theirs)
|
||||
// GET /shifts → 200 [ { id, title, assignee, start, end }, … ]
|
||||
// POST /shifts → 201 { id, … } (body: { title, assignee, start, end })
|
||||
|
||||
import { randomUUID } from "node:crypto";
|
||||
@@ -11,12 +11,10 @@ import { createServer } from "node:http";
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 4000);
|
||||
|
||||
// `assigneeId` is the identity the rows are owned by — an opaque, stable subject id, which is what
|
||||
// `ctx.user.id` carries. These are this demo's own people; a real backend joins on your IdP's ids.
|
||||
const shifts = [
|
||||
{ id: randomUUID(), title: "Morning — Front desk", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", assignee: "Avery Kline", start: "2026-06-22 08:00", end: "2026-06-22 12:00" },
|
||||
{ id: randomUUID(), title: "Afternoon — Support", assigneeId: "019bdc1a-4a83-7de2-8f05-6c93a71be4d8", assignee: "Blair Mora", start: "2026-06-22 12:00", end: "2026-06-22 17:00" },
|
||||
{ id: randomUUID(), title: "Evening — On-call", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", assignee: "Casey Nguyen", start: "2026-06-22 17:00", end: "2026-06-22 22:00" },
|
||||
{ id: randomUUID(), title: "Morning — Front desk", assignee: "Avery Kline", start: "2026-06-22 08:00", end: "2026-06-22 12:00" },
|
||||
{ id: randomUUID(), title: "Afternoon — Support", assignee: "Blair Mora", start: "2026-06-22 12:00", end: "2026-06-22 17:00" },
|
||||
{ id: randomUUID(), title: "Evening — On-call", assignee: "Casey Nguyen", start: "2026-06-22 17:00", end: "2026-06-22 22:00" },
|
||||
];
|
||||
|
||||
const json = (res, status, body) => {
|
||||
@@ -35,14 +33,10 @@ const readBody = (req) =>
|
||||
|
||||
createServer(async (req, res) => {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
if (url.pathname === "/shifts" && req.method === "GET") {
|
||||
const assigneeId = url.searchParams.get("assigneeId");
|
||||
if (assigneeId === null) return json(res, 200, shifts);
|
||||
return json(res, 200, shifts.filter((s) => s.assigneeId === assigneeId));
|
||||
}
|
||||
if (url.pathname === "/shifts" && req.method === "GET") return json(res, 200, shifts);
|
||||
if (url.pathname === "/shifts" && req.method === "POST") {
|
||||
const b = await readBody(req);
|
||||
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), assigneeId: "", end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
|
||||
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
|
||||
shifts.push(shift);
|
||||
return json(res, 201, shift);
|
||||
}
|
||||
|
||||
Generated
+17
-17
@@ -8,12 +8,12 @@
|
||||
"dependencies": {
|
||||
"@larvit/log": "2.3.0",
|
||||
"ejs": "6.0.1",
|
||||
"lucide-static": "1.64.0",
|
||||
"postgres": "4.0.0"
|
||||
"lucide-static": "1.33.0",
|
||||
"postgres": "3.4.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/node": "24.19.2",
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -37,13 +37,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
|
||||
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
"undici-types": "~7.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
@@ -399,18 +399,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-static": {
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.64.0.tgz",
|
||||
"integrity": "sha512-UxWmMteOXbKHI5E4f2qVtxCAAsI9gsInYK1lNw0nrOA0w4FkJeOVZ+UOV6aGA2377FZe3qyiFYq3lqjBKZ6OuA==",
|
||||
"version": "1.33.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.33.0.tgz",
|
||||
"integrity": "sha512-jNGgvTNcLUfVRX4N9PH9pVVTJzoph/BmYmgU838bYBQodkUJL4nAThkuymFz1x3OUYMhJxPndC7rdg1sxOPYKg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/postgres": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-4.0.0.tgz",
|
||||
"integrity": "sha512-kBh8qXtcvvIAc00DftQyOxz7M/3Zxww1tRMXZPLzipNXXl5PIzHml+9zB3Mrge2U8ldc0WF1ZdLp1iYQi7Kh3A==",
|
||||
"version": "3.4.9",
|
||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
|
||||
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
|
||||
"license": "Unlicense",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
@@ -453,9 +453,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
+4
-4
@@ -13,17 +13,17 @@
|
||||
"dev": "node --watch src/server.ts",
|
||||
"gen-jwks": "node src/auth/gen-jwks.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test \"src/**/*.test.ts\" \"plugins/**/*.test.ts\" \"examples/**/*.test.ts\" \"registry-cleanup/**/*.test.ts\" \"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": {
|
||||
"@larvit/log": "2.3.0",
|
||||
"ejs": "6.0.1",
|
||||
"lucide-static": "1.64.0",
|
||||
"postgres": "4.0.0"
|
||||
"lucide-static": "1.33.0",
|
||||
"postgres": "3.4.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/node": "24.19.2",
|
||||
"@types/node": "24.13.3",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/* =============================================================
|
||||
Auth flow — login / register / forgot password
|
||||
Built on the shared tokens in styles.css. Pure HTML/CSS:
|
||||
- screens switch via :target (no JS)
|
||||
- SSO is a config section toggled by a class/checkbox; providers
|
||||
are a <ul> you extend by adding one <li class="sso-item">.
|
||||
============================================================= */
|
||||
|
||||
.auth-stage {
|
||||
min-height: 100dvh;
|
||||
display: grid; place-items: center;
|
||||
@@ -26,6 +34,13 @@
|
||||
.landing-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 4px; }
|
||||
.landing-actions .btn { height: 40px; padding: 0 20px; font-size: var(--fz); }
|
||||
|
||||
/* ---- screen switching via :target (login is the default) ---- */
|
||||
.auth-view { display: none; }
|
||||
#login { display: block; }
|
||||
#register:target, #forgot:target { display: block; }
|
||||
body:has(#register:target) #login,
|
||||
body:has(#forgot:target) #login { display: none; }
|
||||
|
||||
/* card */
|
||||
.auth-card {
|
||||
background: var(--surface);
|
||||
@@ -108,6 +123,8 @@
|
||||
|
||||
.field-hint { font-size: var(--fz-xs); color: var(--text-faint); }
|
||||
|
||||
.remember { margin-top: 2px; }
|
||||
|
||||
/* full-width primary button (extends .btn / .btn-primary from styles.css) */
|
||||
.btn-block { width: 100%; justify-content: center; height: 40px; font-size: var(--fz); }
|
||||
|
||||
@@ -121,6 +138,74 @@
|
||||
.auth-aside a { color: var(--accent); }
|
||||
.auth-aside a:hover { text-decoration: underline; }
|
||||
|
||||
/* legal footnote */
|
||||
.auth-foot { margin: 0; text-align: center; font-size: var(--fz-xs); color: var(--text-faint); }
|
||||
.auth-foot a { color: var(--text-muted); text-decoration: underline; }
|
||||
|
||||
/* =============================================================
|
||||
Template preview controls (delete this block + its markup for
|
||||
production). Drives theme + SSO on/off with zero JS.
|
||||
============================================================= */
|
||||
.tpl-controls {
|
||||
position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%);
|
||||
z-index: 50;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 6px 8px 6px 14px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 99px; box-shadow: 0 6px 22px rgba(0,0,0,.12);
|
||||
}
|
||||
.tpl-label { font-size: var(--fz-xs); color: var(--text-faint);
|
||||
text-transform: uppercase; letter-spacing: .05em; font-weight: 600; }
|
||||
.tpl-sep { width: 1px; height: 20px; background: var(--border); }
|
||||
.tpl-controls .theme-switch { width: 156px; }
|
||||
|
||||
/* on/off switch */
|
||||
.tpl-toggle { display: inline-flex; align-items: center; gap: 8px;
|
||||
font-size: var(--fz-sm); color: var(--text-muted); cursor: pointer; }
|
||||
.tpl-toggle input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||
.tpl-track { width: 32px; height: 18px; border-radius: 99px;
|
||||
background: var(--border-2); position: relative; transition: background .15s ease; }
|
||||
.tpl-track::after { content: ""; position: absolute; top: 2px; left: 2px;
|
||||
width: 14px; height: 14px; border-radius: 50%; background: #fff;
|
||||
box-shadow: 0 1px 2px rgba(0,0,0,.3); transition: transform .15s ease; }
|
||||
.tpl-toggle:has(input:checked) .tpl-track { background: var(--accent); }
|
||||
.tpl-toggle:has(input:checked) .tpl-track::after { transform: translateX(14px); }
|
||||
.tpl-toggle:has(input:focus-visible) .tpl-track {
|
||||
outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.tpl-label { display: none; }
|
||||
.tpl-controls { gap: 10px; padding: 6px 10px; }
|
||||
}
|
||||
|
||||
/* =============================================================
|
||||
Forgot-password feedback (Post/Redirect/Get, no JS).
|
||||
The form POSTs to its own URL; on the redirect-GET the server
|
||||
re-renders #forgot with a state class so the matching alert shows:
|
||||
<section id="forgot" class="auth-view state-sent"> … </section>
|
||||
(state-sent | state-error). No extra view is added.
|
||||
The preview radios below demonstrate the same states live.
|
||||
============================================================= */
|
||||
.forgot-alert { display: none; }
|
||||
#forgot.state-sent .forgot-alert.is-sent,
|
||||
body:has(#fstate-sent:checked) #forgot .forgot-alert.is-sent { display: flex; }
|
||||
#forgot.state-error .forgot-alert.is-error,
|
||||
body:has(#fstate-error:checked) #forgot .forgot-alert.is-error { display: flex; }
|
||||
|
||||
/* the Forgot-state control only appears while the forgot view is open */
|
||||
.tpl-forgot { display: none; }
|
||||
body:has(#forgot:target) .tpl-forgot { display: flex; }
|
||||
.tpl-controls .segmented { padding: 1px; }
|
||||
.tpl-controls .segmented label { padding: 3px 8px; }
|
||||
|
||||
/* =============================================================
|
||||
Field-level errors (e.g. "email already used"), server-driven.
|
||||
The server re-renders the field's form with a state class and
|
||||
adds aria-invalid="true" to the input:
|
||||
<section id="register" class="auth-view state-email-taken"> … </section>
|
||||
Reusable: drop a <p class="field-error"> under any field and gate
|
||||
it on whatever state your server sets.
|
||||
============================================================= */
|
||||
.field-error {
|
||||
display: none; align-items: center; gap: 6px; margin: 0;
|
||||
font-size: var(--fz-xs); color: var(--neg);
|
||||
@@ -128,3 +213,36 @@
|
||||
.field.has-error .field-error { display: flex; } /* server-rendered field shows its own error */
|
||||
.field-error > .ico { flex: 0 0 auto; color: var(--neg); }
|
||||
.field-error a { color: var(--neg); font-weight: 600; text-decoration: underline; }
|
||||
|
||||
/* register form-level error banner (pairs with field-level errors) */
|
||||
.register-alert { display: none; }
|
||||
|
||||
/* --- state A: email already taken — field-level error only --- */
|
||||
#register.state-email-taken .err-email,
|
||||
body:has(#rstate-taken:checked) #register .err-email { display: flex; }
|
||||
#register.state-email-taken #reg-email,
|
||||
body:has(#rstate-taken:checked) #register #reg-email { border-color: var(--neg); }
|
||||
|
||||
/* --- state B: multiple errors — banner + several field errors together --- */
|
||||
#register.state-invalid .register-alert,
|
||||
body:has(#rstate-combined:checked) #register .register-alert { display: flex; }
|
||||
#register.state-invalid .err-email,
|
||||
#register.state-invalid .err-password,
|
||||
body:has(#rstate-combined:checked) #register .err-email,
|
||||
body:has(#rstate-combined:checked) #register .err-password { display: flex; }
|
||||
#register.state-invalid #reg-email,
|
||||
#register.state-invalid #reg-password,
|
||||
body:has(#rstate-combined:checked) #register #reg-email,
|
||||
body:has(#rstate-combined:checked) #register #reg-password { border-color: var(--neg); }
|
||||
|
||||
/* red focus ring on any invalid register input */
|
||||
#register.state-email-taken #reg-email:focus,
|
||||
#register.state-invalid #reg-email:focus,
|
||||
#register.state-invalid #reg-password:focus,
|
||||
body:has(#rstate-taken:checked) #register #reg-email:focus,
|
||||
body:has(#rstate-combined:checked) #register #reg-email:focus,
|
||||
body:has(#rstate-combined:checked) #register #reg-password:focus { box-shadow: 0 0 0 2px var(--neg-bg); }
|
||||
|
||||
/* the Register-state control only appears while the register view is open */
|
||||
.tpl-register { display: none; }
|
||||
body:has(#register:target) .tpl-register { display: flex; }
|
||||
|
||||
+44
-62
@@ -2,6 +2,8 @@
|
||||
App Shell Template — pure HTML/CSS, no JavaScript
|
||||
-------------------------------------------------------------
|
||||
Architecture
|
||||
- Design tokens via light-dark() so theme follows system by
|
||||
default and can be forced Light / Dark via radio + :has().
|
||||
- Everything is a reusable "block": .nav-item, .filter,
|
||||
.chip, .badge, .btn, .menu, .table — combine freely.
|
||||
============================================================= */
|
||||
@@ -19,35 +21,33 @@
|
||||
widgets (date picker, scrollbars, selects).
|
||||
------------------------------------------------------------- */
|
||||
:root {
|
||||
/* Hex, never oklch: below Chromium 111 `var(--accent)` is invalid at computed-value time and the
|
||||
primary button loses its background. A fallback declaration cannot catch that. */
|
||||
/* ---- raw LIGHT palette ---- */
|
||||
--l-bg:#f3f3f4; --l-surface:#ffffff; --l-surface-2:#f6f6f7; --l-surface-3:#fbfbfc;
|
||||
--l-border:#e5e5e8; --l-border-2:#d6d6da;
|
||||
--l-text:#18181b; --l-text-muted:#6b6b73; --l-text-faint:#9a9aa2;
|
||||
--l-accent:#3669ac; --l-accent-bg:#e5f3ff;
|
||||
--l-accent-bd:#a7c7f2; --l-focus:#2a68b7; --l-on-accent:#ffffff;
|
||||
--l-pos:#1f7a3d; --l-pos-bg:#e1f5e4; --l-pos-bd:#a8d5b0;
|
||||
--l-neg:#c53637; --l-neg-bg:#ffebe8; --l-neg-bd:#febab4;
|
||||
--l-warn:#8f5d00; --l-warn-bg:#fef0d4; --l-warn-bd:#e3c78e;
|
||||
--l-info:#1d6aa5;--l-info-bg:#e2f5ff;--l-info-bd:#a2cdf2;
|
||||
--l-accent:oklch(0.52 0.12 256); --l-accent-bg:oklch(0.96 0.03 256);
|
||||
--l-accent-bd:oklch(0.82 0.07 256); --l-focus:oklch(0.52 0.14 256);
|
||||
--l-pos:oklch(0.54 0.13 150); --l-pos-bg:oklch(0.95 0.03 150); --l-pos-bd:oklch(0.83 0.07 150);
|
||||
--l-neg:oklch(0.55 0.18 25); --l-neg-bg:oklch(0.96 0.03 25); --l-neg-bd:oklch(0.85 0.08 25);
|
||||
--l-warn:oklch(0.55 0.12 75); --l-warn-bg:oklch(0.96 0.04 85); --l-warn-bd:oklch(0.84 0.08 85);
|
||||
--l-info:oklch(0.54 0.12 245);--l-info-bg:oklch(0.96 0.03 245);--l-info-bd:oklch(0.83 0.07 245);
|
||||
|
||||
/* ---- raw DARK palette ---- */
|
||||
--d-bg:#0d0d0f; --d-surface:#161619; --d-surface-2:#1d1d22; --d-surface-3:#1a1a1e;
|
||||
--d-border:#2a2a31; --d-border-2:#3a3a43;
|
||||
--d-text:#ededf0; --d-text-muted:#9b9ba6; --d-text-faint:#6a6a74;
|
||||
--d-accent:#6ba0e8; --d-accent-bg:#1d3351;
|
||||
--d-accent-bd:#3f5f8a; --d-focus:#72adfb; --d-on-accent:#0d0d0f;
|
||||
--d-pos:#62c37a; --d-pos-bg:#1a3520; --d-pos-bd:#376040;
|
||||
--d-neg:#ff7e76; --d-neg-bg:#4b1d1b; --d-neg-bd:#8a423d;
|
||||
--d-warn:#e4b750; --d-warn-bg:#3e2d10; --d-warn-bd:#72592c;
|
||||
--d-info:#65b2f1;--d-info-bg:#12334c;--d-info-bd:#305f85;
|
||||
--d-accent:oklch(0.70 0.12 256); --d-accent-bg:oklch(0.32 0.06 256);
|
||||
--d-accent-bd:oklch(0.48 0.08 256); --d-focus:oklch(0.74 0.13 256);
|
||||
--d-pos:oklch(0.74 0.14 150); --d-pos-bg:oklch(0.30 0.05 150); --d-pos-bd:oklch(0.45 0.07 150);
|
||||
--d-neg:oklch(0.74 0.16 25); --d-neg-bg:oklch(0.30 0.07 25); --d-neg-bd:oklch(0.47 0.10 25);
|
||||
--d-warn:oklch(0.80 0.13 85); --d-warn-bg:oklch(0.31 0.05 80); --d-warn-bd:oklch(0.48 0.07 80);
|
||||
--d-info:oklch(0.74 0.12 245);--d-info-bg:oklch(0.31 0.06 245);--d-info-bd:oklch(0.47 0.08 245);
|
||||
|
||||
/* ---- active mapping: default = LIGHT ---- */
|
||||
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
|
||||
--border:var(--l-border); --border-2:var(--l-border-2);
|
||||
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
|
||||
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --on-accent:var(--l-on-accent);
|
||||
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus);
|
||||
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
|
||||
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
|
||||
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
|
||||
@@ -58,7 +58,7 @@
|
||||
--radius: 5px;
|
||||
--nav-w: 264px;
|
||||
--row-h: 34px;
|
||||
--pad-x: 12px; /* side padding of the content pane's bars and form page, and of every table cell, so their text lines up */
|
||||
--pad-x: 12px;
|
||||
--fz: 13px;
|
||||
--fz-sm: 12px;
|
||||
--fz-xs: 11px;
|
||||
@@ -74,7 +74,7 @@
|
||||
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
|
||||
--border:var(--d-border); --border-2:var(--d-border-2);
|
||||
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
|
||||
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --on-accent:var(--d-on-accent);
|
||||
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus);
|
||||
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
|
||||
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
|
||||
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
|
||||
@@ -88,7 +88,7 @@ html:has(#theme-dark:checked) {
|
||||
--bg:var(--d-bg); --surface:var(--d-surface); --surface-2:var(--d-surface-2); --surface-3:var(--d-surface-3);
|
||||
--border:var(--d-border); --border-2:var(--d-border-2);
|
||||
--text:var(--d-text); --text-muted:var(--d-text-muted); --text-faint:var(--d-text-faint);
|
||||
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus); --on-accent:var(--d-on-accent);
|
||||
--accent:var(--d-accent); --accent-bg:var(--d-accent-bg); --accent-bd:var(--d-accent-bd); --focus:var(--d-focus);
|
||||
--pos:var(--d-pos); --pos-bg:var(--d-pos-bg); --pos-bd:var(--d-pos-bd);
|
||||
--neg:var(--d-neg); --neg-bg:var(--d-neg-bg); --neg-bd:var(--d-neg-bd);
|
||||
--warn:var(--d-warn); --warn-bg:var(--d-warn-bg); --warn-bd:var(--d-warn-bd);
|
||||
@@ -101,7 +101,7 @@ html:has(#theme-light:checked) {
|
||||
--bg:var(--l-bg); --surface:var(--l-surface); --surface-2:var(--l-surface-2); --surface-3:var(--l-surface-3);
|
||||
--border:var(--l-border); --border-2:var(--l-border-2);
|
||||
--text:var(--l-text); --text-muted:var(--l-text-muted); --text-faint:var(--l-text-faint);
|
||||
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus); --on-accent:var(--l-on-accent);
|
||||
--accent:var(--l-accent); --accent-bg:var(--l-accent-bg); --accent-bd:var(--l-accent-bd); --focus:var(--l-focus);
|
||||
--pos:var(--l-pos); --pos-bg:var(--l-pos-bg); --pos-bd:var(--l-pos-bd);
|
||||
--neg:var(--l-neg); --neg-bg:var(--l-neg-bg); --neg-bd:var(--l-neg-bd);
|
||||
--warn:var(--l-warn); --warn-bg:var(--l-warn-bg); --warn-bd:var(--l-warn-bd);
|
||||
@@ -111,6 +111,7 @@ html:has(#theme-light:checked) {
|
||||
|
||||
/* ---------- 2. RESET ---------------------------------------- */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text);
|
||||
-webkit-font-smoothing: antialiased; }
|
||||
button { font: inherit; color: inherit; }
|
||||
@@ -150,7 +151,8 @@ summary { list-style: none; cursor: pointer; }
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: var(--nav-w) minmax(0, 1fr);
|
||||
min-height: 100dvh;
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ---------- 4. SIDEBAR -------------------------------------- */
|
||||
@@ -158,6 +160,7 @@ summary { list-style: none; cursor: pointer; }
|
||||
grid-column: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
@@ -169,9 +172,9 @@ summary { list-style: none; cursor: pointer; }
|
||||
.brand-mark {
|
||||
width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto;
|
||||
background: var(--accent);
|
||||
display: grid; place-items: center; color: var(--on-accent);
|
||||
display: grid; place-items: center; color: #fff;
|
||||
}
|
||||
.brand-mark .ico { stroke: var(--on-accent); }
|
||||
.brand-mark .ico { stroke: #fff; }
|
||||
.brand-logo { width: 22px; height: 22px; border-radius: 5px; flex: 0 0 auto; object-fit: contain; }
|
||||
.brand-name { font-weight: 650; letter-spacing: -.01em; }
|
||||
.brand-sub { color: var(--text-faint); font-size: var(--fz-xs); margin-left: auto; }
|
||||
@@ -189,10 +192,9 @@ summary { list-style: none; cursor: pointer; }
|
||||
• CLICKABLE = label is <a href> → navigates on click.
|
||||
STATIC = label is <span> → not a navigation target.
|
||||
So "header" and "clickable" are independent: any combination works.
|
||||
Children sit in a sibling <ul class="nav-children">. Where :has() works, a
|
||||
branch is folded unless THIS row's own <details open> (scoped with > so a node
|
||||
only ever controls its own subtree — infinite depth, no JS). Without :has()
|
||||
every branch stays open, since nothing could reopen one.
|
||||
Children sit in a sibling <ul class="nav-children">, revealed by
|
||||
:has() on THIS row's own <details open> (scoped with > so a node
|
||||
only ever controls its own subtree — infinite depth, no JS).
|
||||
============================================================ */
|
||||
.nav-row { display: flex; align-items: center; gap: 2px; }
|
||||
|
||||
@@ -236,10 +238,8 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.nav-self[aria-current="page"] .ico { color: var(--accent); }
|
||||
|
||||
/* children + infinite indentation with a guide line per level */
|
||||
@supports selector(:has(a)) {
|
||||
.nav-node > .nav-children { display: none; }
|
||||
.nav-node:has(> .nav-row > .nav-disc[open]) > .nav-children { display: block; }
|
||||
}
|
||||
.nav-children { padding-left: 22px; position: relative; }
|
||||
.nav-children::before {
|
||||
content: ""; position: absolute; left: 10px; top: 2px; bottom: 2px;
|
||||
@@ -282,13 +282,6 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
outline: 2px solid var(--focus); outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* Without :has() every branch is open and every theme rule is inert: neither control does anything.
|
||||
After the base rules, since @supports adds no specificity. */
|
||||
@supports not selector(:has(a)) {
|
||||
.nav-disc { visibility: hidden; } /* keeps its width, so the labels stay aligned */
|
||||
.theme-switch { display: none; }
|
||||
}
|
||||
|
||||
.footer-actions { display: flex; align-items: center; gap: 4px; }
|
||||
.profile {
|
||||
display: flex; align-items: center; gap: 9px; flex: 1 1 auto;
|
||||
@@ -319,15 +312,10 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
}
|
||||
.btn:hover { background: var(--surface-2); }
|
||||
.btn-primary { background: var(--accent); border-color: var(--accent);
|
||||
color: var(--on-accent); font-weight: 550; }
|
||||
color: #fff; font-weight: 550; }
|
||||
.btn-primary:hover { filter: brightness(1.06); }
|
||||
.btn-ghost { background: transparent; border-color: transparent; }
|
||||
.btn-ghost:hover { background: var(--surface-2); }
|
||||
.btn-menu::after {
|
||||
content: ""; width: 7px; height: 7px; margin: -2px 1px 0 1px;
|
||||
border-right: 1.5px solid var(--text-faint); border-bottom: 1.5px solid var(--text-faint);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.icon-btn {
|
||||
width: 30px; height: 30px; padding: 0; justify-content: center;
|
||||
color: var(--text-muted);
|
||||
@@ -338,7 +326,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.content {
|
||||
grid-column: 2;
|
||||
display: flex; flex-direction: column;
|
||||
min-width: 0;
|
||||
min-width: 0; min-height: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
@@ -346,7 +334,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.topbar {
|
||||
flex: 0 0 auto; height: 48px;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 0 var(--pad-x); border-bottom: 1px solid var(--border);
|
||||
padding: 0 16px; border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
.hamburger { display: none; } /* shown only on narrow */
|
||||
@@ -362,7 +350,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.filters {
|
||||
flex: 0 0 auto; background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding: 10px var(--pad-x);
|
||||
padding: 10px 16px;
|
||||
display: flex; flex-direction: column; gap: 9px;
|
||||
}
|
||||
.filter-row {
|
||||
@@ -506,8 +494,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
position-anchor: auto;
|
||||
position-try-fallbacks: flip-block, flip-inline;
|
||||
top: anchor(bottom); right: anchor(right);
|
||||
min-width: 210px; max-width: min(320px, 92vw); padding: 6px;
|
||||
max-height: 60vh; overflow-y: auto; overflow-wrap: anywhere;
|
||||
min-width: 210px; padding: 6px;
|
||||
background: var(--surface); color: var(--text);
|
||||
border: 1px solid var(--border-2); border-radius: var(--radius);
|
||||
box-shadow: 0 8px 28px rgba(0,0,0,.16);
|
||||
@@ -566,12 +553,13 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.pill-clear:hover { text-decoration: underline; }
|
||||
|
||||
/* ---------- 9. TABLE --------------------------------------- */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.table-wrap { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
||||
table.table {
|
||||
width: 100%; border-collapse: separate; border-spacing: 0;
|
||||
font-size: var(--fz); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.table thead th {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
background: var(--surface-3);
|
||||
border-bottom: 1px solid var(--border-2);
|
||||
color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs);
|
||||
@@ -594,7 +582,7 @@ table.table {
|
||||
.col-num { text-align: right; }
|
||||
.cell-strong { font-weight: 550; }
|
||||
.cell-muted { color: var(--text-muted); }
|
||||
.table-empty { text-align: center; color: var(--text-muted); padding: 28px var(--pad-x); }
|
||||
.table-empty { text-align: center; color: var(--text-muted); padding: 28px 12px; }
|
||||
.cell-mono { font-variant-numeric: tabular-nums;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); }
|
||||
|
||||
@@ -658,7 +646,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
||||
.pager {
|
||||
flex: 0 0 auto;
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 8px var(--pad-x); border-top: 1px solid var(--border);
|
||||
padding: 8px 16px; border-top: 1px solid var(--border);
|
||||
background: var(--surface); font-size: var(--fz-sm); color: var(--text-muted);
|
||||
}
|
||||
.pager .spacer { flex: 1 1 auto; }
|
||||
@@ -673,25 +661,20 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
||||
}
|
||||
.page-btn:hover { background: var(--surface-2); color: var(--text); }
|
||||
.page-btn[aria-current="page"] { background: var(--accent); border-color: var(--accent);
|
||||
color: var(--on-accent); font-weight: 600; }
|
||||
color: #fff; font-weight: 600; }
|
||||
.page-btn:disabled { opacity: .4; cursor: not-allowed; }
|
||||
|
||||
/* ---------- 11. RESPONSIVE / HAMBURGER --------------------- */
|
||||
.scrim { display: none; }
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.app { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto 1fr; }
|
||||
.content { grid-column: 1; grid-row: 2; }
|
||||
.search { min-width: 150px; flex: 1 1 auto; }
|
||||
|
||||
/* Off-canvas only where :has() works: without it nothing can read #nav-toggle, so the sidebar
|
||||
stays in the flow above the content and the hamburger stays hidden. */
|
||||
@supports selector(:has(a)) {
|
||||
.app { grid-template-columns: minmax(0, 1fr); }
|
||||
.sidebar {
|
||||
position: fixed; inset: 0 auto 0 0; width: min(86vw, var(--nav-w));
|
||||
z-index: 60; transform: translateX(-100%);
|
||||
box-shadow: 2px 0 24px rgba(0,0,0,.18);
|
||||
}
|
||||
.content { grid-column: 1; }
|
||||
.hamburger { display: inline-flex; }
|
||||
|
||||
/* checkbox-driven open state */
|
||||
@@ -700,7 +683,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
||||
display: block; position: fixed; inset: 0; z-index: 50;
|
||||
background: rgba(0,0,0,.42);
|
||||
}
|
||||
}
|
||||
.search { min-width: 150px; flex: 1 1 auto; }
|
||||
}
|
||||
@media (max-width: 560px) {
|
||||
.crumbs, .page-sub { display: none; }
|
||||
@@ -708,10 +691,10 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
||||
}
|
||||
|
||||
/* the nav-toggle checkbox itself is visually hidden but focusable */
|
||||
#nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
|
||||
#nav-toggle { position: absolute; opacity: 0; pointer-events: none; }
|
||||
|
||||
/* admin forms: create/edit user, account actions */
|
||||
.form-page { padding: 16px var(--pad-x); display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
|
||||
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
|
||||
.form-card {
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
padding: 18px; background: var(--surface);
|
||||
@@ -733,6 +716,5 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
||||
/* Chromeless shell: a page may drop the sidebar for a focused single column. */
|
||||
.app-bare { grid-template-columns: minmax(0, 1fr); }
|
||||
.app-bare .content { grid-column: 1; }
|
||||
|
||||
/* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */
|
||||
.shell-auth { flex: 1 1 auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
|
||||
.shell-auth { flex: 1 1 auto; overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "node:test";
|
||||
import { checkTagMatchesContract, readHostApiVersion } from "./contract-version.ts";
|
||||
|
||||
test("readHostApiVersion pulls the constant out of the real source, and returns null when absent", () => {
|
||||
const real = readFileSync("src/plugin-host/plugin.ts", "utf8");
|
||||
assert.match(readHostApiVersion(real) ?? "", /^\d+\.\d+\.\d+$/);
|
||||
assert.equal(readHostApiVersion('export const SOMETHING_ELSE = "1.0.0";'), null);
|
||||
});
|
||||
|
||||
test("bumping HOST_API_VERSION is a deliberate act, so pin the shipped value", () => {
|
||||
// Not a substitute for the release gate — this test cannot see a tag. It is the tripwire that
|
||||
// makes an accidental edit fail here rather than at release time.
|
||||
assert.equal(readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")), "0.4.0");
|
||||
});
|
||||
|
||||
test("every author-facing apiVersion sample matches the shipped contract", () => {
|
||||
// A plugin author copies these; a stale one produces a boot-aborting refuse on first run. The
|
||||
// examples deliberately write a literal rather than importing the constant (AGENTS.md), so this
|
||||
// is the only thing keeping the copies honest.
|
||||
const host = readHostApiVersion(readFileSync("src/plugin-host/plugin.ts", "utf8")) ?? "";
|
||||
const [major, minor] = host.split(".");
|
||||
for (const file of [
|
||||
"README.md",
|
||||
"examples/plugins/admin/plugin.ts",
|
||||
"examples/plugins/scheduling/plugin.ts",
|
||||
"release-tooling/dockerhub-overview.md.tmpl",
|
||||
"views/index.ejs",
|
||||
]) {
|
||||
const found = [...readFileSync(file, "utf8").matchAll(/apiVersion: "(\d+\.\d+\.\d+)"/g)].map((m) => m[1]);
|
||||
assert.ok(found.length > 0, `${file} should carry at least one apiVersion sample`);
|
||||
for (const sample of found) {
|
||||
const [sMajor, sMinor] = (sample ?? "").split(".");
|
||||
assert.equal(`${sMajor}.${sMinor}`, `${major}.${minor}`, `${file} samples apiVersion ${sample}, host is ${host}`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("checkTagMatchesContract: major.minor must agree, patch may lag", () => {
|
||||
assert.equal(checkTagMatchesContract("v0.1.0", "0.1.0").ok, true);
|
||||
assert.equal(checkTagMatchesContract("v0.1.7", "0.1.0").ok, true); // auto-release cut patches
|
||||
assert.equal(checkTagMatchesContract("0.1.0", "0.1.0").ok, true); // bare tag, no v
|
||||
assert.equal(checkTagMatchesContract("v0.2.0", "0.1.0").ok, false); // plugin-visible, needs a bump
|
||||
assert.equal(checkTagMatchesContract("v1.0.0", "0.1.0").ok, false);
|
||||
});
|
||||
|
||||
test("checkTagMatchesContract names what to fix rather than just failing", () => {
|
||||
const res = checkTagMatchesContract("v0.2.0", "0.1.0");
|
||||
assert.equal(res.ok, false);
|
||||
assert.match(res.ok === false ? res.error : "", /HOST_API_VERSION to 0\.2\.0/);
|
||||
});
|
||||
|
||||
test("checkTagMatchesContract rejects junk on either side without throwing", () => {
|
||||
assert.equal(checkTagMatchesContract("v0.1.0", null).ok, false); // constant not found
|
||||
assert.equal(checkTagMatchesContract("nope", "0.1.0").ok, false);
|
||||
assert.equal(checkTagMatchesContract("v0.1.0", "1.0").ok, false);
|
||||
});
|
||||
@@ -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
-16
@@ -6,7 +6,7 @@
|
||||
"automerge": true,
|
||||
"packageRules": [
|
||||
{
|
||||
"description": "The host's own runtime deps. Release-Bump is opt-in per surface (README → CI/CD): updateType rates the dependency's own jump, not whether it reaches a running Plainpages",
|
||||
"description": "The host's own runtime deps. Release-Bump is opt-in per surface (README → CI/CD) because updateType rates the dependency's own jump, not its effect here — unscoped, the bot's self-update bumped the product",
|
||||
"matchDepTypes": ["dependencies"],
|
||||
"matchFileNames": ["package.json"],
|
||||
"matchManagers": ["npm"],
|
||||
@@ -24,12 +24,6 @@
|
||||
"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"],
|
||||
@@ -59,15 +53,8 @@
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"managerFilePatterns": ["release-tooling/dockerhub-overview.md.tmpl"],
|
||||
"matchStrings": ["image: (?<depName>[^:\\s]+):(?<currentValue>v?\\d[^\\s]*)"],
|
||||
"datasourceTemplate": "docker"
|
||||
},
|
||||
{
|
||||
"customType": "regex",
|
||||
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, renovate auto-release, release)",
|
||||
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/release.yml", ".gitea/workflows/renovate.yml"],
|
||||
"description": "Pin the node image workflow run-steps invoke (registry-cleanup, auto-release)",
|
||||
"managerFilePatterns": [".gitea/workflows/registry-cleanup.yml", ".gitea/workflows/renovate.yml"],
|
||||
"matchStrings": ["\\snode:(?<currentValue>[0-9][^\\s\"']*)"],
|
||||
"depNameTemplate": "node",
|
||||
"datasourceTemplate": "docker"
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { User } from "../http/context.ts";
|
||||
import { allows, gatesSet } from "./gate.ts";
|
||||
|
||||
const holder: User = { email: "holder@example.test", id: "01a06091-ba9f-765f-abf4-b5144c314bc7", permissions: ["x:read"] };
|
||||
const stranger: User = { email: "stranger@example.test", id: "01a06091-baa3-7b4d-810a-c9ee7e559d98", permissions: [] };
|
||||
|
||||
test("allows: ungated and public are open to anyone; session needs a user; permission needs the token", () => {
|
||||
assert.equal(allows({}, null), true);
|
||||
assert.equal(allows({ public: true }, null), true);
|
||||
|
||||
assert.equal(allows({ session: true }, null), false);
|
||||
assert.equal(allows({ session: true }, stranger), true); // signed in is the whole gate — no grant
|
||||
|
||||
assert.equal(allows({ permission: "x:read" }, null), false);
|
||||
assert.equal(allows({ permission: "x:read" }, stranger), false);
|
||||
assert.equal(allows({ permission: "x:read" }, holder), true);
|
||||
});
|
||||
|
||||
test("gatesSet names the gates a declaration sets, so discovery can refuse more than one", () => {
|
||||
assert.deepEqual(gatesSet({}), []);
|
||||
assert.deepEqual(gatesSet({ session: true }), ["session"]);
|
||||
assert.deepEqual(gatesSet({ permission: "x:read", public: true }), ["public", "permission"]);
|
||||
assert.deepEqual(gatesSet({ permission: "x:read", public: true, session: true }), ["public", "session", "permission"]);
|
||||
// Only `true` sets a gate, so a manifest spelling one `false` names none — which discovery refuses.
|
||||
assert.deepEqual(gatesSet({ public: false, session: false }), []);
|
||||
});
|
||||
@@ -1,22 +0,0 @@
|
||||
// One home for the gate rule, so the router and the menu can never disagree about what a visitor
|
||||
// may reach. README → Public pages & menu items.
|
||||
import type { User } from "../http/context.ts";
|
||||
|
||||
const GATES = ["public", "session", "permission"] as const;
|
||||
|
||||
export interface Gate {
|
||||
permission?: string; // the Keto Permission the caller must hold, `<resource>:<action>`
|
||||
public?: boolean; // anyone, signed in or not
|
||||
session?: boolean; // any signed-in user, no grant to hold; anonymous is sent to /login
|
||||
}
|
||||
|
||||
export function allows(gate: Gate, user: User | null): boolean {
|
||||
if (gate.public === true) return true;
|
||||
if (gate.session === true) return user !== null;
|
||||
return gate.permission == null || (user?.permissions.includes(gate.permission) ?? false);
|
||||
}
|
||||
|
||||
export function gatesSet(gate: Gate | null | undefined): string[] {
|
||||
if (gate == null) return [];
|
||||
return GATES.filter((name) => (name === "permission" ? gate.permission != null : gate[name] === true));
|
||||
}
|
||||
+2
-14
@@ -92,24 +92,12 @@ test("completeLogin returns null and touches nothing when there is no active ses
|
||||
assert.equal(touched, false);
|
||||
});
|
||||
|
||||
test("completeLogin throws if the tokenizer yields no JWT", async () => {
|
||||
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
||||
test("completeLogin maps a missing email trait to null and throws if the tokenizer yields no JWT", async () => {
|
||||
const identity: Identity = { id: ID, traits: {} };
|
||||
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity }) as Session }); // never returns a tokenized JWT
|
||||
await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/);
|
||||
});
|
||||
|
||||
// An identity with no email is no session, decided here so /auth/complete and remintSession cannot
|
||||
// disagree: `claimsToUser` reads a token carrying none as anonymous, so minting one would hand the
|
||||
// browser a cookie every later request refuses.
|
||||
test("completeLogin refuses an identity carrying no email, before it mints anything", async () => {
|
||||
const identity: Identity = { id: ID, traits: {} };
|
||||
let touched = false;
|
||||
const kratosAdmin = adminStub({ updateMetadataPublic: async () => { touched = true; return { id: ID }; } });
|
||||
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity, tokenized: "h.p.s" }) as Session });
|
||||
assert.equal(await completeLogin({ keto: ketoStub(), kratosAdmin, kratosPublic }, "c"), null);
|
||||
assert.equal(touched, false); // no Keto read, no metadata write, no JWT
|
||||
});
|
||||
|
||||
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
|
||||
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
|
||||
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
|
||||
|
||||
+3
-9
@@ -31,7 +31,7 @@ export interface LoginDeps {
|
||||
}
|
||||
|
||||
export interface CompletedLogin {
|
||||
email: string;
|
||||
email: string | null;
|
||||
userId: string;
|
||||
jwt: string;
|
||||
permissions: string[];
|
||||
@@ -61,13 +61,7 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
|
||||
if (!session?.identity) return null;
|
||||
const userId = session.identity.id;
|
||||
const emailTrait = session.identity.traits?.["email"];
|
||||
const email = typeof emailTrait === "string" ? emailTrait : "";
|
||||
// No email is no session: `claimsToUser` reads a token carrying none as anonymous, so minting one
|
||||
// would hand the browser a cookie every later request refuses.
|
||||
if (!email) {
|
||||
currentLog()?.warn("session dropped: identity has no email", { sub: userId });
|
||||
return null;
|
||||
}
|
||||
const email = typeof emailTrait === "string" ? emailTrait : null;
|
||||
|
||||
const permissions = await readPermissions(deps.keto, userId);
|
||||
await deps.kratosAdmin.updateMetadataPublic(userId, { permissions });
|
||||
@@ -93,7 +87,7 @@ export interface Reminted {
|
||||
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
|
||||
const completed = await completeLogin(deps, cookie);
|
||||
if (!completed) return { setCookie: clearSessionCookie(options), user: null };
|
||||
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email, id: completed.userId, permissions: completed.permissions } };
|
||||
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email ?? "", id: completed.userId, permissions: completed.permissions } };
|
||||
}
|
||||
|
||||
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { AUTH_FLOWS } from "./flow-view.ts";
|
||||
import { gatesSet } from "./gate.ts";
|
||||
import type { HydraAdmin } from "./hydra-admin.ts";
|
||||
import type { KetoClient } from "./keto-client.ts";
|
||||
import type { KratosAdmin } from "./kratos-admin.ts";
|
||||
@@ -40,12 +39,8 @@ test("hydra alone ⇒ only RP-initiated logout of the OAuth2 group (login/consen
|
||||
});
|
||||
|
||||
test("everything wired ⇒ the full group: OAuth2 challenges, consent GET+POST, /auth/complete", () => {
|
||||
const routes = buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin }));
|
||||
const got = keys(routes);
|
||||
const got = keys(buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin })));
|
||||
for (const key of ["GET /auth/complete", "GET /login", "GET /oauth2/consent", "GET /oauth2/login", "GET /oauth2/logout", "POST /logout", "POST /oauth2/consent"]) {
|
||||
assert.ok(got.includes(key), key);
|
||||
}
|
||||
// Discovery enforces exactly one gate per plugin declaration; nothing checks the host's own table
|
||||
// at boot, so a route added here without a gate would be silently public.
|
||||
for (const route of routes) assert.deepEqual(gatesSet(route), ["public"], `${route.method} ${route.path}`);
|
||||
});
|
||||
|
||||
+8
-8
@@ -240,20 +240,20 @@ export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secure
|
||||
const routes: BuiltinRoute[] = [];
|
||||
if (kratos) {
|
||||
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
|
||||
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path, public: true });
|
||||
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path });
|
||||
}
|
||||
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout", public: true });
|
||||
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout" });
|
||||
}
|
||||
if (hydra && kratos) {
|
||||
const provider = { hydra, kratos };
|
||||
routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login", public: true });
|
||||
routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent", public: true });
|
||||
routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent", public: true });
|
||||
routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login" });
|
||||
routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent" });
|
||||
routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent" });
|
||||
}
|
||||
if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout", public: true });
|
||||
if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout" });
|
||||
if (kratos && kratosAdmin && keto) {
|
||||
routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete", public: true });
|
||||
routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete" });
|
||||
}
|
||||
routes.push({ handler: errorSink, method: "GET", path: "/error", public: true });
|
||||
routes.push({ handler: errorSink, method: "GET", path: "/error" });
|
||||
return routes;
|
||||
}
|
||||
|
||||
@@ -56,7 +56,6 @@ export interface Config {
|
||||
pluginDbSecret: string; // derives each plugin's database password (src/plugin-host/storage.ts)
|
||||
pluginDbUrl: string | undefined; // credential-free Postgres base URL; unset ⇒ plugin storage is off
|
||||
port: number;
|
||||
requireSecureSecrets: boolean; // enforce real secrets — the host's own, and every plugin's declared `secret`
|
||||
revocationDenylist: boolean; // enable the optional instant permission/session revoke denylist
|
||||
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
|
||||
secureCookies: boolean;
|
||||
@@ -189,7 +188,6 @@ export function loadConfig(env: Env = process.env): Config {
|
||||
pluginDbSecret: resolvePluginDbSecret(env, requireSecure && Boolean(env["PLUGIN_DB_URL"])),
|
||||
pluginDbUrl: readCredentiallessUrl(env, "PLUGIN_DB_URL"),
|
||||
port: readPort(env),
|
||||
requireSecureSecrets: requireSecure,
|
||||
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or permission
|
||||
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
|
||||
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
|
||||
|
||||
@@ -284,39 +284,6 @@ test("every response carries the security headers; HSTS follows SECURE_COOKIES",
|
||||
assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/);
|
||||
});
|
||||
|
||||
test("Cache-Control: pages and JSON are private, no-store unless the route sets its own; assets are left alone", async (t) => {
|
||||
const NO_STORE = "private, no-store";
|
||||
const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? "";
|
||||
assert.match(csrf, new RegExp(`^${CSRF_COOKIE}=`));
|
||||
|
||||
// The signed-out landing is no-store as well: it and the signed-in page share one URL.
|
||||
assert.equal((await fetch(base + "/")).headers.get("cache-control"), NO_STORE);
|
||||
assert.equal((await fetch(base + "/", { headers: { cookie: csrf } })).headers.get("cache-control"), NO_STORE);
|
||||
assert.equal((await fetch(base + "/", { headers: { cookie: csrf }, method: "HEAD" })).headers.get("cache-control"), NO_STORE);
|
||||
assert.equal((await fetch(base + "/", { headers: { cookie: `${csrf}; ${session()}` } })).headers.get("cache-control"), NO_STORE);
|
||||
|
||||
assert.equal((await fetch(base + "/dashboard", { headers: { cookie: session() } })).headers.get("cache-control"), NO_STORE);
|
||||
assert.equal((await fetch(base + "/dashboard", { redirect: "manual" })).headers.get("cache-control"), NO_STORE); // sign-in bounce
|
||||
assert.equal((await fetch(base + "/nope", { headers: { cookie: csrf } })).headers.get("cache-control"), NO_STORE); // 404
|
||||
assert.equal((await fetch(base + "/public/css/styles.css")).headers.get("cache-control"), null);
|
||||
|
||||
const demo: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
home: () => ({ html: "<p>plugin home</p>" }),
|
||||
id: "demo",
|
||||
routes: [
|
||||
{ handler: () => ({ html: "open" }), method: "GET", path: "/open", public: true },
|
||||
{ handler: () => ({ headers: { "Cache-Control": "public, max-age=600" }, html: "same" }), method: "GET", path: "/same", public: true },
|
||||
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data", public: true },
|
||||
],
|
||||
};
|
||||
const url = await startApp(t, [demo]);
|
||||
for (const path of ["/demo/open", "/demo/data", "/"]) {
|
||||
assert.equal((await fetch(url + path)).headers.get("cache-control"), NO_STORE, path);
|
||||
}
|
||||
assert.equal((await fetch(url + "/demo/same")).headers.get("cache-control"), "public, max-age=600");
|
||||
});
|
||||
|
||||
// Production caches compiled templates; rendering must stay correct across repeated requests.
|
||||
test("renders correctly with template caching enabled", async () => {
|
||||
const app = createApp({ cache: true });
|
||||
@@ -642,7 +609,6 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
||||
{ handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" },
|
||||
{ handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" },
|
||||
{ handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate
|
||||
{ handler: () => ({ html: "mine" }), method: "GET", path: "/mine", session: true }, // declarative session gate
|
||||
],
|
||||
};
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] });
|
||||
@@ -676,12 +642,6 @@ test("guards map to responses: requireSession → /login, a failed can/check →
|
||||
assert.equal(gDenied.status, 403);
|
||||
assert.match(await gDenied.text(), /403/); // the rendered 403.ejs over HTTP
|
||||
assert.equal((await fetch(url + "/guarded/gated", auth(["secret:read"]))).status, 200);
|
||||
|
||||
// declarative `session` gate: anonymous → sign in, and any signed-in user through, grant or none.
|
||||
const sAnon = await fetch(url + "/guarded/mine", { redirect: "manual" });
|
||||
assert.equal(sAnon.status, 303);
|
||||
assert.equal(sAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fmine");
|
||||
assert.equal((await fetch(url + "/guarded/mine", auth([]))).status, 200);
|
||||
});
|
||||
|
||||
test("plugin hooks: onRequest can short-circuit a request and onResponse observes the handler result", async (t) => {
|
||||
|
||||
+12
-27
@@ -26,18 +26,14 @@ import { createLogger, type Log, requestLogger, runWithLog } from "../logger.ts"
|
||||
import { remintSession } from "../auth/login.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
||||
import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
|
||||
import type { PluginSettings } from "../plugin-host/settings.ts";
|
||||
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
||||
import { allows, type Gate } from "../auth/gate.ts";
|
||||
import { allowedMethods, matchRoute } from "../plugin-host/router.ts";
|
||||
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||
import { securityHeaders } from "./security-headers.ts";
|
||||
import { localPath } from "./safe-url.ts";
|
||||
import { routePublic, serveStatic } from "./static.ts";
|
||||
import { renderPluginView } from "../plugin-host/view-resolver.ts";
|
||||
|
||||
const NO_STORE = "private, no-store";
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
export interface AppOptions {
|
||||
@@ -58,7 +54,6 @@ export interface AppOptions {
|
||||
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
|
||||
publicDir?: string;
|
||||
secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http)
|
||||
settingsCatalog?: readonly PluginSettings[]; // resolved at boot (server.ts, needs the env); → ctx.declaredSettings
|
||||
viewsDir?: string;
|
||||
}
|
||||
|
||||
@@ -93,7 +88,6 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
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 permissionCatalog = declaredPermissions(plugins);
|
||||
const settingsCatalog = options.settingsCatalog ?? [];
|
||||
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
||||
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
|
||||
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
||||
@@ -159,6 +153,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
|
||||
// starter page.
|
||||
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
||||
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
|
||||
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
||||
csrf.setCookie();
|
||||
if (dashboardPlugin) {
|
||||
@@ -175,8 +170,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// routes.ts, capability-gated on the wired clients) plus the two landing slots above.
|
||||
const builtinRoutes: BuiltinRoute[] = [
|
||||
...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }),
|
||||
{ handler: serveHome, method: "GET", path: "/", public: true },
|
||||
{ handler: serveDashboard, method: "GET", path: "/dashboard", session: true },
|
||||
{ handler: serveHome, method: "GET", path: "/" },
|
||||
{ handler: serveDashboard, method: "GET", path: "/dashboard" },
|
||||
];
|
||||
|
||||
// The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every
|
||||
@@ -205,11 +200,6 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// A cache in front of us must key on the language. Set after the static branch: an asset is
|
||||
// the same bytes in every language, and a Vary there fragments its entry per raw header.
|
||||
res.setHeader("vary", "accept-language");
|
||||
// Fail closed: a cache in front of us must not keep a page that may carry a user's data. Not
|
||||
// even the signed-out `/` is shared: it and the signed-in page have one URL, and a cache keyed
|
||||
// on URL and Accept-Language would hand the signed-out one to a signed-in visitor. A route that
|
||||
// is the same for everyone sets its own value in RouteResult.headers; the host doesn't police it.
|
||||
res.setHeader("cache-control", NO_STORE);
|
||||
|
||||
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
|
||||
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
|
||||
@@ -269,9 +259,9 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
|
||||
// Base context (no route params), for the built-in routes. Every plugin-owned render — a
|
||||
// landing slot, a hook short-circuit, a plugin route — gets `contextFor(id)` instead.
|
||||
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, declaredSettings: settingsCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const ctx = buildContext(req, res, { chrome, declaredPermissions: permissionCatalog, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||
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);
|
||||
|
||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||
@@ -285,19 +275,15 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
}
|
||||
}
|
||||
|
||||
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply lacks
|
||||
// the permission gets the 403 page.
|
||||
const refuse = async (gate: Gate, gateCtx: RequestContext): Promise<void> => {
|
||||
if (!gateCtx.user) { res.writeHead(303, { location: carryLocale(loginRedirect(gateCtx)) }).end(); return; }
|
||||
reqLog.warn("forbidden: missing permission", { path: pathname, required: gate.permission ?? "", sub: gateCtx.user.id });
|
||||
sendHtml(res, 403, await renderPage("403", {}));
|
||||
};
|
||||
|
||||
const match = matchRoute(plugins, method, pathname);
|
||||
if (match) {
|
||||
const routeCtx = contextFor(match.plugin.id, match.params);
|
||||
if (!allows(match.route, routeCtx.user)) {
|
||||
await refuse(match.route, routeCtx);
|
||||
if (!isAuthorized(match.route, routeCtx.permissions)) {
|
||||
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply
|
||||
// lacks the permission gets the 403 page.
|
||||
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
||||
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
|
||||
sendHtml(res, 403, await renderPage("403", {}));
|
||||
return;
|
||||
}
|
||||
csrfMint.setCookie();
|
||||
@@ -311,7 +297,6 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
|
||||
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
||||
if (builtin) {
|
||||
if (!allows(builtin, ctx.user)) { await refuse(builtin, ctx); return; }
|
||||
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
// mint (host-only — a plugin reads the token via ctx.chrome instead). app.ts matches this table
|
||||
// after plugin routes — exact path, a GET route also answering HEAD like the plugin router — and
|
||||
// pipes the result through sendResult against the core views.
|
||||
import type { Gate } from "../auth/gate.ts";
|
||||
import type { RequestContext } from "./context.ts";
|
||||
import type { RouteResult } from "../plugin-host/plugin.ts";
|
||||
|
||||
@@ -20,7 +19,7 @@ export interface RequestCsrf {
|
||||
// own context — otherwise the plugin's keys render as bare keys on the pages it owns.
|
||||
export type PluginContextFactory = (pluginId: string) => RequestContext;
|
||||
|
||||
export interface BuiltinRoute extends Gate {
|
||||
export interface BuiltinRoute {
|
||||
// Returns a RouteResult, or null when the handler wrote to ctx.res itself
|
||||
// (the landing slots dispatch a plugin's own result against that plugin's views).
|
||||
handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import type { PageChrome } from "../ui/chrome.ts"; // type-only: no runtime import, so no cycle
|
||||
import type { PermissionDecl } from "../plugin-host/plugin.ts"; // type-only
|
||||
import type { PluginSettings } from "../plugin-host/settings.ts"; // type-only
|
||||
import type { SystemCapabilities } from "../plugin-host/system.ts"; // type-only
|
||||
import { DEFAULT_LOCALE } from "../i18n/catalog.ts";
|
||||
import { ENGLISH } from "../i18n/english.ts";
|
||||
@@ -43,9 +42,6 @@ export interface RequestContext {
|
||||
// screen offers when granting one. Pairs with `permissions` below: this is what *exists*, that is
|
||||
// what *this user holds*. Empty when no installed plugin declares any.
|
||||
declaredPermissions: readonly PermissionDecl[];
|
||||
// What each installed plugin declares it can be configured with, and how each key resolved — one
|
||||
// entry per plugin, including those declaring nothing. A secret's value is never carried here.
|
||||
declaredSettings: readonly PluginSettings[];
|
||||
params: Record<string, string>; // path params from the route match, e.g. /users/:id → { id }
|
||||
permissions: string[]; // user?.permissions ?? [] — coarse gate without a null-check
|
||||
query: URLSearchParams; // alias of url.searchParams, for ctx.query.get("q")
|
||||
@@ -71,7 +67,6 @@ export interface BuildContextOptions {
|
||||
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
||||
chrome?: () => PageChrome;
|
||||
declaredPermissions?: readonly PermissionDecl[];
|
||||
declaredSettings?: readonly PluginSettings[];
|
||||
user?: User | null;
|
||||
locale?: string;
|
||||
localeHref?: (href: string) => string;
|
||||
@@ -101,7 +96,6 @@ export function buildContext(
|
||||
return {
|
||||
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
||||
declaredPermissions: options.declaredPermissions ?? [],
|
||||
declaredSettings: options.declaredSettings ?? [],
|
||||
user,
|
||||
locale: options.locale ?? DEFAULT_LOCALE,
|
||||
localeHref: options.localeHref ?? ((href) => href),
|
||||
|
||||
@@ -100,7 +100,6 @@ const messages = {
|
||||
"filter.remove": "Remove {{label}} filter",
|
||||
"filter.reset": "Reset",
|
||||
"filter.search": "Search",
|
||||
"filter.selected": "{{label}}, {{count}} selected",
|
||||
"filter.to": "To",
|
||||
"filter.toSeparator": "to",
|
||||
|
||||
|
||||
@@ -91,7 +91,6 @@ const messages: CoreMessages = {
|
||||
"filter.remove": "Ta bort filtret {{label}}",
|
||||
"filter.reset": "Återställ",
|
||||
"filter.search": "Sök",
|
||||
"filter.selected": "{{label}}, {{count}} valda",
|
||||
"filter.to": "Till",
|
||||
"filter.toSeparator": "till",
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test, type TestContext } from "node:test";
|
||||
import { discoverPlugins } from "./discovery.ts";
|
||||
import { HOST_API_VERSION } from "./plugin.ts";
|
||||
|
||||
// Write a throwaway plugins/ tree of `relpath → source` and clean it up after the test. Fixtures
|
||||
// default-export plain objects — definePlugin is identity, so a literal is an equivalent manifest.
|
||||
@@ -20,8 +19,8 @@ function scaffold(t: TestContext, files: Record<string, string>): string {
|
||||
}
|
||||
|
||||
const full = (id: string): string =>
|
||||
`export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}", public: true }], ` +
|
||||
`routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "${id}" }) }] };`;
|
||||
`export default { apiVersion: "1.0.0", nav: [{ id: "${id}:root", label: "${id}" }], ` +
|
||||
`routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`;
|
||||
|
||||
test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => {
|
||||
assert.deepEqual(await discoverPlugins({ dir: join(tmpdir(), "pp-does-not-exist-xyz") }), []);
|
||||
@@ -31,12 +30,12 @@ test("discovers each folder's manifest, sorted, id derived from the folder name"
|
||||
const dir = scaffold(t, {
|
||||
"beta/plugin.ts": full("beta"),
|
||||
"alpha/plugin.ts": full("alpha"),
|
||||
"gamma/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", storage: true };`,
|
||||
"gamma/plugin.ts": `export default { apiVersion: "1.0.0", storage: true };`,
|
||||
});
|
||||
const plugins = await discoverPlugins({ dir });
|
||||
|
||||
assert.deepEqual(plugins.map((p) => p.id), ["alpha", "beta", "gamma"]); // deterministic order
|
||||
assert.equal(plugins[0]?.apiVersion, HOST_API_VERSION);
|
||||
assert.equal(plugins[0]?.apiVersion, "1.0.0");
|
||||
assert.equal(plugins[0]?.nav?.[0]?.label, "alpha");
|
||||
assert.equal(typeof plugins[0]?.routes?.[0]?.handler, "function"); // handlers survive import
|
||||
assert.equal(plugins[0]?.storage, undefined); // storage is opt-in, never assumed
|
||||
@@ -52,30 +51,21 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
||||
{ name: "no default export", files: { "named-only/plugin.ts": "export const x = 1;" }, match: /named-only.*default/s },
|
||||
{ name: "import throws", files: { "explodes/plugin.ts": "throw new Error('boom');" }, match: /explodes.*boom/s },
|
||||
{ name: "incompatible apiVersion", files: { "future/plugin.ts": `export default { apiVersion: "2.0.0" };` }, match: /future.*apiVersion/s },
|
||||
{ name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: "nope" };` }, match: /weird.*routes.*array/s },
|
||||
{ name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: "nope" };` }, match: /weirdhome.*home.*function/s },
|
||||
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
|
||||
{ name: "non-boolean storage", files: { "weirdstore/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", storage: "postgres://db" };` }, match: /weirdstore.*storage.*boolean/s },
|
||||
{ name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "1.0.0", routes: "nope" };` }, match: /weird.*routes.*array/s },
|
||||
{ name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "1.0.0", home: "nope" };` }, match: /weirdhome.*home.*function/s },
|
||||
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
|
||||
{ name: "non-boolean storage", files: { "weirdstore/plugin.ts": `export default { apiVersion: "1.0.0", storage: "postgres://db" };` }, match: /weirdstore.*storage.*boolean/s },
|
||||
// The folder name becomes a Postgres identifier, which truncates past 63 bytes.
|
||||
{ name: "a storage plugin whose folder name overflows a Postgres identifier", files: { [`${"a".repeat(57)}/plugin.ts`]: `export default { apiVersion: "${HOST_API_VERSION}", storage: true };` }, match: /storage.*56 characters/s },
|
||||
{ name: "a storage plugin whose folder name overflows a Postgres identifier", files: { [`${"a".repeat(57)}/plugin.ts`]: `export default { apiVersion: "1.0.0", storage: true };` }, match: /storage.*56 characters/s },
|
||||
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
|
||||
{ name: "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 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 route marked session AND permission is contradictory", files: { "contrasess/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contrasess.*session.*permission/s },
|
||||
{ name: "a route marked public AND session is contradictory", files: { "contrapub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, session: true, handler: () => ({ html: "x" }) }] };` }, match: /contrapub.*public.*session/s },
|
||||
{ name: "a route whose session flag is a truthy non-boolean is refused, not read as ungated", files: { "truthy/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: "yes", handler: () => ({ html: "x" }) }] };` }, match: /truthy.*session.*true/s },
|
||||
{ name: "a nav node whose public flag is a truthy non-boolean is refused too", files: { "truthynav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: 1 }] };` }, match: /truthynav.*public.*true/s },
|
||||
{ name: "a nav node marked session AND permission is contradictory", files: { "contrasessnav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", session: true, permission: "x:read" }] };` }, match: /contrasessnav.*session.*permission/s },
|
||||
// A gate is named, never forgotten: a route or node without one would be an open page nobody chose.
|
||||
{ name: "a route naming no gate at all is refused, not served to everyone", files: { "nogate/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", handler: () => ({ html: "x" }) }] };` }, match: /nogate.*names no gate/s },
|
||||
{ name: "a nav node naming no gate at all is refused too — a section header says `public` outright", files: { "nogatenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N" }] };` }, match: /nogatenav.*names no gate/s },
|
||||
{ name: "a gate set to false is refused — it reads as a gate but sets none", files: { "falsegate/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: false, handler: () => ({ html: "x" }) }] };` }, match: /falsegate.*public.*true/s },
|
||||
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
|
||||
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s },
|
||||
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
|
||||
// only in the admin GUI, so it holds for a plugin installed without that GUI.
|
||||
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
|
||||
{ name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s },
|
||||
{ name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s },
|
||||
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
|
||||
{ name: "a nav node gating on a bare word", files: { "barenav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", permission: "admin" }] };` }, match: /barenav.*admin.*<resource>:<action>/s },
|
||||
{ name: "a declared permission that is a bare word", files: { "baredecl/plugin.ts": `export default { apiVersion: "1.0.0", permissions: [{ name: "admin" }] };` }, match: /baredecl.*admin.*<resource>:<action>/s },
|
||||
{ name: "a plugin shipping its own copy of the barrel", files: { "shadow/node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "shadow/plugin.ts": full("shadow") }, match: /shadow.*@plainpages\/plugin-api/s },
|
||||
{ name: "a plugin package.json that forgets type: module", files: { "cjs/package.json": `{ "name": "cjs" }`, "cjs/plugin.ts": full("cjs") }, match: /cjs.*"type": "module"/s },
|
||||
{ name: "a plugin package.json that is not valid JSON", files: { "bent/package.json": `{`, "bent/plugin.ts": full("bent") }, match: /bent.*package\.json.*JSON/s },
|
||||
@@ -83,8 +73,8 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
||||
// `npm install --prefix plugins` — the documented command with one path segment dropped.
|
||||
{ name: "a package.json in the scan root itself", files: { "package.json": `{ "name": "oops" }`, "ok/plugin.ts": full("ok") }, match: /plugins\/package\.json must not exist/ },
|
||||
{ name: "a node_modules in the scan root itself", files: { "node_modules/@plainpages/plugin-api/index.js": `export class GuardError extends Error {}`, "ok/plugin.ts": full("ok") }, match: /plugins\/node_modules must not exist/ },
|
||||
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", home: () => ({ html: "b" }) };` }, match: /home/ },
|
||||
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
|
||||
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
|
||||
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
|
||||
];
|
||||
|
||||
for (const c of badCases) {
|
||||
@@ -97,7 +87,7 @@ for (const c of badCases) {
|
||||
// upgrade, not the author of the manifest — so the message has to carry the remedy, not just the
|
||||
// rule. A pre-existing `plugins/admin` gating on the old `admin` permission is exactly this case.
|
||||
test("a discovery failure tells the operator their plugins/ copy may just be out of date", async (t) => {
|
||||
const dir = scaffold(t, { "admin/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/users", permission: "admin", handler: () => ({ html: "x" }) }] };` });
|
||||
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) => {
|
||||
assert.match(err.message, /gates on "admin"/); // what is wrong
|
||||
assert.match(err.message, /re-copy it/); // …and what to do about it
|
||||
@@ -105,19 +95,12 @@ test("a discovery failure tells the operator their plugins/ copy may just be out
|
||||
});
|
||||
});
|
||||
|
||||
test("a route + nav node may be marked public, or session, and load fine", async (t) => {
|
||||
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" }) }] };`,
|
||||
"sess/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/sess", id: "s", label: "S", session: true }], routes: [{ method: "GET", path: "/", session: true, handler: () => ({ html: "x" }) }] };`,
|
||||
});
|
||||
test("a route + nav node may be marked public and load fine", async (t) => {
|
||||
const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` });
|
||||
const plugins = await discoverPlugins({ dir });
|
||||
assert.equal(plugins.length, 2);
|
||||
const pub = plugins.find((p) => p.id === "pub");
|
||||
const sess = plugins.find((p) => p.id === "sess");
|
||||
assert.equal(pub?.routes?.[0]?.public, true);
|
||||
assert.equal(pub?.nav?.[0]?.public, true);
|
||||
assert.equal(sess?.routes?.[0]?.session, true);
|
||||
assert.equal(sess?.nav?.[0]?.session, true);
|
||||
assert.equal(plugins.length, 1);
|
||||
assert.equal(plugins[0]?.routes?.[0]?.public, true);
|
||||
assert.equal(plugins[0]?.nav?.[0]?.public, true);
|
||||
});
|
||||
|
||||
test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => {
|
||||
@@ -128,7 +111,7 @@ test("`admin` is not reserved — the admin screens ship as a drop-in plugin mou
|
||||
});
|
||||
|
||||
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
|
||||
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 });
|
||||
assert.equal(plugins.length, 1);
|
||||
assert.equal(typeof plugins[0]?.home, "function");
|
||||
@@ -143,7 +126,7 @@ test("a plugin may carry its own package.json, node_modules and dependencies", a
|
||||
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`,
|
||||
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
|
||||
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
|
||||
`export default definePlugin({ apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: price(20) }) }] });`,
|
||||
`export default definePlugin({ apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`,
|
||||
});
|
||||
|
||||
const plugins = await discoverPlugins({ dir });
|
||||
@@ -170,7 +153,7 @@ test("a dangling plugin symlink fails loud rather than vanishing", async (t) =>
|
||||
});
|
||||
|
||||
test("a shared permission name only warns — both plugins still load", async (t) => {
|
||||
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 warnings: string[] = [];
|
||||
const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } });
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
import { type Gate, gatesSet } from "../auth/gate.ts";
|
||||
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
|
||||
import { settingsDeclError } from "./settings.ts";
|
||||
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts";
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
@@ -143,47 +141,46 @@ function shapeError(manifest: PluginManifest): string | null {
|
||||
}
|
||||
// A truthy non-boolean (a DSN, say) must not quietly read as "provision me one".
|
||||
if (manifest.storage !== undefined && typeof manifest.storage !== "boolean") return `"storage" must be a boolean`;
|
||||
if (manifest.settings !== undefined) {
|
||||
const settings = settingsDeclError(manifest.settings);
|
||||
if (settings) return settings;
|
||||
}
|
||||
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
|
||||
// "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
|
||||
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
||||
const gate = gateError(`route "${route?.method} ${route?.path}"`, route);
|
||||
if (gate) return gate;
|
||||
if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`;
|
||||
}
|
||||
const navContradiction = findPublicNavContradiction(manifest.nav);
|
||||
if (navContradiction) return navContradiction;
|
||||
// Every permission name the manifest mentions — gated on or declared — must be `<resource>:<action>`.
|
||||
// A bare word names a role, and roles are groups here (README → Naming a permission).
|
||||
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
||||
if (route?.permission != null && !isValidPermissionName(route.permission)) {
|
||||
return `route "${route.method} ${route.path}" gates on "${route.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
|
||||
}
|
||||
}
|
||||
const navGate = findNavGateError(manifest.nav);
|
||||
if (navGate) return navGate;
|
||||
for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) {
|
||||
if (decl?.name == null || !isValidPermissionName(decl.name)) {
|
||||
return `declared permission "${decl?.name}" is not <resource>:<action>, e.g. "things:read"`;
|
||||
}
|
||||
}
|
||||
const navPermission = findInvalidNavPermission(manifest.nav);
|
||||
if (navPermission) return navPermission;
|
||||
return null;
|
||||
}
|
||||
|
||||
// Every rule a declaration's gate must satisfy. Exactly one gate, always: a missing one would be an
|
||||
// open page nobody chose, and anything but `true` (a `false`, a `"yes"`) sets no gate while looking
|
||||
// like it does. A permission name is `<resource>:<action>` because a bare word names a role, and
|
||||
// roles are groups here (README → Naming a permission).
|
||||
function gateError(what: string, gate: Gate | null | undefined): string | null {
|
||||
for (const flag of ["public", "session"] as const) {
|
||||
const value = gate?.[flag];
|
||||
if (value !== undefined && value !== true) return `${what} sets ${flag} to ${JSON.stringify(value)}; a gate is declared with \`true\``;
|
||||
}
|
||||
const gates = gatesSet(gate);
|
||||
if (gates.length === 0) return `${what} names no gate; name exactly one — public, session or permission`;
|
||||
if (gates.length > 1) return `${what} sets ${gates.join(" and ")}; name exactly one — public, session or permission`;
|
||||
if (gate?.permission != null && !isValidPermissionName(gate.permission)) {
|
||||
return `${what} gates on "${gate.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findNavGateError(nodes: PluginManifest["nav"]): string | null {
|
||||
// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory.
|
||||
function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null {
|
||||
for (const node of Array.isArray(nodes) ? nodes : []) {
|
||||
const err = gateError(`nav node "${node?.label ?? node?.id ?? "?"}"`, node);
|
||||
if (err) return err;
|
||||
const inChild = findNavGateError(node?.children);
|
||||
if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`;
|
||||
const inChild = findPublicNavContradiction(node?.children);
|
||||
if (inChild) return inChild;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findInvalidNavPermission(nodes: PluginManifest["nav"]): string | null {
|
||||
for (const node of Array.isArray(nodes) ? nodes : []) {
|
||||
if (node?.permission != null && !isValidPermissionName(node.permission)) {
|
||||
return `nav node "${node.label ?? node.id ?? "?"}" gates on "${node.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
|
||||
}
|
||||
const inChild = findInvalidNavPermission(node?.children);
|
||||
if (inChild) return inChild;
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -8,14 +8,11 @@ export { definePlugin, isValidPermissionName } from "./plugin.ts";
|
||||
export type { BootContext, HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts";
|
||||
// A plugin's own database, handed to onBoot when the manifest sets `storage`. Credentials, not a
|
||||
// client — the plugin depends on whichever driver it prefers (README → Plugin storage).
|
||||
export type { PluginSettings, SettingDecl, SettingSummary, SettingType, SettingValue } from "./settings.ts";
|
||||
export type { StorageCredentials } from "./storage.ts";
|
||||
export type { RequestContext, User } from "../http/context.ts";
|
||||
export type { PageChrome } from "../ui/chrome.ts";
|
||||
export type { NavNode } from "../ui/nav.ts";
|
||||
export { can, check, GuardError, requireSession } from "../auth/guards.ts";
|
||||
// The three coarse gates a route or nav node may declare — `Route` and `NavNode` both extend it.
|
||||
export type { Gate } from "../auth/gate.ts";
|
||||
// Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for
|
||||
// authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator
|
||||
// in a unit test. `PluralMessage` types a message that varies with a count.
|
||||
|
||||
@@ -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", () => {
|
||||
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.0", "1.2.0").level, "warn"); // older minor still runs (additive), nudge to update
|
||||
assert.equal(checkApiVersion("1.3.0", "1.2.0").level, "refuse"); // needs features a newer host has
|
||||
assert.equal(checkApiVersion("2.0.0", "1.5.0").level, "refuse"); // incompatible major (newer)
|
||||
assert.equal(checkApiVersion("1.0.0", "2.0.0").level, "refuse"); // incompatible major (older)
|
||||
assert.equal(checkApiVersion("0.1.0", "0.1.9").level, "ok"); // pre-1.0 patch is still ignored
|
||||
assert.equal(checkApiVersion("0.1.0", "0.2.0").level, "refuse"); // pre-1.0 the minor IS the breaking slot
|
||||
assert.match(checkApiVersion("0.2.0", "0.1.0").message, /upgrade the host/); // ahead of the host, even pre-1.0
|
||||
for (const bad of ["1", "1.2", "v1.2.3", "01.2.3", "1.2.x", "", 1, undefined, null]) {
|
||||
assert.equal(checkApiVersion(bad).level, "refuse", `${String(bad)} must refuse`);
|
||||
}
|
||||
|
||||
+16
-37
@@ -4,14 +4,12 @@
|
||||
// A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount =
|
||||
// `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice.
|
||||
|
||||
import type { Gate } from "../auth/gate.ts";
|
||||
import type { RequestContext } from "../http/context.ts";
|
||||
import type { NavNode } from "../ui/nav.ts";
|
||||
import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
|
||||
import type { StorageCredentials } from "./storage.ts";
|
||||
|
||||
// The Plainpages release this contract ships in — see README → Contract versioning.
|
||||
export const HOST_API_VERSION = "0.4.0";
|
||||
// Bump major on a breaking manifest/handler change, minor on an additive one.
|
||||
export const HOST_API_VERSION = "1.0.0";
|
||||
|
||||
export type HttpMethod = "DELETE" | "GET" | "HEAD" | "PATCH" | "POST" | "PUT";
|
||||
|
||||
@@ -25,10 +23,14 @@ export type RouteResult =
|
||||
|
||||
export type RouteHandler = (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void;
|
||||
|
||||
export interface Route extends Gate {
|
||||
export interface Route {
|
||||
handler: RouteHandler;
|
||||
method: HttpMethod;
|
||||
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
||||
permission?: string; // coarse gate — the Keto Permission the caller must hold; checked before the handler runs
|
||||
// Same as omitting `permission`, but stated outright so public is a deliberate choice rather than
|
||||
// a forgotten gate. Mutually exclusive with `permission` (discovery refuses both).
|
||||
public?: boolean;
|
||||
}
|
||||
|
||||
// A Keto Permission this plugin gates on — declared for docs/seeding. Names are a shared global
|
||||
@@ -60,39 +62,30 @@ export function declaredPermissions(plugins: Plugin[]): PermissionDecl[] {
|
||||
}
|
||||
|
||||
// What onBoot receives. A hook declaring no parameter stays valid, so this may grow additively.
|
||||
export type BootContext<S extends readonly SettingDecl[] = readonly SettingDecl[]> = {
|
||||
export interface BootContext {
|
||||
storage?: StorageCredentials; // this plugin's own database; present iff the manifest declared `storage`
|
||||
} & SettingsSlot<S>;
|
||||
|
||||
// Required once the manifest declares settings, so that plugin reads `settings.key` without a guard
|
||||
// for the case it just ruled out; optional for a manifest that declared none.
|
||||
type SettingsSlot<S extends readonly SettingDecl[]> = readonly [] extends S
|
||||
? { settings?: SettingsOf<S> }
|
||||
: { settings: SettingsOf<S> };
|
||||
}
|
||||
|
||||
// Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud.
|
||||
export interface PluginHooks<S extends readonly SettingDecl[] = readonly SettingDecl[]> {
|
||||
onBoot?: (host: BootContext<S>) => Promise<void> | void; // after discovery, before the server listens
|
||||
export interface PluginHooks {
|
||||
onBoot?: (host: BootContext) => Promise<void> | void; // after discovery, before the server listens
|
||||
onRequest?: (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void; // may short-circuit
|
||||
onResponse?: (ctx: RequestContext, result: RouteResult | null) => Promise<void> | void;
|
||||
}
|
||||
|
||||
// The authored manifest — a plugin's `plugin.ts` default-exports this. No `id`/mount path: the
|
||||
// host derives them from the folder name at discovery (see Plugin).
|
||||
export interface PluginManifest<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)
|
||||
// Take over "/dashboard"; the host gates it to a signed-in session first. At most one plugin may
|
||||
// declare it (findConflicts → error, never last-write-wins).
|
||||
dashboard?: RouteHandler;
|
||||
// Take over the ungated public landing "/". At most one plugin may declare it.
|
||||
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
|
||||
permissions?: PermissionDecl[];
|
||||
routes?: Route[];
|
||||
// Operator-supplied configuration, one PLUGIN_SETTING_<ID>_<KEY> variable per key; the resolved
|
||||
// values arrive on onBoot's BootContext, typed from these declarations (settings.ts).
|
||||
settings?: S;
|
||||
// Ask for a Postgres database of this plugin's own; its credentials arrive on onBoot's BootContext.
|
||||
// The host provisions and locks it down but owns no schema inside it, and never drops it.
|
||||
storage?: boolean;
|
||||
@@ -106,9 +99,7 @@ export interface Plugin extends PluginManifest {
|
||||
|
||||
// Types the manifest and returns it unchanged; validation happens at discovery, so a plugin may
|
||||
// equally be a plain typed object.
|
||||
// The `const` parameter captures the literal `settings`, so onBoot receives each key at its declared
|
||||
// type instead of a union every plugin author would have to narrow with a cast.
|
||||
export function definePlugin<const S extends readonly SettingDecl[]>(manifest: PluginManifest<S>): PluginManifest<S> {
|
||||
export function definePlugin(manifest: PluginManifest): PluginManifest {
|
||||
return manifest;
|
||||
}
|
||||
|
||||
@@ -167,17 +158,13 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
|
||||
return { level: "refuse", message: `plugin targets apiVersion ${pluginVersion} but host is ${hostVersion}; upgrade the host` };
|
||||
}
|
||||
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.
|
||||
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: "warn", message: `plugin targets apiVersion ${pluginVersion}; host is ${hostVersion} — newer features available` };
|
||||
}
|
||||
return { level: "ok", message: `apiVersion ${pluginVersion}` };
|
||||
}
|
||||
|
||||
export interface PluginConflict {
|
||||
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route" | "setting";
|
||||
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route";
|
||||
level: "error" | "warn";
|
||||
message: string;
|
||||
plugins: string[]; // unique ids involved
|
||||
@@ -218,14 +205,6 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "<resource>" unless shared on purpose`, plugins: uniq(owners) });
|
||||
});
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { Plugin, Route } from "./plugin.ts";
|
||||
import { allowedMethods, matchRoute } from "./router.ts";
|
||||
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
|
||||
|
||||
const noop: Route["handler"] = () => ({ html: "x" });
|
||||
|
||||
@@ -54,3 +54,14 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the
|
||||
assert.deepEqual(allowedMethods(plugins, "/x/a"), ["GET", "HEAD", "POST"]);
|
||||
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
|
||||
});
|
||||
|
||||
test("isAuthorized: open routes pass; gated routes require the permission token; public is explicitly open", () => {
|
||||
const open: Route = { handler: noop, method: "GET", path: "/" };
|
||||
const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" };
|
||||
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias
|
||||
assert.equal(isAuthorized(open, []), true);
|
||||
assert.equal(isAuthorized(gated, []), false);
|
||||
assert.equal(isAuthorized(gated, ["x:read"]), true);
|
||||
assert.equal(isAuthorized(gated, ["other"]), false);
|
||||
assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting permission — but stated outright
|
||||
});
|
||||
|
||||
@@ -73,3 +73,10 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
|
||||
}
|
||||
return [...methods].sort();
|
||||
}
|
||||
|
||||
// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise
|
||||
// the user's permissions (from the session JWT) must include the token. The same rule composeNav uses
|
||||
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
|
||||
export function isAuthorized(route: Route, permissions: string[]): boolean {
|
||||
return route.public === true || route.permission == null || permissions.includes(route.permission);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
+6
-23
@@ -2,7 +2,6 @@ import { createApp } from "./http/app.ts";
|
||||
import { loadConfig } from "./config.ts";
|
||||
import { createDenylist } from "./auth/denylist.ts";
|
||||
import { discoverPlugins } from "./plugin-host/discovery.ts";
|
||||
import { HOST_API_VERSION } from "./plugin-host/plugin.ts";
|
||||
import { withTimeout } from "./auth/fetch-timeout.ts";
|
||||
import { runBootHooks } from "./plugin-host/hooks.ts";
|
||||
import { createHydraAdmin } from "./auth/hydra-admin.ts";
|
||||
@@ -14,7 +13,6 @@ import { createKratosAdmin } from "./auth/kratos-admin.ts";
|
||||
import { createKratosPublic } from "./auth/kratos-public.ts";
|
||||
import { createLogger, tracedFetch } from "./logger.ts";
|
||||
import { loadMenuConfig } from "./ui/menu-config.ts";
|
||||
import { resolveSettings, settingsEnvNames, strayNames } from "./plugin-host/settings.ts";
|
||||
import { buildCredentials, storagePluginIds, type StorageCredentials } from "./plugin-host/storage.ts";
|
||||
|
||||
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
|
||||
@@ -63,29 +61,15 @@ 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(", ") });
|
||||
}
|
||||
// onBoot is the only way credentials are handed over, so without one the database is provisioned
|
||||
// and unreachable. A warning, not a refusal — the plugin still works, it just cannot store anything.
|
||||
const unreachable = plugins.filter((plugin) => plugin.storage && !plugin.hooks?.onBoot).map((plugin) => plugin.id);
|
||||
if (unreachable.length > 0) log.warn("plugins declare storage but have no onBoot to receive it", { plugins: unreachable.join(", ") });
|
||||
|
||||
// plugin onBoot — after discovery, before listen; a throw aborts boot.
|
||||
await runBootHooks(plugins, (plugin) => {
|
||||
const storage = storageCredentials.get(plugin.id);
|
||||
const values = settings.values.get(plugin.id);
|
||||
return { ...(values ? { settings: values } : {}), ...(storage ? { storage } : {}) };
|
||||
return storage ? { storage } : {};
|
||||
});
|
||||
|
||||
const server = createApp({
|
||||
@@ -106,9 +90,8 @@ const server = createApp({
|
||||
menu,
|
||||
plugins,
|
||||
secureCookies: config.secureCookies,
|
||||
settingsCatalog: settings.catalog,
|
||||
}).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
|
||||
|
||||
+5
-3
@@ -10,7 +10,7 @@ import { composeNav, type NavNode } from "./nav.ts";
|
||||
import type { Plugin } from "../plugin-host/plugin.ts";
|
||||
import { branding, shellUser, type ShellUser } from "./shell-context.ts";
|
||||
|
||||
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard", session: true };
|
||||
const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "nav.dashboard" };
|
||||
|
||||
export interface PageChrome {
|
||||
brand: { logo?: string; name: string; sub?: string };
|
||||
@@ -35,7 +35,8 @@ export interface ChromeOptions {
|
||||
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
const t = opts.t ?? ENGLISH;
|
||||
const carryLocale = opts.localeHref ?? ((href: string) => href);
|
||||
const fragments: NavNode[][] = [[DASHBOARD_NAV]];
|
||||
// Dashboard is gated, so an anonymous click would only dead-end at /login.
|
||||
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
|
||||
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
|
||||
// translator before merging. composeNav then runs the core one over the result; already-translated
|
||||
// text passes through it.
|
||||
@@ -43,7 +44,8 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
|
||||
}
|
||||
|
||||
const nav = composeNav(fragments, opts.menu.override, opts.user ?? null, t);
|
||||
const permissions = opts.user?.permissions ?? [];
|
||||
const nav = composeNav(fragments, opts.menu.override, permissions, t);
|
||||
if (opts.currentPath) {
|
||||
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
|
||||
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
|
||||
|
||||
@@ -37,15 +37,6 @@ const config = {
|
||||
options: [{ value: "engineering", label: "Engineering" }, { value: "design", label: "Design" }, { value: "oncall", label: "On-call" }],
|
||||
},
|
||||
{ type: "daterange", legend: "Joined", from: { name: "joined_from", value: "2026-01-01", label: "Joined from" }, to: { name: "joined_to", value: "2026-06-14", label: "Joined to" } },
|
||||
{
|
||||
type: "multiselect",
|
||||
name: "owner",
|
||||
legend: "Owner",
|
||||
note: "30 of 45",
|
||||
value: ["ann"],
|
||||
options: [{ value: "ann", label: "Ann Berg" }, { value: "bo", label: "Bo Falk" }],
|
||||
},
|
||||
{ type: "multiselect", name: "room", legend: "Room", options: [{ value: "lab", label: "Lab" }] },
|
||||
],
|
||||
],
|
||||
pills: [{ label: "Team", value: "Engineering", remove: "?tag=oncall" }],
|
||||
@@ -73,14 +64,6 @@ test("filter-bar renders a GET form with every control type, reflecting current
|
||||
assert.match(html, /<input type="checkbox" name="tag" value="oncall" checked>On-call/);
|
||||
assert.match(html, /<input type="checkbox" name="tag" value="design">Design/);
|
||||
|
||||
// multiselect — a disclosure: the trigger names the filter and counts what is chosen, the options
|
||||
// are checkboxes in a fieldset the popover holds, so a long list costs one line of the bar.
|
||||
assert.match(html, /<div class="menu"><button class="btn btn-menu" type="button" popovertarget="f-owner-menu" aria-label="Owner, 1 selected">Owner<span class="badge">1<\/span><\/button><div id="f-owner-menu" class="menu-pop left" popover>/);
|
||||
assert.match(html, /popover><div class="menu-head">30 of 45<\/div><fieldset class="menu-field"><legend class="menu-head">Owner<\/legend><label class="menu-check"><input type="checkbox" name="owner" value="ann" checked>Ann Berg<\/label><label class="menu-check"><input type="checkbox" name="owner" value="bo">Bo Falk<\/label><\/fieldset>/);
|
||||
|
||||
// Nothing chosen — no badge and no count in the accessible name.
|
||||
assert.match(html, /<button class="btn btn-menu" type="button" popovertarget="f-room-menu">Room<\/button>/);
|
||||
|
||||
// daterange — calendar icon + two date inputs with values.
|
||||
assert.match(html, /<div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"\s*\/?><\/svg>.*?<input type="date" id="f-joined_from" name="joined_from" value="2026-01-01">.*?<span class="to" aria-hidden="true">to<\/span>.*?<input type="date" id="f-joined_to" name="joined_to" value="2026-06-14">/);
|
||||
|
||||
|
||||
+2
-2
@@ -12,7 +12,7 @@ const flat = (s: string): string => s.replace(/>\s+</g, "><").replace(/\s+/g, "
|
||||
test("menu renders trigger, positioning, the item matrix and check groups", async () => {
|
||||
const html = flat(await render({
|
||||
id: "cols-menu",
|
||||
trigger: { count: 3, icon: "i-cols", text: "Columns", label: "Column settings" },
|
||||
trigger: { icon: "i-cols", text: "Columns", label: "Column settings" },
|
||||
align: "left", up: true, width: 240,
|
||||
items: [
|
||||
{ head: "Actions" },
|
||||
@@ -30,7 +30,7 @@ test("menu renders trigger, positioning, the item matrix and check groups", asyn
|
||||
|
||||
// Trigger: icon + text + aria-label, wired to the panel by id; popover carries align/up + width.
|
||||
// The panel is the trigger's next sibling inside the wrapper — the CSS open state reads that.
|
||||
assert.match(html, /<div class="menu"><button class="btn" type="button" popovertarget="cols-menu" aria-label="Column settings"><svg class="ico ico-sm"><use href="#i-cols"\s*\/?><\/svg>Columns<span class="badge">3<\/span><\/button><div id="cols-menu" class="menu-pop left up" popover style="min-width:240px">/);
|
||||
assert.match(html, /<div class="menu"><button class="btn" type="button" popovertarget="cols-menu" aria-label="Column settings"><svg class="ico ico-sm"><use href="#i-cols"\s*\/?><\/svg>Columns<\/button><div id="cols-menu" class="menu-pop left up" popover style="min-width:240px">/);
|
||||
|
||||
// Item matrix: head, button-with-icon, link, separator, danger button.
|
||||
assert.match(html, /<div class="menu-head">Actions<\/div>/);
|
||||
|
||||
+9
-19
@@ -1,12 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import type { User } from "../http/context.ts";
|
||||
import { composeNav, type NavNode } from "./nav.ts";
|
||||
|
||||
function viewer(...permissions: string[]): User {
|
||||
return { email: "viewer@example.test", id: "01a06091-ba9f-765f-abf4-b5144c314bc7", permissions };
|
||||
}
|
||||
|
||||
// Two plugin fragments; ids let the override target nodes, `permission` gates per permission.
|
||||
const fragments: NavNode[][] = [
|
||||
[{
|
||||
@@ -20,7 +15,7 @@ const fragments: NavNode[][] = [
|
||||
];
|
||||
|
||||
test("composeNav merges fragments, filters by permission, and emits clean render nodes", () => {
|
||||
const tree = composeNav(fragments, {}, viewer("scheduling:read"));
|
||||
const tree = composeNav(fragments, {}, ["scheduling:read"]);
|
||||
|
||||
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
|
||||
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
|
||||
@@ -35,7 +30,7 @@ test("composeNav drops gated subtrees, empty headers, and (with no permissions)
|
||||
{ id: "admin", label: "Admin", permission: "users:read", children: [{ href: "/u", id: "u", label: "Users" }] },
|
||||
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
|
||||
]];
|
||||
assert.deepEqual(composeNav(gatedHeader, {}, viewer()), [
|
||||
assert.deepEqual(composeNav(gatedHeader, {}, []), [
|
||||
{ label: "Free", children: [{ href: "/d", label: "Docs" }] },
|
||||
]);
|
||||
|
||||
@@ -44,31 +39,26 @@ test("composeNav drops gated subtrees, empty headers, and (with no permissions)
|
||||
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x:read" }] },
|
||||
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y:read" }] },
|
||||
]];
|
||||
assert.deepEqual(composeNav(emptyHeader, {}, viewer()), [{ href: "/hub", label: "Hub" }]);
|
||||
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
|
||||
|
||||
// No fragments / no permissions → empty tree, never throws.
|
||||
assert.deepEqual(composeNav(), []);
|
||||
});
|
||||
|
||||
test("composeNav shows a public node to everyone and a session node to any signed-in user", () => {
|
||||
// A header with a public child, a session child and a gated child: the public child keeps the
|
||||
// header alive for an anonymous visitor — so a plugin can show a menu option to all.
|
||||
test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
|
||||
// A header with one public child + one gated child: with no permissions, the public child keeps the
|
||||
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
|
||||
const frag: NavNode[][] = [[{
|
||||
icon: "i-cal", id: "sched", label: "Scheduling",
|
||||
children: [
|
||||
{ href: "/scheduling", id: "overview", label: "Overview", public: true },
|
||||
{ href: "/scheduling/mine", id: "mine", label: "Mine", session: true },
|
||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
|
||||
],
|
||||
}]];
|
||||
// `public`/`session` are filter-only (like id/permission) — never rendered into the output node.
|
||||
assert.deepEqual(composeNav(frag, {}, null), [
|
||||
// `public` is filter-only (like id/permission) — never rendered into the output node.
|
||||
assert.deepEqual(composeNav(frag, {}, []), [
|
||||
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
|
||||
]);
|
||||
// Signed in with no permission at all: the session node appears, the permission-gated one does not.
|
||||
assert.deepEqual(composeNav(frag, {}, viewer()), [
|
||||
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }, { href: "/scheduling/mine", label: "Mine" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("composeNav applies the override: rename, group, order, hide (then filters)", () => {
|
||||
@@ -84,7 +74,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
|
||||
groups: [{ icon: "i-box", id: "grp", label: "Group", open: true, children: ["b", "c"] }], // wrap b+c
|
||||
order: ["grp", "a"], // grp before the lone a
|
||||
hide: ["c"], // remove c from inside the group
|
||||
}, viewer("secrets:read"));
|
||||
}, ["secrets:read"]);
|
||||
|
||||
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "secrets:read" is present.
|
||||
assert.deepEqual(tree, [
|
||||
|
||||
+13
-13
@@ -1,14 +1,12 @@
|
||||
// composeNav: merge each plugin's nav fragment into one tree, apply the central override, then
|
||||
// filter per user. Pure and I/O-free — menu gating reads the JWT `permissions` claim, never Keto.
|
||||
// A node is visible iff `allows` passes its gate; a gated header hides its whole subtree, and a pure
|
||||
// header left with no children is dropped.
|
||||
// permission-filter per user. Pure and I/O-free — menu gating reads the JWT `permissions` claim,
|
||||
// never Keto. A node is visible iff it is `public`, declares no `permission`, or the user holds that
|
||||
// name; a gated header hides its whole subtree, and a pure header left with no children is dropped.
|
||||
|
||||
import { allows, type Gate } from "../auth/gate.ts";
|
||||
import type { User } from "../http/context.ts";
|
||||
import { ENGLISH } from "../i18n/english.ts";
|
||||
import type { Translate } from "../i18n/translate.ts";
|
||||
|
||||
export interface NavNode extends Gate {
|
||||
export interface NavNode {
|
||||
id?: string; // stable key for override targeting; stripped from the rendered tree
|
||||
children?: NavNode[];
|
||||
count?: number;
|
||||
@@ -17,10 +15,12 @@ export interface NavNode extends Gate {
|
||||
icon?: string;
|
||||
label: string;
|
||||
open?: boolean;
|
||||
permission?: string; // required permission token; consumed by the filter, never rendered
|
||||
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both).
|
||||
}
|
||||
|
||||
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
|
||||
// order → hide, then the per-user gate filter runs last.
|
||||
// order → hide, then the per-user permission filter runs last.
|
||||
export interface NavOverride {
|
||||
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
|
||||
hide?: string[]; // remove nodes by id, at any depth (incl. a group's id)
|
||||
@@ -39,7 +39,7 @@ export interface NavGroupSpec {
|
||||
export function composeNav(
|
||||
fragments: NavNode[][] = [],
|
||||
override: NavOverride = {},
|
||||
user: User | null = null,
|
||||
permissions: string[] = [],
|
||||
t: Translate = ENGLISH,
|
||||
): NavNode[] {
|
||||
let nodes: NavNode[] = fragments.flat();
|
||||
@@ -47,7 +47,7 @@ export function composeNav(
|
||||
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
|
||||
if (override.order?.length) nodes = applyOrder(nodes, override.order);
|
||||
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
|
||||
return filterByGate(nodes, user).map((node) => toRenderNode(node, t));
|
||||
return filterByRoles(nodes, new Set(permissions)).map((node) => toRenderNode(node, t));
|
||||
}
|
||||
|
||||
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
|
||||
@@ -104,19 +104,19 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
function filterByGate(nodes: NavNode[], user: User | null): NavNode[] {
|
||||
function filterByRoles(nodes: NavNode[], permissions: Set<string>): NavNode[] {
|
||||
const out: NavNode[] = [];
|
||||
for (const n of nodes) {
|
||||
if (!allows(n, user)) continue; // gated → drop node + subtree
|
||||
if (n.public !== true && n.permission != null && !permissions.has(n.permission)) continue; // gated → drop node + subtree (public always shows)
|
||||
if (!n.children) { out.push(n); continue; }
|
||||
const children = filterByGate(n.children, user);
|
||||
const children = filterByRoles(n.children, permissions);
|
||||
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
|
||||
out.push({ ...n, children });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Strip the helper-only fields (id and the gate) and drop absent ones, so the tree is exactly
|
||||
// Strip the helper-only fields (id/permission) and drop absent ones, so the tree is exactly
|
||||
// what nav-tree.ejs reads. Labels (a manifest's, or the central override's rename) pass through
|
||||
// `t` on the way out: a label that names a catalog key is translated, any other renders as written.
|
||||
function toRenderNode(n: NavNode, t: Translate): NavNode {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const css = readFileSync(join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public", "css", "styles.css"), "utf8");
|
||||
|
||||
const token = (name: string): string => {
|
||||
const hex = new RegExp(`--${name}:\\s*(#[0-9a-f]{6})\\b`, "i").exec(css)?.[1];
|
||||
assert.ok(hex, `--${name} is not a six-digit hex colour`);
|
||||
return hex;
|
||||
};
|
||||
|
||||
const luminance = (hex: string): number => {
|
||||
const [r = 0, g = 0, b = 0] = [1, 3, 5].map((i) => {
|
||||
const c = Number.parseInt(hex.slice(i, i + 2), 16) / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
};
|
||||
|
||||
const contrast = (a: string, b: string): number => {
|
||||
const [hi = 0, lo = 0] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi + 0.05) / (lo + 0.05);
|
||||
};
|
||||
|
||||
// WCAG AA for normal text.
|
||||
for (const theme of ["l", "d"]) {
|
||||
for (const tone of ["pos", "neg", "warn", "info"]) {
|
||||
test(`${tone} badge text reaches 4.5:1 in ${theme === "l" ? "light" : "dark"} mode`, () => {
|
||||
assert.ok(contrast(token(`${theme}-${tone}`), token(`${theme}-${tone}-bg`)) >= 4.5);
|
||||
});
|
||||
}
|
||||
test(`text on the accent reaches 4.5:1 in ${theme === "l" ? "light" : "dark"} mode`, () => {
|
||||
assert.ok(contrast(token(`${theme}-on-accent`), token(`${theme}-accent`)) >= 4.5);
|
||||
});
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { test } from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const cssDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "public", "css");
|
||||
|
||||
// A custom property holding oklch() passes unchecked on an engine below Chromium 111, so the
|
||||
// var() that reads it is invalid at computed-value time: white text on no background.
|
||||
for (const file of readdirSync(cssDir).filter((f) => f.endsWith(".css"))) {
|
||||
test(`${file} declares no oklch() colour`, () => {
|
||||
assert.ok(!/oklch\(/i.test(readFileSync(join(cssDir, file), "utf8")));
|
||||
});
|
||||
}
|
||||
|
||||
const stylesheet = readFileSync(join(cssDir, "styles.css"), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
|
||||
// The index of the `}` closing the block whose `{` is the first one at or after `start`.
|
||||
function blockEnd(start: number): number {
|
||||
let depth = 0;
|
||||
for (let i = stylesheet.indexOf("{", start); i < stylesheet.length; i++) {
|
||||
if (stylesheet[i] === "{") depth++;
|
||||
if (stylesheet[i] === "}" && --depth === 0) return i;
|
||||
}
|
||||
throw new Error("unbalanced braces in styles.css");
|
||||
}
|
||||
|
||||
// The text is all there is to check: Playwright's engines all support `:has()`, so no E2E sees the
|
||||
// stylesheet an older engine gets.
|
||||
// Chromium below 105 and Firefox below 121 have no `:has()`, and nothing else reveals a folded
|
||||
// branch there, so the fold may only exist where `:has()` works.
|
||||
test("styles.css folds .nav-children only inside @supports selector(:has(a))", () => {
|
||||
const open = stylesheet.indexOf("@supports selector(:has(a)) {");
|
||||
assert.ok(open >= 0, "no @supports selector(:has(a)) block");
|
||||
const close = blockEnd(open);
|
||||
const outside = stylesheet.slice(0, open) + stylesheet.slice(close + 1);
|
||||
assert.ok(!/\.nav-children\s*\{[^}]*display:\s*none/.test(outside), "a .nav-children rule hides it outside the guard");
|
||||
assert.match(stylesheet.slice(open, close), /\.nav-children\s*\{\s*display:\s*none/, "the fold rule is gone");
|
||||
});
|
||||
|
||||
// `@supports` adds no specificity, so the hiding rule has to come after the base `.theme-switch` rule.
|
||||
test("styles.css hides the theme switch and the nav toggles where :has() is missing, after their base rules", () => {
|
||||
const guard = stylesheet.indexOf("@supports not selector(:has(a)) {");
|
||||
assert.ok(guard >= 0, "no @supports not selector(:has(a)) block");
|
||||
const base = stylesheet.search(/^\.theme-switch\s*\{/m);
|
||||
assert.ok(base >= 0 && base < guard, "the guard precedes the base .theme-switch rule");
|
||||
const block = stylesheet.slice(guard, stylesheet.indexOf("\n}", guard));
|
||||
assert.match(block, /\.theme-switch\s*\{\s*display:\s*none/);
|
||||
assert.match(block, /\.nav-disc\s*\{\s*visibility:\s*hidden/);
|
||||
});
|
||||
|
||||
// Without `:has()` nothing reads #nav-toggle, so an off-canvas sidebar there is unreachable and the
|
||||
// hamburger does nothing: the sidebar has to stay in the flow, with the hamburger hidden.
|
||||
test("styles.css moves the sidebar off-canvas at 860px only inside @supports selector(:has(a))", () => {
|
||||
const open = stylesheet.indexOf("@media (max-width: 860px) {");
|
||||
assert.ok(open >= 0, "no @media (max-width: 860px) block");
|
||||
const close = blockEnd(open);
|
||||
const media = stylesheet.slice(open, close);
|
||||
const guard = media.indexOf("@supports selector(:has(a)) {");
|
||||
assert.ok(guard >= 0, "no @supports selector(:has(a)) block inside the 860px media block");
|
||||
const guardEnd = blockEnd(open + guard) - open;
|
||||
const outside = media.slice(0, guard) + media.slice(guardEnd + 1);
|
||||
const inside = media.slice(guard, guardEnd);
|
||||
for (const offCanvas of [/translateX\(-100%\)/, /position:\s*fixed/, /#nav-toggle/, /\.hamburger/]) {
|
||||
assert.doesNotMatch(outside, offCanvas, `${offCanvas} applies outside the guard`);
|
||||
assert.match(inside, offCanvas, `${offCanvas} is gone from the guard`);
|
||||
}
|
||||
});
|
||||
@@ -1,203 +1,72 @@
|
||||
# todo
|
||||
# Primary todo
|
||||
|
||||
## Scoring
|
||||
## Unfinnished work
|
||||
|
||||
`Score = 100 * V / (2**S + 8)`
|
||||
- [ ] 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".
|
||||
- [ ] 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.
|
||||
- [ ] A grant whose plugin was uninstalled is invisible and unremovable in the GUI. `grantDiff` deliberately never revokes an undeclared name, but nothing *shows* it either — so it can't be audited or cleaned, and reinstalling that plugin silently reactivates access nobody remembers granting. Sketch: a read-only "held, but no installed plugin offers this" list with a remove action.
|
||||
- [ ] A plugin may gate a route on a permission it never declares — declaring stays optional on purpose. The cost is a dead end: the picker is built from declarations only, so that route is ungrantable from the GUI with no boot error and a permanent 403 as the operator's only clue. Sketch: a discovery *warning* (not an error) naming the gated-but-undeclared permission.
|
||||
- [ ] Saving permissions gives no confirmation, and a partial failure is silent. `applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a half-applied set behind the generic error page; and a successful save is indistinguishable from "nothing changed". The `alert alert-pos` pattern the recovery-code banner uses is already available.
|
||||
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
|
||||
- [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks its most logic-bearing file (`console-guard.ts`). Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither.
|
||||
- [ ] Decide whether Playwright's `workers` should be pinned. Unset, it sizes the pool from `os.cpus()`, which reports the host's cores regardless of a container CPU quota — and with `retries: 0` a starved runner turns a slow test straight into a red gate. Fine on the current act_runner; revisit if CI ever runs constrained.
|
||||
- [ ] Record the browser floor Plainpages requires, and whether the fallback is the contract or a courtesy. The stylesheet needs `:has()` (Dec 2023); the menus need the popover API (Safari 17) and CSS anchor positioning for placement (newer still, and unguarded — the `@supports` test covers popover only). An iPadOS 16 tablet, capped at Safari 16, therefore gets panels flowing inline rather than working menus. Either state a supported floor or accept the fallback for those devices; nobody has rendered that path on real hardware.
|
||||
- [ ] Decide whether the profile dropdown still earns a dropdown. It holds one item, Sign out, behind a click, and its "Signed in as X" head repeats what the trigger already shows.
|
||||
- [ ] Trim whitespace around the verification code — a copy+pasted code from the email fails, today as a browser `pattern` refusal rather than a Kratos rejection, now carrying a digits-only hint so that refusal isn't bare. The paste still fails. **Own session.** No zero-JS fix exists: `pattern` is rejective and cannot transform, so loosening it only lets the untrimmed value through. The real fix is a host-side proxy of the flow POST, which is feasible and half-built — `submitFlow` (`src/auth/kratos-public.ts:132`) already relays cookies and normalises a 422 `redirect_browser_to`, and has no callers outside tests. CSRF improves rather than breaks: the host already writes Kratos' cookie onto its own origin (`src/auth/routes.ts:77`), so the POST becomes same-origin, and `form-action 'self'` could finally join the CSP. Two risks: one `flow.ui.action` serves all five flows, so this rewrites the sign-in path, not just code entry; and Go's nosurf checks Referer only over https, which no test here covers. **Check first:** `kratos-admin.ts:75` returns a `recovery_link` alongside the code — if the stock courier mail carries one (unverified), a template change fixes the UX with no host code. Code entry has no E2E coverage today either way.
|
||||
- [ ] Guard against the double-clicked submit, without client-side JavaScript. A non-technical user clicks a button twice when nothing happens fast enough, so a second identical POST is an expected event, not misuse — today it creates two users, mints two recovery codes, or registers two OAuth2 clients. Constraints: HTML/CSS only, and it must not break an action that is *legitimately* repeatable. Sketch: a CSS-only affordance so the second click has nothing to hit, paired with the host recognising a duplicate on the server — same session, route and payload within a short window — then logging and dropping it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form beats hashing the payload, and the CSRF plumbing already mints per-request tokens), the window length, where the record lives given the app is stateless, and how a plugin declares a route repeatable.
|
||||
- [ ] Decide the caching contract for rendered pages. Responses carry `Vary: Accept-Language` but nothing sets `Cache-Control`, so a shared cache has no instruction and a signed-in page is not marked `private`. Either set the headers deliberately (public cacheable, gated `private, no-store`) or record in AGENTS.md that the reverse proxy owns this.
|
||||
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule applied to namespaces. A design question, not a naming one.
|
||||
- [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression.
|
||||
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md and README → Security model; not accepted ⇒ bind the nonce to `sub`.
|
||||
- [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host**, but nothing here has been run on one. Two suspects, both from the `--user "$(id -u):$(id -g)"` idiom: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer. The same question covers rootless Docker, where README already says to *drop* the flag.
|
||||
|
||||
`Bar = 1`
|
||||
### Architectural review findings (2026-07-02)
|
||||
|
||||
`Next ID = 39`
|
||||
Prioritized. Overall verdict: architecture is sound; these are refinements.
|
||||
|
||||
## Items
|
||||
- [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth.
|
||||
- [ ] **LOW — The users list offers a pencil "Edit" row action to a `users:read` holder.** The link is harmless (it opens the read-only detail page), but the label contradicts what the reader can do. Needs `canWrite` threaded into `listTable` plus a `common.view` core catalog key and an `i-eye` entry in `ICON_NAMES`.
|
||||
- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block.
|
||||
- [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive.
|
||||
- [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`.
|
||||
- [ ] **LOW — Decide (once) on a `ctx.system` facade.** `@plainpages/plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear.
|
||||
- [ ] **LOW — README/AGENTS.md gaps:** state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population and plugin count — per-plugin dependency isolation prices N dependency trees on disk and in RSS, and that number is what says whether the trade needs revisiting (build-time dedupe for baked images stays open).
|
||||
|
||||
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 26 | 0.4.21 | | | **Add a domain glossary and the project's intended horizon to the README.** | 1 | 2 | 1 | 2 | 3 | 9 by 3, code health by 2 | 11.9 |
|
||||
| 8 | 0.4.21 | | | **Run the visual E2E specs at viewports from a large desktop down to a small phone.** | 2 | 1 | 1 | 2 | 4 | code health by 3 | 9.0 |
|
||||
| 9 | 0.4.21 | | | **Typecheck `e2e-tests/` in the gate.** | 1 | 1 | 1 | 1 | 3 | code health by 2 | 9.0 |
|
||||
| 24 | 0.4.21 | | | **Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.** | 2 | 1 | 2 | 3 | 4 | code health by 3 | 9.0 |
|
||||
| 17 | 0.4.21 | | | **Remove the icons no caller uses from `ICON_NAMES` and the sprite.** | 1 | 1 | 1 | 1 | 1 | 8 by 1, code health by 1 | 8.8 |
|
||||
| 21 | 0.4.21 | | | **Fail the gate on a function that exceeds a size or complexity limit.** | 3 | 1 | 1 | 3 | 5 | code health by 4 | 7.2 |
|
||||
| 38 | 0.4.21 | | | **Correct the TOKENS comment at the top of `public/css/styles.css` so it no longer claims `:has()` swaps are "rock-solid" or that the theme follows the OS on every engine.** | 1 | 2 | 1 | 2 | 1 | code health by 1 | 7.2 |
|
||||
| 32 | 0.4.21 | | | **Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.** | 3 | 1 | 1 | 3 | 4 | code health by 2 | 6.0 |
|
||||
| 20 | 0.4.21 | | | **Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.** | 2 | 2 | 2 | 4 | 3 | 2 by 4 | 4.2 |
|
||||
| 4 | 0.4.21 | | | **Refuse a permission save whose baseline no longer matches Keto, so concurrent edits are not silently lost.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 |
|
||||
| 7 | 0.4.21 | | | **Confirm a successful permission save, and report a partial failure instead of hiding it.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 |
|
||||
| 22 | 0.4.21 | | | **Offer a `users:read` holder a View row action on the users list, not Edit.** | 2 | 2 | 1 | 3 | 3 | 2 by 2 | 2.1 |
|
||||
| 34 | 0.4.21 | | | **Serve every stylesheet and script under a content-versioned URL with a year-long immutable `Cache-Control`, so a page view downloads none of them again.** | 4 | 2 | 3 | 7 | 6 | 8 by 6, 2 by 2 | 1.8 |
|
||||
| 3 | 0.4.21 | | | **Guard the group paths against self-lockout, or accept the gap explicitly.** | 3 | 2 | 2 | 5 | 6 | 2 by 6 | 1.4 |
|
||||
| 1 | 0.4.21 | | owner | **Publish every release to Docker Hub, and give the Hub page its short description.** | 3 | 3 | 2 | 6 | 5 | 1 by 3 | 1.3 |
|
||||
| 13 | 0.4.21 | | | **Accept a verification code pasted with surrounding whitespace.** | 4 | 2 | 3 | 7 | 7 | 2 by 8 | 1.0 |
|
||||
| 19 | 0.5.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
|
||||
| 16 | 0.5.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
|
||||
| 6 | 0.5.0 | | | **Warn at discovery when a route or nav node gates on a permission no plugin declares.** | 2 | 1 | 1 | 2 | 2 | 7 by 3, 9 by 3 | 8.0 |
|
||||
| 23 | 0.5.0 | | | **Give list pages one view-model helper from query to pagination, instead of ~40 hand-written lines each.** | 3 | 1 | 2 | 4 | 5 | 1 by 4, 9 by 2, code health by 3 | 7.9 |
|
||||
| 28 | 0.5.0 | | | **Let a plugin add entries to the profile menu through the plugin API.** | 2 | 2 | 2 | 4 | 4 | 7 by 4, 1 by 2 | 4.2 |
|
||||
| 2 | 0.5.0 | | | **Rename the admin example plugin to a name that says what it administers.** | 3 | 1 | 2 | 4 | 6 | 9 by 3, code health by 2 | 2.6 |
|
||||
| 5 | 0.5.0 | | | **List grants that no installed plugin declares, with a remove action.** | 2 | 2 | 2 | 4 | 4 | 2 by 3 | 2.1 |
|
||||
| 14 | 0.5.0 | | | **Drop a double-clicked duplicate submit, with no client-side JavaScript.** | 4 | 2 | 3 | 7 | 7 | 2 by 7, 1 by 4 | 1.4 |
|
||||
## Finnished work
|
||||
|
||||
## Details
|
||||
|
||||
### 26. Add a domain glossary and the project's intended horizon to the README.
|
||||
|
||||
Glossary: host, manifest, chrome, nav fragment, permission token, system plugin, denylist. Also the
|
||||
expected plugin count, since per-plugin dependency isolation prices N dependency trees on disk and
|
||||
in RSS.
|
||||
|
||||
### 24. Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.
|
||||
|
||||
`ShellModel`/`buildShellContext` has one consumer, the dashboard, and duplicates `PageChrome` on
|
||||
almost every field. Keep `shellUser` as the shared primitive.
|
||||
|
||||
### 17. Remove the icons no caller uses from `ICON_NAMES` and the sprite.
|
||||
|
||||
`i-chart`, `i-copy` and `i-download` have no caller, and the sprite is inlined into every page.
|
||||
AGENTS.md → "`ICON_NAMES` is a host-owned registry" already makes this routine cleanup.
|
||||
|
||||
### 21. Fail the gate on a function that exceeds a size or complexity limit.
|
||||
|
||||
Only `tsc --strict` runs today. A new tool is a new dependency, so justify it.
|
||||
|
||||
### 38. Correct the TOKENS comment at the top of `public/css/styles.css` so it no longer claims `:has()` swaps are "rock-solid" or that the theme follows the OS on every engine.
|
||||
|
||||
Section 1's TOKENS comment says the theme follows the OS by default and that `:has()` variable swaps
|
||||
are "rock-solid". On an engine without `:has()` (Chromium below 105, Firefox below 121) every theme
|
||||
rule is inert, the OS-dark rule included, so those engines stay light, as the
|
||||
`@supports not selector(:has(a))` block further down says. A reader trusting the comment expects dark
|
||||
mode there.
|
||||
|
||||
### 32. Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.
|
||||
|
||||
Nothing checks README → "Two browser floors", and no E2E engine is old enough to show a break.
|
||||
Lint every stylesheet, the examples' included, against Chromium 87, Safari 15 and Firefox 115, and
|
||||
allow a feature only inside an `@supports` that guards it.
|
||||
|
||||
### 20. Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.
|
||||
|
||||
`flowPage` (`src/auth/routes.ts`) rethrows a 401, which Kratos answers to a settings-flow init with no
|
||||
session. `/settings` stays `public`, since recovery lands there with a Kratos session but no app JWT;
|
||||
a 401 should redirect to `/login` with the page as `return_to`. Add the missing E2E.
|
||||
|
||||
### 4. Refuse a permission save whose baseline no longer matches Keto, so concurrent edits are not silently lost.
|
||||
|
||||
Sketch: post the rendered set as a hidden baseline; when it no longer matches Keto, re-render with
|
||||
"this changed while you had the page open" rather than applying.
|
||||
|
||||
### 7. Confirm a successful permission save, and report a partial failure instead of hiding it.
|
||||
|
||||
`applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a
|
||||
half-applied set behind the generic error page. The `alert alert-pos` pattern of the recovery-code
|
||||
banner is available.
|
||||
|
||||
### 22. Offer a `users:read` holder a View row action on the users list, not Edit.
|
||||
|
||||
The pencil opens the read-only detail page, so it is harmless, but the label contradicts what the
|
||||
reader can do. Needs `canWrite` threaded into `listTable`, a `common.view` core catalog key and an
|
||||
`i-eye` icon.
|
||||
|
||||
### 34. Serve every stylesheet and script under a content-versioned URL with a year-long immutable `Cache-Control`, so a page view downloads none of them again.
|
||||
|
||||
`serveStatic` (`src/http/static.ts`) sends only `content-length` and `content-type`, so every page
|
||||
view downloads `styles.css`, `auth.css` and each plugin's stylesheet again: about 50 kB on a plugin's
|
||||
issue page, seconds per page over GPRS. Firefox can paint the page unstyled meanwhile, when an
|
||||
extension forces layout early.
|
||||
|
||||
- A versioned URL carries a hash of the file's content, as `?v=<hash>` or in the path, and answers
|
||||
`Cache-Control: public, max-age=31536000, immutable`. A release changes the hash, so it still
|
||||
reaches every browser at once.
|
||||
- An unversioned `/public/` URL answers a short `max-age` with revalidation: `ETag` or
|
||||
`Last-Modified`, and 304 on `If-None-Match` or `If-Modified-Since`, for GET and HEAD.
|
||||
- Every `/public/` href the host renders comes out versioned: the shell's `<link>`s and favicon,
|
||||
the error pages (`views/403.ejs`, `404`, `500`, `503`, `error`), `views/oauth-consent.ejs`, and
|
||||
the `styles` hrefs a plugin passes to `views/partials/shell.ejs`.
|
||||
- **Recommended: the host versions a plugin's hrefs itself.** A plugin names its asset once, as
|
||||
today, and the shell adds the version to any `/public/` href. A helper would make every plugin
|
||||
author remember to call it, and adds to the barrel.
|
||||
- **Contract:** `views/partials/*.ejs` are contract surface (AGENTS.md → Rules). Host-side
|
||||
versioning keeps the `styles` option and its meaning, and an unversioned href keeps working, so
|
||||
no minor bump of `HOST_API_VERSION` is needed and 0.4.21 holds. A helper exported through
|
||||
`@plainpages/plugin-api` would add to the contract and need 0.5.0.
|
||||
- Pages stay `private, no-store` (`src/http/app.ts`); the static branch returns before that header
|
||||
is set.
|
||||
|
||||
Tests first, with expected values from this item: `src/http` unit tests for both header sets and the
|
||||
304s, and an E2E test that a repeat page view fetches no stylesheet again. State the choice once in a
|
||||
comment in `static.ts`, and in a sentence where the README tells plugin authors and operators about
|
||||
assets. Requested by plainpages-plugin-fastjira, whose own item waits on the release that ships this.
|
||||
|
||||
### 3. Guard the group paths against self-lockout, or accept the gap explicitly.
|
||||
|
||||
The self-revoke guard covers only your own direct grants on the Users screen. Unticking a permission
|
||||
on a group you belong to, leaving that group, or deleting it can still strip your own access with no
|
||||
warning. AGENTS.md records this as a known gap. The robust fix is a "last effective holder" check,
|
||||
which needs a reverse Keto query.
|
||||
|
||||
### 1. Publish every release to Docker Hub, and give the Hub page its short description.
|
||||
|
||||
Hub's newest tag is 0.4.0, while git has v0.4.1 onwards, and the overview still says 0.4.0. No
|
||||
`release.yml` run exists for the tags auto-release pushes as `renovate-bot`; find out why Gitea
|
||||
doesn't fire `push: tags` for them, fix it, and backfill the missing tags. fejkdata did this
|
||||
(`../fejkdata`, commits `51d924a` and `34c8234`), and two parts port directly: a one-line
|
||||
description riding the existing PATCH as its `description` field, and a unit test that the
|
||||
description opens a README sentence and every command the overview shows appears in the README.
|
||||
fejkdata also syncs the page on any push to `main`, with its own token in an environment only `main`
|
||||
may use; decide whether that fits here, where `{{VERSION}}` must render a released tag.
|
||||
|
||||
### 13. Accept a verification code pasted with surrounding whitespace.
|
||||
|
||||
A pasted code fails the browser's `pattern` check. No zero-JS fix exists: `pattern`
|
||||
cannot transform, so loosening it only lets the untrimmed value through to Kratos. **Check first:**
|
||||
`kratos-admin.ts` returns a `recovery_link` alongside the code; if the stock courier mail can carry
|
||||
one, a template change fixes this with no host code. Otherwise proxy the flow POST through the host:
|
||||
`submitFlow` (`src/auth/kratos-public.ts`) already relays cookies and normalises a 422
|
||||
`redirect_browser_to`, and has no callers outside tests. CSRF improves, since the POST becomes
|
||||
same-origin and `form-action 'self'` can join the CSP. Risks: one `flow.ui.action` serves all five
|
||||
flows, so this rewrites the sign-in path; and Go's nosurf checks Referer only over https, which no
|
||||
test covers. Code entry has no E2E coverage today.
|
||||
|
||||
### 19. Run the documented Docker commands on macOS and fix whatever misbehaves.
|
||||
|
||||
Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is
|
||||
`dialout` inside the noble image, and Docker Desktop remaps bind-mount ownership in its VM.
|
||||
|
||||
### 16. Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.
|
||||
|
||||
Ory's own examples model `Shift`, `Document` and so on. One global `Resource` bucket is the
|
||||
project's "no catch-all names" rule applied to namespaces.
|
||||
|
||||
### 6. Warn at discovery when a route or nav node gates on a permission no plugin declares.
|
||||
|
||||
Declaring stays optional on purpose, but the picker is built from declarations only, so such a route
|
||||
is ungrantable from the GUI and answers a permanent 403. A warning, not an error.
|
||||
|
||||
### 23. Give list pages one view-model helper from query to pagination, instead of ~40 hand-written lines each.
|
||||
|
||||
Every list screen bridges `parseListQuery`/`paginate` to the partials by hand. At minimum a
|
||||
`buildPaginationModel(page, hrefFor)`. Exported through the barrel, so it is contract surface.
|
||||
|
||||
### 28. Let a plugin add entries to the profile menu through the plugin API.
|
||||
|
||||
The profile dropdown stays a dropdown: Sign out is its only entry today, and it will gain more.
|
||||
`views/partials/shell.ejs` hand-rolls it (AGENTS.md → UI, the dropdown rule), so the entries need a
|
||||
manifest field that discovery checks, gated like a nav node.
|
||||
|
||||
### 2. Rename the admin example plugin to a name that says what it administers.
|
||||
|
||||
Candidates: `auth-admin`, `users-groups-admin`. The id is also the mount path, so the compose files,
|
||||
E2E stacks and README move with it.
|
||||
|
||||
### 5. List grants that no installed plugin declares, with a remove action.
|
||||
|
||||
`grantDiff` never revokes an undeclared name, and nothing shows one either, so it can't be audited or
|
||||
cleaned, and reinstalling its plugin silently reactivates access nobody remembers granting.
|
||||
|
||||
### 14. Drop a double-clicked duplicate submit, with no client-side JavaScript.
|
||||
|
||||
A second identical POST is expected from a non-technical user, and today creates two
|
||||
users, mints two recovery codes, or registers two OAuth2 clients. It must not break a legitimately
|
||||
repeatable action. Sketch: a CSS-only affordance so the second click has nothing to hit, plus the
|
||||
host recognising a duplicate (same session, route and payload within a short window), logging and
|
||||
dropping it. Open: what identifies "the same submission" (a one-time token minted into each form
|
||||
beats hashing the payload), the window, where the record lives given the host is stateless, and how
|
||||
a plugin declares a route repeatable.
|
||||
- [x] Give a plugin persistent storage: `storage: true` provisions a Postgres database + login role named `plugin_<id>`, credentials arrive on `onBoot`, passwords are derived from `PLUGIN_DB_SECRET` rather than stored.
|
||||
- [x] Refuse a stray `package.json`/`node_modules` in `config/` by name, as plugin folders already are.
|
||||
- [x] Let Renovate reach the example plugins' manifests (`ignorePaths` overrides `config:recommended`).
|
||||
- [x] The seeded admin is granted each permission once — `seedPermissions` dedupes and the grant PUT is idempotent.
|
||||
- [x] Run the E2E runner as the invoking user so its artifacts aren't root-owned.
|
||||
- [x] Install node_modules above `WORKDIR /app` so no mount leaves a root-owned dir in the checkout.
|
||||
- [x] Enforce `<resource>:<action>` permission names at discovery; split `admin` per screen.
|
||||
- [x] Make the declared-permission catalog the fixed list; delete the Permissions screen, move granting onto Users and Groups.
|
||||
- [x] Fail a Playwright test on any browser console warning, error or uncaught exception, in every engine.
|
||||
- [x] Skip the CI gate when only markdown changed.
|
||||
- [x] Replace the `<details>` popup menus with the Popover API so an outside click dismisses them.
|
||||
- [x] Organize the files in src into folders.
|
||||
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
|
||||
- [x] Move the scheduling example out of `plugins/` into `examples/`.
|
||||
- [x] Make `config/` an empty drop-in mount with the defaults as fallback.
|
||||
- [x] Turn the built-in admin pages into a drop-in example plugin.
|
||||
- [x] CI/CD — test on push to any branch except main.
|
||||
- [x] CI/CD — require a PR to main, gated on a green build, fast-forward-only.
|
||||
- [x] CI/CD — force-push mirror to GitHub after every merge to main.
|
||||
- [x] CI/CD — build and push the app image, tagged with the commit hash, as part of the gate.
|
||||
- [x] CI/CD — re-tag the hash image to semver on a `vX.Y.Z` tag.
|
||||
- [x] CI/CD — sync released tags to Docker Hub.
|
||||
- [x] Write README-dockerhub.md for the Docker Hub overview.
|
||||
- [x] CI/CD — set up the Renovate bot.
|
||||
- [x] CI/CD — give Renovate a read-only `GITHUB_COM_TOKEN` so github.com lookups aren't rate-limited.
|
||||
- [x] CI/CD — auto-release on Renovate updates, versioned from the `Release-Bump:` trailer.
|
||||
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen.
|
||||
- [x] Document the auth security model in the README.
|
||||
- [x] Add i18n support.
|
||||
- [x] Settle the identity-vs-user vocabulary.
|
||||
- [x] Use one uniform verb per action in the English UI (sign in / sign out / create account).
|
||||
- [x] Remove the dead "Profile" link from the sidebar profile menu.
|
||||
- [x] Remove the unspecified "Settings"/"Preferences" cog from the sidebar footer.
|
||||
- [x] **HIGH — Split `handleRequest` in `src/http/app.ts`** — extract the built-in endpoints into named handlers on an internal route table.
|
||||
|
||||
+1
-1
@@ -24,5 +24,5 @@
|
||||
"forceConsistentCasingInFileNames": 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.replace")}</p>
|
||||
<pre class="code-block"><code>export default definePlugin({
|
||||
apiVersion: "0.4.0",
|
||||
apiVersion: "1.0.0",
|
||||
// view names plugins/<id>/views/<view>.ejs, rendered in this same shell
|
||||
dashboard: (ctx) => ({ view: "dashboard", data: { /* … */ } }),
|
||||
});</code></pre>
|
||||
|
||||
@@ -2,15 +2,12 @@
|
||||
Filter bar: a real GET form so filtering is server-side and zero-JS. Config:
|
||||
rows: Control[][] rows of controls, laid out left→right
|
||||
pills, clearHref, label, action, applyLabel
|
||||
Control.type ∈ search | segmented | select | chips | multiselect | daterange | spacer.
|
||||
Control.type ∈ search | segmented | select | chips | daterange | spacer.
|
||||
search { name, placeholder?, value?, label? }
|
||||
segmented { name, legend?, value?, options:{value,label,count?}[] } (radios)
|
||||
select { name, label, value?, options:{value,label}[] }
|
||||
chips { name, legend?, value?:string[], options:{value,label}[] } (checkboxes)
|
||||
multiselect { name, legend?, note?, value?:string[], options:{value,label}[] } (checkboxes in a popover)
|
||||
daterange { legend?, from:{name,value?,label?}, to:{name,value?,label?} }
|
||||
chips and multiselect are the same checkboxes on the same parameter: on the bar, or behind a
|
||||
button once the list is too long to lay there.
|
||||
The form is a GET, which replaces the whole query string — so the visitor's chosen language rides
|
||||
along as a hidden input, and every href here (pills, clear) is run through localeHref.
|
||||
%><%
|
||||
@@ -37,16 +34,6 @@
|
||||
<span class="filter"><label class="sr-only" for="f-<%= c.name %>"><%= c.label %></label><span class="select"><select id="f-<%= c.name %>" name="<%= c.name %>"><% c.options.forEach((o) => { %><option value="<%= o.value %>"<% if (eq(c.value, o.value)) { %> selected<% } %>><%= o.label %></option><% }) %></select></span></span>
|
||||
<% } else if (c.type === "chips") { -%>
|
||||
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || c.name %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || c.name %></span><div class="chips"><% (c.options).forEach((o) => { const on = (c.value || []).map(String).includes(String(o.value)); %><label class="chip"><span class="chip-dot" aria-hidden="true"></span><input type="checkbox" name="<%= c.name %>" value="<%= o.value %>"<% if (on) { %> checked<% } %>><%= o.label %></label><% }) %></div></fieldset>
|
||||
<% } else if (c.type === "multiselect") { const legend = c.legend || c.name; const on = (c.value || []).map(String); -%>
|
||||
<%- include("menu", {
|
||||
id: "f-" + c.name + "-menu",
|
||||
align: "left", kebab: false, up: false, width: null, // explicit: EJS would otherwise inherit the page's own
|
||||
trigger: { class: "btn btn-menu", count: on.length || null, label: on.length ? t("filter.selected", { count: on.length, label: legend }) : null, text: legend },
|
||||
items: [
|
||||
...(c.note ? [{ head: c.note }] : []),
|
||||
{ group: { legend, name: c.name, options: c.options.map((o) => ({ checked: on.includes(String(o.value)), label: o.label, value: o.value })) } },
|
||||
],
|
||||
}) %>
|
||||
<% } else if (c.type === "daterange") { -%>
|
||||
<fieldset class="filter-field"><legend class="sr-only"><%= c.legend || t("filter.dateRange") %></legend><span class="filter-legend" aria-hidden="true"><%= c.legend || t("filter.dateRange") %></span><div class="daterange"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-cal"/></svg><label class="sr-only" for="f-<%= c.from.name %>"><%= c.from.label || t("filter.from") %></label><input type="date" id="f-<%= c.from.name %>" name="<%= c.from.name %>"<% if (c.from.value) { %> value="<%= c.from.value %>"<% } %>><span class="to" aria-hidden="true"><%= t("filter.toSeparator") %></span><label class="sr-only" for="f-<%= c.to.name %>"><%= c.to.label || t("filter.to") %></label><input type="date" id="f-<%= c.to.name %>" name="<%= c.to.name %>"<% if (c.to.value) { %> value="<%= c.to.value %>"<% } %>></div></fieldset>
|
||||
<% } else if (c.type === "spacer") { -%>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<symbol id="i-box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></symbol>
|
||||
<symbol id="i-cal" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /></symbol>
|
||||
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M5 21v-6" /><path d="M12 21V3" /><path d="M19 21V9" /></symbol>
|
||||
<symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m16 9-5.5 5.5L8 12" /></symbol>
|
||||
<symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></symbol>
|
||||
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>
|
||||
<symbol id="i-cols" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="M15 3v18" /></symbol>
|
||||
<symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></symbol>
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
Config:
|
||||
id string REQUIRED — the panel's id and the trigger's popovertarget. Name it for what
|
||||
the menu is (`locale-menu`); it must be unique on the page.
|
||||
trigger { class?(="btn", "" ⇒ none) · label?(aria-label) · icon? · text? · count?(badge) · html?(raw inner, wins) }
|
||||
trigger { class?(="btn", "" ⇒ none) · label?(aria-label) · icon? · text? · html?(raw inner, wins) }
|
||||
align? "left" left-align the popover (default right)
|
||||
up? boolean open upward (footer menus)
|
||||
kebab? boolean bare kebab trigger (adds .kebab)
|
||||
@@ -26,7 +26,7 @@
|
||||
const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : "");
|
||||
const width = locals.width;
|
||||
-%>
|
||||
<div class="menu"><button<% if (btnCls) { %> class="<%= btnCls %>"<% } %> type="button" popovertarget="<%= locals.id %>"<% if (trigger.label) { %> aria-label="<%= trigger.label %>"<% } %>><% if (trigger.html != null) { %><%- trigger.html %><% } else { if (trigger.icon) { %><svg class="ico ico-sm"><use href="#<%= trigger.icon %>"/></svg><% } if (trigger.text) { %><%= trigger.text %><% } if (trigger.count != null) { %><span class="badge"><%= trigger.count %></span><% } } %></button><div id="<%= locals.id %>" class="<%= popCls %>" popover<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>>
|
||||
<div class="menu"><button<% if (btnCls) { %> class="<%= btnCls %>"<% } %> type="button" popovertarget="<%= locals.id %>"<% if (trigger.label) { %> aria-label="<%= trigger.label %>"<% } %>><% if (trigger.html != null) { %><%- trigger.html %><% } else { if (trigger.icon) { %><svg class="ico ico-sm"><use href="#<%= trigger.icon %>"/></svg><% } if (trigger.text) { %><%= trigger.text %><% } } %></button><div id="<%= locals.id %>" class="<%= popCls %>" popover<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>>
|
||||
<% items.forEach((it) => { -%>
|
||||
<% if (it.head != null) { -%>
|
||||
<div class="menu-head"><%= it.head %></div>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<%#
|
||||
Recursive nav tree. Each node: { label, href?, icon?, count?, current?, open?, children? }.
|
||||
Shape is orthogonal: header (children → disclosure toggle + nested <ul>) vs leaf (spacer),
|
||||
and clickable (href → <a>) vs static (<span>). Where :has() works, the CSS reveals children
|
||||
only when the sibling .nav-disc is [open]; elsewhere it shows them all. `root` picks the outer class.
|
||||
and clickable (href → <a>) vs static (<span>). The CSS reveals children only when the
|
||||
sibling .nav-disc is [open]. `root` picks the outer class.
|
||||
%><%
|
||||
const nodes = locals.nodes || [];
|
||||
const root = locals.root !== false;
|
||||
|
||||
Reference in New Issue
Block a user