Point a failed discovery at the stale plugins/ copy, and document upgrading
CI / full-gate (push) Successful in 2m39s

This commit is contained in:
2026-08-05 17:52:34 +02:00
parent f76cd2a267
commit 45b16824f1
5 changed files with 58 additions and 1 deletions
+9
View File
@@ -93,6 +93,15 @@ them. Revisit only if the stated reason stops holding.
gates on one operation, so it gates on a permission, and a bundle is just a group with several
grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the
separate per-row tier.
- **Tightening a discovery rule breaks every already-copied plugin, and CI cannot see it.** `plugins/`
is an operator-owned drop-in mount that ships empty, so every test — unit and e2e — only ever sees a
*fresh* copy of `examples/`. An operator's copy is whatever version they took. When a manifest rule
gets stricter, it must ship with a README → Upgrading entry and a re-copy instruction, and the
discovery error carries a line saying so. Learned the hard way twice on 2026-08-05: the
`<resource>:<action>` rule bricked a pre-existing `plugins/admin` at boot, and the matching
`ADMIN_PERMISSIONS` check bricked it on a value that had been the shipped default. Fail-loud stays
right — the alternative is a route gating on a name nobody can be granted, i.e. a permanent silent
403 — but "loud" has to include the remedy.
- **A permission name is always `<resource>:<action>`** — `scheduling:read`, `users:write`. A bare
word names *who someone is* — a role — and roles are groups here; the old catch-all `admin`
permission was exactly that mistake, split into `users:`/`groups:`/`permissions:`/`oauth2-clients:`
+24
View File
@@ -113,6 +113,7 @@ From here, render real pages against the app shell and fetch upstream data — s
- [the full gate](#the-full-gate-one-command)
- [CI/CD](#cicd)
- [Production & deployment](#production--deployment)
- [Upgrading](#upgrading)
- [Observability](#observability)
- [JWT signing key & rotation](#jwt-signing-key--rotation)
- [Project layout](#project-layout)
@@ -1740,6 +1741,29 @@ declared permission names in Keto (plus any `ADMIN_PERMISSIONS`), so permission
dropped-in plugin) resolve out of the box. The web app waits for Kratos + Keto to be healthy
*and* the bootstrap to finish before starting. **Change the demo admin before production.**
## Upgrading
**Re-copy your drop-in plugins.** Anything under `plugins/` is *your* copy — the host never updates
it. When you pull a newer Plainpages, a plugin you copied from `examples/` is still the old one, and
the host may have tightened a manifest rule since. Discovery fails loud at boot rather than running a
plugin it can't honour, naming the plugin and the rule:
```bash
rm -rf plugins/admin && cp -r examples/plugins/admin plugins/admin
docker compose up -d --build
```
Do the same for any other folder you copied out of `examples/`. A plugin you wrote yourself needs the
manifest change the error names — the same rules the shipped examples follow.
### Breaking changes
- **Permission names must be `<resource>:<action>`** (2026-08-05). A manifest gating on — or
declaring — a bare word like `admin` now stops the boot. The bundled admin plugin was split into
`users:`/`groups:`/`oauth2-clients:` × `read`/`write`, so a copy taken before this needs re-copying.
`ADMIN_PERMISSIONS` is held to the same rule, but an unusable value there is dropped with a warning
rather than failing the boot. See [Naming a permission](#naming-a-permission).
## Observability
Logging is **structured** and **OTLP-native**, on
+5
View File
@@ -13,6 +13,11 @@ docker compose up -d
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so the
section appears in the menu and the screens work immediately.
> **Already have `plugins/admin` from an earlier version?** Re-copy it. Your copy is yours — the host
> never updates it — and this plugin's permissions changed on 2026-08-05 (`admin` → `users:`/`groups:`/
> `oauth2-clients:` × `read`/`write`). A stale copy stops the boot with a message naming it; see
> [README → Upgrading](../../../README.md#upgrading).
Every string it renders comes from its own catalogs (`i18n/en-US.ts`, `i18n/sv-SE.ts`) — the nav
labels included, which are catalog keys in `admin-shared.ts`. Each pure view-model builder takes an
optional `t`; the handlers pass `ctx.t`, and the default is the plugin's own English so a unit test
+12
View File
@@ -67,6 +67,18 @@ for (const c of badCases) {
});
}
// The reader of a discovery failure is usually an operator whose plugins/ copy went stale after an
// upgrade, not the author of the manifest — so the message has to carry the remedy, not just the
// rule. A pre-existing `plugins/admin` gating on the old `admin` permission is exactly this case.
test("a discovery failure tells the operator their plugins/ copy may just be out of date", async (t) => {
const dir = scaffold(t, { "admin/plugin.ts": `export default { apiVersion: "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
return true;
});
});
test("a route + nav node may be marked public and load fine", async (t) => {
const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` });
const plugins = await discoverPlugins({ dir });
+8 -1
View File
@@ -65,7 +65,14 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
}
if (errors.length) {
throw new Error(`Plugin discovery failed:\n${errors.map((e) => ` - ${e}`).join("\n")}`);
// `plugins/` is a drop-in mount the operator owns, so the reader of this message often didn't
// write the manifest — they copied it. Tightening a contract rule breaks those copies at boot,
// and the rule alone doesn't tell them the remedy is one command.
throw new Error(
`Plugin discovery failed:\n${errors.map((e) => ` - ${e}`).join("\n")}\n` +
`A plugin under plugins/ is your own copy. If it came from examples/, re-copy it — ` +
`the host contract may have changed since (see README → Upgrading).`,
);
}
return plugins;
}