Gate a route or nav node on a session, not only a permission #103
@@ -231,6 +231,7 @@ Against the reference plugins' actual routes:
|
|||||||
| Request | Gate | alice | bob | carol | anonymous |
|
| Request | Gate | alice | bob | carol | anonymous |
|
||||||
| --- | --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- | --- |
|
||||||
| `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ |
|
| `GET /scheduling` | `public: true` | ✅ | ✅ | ✅ | ✅ |
|
||||||
|
| `GET /scheduling/mine` | `session: true` | ✅ | ✅ | ✅ | → `/login` |
|
||||||
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
|
| `GET /scheduling/shifts` | `scheduling:read` | ✅ | ✅ | 403 | → `/login` |
|
||||||
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
| `GET /scheduling/shifts/new` | `scheduling:write` | ✅ | 403 | 403 | → `/login` |
|
||||||
| `POST /scheduling/shifts` | `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
|
That is the same as omitting `permission`, but stated outright so public 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 — the gate for a plugin whose
|
**`session: true`** takes any signed-in user, with no grant to hold — for a plugin whose data is the
|
||||||
data is the visitor's own (their upstream account, their own tokens), where a permission would name
|
visitor's own. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
|
||||||
a distinction that does not exist. An anonymous visitor is bounced to `/login` with the page as
|
permission gate does.
|
||||||
`return_to`, exactly as a permission gate does.
|
|
||||||
|
|
||||||
A declaration names **exactly one** of the three; two of them contradict, and discovery refuses the
|
A declaration names **exactly one** of the three; two of them contradict, and discovery refuses the
|
||||||
plugin at boot.
|
plugin at boot.
|
||||||
|
|||||||
@@ -39,7 +39,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 — "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=<who>` | `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) |
|
| `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
|
||||||
|
|||||||
@@ -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 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");
|
||||||
// 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" });
|
assert.deepEqual(asked, { assignee: "Blair.Mora@example.test" });
|
||||||
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"]);
|
||||||
|
|||||||
@@ -190,9 +190,6 @@ export function newShiftForm(): RouteHandler {
|
|||||||
return (ctx) => ({ data: buildFormModel({ chrome: ctx.chrome, t: ctx.t }), view: "shift-new" });
|
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 {
|
export function myShifts(upstream: ShiftsUpstream): RouteHandler {
|
||||||
return async (ctx) => {
|
return async (ctx) => {
|
||||||
const user = requireSession(ctx);
|
const user = requireSession(ctx);
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ import { createServer } from "node:http";
|
|||||||
const PORT = Number(process.env.PORT ?? 4000);
|
const PORT = Number(process.env.PORT ?? 4000);
|
||||||
|
|
||||||
const shifts = [
|
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: "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", start: "2026-06-22 12:00", end: "2026-06-22 17: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", start: "2026-06-22 17:00", end: "2026-06-22 22: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" },
|
{ id: randomUUID(), title: "Night — Escalations", assignee: "admin@plainpages.local", start: "2026-06-22 22:00", end: "2026-06-23 06:00" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user