diff --git a/README.md b/README.md index eace79f..8621d3c 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,7 @@ Against the reference plugins' actual routes: | Request | Gate | alice | bob | carol | anonymous | | --- | --- | --- | --- | --- | --- | | `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ | +| `GET /scheduling/mine` | `session: true` | ✅ | ✅ | ✅ | → `/login` | | `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` | | `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` | | `POST /scheduling/shifts` | `scheduling:write` | ✅ | 403 | 403 | → `/login` | @@ -585,10 +586,9 @@ A route or nav node marked **`public: true`** is reachable by anyone and shows i That is the same as omitting `permission`, but stated outright so public is a deliberate choice rather than a forgotten gate. -**`session: true`** takes any signed-in user, with no grant to hold — the gate for a plugin whose -data is the visitor's own (their upstream account, their own tokens), where a permission would name -a distinction that does not exist. An anonymous visitor is bounced to `/login` with the page as -`return_to`, exactly as a permission gate does. +**`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 +permission gate does. A declaration names **exactly one** of the three; two of them contradict, and discovery refuses the plugin at boot. diff --git a/examples/plugins/scheduling/README.md b/examples/plugins/scheduling/README.md index 9a07bc9..c78c70d 100644 --- a/examples/plugins/scheduling/README.md +++ b/examples/plugins/scheduling/README.md @@ -39,7 +39,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`, 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 | +| `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 | | `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 e848bee..403652e 100644 --- a/examples/plugins/scheduling/shifts.test.ts +++ b/examples/plugins/scheduling/shifts.test.ts @@ -182,8 +182,6 @@ test("my shifts asks the upstream for the visitor's own rows, and names them in 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"]); diff --git a/examples/plugins/scheduling/shifts.ts b/examples/plugins/scheduling/shifts.ts index 0eadfd9..fe4a66e 100644 --- a/examples/plugins/scheduling/shifts.ts +++ b/examples/plugins/scheduling/shifts.ts @@ -190,9 +190,6 @@ export function newShiftForm(): RouteHandler { return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" }); } -// 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. 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); diff --git a/examples/shifts-upstream/server.ts b/examples/shifts-upstream/server.ts index a06e528..c534504 100644 --- a/examples/shifts-upstream/server.ts +++ b/examples/shifts-upstream/server.ts @@ -12,9 +12,9 @@ import { createServer } from "node:http"; const PORT = Number(process.env.PORT ?? 4000); 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: "Morning — Front desk", assignee: "avery.kline@plainpages.local", 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: "Evening — On-call", assignee: "casey.nguyen@plainpages.local", 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" }, ];