Gate a route or nav node on a session, not only a permission #103
@@ -182,6 +182,12 @@ Revisit only if the stated reason stops holding.
|
|||||||
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
example it keeps the route table and the in-handler guard deriving from one function, so 29 routes
|
||||||
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
× 2 gate sites cannot drift. Generalised, it would make authorization a function of the transport
|
||||||
verb — a route table must answer "what does this need?" on its own.
|
verb — a route table must answer "what does this need?" on its own.
|
||||||
|
- **A gate is one of three, and `session` is a first-class one.** A route or nav node names exactly
|
||||||
|
one of `public`, `session`, `permission` (discovery refuses two), and `src/auth/gate.ts` is the one
|
||||||
|
home of the rule the router and the menu both read. `session` exists because a plugin whose data is
|
||||||
|
the visitor's own — their upstream account, their own tokens — has no distinction a permission could
|
||||||
|
name; the alternative, granting every newly registered user a permission, couples the identity
|
||||||
|
lifecycle to a Keto write that nothing retries when it fails.
|
||||||
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
|
- **A `:read`-only holder must never be shown a write affordance.** The list/detail models carry
|
||||||
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
|
`canWrite` and the views drop create/save/delete/add/remove; the permission picker still renders,
|
||||||
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
|
disabled, because *seeing* who holds what is the point of `:read`. A **write-intent GET** (a create
|
||||||
|
|||||||
@@ -389,13 +389,14 @@ A plugin may be routes-only, nav-only, or hooks-only — every collection field
|
|||||||
|
|
||||||
### Routes & handlers
|
### Routes & handlers
|
||||||
|
|
||||||
A route is `{ method, path, permission?, public?, handler }`. `path` is **relative to the plugin's
|
A route is `{ method, path, permission?, public?, session?, handler }`. `path` is **relative to the plugin's
|
||||||
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches
|
mount path `/<id>`** (so `path: "/:id"` in the `things` plugin serves `/things/:id`); the host matches
|
||||||
`method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the
|
`method` + the resolved full path, extracts `:name` segments into `ctx.params.name`, runs the
|
||||||
`permission` gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with
|
`permission` gate ([a coarse JWT-claim check](#nav--permission-gates)), then calls the handler with
|
||||||
the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login`
|
the [request context](#requestcontext). A failed gate redirects an **anonymous** visitor to `/login`
|
||||||
with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page.
|
with the page as `return_to`; a **signed-in** user lacking the permission gets the **403** page.
|
||||||
`public: true` means no gate at all (see [Public pages](#public-pages--menu-items)).
|
`public: true` means no gate at all, `session: true` any signed-in user (see
|
||||||
|
[Public pages](#public-pages--menu-items)).
|
||||||
|
|
||||||
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
|
`method` is one of `GET HEAD POST PUT PATCH DELETE`. A `GET` route also answers `HEAD`.
|
||||||
|
|
||||||
@@ -569,7 +570,8 @@ system plugins you author or vendor. An ordinary domain plugin ignores it.
|
|||||||
|
|
||||||
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
|
A plugin's `nav` fragment is merged into the global menu by `composeNav` (`src/ui/nav.ts`), which
|
||||||
applies the central override and then **filters per user** by the permissions in the session JWT: a
|
applies the central override and then **filters per user** by the permissions in the session JWT: a
|
||||||
node shows iff it is `public`, declares no `permission`, or the user holds that name. A node's `icon`
|
node shows iff it is `public`, is `session` and someone is signed in, declares no `permission`, or the
|
||||||
|
user holds that name. A node's `icon`
|
||||||
is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are
|
is a **Lucide icon** by sprite id (e.g. `i-cal` → lucide `calendar`); the available ids are
|
||||||
`ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
|
`ICON_NAMES` in `src/ui/icons.ts`, and adding one means registering its lucide name there.
|
||||||
|
|
||||||
@@ -582,7 +584,15 @@ drops a header whose children all filtered out. That only works while the header
|
|||||||
|
|
||||||
A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu.
|
A route or nav node marked **`public: true`** is reachable by anyone and shows in everyone's menu.
|
||||||
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. The two are **mutually exclusive** — declaring both is refused at boot.
|
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.
|
||||||
|
|
||||||
|
A declaration names **exactly one** of the three; two of them contradict, and discovery refuses the
|
||||||
|
plugin at boot.
|
||||||
|
|
||||||
A public page still renders in the native shell; for an anonymous visitor `ctx.user` is `null`, the
|
A public page still renders in the native shell; for an anonymous visitor `ctx.user` is `null`, the
|
||||||
shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden,
|
shell shows a **Sign in** link in place of the profile block, the gated **Dashboard** link is hidden,
|
||||||
@@ -635,7 +645,7 @@ The host detects collisions across all discovered plugins with `findConflicts` a
|
|||||||
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
|
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
|
||||||
rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function
|
rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function
|
||||||
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
|
`home`/`dashboard`, a permission name that isn't [`<resource>:<action>`](#naming-a-permission), or a
|
||||||
route/nav node setting both `public` and `permission`.
|
route/nav node naming more than one gate.
|
||||||
|
|
||||||
### Hooks
|
### Hooks
|
||||||
|
|
||||||
@@ -913,7 +923,8 @@ The menu is **driven entirely by config** and assembled from two sources:
|
|||||||
|
|
||||||
Every nav item may carry a `permission`; the rendered tree is **filtered per user** from the session
|
Every nav item may carry a `permission`; the rendered tree is **filtered per user** from the session
|
||||||
JWT (no per-request authz call), so the menu only shows what that person can reach. An item may
|
JWT (no per-request authz call), so the menu only shows what that person can reach. An item may
|
||||||
instead be **`public: true`** to show it to everyone — mutually exclusive with `permission`.
|
instead be **`public: true`** (everyone) or **`session: true`** (anyone signed in) — one gate per
|
||||||
|
item, never two.
|
||||||
Branding (name, logo, default theme) renders in the app shell.
|
Branding (name, logo, default theme) renders in the app shell.
|
||||||
|
|
||||||
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders
|
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders
|
||||||
|
|||||||
@@ -143,11 +143,11 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to
|
|||||||
await expect(page.getByRole("link", { name: "Back home" })).toBeVisible();
|
await expect(page.getByRole("link", { name: "Back home" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
// The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is
|
// The reference plugin (plugins/scheduling) ships discovered in the image, and shows all three
|
||||||
// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated,
|
// gates: the public Overview is reachable by anyone, My shifts takes any session, and the shifts
|
||||||
// so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full
|
// list needs a permission. The authenticated list/form flow is the full E2E (full-flow.spec).
|
||||||
// E2E (full-flow.spec). Side-effect-free.
|
// Side-effect-free.
|
||||||
test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => {
|
test("the reference plugin: public Overview is open to all, My shifts takes any session, the gated Shifts redirects to /login", async ({ page, request }) => {
|
||||||
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
|
// `request` is the isolated API context — it doesn't carry the beforeEach session cookie, so these
|
||||||
// probes are genuinely anonymous.
|
// probes are genuinely anonymous.
|
||||||
// The public overview is reachable with no session (200), not bounced to sign in.
|
// The public overview is reachable with no session (200), not bounced to sign in.
|
||||||
@@ -166,10 +166,22 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red
|
|||||||
expect(res.status()).toBe(303);
|
expect(res.status()).toBe(303);
|
||||||
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
|
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
|
||||||
|
|
||||||
|
// A `session: true` route bounces an anonymous visitor the same way — no permission involved.
|
||||||
|
const mine = await request.get("/scheduling/mine", { maxRedirects: 0 });
|
||||||
|
expect(mine.status()).toBe(303);
|
||||||
|
expect(mine.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fmine");
|
||||||
|
|
||||||
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
|
// The signed-in member (no scheduling permission) sees the public Scheduling → Overview leaf in the nav,
|
||||||
// but the gated Shifts leaf is filtered out.
|
// but the gated Shifts leaf is filtered out.
|
||||||
await page.goto("/dashboard");
|
await page.goto("/dashboard");
|
||||||
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
await expect(page.locator('.sidebar a[href="/dashboard"]')).toHaveCount(1); // the one unified menu renders
|
||||||
await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
|
await expect(page.locator('.sidebar a[href="/scheduling"]')).toHaveCount(1); // public Overview shown
|
||||||
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
|
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(0); // gated leaf filtered out
|
||||||
|
await expect(page.locator('.sidebar a[href="/scheduling/mine"]')).toHaveCount(1); // session gate: a session is enough
|
||||||
|
|
||||||
|
// And the page itself renders for that same member, holding no permission at all. This stack runs
|
||||||
|
// no shifts upstream, so the list degrades to its empty state — which still names whose page it is.
|
||||||
|
await page.goto("/scheduling/mine");
|
||||||
|
await expect(page.getByRole("heading", { name: "My shifts" })).toBeVisible();
|
||||||
|
await expect(page.getByText("No shifts are assigned to demo@plainpages.local.")).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,8 +15,9 @@ What it demonstrates:
|
|||||||
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
|
`POST /scheduling/shifts` CSRF-verifies it (`ctx.verifyCsrf`) and forwards the create upstream,
|
||||||
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
|
then POST-redirect-GET. The form body lives in the plugin's own `views/partials/shift-form.ejs`,
|
||||||
reusing the core `field` partial.
|
reusing the core `field` partial.
|
||||||
- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` /
|
- **All three route gates** — the Overview is `public` (anyone), "My shifts" is `session` (any
|
||||||
`scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant.
|
signed-in visitor, showing only rows assigned to them), and "Shifts" is gated on `scheduling:read` /
|
||||||
|
`scheduling:write`; a leaf whose gate a visitor fails is invisible in the menu.
|
||||||
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
|
- **Its own translations** — every string comes from `i18n/en-US.ts` (`sv-SE.ts` beside it), including
|
||||||
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
|
the nav labels, which are catalog keys in the manifest. `shifts.count` shows a plural message, and
|
||||||
the views carry the visitor's language onto their links with `localeHref()`.
|
the views carry the visitor's language onto their links with `localeHref()`.
|
||||||
@@ -50,6 +51,7 @@ cosmetically) — normalise to your backend's format there if it matters.
|
|||||||
|
|
||||||
## Granting access
|
## Granting access
|
||||||
|
|
||||||
A user sees Scheduling once they hold the `scheduling:read` permission in Keto (and `scheduling:write`
|
A user sees the shift list once they hold the `scheduling:read` permission in Keto (and
|
||||||
to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
`scheduling:write` to create). The one-command bootstrap grants both to the demo admin, so the seeded
|
||||||
`admin@plainpages.local` can use it immediately.
|
`admin@plainpages.local` can use it immediately. "My shifts" needs no grant at all — signing in is
|
||||||
|
its whole gate.
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ const messages = {
|
|||||||
"scheduling.filter.searchLabel": "Search shifts",
|
"scheduling.filter.searchLabel": "Search shifts",
|
||||||
"scheduling.filter.searchPlaceholder": "Search title or assignee…",
|
"scheduling.filter.searchPlaceholder": "Search title or assignee…",
|
||||||
"scheduling.form.submit": "Create shift",
|
"scheduling.form.submit": "Create shift",
|
||||||
|
"scheduling.mine.empty": "No shifts are assigned to {{email}}.",
|
||||||
|
"scheduling.mine.title": "My shifts",
|
||||||
|
"scheduling.nav.mine": "My shifts",
|
||||||
"scheduling.nav.overview": "Overview",
|
"scheduling.nav.overview": "Overview",
|
||||||
"scheduling.nav.section": "Scheduling",
|
"scheduling.nav.section": "Scheduling",
|
||||||
"scheduling.nav.shifts": "Shifts",
|
"scheduling.nav.shifts": "Shifts",
|
||||||
|
|||||||
@@ -9,6 +9,9 @@ const messages: SchedulingMessages = {
|
|||||||
"scheduling.filter.searchLabel": "Sök pass",
|
"scheduling.filter.searchLabel": "Sök pass",
|
||||||
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
|
"scheduling.filter.searchPlaceholder": "Sök på namn eller person…",
|
||||||
"scheduling.form.submit": "Skapa pass",
|
"scheduling.form.submit": "Skapa pass",
|
||||||
|
"scheduling.mine.empty": "Inga pass är tilldelade {{email}}.",
|
||||||
|
"scheduling.mine.title": "Mina pass",
|
||||||
|
"scheduling.nav.mine": "Mina pass",
|
||||||
"scheduling.nav.overview": "Översikt",
|
"scheduling.nav.overview": "Översikt",
|
||||||
"scheduling.nav.section": "Schemaläggning",
|
"scheduling.nav.section": "Schemaläggning",
|
||||||
"scheduling.nav.shifts": "Pass",
|
"scheduling.nav.shifts": "Pass",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
|
// folder, rename it, point it at your own backend. Full contract: README.md → Building plugins.
|
||||||
|
|
||||||
import { definePlugin } from "@plainpages/plugin-api";
|
import { definePlugin } from "@plainpages/plugin-api";
|
||||||
import { createShift, createUpstream, listShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
import { createShift, createUpstream, listShifts, MINE_PATH, myShifts, newShiftForm, overview, READ, SCHEDULING_PATH, SHIFTS_PATH, WRITE } from "./shifts.ts";
|
||||||
|
|
||||||
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
|
// The upstream this plugin reads/writes — a stand-in for your real backend (the plugin is
|
||||||
// stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it
|
// stateless). Its URL is a declared setting, so it is resolved and validated before onBoot hands it
|
||||||
@@ -25,6 +25,7 @@ export default definePlugin({
|
|||||||
nav: [{
|
nav: [{
|
||||||
children: [
|
children: [
|
||||||
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true },
|
{ href: SCHEDULING_PATH, id: "scheduling:overview", label: "scheduling.nav.overview", public: true },
|
||||||
|
{ href: MINE_PATH, id: "scheduling:mine", label: "scheduling.nav.mine", session: true },
|
||||||
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ },
|
{ href: SHIFTS_PATH, id: "scheduling:shifts", label: "scheduling.nav.shifts", permission: READ },
|
||||||
],
|
],
|
||||||
icon: "i-cal",
|
icon: "i-cal",
|
||||||
@@ -42,6 +43,7 @@ export default definePlugin({
|
|||||||
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
|
// (anyone may reach /scheduling, signed in or not); the rest need a permission.
|
||||||
routes: [
|
routes: [
|
||||||
{ handler: overview(), method: "GET", path: "/", public: true },
|
{ handler: overview(), method: "GET", path: "/", public: true },
|
||||||
|
{ handler: myShifts(upstream), method: "GET", path: "/mine", session: true },
|
||||||
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
|
{ handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ },
|
||||||
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
|
{ handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE },
|
||||||
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
|
{ handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE },
|
||||||
|
|||||||
@@ -4,21 +4,21 @@ import { Readable } from "node:stream";
|
|||||||
import test from "node:test";
|
import test from "node:test";
|
||||||
// Import only from the @plainpages/plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
// Import only from the @plainpages/plugin-api barrel — the same contract boundary shifts.ts uses (the host may
|
||||||
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
// refactor any deeper src/* freely behind it); the test models the dev/test story the contract preaches.
|
||||||
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult } from "@plainpages/plugin-api";
|
import { englishTranslator, GuardError, Log, type PageChrome, type RequestContext, type RouteResult, type User } from "@plainpages/plugin-api";
|
||||||
import enUS from "./i18n/en-US.ts";
|
import enUS from "./i18n/en-US.ts";
|
||||||
import {
|
import {
|
||||||
buildFormModel, createShift, createUpstream, listShifts, newShiftForm, overview, readInput,
|
buildFormModel, createShift, createUpstream, listShifts, myShifts, newShiftForm, overview, readInput,
|
||||||
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
||||||
} from "./shifts.ts";
|
} from "./shifts.ts";
|
||||||
|
|
||||||
const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them
|
const t = englishTranslator(enUS); // this plugin's catalog then the host's, as the host would chain them
|
||||||
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], signInHref: "/login", user: { email: "", initials: "T", name: "Tester" } };
|
||||||
|
|
||||||
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
function fakeCtx(opts: { body?: string; permissions?: string[]; url?: string; user?: User; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||||
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||||
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
const req = Readable.from(opts.body != null ? [Buffer.from(opts.body)] : []) as unknown as IncomingMessage;
|
||||||
return {
|
return {
|
||||||
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
chrome: CHROME, declaredPermissions: [], declaredSettings: [], user: opts.user ?? null, locale: "en-US", localeHref: (href) => href, locales: ["en-US"], log: new Log("none"), params: {},
|
||||||
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
|
query: url.searchParams, req, res: {} as ServerResponse, permissions: opts.permissions ?? [], t, url,
|
||||||
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
verifyCsrf: opts.verifyCsrf ?? (() => true),
|
||||||
};
|
};
|
||||||
@@ -171,3 +171,20 @@ test("buildFormModel marks title/assignee required and attaches field errors", (
|
|||||||
assert.equal(title.error, "needed");
|
assert.equal(title.error, "needed");
|
||||||
assert.equal(fields.find((f) => f.name === "start")!.required, undefined);
|
assert.equal(fields.find((f) => f.name === "start")!.required, undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- 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 () => {
|
||||||
|
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
|
||||||
|
|
||||||
|
const r = asView(await myShifts(fakeUpstream({ list: async () => listed }))(fakeCtx({ url: "http://localhost/scheduling/mine", user })));
|
||||||
|
assert.equal(r.view, "mine");
|
||||||
|
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.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.
|
||||||
|
await assert.rejects(async () => { await myShifts(fakeUpstream())(fakeCtx()); }, GuardError);
|
||||||
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
|
// pure functions against a mock upstream with no network (README.md → Local dev & test story).
|
||||||
|
|
||||||
// One import from the host's @plainpages/plugin-api barrel — the stable author surface (see README.md → Building plugins).
|
// One import from the host's @plainpages/plugin-api barrel — the stable author surface (see README.md → Building plugins).
|
||||||
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, type RouteHandler, type Translate, tracedFetch } from "@plainpages/plugin-api";
|
import { can, CSRF_FIELD, englishTranslator, GuardError, type PageChrome, parseListQuery, readFormBody, requireSession, type RouteHandler, type Translate, tracedFetch } from "@plainpages/plugin-api";
|
||||||
import enUS from "./i18n/en-US.ts";
|
import enUS from "./i18n/en-US.ts";
|
||||||
|
|
||||||
// The plugin's own English (its catalog, then the host's), for a view model built outside a request:
|
// The plugin's own English (its catalog, then the host's), for a view model built outside a request:
|
||||||
@@ -16,6 +16,7 @@ const EN: Translate = englishTranslator(enUS);
|
|||||||
|
|
||||||
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page
|
||||||
export const SHIFTS_PATH = "/scheduling/shifts";
|
export const SHIFTS_PATH = "/scheduling/shifts";
|
||||||
|
export const MINE_PATH = "/scheduling/mine"; // the visitor's own shifts — a session is the whole gate
|
||||||
export const READ = "scheduling:read"; // the permission gating the list + nav
|
export const READ = "scheduling:read"; // the permission gating the list + nav
|
||||||
export const WRITE = "scheduling:write"; // the permission gating create
|
export const WRITE = "scheduling:write"; // the permission gating create
|
||||||
|
|
||||||
@@ -190,6 +191,41 @@ export function newShiftForm(): RouteHandler {
|
|||||||
// gate lets an anonymous visitor through and the menu option shows for everyone. The real data
|
// 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
|
// (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).
|
// 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.
|
||||||
|
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();
|
||||||
|
} 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" };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMineModel(opts: { chrome: PageChrome; email: string; error?: string; shifts: Shift[]; t?: Translate }) {
|
||||||
|
const t = opts.t ?? EN;
|
||||||
|
return {
|
||||||
|
breadcrumbs: [{ label: t("scheduling.mine.title") }],
|
||||||
|
chrome: opts.chrome,
|
||||||
|
count: t("scheduling.shifts.count", { count: opts.shifts.length }),
|
||||||
|
...(opts.error ? { error: opts.error } : {}),
|
||||||
|
table: {
|
||||||
|
caption: t("scheduling.mine.title"),
|
||||||
|
columns: [{ label: t("scheduling.table.shift") }, { label: t("scheduling.table.start") }, { label: t("scheduling.table.end") }],
|
||||||
|
emptyText: t("scheduling.mine.empty", { email: opts.email }),
|
||||||
|
rows: opts.shifts.map((s) => ({ cells: [{ rowHeader: { text: s.title } }, s.start, s.end], name: s.title })),
|
||||||
|
},
|
||||||
|
title: t("scheduling.mine.title"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function overview(): RouteHandler {
|
export function overview(): RouteHandler {
|
||||||
return (ctx) => ({
|
return (ctx) => ({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<%#
|
||||||
|
Scheduling · the visitor's own shifts (reference plugin). Reached behind `session: true`, so
|
||||||
|
ctx.user is always set by the time this renders.
|
||||||
|
Data: chrome, title, breadcrumbs, count, table, error?
|
||||||
|
%><%
|
||||||
|
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||||
|
const tableHtml = include("partials/data-table", table);
|
||||||
|
const alertHtml = locals.error ? include("partials/alert", { text: locals.error, tone: "neg" }) : "";
|
||||||
|
-%>
|
||||||
|
<%- include("partials/shell", {
|
||||||
|
body: '<div class="scheduling-page">' + alertHtml + '<p class="shift-count">' + count + '</p>' + tableHtml + '</div>',
|
||||||
|
brand: chrome.brand,
|
||||||
|
breadcrumbs,
|
||||||
|
csrfToken: chrome.csrfToken,
|
||||||
|
nav: navHtml,
|
||||||
|
styles: ["/public/scheduling/scheduling.css"],
|
||||||
|
theme: chrome.theme,
|
||||||
|
title,
|
||||||
|
user: chrome.user,
|
||||||
|
}) %>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import type { User } from "../http/context.ts";
|
||||||
|
import { allows, gatesSet } from "./gate.ts";
|
||||||
|
|
||||||
|
const holder: User = { email: "holder@example.test", id: "01a06091-ba9f-765f-abf4-b5144c314bc7", permissions: ["x:read"] };
|
||||||
|
const stranger: User = { email: "stranger@example.test", id: "01a06091-baa3-7b4d-810a-c9ee7e559d98", permissions: [] };
|
||||||
|
|
||||||
|
test("allows: ungated and public are open to anyone; session needs a user; permission needs the token", () => {
|
||||||
|
assert.equal(allows({}, null), true);
|
||||||
|
assert.equal(allows({ public: true }, null), true);
|
||||||
|
|
||||||
|
assert.equal(allows({ session: true }, null), false);
|
||||||
|
assert.equal(allows({ session: true }, stranger), true); // signed in is the whole gate — no grant
|
||||||
|
|
||||||
|
assert.equal(allows({ permission: "x:read" }, null), false);
|
||||||
|
assert.equal(allows({ permission: "x:read" }, stranger), false);
|
||||||
|
assert.equal(allows({ permission: "x:read" }, holder), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gatesSet names the gates a declaration sets, so discovery can refuse more than one", () => {
|
||||||
|
assert.deepEqual(gatesSet({}), []);
|
||||||
|
assert.deepEqual(gatesSet({ session: true }), ["session"]);
|
||||||
|
assert.deepEqual(gatesSet({ permission: "x:read", public: true }), ["public", "permission"]);
|
||||||
|
assert.deepEqual(gatesSet({ permission: "x:read", public: true, session: true }), ["public", "session", "permission"]);
|
||||||
|
// `false` is not a gate — only a set one counts, so { session: false } is an ungated route.
|
||||||
|
assert.deepEqual(gatesSet({ public: false, session: false }), []);
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// The coarse gate a route or nav node declares. One home for the rule, so the router and the menu
|
||||||
|
// can never disagree about what a visitor may reach.
|
||||||
|
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;
|
||||||
|
|
||||||
|
export interface Gate {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
export function allows(gate: Gate, user: User | null): boolean {
|
||||||
|
if (gate.public === true) return true;
|
||||||
|
if (gate.session === true) return user !== null;
|
||||||
|
return gate.permission == null || (user?.permissions.includes(gate.permission) ?? false);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Which gates a declaration sets — discovery refuses more than one, since they contradict.
|
||||||
|
export function gatesSet(gate: Gate | null | undefined): string[] {
|
||||||
|
if (gate == null) return [];
|
||||||
|
return GATES.filter((name) => (name === "permission" ? gate.permission != null : gate[name] === true));
|
||||||
|
}
|
||||||
+3
-2
@@ -28,7 +28,8 @@ import { DEFAULT_MENU, type MenuConfig } from "../ui/menu-config.ts";
|
|||||||
import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
|
import { declaredPermissions, type Plugin, type RouteHandler, type RouteResult } from "../plugin-host/plugin.ts";
|
||||||
import type { PluginSettings } from "../plugin-host/settings.ts";
|
import type { PluginSettings } from "../plugin-host/settings.ts";
|
||||||
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
import type { SystemCapabilities } from "../plugin-host/system.ts";
|
||||||
import { allowedMethods, isAuthorized, matchRoute } from "../plugin-host/router.ts";
|
import { allows } from "../auth/gate.ts";
|
||||||
|
import { allowedMethods, matchRoute } from "../plugin-host/router.ts";
|
||||||
import { buildAuthRoutes } from "../auth/routes.ts";
|
import { buildAuthRoutes } from "../auth/routes.ts";
|
||||||
import { securityHeaders } from "./security-headers.ts";
|
import { securityHeaders } from "./security-headers.ts";
|
||||||
import { localPath } from "./safe-url.ts";
|
import { localPath } from "./safe-url.ts";
|
||||||
@@ -281,7 +282,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
const match = matchRoute(plugins, method, pathname);
|
const match = matchRoute(plugins, method, pathname);
|
||||||
if (match) {
|
if (match) {
|
||||||
const routeCtx = contextFor(match.plugin.id, match.params);
|
const routeCtx = contextFor(match.plugin.id, match.params);
|
||||||
if (!isAuthorized(match.route, routeCtx.permissions)) {
|
if (!allows(match.route, routeCtx.user)) {
|
||||||
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply
|
// Anonymous → sign in, remembering the page as return_to; a signed-in user who simply
|
||||||
// lacks the permission gets the 403 page.
|
// lacks the permission gets the 403 page.
|
||||||
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; }
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
|||||||
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
|
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
|
||||||
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
|
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
|
||||||
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s },
|
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", public: true, permission: "x:read" }] };` }, match: /contranav.*public.*permission/s },
|
||||||
|
{ name: "a route marked session AND permission is contradictory", files: { "contrasess/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", session: true, permission: "x:read", handler: () => ({ html: "x" }) }] };` }, match: /contrasess.*session.*permission/s },
|
||||||
|
{ name: "a route marked public AND session is contradictory", files: { "contrapub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", public: true, session: true, handler: () => ({ html: "x" }) }] };` }, match: /contrapub.*public.*session/s },
|
||||||
|
{ name: "a nav node marked session AND permission is contradictory", files: { "contrasessnav/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ id: "n", label: "N", session: true, permission: "x:read" }] };` }, match: /contrasessnav.*session.*permission/s },
|
||||||
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
|
// A permission name is <resource>:<action> wherever the manifest mentions one. Enforced here, not
|
||||||
// only in the admin GUI, so it holds for a plugin installed without that GUI.
|
// only in the admin GUI, so it holds for a plugin installed without that GUI.
|
||||||
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
|
{ name: "a route gating on a bare word", files: { "bare/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", routes: [{ method: "GET", path: "/", permission: "admin", handler: () => ({ html: "x" }) }] };` }, match: /bare.*admin.*<resource>:<action>/s },
|
||||||
@@ -96,12 +99,19 @@ test("a discovery failure tells the operator their plugins/ copy may just be out
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("a route + nav node may be marked public and load fine", async (t) => {
|
test("a route + nav node may be marked public, or session, and load fine", async (t) => {
|
||||||
const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` });
|
const dir = scaffold(t, {
|
||||||
|
"pub/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };`,
|
||||||
|
"sess/plugin.ts": `export default { apiVersion: "${HOST_API_VERSION}", nav: [{ href: "/sess", id: "s", label: "S", session: true }], routes: [{ method: "GET", path: "/", session: true, handler: () => ({ html: "x" }) }] };`,
|
||||||
|
});
|
||||||
const plugins = await discoverPlugins({ dir });
|
const plugins = await discoverPlugins({ dir });
|
||||||
assert.equal(plugins.length, 1);
|
assert.equal(plugins.length, 2);
|
||||||
assert.equal(plugins[0]?.routes?.[0]?.public, true);
|
const pub = plugins.find((p) => p.id === "pub");
|
||||||
assert.equal(plugins[0]?.nav?.[0]?.public, true);
|
const sess = plugins.find((p) => p.id === "sess");
|
||||||
|
assert.equal(pub?.routes?.[0]?.public, true);
|
||||||
|
assert.equal(pub?.nav?.[0]?.public, true);
|
||||||
|
assert.equal(sess?.routes?.[0]?.session, true);
|
||||||
|
assert.equal(sess?.nav?.[0]?.session, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => {
|
test("`admin` is not reserved — the admin screens ship as a drop-in plugin mounted at /admin", async (t) => {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
||||||
import { dirname, join } from "node:path";
|
import { dirname, join } from "node:path";
|
||||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||||
|
import { gatesSet } from "../auth/gate.ts";
|
||||||
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
|
import { checkApiVersion, findConflicts, isValidPermissionName, isValidPluginId, RESERVED_PLUGIN_IDS, type Plugin, type PluginManifest } from "./plugin.ts";
|
||||||
import { settingsDeclError } from "./settings.ts";
|
import { settingsDeclError } from "./settings.ts";
|
||||||
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts";
|
import { isValidStoragePluginId, MAX_STORAGE_PLUGIN_ID_LENGTH } from "./storage.ts";
|
||||||
@@ -146,12 +147,13 @@ function shapeError(manifest: PluginManifest): string | null {
|
|||||||
const settings = settingsDeclError(manifest.settings);
|
const settings = settingsDeclError(manifest.settings);
|
||||||
if (settings) return settings;
|
if (settings) return settings;
|
||||||
}
|
}
|
||||||
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
|
// Two gates on one route or nav node contradict each other — "open to all" vs "needs a session"
|
||||||
// "needs this permission". Refuse rather than silently pick one, so the author's intent is unambiguous.
|
// vs "needs this permission". Refuse rather than silently pick one, so intent stays unambiguous.
|
||||||
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
|
||||||
if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`;
|
const gates = gatesSet(route);
|
||||||
|
if (gates.length > 1) return `route "${route?.method} ${route?.path}" sets ${gates.join(" and ")}; a route names exactly one gate — public, session or permission`;
|
||||||
}
|
}
|
||||||
const navContradiction = findPublicNavContradiction(manifest.nav);
|
const navContradiction = findNavGateContradiction(manifest.nav);
|
||||||
if (navContradiction) return navContradiction;
|
if (navContradiction) return navContradiction;
|
||||||
// Every permission name the manifest mentions — gated on or declared — must be `<resource>:<action>`.
|
// Every permission name the manifest mentions — gated on or declared — must be `<resource>:<action>`.
|
||||||
// A bare word names a role, and roles are groups here (README → Naming a permission).
|
// A bare word names a role, and roles are groups here (README → Naming a permission).
|
||||||
@@ -170,11 +172,12 @@ function shapeError(manifest: PluginManifest): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory.
|
// Recurse the nav fragment: a node naming more than one gate is contradictory, same as a route.
|
||||||
function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null {
|
function findNavGateContradiction(nodes: PluginManifest["nav"]): string | null {
|
||||||
for (const node of Array.isArray(nodes) ? nodes : []) {
|
for (const node of Array.isArray(nodes) ? nodes : []) {
|
||||||
if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`;
|
const gates = gatesSet(node);
|
||||||
const inChild = findPublicNavContradiction(node?.children);
|
if (gates.length > 1) return `nav node "${node?.label ?? node?.id ?? "?"}" sets ${gates.join(" and ")}; a node names exactly one gate — public, session or permission`;
|
||||||
|
const inChild = findNavGateContradiction(node?.children);
|
||||||
if (inChild) return inChild;
|
if (inChild) return inChild;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ export interface Route {
|
|||||||
// Same as omitting `permission`, but stated outright so public is a deliberate choice rather than
|
// 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).
|
// a forgotten gate. Mutually exclusive with `permission` (discovery refuses both).
|
||||||
public?: boolean;
|
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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import type { Plugin, Route } from "./plugin.ts";
|
import type { Plugin, Route } from "./plugin.ts";
|
||||||
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
|
import { allowedMethods, matchRoute } from "./router.ts";
|
||||||
|
|
||||||
const noop: Route["handler"] = () => ({ html: "x" });
|
const noop: Route["handler"] = () => ({ html: "x" });
|
||||||
|
|
||||||
@@ -54,14 +54,3 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the
|
|||||||
assert.deepEqual(allowedMethods(plugins, "/x/a"), ["GET", "HEAD", "POST"]);
|
assert.deepEqual(allowedMethods(plugins, "/x/a"), ["GET", "HEAD", "POST"]);
|
||||||
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
|
assert.deepEqual(allowedMethods(plugins, "/x/missing"), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("isAuthorized: open routes pass; gated routes require the permission token; public is explicitly open", () => {
|
|
||||||
const open: Route = { handler: noop, method: "GET", path: "/" };
|
|
||||||
const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" };
|
|
||||||
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias
|
|
||||||
assert.equal(isAuthorized(open, []), true);
|
|
||||||
assert.equal(isAuthorized(gated, []), false);
|
|
||||||
assert.equal(isAuthorized(gated, ["x:read"]), true);
|
|
||||||
assert.equal(isAuthorized(gated, ["other"]), false);
|
|
||||||
assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting permission — but stated outright
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -73,10 +73,3 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
|
|||||||
}
|
}
|
||||||
return [...methods].sort();
|
return [...methods].sort();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise
|
|
||||||
// the user's permissions (from the session JWT) must include the token. The same rule composeNav uses
|
|
||||||
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
|
|
||||||
export function isAuthorized(route: Route, permissions: string[]): boolean {
|
|
||||||
return route.public === true || route.permission == null || permissions.includes(route.permission);
|
|
||||||
}
|
|
||||||
|
|||||||
+1
-2
@@ -44,8 +44,7 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
|||||||
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
|
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
|
||||||
}
|
}
|
||||||
|
|
||||||
const permissions = opts.user?.permissions ?? [];
|
const nav = composeNav(fragments, opts.menu.override, opts.user ?? null, t);
|
||||||
const nav = composeNav(fragments, opts.menu.override, permissions, t);
|
|
||||||
if (opts.currentPath) {
|
if (opts.currentPath) {
|
||||||
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
|
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
|
||||||
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
|
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard.
|
||||||
|
|||||||
+19
-9
@@ -1,7 +1,12 @@
|
|||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
|
import type { User } from "../http/context.ts";
|
||||||
import { composeNav, type NavNode } from "./nav.ts";
|
import { composeNav, type NavNode } from "./nav.ts";
|
||||||
|
|
||||||
|
function viewer(...permissions: string[]): User {
|
||||||
|
return { email: "viewer@example.test", id: "01a06091-ba9f-765f-abf4-b5144c314bc7", permissions };
|
||||||
|
}
|
||||||
|
|
||||||
// Two plugin fragments; ids let the override target nodes, `permission` gates per permission.
|
// Two plugin fragments; ids let the override target nodes, `permission` gates per permission.
|
||||||
const fragments: NavNode[][] = [
|
const fragments: NavNode[][] = [
|
||||||
[{
|
[{
|
||||||
@@ -15,7 +20,7 @@ const fragments: NavNode[][] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
test("composeNav merges fragments, filters by permission, and emits clean render nodes", () => {
|
test("composeNav merges fragments, filters by permission, and emits clean render nodes", () => {
|
||||||
const tree = composeNav(fragments, {}, ["scheduling:read"]);
|
const tree = composeNav(fragments, {}, viewer("scheduling:read"));
|
||||||
|
|
||||||
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
|
// Reports gone (no reports:read), Manage gone (no scheduling:admin), header kept with Shifts.
|
||||||
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
|
// Output carries no `id`/`permission` and omits absent fields — ready for nav-tree.ejs.
|
||||||
@@ -30,7 +35,7 @@ test("composeNav drops gated subtrees, empty headers, and (with no permissions)
|
|||||||
{ id: "admin", label: "Admin", permission: "users:read", children: [{ href: "/u", id: "u", label: "Users" }] },
|
{ id: "admin", label: "Admin", permission: "users:read", children: [{ href: "/u", id: "u", label: "Users" }] },
|
||||||
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
|
{ id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] },
|
||||||
]];
|
]];
|
||||||
assert.deepEqual(composeNav(gatedHeader, {}, []), [
|
assert.deepEqual(composeNav(gatedHeader, {}, viewer()), [
|
||||||
{ label: "Free", children: [{ href: "/d", label: "Docs" }] },
|
{ label: "Free", children: [{ href: "/d", label: "Docs" }] },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -39,26 +44,31 @@ test("composeNav drops gated subtrees, empty headers, and (with no permissions)
|
|||||||
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x:read" }] },
|
{ id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x:read" }] },
|
||||||
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y:read" }] },
|
{ href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y:read" }] },
|
||||||
]];
|
]];
|
||||||
assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]);
|
assert.deepEqual(composeNav(emptyHeader, {}, viewer()), [{ href: "/hub", label: "Hub" }]);
|
||||||
|
|
||||||
// No fragments / no permissions → empty tree, never throws.
|
// No fragments / no permissions → empty tree, never throws.
|
||||||
assert.deepEqual(composeNav(), []);
|
assert.deepEqual(composeNav(), []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
|
test("composeNav shows a public node to everyone and a session node to any signed-in user", () => {
|
||||||
// A header with one public child + one gated child: with no permissions, the public child keeps the
|
// A header with a public child, a session child and a gated child: the public child keeps the
|
||||||
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
|
// header alive for an anonymous visitor — so a plugin can show a menu option to all.
|
||||||
const frag: NavNode[][] = [[{
|
const frag: NavNode[][] = [[{
|
||||||
icon: "i-cal", id: "sched", label: "Scheduling",
|
icon: "i-cal", id: "sched", label: "Scheduling",
|
||||||
children: [
|
children: [
|
||||||
{ href: "/scheduling", id: "overview", label: "Overview", public: true },
|
{ href: "/scheduling", id: "overview", label: "Overview", public: true },
|
||||||
|
{ href: "/scheduling/mine", id: "mine", label: "Mine", session: true },
|
||||||
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
|
{ href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" },
|
||||||
],
|
],
|
||||||
}]];
|
}]];
|
||||||
// `public` is filter-only (like id/permission) — never rendered into the output node.
|
// `public`/`session` are filter-only (like id/permission) — never rendered into the output node.
|
||||||
assert.deepEqual(composeNav(frag, {}, []), [
|
assert.deepEqual(composeNav(frag, {}, null), [
|
||||||
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
|
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] },
|
||||||
]);
|
]);
|
||||||
|
// Signed in with no permission at all: the session node appears, the permission-gated one does not.
|
||||||
|
assert.deepEqual(composeNav(frag, {}, viewer()), [
|
||||||
|
{ icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }, { href: "/scheduling/mine", label: "Mine" }] },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("composeNav applies the override: rename, group, order, hide (then filters)", () => {
|
test("composeNav applies the override: rename, group, order, hide (then filters)", () => {
|
||||||
@@ -74,7 +84,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters)
|
|||||||
groups: [{ icon: "i-box", id: "grp", label: "Group", open: true, children: ["b", "c"] }], // wrap b+c
|
groups: [{ icon: "i-box", id: "grp", label: "Group", open: true, children: ["b", "c"] }], // wrap b+c
|
||||||
order: ["grp", "a"], // grp before the lone a
|
order: ["grp", "a"], // grp before the lone a
|
||||||
hide: ["c"], // remove c from inside the group
|
hide: ["c"], // remove c from inside the group
|
||||||
}, ["secrets:read"]);
|
}, viewer("secrets:read"));
|
||||||
|
|
||||||
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "secrets:read" is present.
|
// grp emitted (b only, c hidden), reordered before a; Secret kept now that permission "secrets:read" is present.
|
||||||
assert.deepEqual(tree, [
|
assert.deepEqual(tree, [
|
||||||
|
|||||||
+11
-8
@@ -1,8 +1,10 @@
|
|||||||
// composeNav: merge each plugin's nav fragment into one tree, apply the central override, then
|
// composeNav: merge each plugin's nav fragment into one tree, apply the central override, then
|
||||||
// permission-filter per user. Pure and I/O-free — menu gating reads the JWT `permissions` claim,
|
// filter per user. Pure and I/O-free — menu gating reads the JWT `permissions` claim, never Keto.
|
||||||
// never Keto. A node is visible iff it is `public`, declares no `permission`, or the user holds that
|
// A node is visible iff `allows` passes its gate; a gated header hides its whole subtree, and a pure
|
||||||
// name; 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 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";
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ export interface NavNode {
|
|||||||
open?: boolean;
|
open?: boolean;
|
||||||
permission?: string; // required permission token; consumed by the filter, never rendered
|
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).
|
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 →
|
||||||
@@ -39,7 +42,7 @@ export interface NavGroupSpec {
|
|||||||
export function composeNav(
|
export function composeNav(
|
||||||
fragments: NavNode[][] = [],
|
fragments: NavNode[][] = [],
|
||||||
override: NavOverride = {},
|
override: NavOverride = {},
|
||||||
permissions: string[] = [],
|
user: User | null = null,
|
||||||
t: Translate = ENGLISH,
|
t: Translate = ENGLISH,
|
||||||
): NavNode[] {
|
): NavNode[] {
|
||||||
let nodes: NavNode[] = fragments.flat();
|
let nodes: NavNode[] = fragments.flat();
|
||||||
@@ -47,7 +50,7 @@ export function composeNav(
|
|||||||
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
|
if (override.groups?.length) nodes = applyGroups(nodes, override.groups);
|
||||||
if (override.order?.length) nodes = applyOrder(nodes, override.order);
|
if (override.order?.length) nodes = applyOrder(nodes, override.order);
|
||||||
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
|
if (override.hide?.length) nodes = hideTree(nodes, new Set(override.hide));
|
||||||
return filterByRoles(nodes, new Set(permissions)).map((node) => toRenderNode(node, t));
|
return filterByGate(nodes, user).map((node) => toRenderNode(node, t));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
|
function renameTree(nodes: NavNode[], rename: Record<string, string>): NavNode[] {
|
||||||
@@ -104,12 +107,12 @@ function hideTree(nodes: NavNode[], hide: Set<string>): NavNode[] {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterByRoles(nodes: NavNode[], permissions: Set<string>): NavNode[] {
|
function filterByGate(nodes: NavNode[], user: User | null): NavNode[] {
|
||||||
const out: NavNode[] = [];
|
const out: NavNode[] = [];
|
||||||
for (const n of nodes) {
|
for (const n of nodes) {
|
||||||
if (n.public !== true && n.permission != null && !permissions.has(n.permission)) continue; // gated → drop node + subtree (public always shows)
|
if (!allows(n, user)) continue; // gated → drop node + subtree
|
||||||
if (!n.children) { out.push(n); continue; }
|
if (!n.children) { out.push(n); continue; }
|
||||||
const children = filterByRoles(n.children, permissions);
|
const children = filterByGate(n.children, user);
|
||||||
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
|
if (children.length === 0 && n.href == null) continue; // empty pure header → drop
|
||||||
out.push({ ...n, children });
|
out.push({ ...n, children });
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user