Say the gate rule once, and key the example's shifts by the identifier it scopes on
CI / full-gate (push) Successful in 3m4s
CI / full-gate (push) Successful in 3m4s
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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=<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) |
|
||||
|
||||
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 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"]);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" },
|
||||
];
|
||||
|
||||
|
||||
Reference in New Issue
Block a user