diff --git a/AGENTS.md b/AGENTS.md index 105ad54..ef3281f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 + `:` 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 `:`** — `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:` diff --git a/README.md b/README.md index 19a7a8e..b25f06c 100644 --- a/README.md +++ b/README.md @@ -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 `:`** (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 diff --git a/examples/plugins/admin/README.md b/examples/plugins/admin/README.md index 564854e..c1fdf82 100644 --- a/examples/plugins/admin/README.md +++ b/examples/plugins/admin/README.md @@ -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 diff --git a/src/plugin-host/discovery.test.ts b/src/plugin-host/discovery.test.ts index 7283721..b1b8cd5 100644 --- a/src/plugin-host/discovery.test.ts +++ b/src/plugin-host/discovery.test.ts @@ -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 }); diff --git a/src/plugin-host/discovery.ts b/src/plugin-host/discovery.ts index ececc56..3623ccc 100644 --- a/src/plugin-host/discovery.ts +++ b/src/plugin-host/discovery.ts @@ -65,7 +65,14 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise ` - ${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; }