From acce2d2556d4dd2aded3f24ea9c11504856dd64d Mon Sep 17 00:00:00 2001 From: lilleman Date: Mon, 3 Aug 2026 11:31:04 +0200 Subject: [PATCH] Rename the plugin-API permission gate to role --- README-dockerhub.md | 2 +- compose.yml | 2 +- e2e-tests/compose.full.yml | 2 +- e2e-tests/visual.spec.ts | 2 +- examples/README.md | 2 +- examples/config/menu.ts | 2 +- examples/plugins/admin/README.md | 6 ++--- examples/plugins/admin/admin-roles.test.ts | 2 +- examples/plugins/admin/admin-roles.ts | 10 +++---- examples/plugins/admin/admin-shared.test.ts | 6 ++--- examples/plugins/admin/admin-shared.ts | 10 +++---- examples/plugins/admin/admin-users.ts | 2 +- examples/plugins/admin/plugin.ts | 8 +++--- examples/plugins/scheduling/README.md | 2 +- examples/plugins/scheduling/plugin.ts | 18 ++++++------- examples/plugins/scheduling/shifts.ts | 4 +-- src/auth/bootstrap.ts | 10 +++---- src/auth/guards.ts | 2 +- src/auth/jwt-middleware.ts | 2 +- src/http/app.test.ts | 12 ++++----- src/http/app.ts | 4 +-- src/plugin-host/discovery.test.ts | 12 ++++----- src/plugin-host/discovery.ts | 12 ++++----- src/plugin-host/plugin-api.ts | 2 +- src/plugin-host/plugin.test.ts | 20 +++++++------- src/plugin-host/plugin.ts | 30 ++++++++++----------- src/plugin-host/router.test.ts | 4 +-- src/plugin-host/router.ts | 6 ++--- src/ui/chrome.test.ts | 8 +++--- src/ui/menu-config.ts | 2 +- src/ui/nav.test.ts | 22 +++++++-------- src/ui/nav.ts | 16 +++++------ 32 files changed, 122 insertions(+), 122 deletions(-) diff --git a/README-dockerhub.md b/README-dockerhub.md index 6847430..001873c 100644 --- a/README-dockerhub.md +++ b/README-dockerhub.md @@ -190,4 +190,4 @@ export default definePlugin({ ``` Restart (`docker compose restart web`) and visit . Views, -forms, permissions, and the runnable reference plugin: repo README → Building plugins. +forms, roles, and the runnable reference plugin: repo README → Building plugins. diff --git a/compose.yml b/compose.yml index e38a7fe..488ef0d 100644 --- a/compose.yml +++ b/compose.yml @@ -131,7 +131,7 @@ services: ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@plainpages.local} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-admin} # Base roles for the demo admin; bootstrap also grants every discovered plugin's declared - # permission tokens (so the reference plugin — and any drop-in — works out of the box). + # role names (so the reference plugin — and any drop-in — works out of the box). ADMIN_ROLES: ${ADMIN_ROLES:-admin} APP_URL: ${APP_URL:-http://localhost:3000} # printed in the first-run login banner JWKS_FILE: /etc/config/kratos/tokenizer/jwks.json diff --git a/e2e-tests/compose.full.yml b/e2e-tests/compose.full.yml index 9a3585b..bcd125f 100644 --- a/e2e-tests/compose.full.yml +++ b/e2e-tests/compose.full.yml @@ -30,7 +30,7 @@ services: - ./examples/plugins/scheduling:/app/plugins/scheduling:ro - ./examples/plugins/admin:/app/plugins/admin:ro - # bootstrap grants the demo admin every discovered plugin's permission tokens, so it needs the + # bootstrap grants the demo admin every discovered plugin's role names, so it needs the # example plugins present too — else the admin lacks scheduling:read/write and the gated pages 403. bootstrap: volumes: diff --git a/e2e-tests/visual.spec.ts b/e2e-tests/visual.spec.ts index 38dbd34..9e03271 100644 --- a/e2e-tests/visual.spec.ts +++ b/e2e-tests/visual.spec.ts @@ -114,7 +114,7 @@ test("unknown routes serve the 404 page (a real user-facing flow, covered end-to }); // The reference plugin (plugins/scheduling) ships discovered in the image. Its public Overview is -// reachable by anyone and its menu header shows for everyone; the shifts list stays permission-gated, +// reachable by anyone and its menu header shows for everyone; the shifts list stays role-gated, // so an anonymous visitor is bounced to sign in. The authenticated list/form flow is the full // E2E (full-flow.spec). Side-effect-free. test("the reference plugin: public Overview is open to all, the gated Shifts redirects to /login", async ({ page, request }) => { diff --git a/examples/README.md b/examples/README.md index 5b7cda1..75f3165 100644 --- a/examples/README.md +++ b/examples/README.md @@ -5,7 +5,7 @@ across (or bind-mount your own) and restart. | Path | Copy into | Example of | | --- | --- | --- | -| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and permission-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). | +| [`plugins/scheduling/`](plugins/scheduling/) | `plugins/scheduling/` | The reference plugin: a list page over an upstream REST service, a CSRF-guarded form that forwards a write, and role-gated nav — built from the core building blocks, holding no state. Imports the host surface as `#plugin-api`. See its [README](plugins/scheduling/README.md) and the [plugin contract](../README.md#building-plugins). | | [`plugins/admin/`](plugins/admin/) | `plugins/admin/` | The system-admin plugin: the Users / Groups / Roles / OAuth2-clients screens for running Plainpages itself. A *system* plugin — it administers the Ory identity stack via the privileged [`ctx.system`](../README.md#system-capabilities-the-ctxsystem-surface) surface instead of its own upstream. Copy it in to get a GUI for user & group admin. See its [README](plugins/admin/README.md). | | [`config/menu.ts`](config/menu.ts) | `config/menu.ts` | The central menu override + branding template (rename/group/order/hide nav, set app name/logo/theme). Imports its typed builder as `#menu-config`; `config/` ships empty, so defaults apply until you copy this in. See [The menu system](../README.md#the-menu-system). | | [`shifts-upstream/`](shifts-upstream/) | — (dev service) | A throwaway mock backend the reference plugin reads/writes — stdlib-only, in-memory, no auth. Stands in for your real service so `docker compose up` shows the plugin working out of the box; in production you point `SCHEDULING_UPSTREAM` at the real thing instead. | diff --git a/examples/config/menu.ts b/examples/config/menu.ts index ea6e4d9..4cc7955 100644 --- a/examples/config/menu.ts +++ b/examples/config/menu.ts @@ -3,7 +3,7 @@ // config/ ships empty; mount your own or copy this in. Absent config = built-in defaults. // // Brand the app and reorder/rename/group/hide nav nodes (by their `id`) across all plugins — -// the override always wins, applied before the per-user permission filter. Every field is +// the override always wins, applied before the per-user role filter. Every field is // optional; delete one to fall back to the default. // See src/ui/menu-config.ts (types), src/ui/nav.ts (NavOverride), README.md (The menu system). diff --git a/examples/plugins/admin/README.md b/examples/plugins/admin/README.md index 51d29e5..5d693e8 100644 --- a/examples/plugins/admin/README.md +++ b/examples/plugins/admin/README.md @@ -28,12 +28,12 @@ stack**, so they use the privileged **`ctx.system`** surface the host exposes to `ctx.system` is populated only when the host wired those services (the dev stack wires Kratos + Keto, and Hydra when configured). Where a capability is absent the screen degrades to a themed 503 rather than crashing — see `admin-shared.ts`. Everything else is an ordinary plugin: folder-discovered, -gated per route by `permission: "admin"`, rendering the core building blocks in `views/`. +gated per route by `role: "admin"`, rendering the core building blocks in `views/`. ## Layout -- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` permission token, and the - route table — one thin handler per method+path, all gated by `permission: "admin"`. +- `plugin.ts` — the manifest: the gated Admin nav fragment, the `admin` role, and the + route table — one thin handler per method+path, all gated by `role: "admin"`. - `admin-users.ts` · `admin-groups.ts` · `admin-roles.ts` · `admin-clients.ts` — each a set of pure view-model builders (unit-tested in the matching `*.test.ts`) plus thin per-route handlers keyed on `ctx.params` (the host extracts `:id`/`:name`), sharing a small `withX` wrapper that resolves the diff --git a/examples/plugins/admin/admin-roles.test.ts b/examples/plugins/admin/admin-roles.test.ts index 3369ea4..727f257 100644 --- a/examples/plugins/admin/admin-roles.test.ts +++ b/examples/plugins/admin/admin-roles.test.ts @@ -1,4 +1,4 @@ -// Built-in Roles & permissions admin screen: the pure view-model + Keto builders. A role is a +// Built-in Roles admin screen: the pure view-model + Keto builders. A role is a // Keto subject set (Role:#members); members are users (subject_id) or groups (subject_set) — // "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the // distinct set of users who hold the role directly or transitively via a group. The HTTP diff --git a/examples/plugins/admin/admin-roles.ts b/examples/plugins/admin/admin-roles.ts index 0544c78..bda6b6b 100644 --- a/examples/plugins/admin/admin-roles.ts +++ b/examples/plugins/admin/admin-roles.ts @@ -1,4 +1,4 @@ -// Roles & permissions admin screen: list / create / delete Keto roles and assign +// Roles admin screen: list / create / delete Keto roles and assign // them to users and groups. A role is a Keto subject set `Role:#members` (OPL: members are users // or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the // Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging) @@ -9,7 +9,7 @@ // ctx.params) over a shared `withRoles` gate — admin-only, CSRF-guarded. import { type ExpandTree, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type User } from "#plugin-api"; -import { ADMIN_PERMISSION, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; +import { ADMIN_ROLE, ADMIN_ROLES_BASE, buildConfirmModel, guardedForm, notFound, requireAdmin, unavailable } from "./admin-shared.ts"; import { type GroupView, groupsFromTuples, @@ -333,7 +333,7 @@ export const rolesAddMember = withRoleName(async (deps, name) => { // GET /admin/roles/:name/delete — confirm, except the admin role can't be deleted. export const rolesDeleteConfirm = withRoleName((deps, name) => { - if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access."); + if (name === ADMIN_ROLE) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access."); const base = detailHref(name); return Promise.resolve({ data: { chrome: deps.ctx.chrome, model: buildConfirmModel({ breadcrumbs: [{ href: ADMIN_ROLES_BASE, label: "Roles" }, { href: base, label: name }, { label: "Delete" }], @@ -347,7 +347,7 @@ export const rolesDeleteConfirm = withRoleName((deps, name) => { export const rolesDelete = withRoleName(async (deps, name) => { const { ctx, keto, user } = deps; await guardedForm(ctx); // CSRF-verify the POST - if (name === ADMIN_PERMISSION) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access."); + if (name === ADMIN_ROLE) return roleDetailResult(deps, name, "The admin role can't be deleted — it would remove all admin access."); await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); ctx.log.info("admin: role deleted", { actor: user.id, role: name }); return { redirect: ADMIN_ROLES_BASE }; @@ -360,7 +360,7 @@ export const rolesRemoveMember = withRoleName(async (deps, name) => { const { ctx, keto, revoke, user } = deps; const form = (await guardedForm(ctx))!; const member = (form.get("member") ?? "").trim(); - if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access."); + if (name === ADMIN_ROLE && member === `user:${user.id}`) return roleDetailResult(deps, name, "You can't revoke your own admin access."); const tuple = roleMemberTuple(name, member); if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(revoke, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); } return { redirect: detailHref(name) }; diff --git a/examples/plugins/admin/admin-shared.test.ts b/examples/plugins/admin/admin-shared.test.ts index 6d49f2f..a7acf04 100644 --- a/examples/plugins/admin/admin-shared.test.ts +++ b/examples/plugins/admin/admin-shared.test.ts @@ -7,7 +7,7 @@ import type { IncomingMessage, ServerResponse } from "node:http"; import { Readable } from "node:stream"; import { test } from "node:test"; import { GuardError, type Log, type PageChrome, type RequestContext, type User } from "#plugin-api"; -import { ADMIN_NAV, ADMIN_PERMISSION, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts"; +import { ADMIN_NAV, ADMIN_ROLE, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, requireAdmin } from "./admin-shared.ts"; const admin: User = { email: "ada@x.io", id: "u1", roles: ["admin"] }; const member: User = { email: "bo@x.io", id: "u2", roles: ["scheduling:read"] }; @@ -27,11 +27,11 @@ function fakeCtx(opts: { body?: string; method?: string; user?: User | null; ver test("ADMIN_NAV: a gated Admin header over the four screens; no per-request current/open state", () => { assert.equal(ADMIN_NAV.id, "admin"); - assert.equal(ADMIN_NAV.permission, ADMIN_PERMISSION); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin + assert.equal(ADMIN_NAV.role, ADMIN_ROLE); // gate on the header ⇒ composeNav drops the whole subtree for a non-admin assert.equal(ADMIN_NAV.open, undefined); // the host current-marks + opens; the fragment stays static assert.deepEqual(ADMIN_NAV.children?.map((c) => c.href), ["/admin/users", "/admin/groups", "/admin/roles", "/admin/clients"]); assert.deepEqual(ADMIN_NAV.children?.map((c) => c.label), ["Users", "Groups", "Roles", "OAuth2 clients"]); - assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.permission === undefined)); // the header's gate covers the subtree + assert.ok(ADMIN_NAV.children?.every((c) => c.current === undefined && c.role === undefined)); // the header's gate covers the subtree }); // ---- auth gates ---- diff --git a/examples/plugins/admin/admin-shared.ts b/examples/plugins/admin/admin-shared.ts index 497d802..bf94909 100644 --- a/examples/plugins/admin/admin-shared.ts +++ b/examples/plugins/admin/admin-shared.ts @@ -5,7 +5,7 @@ import { can, CSRF_FIELD, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type User } from "#plugin-api"; -export const ADMIN_PERMISSION = "admin"; // role token gating the whole admin section +export const ADMIN_ROLE = "admin"; // role token gating the whole admin section export const ADMIN_USERS_BASE = "/admin/users"; export const ADMIN_GROUPS_BASE = "/admin/groups"; export const ADMIN_ROLES_BASE = "/admin/roles"; @@ -14,7 +14,7 @@ export const ADMIN_CLIENTS_BASE = "/admin/clients"; export type AdminScreen = "clients" | "groups" | "roles" | "users"; // The plugin's nav fragment: the gated "Admin" header + its four screens. The host composes it into -// the one global menu, filters per user (the header's `permission` drops the whole subtree for a +// the one global menu, filters per user (the header's `role` drops the whole subtree for a // non-admin), and current-marks the active item — so there is no `current`/`open` state here. export const ADMIN_NAV: NavNode = { children: [ @@ -26,15 +26,15 @@ export const ADMIN_NAV: NavNode = { icon: "i-shield", id: "admin", label: "Admin", - permission: ADMIN_PERMISSION, + role: ADMIN_ROLE, }; -// The admin gate: a signed-in admin only. Each route already declares `permission: "admin"`, so the +// The admin gate: a signed-in admin only. Each route already declares `role: "admin"`, so the // host enforces this before the handler runs; this is defence-in-depth and what a direct unit test // relies on. Returns the (non-null) user for the handler to thread on. GuardError → /login or 403. export function requireAdmin(ctx: RequestContext): User { const user = requireSession(ctx); // anonymous → GuardError → /login (return_to kept) - if (!can(ctx, ADMIN_PERMISSION)) throw new GuardError(403, "admin role required"); + if (!can(ctx, ADMIN_ROLE)) throw new GuardError(403, "admin role required"); return user; } diff --git a/examples/plugins/admin/admin-users.ts b/examples/plugins/admin/admin-users.ts index 1f3dae9..8d3dc04 100644 --- a/examples/plugins/admin/admin-users.ts +++ b/examples/plugins/admin/admin-users.ts @@ -268,7 +268,7 @@ function readUserInput(form: URLSearchParams): UserInput { // the Kratos capability (else a themed 503). Each route below is a thin handler over these. interface UsersDeps { ctx: RequestContext; kratosAdmin: KratosAdmin; revoke: ((sub: string) => void) | undefined; user: User; } -// Resolve the shared deps, then run `inner`. The route's `permission: "admin"` already gated at the +// Resolve the shared deps, then run `inner`. The route's `role: "admin"` already gated at the // host; `requireAdmin` is defence-in-depth and yields the user. GuardError (auth/CSRF) → host maps it. function withUser(inner: (deps: UsersDeps) => Promise): RouteHandler { return async (ctx) => { diff --git a/examples/plugins/admin/plugin.ts b/examples/plugins/admin/plugin.ts index cc5b3d5..1ec59bb 100644 --- a/examples/plugins/admin/plugin.ts +++ b/examples/plugins/admin/plugin.ts @@ -11,19 +11,19 @@ import { clientsCreate, clientsDeleteConfirm, clientsDelete, clientsDetail, clie import { groupsAddMember, groupsCreate, groupsDelete, groupsDeleteConfirm, groupsDetail, groupsList, groupsNewForm, groupsRemoveMember } from "./admin-groups.ts"; import { rolesAddMember, rolesCreate, rolesDelete, rolesDeleteConfirm, rolesDetail, rolesList, rolesNewForm, rolesRemoveMember } from "./admin-roles.ts"; import { usersCreate, usersDeleteConfirm, usersDelete, usersEditForm, usersList, usersNewForm, usersRecovery, usersState, usersUpdate } from "./admin-users.ts"; -import { ADMIN_NAV, ADMIN_PERMISSION } from "./admin-shared.ts"; +import { ADMIN_NAV, ADMIN_ROLE } from "./admin-shared.ts"; -// Every admin route is gated by the one `admin` permission — the host redirects an anonymous visitor +// Every admin route is gated by the one `admin` role — the host redirects an anonymous visitor // to /login, gives a signed-in non-admin the 403 page, and filters the nav the same way. Handlers are // thin and keyed on ctx.params (the host extracts :id / :name), the idiomatic per-route style. -const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, permission: ADMIN_PERMISSION }); +const r = (method: HttpMethod, path: string, handler: RouteHandler): Route => ({ handler, method, path, role: ADMIN_ROLE }); export default definePlugin({ apiVersion: "1.0.0", // the host contract this was built against — a literal, never HOST_API_VERSION nav: [ADMIN_NAV], - permissions: [{ description: "Administer users, groups, roles, and OAuth2 clients", token: ADMIN_PERMISSION }], + roles: [{ description: "Administer users, groups, roles, and OAuth2 clients", name: ADMIN_ROLE }], routes: [ // Users diff --git a/examples/plugins/scheduling/README.md b/examples/plugins/scheduling/README.md index 810599a..e5f56cb 100644 --- a/examples/plugins/scheduling/README.md +++ b/examples/plugins/scheduling/README.md @@ -15,7 +15,7 @@ What it demonstrates: `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`, reusing the core `field` partial. -- **Permission-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` / +- **Role-gated nav** — the "Shifts" nav leaf and routes are gated on `scheduling:read` / `scheduling:write`; the whole "Scheduling" section is invisible to anyone without the grant. The plugin holds **no state** — data lives upstream (README → *Stateless*). Handlers are thin and diff --git a/examples/plugins/scheduling/plugin.ts b/examples/plugins/scheduling/plugin.ts index 8efea4a..ec27807 100644 --- a/examples/plugins/scheduling/plugin.ts +++ b/examples/plugins/scheduling/plugin.ts @@ -1,5 +1,5 @@ // Reference plugin: a worked example of the contract — a list page that fetches upstream -// data, a CSRF-guarded form that forwards a write upstream, and permission-gated nav. Copy this +// data, a CSRF-guarded form that forwards a write upstream, and role-gated nav. Copy this // folder, rename it, point it at your own backend. Full contract: README.md → Building plugins. import { definePlugin } from "#plugin-api"; @@ -23,7 +23,7 @@ export default definePlugin({ nav: [{ children: [ { href: SCHEDULING_PATH, id: "scheduling:overview", label: "Overview", public: true }, - { href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", permission: READ }, + { href: SHIFTS_PATH, id: "scheduling:shifts", label: "Shifts", role: READ }, ], icon: "i-cal", id: "scheduling", @@ -31,17 +31,17 @@ export default definePlugin({ }], // Tokens this plugin introduces (docs + Keto seeding). Namespaced `:`. - permissions: [ - { description: "View shifts", token: READ }, - { description: "Create and edit shifts", token: WRITE }, + roles: [ + { description: "View shifts", name: READ }, + { description: "Create and edit shifts", name: WRITE }, ], - // Mounted under /scheduling; `permission` gates before the handler runs. The overview is `public` + // Mounted under /scheduling; `role` gates before the handler runs. The overview is `public` // (anyone may reach /scheduling, signed in or not); the rest need a role. routes: [ { handler: overview(), method: "GET", path: "/", public: true }, - { handler: listShifts(upstream), method: "GET", path: "/shifts", permission: READ }, - { handler: newShiftForm(), method: "GET", path: "/shifts/new", permission: WRITE }, - { handler: createShift(upstream), method: "POST", path: "/shifts", permission: WRITE }, + { handler: listShifts(upstream), method: "GET", path: "/shifts", role: READ }, + { handler: newShiftForm(), method: "GET", path: "/shifts/new", role: WRITE }, + { handler: createShift(upstream), method: "POST", path: "/shifts", role: WRITE }, ], }); diff --git a/examples/plugins/scheduling/shifts.ts b/examples/plugins/scheduling/shifts.ts index 49c7505..7cdcdab 100644 --- a/examples/plugins/scheduling/shifts.ts +++ b/examples/plugins/scheduling/shifts.ts @@ -10,8 +10,8 @@ import { can, CSRF_FIELD, GuardError, type PageChrome, parseListQuery, readFormB export const SCHEDULING_PATH = "/scheduling"; // the plugin's public overview page export const SHIFTS_PATH = "/scheduling/shifts"; -export const READ = "scheduling:read"; // permission token gating the list + nav -export const WRITE = "scheduling:write"; // permission token gating create +export const READ = "scheduling:read"; // role name gating the list + nav +export const WRITE = "scheduling:write"; // role name gating create export interface Shift { id: string; diff --git a/src/auth/bootstrap.ts b/src/auth/bootstrap.ts index 71114e2..ac864f4 100644 --- a/src/auth/bootstrap.ts +++ b/src/auth/bootstrap.ts @@ -2,8 +2,8 @@ // kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`: // 1. generate the JWKS signing key if absent (committed dev key makes this a safety net); // 2. seed a demo admin (admin@plainpages.local / admin) in Kratos; -// 3. grant it its roles in Keto so menu/permission checks resolve out of the box — `admin` plus -// every discovered plugin's declared permission tokens, so a dropped-in plugin is usable by +// 3. grant it its roles in Keto so menu/role checks resolve out of the box — `admin` plus +// every discovered plugin's declared role names, so a dropped-in plugin is usable by // the demo admin with no host config edit (the host stays plugin-agnostic). // Then prints a first-run banner; fails loud on any unexpected upstream error. import { existsSync, writeFileSync } from "node:fs"; @@ -29,7 +29,7 @@ export function roleTuple(identityId: string, role: string) { } // The roles to grant the demo admin = the configured base (ADMIN_ROLES, default just `admin`) -// unioned with every discovered plugin's declared permission tokens (a route/nav `permission` is a +// unioned with every discovered plugin's declared role names (a route/nav `role` is a // coarse role — granted as a Keto `Role:#members` tuple). So the host names no plugin, yet a // dropped-in plugin's tokens are seeded out of the box. Deduped, order-stable, blanks dropped. export function seedRoles(adminRolesEnv: string | undefined, declaredTokens: string[]): string[] { @@ -143,9 +143,9 @@ async function main() { await runWithLog(log, async () => { if (ensureJwks(env["JWKS_FILE"] ?? "/etc/config/kratos/tokenizer/jwks.json")) log.info("generated a JWKS signing key"); - // Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared permission tokens, so the + // Seed `admin` (or ADMIN_ROLES) + every discovered plugin's declared role names, so the // shipped example — and any dropped-in plugin — works for the demo admin without a host edit. - const declared = (await discoverPlugins()).flatMap((p) => (p.permissions ?? []).map((d) => d.token)); + const declared = (await discoverPlugins()).flatMap((p) => (p.roles ?? []).map((d) => d.name)); const roles = seedRoles(env["ADMIN_ROLES"], declared); const email = env["ADMIN_EMAIL"] ?? "admin@plainpages.local"; const password = env["ADMIN_PASSWORD"] ?? "admin"; diff --git a/src/auth/guards.ts b/src/auth/guards.ts index 4a7c3bc..5405f42 100644 --- a/src/auth/guards.ts +++ b/src/auth/guards.ts @@ -1,5 +1,5 @@ // Auth guards: in-handler authorization, the imperative counterpart to the -// declarative route `permission` gate. The middleware already verified the session JWT and put +// declarative route `role` gate. The middleware already verified the session JWT and put // the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps // to a response); `can`/`check` are predicates a handler branches on. `check` is the one live // Keto call — the fine-grained "may I?" tier (README), reserved for relationship rules. diff --git a/src/auth/jwt-middleware.ts b/src/auth/jwt-middleware.ts index df110f0..68479bb 100644 --- a/src/auth/jwt-middleware.ts +++ b/src/auth/jwt-middleware.ts @@ -2,7 +2,7 @@ // the hot path that never calls Ory. Select the verify key by `kid` from the cached JWKS, // check the signature (src/auth/jwt.ts), validate the time/issuer/audience claims, project the // User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null -// (anonymous), so the route renders signed-out and the permission gate denies. +// (anonymous), so the route renders signed-out and the role gate denies. import type { User } from "../http/context.ts"; import { parseCookies } from "../http/cookie.ts"; import type { Denylist } from "./denylist.ts"; diff --git a/src/http/app.test.ts b/src/http/app.test.ts index f55ec21..fcb43e3 100644 --- a/src/http/app.test.ts +++ b/src/http/app.test.ts @@ -385,7 +385,7 @@ test("renders the 500 HTML page when a handler throws", async () => { } }); -// A test plugin exercising each RouteResult shape, a path param, and the permission gate. +// A test plugin exercising each RouteResult shape, a path param, and the role gate. const demoPlugin: Plugin = { apiVersion: "1.0.0", id: "demo", @@ -393,7 +393,7 @@ const demoPlugin: Plugin = { { handler: (ctx) => ({ html: `

Hi ${ctx.params.name}

` }), method: "GET", path: "/hello/:name" }, { handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" }, { handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" }, - { handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" }, + { handler: () => ({ html: "secret" }), method: "GET", path: "/secret", role: "demo:read" }, { handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // blessed public { handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" }, ], @@ -406,7 +406,7 @@ async function startApp(t: TestContext, plugins: Plugin[], pluginsDir?: string): return `http://localhost:${(app.address() as AddressInfo).port}`; } -test("mounts plugin routes: params, html/json/redirect/view results, and the permission gate", async (t) => { +test("mounts plugin routes: params, html/json/redirect/view results, and the role gate", async (t) => { const dir = mkdtempSync(join(tmpdir(), "pp-plugins-")); mkdirSync(join(dir, "demo", "views"), { recursive: true }); mkdirSync(join(dir, "demo", "public"), { recursive: true }); @@ -610,7 +610,7 @@ test("guards map to responses: requireSession → /login, a failed can/check → { handler: (ctx) => ({ html: `hi ${requireSession(ctx).email}` }), method: "GET", path: "/me" }, { handler: (ctx) => { if (!can(ctx, "admin")) throw new GuardError(403, "no"); return { html: "ok" }; }, method: "GET", path: "/admin-only" }, { handler: async (ctx) => { if (!(await check(keto, ctx, { namespace: "Resource", object: ctx.params.id ?? "", relation: "view" }))) throw new GuardError(403, "no"); return { html: "seen" }; }, method: "GET", path: "/doc/:id" }, - { handler: () => ({ html: "gated" }), method: "GET", path: "/gated", permission: "secret:read" }, // declarative route gate + { handler: () => ({ html: "gated" }), method: "GET", path: "/gated", role: "secret:read" }, // declarative route gate ], }; const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [guarded] }); @@ -636,7 +636,7 @@ test("guards map to responses: requireSession → /login, a failed can/check → assert.equal((await fetch(url + "/guarded/doc/open", auth([]))).status, 200); assert.equal((await fetch(url + "/guarded/doc/shut", auth([]))).status, 403); - // declarative route `permission` gate: anonymous → sign in, signed-in-without-role → the 403 page, with → 200. + // declarative route `role` gate: anonymous → sign in, signed-in-without-role → the 403 page, with → 200. const gAnon = await fetch(url + "/guarded/gated", { redirect: "manual" }); assert.equal(gAnon.status, 303); assert.equal(gAnon.headers.get("location"), "/login?return_to=%2Fguarded%2Fgated"); @@ -1225,7 +1225,7 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g assert.equal((await get("/admin/groups/%ZZ")).status, 404); }); -// Built-in Roles & permissions admin screen: gate + list/create/assign/revoke/delete over HTTP +// Built-in Roles admin screen: gate + list/create/assign/revoke/delete over HTTP // against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the // effective-access view surfaces a user reachable only through a group. test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => { diff --git a/src/http/app.ts b/src/http/app.ts index 81bf0e2..0f4c01a 100644 --- a/src/http/app.ts +++ b/src/http/app.ts @@ -240,7 +240,7 @@ export function createApp(options: AppOptions = {}): Server { } } - // Plugin routes (any method): gate on the route's permission, then run the handler. The + // Plugin routes (any method): gate on the route's role, then run the handler. The // handler gets ctx.chrome (native app shell) + ctx.verifyCsrf (guard its own forms); a fresh // CSRF cookie is set so those forms have a valid double-submit token. const match = matchRoute(plugins, method, pathname); @@ -250,7 +250,7 @@ export function createApp(options: AppOptions = {}): Server { // Anonymous → sign in (like the built-in screens' requireSession), remembering the page as // return_to; a signed-in user who simply lacks the role gets the 403 page. if (!routeCtx.user) { res.writeHead(303, { location: loginRedirect(routeCtx) }).end(); return; } - reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.permission ?? "", sub: routeCtx.user.id }); + reqLog.warn("forbidden: missing role", { path: pathname, required: match.route.role ?? "", sub: routeCtx.user.id }); sendHtml(res, 403, await render("403", { title: "Forbidden" })); return; } diff --git a/src/plugin-host/discovery.test.ts b/src/plugin-host/discovery.test.ts index e9df7eb..853e78d 100644 --- a/src/plugin-host/discovery.test.ts +++ b/src/plugin-host/discovery.test.ts @@ -50,8 +50,8 @@ const badCases: Array<{ name: string; files: Record; match: RegE { name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s }, { name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s }, { 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: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x", 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: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x" }] };` }, match: /contranav.*public.*permission/s }, + { name: "a route marked public AND role is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, role: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*role/s }, + { name: "a nav node marked public AND role is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, role: "x" }] };` }, match: /contranav.*public.*role/s }, { name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ }, { name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ }, ]; @@ -85,12 +85,12 @@ test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) assert.equal(typeof plugins[0]?.dashboard, "function"); }); -test("a shared permission token only warns — both plugins still load", async (t) => { - const perm = `export default { apiVersion: "1.0.0", permissions: [{ token: "shared:read" }] };`; - const dir = scaffold(t, { "x/plugin.ts": perm, "y/plugin.ts": perm }); +test("a shared role name only warns — both plugins still load", async (t) => { + const shared = `export default { apiVersion: "1.0.0", roles: [{ name: "shared:read" }] };`; + const dir = scaffold(t, { "x/plugin.ts": shared, "y/plugin.ts": shared }); const warnings: string[] = []; const plugins = await discoverPlugins({ dir, logger: { warn: (m) => warnings.push(String(m)) } }); assert.equal(plugins.length, 2); - assert.ok(warnings.some((w) => /shared:read/.test(w)), "expected a permission-conflict warning"); + assert.ok(warnings.some((w) => /shared:read/.test(w)), "expected a role-conflict warning"); }); diff --git a/src/plugin-host/discovery.ts b/src/plugin-host/discovery.ts index 25bea8b..e3f978e 100644 --- a/src/plugin-host/discovery.ts +++ b/src/plugin-host/discovery.ts @@ -2,7 +2,7 @@ // validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules // (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and // error-level conflict is collected into one boot-stopping Error; warn-level diagnostics -// (older-minor apiVersion, shared permission token) log and load continues. Folder name = id. +// (older-minor apiVersion, shared role name) log and load continues. Folder name = id. import { existsSync, readdirSync } from "node:fs"; import { dirname, join } from "node:path"; @@ -85,7 +85,7 @@ function asManifest(value: unknown): PluginManifest | null { // The collection fields feed findConflicts, which iterates them — a non-array crashes it opaquely. function shapeError(manifest: PluginManifest): string | null { - for (const field of ["nav", "permissions", "routes"] as const) { + for (const field of ["nav", "roles", "routes"] as const) { if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`; } // `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so @@ -93,20 +93,20 @@ function shapeError(manifest: PluginManifest): string | null { for (const slot of ["home", "dashboard"] as const) { if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`; } - // `public` and `permission` are contradictory on the same route/nav node — "open to all" vs + // `public` and `role` are contradictory on the same route/nav node — "open to all" vs // "needs this role". Refuse rather than silently pick one, so the author's intent is unambiguous. 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`; + if (route?.public === true && route.role != null) return `route "${route.method} ${route.path}" sets both public and role — they are mutually exclusive`; } const navContradiction = findPublicNavContradiction(manifest.nav); if (navContradiction) return navContradiction; return null; } -// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory. +// Recurse the nav fragment: a node that is both `public` and `role`-gated is contradictory. function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null { 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`; + if (node?.public === true && node.role != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and role — they are mutually exclusive`; const inChild = findPublicNavContradiction(node?.children); if (inChild) return inChild; } diff --git a/src/plugin-host/plugin-api.ts b/src/plugin-host/plugin-api.ts index 270214b..933c8f5 100644 --- a/src/plugin-host/plugin-api.ts +++ b/src/plugin-host/plugin-api.ts @@ -5,7 +5,7 @@ // a plugin should import from here, never reach into deeper modules. See README.md → Building plugins. export { definePlugin } from "./plugin.ts"; -export type { HttpMethod, Plugin, PluginHooks, PluginManifest, PermissionDecl, Route, RouteHandler, RouteResult } from "./plugin.ts"; +export type { HttpMethod, Plugin, PluginHooks, PluginManifest, RoleDecl, Route, RouteHandler, RouteResult } from "./plugin.ts"; export type { RequestContext, User } from "../http/context.ts"; export type { PageChrome } from "../ui/chrome.ts"; export type { NavNode } from "../ui/nav.ts"; diff --git a/src/plugin-host/plugin.test.ts b/src/plugin-host/plugin.test.ts index 11da110..1b77b60 100644 --- a/src/plugin-host/plugin.test.ts +++ b/src/plugin-host/plugin.test.ts @@ -21,13 +21,13 @@ const scheduling: PluginManifest = definePlugin({ apiVersion: "1.0.0", hooks: { onBoot: () => {} }, nav: [{ - children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], + children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }], icon: "i-cal", id: "scheduling:root", label: "Scheduling", }], - permissions: [{ description: "View shifts", token: "scheduling:read" }], + roles: [{ description: "View shifts", name: "scheduling:read" }], routes: [ - { handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", permission: "scheduling:read" }, - { handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", permission: "scheduling:write" }, + { handler: () => ({ data: { rows: [] }, view: "shifts" }), method: "GET", path: "/shifts", role: "scheduling:read" }, + { handler: () => ({ redirect: "/scheduling/shifts" }), method: "POST", path: "/shifts", role: "scheduling:write" }, { handler: (ctx) => void ctx.res.end("raw"), method: "GET", path: "/raw" }, // void = handler wrote res itself ], }); @@ -87,19 +87,19 @@ test("findConflicts: a duplicate id and a colliding route are loud errors", () = assert.ok(dupRoute.some((c) => c.kind === "route" && c.level === "error" && c.message.includes("/a/t"))); }); -test("findConflicts: duplicate nav id is an error, a shared permission token only warns", () => { +test("findConflicts: duplicate nav id is an error, a shared role name only warns", () => { const navDup = findConflicts([ p({ id: "a", nav: [{ id: "dup", label: "A" }] }), p({ id: "b", nav: [{ id: "dup", label: "B" }] }), ]); assert.ok(navDup.some((c) => c.kind === "nav-id" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b"))); - // Sharing a permission across plugins is legitimate (shared role) → warn, not error. - const permDup = findConflicts([ - p({ id: "a", permissions: [{ token: "shared:read" }] }), - p({ id: "b", permissions: [{ token: "shared:read" }] }), + // Sharing a role across plugins is legitimate → warn, not error. + const roleDup = findConflicts([ + p({ id: "a", roles: [{ name: "shared:read" }] }), + p({ id: "b", roles: [{ name: "shared:read" }] }), ]); - assert.ok(permDup.some((c) => c.kind === "permission" && c.level === "warn")); + assert.ok(roleDup.some((c) => c.kind === "role" && c.level === "warn")); }); test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => { diff --git a/src/plugin-host/plugin.ts b/src/plugin-host/plugin.ts index 660ab02..38b230a 100644 --- a/src/plugin-host/plugin.ts +++ b/src/plugin-host/plugin.ts @@ -29,18 +29,18 @@ export interface Route { handler: RouteHandler; method: HttpMethod; path: string; // relative to the plugin's mount path `/`; ":name" segments → ctx.params.name - permission?: string; // coarse gate (a role token); checked before the handler runs - // Mark the page reachable by anyone, signed in or not. The same as omitting `permission` - // — a no-permission route is already open — but stated outright, so "public" is a deliberate - // choice, not an accident. Mutually exclusive with `permission` (discovery refuses both). + role?: string; // coarse gate — the Keto Role the caller must hold; checked before the handler runs + // Mark the page reachable by anyone, signed in or not. The same as omitting `role` + // — an ungated route is already open — but stated outright, so "public" is a deliberate + // choice, not an accident. Mutually exclusive with `role` (discovery refuses both). public?: boolean; } -// A permission token this plugin introduces — declared for docs/seeding. Tokens are a shared -// global namespace (so an operator grants them in Keto); namespace as `:`. -export interface PermissionDecl { +// A Keto Role this plugin gates on — declared for docs/seeding. Role names are a shared +// global namespace (so an operator grants them once in Keto); namespace as `:`. +export interface RoleDecl { description?: string; - token: string; + name: string; } // Optional hooks on system actions. Crash-isolation is a non-goal — a throwing hook fails loud. @@ -63,7 +63,7 @@ export interface PluginManifest { home?: RouteHandler; hooks?: PluginHooks; nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/ui/icons.ts), node ids must be globally unique - permissions?: PermissionDecl[]; + roles?: RoleDecl[]; routes?: Route[]; } @@ -147,7 +147,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO } export interface PluginConflict { - kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route"; + kind: "dashboard" | "home" | "id" | "nav-id" | "role" | "route"; level: "error" | "warn"; message: string; plugins: string[]; // unique ids involved @@ -155,8 +155,8 @@ export interface PluginConflict { // The conflict rules: defined, loud resolution — never last-write-wins. Pure over the discovered // plugins; discovery throws on any "error" and logs every "warn". Mount-path (`/`) uniqueness -// is structural — it follows from the id check, so it needs no rule of its own. Shared permission -// tokens are the one intentional overlap, so they warn rather than error. +// is structural — it follows from the id check, so it needs no rule of its own. Shared role +// names are the one intentional overlap, so they warn rather than error. export function findConflicts(plugins: Plugin[]): PluginConflict[] { const out: PluginConflict[] = []; @@ -184,9 +184,9 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] { }); collect(plugins, (plugin, push) => { - for (const decl of plugin.permissions ?? []) push(decl.token); - }).forEach((owners, token) => { - if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${token}" declared by ${uniq(owners).length} plugins; namespace as ":" unless shared on purpose`, plugins: uniq(owners) }); + for (const decl of plugin.roles ?? []) push(decl.name); + }).forEach((owners, name) => { + if (owners.length > 1) out.push({ kind: "role", level: "warn", message: `role "${name}" declared by ${uniq(owners).length} plugins; namespace as ":" unless shared on purpose`, plugins: uniq(owners) }); }); return out; diff --git a/src/plugin-host/router.test.ts b/src/plugin-host/router.test.ts index 70ef699..25785d2 100644 --- a/src/plugin-host/router.test.ts +++ b/src/plugin-host/router.test.ts @@ -57,11 +57,11 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the test("isAuthorized: open routes pass; gated routes require the role 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 gated: Route = { handler: noop, method: "GET", path: "/", role: "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 + assert.equal(isAuthorized(pub, []), true); // open to anonymous, like omitting role — but stated outright }); diff --git a/src/plugin-host/router.ts b/src/plugin-host/router.ts index 3eaeec6..f6ce394 100644 --- a/src/plugin-host/router.ts +++ b/src/plugin-host/router.ts @@ -74,9 +74,9 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] { return [...methods].sort(); } -// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise +// Coarse role gate: a route marked `public` (or one with no `role`) is open; otherwise // the user's roles (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). +// for the menu. `public` and `role` are mutually exclusive (discovery refuses both). export function isAuthorized(route: Route, roles: string[]): boolean { - return route.public === true || route.permission == null || roles.includes(route.permission); + return route.public === true || route.role == null || roles.includes(route.role); } diff --git a/src/ui/chrome.test.ts b/src/ui/chrome.test.ts index 0afdbad..e643314 100644 --- a/src/ui/chrome.test.ts +++ b/src/ui/chrome.test.ts @@ -9,13 +9,13 @@ const scheduling: Plugin = { apiVersion: "1.0.0", id: "scheduling", nav: [{ - children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", permission: "scheduling:read" }], + children: [{ href: "/scheduling/shifts", id: "scheduling:shifts", label: "Shifts", role: "scheduling:read" }], icon: "i-cal", id: "scheduling", label: "Scheduling", }], }; // A plugin with a public nav node (reachable by anyone, signed in or not). const portal: Plugin = { apiVersion: "1.0.0", id: "portal", nav: [{ href: "/portal", id: "portal", label: "Portal", public: true }] }; -// A gated section fragment like the admin plugin's nav: the header carries the permission, so +// A gated section fragment like the admin plugin's nav: the header carries the role, so // composeNav drops the whole subtree for a non-holder (the admin screens ship as a drop-in plugin). const adminLike: Plugin = { apiVersion: "1.0.0", id: "admin", @@ -24,7 +24,7 @@ const adminLike: Plugin = { { href: "/admin/users", id: "users", label: "Users" }, { href: "/admin/groups", id: "groups", label: "Groups" }, ], - icon: "i-shield", id: "admin", label: "Admin", permission: "admin", + icon: "i-shield", id: "admin", label: "Admin", role: "admin", }], }; @@ -45,7 +45,7 @@ test("anonymous shell Sign-in link carries the current page as return_to", () => assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal"); }); -test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => { +test("a role holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => { const chrome = buildPluginChrome({ currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling], user: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] }, diff --git a/src/ui/menu-config.ts b/src/ui/menu-config.ts index 9e568af..3f194f6 100644 --- a/src/ui/menu-config.ts +++ b/src/ui/menu-config.ts @@ -1,7 +1,7 @@ // Central menu config: config/menu.ts lets an operator set branding (app name, logo, // default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/ // group/hide part is the NavOverride composeNav already applies (the override always wins, before -// the per-user permission filter). Authored as TypeScript (defineMenu types it); loaded once at +// the per-user role filter). Authored as TypeScript (defineMenu types it); loaded once at // boot — fail-loud on a malformed file, defaults when absent (clean clone needs no config). import { existsSync } from "node:fs"; diff --git a/src/ui/nav.test.ts b/src/ui/nav.test.ts index 0e0cdb9..34d8136 100644 --- a/src/ui/nav.test.ts +++ b/src/ui/nav.test.ts @@ -2,23 +2,23 @@ import assert from "node:assert/strict"; import { test } from "node:test"; import { composeNav, type NavNode } from "./nav.ts"; -// Two plugin fragments; ids let the override target nodes, `permission` gates per role. +// Two plugin fragments; ids let the override target nodes, `role` gates per role. const fragments: NavNode[][] = [ [{ icon: "i-cal", id: "sched", label: "Scheduling", children: [ - { href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" }, - { href: "/scheduling/manage", id: "manage", label: "Manage", permission: "scheduling:admin" }, + { href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" }, + { href: "/scheduling/manage", id: "manage", label: "Manage", role: "scheduling:admin" }, ], }], - [{ href: "/reports", id: "reports", label: "Reports", permission: "reports:read" }], + [{ href: "/reports", id: "reports", label: "Reports", role: "reports:read" }], ]; test("composeNav merges fragments, filters by role, and emits clean render nodes", () => { const tree = composeNav(fragments, {}, ["scheduling:read"]); // 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`/`role` and omits absent fields — ready for nav-tree.ejs. assert.deepEqual(tree, [ { icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling/shifts", label: "Shifts" }] }, ]); @@ -27,7 +27,7 @@ test("composeNav merges fragments, filters by role, and emits clean render nodes test("composeNav drops gated subtrees, empty headers, and (with no roles) all gated nodes", () => { // A header the user can't reach takes its whole subtree, even visible children. const gatedHeader: NavNode[][] = [[ - { id: "admin", label: "Admin", permission: "admin", children: [{ href: "/u", id: "u", label: "Users" }] }, + { id: "admin", label: "Admin", role: "admin", children: [{ href: "/u", id: "u", label: "Users" }] }, { id: "free", label: "Free", children: [{ href: "/d", id: "d", label: "Docs" }] }, ]]; assert.deepEqual(composeNav(gatedHeader, {}, []), [ @@ -36,8 +36,8 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga // A pure header whose children are all filtered is dropped; a header with an href survives as a leaf. const emptyHeader: NavNode[][] = [[ - { id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", permission: "x" }] }, - { href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", permission: "y" }] }, + { id: "sec", label: "Section", children: [{ href: "/x", id: "x", label: "X", role: "x" }] }, + { href: "/hub", id: "hub", label: "Hub", children: [{ href: "/y", id: "y", label: "Y", role: "y" }] }, ]]; assert.deepEqual(composeNav(emptyHeader, {}, []), [{ href: "/hub", label: "Hub" }]); @@ -52,10 +52,10 @@ test("composeNav keeps a node marked public for everyone — the blessed public icon: "i-cal", id: "sched", label: "Scheduling", children: [ { href: "/scheduling", id: "overview", label: "Overview", public: true }, - { href: "/scheduling/shifts", id: "shifts", label: "Shifts", permission: "scheduling:read" }, + { href: "/scheduling/shifts", id: "shifts", label: "Shifts", role: "scheduling:read" }, ], }]]; - // `public` is filter-only (like id/permission) — never rendered into the output node. + // `public` is filter-only (like id/role) — never rendered into the output node. assert.deepEqual(composeNav(frag, {}, []), [ { icon: "i-cal", label: "Scheduling", children: [{ href: "/scheduling", label: "Overview" }] }, ]); @@ -66,7 +66,7 @@ test("composeNav applies the override: rename, group, order, hide (then filters) { href: "/a", id: "a", label: "Alpha" }, { href: "/b", id: "b", label: "Beta" }, { href: "/c", id: "c", label: "Gamma" }, - { href: "/secret", id: "secret", label: "Secret", permission: "root" }, + { href: "/secret", id: "secret", label: "Secret", role: "root" }, ]]; const tree = composeNav(base, { diff --git a/src/ui/nav.ts b/src/ui/nav.ts index c8757ef..48db8ec 100644 --- a/src/ui/nav.ts +++ b/src/ui/nav.ts @@ -1,10 +1,10 @@ // 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 +// override, then role-filter per user. Pure and I/O-free — menu gating reads the JWT // `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or -// declares no `permission`, or `roles` includes that permission token; a gated header hides its whole +// declares no `role`, or `roles` includes that role name; a gated header hides its whole // subtree, and a pure header left with no children is dropped. The config/menu.ts supplies // the override (+ branding); this helper only transforms data, so its result is per-deployment -// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/permission). +// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/role). export interface NavNode { id?: string; // stable key for override targeting; stripped from the rendered tree @@ -15,12 +15,12 @@ export interface NavNode { icon?: string; label: string; open?: boolean; - permission?: string; // required role 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). + role?: string; // required role token; consumed by the filter, never rendered + public?: boolean; // show to everyone, signed in or not — the blessed alias for "no role", stated outright; consumed by the filter, never rendered. Mutually exclusive with role (discovery refuses both). } // Central override (config/menu.ts). Targets nodes by `id`; applied rename → group → -// order → hide, then the per-user permission filter runs last. +// order → hide, then the per-user role filter runs last. export interface NavOverride { groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header hide?: string[]; // remove nodes by id, at any depth (incl. a group's id) @@ -106,7 +106,7 @@ function hideTree(nodes: NavNode[], hide: Set): NavNode[] { function filterByRoles(nodes: NavNode[], roles: Set): NavNode[] { const out: NavNode[] = []; for (const n of nodes) { - if (n.public !== true && n.permission != null && !roles.has(n.permission)) continue; // gated → drop node + subtree (public always shows) + if (n.public !== true && n.role != null && !roles.has(n.role)) continue; // gated → drop node + subtree (public always shows) if (!n.children) { out.push(n); continue; } const children = filterByRoles(n.children, roles); if (children.length === 0 && n.href == null) continue; // empty pure header → drop @@ -115,7 +115,7 @@ function filterByRoles(nodes: NavNode[], roles: Set): NavNode[] { return out; } -// Strip the helper-only fields (id/permission) and drop absent ones, so the tree is exactly +// Strip the helper-only fields (id/role) and drop absent ones, so the tree is exactly // what nav-tree.ejs reads. function toRenderNode(n: NavNode): NavNode { const out: NavNode = { label: n.label };