Point a failed discovery at the stale plugins/ copy, and document upgrading
CI / full-gate (push) Successful in 2m39s
CI / full-gate (push) Successful in 2m39s
This commit is contained in:
@@ -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
|
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
|
grants (groups nest). Ory's own "permission" (the `Resource` `permits`: view/edit/delete) is the
|
||||||
separate per-row tier.
|
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
|
- **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`
|
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:`
|
permission was exactly that mistake, split into `users:`/`groups:`/`permissions:`/`oauth2-clients:`
|
||||||
|
|||||||
@@ -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)
|
- [the full gate](#the-full-gate-one-command)
|
||||||
- [CI/CD](#cicd)
|
- [CI/CD](#cicd)
|
||||||
- [Production & deployment](#production--deployment)
|
- [Production & deployment](#production--deployment)
|
||||||
|
- [Upgrading](#upgrading)
|
||||||
- [Observability](#observability)
|
- [Observability](#observability)
|
||||||
- [JWT signing key & rotation](#jwt-signing-key--rotation)
|
- [JWT signing key & rotation](#jwt-signing-key--rotation)
|
||||||
- [Project layout](#project-layout)
|
- [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
|
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.**
|
*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
|
## Observability
|
||||||
|
|
||||||
Logging is **structured** and **OTLP-native**, on
|
Logging is **structured** and **OTLP-native**, on
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ docker compose up -d
|
|||||||
The bootstrap grants the seeded `admin@plainpages.local` every permission this plugin declares, so the
|
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.
|
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
|
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
|
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
|
optional `t`; the handlers pass `ctx.t`, and the default is the plugin's own English so a unit test
|
||||||
|
|||||||
@@ -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) => {
|
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 dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` });
|
||||||
const plugins = await discoverPlugins({ dir });
|
const plugins = await discoverPlugins({ dir });
|
||||||
|
|||||||
@@ -65,7 +65,14 @@ export async function discoverPlugins(options: DiscoverOptions = {}): Promise<Pl
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (errors.length) {
|
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;
|
return plugins;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user