Scope My shifts at the upstream, and give Route and NavNode one gate declaration
CI / full-gate (push) Successful in 2m56s
CI / full-gate (push) Successful in 2m56s
This commit is contained in:
@@ -193,6 +193,13 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await page.goto("/scheduling/shifts");
|
await page.goto("/scheduling/shifts");
|
||||||
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
|
||||||
|
// 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 () => {
|
test("plugin settings: the screen names the variable that sets each declared key", async () => {
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ What it demonstrates:
|
|||||||
The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and
|
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`).
|
`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
|
## Upstream
|
||||||
|
|
||||||
Set `PLUGIN_SETTING_SCHEDULING_UPSTREAM` to your backend's base URL. The dev compose points it at a tiny in-memory
|
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 |
|
| 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=<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 |
|
||||||
| `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
|
||||||
|
|||||||
@@ -174,15 +174,19 @@ 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 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 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@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");
|
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 }[] };
|
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
|
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.
|
// The route carries `session: true`, but the handler asserts the session itself rather than trusting it.
|
||||||
|
|||||||
@@ -47,7 +47,9 @@ export class UpstreamError extends Error {
|
|||||||
|
|
||||||
export interface ShiftsUpstream {
|
export interface ShiftsUpstream {
|
||||||
create(input: ShiftInput): Promise<void>;
|
create(input: ShiftInput): Promise<void>;
|
||||||
list(): Promise<Shift[]>;
|
// `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<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`
|
||||||
@@ -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);
|
if (!res.ok) throw new UpstreamError(`create shift failed (${res.status})`, res.status);
|
||||||
},
|
},
|
||||||
async list() {
|
async list(opts = {}) {
|
||||||
const res = await fetchImpl(`${base()}/shifts`, { headers: { accept: "application/json" } });
|
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);
|
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();
|
||||||
return Array.isArray(data) ? data.map(toShift) : [];
|
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" });
|
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
|
// 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 {
|
export function myShifts(upstream: ShiftsUpstream): RouteHandler {
|
||||||
return async (ctx) => {
|
return async (ctx) => {
|
||||||
const user = requireSession(ctx);
|
const user = requireSession(ctx);
|
||||||
let shifts: Shift[] = [];
|
let shifts: Shift[] = [];
|
||||||
let error: string | undefined;
|
let error: string | undefined;
|
||||||
try {
|
try {
|
||||||
shifts = await upstream.list();
|
shifts = await upstream.list({ assignee: user.email });
|
||||||
} 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");
|
||||||
}
|
}
|
||||||
const mine = shifts.filter((s) => s.assignee.toLowerCase() === user.email.toLowerCase());
|
return { data: buildMineModel({ chrome: ctx.chrome, email: user.email, ...(error ? { error } : {}), shifts, t: ctx.t }), view: "mine" };
|
||||||
return { data: buildMineModel({ chrome: ctx.chrome, email: user.email, ...(error ? { error } : {}), shifts: mine, 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 {
|
export function overview(): RouteHandler {
|
||||||
return (ctx) => ({
|
return (ctx) => ({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -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: "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: "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: "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) => {
|
const json = (res, status, body) => {
|
||||||
@@ -33,7 +34,11 @@ 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") 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") {
|
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 ?? ""), end: String(b.end ?? ""), start: String(b.start ?? ""), title: String(b.title ?? "") };
|
||||||
|
|||||||
+4
-2
@@ -5,10 +5,12 @@ import type { User } from "../http/context.ts";
|
|||||||
// Widest first: whoever passes an earlier gate passes it without holding anything.
|
// Widest first: whoever passes an earlier gate passes it without holding anything.
|
||||||
const GATES = ["public", "session", "permission"] as const;
|
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 {
|
export interface Gate {
|
||||||
permission?: string | undefined; // the Keto Permission the caller must hold
|
permission?: string | undefined; // the Keto Permission the caller must hold, `<resource>:<action>`
|
||||||
public?: boolean | undefined; // anyone, signed in or not
|
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 {
|
export function allows(gate: Gate, user: User | null): boolean {
|
||||||
|
|||||||
@@ -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
|
// 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.
|
// plugin can type against them and `instanceof`-match their errors. See README → System capabilities.
|
||||||
export type { SystemCapabilities } from "./system.ts";
|
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 { Identity, KratosAdmin, RecoveryCode } from "../auth/kratos-admin.ts";
|
||||||
export type { ExpandTree, KetoClient, RelationQuery, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
export type { ExpandTree, KetoClient, RelationQuery, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
|
||||||
export type { HydraAdmin, OAuth2Client } from "../auth/hydra-admin.ts";
|
export type { HydraAdmin, OAuth2Client } from "../auth/hydra-admin.ts";
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
// A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount =
|
// A plugin's identity is its folder under plugins/: folder name = `id` (isValidPluginId), mount =
|
||||||
// `/<id>`. Neither is in the manifest — the host derives them, so they can't drift or be claimed twice.
|
// `/<id>`. 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 { RequestContext } from "../http/context.ts";
|
||||||
import type { NavNode } from "../ui/nav.ts";
|
import type { NavNode } from "../ui/nav.ts";
|
||||||
import { envName, type SettingDecl, type SettingsOf } from "./settings.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> | RouteResult | void;
|
export type RouteHandler = (ctx: RequestContext) => Promise<RouteResult | void> | RouteResult | void;
|
||||||
|
|
||||||
export interface Route {
|
// `Gate` carries `permission`/`public`/`session`, checked before the handler runs.
|
||||||
|
export interface Route extends Gate {
|
||||||
handler: RouteHandler;
|
handler: RouteHandler;
|
||||||
method: HttpMethod;
|
method: HttpMethod;
|
||||||
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
|
path: string; // relative to the plugin's mount path `/<id>`; ":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
|
// A Keto Permission this plugin gates on — declared for docs/seeding. Names are a shared global
|
||||||
|
|||||||
+3
-5
@@ -3,12 +3,13 @@
|
|||||||
// A node is visible iff `allows` passes its gate; a gated header hides its whole subtree, and a pure
|
// 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.
|
// 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 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";
|
||||||
|
|
||||||
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
|
id?: string; // stable key for override targeting; stripped from the rendered tree
|
||||||
children?: NavNode[];
|
children?: NavNode[];
|
||||||
count?: number;
|
count?: number;
|
||||||
@@ -17,9 +18,6 @@ export interface NavNode {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
label: string;
|
label: string;
|
||||||
open?: boolean;
|
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 →
|
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
|
||||||
|
|||||||
Reference in New Issue
Block a user