From c7e6d6675053bef6f0bfc155380506f56d1e6b5e Mon Sep 17 00:00:00 2001 From: Lilleman auf Larv Date: Wed, 2 Sep 2026 08:15:08 +0200 Subject: [PATCH] Scope My shifts at the upstream, and give Route and NavNode one gate declaration --- e2e-tests/full-flow.spec.ts | 7 ++++++ examples/plugins/scheduling/README.md | 6 +++++- examples/plugins/scheduling/shifts.test.ts | 12 +++++++---- examples/plugins/scheduling/shifts.ts | 25 ++++++++++++---------- examples/shifts-upstream/server.ts | 7 +++++- src/auth/gate.ts | 6 ++++-- src/plugin-host/plugin-api.ts | 1 + src/plugin-host/plugin.ts | 11 +++------- src/ui/nav.ts | 8 +++---- 9 files changed, 51 insertions(+), 32 deletions(-) diff --git a/e2e-tests/full-flow.spec.ts b/e2e-tests/full-flow.spec.ts index a884569..6dc8723 100644 --- a/e2e-tests/full-flow.spec.ts +++ b/e2e-tests/full-flow.spec.ts @@ -193,6 +193,13 @@ test.describe.serial("authenticated admin journey", () => { await page.goto("/scheduling/shifts"); await expect(page.locator("h1")).toHaveText("Shifts"); 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 + // signed-in admin is there, and another person's shift is not. + await page.goto("/scheduling/mine"); + await expect(page.locator("h1")).toHaveText("My shifts"); + await expect(page.locator("table")).toContainText("Night — Escalations"); + await expect(page.locator("table")).not.toContainText("Morning — Front desk"); }); test("plugin settings: the screen names the variable that sets each declared key", async () => { diff --git a/examples/plugins/scheduling/README.md b/examples/plugins/scheduling/README.md index 5698100..f0e142b 100644 --- a/examples/plugins/scheduling/README.md +++ b/examples/plugins/scheduling/README.md @@ -26,6 +26,10 @@ What it demonstrates: The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and `fetch` is injectable, so they unit-test as pure functions (`shifts.test.ts`). +The shifts list and "My shifts" repeat a little view-model and markup rather than sharing a +parameterised one: an example is read far more often than it is changed, and each page is meant to be +followed top to bottom on its own. + ## Upstream Set `PLUGIN_SETTING_SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory @@ -39,7 +43,7 @@ Your backend must expose two routes; the plugin treats any non-2xx as a recovera | Route | Request | Success | Response body | | --- | --- | --- | --- | -| `GET /shifts` | `Accept: application/json` | `200` | JSON array of `{ id, title, assignee, start, end }` (all strings; missing fields coerce to `""`) | +| `GET /shifts` | `Accept: application/json`, optional `?assignee=` | `200` | JSON array of `{ id, title, assignee, start, end }` (all strings; missing fields coerce to `""`). With `assignee`, only that person's rows — "My shifts" asks for them rather than filtering everyone's here, because ownership is the backend's rule to enforce | | `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 diff --git a/examples/plugins/scheduling/shifts.test.ts b/examples/plugins/scheduling/shifts.test.ts index 76a4b7e..e848bee 100644 --- a/examples/plugins/scheduling/shifts.test.ts +++ b/examples/plugins/scheduling/shifts.test.ts @@ -174,15 +174,19 @@ test("buildFormModel marks title/assignee required and attaches field errors", ( // ---- the session-gated page: the visitor's own rows ---- -test("my shifts renders only the signed-in visitor's own rows, and names them in the empty state", async () => { +test("my shifts asks the upstream for the visitor's own rows, and names them in the empty state", async () => { 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 listed = [...SHIFTS, mine]; // SHIFTS are assigned to other people + let asked: { assignee?: string } | undefined; - const r = asView(await myShifts(fakeUpstream({ list: async () => listed }))(fakeCtx({ url: "http://localhost/scheduling/mine", user }))); + const upstream = fakeUpstream({ list: async (opts) => { asked = opts; return [mine]; } }); + const r = asView(await myShifts(upstream)(fakeCtx({ url: "http://localhost/scheduling/mine", user }))); assert.equal(r.view, "mine"); + // The ownership rule is the upstream's: the page asks for one person's rows rather than filtering + // everyone's here, so a real backend never hands this handler another visitor's shifts. + assert.deepEqual(asked, { assignee: "Blair.Mora@example.test" }); const table = r.data["table"] as { emptyText: string; rows: { name: string }[] }; - assert.deepEqual(table.rows.map((row) => row.name), ["Evening on-call"]); // matched case-insensitively + 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 // The route carries `session: true`, but the handler asserts the session itself rather than trusting it. diff --git a/examples/plugins/scheduling/shifts.ts b/examples/plugins/scheduling/shifts.ts index bb59196..469d8b9 100644 --- a/examples/plugins/scheduling/shifts.ts +++ b/examples/plugins/scheduling/shifts.ts @@ -47,7 +47,9 @@ export class UpstreamError extends Error { export interface ShiftsUpstream { create(input: ShiftInput): Promise; - list(): Promise; + // `assignee` 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. + list(opts?: { assignee?: string }): Promise; } // REST client over the upstream service (a stand-in for the customer's real backend). `fetch` @@ -66,8 +68,9 @@ export function createUpstream(baseUrl: () => string, fetchImpl: typeof fetch = }); if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status); }, - async list() { - const res = await fetchImpl(`${base()}/shifts`, { headers: { accept: "application/json" } }); + async list(opts = {}) { + const query = opts.assignee == null ? "" : `?${new URLSearchParams({ assignee: opts.assignee })}`; + const res = await fetchImpl(`${base()}/shifts${query}`, { headers: { accept: "application/json" } }); if (!res.ok) throw new UpstreamError(`list shifts failed (${res.status})`, res.status); const data: unknown = await res.json(); return Array.isArray(data) ? data.map(toShift) : []; @@ -187,25 +190,21 @@ export function newShiftForm(): RouteHandler { return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" }); } -// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the -// gate lets an anonymous visitor through and the menu option shows for everyone. The real data -// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone -// else a prompt to sign in. ctx.user may be null here, so read the permission via can() (zero I/O). // The `session: true` archetype: the rows are the visitor's own, so there is no distinction a -// permission could name — anyone signed in sees theirs and only theirs. +// permission could name — anyone signed in sees theirs and only theirs. The scoping is the +// upstream's, never a filter here: it owns the data and answers for one person's rows. export function myShifts(upstream: ShiftsUpstream): RouteHandler { return async (ctx) => { const user = requireSession(ctx); let shifts: Shift[] = []; let error: string | undefined; try { - shifts = await upstream.list(); + shifts = await upstream.list({ assignee: user.email }); } catch (err) { ctx.log.warn("scheduling upstream unreachable", { error: String(err) }); error = ctx.t("scheduling.upstream.list"); } - const mine = shifts.filter((s) => s.assignee.toLowerCase() === user.email.toLowerCase()); - return { data: buildMineModel({ chrome: ctx.chrome, email: user.email, ...(error ? { error } : {}), shifts: mine, t: ctx.t }), view: "mine" }; + return { data: buildMineModel({ chrome: ctx.chrome, email: user.email, ...(error ? { error } : {}), shifts, t: ctx.t }), view: "mine" }; }; } @@ -226,6 +225,10 @@ export function buildMineModel(opts: { chrome: PageChrome; email: string; error? }; } +// Public overview: a page anyone may reach — its route + nav node are marked `public`, so the +// gate lets an anonymous visitor through and the menu option shows for everyone. The real data +// (the shifts list) stays behind `scheduling:read`; a reader gets a link straight to it, anyone +// else a prompt to sign in. ctx.user may be null here, so read the permission via can() (zero I/O). export function overview(): RouteHandler { return (ctx) => ({ data: { diff --git a/examples/shifts-upstream/server.ts b/examples/shifts-upstream/server.ts index c769d4f..8e6be97 100644 --- a/examples/shifts-upstream/server.ts +++ b/examples/shifts-upstream/server.ts @@ -15,6 +15,7 @@ const shifts = [ { id: randomUUID(), title: "Morning — Front desk", assignee: "Avery Kline", start: "2026-06-22 08:00", end: "2026-06-22 12:00" }, { id: randomUUID(), title: "Afternoon — Support", assignee: "Blair Mora", start: "2026-06-22 12:00", end: "2026-06-22 17:00" }, { id: randomUUID(), title: "Evening — On-call", 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) => { @@ -33,7 +34,11 @@ const readBody = (req) => createServer(async (req, res) => { const url = new URL(req.url ?? "/", "http://localhost"); - if (url.pathname === "/shifts" && req.method === "GET") return json(res, 200, shifts); + if (url.pathname === "/shifts" && req.method === "GET") { + const assignee = url.searchParams.get("assignee"); + if (assignee === null) return json(res, 200, shifts); + return json(res, 200, shifts.filter((s) => s.assignee.toLowerCase() === assignee.toLowerCase())); + } if (url.pathname === "/shifts" && req.method === "POST") { 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 ?? "") }; diff --git a/src/auth/gate.ts b/src/auth/gate.ts index 6e47fcc..270c516 100644 --- a/src/auth/gate.ts +++ b/src/auth/gate.ts @@ -5,10 +5,12 @@ import type { User } from "../http/context.ts"; // Widest first: whoever passes an earlier gate passes it without holding anything. const GATES = ["public", "session", "permission"] as const; +// A route or nav node names exactly one of these; discovery refuses two. Omitting all three is the +// same as `public`, which is why stating it outright makes an open gate a choice, not an oversight. export interface Gate { - permission?: string | undefined; // the Keto Permission the caller must hold + permission?: string | undefined; // the Keto Permission the caller must hold, `:` public?: boolean | undefined; // anyone, signed in or not - session?: boolean | undefined; // any signed-in user, no grant needed + session?: boolean | undefined; // any signed-in user, no grant to hold; anonymous is sent to /login } export function allows(gate: Gate, user: User | null): boolean { diff --git a/src/plugin-host/plugin-api.ts b/src/plugin-host/plugin-api.ts index 03d1ec7..70b6228 100644 --- a/src/plugin-host/plugin-api.ts +++ b/src/plugin-host/plugin-api.ts @@ -39,6 +39,7 @@ export { CSRF_FIELD } from "../auth/csrf.ts"; // reference consumer. The Ory client types + their error classes are re-exported so a system // plugin can type against them and `instanceof`-match their errors. See README → System capabilities. export type { SystemCapabilities } from "./system.ts"; +export type { Gate } from "../auth/gate.ts"; export type { Identity, KratosAdmin, RecoveryCode } from "../auth/kratos-admin.ts"; export type { ExpandTree, KetoClient, RelationQuery, RelationTuple, SubjectSet } from "../auth/keto-client.ts"; export type { HydraAdmin, OAuth2Client } from "../auth/hydra-admin.ts"; diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts index df97a45..84d749a 100644 --- a/src/plugin-host/plugin.ts +++ b/src/plugin-host/plugin.ts @@ -4,6 +4,7 @@ // A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount = // `/`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice. +import type { Gate } from "../auth/gate.ts"; import type { RequestContext } from "../http/context.ts"; import type { NavNode } from "../ui/nav.ts"; import { envName, type SettingDecl, type SettingsOf } from "./settings.ts"; @@ -24,17 +25,11 @@ export type RouteResult = export type RouteHandler = (ctx: RequestContext) => Promise | RouteResult | void; -export interface Route { +// `Gate` carries `permission`/`public`/`session`, checked before the handler runs. +export interface Route extends Gate { handler: RouteHandler; method: HttpMethod; path: string; // relative to the plugin's mount path `/`; ":name" segments → ctx.params.name - permission?: string; // coarse gate — the Keto Permission the caller must hold; checked before the handler runs - // Same as omitting `permission`, but stated outright so public is a deliberate choice rather than - // a forgotten gate. Mutually exclusive with `permission` (discovery refuses both). - public?: boolean; - // Any signed-in user, no grant to hold — for a plugin whose data is the visitor's own. Anonymous - // is bounced to /login, never 403. Mutually exclusive with the other two (discovery refuses both). - session?: boolean; } // A Keto Permission this plugin gates on — declared for docs/seeding. Names are a shared global diff --git a/src/ui/nav.ts b/src/ui/nav.ts index 689a450..f098a91 100644 --- a/src/ui/nav.ts +++ b/src/ui/nav.ts @@ -3,12 +3,13 @@ // A node is visible iff `allows` passes its gate; a gated header hides its whole subtree, and a pure // header left with no children is dropped. -import { allows } from "../auth/gate.ts"; +import { allows, type Gate } from "../auth/gate.ts"; import type { User } from "../http/context.ts"; import { ENGLISH } from "../i18n/english.ts"; import type { Translate } from "../i18n/translate.ts"; -export interface NavNode { +// `Gate` carries `permission`/`public`/`session` — consumed by the filter, never rendered. +export interface NavNode extends Gate { id?: string; // stable key for override targeting; stripped from the rendered tree children?: NavNode[]; count?: number; @@ -17,9 +18,6 @@ export interface NavNode { icon?: string; label: string; open?: boolean; - permission?: string; // required permission token; consumed by the filter, never rendered - public?: boolean; // show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both). - session?: boolean; // show to any signed-in user, no grant to hold; consumed by the filter, never rendered. Mutually exclusive with the other two (discovery refuses both). } // Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →