§7 review checkpoint (todo §7); ran the architecture + product reviewers on the whole project and addressed findings, no Critical from either. Made permissions honest + decoupled the host from the plugin: new pure seedRoles + bootstrap discoverPlugins() seeds the demo admin admin(/ADMIN_ROLES) ∪ every discovered plugin's declared tokens, dropped the hardcoded scheduling:* from compose ADMIN_ROLES (clean-clone unchanged); docs now state a route/nav permission is a coarse role granted as Keto Role:<token>#members. Added src/plugin-api.ts — the stable author barrel the reference plugin now imports from instead of deep src/* (the contract boundary in code). Made per-plugin CSS usable: shell styles slot + plugins/scheduling/public/scheduling.css linked from the views. Reference now demonstrates hooks.onBoot validating SCHEDULING_UPSTREAM fail-loud (assertHttpUrl). Build ctx.chrome at most once per request (memoized). Doc honesty: fixed the false visual.spec coverage comment, softened the "every plugin ships a Playwright test" claim (authed flow = §8), added an Upstream contract block to the plugin README. Added LICENSE (MIT). Stability-reviewer APPROVE, no Critical/High; addressed both Low nits. typecheck + 301 units green. Deferred: internal route-table (M1)→§9, safeUrl()→§9, data-table empty-state + success-flash→§8/polish, apiVersion-literal enforcement (prose), permission→requireRole rename (future minor).

This commit is contained in:
2026-06-19 15:31:53 +02:00
parent 45d9b2ede9
commit 4e97fb619e
20 changed files with 214 additions and 50 deletions

View File

@@ -55,8 +55,13 @@ Nothing else references it; the operator stays in control through the central me
## The manifest
A plugin imports its host surface from one module — `src/plugin-api.ts`, the **stable author
barrel** (`definePlugin`, the manifest/handler types, `RequestContext`, the guards, and the
body/CSRF/list-query helpers). That barrel *is* the contract boundary; don't reach into deeper
`src/*` modules — the host may refactor those freely as long as the barrel holds.
```ts
import { definePlugin } from "../../src/plugin.ts";
import { definePlugin } from "../../src/plugin-api.ts";
import { listShifts, createShift } from "./shifts.ts";
export default definePlugin({
@@ -69,7 +74,8 @@ export default definePlugin({
children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }],
}],
// Permission tokens this plugin introduces (for docs + Keto seeding). Optional.
// Permission tokens this plugin introduces. Declared for documentation, conflict detection, and
// bootstrap seeding (the demo admin is granted every discovered plugin's tokens). Optional.
permissions: [
{ token: "scheduling:read", description: "View shifts" },
{ token: "scheduling:write", description: "Create and edit shifts" },
@@ -93,7 +99,7 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder
| --- | --- | --- |
| `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. See [Versioning](#contract-versioning). |
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/icons.ts`); node `id`s must be globally unique. |
| `permissions` | no | Tokens this plugin introduces; declared for documentation and seeding. |
| `permissions` | no | Tokens this plugin introduces; declared for docs, conflict detection, and bootstrap seeding (see [Nav & permissions](#nav--permissions)). |
| `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). |
@@ -122,8 +128,7 @@ type RouteResult =
```ts
// shifts.ts
import type { RequestContext } from "../../src/context.ts";
import { parseListQuery } from "../../src/list-query.ts";
import { parseListQuery, type RequestContext } from "../../src/plugin-api.ts";
export async function listShifts(ctx: RequestContext) {
const q = parseListQuery(ctx.url);
@@ -135,8 +140,10 @@ export async function listShifts(ctx: RequestContext) {
- **`view`** resolves against the plugin's own `views/` (`src/view-resolver.ts`) — nested names
like `"shifts/edit"` work, and an out-of-bounds name is refused. The template may `include()`
the core building-block partials (app shell, nav tree, data table, …) and its own
partials/subfolders to render a full page — exactly as the built-in screens do.
- **Finer authorization than the route `permission`** uses the guards in `src/guards.ts`:
partials/subfolders to render a full page — exactly as the built-in screens do. To load the
plugin's own CSS, pass its `/public/<id>/x.css` href in the shell's `styles` slot (an array of
extra stylesheet hrefs) — see the reference's `views/shifts.ejs`.
- **Finer authorization than the route `permission`** uses the guards from `src/plugin-api.ts`:
`requireSession(ctx)` (assert a session — throws a `GuardError` the host turns into a redirect
to sign in), `can(ctx, role)` (a coarse JWT-claim check, zero I/O), and `check(keto, ctx,
{namespace, object, relation})` (a live Keto check for relationship rules — the subject is the
@@ -205,10 +212,18 @@ depth, counts, and icons; see `composeNav` for the node shape. A node's `icon` i
icon**, referenced by its sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are
`ICON_NAMES` in `src/icons.ts`, and adding one means registering its lucide name there.
**A `permission` token is a coarse role.** The route/nav gate passes iff the user's JWT `roles`
include the token; those roles come from Keto at login, so an operator grants a token by writing the
Keto tuple `Role:<token>#members@user:<id>` (or to a group) — the admin **Roles** screen does this.
(The fine-grained, per-row tier is the separate Keto `Resource` namespace — see the README's *Three
tiers of "may I?"*; it is not what a route `permission` checks.)
Permission tokens are a **shared global namespace** — that's deliberate, so an operator grants
`scheduling:read` once in Keto and every plugin referencing it is gated consistently. Namespace
your tokens as `<id>:<action>` to avoid accidental clashes. Declaring them in `permissions` is
optional but recommended (it documents them and lets the bootstrap seed Keto, §3).
optional but recommended: it documents them, feeds conflict detection, and lets the one-command
bootstrap seed them — the demo admin is granted every discovered plugin's declared tokens (§3), so
a dropped-in plugin works out of the box without editing host config.
## Contract versioning
@@ -293,9 +308,10 @@ worked example: thin handlers bound to an injectable upstream client, unit-teste
so a test can mount a single manifest and assert its routes, nav, and gating without the rest
of the stack.
3. **E2E the user-facing flow.** Per AGENTS.md §6, every plugin page/form ships *with* a
Playwright test in `e2e/`, side-effect-free so the suite stays `fullyParallel`. The test runs
against the live `web` service with the plugin mounted.
3. **E2E the user-facing flow.** Per AGENTS.md §6, ship a side-effect-free Playwright test in
`e2e/` for each plugin page/form so the suite stays `fullyParallel`, run against the live `web`
service with the plugin mounted. The reference's permission-gating is covered in `visual.spec.ts`;
its authenticated list/form happy-path is the §8 full-E2E item (needs cross-host login infra).
The validation an author hits is the same the host runs: bad `apiVersion` or a conflict
([above](#conflict-rules)) stops boot with a precise message naming the plugin(s) involved.