Split conflict detection out of plugin.ts and the CI/CD section out of the README #223
@@ -57,7 +57,7 @@ jobs:
|
|||||||
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
COMMIT=$(git rev-parse 'HEAD^{commit}')
|
||||||
VERSION=${GIT_TAG#v}
|
VERSION=${GIT_TAG#v}
|
||||||
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|
[ -n "$DOCKERHUB_USER" ] && [ -n "$DOCKERHUB_TOKEN" ] \
|
||||||
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (README -> CI/CD)"; exit 1; }
|
|| { echo "Set the DOCKERHUB_USER variable + DOCKERHUB_TOKEN secret (docs/ci-cd.md)"; exit 1; }
|
||||||
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
|
printf '%s' "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
|
||||||
TAGS="$VERSION ${VERSION%.*} latest"
|
TAGS="$VERSION ${VERSION%.*} latest"
|
||||||
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
|
if [ "${VERSION%%.*}" != "0" ]; then TAGS="$TAGS ${VERSION%%.*}"; fi
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ Revisit only if the stated reason stops holding.
|
|||||||
design: the prefix is `PLUGIN_SETTING_`, never bare `PLUGIN_`, because a plugin id `db` with key
|
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
|
`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
|
`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
|
collision caught by `findConflicts` (`plugin-host/conflicts.ts`); `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
|
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`
|
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
|
— not even as a mask or a length. An author mistake is refused at discovery, a bad operator value
|
||||||
|
|||||||
@@ -126,7 +126,6 @@ docker compose up -d
|
|||||||
- [end-to-end](#end-to-end-playwright)
|
- [end-to-end](#end-to-end-playwright)
|
||||||
- [the full gate](#the-full-gate-one-command)
|
- [the full gate](#the-full-gate-one-command)
|
||||||
- [CI/CD](#cicd)
|
- [CI/CD](#cicd)
|
||||||
- [one-time setup](#one-time-ci-setup)
|
|
||||||
- [Production & deployment](#production--deployment)
|
- [Production & deployment](#production--deployment)
|
||||||
- [Upgrading](#upgrading)
|
- [Upgrading](#upgrading)
|
||||||
- [Observability](#observability)
|
- [Observability](#observability)
|
||||||
@@ -1585,109 +1584,7 @@ gate runs them serially, one stack up/down per suite.
|
|||||||
|
|
||||||
## CI/CD
|
## CI/CD
|
||||||
|
|
||||||
Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
|
[The pipeline, releases and CI setup](docs/ci-cd.md).
|
||||||
[`ci.sh`](#the-full-gate-one-command) — the exact gate you run locally:
|
|
||||||
|
|
||||||
| 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 |
|
|
||||||
| `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 |
|
|
||||||
|
|
||||||
`main` is not re-tested on push — its commits are meant to arrive already green from a
|
|
||||||
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
|
|
||||||
|
|
||||||
**Merge gate on `main`** (Gitea branch protection + repo merge settings, set via the API —
|
|
||||||
no repo files involved): direct pushes are blocked, changes land via PR only, the
|
|
||||||
`CI / full-gate (push)` status must be green (admins included), and the only merge style is
|
|
||||||
**fast-forward-only** — history stays linear and `main`'s head is the exact commit hash of
|
|
||||||
the merged branch, which is why the branch's push-triggered status carries over.
|
|
||||||
|
|
||||||
**Container images** — after a green gate, `ci.yml` builds the app image and pushes it as
|
|
||||||
`gitea.larvit.se/larvit/plainpages:<full commit hash>`. Because merges are fast-forward-only, the
|
|
||||||
image for any `main` commit already exists from that commit's branch gate — build once, promote by
|
|
||||||
re-tagging. The build runs inside the required gate, so a missing/expired token or a registry outage
|
|
||||||
blocks **all** merges until restored; use a non-expiring token or track its expiry. Hash tags
|
|
||||||
accumulate one image per gated push, so nightly `registry-cleanup.yml` prunes them
|
|
||||||
([`registry-cleanup/cleanup.ts`](registry-cleanup/cleanup.ts) defines what survives). Don't add a
|
|
||||||
pattern-based org cleanup rule for this package — its age/count heuristics can't see branch heads or
|
|
||||||
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
|
|
||||||
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. The same job sets the page's short description. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the
|
|
||||||
quick start stays a topology CI has actually run.
|
|
||||||
It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
|
|
||||||
outage leaves the promotion green and the images untouched; and the page has its own door — run the
|
|
||||||
workflow manually with an `overview_version` input to republish it without cutting a release. That
|
|
||||||
input goes through the same contract check as a tag: a non-semver value, or one whose `major.minor`
|
|
||||||
disagrees with the tree being published, is refused. It uses
|
|
||||||
the same `DOCKERHUB_TOKEN` the image push uses, which is why that token needs the **delete** scope.
|
|
||||||
|
|
||||||
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is
|
|
||||||
read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift.
|
|
||||||
Tags go with `--prune`, so deleting one here deletes it there on the next run, and a tag *created* on
|
|
||||||
GitHub is swept away — cut releases on Gitea, never on the mirror. Branches other than `main` match
|
|
||||||
no refspec and are left alone.
|
|
||||||
|
|
||||||
**Dependency updates** — `renovate.yml` runs [Renovate](https://docs.renovatebot.com) nightly
|
|
||||||
(self-hosted, this repo only) against [`renovate.json`](renovate.json), bumping npm deps, Docker base
|
|
||||||
images, Gitea action versions, and the image tags pinned inside workflow `run:` steps (a custom regex
|
|
||||||
manager, so nothing pinned drifts unmanaged). Version-locked sets move together in one PR — the Ory
|
|
||||||
images, and the Playwright runner + its browser image — and every bump keeps the **exact semver pin**
|
|
||||||
exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once
|
|
||||||
`CI / full-gate (push)` is green; only a red gate needs a human.
|
|
||||||
|
|
||||||
**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
|
|
||||||
`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.
|
|
||||||
|
|
||||||
### One-time CI setup
|
|
||||||
|
|
||||||
| 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. |
|
|
||||||
| `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. |
|
|
||||||
|
|
||||||
Gitea rejects secret names prefixed `GITEA_`/`GITHUB_`. Each job fails loud until its secret exists.
|
|
||||||
The container package is **org-owned**, so it lists under `larvit/-/packages` — link it to the repo's
|
|
||||||
Packages tab once: `POST /api/v1/packages/larvit/container/plainpages/-/link/plainpages`.
|
|
||||||
|
|
||||||
**The runner** — register one
|
|
||||||
[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode labelled `docker-host`
|
|
||||||
(`labels: ["docker-host:host"]`) on a machine with Docker Engine + Compose, git, and Node +
|
|
||||||
github.com access. Runs must **never overlap** — the e2e stacks use fixed compose project names, the
|
|
||||||
devstack suite uses host networking, and the workflows share the Docker daemon's registry login — so
|
|
||||||
keep exactly one runner at capacity 1, with host ports 3000/4433 free.
|
|
||||||
|
|
||||||
## Production & deployment
|
## Production & deployment
|
||||||
|
|
||||||
@@ -1884,7 +1781,7 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
|
|||||||
i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime ·
|
i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime ·
|
||||||
english · view-locals · locales/ (the core en-US + sv-SE catalogs)
|
english · view-locals · locales/ (the core en-US + sv-SE catalogs)
|
||||||
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
|
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
|
||||||
(ctx.system) · discovery · router · hooks · view-resolver · storage (the rules) ·
|
(ctx.system) · discovery · conflicts · router · hooks · view-resolver · storage (the rules) ·
|
||||||
storage-provisioning (the DDL; bootstrap-only, holds the driver)
|
storage-provisioning (the DDL; bootstrap-only, holds the driver)
|
||||||
ui/ chrome (`ctx.chrome`: menu, brand, user) · nav (composeNav) · menu-config
|
ui/ chrome (`ctx.chrome`: menu, brand, user) · nav (composeNav) · menu-config
|
||||||
(`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
|
(`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
|
||||||
@@ -1911,7 +1808,7 @@ release-tooling/ Everything the release runs: next-version (the bump math),
|
|||||||
registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts)
|
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
|
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
|
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
|
||||||
.gitea/workflows/ Gitea Actions — see CI/CD
|
.gitea/workflows/ Gitea Actions — see docs/ci-cd.md
|
||||||
```
|
```
|
||||||
|
|
||||||
## Extending the core
|
## Extending the core
|
||||||
|
|||||||
+105
@@ -0,0 +1,105 @@
|
|||||||
|
# CI/CD
|
||||||
|
|
||||||
|
Gitea Actions (`.gitea/workflows/`) runs the pipeline; the test job runs
|
||||||
|
[`ci.sh`](../README.md#the-full-gate-one-command) — the exact gate you run locally:
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
| `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 |
|
||||||
|
|
||||||
|
`main` is not re-tested on push — its commits are meant to arrive already green from a
|
||||||
|
gated branch, so the status check to gate a merge on is `CI / full-gate (push)`.
|
||||||
|
|
||||||
|
**Merge gate on `main`** (Gitea branch protection + repo merge settings, set via the API —
|
||||||
|
no repo files involved): direct pushes are blocked, changes land via PR only, the
|
||||||
|
`CI / full-gate (push)` status must be green (admins included), and the only merge style is
|
||||||
|
**fast-forward-only** — history stays linear and `main`'s head is the exact commit hash of
|
||||||
|
the merged branch, which is why the branch's push-triggered status carries over.
|
||||||
|
|
||||||
|
**Container images** — after a green gate, `ci.yml` builds the app image and pushes it as
|
||||||
|
`gitea.larvit.se/larvit/plainpages:<full commit hash>`. Because merges are fast-forward-only, the
|
||||||
|
image for any `main` commit already exists from that commit's branch gate — build once, promote by
|
||||||
|
re-tagging. The build runs inside the required gate, so a missing/expired token or a registry outage
|
||||||
|
blocks **all** merges until restored; use a non-expiring token or track its expiry. Hash tags
|
||||||
|
accumulate one image per gated push, so nightly `registry-cleanup.yml` prunes them
|
||||||
|
([`registry-cleanup/select-versions.ts`](../registry-cleanup/select-versions.ts) defines what survives). Don't add a
|
||||||
|
pattern-based org cleanup rule for this package — its age/count heuristics can't see branch heads or
|
||||||
|
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
|
||||||
|
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](../README.md#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. The same job sets the page's short description. Its sidecar pins are Renovate-managed and gated against this repo's own compose files, so the
|
||||||
|
quick start stays a topology CI has actually run.
|
||||||
|
It is its own job for two reasons: the images are already pushed and irreversible by then, so a Hub
|
||||||
|
outage leaves the promotion green and the images untouched; and the page has its own door — run the
|
||||||
|
workflow manually with an `overview_version` input to republish it without cutting a release. That
|
||||||
|
input goes through the same contract check as a tag: a non-semver value, or one whose `major.minor`
|
||||||
|
disagrees with the tree being published, is refused. It uses
|
||||||
|
the same `DOCKERHUB_TOKEN` the image push uses, which is why that token needs the **delete** scope.
|
||||||
|
|
||||||
|
**GitHub mirror** — [github.com/larvit/plainpages](https://github.com/larvit/plainpages) is
|
||||||
|
read-only; after every merge `mirror.yml` force-pushes `main` and all tags, overwriting any drift.
|
||||||
|
Tags go with `--prune`, so deleting one here deletes it there on the next run, and a tag *created* on
|
||||||
|
GitHub is swept away — cut releases on Gitea, never on the mirror. Branches other than `main` match
|
||||||
|
no refspec and are left alone.
|
||||||
|
|
||||||
|
**Dependency updates** — `renovate.yml` runs [Renovate](https://docs.renovatebot.com) nightly
|
||||||
|
(self-hosted, this repo only) against [`renovate.json`](../renovate.json), bumping npm deps, Docker base
|
||||||
|
images, Gitea action versions, and the image tags pinned inside workflow `run:` steps (a custom regex
|
||||||
|
manager, so nothing pinned drifts unmanaged). Version-locked sets move together in one PR — the Ory
|
||||||
|
images, and the Playwright runner + its browser image — and every bump keeps the **exact semver pin**
|
||||||
|
exact. Each PR runs the normal gate on its `renovate/*` branch and automerges once
|
||||||
|
`CI / full-gate (push)` is green; only a red gate needs a human.
|
||||||
|
|
||||||
|
**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
|
||||||
|
`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.
|
||||||
|
|
||||||
|
### One-time CI setup
|
||||||
|
|
||||||
|
| 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. |
|
||||||
|
| `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. |
|
||||||
|
|
||||||
|
Gitea rejects secret names prefixed `GITEA_`/`GITHUB_`. Each job fails loud until its secret exists.
|
||||||
|
The container package is **org-owned**, so it lists under `larvit/-/packages` — link it to the repo's
|
||||||
|
Packages tab once: `POST /api/v1/packages/larvit/container/plainpages/-/link/plainpages`.
|
||||||
|
|
||||||
|
**The runner** — register one
|
||||||
|
[act_runner](https://docs.gitea.com/usage/actions/act-runner) in host mode labelled `docker-host`
|
||||||
|
(`labels: ["docker-host:host"]`) on a machine with Docker Engine + Compose, git, and Node +
|
||||||
|
github.com access. Runs must **never overlap** — the e2e stacks use fixed compose project names, the
|
||||||
|
devstack suite uses host networking, and the workflows share the Docker daemon's registry login — so
|
||||||
|
keep exactly one runner at capacity 1, with host ports 3000/4433 free.
|
||||||
@@ -373,3 +373,26 @@ Inherited architect, decided by:
|
|||||||
|
|
||||||
- Shape: `views/partials/shell.ejs:74` `user.menu`: a third meaning of `menu` beside the sidebar switch at line 19 and the `.menu` popover class
|
- Shape: `views/partials/shell.ejs:74` `user.menu`: a third meaning of `menu` beside the sidebar switch at line 19 and the `.menu` popover class
|
||||||
- Shape: `src/plugin-host/plugin.ts:9` `import type { ProfileMenuEntry }`: a second contract type defined under `ui/`, with a type-only import cycle through `chrome.ts`
|
- Shape: `src/plugin-host/plugin.ts:9` `import type { ProfileMenuEntry }`: a second contract type defined under `ui/`, with a type-only import cycle through `chrome.ts`
|
||||||
|
|
||||||
|
## 2026-11-08T19:50:52Z, PR #223 at f8a6119, against b7e9780
|
||||||
|
|
||||||
|
Ruling: better
|
||||||
|
|
||||||
|
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| Mid A | better | better | better | same | better |
|
||||||
|
| Inherited architect | better | better | better | same | better |
|
||||||
|
|
||||||
|
Mid A, decided by:
|
||||||
|
|
||||||
|
- Navigation, Locality, Shape: `src/http/app.ts:183` `handleRequest`: a 20-line list of named stages replaces a 170-line closure
|
||||||
|
- Locality: `src/http/app.ts:213` `Host`: 19 fields passed to every stage, broader than any stage needs
|
||||||
|
- Shape, Navigation: `src/ui/chrome.ts:29` `ShellUser.profileLinks`: the name says what the field holds
|
||||||
|
- Navigation, Shape: `src/plugin-host/conflicts.ts:15` `findConflicts`: collision rules have their own file
|
||||||
|
|
||||||
|
Inherited architect, decided by:
|
||||||
|
|
||||||
|
- Locality, Shape, Navigation: `src/http/app.ts:183` `handleRequest`: named stages replace one 165-line closure
|
||||||
|
- Locality: `src/http/app.ts:213` `Host`: a 19-field bag wider than any stage needs
|
||||||
|
- Shape: `src/ui/chrome.ts:29` `ShellUser.profileLinks`: no longer collides with `MenuConfig` and the `menu` partial
|
||||||
|
- Navigation: `.gitea/workflows/release.yml:60` error pointer: names `docs/ci-cd.md`, one hop from the failure
|
||||||
|
|||||||
@@ -405,3 +405,21 @@ Read most (opens and searches):
|
|||||||
- `src/plugin-host/plugin-api.ts` 3
|
- `src/plugin-host/plugin-api.ts` 3
|
||||||
- `src/ui/chrome.test.ts` 3
|
- `src/ui/chrome.test.ts` 3
|
||||||
- `CHANGELOG.md` 2
|
- `CHANGELOG.md` 2
|
||||||
|
|
||||||
|
## Reading cost, chunk f4e8ff50cb2a, finished 2026-11-08T19:37:35Z
|
||||||
|
|
||||||
|
73698 new input tokens before the first commit.
|
||||||
|
|
||||||
|
Read most (opens and searches):
|
||||||
|
|
||||||
|
- `src/http/app.ts` 7
|
||||||
|
- `src/function-limits.test.ts` 2
|
||||||
|
- `src/http/context.ts` 2
|
||||||
|
- `src/http/static.ts` 2
|
||||||
|
- `src/plugin-host/plugin.ts` 2
|
||||||
|
- `todo.md` 2
|
||||||
|
- `AGENTS.md` 1
|
||||||
|
- `src/http/builtin-route.ts` 1
|
||||||
|
- `src/plugin-host/hooks.ts` 1
|
||||||
|
|
||||||
|
## Reading cost reset, 2026-11-08T19:39:42Z
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ async function main(): Promise<number> {
|
|||||||
if (!version || !repo || !user || !token) {
|
if (!version || !repo || !user || !token) {
|
||||||
return fail(
|
return fail(
|
||||||
"usage: dockerhub-overview.ts <X.Y.Z>; needs DOCKERHUB_REPO, DOCKERHUB_USER and " +
|
"usage: dockerhub-overview.ts <X.Y.Z>; needs DOCKERHUB_REPO, DOCKERHUB_USER and " +
|
||||||
"DOCKERHUB_TOKEN (README -> CI/CD)",
|
"DOCKERHUB_TOKEN (docs/ci-cd.md)",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// The page is public, so never render a version that resolves to no image.
|
// The page is public, so never render a version that resolves to no image.
|
||||||
@@ -94,7 +94,7 @@ async function main(): Promise<number> {
|
|||||||
`Docker Hub description and overview PATCH failed: ${res.status} ${res.text}` +
|
`Docker Hub description and overview PATCH failed: ${res.status} ${res.text}` +
|
||||||
(res.status === 403
|
(res.status === 403
|
||||||
? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " +
|
? "\n403 means DOCKERHUB_TOKEN lacks the delete scope — editing the overview needs " +
|
||||||
"read/write/delete, which pushing images alone does not (README -> CI/CD)."
|
"read/write/delete, which pushing images alone does not (docs/ci-cd.md)."
|
||||||
: ""),
|
: ""),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Pure release-version math for the Renovate auto-release (see renovate.yml → auto-release job,
|
// Pure release-version math for the Renovate auto-release (see renovate.yml → auto-release job,
|
||||||
// README → CI/CD). Renovate stamps each commit with a `Release-Bump: <updateType>` trailer; the
|
// docs/ci-cd.md). Renovate stamps each commit with a `Release-Bump: <updateType>` trailer; the
|
||||||
// workflow feeds those values here to pick the next `vX.Y.Z` tag. Kept side-effect-free and unit
|
// workflow feeds those values here to pick the next `vX.Y.Z` tag. Kept side-effect-free and unit
|
||||||
// tested (next-version.test.ts) — the git/tag/push side lives in the workflow shell.
|
// tested (next-version.test.ts) — the git/tag/push side lives in the workflow shell.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
"automerge": true,
|
"automerge": true,
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"description": "The host's own runtime deps. Release-Bump is opt-in per surface (README → CI/CD): updateType rates the dependency's own jump, not whether it reaches a running Plainpages",
|
"description": "The host's own runtime deps. Release-Bump is opt-in per surface (docs/ci-cd.md): updateType rates the dependency's own jump, not whether it reaches a running Plainpages",
|
||||||
"matchDepTypes": ["dependencies"],
|
"matchDepTypes": ["dependencies"],
|
||||||
"matchFileNames": ["package.json"],
|
"matchFileNames": ["package.json"],
|
||||||
"matchManagers": ["npm"],
|
"matchManagers": ["npm"],
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import { findConflicts } from "./conflicts.ts";
|
||||||
|
import type { Plugin } from "./plugin.ts";
|
||||||
|
|
||||||
|
// A minimal discovered plugin (id = folder name; mount path is the derived `/<id>`), per case.
|
||||||
|
const p = (over: Partial<Plugin> & Pick<Plugin, "id">): Plugin => ({ apiVersion: "1.0.0", ...over });
|
||||||
|
|
||||||
|
test("findConflicts: a clean set has none", () => {
|
||||||
|
assert.deepEqual(findConflicts([p({ id: "a" }), p({ id: "b" })]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findConflicts: a duplicate id and a colliding route are loud errors", () => {
|
||||||
|
const dupId = findConflicts([p({ id: "a" }), p({ id: "a" })]);
|
||||||
|
assert.ok(dupId.some((c) => c.kind === "id" && c.level === "error"));
|
||||||
|
|
||||||
|
// Cross-plugin routes can't collide (unique `/<id>` prefix); two identical routes in one can.
|
||||||
|
const noop = () => {};
|
||||||
|
const dupRoute = findConflicts([p({
|
||||||
|
id: "a",
|
||||||
|
routes: [{ handler: noop, method: "GET", path: "/t" }, { handler: noop, method: "GET", path: "/t" }],
|
||||||
|
})]);
|
||||||
|
assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findConflicts: duplicate nav id is an error, a shared permission name only warns", () => {
|
||||||
|
const navDup = findConflicts([
|
||||||
|
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
|
||||||
|
p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
|
||||||
|
]);
|
||||||
|
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||||
|
|
||||||
|
// Sharing a permission across plugins is legitimate → warn, not error.
|
||||||
|
const permissionDup = findConflicts([
|
||||||
|
p({ id: "a", permissions: [{ name: "shared:read" }] }),
|
||||||
|
p({ id: "b", permissions: [{ name: "shared:read" }] }),
|
||||||
|
]);
|
||||||
|
assert.ok(permissionDup.some((c) => c.kind === "permission" && c.level === "warn"));
|
||||||
|
});
|
||||||
|
|
||||||
|
const noopHandler = () => {};
|
||||||
|
|
||||||
|
test("findConflicts: a route, nav node or profile entry gating on a permission no plugin declares warns once per plugin and name, sorted", () => {
|
||||||
|
const gated = findConflicts([
|
||||||
|
p({ id: "a", nav: [{ children: [{ id: "a:x", label: "X", permission: "navonly:read" }], id: "a:root", label: "A", session: true }], routes: [
|
||||||
|
{ handler: noopHandler, method: "GET", path: "/x", permission: "routeonly:read" },
|
||||||
|
{ handler: noopHandler, method: "GET", path: "/y", permission: "both:read" },
|
||||||
|
{ handler: noopHandler, method: "POST", path: "/y", permission: "both:read" },
|
||||||
|
{ handler: noopHandler, method: "GET", path: "/z", permission: "declared:write" },
|
||||||
|
], permissions: [], profileMenu: [{ href: "/p", label: "P", permission: "profileonly:read" }, { href: "/q", label: "Q", session: true }, { href: "/r", label: "R", public: true }] }),
|
||||||
|
p({ id: "b", nav: [{ id: "b:y", label: "Y", permission: "both:read" }], permissions: [{ name: "declared:write" }] }),
|
||||||
|
]);
|
||||||
|
assert.ok(gated.every((c) => c.kind === "permission" && c.level === "warn"));
|
||||||
|
assert.deepEqual(gated.map((c) => c.plugins), [["a"], ["a"], ["a"], ["a"], ["b"]]);
|
||||||
|
assert.deepEqual(gated.map((c) => /"([^"]+:[a-z]+)"/.exec(c.message)?.[1]), ["both:read", "navonly:read", "profileonly:read", "routeonly:read", "both:read"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findConflicts: a gate on a permission another plugin declares, or on none, stays quiet", () => {
|
||||||
|
assert.deepEqual(findConflicts([
|
||||||
|
p({ id: "a", permissions: [{ name: "a:read" }], routes: [{ handler: noopHandler, method: "GET", path: "/x", permission: "a:read" }] }),
|
||||||
|
p({ id: "b", routes: [{ handler: noopHandler, method: "GET", path: "/y", permission: "a:read" }, { handler: noopHandler, method: "GET", path: "/z", session: true }] }),
|
||||||
|
]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
|
||||||
|
const handler = () => ({ html: "x" });
|
||||||
|
const homeDup = findConflicts([p({ id: "a", home: handler }), p({ id: "b", home: handler })]);
|
||||||
|
assert.ok(homeDup.some((c) => c.kind === "home" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||||
|
const dashDup = findConflicts([p({ id: "a", dashboard: handler }), p({ id: "b", dashboard: handler })]);
|
||||||
|
assert.ok(dashDup.some((c) => c.kind === "dashboard" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||||
|
// One owner of each (even both on one plugin) is fine.
|
||||||
|
assert.deepEqual(findConflicts([p({ id: "a", dashboard: handler, home: handler }), p({ id: "b" })]).filter((c) => c.kind === "home" || c.kind === "dashboard"), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findConflicts: two settings that map to one environment variable are an error", () => {
|
||||||
|
// `a-b` + `c` and `a` + `bC` both become A_B_C.
|
||||||
|
const clash = findConflicts([
|
||||||
|
p({ id: "a-b", settings: [{ key: "c", type: "string" }] }),
|
||||||
|
p({ id: "a", settings: [{ key: "bC", type: "string" }] }),
|
||||||
|
]);
|
||||||
|
assert.ok(clash.some((c) => c.kind === "setting" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("a-b")));
|
||||||
|
assert.deepEqual(findConflicts([
|
||||||
|
p({ id: "a", settings: [{ key: "x", type: "string" }] }),
|
||||||
|
p({ id: "b", settings: [{ key: "x", type: "string" }] }),
|
||||||
|
]), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("findConflicts: nav nodes without an id never collide, at any depth", () => {
|
||||||
|
assert.deepEqual(findConflicts([
|
||||||
|
p({ id: "a", nav: [{ children: [{ label: "A1" }], label: "A" }] }),
|
||||||
|
p({ id: "b", nav: [{ children: [{ label: "B1" }], label: "B" }] }),
|
||||||
|
]), []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import type { NavNode } from "../ui/nav.ts";
|
||||||
|
import { declaredPermissions, fullPath, type Plugin } from "./plugin.ts";
|
||||||
|
import { envName } from "./settings.ts";
|
||||||
|
|
||||||
|
export interface PluginConflict {
|
||||||
|
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route" | "setting";
|
||||||
|
level: "error" | "warn";
|
||||||
|
message: string;
|
||||||
|
plugins: string[]; // unique ids involved
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loud resolution, never last-write-wins: discovery throws on any "error" and logs every "warn".
|
||||||
|
// Mount-path uniqueness needs no rule of its own — it follows from the id check. Shared permission
|
||||||
|
// names are the one intentional overlap, so they warn rather than error.
|
||||||
|
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||||
|
const out: PluginConflict[] = [];
|
||||||
|
|
||||||
|
const idCounts = new Map<string, number>();
|
||||||
|
for (const plugin of plugins) idCounts.set(plugin.id, (idCounts.get(plugin.id) ?? 0) + 1);
|
||||||
|
for (const [id, n] of idCounts) {
|
||||||
|
if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] });
|
||||||
|
}
|
||||||
|
|
||||||
|
// The landing pages are single slots: "/" (home) and "/dashboard" (dashboard) take one owner
|
||||||
|
// each — two plugins claiming either is a loud error, not a race.
|
||||||
|
for (const slot of ["home", "dashboard"] as const) {
|
||||||
|
const owners = plugins.filter((plugin) => plugin[slot]).map((plugin) => plugin.id);
|
||||||
|
if (owners.length > 1) out.push({ kind: slot, level: "error", message: `${owners.length} plugins claim "${slot}" (${owners.join(", ")}); only one may own that page`, plugins: uniq(owners) });
|
||||||
|
}
|
||||||
|
|
||||||
|
collect(plugins, (plugin, push) => {
|
||||||
|
for (const route of plugin.routes ?? []) push(`${route.method} ${fullPath(plugin.id, route.path)}`);
|
||||||
|
}).forEach((owners, key) => {
|
||||||
|
if (owners.length > 1) out.push({ kind: "route", level: "error", message: `${owners.length} routes resolve to "${key}"`, plugins: uniq(owners) });
|
||||||
|
});
|
||||||
|
|
||||||
|
collect(plugins, (plugin, push) => collectNavIds(plugin.nav, push)).forEach((owners, id) => {
|
||||||
|
if (owners.length > 1) out.push({ kind: "nav-id", level: "error", message: `nav id "${id}" used ${owners.length}×; override targets ids, so they must be unique`, plugins: uniq(owners) });
|
||||||
|
});
|
||||||
|
|
||||||
|
collect(plugins, (plugin, push) => {
|
||||||
|
for (const decl of plugin.permissions ?? []) push(decl.name);
|
||||||
|
}).forEach((owners, name) => {
|
||||||
|
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) });
|
||||||
|
});
|
||||||
|
|
||||||
|
out.push(...undeclaredGates(plugins));
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Declaring is optional, but the admin screens offer only declared names: a route gated on one nobody
|
||||||
|
// declares answers 403 (a nav node is hidden) until the Keto tuple is written by hand or
|
||||||
|
// ADMIN_PERMISSIONS seeds it.
|
||||||
|
function undeclaredGates(plugins: Plugin[]): PluginConflict[] {
|
||||||
|
const declared = new Set(declaredPermissions(plugins).map((decl) => decl.name));
|
||||||
|
const out: PluginConflict[] = [];
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
const gated = new Set<string>();
|
||||||
|
for (const route of plugin.routes ?? []) if (route.permission != null) gated.add(route.permission);
|
||||||
|
collectNavPermissions(plugin.nav, (name) => gated.add(name));
|
||||||
|
for (const entry of plugin.profileMenu ?? []) if (entry.permission != null) gated.add(entry.permission);
|
||||||
|
for (const name of [...gated].sort()) {
|
||||||
|
if (!declared.has(name)) out.push({ kind: "permission", level: "warn", message: `plugin "${plugin.id}" gates on "${name}", which no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's \`permissions\``, plugins: [plugin.id] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectNavPermissions(nodes: NavNode[] | undefined, push: (name: string) => void): void {
|
||||||
|
for (const node of nodes ?? []) {
|
||||||
|
if (node.permission != null) push(node.permission);
|
||||||
|
collectNavPermissions(node.children, push);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map each emitted key → the plugin ids that emitted it (repeats kept, so within-plugin dups count).
|
||||||
|
function collect(plugins: Plugin[], emit: (plugin: Plugin, push: (key: string) => void) => void): Map<string, string[]> {
|
||||||
|
const owners = new Map<string, string[]>();
|
||||||
|
for (const plugin of plugins) emit(plugin, (key) => owners.set(key, [...(owners.get(key) ?? []), plugin.id]));
|
||||||
|
return owners;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectNavIds(nodes: NavNode[] | undefined, push: (id: string) => void): void {
|
||||||
|
for (const node of nodes ?? []) {
|
||||||
|
if (node.id != null) push(node.id);
|
||||||
|
collectNavIds(node.children, push);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniq(xs: string[]): string[] {
|
||||||
|
return [...new Set(xs)];
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
// Plugin discovery: scan plugins/, import each folder's plugin.ts default export,
|
// Plugin discovery: scan plugins/, import each folder's plugin.ts default export,
|
||||||
// validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules
|
// validate it, assemble the loaded Plugin[]. The imperative shell over the pure rules in plugin.ts
|
||||||
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
|
// (isValidPluginId, checkApiVersion) and conflicts.ts (findConflicts). Fails loud: every per-plugin problem and
|
||||||
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
|
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
|
||||||
// (older-minor apiVersion, shared permission name) log and load continues. Folder name = id.
|
// (older-minor apiVersion, shared permission name) log and load continues. Folder name = id.
|
||||||
|
|
||||||
@@ -8,7 +8,8 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
import { type Gate, gatesSet } from "../auth/gate.ts";
|
import { type Gate, gatesSet } from "../auth/gate.ts";
|
||||||
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
|
import { findConflicts } from "./conflicts.ts";
|
||||||
|
import { checkApiVersion, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
|
||||||
import { settingsDeclError } from "./settings.ts";
|
import { settingsDeclError } from "./settings.ts";
|
||||||
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts";
|
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts";
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import {
|
|||||||
checkApiVersion,
|
checkApiVersion,
|
||||||
declaredPermissions,
|
declaredPermissions,
|
||||||
definePlugin,
|
definePlugin,
|
||||||
findConflicts,
|
|
||||||
HOST_API_VERSION,
|
HOST_API_VERSION,
|
||||||
isValidPermissionName,
|
isValidPermissionName,
|
||||||
isValidPluginId,
|
isValidPluginId,
|
||||||
@@ -94,75 +93,6 @@ test("checkApiVersion: semver compat — equal/patch ok, older minor warns, newe
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// A minimal discovered plugin (id = folder name; mount path is the derived `/<id>`), per case.
|
|
||||||
const p = (over: Partial<Plugin> & Pick<Plugin, "id">): Plugin => ({ apiVersion: "1.0.0", ...over });
|
|
||||||
|
|
||||||
test("findConflicts: a clean set has none", () => {
|
|
||||||
assert.deepEqual(findConflicts([p({ id: "a" }), p({ id: "b" })]), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findConflicts: a duplicate id and a colliding route are loud errors", () => {
|
|
||||||
const dupId = findConflicts([p({ id: "a" }), p({ id: "a" })]);
|
|
||||||
assert.ok(dupId.some((c) => c.kind === "id" && c.level === "error"));
|
|
||||||
|
|
||||||
// Cross-plugin routes can't collide (unique `/<id>` prefix); two identical routes in one can.
|
|
||||||
const noop = () => {};
|
|
||||||
const dupRoute = findConflicts([p({
|
|
||||||
id: "a",
|
|
||||||
routes: [{ handler: noop, method: "GET", path: "/t" }, { handler: noop, method: "GET", path: "/t" }],
|
|
||||||
})]);
|
|
||||||
assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t")));
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findConflicts: duplicate nav id is an error, a shared permission name only warns", () => {
|
|
||||||
const navDup = findConflicts([
|
|
||||||
p({ id: "a", nav: [{ id: "dup", label: "A" }] }),
|
|
||||||
p({ id: "b", nav: [{ id: "dup", label: "B" }] }),
|
|
||||||
]);
|
|
||||||
assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
|
||||||
|
|
||||||
// Sharing a permission across plugins is legitimate → warn, not error.
|
|
||||||
const permissionDup = findConflicts([
|
|
||||||
p({ id: "a", permissions: [{ name: "shared:read" }] }),
|
|
||||||
p({ id: "b", permissions: [{ name: "shared:read" }] }),
|
|
||||||
]);
|
|
||||||
assert.ok(permissionDup.some((c) => c.kind === "permission" && c.level === "warn"));
|
|
||||||
});
|
|
||||||
|
|
||||||
const noopHandler = () => {};
|
|
||||||
|
|
||||||
test("findConflicts: a route, nav node or profile entry gating on a permission no plugin declares warns once per plugin and name, sorted", () => {
|
|
||||||
const gated = findConflicts([
|
|
||||||
p({ id: "a", nav: [{ children: [{ id: "a:x", label: "X", permission: "navonly:read" }], id: "a:root", label: "A", session: true }], routes: [
|
|
||||||
{ handler: noopHandler, method: "GET", path: "/x", permission: "routeonly:read" },
|
|
||||||
{ handler: noopHandler, method: "GET", path: "/y", permission: "both:read" },
|
|
||||||
{ handler: noopHandler, method: "POST", path: "/y", permission: "both:read" },
|
|
||||||
{ handler: noopHandler, method: "GET", path: "/z", permission: "declared:write" },
|
|
||||||
], permissions: [], profileMenu: [{ href: "/p", label: "P", permission: "profileonly:read" }, { href: "/q", label: "Q", session: true }, { href: "/r", label: "R", public: true }] }),
|
|
||||||
p({ id: "b", nav: [{ id: "b:y", label: "Y", permission: "both:read" }], permissions: [{ name: "declared:write" }] }),
|
|
||||||
]);
|
|
||||||
assert.ok(gated.every((c) => c.kind === "permission" && c.level === "warn"));
|
|
||||||
assert.deepEqual(gated.map((c) => c.plugins), [["a"], ["a"], ["a"], ["a"], ["b"]]);
|
|
||||||
assert.deepEqual(gated.map((c) => /"([^"]+:[a-z]+)"/.exec(c.message)?.[1]), ["both:read", "navonly:read", "profileonly:read", "routeonly:read", "both:read"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findConflicts: a gate on a permission another plugin declares, or on none, stays quiet", () => {
|
|
||||||
assert.deepEqual(findConflicts([
|
|
||||||
p({ id: "a", permissions: [{ name: "a:read" }], routes: [{ handler: noopHandler, method: "GET", path: "/x", permission: "a:read" }] }),
|
|
||||||
p({ id: "b", routes: [{ handler: noopHandler, method: "GET", path: "/y", permission: "a:read" }, { handler: noopHandler, method: "GET", path: "/z", session: true }] }),
|
|
||||||
]), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
|
|
||||||
const handler = () => ({ html: "x" });
|
|
||||||
const homeDup = findConflicts([p({ id: "a", home: handler }), p({ id: "b", home: handler })]);
|
|
||||||
assert.ok(homeDup.some((c) => c.kind === "home" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
|
||||||
const dashDup = findConflicts([p({ id: "a", dashboard: handler }), p({ id: "b", dashboard: handler })]);
|
|
||||||
assert.ok(dashDup.some((c) => c.kind === "dashboard" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
|
||||||
// One owner of each (even both on one plugin) is fine.
|
|
||||||
assert.deepEqual(findConflicts([p({ id: "a", dashboard: handler, home: handler }), p({ id: "b" })]).filter((c) => c.kind === "home" || c.kind === "dashboard"), []);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Drift guard: every mount but the auth flows is listed by hand, so a new top-level built-in route
|
// Drift guard: every mount but the auth flows is listed by hand, so a new top-level built-in route
|
||||||
// needs a line here too.
|
// needs a line here too.
|
||||||
test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field) and `admin` (a plugin) are NOT reserved", () => {
|
test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field) and `admin` (a plugin) are NOT reserved", () => {
|
||||||
|
|||||||
+2
-100
@@ -8,7 +8,7 @@ import type { Gate } from "../auth/gate.ts";
|
|||||||
import type { RequestContext } from "../http/context.ts";
|
import type { RequestContext } from "../http/context.ts";
|
||||||
import type { ProfileMenuEntry } from "../ui/chrome.ts";
|
import type { ProfileMenuEntry } from "../ui/chrome.ts";
|
||||||
import type { NavNode } from "../ui/nav.ts";
|
import type { NavNode } from "../ui/nav.ts";
|
||||||
import { envName, type SettingDecl, type SettingsOf } from "./settings.ts";
|
import type { SettingDecl, SettingsOf } from "./settings.ts";
|
||||||
import type { StorageCredentials } from "./storage.ts";
|
import type { StorageCredentials } from "./storage.ts";
|
||||||
|
|
||||||
// The Plainpages release this contract ships in — see README → Contract versioning.
|
// The Plainpages release this contract ships in — see README → Contract versioning.
|
||||||
@@ -179,106 +179,8 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
|
|||||||
return { level: "ok", message: `apiVersion ${pluginVersion}` };
|
return { level: "ok", message: `apiVersion ${pluginVersion}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PluginConflict {
|
|
||||||
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route" | "setting";
|
|
||||||
level: "error" | "warn";
|
|
||||||
message: string;
|
|
||||||
plugins: string[]; // unique ids involved
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loud resolution, never last-write-wins: discovery throws on any "error" and logs every "warn".
|
|
||||||
// Mount-path uniqueness needs no rule of its own — it follows from the id check. Shared permission
|
|
||||||
// names are the one intentional overlap, so they warn rather than error.
|
|
||||||
export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
|
||||||
const out: PluginConflict[] = [];
|
|
||||||
|
|
||||||
const idCounts = new Map<string, number>();
|
|
||||||
for (const plugin of plugins) idCounts.set(plugin.id, (idCounts.get(plugin.id) ?? 0) + 1);
|
|
||||||
for (const [id, n] of idCounts) {
|
|
||||||
if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] });
|
|
||||||
}
|
|
||||||
|
|
||||||
// The landing pages are single slots: "/" (home) and "/dashboard" (dashboard) take one owner
|
|
||||||
// each — two plugins claiming either is a loud error, not a race.
|
|
||||||
for (const slot of ["home", "dashboard"] as const) {
|
|
||||||
const owners = plugins.filter((plugin) => plugin[slot]).map((plugin) => plugin.id);
|
|
||||||
if (owners.length > 1) out.push({ kind: slot, level: "error", message: `${owners.length} plugins claim "${slot}" (${owners.join(", ")}); only one may own that page`, plugins: uniq(owners) });
|
|
||||||
}
|
|
||||||
|
|
||||||
collect(plugins, (plugin, push) => {
|
|
||||||
for (const route of plugin.routes ?? []) push(`${route.method} ${fullPath(plugin.id, route.path)}`);
|
|
||||||
}).forEach((owners, key) => {
|
|
||||||
if (owners.length > 1) out.push({ kind: "route", level: "error", message: `${owners.length} routes resolve to "${key}"`, plugins: uniq(owners) });
|
|
||||||
});
|
|
||||||
|
|
||||||
collect(plugins, (plugin, push) => collectNavIds(plugin.nav, push)).forEach((owners, id) => {
|
|
||||||
if (owners.length > 1) out.push({ kind: "nav-id", level: "error", message: `nav id "${id}" used ${owners.length}×; override targets ids, so they must be unique`, plugins: uniq(owners) });
|
|
||||||
});
|
|
||||||
|
|
||||||
collect(plugins, (plugin, push) => {
|
|
||||||
for (const decl of plugin.permissions ?? []) push(decl.name);
|
|
||||||
}).forEach((owners, name) => {
|
|
||||||
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) });
|
|
||||||
});
|
|
||||||
|
|
||||||
out.push(...undeclaredGates(plugins));
|
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Declaring is optional, but the admin screens offer only declared names: a route gated on one nobody
|
|
||||||
// declares answers 403 (a nav node is hidden) until the Keto tuple is written by hand or
|
|
||||||
// ADMIN_PERMISSIONS seeds it.
|
|
||||||
function undeclaredGates(plugins: Plugin[]): PluginConflict[] {
|
|
||||||
const declared = new Set(declaredPermissions(plugins).map((decl) => decl.name));
|
|
||||||
const out: PluginConflict[] = [];
|
|
||||||
for (const plugin of plugins) {
|
|
||||||
const gated = new Set<string>();
|
|
||||||
for (const route of plugin.routes ?? []) if (route.permission != null) gated.add(route.permission);
|
|
||||||
collectNavPermissions(plugin.nav, (name) => gated.add(name));
|
|
||||||
for (const entry of plugin.profileMenu ?? []) if (entry.permission != null) gated.add(entry.permission);
|
|
||||||
for (const name of [...gated].sort()) {
|
|
||||||
if (!declared.has(name)) out.push({ kind: "permission", level: "warn", message: `plugin "${plugin.id}" gates on "${name}", which no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's \`permissions\``, plugins: [plugin.id] });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectNavPermissions(nodes: NavNode[] | undefined, push: (name: string) => void): void {
|
|
||||||
for (const node of nodes ?? []) {
|
|
||||||
if (node.permission != null) push(node.permission);
|
|
||||||
collectNavPermissions(node.children, push);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map each emitted key → the plugin ids that emitted it (repeats kept, so within-plugin dups count).
|
|
||||||
function collect(plugins: Plugin[], emit: (plugin: Plugin, push: (key: string) => void) => void): Map<string, string[]> {
|
|
||||||
const owners = new Map<string, string[]>();
|
|
||||||
for (const plugin of plugins) emit(plugin, (key) => owners.set(key, [...(owners.get(key) ?? []), plugin.id]));
|
|
||||||
return owners;
|
|
||||||
}
|
|
||||||
|
|
||||||
function collectNavIds(nodes: NavNode[] | undefined, push: (id: string) => void): void {
|
|
||||||
for (const node of nodes ?? []) {
|
|
||||||
if (node.id != null) push(node.id);
|
|
||||||
collectNavIds(node.children, push);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A route's full path = the plugin's mount path `/<id>` + the route path. The single source of
|
// A route's full path = the plugin's mount path `/<id>` + the route path. The single source of
|
||||||
// truth for both conflict detection (here) and the router, so they can't disagree.
|
// truth for both conflict detection and the router, so they can't disagree.
|
||||||
export function fullPath(id: string, path: string): string {
|
export function fullPath(id: string, path: string): string {
|
||||||
return `/${id}${path.startsWith("/") ? path : `/${path}`}`;
|
return `/${id}${path.startsWith("/") ? path : `/${path}`}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function uniq(xs: string[]): string[] {
|
|
||||||
return [...new Set(xs)];
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@
|
|||||||
|
|
||||||
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|
||||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||||
| 59 | 0.5.0 | | | **Lower the reading cost of the files a chunk reads most before its first commit.** | 2 | 2 | 1 | 3 | 4 | code health by 3 | 9.0 |
|
|
||||||
| 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 |
|
| 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 |
|
| 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 |
|
||||||
| 41 | 0.5.0 | | | **Apply a permission save as one Keto call, so it cannot stop part-way.** | 2 | 2 | 2 | 4 | 3 | 2 by 2 | 2.1 |
|
| 41 | 0.5.0 | | | **Apply a permission save as one Keto call, so it cannot stop part-way.** | 2 | 2 | 2 | 4 | 3 | 2 by 2 | 2.1 |
|
||||||
@@ -28,13 +27,6 @@
|
|||||||
|
|
||||||
## Details
|
## Details
|
||||||
|
|
||||||
### 59. Lower the reading cost of the files a chunk reads most before its first commit.
|
|
||||||
|
|
||||||
`reading-cost` reports a rise: the last 5 chunks' median is 87251 new input tokens before their
|
|
||||||
first commit, the 5 before them 70583. The files the last 5 chunks read most: `todo.md` 34 times,
|
|
||||||
`README.md` 24, `src/plugin-host/plugin.ts` 11, `src/plugin-host/plugin.test.ts` 10, `AGENTS.md` 6.
|
|
||||||
Cut or split what a chunk reads without needing it, then run `reading-cost --reset`.
|
|
||||||
|
|
||||||
### 2. Rename the admin example plugin to a name that says what it administers.
|
### 2. Rename the admin example plugin to a name that says what it administers.
|
||||||
|
|
||||||
The new name is `auth-admin`, chosen by the maintainer. The id is also the mount path, so the compose files,
|
The new name is `auth-admin`, chosen by the maintainer. The id is also the mount path, so the compose files,
|
||||||
|
|||||||
Reference in New Issue
Block a user