Compare commits

...

4 Commits

23 changed files with 178 additions and 133 deletions
+11 -4
View File
@@ -182,12 +182,19 @@ Revisit only if the stated reason stops holding.
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport × 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
verb — a route table must answer "what does this need?" on its own. verb — a route table must answer "what does this need?" on its own.
- **A gate is one of three, and `session` is a first-class one.** A route or nav node names exactly - **A gate is one of three, named exactly once, and `session` is a first-class one.** A route or nav
one of `public`, `session`, `permission` (discovery refuses two), and `src/auth/gate.ts` is the one node names exactly one of `public`, `session`, `permission` — discovery refuses none, two, and a
home of the rule the router and the menu both read. `session` exists because a plugin whose data is flag spelled anything but `true`, so a forgotten gate fails the boot rather than publishing a page.
`src/auth/gate.ts` is the one home of the rule the plugin router, the host's own route table and
the menu all read. Exactly-one-gate is a discovery-time rule on manifests, not a runtime
invariant: `allows({}, user)` stays open **by design**, because the central override's `groups`
builds header nodes that carry no gate. Making `allows` fail closed would hide every
operator-grouped section. `session` exists because a plugin whose data is
the visitor's own — their upstream account, their own tokens — has no distinction a permission could the visitor's own — their upstream account, their own tokens — has no distinction a permission could
name; the alternative, granting every newly registered user a permission, couples the identity name; the alternative, granting every newly registered user a permission, couples the identity
lifecycle to a Keto write that nothing retries when it fails. lifecycle to a Keto write that nothing retries when it fails. A page scoped to "mine" joins on
`ctx.user.id`, never the email — an address is user-changeable and can be reassigned to someone
who would then inherit the previous holder's rows.
- **The reference plugin's two shift pages duplicate a view model and markup on purpose.** An example - **The reference plugin's two shift pages duplicate a view model and markup on purpose.** An example
is read far more often than it is changed, and each page reads top to bottom on its own. **Valid is read far more often than it is changed, and each page reads top to bottom on its own. **Valid
while `examples/plugins/scheduling` stays a teaching artifact rather than a maintained product.** while `examples/plugins/scheduling` stays a teaching artifact rather than a maintained product.**
+23 -23
View File
@@ -352,7 +352,7 @@ import { listThings, createThings } from "./handlers.ts";
export default definePlugin({ export default definePlugin({
apiVersion: "0.4.0", // semver string of the host contract this plugin was built against (see Versioning) apiVersion: "0.4.0", // semver string of the host contract this plugin was built against (see Versioning)
// Nav fragment, merged into the global menu and permission-filtered per user. // Nav fragment, merged into the global menu and gate-filtered per user.
// `icon` is a Lucide icon by its sprite id (src/ui/icons.ts). // `icon` is a Lucide icon by its sprite id (src/ui/icons.ts).
nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }], nav: [{ href: "/things", icon: "i-cal", id: "things:list", label: "Things", permission: "things:read" }],
@@ -362,7 +362,7 @@ export default definePlugin({
{ description: "Create and edit things", name: "things:write" }, { description: "Create and edit things", name: "things:write" },
], ],
// Route handlers, mounted under the plugin's path (/things). `permission` gates first. // Route handlers, mounted under the plugin's path (/things). The gate runs first.
routes: [ routes: [
{ method: "GET", path: "/", permission: "things:read", handler: listThings }, { method: "GET", path: "/", permission: "things:read", handler: listThings },
{ method: "POST", path: "/", permission: "things:write", handler: createThings }, { method: "POST", path: "/", permission: "things:write", handler: createThings },
@@ -379,7 +379,7 @@ folder-derived `id` to produce the loaded `Plugin`.
| `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). | | `apiVersion` | yes | Semver string of the host contract the plugin was built against. See [Versioning](#contract-versioning). |
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). | | `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. | | `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). Every node names [exactly one gate](#public-pages--menu-items). `icon` is a Lucide sprite id (`src/ui/icons.ts`); node `id`s must be globally unique. A `label` that names a catalog key is [translated](#languages-i18n); anything else renders as written. |
| `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). | | `permissions` | no | Permissions this plugin gates on. See [Nav & permission gates](#nav--permission-gates). |
| `routes` | no | See [Routes & handlers](#routes--handlers). | | `routes` | no | See [Routes & handlers](#routes--handlers). |
| `hooks` | no | See [Hooks](#hooks). | | `hooks` | no | See [Hooks](#hooks). |
@@ -390,14 +390,13 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
### Routes & handlers ### Routes & handlers
A route is `{ method, path, permission?, public?, session?, handler }`. `path` is **relative to the plugin's A route is `{ method, path, handler }` plus [exactly one gate](#public-pages--menu-items) —
`permission`, `public: true` or `session: true`. `path` is **relative to the plugin's
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches
`method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the `method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the
`permission` gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with
the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login` the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login`
with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page. with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page.
`public: true` means no gate at all, `session: true` any signed-in user (see
[Public pages](#public-pages--menu-items)).
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`. `method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
@@ -571,27 +570,28 @@ system plugins you author or vendor. An ordinary domain plugin ignores it.
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
applies the central override and then **filters per user** by the permissions in the session JWT: a applies the central override and then **filters per user** by the permissions in the session JWT: a
node shows iff it is `public`, is `session` and someone is signed in, declares no `permission`, or node shows iff it is `public`, is `session` and someone is signed in, or names a `permission` the
the user holds that name. A node's `icon` is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are user holds. A node's `icon` is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the
`ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there. available ids are `ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name
there.
**Gating a section header.** A `permission` on the header takes the whole subtree with it. When the **Gating a section header.** A `permission` on the header takes the whole subtree with it. When the
children need *different* permissions, leave the header ungated and gate each child — `composeNav` children need *different* permissions, mark the header `public: true` — it then gates nothing, each
drops a header whose children all filtered out. That only works while the header carries **no child decides, and `composeNav` drops a header whose children all filtered out. That only works while
`href`**: give it one and it survives as an ungated leaf, visible to everyone. the header carries **no `href`**: give it one and it survives as a leaf, visible to everyone.
#### Public pages & menu items #### Public pages & menu items
A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu. A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu —
That is the same as omitting `permission`, but stated outright so public is a deliberate choice open stated outright, so it is a deliberate choice rather than a forgotten gate.
rather than a forgotten gate.
**`session: true`** takes any signed-in user, with no grant to hold — for a plugin whose data is the **`session: true`** takes any signed-in user, with no grant to hold — for a plugin whose data is the
visitor's own. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a visitor's own. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
permission gate does. permission gate does.
A declaration names **exactly one** of the three; two of them contradict, and discovery refuses the Every route and nav node names **exactly one** of the three, spelled `true` (or a permission name).
plugin at boot. Naming none, naming two, or spelling one `false` is refused at boot — so a forgotten gate fails the
plugin instead of publishing a page.
A public page still renders in the native shell; for an anonymous visitor `ctx.user` is `null`, the A public page still renders in the native shell; for an anonymous visitor `ctx.user` is `null`, the
shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden, shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden,
@@ -644,7 +644,7 @@ The host detects collisions across all discovered plugins with `findConflicts` a
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a `home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
route/nav node naming more than one gate. route/nav node that does not name [exactly one gate](#public-pages--menu-items).
### Hooks ### Hooks
@@ -920,10 +920,10 @@ The menu is **driven entirely by config** and assembled from two sources:
export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } }); export default defineMenu({ branding: { name: "Acme Ops" }, override: { hide: ["teams"] } });
``` ```
Every nav item may carry a `permission`; the rendered tree is **filtered per user** from the session Every nav node a **plugin** declares names one gate — a `permission`, **`public: true`** (everyone)
JWT (no per-request authz call), so the menu only shows what that person can reach. An item may or **`session: true`** (anyone signed in); a header this override groups takes none, and shows
instead be **`public: true`** (everyone) or **`session: true`** (anyone signed in) — one gate per whenever a child does. The rendered tree is **filtered per user** from the session JWT (no
item, never two. per-request authz call), so the menu only shows what that person can reach.
Branding (name, logo, default theme) renders in the app shell. Branding (name, logo, default theme) renders in the app shell.
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders **One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders
+2 -3
View File
@@ -194,11 +194,10 @@ test.describe.serial("authenticated admin journey", () => {
await expect(page.locator("h1")).toHaveText("Shifts"); await expect(page.locator("h1")).toHaveText("Shifts");
await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream await expect(page.locator("table")).toContainText("Morning — Front desk"); // seeded by the mock upstream
// The session-gated page asks the upstream for this visitor's rows: the one seeded against the // The admin owns none of the demo's rows, so an empty page is the no-leak assertion.
// signed-in admin is there, and another person's shift is not.
await page.goto("/scheduling/mine"); await page.goto("/scheduling/mine");
await expect(page.locator("h1")).toHaveText("My shifts"); await expect(page.locator("h1")).toHaveText("My shifts");
await expect(page.locator("table")).toContainText("Night — Escalations"); await expect(page.getByText("No shifts are assigned to admin@plainpages.local")).toBeVisible();
await expect(page.locator("table")).not.toContainText("Morning — Front desk"); await expect(page.locator("table")).not.toContainText("Morning — Front desk");
}); });
+2 -3
View File
@@ -179,9 +179,8 @@ test("the reference plugin: public Overview is open to all, My shifts takes any
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
await expect(page.locator('.sidebar a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough await expect(page.locator('.sidebar a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough
// And the page itself renders for that same member, holding no permission at all. This stack runs // No shifts upstream on this stack, so this also pins the degraded page: the reason, never a 500
// no shifts upstream, so it also pins the degraded page: the reason, never a 500 and never a claim // and never a claim about what is assigned.
// about what is assigned. The working page is asserted against a real upstream in full-flow.spec.
await page.goto("/scheduling/mine"); await page.goto("/scheduling/mine");
await expect(page.getByRole("heading", { name: "My shifts" })).toBeVisible(); await expect(page.getByRole("heading", { name: "My shifts" })).toBeVisible();
await expect(page.getByText("Couldn't reach the scheduling service")).toBeVisible(); await expect(page.getByText("Couldn't reach the scheduling service")).toBeVisible();
+4 -4
View File
@@ -35,10 +35,9 @@ export function actionForMethod(method: string): AdminAction {
return verb === "GET" || verb === "HEAD" ? "read" : "write"; return verb === "GET" || verb === "HEAD" ? "read" : "write";
} }
// The plugin's nav fragment: an ungated "Admin" header + its three screens, each gated on its own // The plugin's nav fragment: the "Admin" header + its four screens, each gated on its own read
// read permission. The header carries no `permission` because a user may hold one screen's and not // permission. composeNav drops a header left with no visible children, so a user holding none of
// another's; composeNav drops a header left with no visible children, so a user holding none of the // them never sees the section. The host current-marks the active item — no `current`/`open` here.
// three never sees the section. The host current-marks the active item — no `current`/`open` here.
export const ADMIN_NAV: NavNode = { export const ADMIN_NAV: NavNode = {
children: [ children: [
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") }, { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "admin.nav.users", permission: permissionName("users", "read") },
@@ -49,6 +48,7 @@ export const ADMIN_NAV: NavNode = {
icon: "i-shield", icon: "i-shield",
id: "admin", id: "admin",
label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels label: "admin.nav.section", // a key in this plugin's catalog; the host translates nav labels
public: true, // the header gates nothing; every child needs a permission, and an empty header is dropped
}; };
// The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already // The screen gate: a signed-in user holding this request's `<resource>:<action>`. Each route already
+6 -2
View File
@@ -18,6 +18,9 @@ What it demonstrates:
- **All three route gates** — the Overview is `public` (anyone), "My shifts" is `session` (any - **All three route gates** — the Overview is `public` (anyone), "My shifts" is `session` (any
signed-in visitor, showing only rows assigned to them), and "Shifts" is gated on `scheduling:read` / signed-in visitor, showing only rows assigned to them), and "Shifts" is gated on `scheduling:read` /
`scheduling:write`; a leaf whose gate a visitor fails is invisible in the menu. `scheduling:write`; a leaf whose gate a visitor fails is invisible in the menu.
- **Ownership joined on the identity id** — "My shifts" asks the upstream for `assigneeId=ctx.user.id`,
the opaque subject id, and renders the row's separate `assignee` display name. An email address is
user-changeable and can be reassigned to someone else, who would then inherit those rows.
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including - **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
the views carry the visitor's language onto their links with `localeHref()`. the views carry the visitor's language onto their links with `localeHref()`.
@@ -39,7 +42,7 @@ Your backend must expose two routes; the plugin treats any non-2xx as a recovera
| Route | Request | Success | Response body | | Route | Request | Success | Response body |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| `GET /shifts` | `Accept: application/json`, optional `?assignee=<who>` | `200` | JSON array of `{ id, title, assignee, start, end }` (all strings; missing fields coerce to `""`). With `assignee`, only that person's rows | | `GET /shifts` | `Accept: application/json`, optional `?assigneeId=<id>` | `200` | JSON array of `{ id, title, assignee, assigneeId, start, end }` (all strings; missing fields coerce to `""`). With `assigneeId`, only that person's rows |
| `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) | | `POST /shifts` | JSON body `{ title, assignee, start, end }` | `2xx` | ignored (the plugin POST-redirect-GETs back to the list) |
Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the Domain rules (overlap, capacity, time ordering) live in your backend — reject with a 4xx and the
@@ -54,4 +57,5 @@ cosmetically) — normalise to your backend's format there if it matters.
A user sees the shift list once they hold the `scheduling:read` permission in Keto (and A user sees the shift list once they hold the `scheduling:read` permission in Keto (and
`scheduling:write` to create). The one-command bootstrap grants both to the demo admin, so the seeded `scheduling:write` to create). The one-command bootstrap grants both to the demo admin, so the seeded
`admin@plainpages.local` can use it immediately. "My shifts" needs no grant at all — signing in is `admin@plainpages.local` can use it immediately. "My shifts" needs no grant at all — signing in is
its whole gate. its whole gate; it lists the rows this demo upstream holds against the signed-in visitor's id, and
the demo's seeded rows belong to three made-up people, so a freshly seeded admin sees it empty.
+1
View File
@@ -31,6 +31,7 @@ export default definePlugin({
icon: "i-cal", icon: "i-cal",
id: "scheduling", id: "scheduling",
label: "scheduling.nav.section", label: "scheduling.nav.section",
public: true, // the header gates nothing; each child names its own gate, and an empty header is dropped
}], }],
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`. // Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`.
+25 -9
View File
@@ -25,8 +25,8 @@ function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; us
} }
const SHIFTS: Shift[] = [ const SHIFTS: Shift[] = [
{ assignee: "Avery Kline", end: "12:00", id: "1", start: "08:00", title: "Morning desk" }, { assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "1", start: "08:00", title: "Morning desk" },
{ assignee: "Blair Mora", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" }, { assignee: "Blair Mora", assigneeId: "019bdc1a-4a83-7de2-8f05-6c93a71be4d8", end: "17:00", id: "2", start: "12:00", title: "Afternoon support" },
]; ];
const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over }); const fakeUpstream = (over: Partial<ShiftsUpstream> = {}): ShiftsUpstream => ({ create: async () => {}, list: async () => SHIFTS, ...over });
@@ -63,11 +63,11 @@ test("createUpstream.list fetches /shifts, asks for JSON, and maps the rows", as
const http = (async (url, init) => { const http = (async (url, init) => {
seen = String(url); seen = String(url);
assert.equal((init?.headers as Record<string, string>).accept, "application/json"); assert.equal((init?.headers as Record<string, string>).accept, "application/json");
return new Response(JSON.stringify([{ assignee: "A", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 }); return new Response(JSON.stringify([{ assignee: "A", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", end: "2", id: "x", start: "1", title: "T", extra: "ignored" }]), { status: 200 });
}) as typeof fetch; }) as typeof fetch;
const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed const shifts = await createUpstream(() => "http://up:4000/", http).list(); // trailing slash trimmed
assert.equal(seen, "http://up:4000/shifts"); assert.equal(seen, "http://up:4000/shifts");
assert.deepEqual(shifts, [{ assignee: "A", end: "2", id: "x", start: "1", title: "T" }]); assert.deepEqual(shifts, [{ assignee: "A", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", end: "2", id: "x", start: "1", title: "T" }]);
}); });
test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => { test("createUpstream throws UpstreamError carrying the status on a non-2xx", async () => {
@@ -174,19 +174,35 @@ test("buildFormModel marks title/assignee required and attaches field errors", (
// ---- the session-gated page: the visitor's own rows ---- // ---- the session-gated page: the visitor's own rows ----
test("my shifts asks the upstream for the visitor's own rows, and names them in the empty state", async () => { test("my shifts scopes the upstream read by the visitor's id, and names them in the empty state", async () => {
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] }; const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
const mine: Shift = { assignee: "blair.mora@example.test", end: "22:00", id: "3", start: "17:00", title: "Evening on-call" }; const mine: Shift = { assignee: "Blair Mora", assigneeId: user.id, end: "22:00", id: "3", start: "17:00", title: "Evening on-call" };
let asked: { assignee?: string } | undefined; let asked: { assigneeId?: string } | undefined;
const upstream = fakeUpstream({ list: async (opts) => { asked = opts; return [mine]; } }); const upstream = fakeUpstream({ list: async (opts) => { asked = opts; return [mine]; } });
const r = asView(await myShifts(upstream)(fakeCtx({ url: "http://localhost/scheduling/mine", user }))); const r = asView(await myShifts(upstream)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
assert.equal(r.view, "mine"); assert.equal(r.view, "mine");
assert.deepEqual(asked, { assignee: "Blair.Mora@example.test" }); assert.deepEqual(asked, { assigneeId: "01a06091-baa3-71f4-a068-4879972979ff" }); // the id, never the address
const table = r.data["table"] as { emptyText: string; rows: { name: string }[] }; const table = r.data["table"] as { emptyText: string; rows: { name: string }[] };
assert.deepEqual(table.rows.map((row) => row.name), ["Evening on-call"]); assert.deepEqual(table.rows.map((row) => row.name), ["Evening on-call"]);
assert.match(table.emptyText, /Blair\.Mora@example\.test/); // an empty page still says whose it is assert.match(table.emptyText, /Blair\.Mora@example\.test/); // an empty page still says whose it is
// The route carries `session: true`, but the handler asserts the session itself rather than trusting it. // `requireSession` narrows `ctx.user` from `User | null` to `User` — the one part of the route's
// `session: true` guarantee the contract cannot state in the handler's type.
await assert.rejects(async () => { await myShifts(fakeUpstream())(fakeCtx()); }, GuardError); await assert.rejects(async () => { await myShifts(fakeUpstream())(fakeCtx()); }, GuardError);
}); });
test("my shifts degrades to the reason alone when the upstream is down, claiming nothing about what is assigned", async () => {
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
const down = fakeUpstream({ list: async () => { throw new UpstreamError("down", 503); } });
const r = asView(await myShifts(down)(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
assert.match(String(r.data["error"]), /scheduling service/i);
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); // mine.ejs drops the count + table while `error` is set
});
test("my shifts drops a row the upstream returned that is not the visitor's", async () => {
const user: User = { email: "Blair.Mora@example.test", id: "01a06091-baa3-71f4-a068-4879972979ff", permissions: [] };
const theirs: Shift = { assignee: "Avery Kline", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", end: "12:00", id: "9", start: "08:00", title: "Not mine" };
const r = asView(await myShifts(fakeUpstream({ list: async () => [theirs] }))(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
assert.deepEqual((r.data["table"] as { rows: unknown[] }).rows, []); // a backend ignoring the scope must not leak through this page
});
+11 -8
View File
@@ -22,7 +22,8 @@ export const WRITE = "scheduling:write"; // the permission gating create
export interface Shift { export interface Shift {
id: string; id: string;
assignee: string; assignee: string; // display name, rendered in the table
assigneeId: string; // who the shift belongs to — an opaque id, the same one `ctx.user.id` carries
end: string; end: string;
start: string; start: string;
title: string; title: string;
@@ -47,9 +48,9 @@ export class UpstreamError extends Error {
export interface ShiftsUpstream { export interface ShiftsUpstream {
create(input: ShiftInput): Promise<void>; create(input: ShiftInput): Promise<void>;
// `assignee` scopes the read at the source, which is where an ownership rule belongs (README → // `assigneeId` scopes the read at the source, which is where an ownership rule belongs (README →
// Three tiers of "may I?"); without it the caller would hold everyone's rows to render one page. // Three tiers of "may I?"); without it the caller would hold everyone's rows to render one page.
list(opts?: { assignee?: string }): Promise<Shift[]>; list(opts?: { assigneeId?: string }): Promise<Shift[]>;
} }
// REST client over the upstream service (a stand-in for the customer's real backend). `fetch` // REST client over the upstream service (a stand-in for the customer's real backend). `fetch`
@@ -69,7 +70,7 @@ export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch =
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status); if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
}, },
async list(opts = {}) { async list(opts = {}) {
const query = opts.assignee == null ? "" : `?${new URLSearchParams({ assignee: opts.assignee })}`; const query = opts.assigneeId == null ? "" : `?${new URLSearchParams({ assigneeId: opts.assigneeId })}`;
const res = await fetchImpl(`${base()}/shifts${query}`, { headers: { accept: "application/json" } }); const res = await fetchImpl(`${base()}/shifts${query}`, { headers: { accept: "application/json" } });
if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status); if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status);
const data: unknown = await res.json(); const data: unknown = await res.json();
@@ -82,7 +83,7 @@ const str = (v: unknown): string => (typeof v === "string" ? v : v == null ? ""
function toShift(raw: unknown): Shift { function toShift(raw: unknown): Shift {
const r = (raw ?? {}) as Record<string, unknown>; const r = (raw ?? {}) as Record<string, unknown>;
return { assignee: str(r["assignee"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) }; return { assignee: str(r["assignee"]), assigneeId: str(r["assigneeId"]), end: str(r["end"]), id: str(r["id"]), start: str(r["start"]), title: str(r["title"]) };
} }
// ---- view models (pure; the EJS views read these) ----------------------------------- // ---- view models (pure; the EJS views read these) -----------------------------------
@@ -196,7 +197,10 @@ export function myShifts(upstream: ShiftsUpstream): RouteHandler {
let shifts: Shift[] = []; let shifts: Shift[] = [];
let error: string | undefined; let error: string | undefined;
try { try {
shifts = await upstream.list({ assignee: user.email }); // Join on the id, never the email: an address is user-changeable and can be reassigned to
// someone else, which would hand them the previous holder's rows. The re-filter is
// defence-in-depth: a backend that ignores an unknown query param would answer with everyone.
shifts = (await upstream.list({ assigneeId: user.id })).filter((s) => s.assigneeId === user.id);
} catch (err) { } catch (err) {
ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); ctx.log.warn("scheduling upstream unreachable", { error: String(err) });
error = ctx.t("scheduling.upstream.list"); error = ctx.t("scheduling.upstream.list");
@@ -215,8 +219,7 @@ export function buildMineModel(opts: { chrome: PageChrome; email: string; error?
table: { table: {
caption: t("scheduling.mine.title"), caption: t("scheduling.mine.title"),
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }], columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
// Only when the upstream answered: a failed read knows nothing about what is assigned. emptyText: t("scheduling.mine.empty", { email: opts.email }),
...(opts.error === undefined ? { emptyText: t("scheduling.mine.empty", { email: opts.email }) } : {}),
rows: opts.shifts.map((s) => ({ cells: [{ rowHeader: { text: s.title } }, s.start, s.end], name: s.title })), rows: opts.shifts.map((s) => ({ cells: [{ rowHeader: { text: s.title } }, s.start, s.end], name: s.title })),
}, },
title: t("scheduling.mine.title"), title: t("scheduling.mine.title"),
+1 -1
View File
@@ -7,7 +7,7 @@
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : ""; const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
body: '<div class="scheduling-page">' + alertHtml + '<p class="shift-count">' + count + '</p>' + tableHtml + '</div>', body: '<div class="scheduling-page">' + alertHtml + (locals.error ? '' : '<p class="shift-count">' + count + '</p>' + tableHtml) + '</div>',
brand: chrome.brand, brand: chrome.brand,
breadcrumbs, breadcrumbs,
csrfToken: chrome.csrfToken, csrfToken: chrome.csrfToken,
+10 -9
View File
@@ -3,7 +3,7 @@
// of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM // of the app: stdlib only, in-memory (state resets on restart), no auth. Point PLUGIN_SETTING_SCHEDULING_UPSTREAM
// at your real service in production. // at your real service in production.
// //
// GET /shifts → 200 [ { id, title, assignee, start, end }, … ] (?assignee=<who> → only theirs) // GET /shifts → 200 [ { id, title, assigneeId, assignee, start, end }, … ] (?assigneeId=<id> → only theirs)
// POST /shifts → 201 { id, … } (body: { title, assignee, start, end }) // POST /shifts → 201 { id, … } (body: { title, assignee, start, end })
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
@@ -11,11 +11,12 @@ import { createServer } from "node:http";
const PORT = Number(process.env.PORT ?? 4000); const PORT = Number(process.env.PORT ?? 4000);
// `assigneeId` is the identity the rows are owned by — an opaque, stable subject id, which is what
// `ctx.user.id` carries. These are this demo's own people; a real backend joins on your IdP's ids.
const shifts = [ const shifts = [
{ id: randomUUID(), title: "Morning — Front desk", assignee: "avery.kline@plainpages.local", start: "2026-06-22 08:00", end: "2026-06-22 12:00" }, { id: randomUUID(), title: "Morning — Front desk", assigneeId: "019bdc1a-3f27-7c41-9a6e-2b1d4f8e05a3", assignee: "Avery Kline", start: "2026-06-22 08:00", end: "2026-06-22 12:00" },
{ id: randomUUID(), title: "Afternoon — Support", assignee: "blair.mora@plainpages.local", start: "2026-06-22 12:00", end: "2026-06-22 17:00" }, { id: randomUUID(), title: "Afternoon — Support", assigneeId: "019bdc1a-4a83-7de2-8f05-6c93a71be4d8", assignee: "Blair Mora", start: "2026-06-22 12:00", end: "2026-06-22 17:00" },
{ id: randomUUID(), title: "Evening — On-call", assignee: "casey.nguyen@plainpages.local", start: "2026-06-22 17:00", end: "2026-06-22 22:00" }, { id: randomUUID(), title: "Evening — On-call", assigneeId: "019bdc1a-5b6e-7a90-b3c7-84f01d2ea9b6", assignee: "Casey Nguyen", start: "2026-06-22 17:00", end: "2026-06-22 22:00" },
{ id: randomUUID(), title: "Night — Escalations", assignee: "admin@plainpages.local", start: "2026-06-22 22:00", end: "2026-06-23 06:00" },
]; ];
const json = (res, status, body) => { const json = (res, status, body) => {
@@ -35,13 +36,13 @@ const readBody = (req) =>
createServer(async (req, res) => { createServer(async (req, res) => {
const url = new URL(req.url ?? "/", "http://localhost"); const url = new URL(req.url ?? "/", "http://localhost");
if (url.pathname === "/shifts" && req.method === "GET") { if (url.pathname === "/shifts" && req.method === "GET") {
const assignee = url.searchParams.get("assignee"); const assigneeId = url.searchParams.get("assigneeId");
if (assignee === null) return json(res, 200, shifts); if (assigneeId === null) return json(res, 200, shifts);
return json(res, 200, shifts.filter((s) => s.assignee.toLowerCase() === assignee.toLowerCase())); return json(res, 200, shifts.filter((s) => s.assigneeId === assigneeId));
} }
if (url.pathname === "/shifts" && req.method === "POST") { if (url.pathname === "/shifts" && req.method === "POST") {
const b = await readBody(req); const b = await readBody(req);
const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") }; const shift = { id: randomUUID(), assignee: String(b.assignee ?? ""), assigneeId: "", end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
shifts.push(shift); shifts.push(shift);
return json(res, 201, shift); return json(res, 201, shift);
} }
+1 -1
View File
@@ -23,6 +23,6 @@ test("gatesSet names the gates a declaration sets, so discovery can refuse more
assert.deepEqual(gatesSet({ session: true }), ["session"]); assert.deepEqual(gatesSet({ session: true }), ["session"]);
assert.deepEqual(gatesSet({ permission: "x:read", public: true }), ["public", "permission"]); assert.deepEqual(gatesSet({ permission: "x:read", public: true }), ["public", "permission"]);
assert.deepEqual(gatesSet({ permission: "x:read", public: true, session: true }), ["public", "session", "permission"]); assert.deepEqual(gatesSet({ permission: "x:read", public: true, session: true }), ["public", "session", "permission"]);
// `false` is not a gate — only a set one counts, so { session: false } is an ungated route. // Only `true` sets a gate, so a manifest spelling one `false` names none — which discovery refuses.
assert.deepEqual(gatesSet({ public: false, session: false }), []); assert.deepEqual(gatesSet({ public: false, session: false }), []);
}); });
+14 -2
View File
@@ -92,12 +92,24 @@ test("completeLogin returns null and touches nothing when there is no active ses
assert.equal(touched, false); assert.equal(touched, false);
}); });
test("completeLogin maps a missing email trait to null and throws if the tokenizer yields no JWT", async () => { test("completeLogin throws if the tokenizer yields no JWT", async () => {
const identity: Identity = { id: ID, traits: {} }; const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity }) as Session }); // never returns a tokenized JWT const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity }) as Session }); // never returns a tokenized JWT
await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/); await assert.rejects(completeLogin({ keto: ketoStub(), kratosAdmin: adminStub(), kratosPublic }, "c"), /tokenizer returned no JWT/);
}); });
// An identity with no email is no session, decided here so /auth/complete and remintSession cannot
// disagree: `claimsToUser` reads a token carrying none as anonymous, so minting one would hand the
// browser a cookie every later request refuses.
test("completeLogin refuses an identity carrying no email, before it mints anything", async () => {
const identity: Identity = { id: ID, traits: {} };
let touched = false;
const kratosAdmin = adminStub({ updateMetadataPublic: async () => { touched = true; return { id: ID }; } });
const kratosPublic = publicStub({ whoami: async () => ({ active: true, identity, tokenized: "h.p.s" }) as Session });
assert.equal(await completeLogin({ keto: ketoStub(), kratosAdmin, kratosPublic }, "c"), null);
assert.equal(touched, false); // no Keto read, no metadata write, no JWT
});
test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => { test("remintSession: a live Kratos session → fresh cookie + refreshed user; a dead session → a clearing cookie + null", async () => {
const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } }; const identity: Identity = { id: ID, traits: { email: "admin@plainpages.local" } };
const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session }); const kratosPublic = publicStub({ whoami: async (o) => (o?.tokenizeAs ? { active: true, identity, tokenized: "h.p.s" } : { active: true, identity }) as Session });
+9 -5
View File
@@ -31,7 +31,7 @@ export interface LoginDeps {
} }
export interface CompletedLogin { export interface CompletedLogin {
email: string | null; email: string;
userId: string; userId: string;
jwt: string; jwt: string;
permissions: string[]; permissions: string[];
@@ -61,7 +61,13 @@ export async function completeLogin(deps: LoginDeps, cookie: string | undefined)
if (!session?.identity) return null; if (!session?.identity) return null;
const userId = session.identity.id; const userId = session.identity.id;
const emailTrait = session.identity.traits?.["email"]; const emailTrait = session.identity.traits?.["email"];
const email = typeof emailTrait === "string" ? emailTrait : null; const email = typeof emailTrait === "string" ? emailTrait : "";
// No email is no session: `claimsToUser` reads a token carrying none as anonymous, so minting one
// would hand the browser a cookie every later request refuses.
if (!email) {
currentLog()?.warn("session dropped: identity has no email", { sub: userId });
return null;
}
const permissions = await readPermissions(deps.keto, userId); const permissions = await readPermissions(deps.keto, userId);
await deps.kratosAdmin.updateMetadataPublic(userId, { permissions }); await deps.kratosAdmin.updateMetadataPublic(userId, { permissions });
@@ -86,9 +92,7 @@ export interface Reminted {
// anonymous instead of re-hitting Ory on every one. // anonymous instead of re-hitting Ory on every one.
export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> { export async function remintSession(deps: LoginDeps, cookie: string | undefined, options: { secure?: boolean } = {}): Promise<Reminted> {
const completed = await completeLogin(deps, cookie); const completed = await completeLogin(deps, cookie);
// No email is no session, exactly as `claimsToUser` reads a token carrying none: a User with an if (!completed) return { setCookie: clearSessionCookie(options), user: null };
// empty email reads as anonymous in the shell, and is a blank key to whatever scopes on it.
if (!completed?.email) return { setCookie: clearSessionCookie(options), user: null };
return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email, id: completed.userId, permissions: completed.permissions } }; return { setCookie: sessionCookie(completed.jwt, options), user: { email: completed.email, id: completed.userId, permissions: completed.permissions } };
} }
+6 -1
View File
@@ -4,6 +4,7 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { AUTH_FLOWS } from "./flow-view.ts"; import { AUTH_FLOWS } from "./flow-view.ts";
import { gatesSet } from "./gate.ts";
import type { HydraAdmin } from "./hydra-admin.ts"; import type { HydraAdmin } from "./hydra-admin.ts";
import type { KetoClient } from "./keto-client.ts"; import type { KetoClient } from "./keto-client.ts";
import type { KratosAdmin } from "./kratos-admin.ts"; import type { KratosAdmin } from "./kratos-admin.ts";
@@ -39,8 +40,12 @@ test("hydra alone ⇒ only RP-initiated logout of the OAuth2 group (login/consen
}); });
test("everything wired ⇒ the full group: OAuth2 challenges, consent GET+POST, /auth/complete", () => { test("everything wired ⇒ the full group: OAuth2 challenges, consent GET+POST, /auth/complete", () => {
const got = keys(buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin }))); const routes = buildAuthRoutes(deps({ hydra, keto, kratos, kratosAdmin }));
const got = keys(routes);
for (const key of ["GET /auth/complete", "GET /login", "GET /oauth2/consent", "GET /oauth2/login", "GET /oauth2/logout", "POST /logout", "POST /oauth2/consent"]) { for (const key of ["GET /auth/complete", "GET /login", "GET /oauth2/consent", "GET /oauth2/login", "GET /oauth2/logout", "POST /logout", "POST /oauth2/consent"]) {
assert.ok(got.includes(key), key); assert.ok(got.includes(key), key);
} }
// Discovery enforces exactly one gate per plugin declaration; nothing checks the host's own table
// at boot, so a route added here without a gate would be silently public.
for (const route of routes) assert.deepEqual(gatesSet(route), ["public"], `${route.method} ${route.path}`);
}); });
+8 -8
View File
@@ -240,20 +240,20 @@ export function buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secure
const routes: BuiltinRoute[] = []; const routes: BuiltinRoute[] = [];
if (kratos) { if (kratos) {
for (const [path, flowType] of Object.entries(AUTH_FLOWS)) { for (const [path, flowType] of Object.entries(AUTH_FLOWS)) {
routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path }); routes.push({ handler: flowPage(kratos, flowType, secureCookies), method: "GET", path, public: true });
} }
routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout" }); routes.push({ handler: logout(kratos, secureCookies), method: "POST", path: "/logout", public: true });
} }
if (hydra && kratos) { if (hydra && kratos) {
const provider = { hydra, kratos }; const provider = { hydra, kratos };
routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login" }); routes.push({ handler: oauthLogin(provider, secureCookies), method: "GET", path: "/oauth2/login", public: true });
routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent" }); routes.push({ handler: consentScreen(provider, menu.branding.name), method: "GET", path: "/oauth2/consent", public: true });
routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent" }); routes.push({ handler: consentDecision(provider), method: "POST", path: "/oauth2/consent", public: true });
} }
if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout" }); if (hydra) routes.push({ handler: oauthLogout(hydra), method: "GET", path: "/oauth2/logout", public: true });
if (kratos && kratosAdmin && keto) { if (kratos && kratosAdmin && keto) {
routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete" }); routes.push({ handler: completeAuth({ keto, kratosAdmin, kratosPublic: kratos }, secureCookies), method: "GET", path: "/auth/complete", public: true });
} }
routes.push({ handler: errorSink, method: "GET", path: "/error" }); routes.push({ handler: errorSink, method: "GET", path: "/error", public: true });
return routes; return routes;
} }
+13 -9
View File
@@ -28,7 +28,7 @@ import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts"; import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
import type { PluginSettings } from "../plugin-host/settings.ts"; import type { PluginSettings } from "../plugin-host/settings.ts";
import type { SystemCapabilities } from "../plugin-host/system.ts"; import type { SystemCapabilities } from "../plugin-host/system.ts";
import { allows } from "../auth/gate.ts"; import { allows, type Gate } from "../auth/gate.ts";
import { allowedMethods, matchRoute } from "../plugin-host/router.ts"; import { allowedMethods, matchRoute } from "../plugin-host/router.ts";
import { buildAuthRoutes } from "../auth/routes.ts"; import { buildAuthRoutes } from "../auth/routes.ts";
import { securityHeaders } from "./security-headers.ts"; import { securityHeaders } from "./security-headers.ts";
@@ -157,7 +157,6 @@ export function createApp(options: AppOptions = {}): Server {
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in // "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
// starter page. // starter page.
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => { const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent. // The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
csrf.setCookie(); csrf.setCookie();
if (dashboardPlugin) { if (dashboardPlugin) {
@@ -174,8 +173,8 @@ export function createApp(options: AppOptions = {}): Server {
// routes.ts, capability-gated on the wired clients) plus the two landing slots above. // routes.ts, capability-gated on the wired clients) plus the two landing slots above.
const builtinRoutes: BuiltinRoute[] = [ const builtinRoutes: BuiltinRoute[] = [
...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }), ...buildAuthRoutes({ hydra, keto, kratos, kratosAdmin, menu, secureCookies }),
{ handler: serveHome, method: "GET", path: "/" }, { handler: serveHome, method: "GET", path: "/", public: true },
{ handler: serveDashboard, method: "GET", path: "/dashboard" }, { handler: serveDashboard, method: "GET", path: "/dashboard", session: true },
]; ];
// The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every // The request handler. Run inside runWithLog (below) so the per-request logger is ambient: every
@@ -279,15 +278,19 @@ export function createApp(options: AppOptions = {}): Server {
} }
} }
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply lacks
// the permission gets the 403 page.
const refuse = async (gate: Gate, gateCtx: RequestContext): Promise<void> => {
if (!gateCtx.user) { res.writeHead(303, { location: carryLocale(loginRedirect(gateCtx)) }).end(); return; }
reqLog.warn("forbidden: missing permission", { path: pathname, required: gate.permission ?? "", sub: gateCtx.user.id });
sendHtml(res, 403, await renderPage("403", {}));
};
const match = matchRoute(plugins, method, pathname); const match = matchRoute(plugins, method, pathname);
if (match) { if (match) {
const routeCtx = contextFor(match.plugin.id, match.params); const routeCtx = contextFor(match.plugin.id, match.params);
if (!allows(match.route, routeCtx.user)) { if (!allows(match.route, routeCtx.user)) {
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply await refuse(match.route, routeCtx);
// lacks the permission gets the 403 page.
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
reqLog.warn("forbidden: missing permission", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id });
sendHtml(res, 403, await renderPage("403", {}));
return; return;
} }
csrfMint.setCookie(); csrfMint.setCookie();
@@ -301,6 +304,7 @@ export function createApp(options: AppOptions = {}): Server {
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname); const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
if (builtin) { if (builtin) {
if (!allows(builtin, ctx.user)) { await refuse(builtin, ctx); return; }
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale); await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
return; return;
} }
+2 -1
View File
@@ -3,6 +3,7 @@
// mint (host-only — a plugin reads the token via ctx.chrome instead). app.ts matches this table // mint (host-only — a plugin reads the token via ctx.chrome instead). app.ts matches this table
// after plugin routes — exact path, a GET route also answering HEAD like the plugin router — and // after plugin routes — exact path, a GET route also answering HEAD like the plugin router — and
// pipes the result through sendResult against the core views. // pipes the result through sendResult against the core views.
import type { Gate } from "../auth/gate.ts";
import type { RequestContext } from "./context.ts"; import type { RequestContext } from "./context.ts";
import type { RouteResult } from "../plugin-host/plugin.ts"; import type { RouteResult } from "../plugin-host/plugin.ts";
@@ -19,7 +20,7 @@ export interface RequestCsrf {
// own context — otherwise the plugin's keys render as bare keys on the pages it owns. // own context — otherwise the plugin's keys render as bare keys on the pages it owns.
export type PluginContextFactory = (pluginId: string) => RequestContext; export type PluginContextFactory = (pluginId: string) => RequestContext;
export interface BuiltinRoute { export interface BuiltinRoute extends Gate {
// Returns a RouteResult, or null when the handler wrote to ctx.res itself // Returns a RouteResult, or null when the handler wrote to ctx.res itself
// (the landing slots dispatch a plugin's own result against that plugin's views). // (the landing slots dispatch a plugin's own result against that plugin's views).
handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null; handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null;
+7 -3
View File
@@ -20,8 +20,8 @@ function scaffold(t: TestContext, files: Record<string, string>): string {
} }
const full = (id: string): string => const full = (id: string): string =>
`export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}" }], ` + `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "${id}:root", label: "${id}", public: true }], ` +
`routes: [{ method: "GET", path: "/", handler: () => ({ html: "${id}" }) }] };`; `routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "${id}" }) }] };`;
test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => { test("a missing plugins/ dir means zero plugins, not an error (clean clone)", async () => {
assert.deepEqual(await discoverPlugins({ dir: join(tmpdir(), "pp-does-not-exist-xyz") }), []); assert.deepEqual(await discoverPlugins({ dir: join(tmpdir(), "pp-does-not-exist-xyz") }), []);
@@ -67,6 +67,10 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
{ name: "a route whose session flag is a truthy non-boolean is refused, not read as ungated", files: { "truthy/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: "yes", handler: () => ({ html: "x" }) }] };` }, match: /truthy.*session.*true/s }, { name: "a route whose session flag is a truthy non-boolean is refused, not read as ungated", files: { "truthy/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: "yes", handler: () => ({ html: "x" }) }] };` }, match: /truthy.*session.*true/s },
{ name: "a nav node whose public flag is a truthy non-boolean is refused too", files: { "truthynav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: 1 }] };` }, match: /truthynav.*public.*true/s }, { name: "a nav node whose public flag is a truthy non-boolean is refused too", files: { "truthynav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: 1 }] };` }, match: /truthynav.*public.*true/s },
{ name: "a nav node marked session AND permission is contradictory", files: { "contrasessnav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", session: true, permission: "x:read" }] };` }, match: /contrasessnav.*session.*permission/s }, { name: "a nav node marked session AND permission is contradictory", files: { "contrasessnav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", session: true, permission: "x:read" }] };` }, match: /contrasessnav.*session.*permission/s },
// A gate is named, never forgotten: a route or node without one would be an open page nobody chose.
{ name: "a route naming no gate at all is refused, not served to everyone", files: { "nogate/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", handler: () => ({ html: "x" }) }] };` }, match: /nogate.*names no gate/s },
{ name: "a nav node naming no gate at all is refused too — a section header says `public` outright", files: { "nogatenav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N" }] };` }, match: /nogatenav.*names no gate/s },
{ name: "a gate set to false is refused — it reads as a gate but sets none", files: { "falsegate/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: false, handler: () => ({ html: "x" }) }] };` }, match: /falsegate.*public.*true/s },
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not // A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
// only in the admin GUI, so it holds for a plugin installed without that GUI. // only in the admin GUI, so it holds for a plugin installed without that GUI.
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s }, { name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
@@ -139,7 +143,7 @@ test("a plugin may carry its own package.json, node_modules and dependencies", a
"shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`, "shop/node_modules/price-tag/package.json": `{ "name": "price-tag", "version": "1.0.0", "type": "module", "exports": "./index.js" }`,
"shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`, "shop/node_modules/price-tag/index.js": `export default (n) => \`\${n} kr\`;`,
"shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` + "shop/plugin.ts": `import { definePlugin } from "@plainpages/plugin-api";\nimport price from "price-tag";\n` +
`export default definePlugin({ apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", handler: () => ({ html: price(20) }) }] });`, `export default definePlugin({ apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: price(20) }) }] });`,
}); });
const plugins = await discoverPlugins({ dir }); const plugins = await discoverPlugins({ dir });
+20 -35
View File
@@ -148,57 +148,42 @@ function shapeError(manifest: PluginManifest): string | null {
if (settings) return settings; if (settings) return settings;
} }
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) { for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
const flag = gateFlagError(`route "${route?.method} ${route?.path}"`, route); const gate = gateError(`route "${route?.method} ${route?.path}"`, route);
if (flag) return flag; if (gate) return gate;
const gates = gatesSet(route);
if (gates.length > 1) return `route "${route?.method} ${route?.path}" sets ${gates.join(" and ")}; a route names exactly one gate — public, session or permission`;
}
const navContradiction = findNavGateContradiction(manifest.nav);
if (navContradiction) return navContradiction;
// Every permission name the manifest mentions — gated on or declared — must be `<resource>:<action>`.
// A bare word names a role, and roles are groups here (README → Naming a permission).
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
if (route?.permission != null && !isValidPermissionName(route.permission)) {
return `route "${route.method} ${route.path}" gates on "${route.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
}
} }
const navGate = findNavGateError(manifest.nav);
if (navGate) return navGate;
for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) { for (const decl of Array.isArray(manifest.permissions) ? manifest.permissions : []) {
if (decl?.name == null || !isValidPermissionName(decl.name)) { if (decl?.name == null || !isValidPermissionName(decl.name)) {
return `declared permission "${decl?.name}" is not <resource>:<action>, e.g. "things:read"`; return `declared permission "${decl?.name}" is not <resource>:<action>, e.g. "things:read"`;
} }
} }
const navPermission = findInvalidNavPermission(manifest.nav);
if (navPermission) return navPermission;
return null; return null;
} }
// A truthy non-boolean sets no gate at all, so `session: "yes"` would read as an open page. // Every rule a declaration's gate must satisfy. Exactly one gate, always: a missing one would be an
function gateFlagError(what: string, gate: Gate | null | undefined): string | null { // open page nobody chose, and anything but `true` (a `false`, a `"yes"`) sets no gate while looking
// like it does. A permission name is `<resource>:<action>` because a bare word names a role, and
// roles are groups here (README → Naming a permission).
function gateError(what: string, gate: Gate | null | undefined): string | null {
for (const flag of ["public", "session"] as const) { for (const flag of ["public", "session"] as const) {
const value = gate?.[flag]; const value = gate?.[flag];
if (value !== undefined && typeof value !== "boolean") return `${what} sets ${flag} to ${JSON.stringify(value)}; a gate is declared with \`true\``; if (value !== undefined && value !== true) return `${what} sets ${flag} to ${JSON.stringify(value)}; a gate is declared with \`true\``;
}
const gates = gatesSet(gate);
if (gates.length === 0) return `${what} names no gate; name exactly one — public, session or permission`;
if (gates.length > 1) return `${what} sets ${gates.join(" and ")}; name exactly one — public, session or permission`;
if (gate?.permission != null && !isValidPermissionName(gate.permission)) {
return `${what} gates on "${gate.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
} }
return null; return null;
} }
function findNavGateContradiction(nodes: PluginManifest["nav"]): string | null { function findNavGateError(nodes: PluginManifest["nav"]): string | null {
for (const node of Array.isArray(nodes) ? nodes : []) { for (const node of Array.isArray(nodes) ? nodes : []) {
const flag = gateFlagError(`nav node "${node?.label ?? node?.id ?? "?"}"`, node); const err = gateError(`nav node "${node?.label ?? node?.id ?? "?"}"`, node);
if (flag) return flag; if (err) return err;
const gates = gatesSet(node); const inChild = findNavGateError(node?.children);
if (gates.length > 1) return `nav node "${node?.label ?? node?.id ?? "?"}" sets ${gates.join(" and ")}; a node names exactly one gate — public, session or permission`;
const inChild = findNavGateContradiction(node?.children);
if (inChild) return inChild;
}
return null;
}
function findInvalidNavPermission(nodes: PluginManifest["nav"]): string | null {
for (const node of Array.isArray(nodes) ? nodes : []) {
if (node?.permission != null && !isValidPermissionName(node.permission)) {
return `nav node "${node.label ?? node.id ?? "?"}" gates on "${node.permission}"; a permission name is <resource>:<action>, e.g. "things:read"`;
}
const inChild = findInvalidNavPermission(node?.children);
if (inChild) return inChild; if (inChild) return inChild;
} }
return null; return null;
+2
View File
@@ -14,6 +14,8 @@ export type { RequestContext, User } from "../http/context.ts";
export type { PageChrome } from "../ui/chrome.ts"; export type { PageChrome } from "../ui/chrome.ts";
export type { NavNode } from "../ui/nav.ts"; export type { NavNode } from "../ui/nav.ts";
export { can, check, GuardError, requireSession } from "../auth/guards.ts"; export { can, check, GuardError, requireSession } from "../auth/guards.ts";
// The three coarse gates a route or nav node may declare — `Route` and `NavNode` both extend it.
export type { Gate } from "../auth/gate.ts";
// Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for // Translation: `ctx.t` and the view-level `t(...)` do the work at runtime — these are for
// authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator // authoring a plugin's own catalogs (plugins/<id>/i18n/<locale>.ts) and for building a translator
// in a unit test. `PluralMessage` types a message that varies with a count. // in a unit test. `PluralMessage` types a message that varies with a count.
-1
View File
@@ -25,7 +25,6 @@ export type RouteResult =
export type RouteHandler = (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void; export type RouteHandler = (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void;
// `Gate` carries `permission`/`public`/`session`, checked before the handler runs.
export interface Route extends Gate { export interface Route extends Gate {
handler: RouteHandler; handler: RouteHandler;
method: HttpMethod; method: HttpMethod;
-1
View File
@@ -8,7 +8,6 @@ import type { User } from "../http/context.ts";
import { ENGLISH } from "../i18n/english.ts"; import { ENGLISH } from "../i18n/english.ts";
import type { Translate } from "../i18n/translate.ts"; import type { Translate } from "../i18n/translate.ts";
// `Gate` carries `permission`/`public`/`session` — consumed by the filter, never rendered.
export interface NavNode extends Gate { export interface NavNode extends Gate {
id?: string; // stable key for override targeting; stripped from the rendered tree id?: string; // stable key for override targeting; stripped from the rendered tree
children?: NavNode[]; children?: NavNode[];