§10 review pass: address the architecture + product reviewers (todo §10); hide the gated Dashboard nav node from anonymous visitors in buildPluginChrome (a no-permission link to /dashboard only dead-ended them at /login) and dedup it into a shared DASHBOARD_NAV (admin-nav.ts, reused by chrome + adminNav). New chrome.signInHref bakes the current page in as return_to for the shell's anonymous Sign-in link (shell.ejs + reference overview.ejs), mirrored as optional ShellModel.signInHref so the typed builder is complete. ctx.chrome is now a lazy, memoized getter (context.ts chrome option = a factory) so a json/redirect handler or the public "/" with a standalone home never composes the global menu — app.ts passes the app-level memoized factory at every site. Default /dashboard prints a "Starter dashboard" note framing the mock-data home as a replaceable demo (signals its inert affordances); stale "until §4" comments fixed. RESERVED_PLUGIN_IDS drift-guard test derives the built-in segments from AUTH_FLOWS + ADMIN_*_BASE + host literals (home stays deliberately unreserved). Refreshed the stale plugin-contract status blurb and documented the chrome.*→partials/shell mapping. Reviewers: architecture + product APPROVE (no addressable findings remain), stability APPROVE (no Critical/High/Medium). typecheck + 356 units + visual(10) + full-flow(7) E2E green.
This commit is contained in:
@@ -21,8 +21,9 @@ time, not in production.
|
||||
> reachable via `include()`), **per-plugin static serving** (`/public/<id>/` → the plugin's
|
||||
> `public/`, `routePublic` in `src/static.ts`), and the **central menu override + branding**
|
||||
> (`config/menu.ts`, loaded by `src/menu-config.ts`, with branding — name, logo, default theme —
|
||||
> rendered in the app shell) are wired. The §2 plugin host is feature-complete; the remaining §2
|
||||
> items are a project-wide review and comment/test cleanup.
|
||||
> rendered in the app shell) are wired and in use by the built-in screens and the reference plugin.
|
||||
> Later phases extended this contract: the replaceable [landing pages](#the-landing-pages-home--dashboard)
|
||||
> and [public pages & menu items](#public-pages--menu-items) (§10), both documented below.
|
||||
|
||||
## Anatomy of a plugin
|
||||
|
||||
@@ -237,11 +238,16 @@ interface RequestContext {
|
||||
}
|
||||
```
|
||||
|
||||
**`ctx.chrome`** is the page chrome the host builds per request — `{ brand, csrfToken, nav, theme,
|
||||
user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same
|
||||
**`ctx.chrome`** is the page chrome the host builds per request — `{ brand, csrfToken, nav, signInHref,
|
||||
theme, user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same
|
||||
sidebar, branding, theme switch and signed-in profile as the built-in screens); `chrome.nav` is the
|
||||
global menu — your plugin's nav fragment plus the others and the admin section — already composed,
|
||||
role-filtered, and current-marked for this request. **`ctx.verifyCsrf(submitted)`** guards a
|
||||
role-filtered, and current-marked for this request (the gated **Dashboard** link is omitted for an
|
||||
anonymous visitor). `chrome.signInHref` is where the shell's anonymous **Sign in** link points — the
|
||||
current page baked in as `return_to`. Map each `chrome.*` to the matching `partials/shell` local —
|
||||
`brand`, `csrfToken`, `nav` (the rendered nav-tree), `signInHref`, `theme`, `user` — exactly as the
|
||||
reference `plugins/scheduling/views/overview.ejs` does; a value you forget simply falls back to its
|
||||
shell default (e.g. a bare `/login`), it does not error. **`ctx.verifyCsrf(submitted)`** guards a
|
||||
state-changing form: render `chrome.csrfToken` in a hidden `_csrf` field, then on POST read your own
|
||||
body and `if (!ctx.verifyCsrf(form.get("_csrf"))) throw new GuardError(403, …)`. The host owns the
|
||||
secret and sets the cookie; the plugin never touches it. (See the reference: `plugins/scheduling/`.)
|
||||
@@ -280,8 +286,9 @@ accident of a forgotten gate**. `public` and `permission` are **mutually exclusi
|
||||
both is contradictory and discovery refuses the plugin at boot.
|
||||
|
||||
A public page still renders in the native shell via `ctx.chrome`; for an anonymous visitor
|
||||
`ctx.user` is `null`, the shell shows a **Sign in** link in place of the profile/sign-out block, and
|
||||
`ctx.roles` is empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling`
|
||||
`ctx.user` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page)
|
||||
in place of the profile/sign-out block, the gated **Dashboard** link is hidden, and `ctx.roles` is
|
||||
empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling`
|
||||
**Overview** is a worked example: it's `public`, so the "Scheduling" menu header shows for everyone,
|
||||
while the actual shifts list stays behind `scheduling:read`.
|
||||
|
||||
|
||||
@@ -164,7 +164,12 @@ test("the reference plugin: public Overview is open to all, the gated Shifts red
|
||||
// The public overview is reachable with no session (200), not bounced to sign in.
|
||||
const pub = await request.get("/scheduling", { maxRedirects: 0 });
|
||||
expect(pub.status()).toBe(200);
|
||||
expect(await pub.text()).toContain("Scheduling");
|
||||
const body = await pub.text();
|
||||
expect(body).toContain("Scheduling");
|
||||
// Anonymous in the native shell (§10): the gated Dashboard link is hidden (it would only dead-end at
|
||||
// /login), and the shell's Sign-in link carries the current page as return_to.
|
||||
expect(body).not.toContain('href="/dashboard"');
|
||||
expect(body).toContain('href="/login?return_to=%2Fscheduling"');
|
||||
|
||||
// The gated shifts list still bounces (don't follow — this Ory-free suite has no /login handler);
|
||||
// assert the gate's 303 with the requested page preserved as return_to (§9).
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
SHIFTS_PATH, type Shift, type ShiftInput, type ShiftsUpstream, UpstreamError, validate,
|
||||
} from "./shifts.ts";
|
||||
|
||||
const CHROME: PageChrome = { brand: { name: "Test" }, csrfToken: "tok", nav: [], 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; roles?: string[]; url?: string; verifyCsrf?: (s: string | null | undefined) => boolean } = {}): RequestContext {
|
||||
const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
breadcrumbs,
|
||||
csrfToken: chrome.csrfToken,
|
||||
nav: navHtml,
|
||||
signInHref: chrome.signInHref,
|
||||
styles: ["/public/scheduling/scheduling.css"],
|
||||
theme: chrome.theme,
|
||||
title,
|
||||
|
||||
@@ -20,6 +20,11 @@ export const ADMIN_CLIENTS_BASE = "/admin/clients";
|
||||
|
||||
export type AdminScreen = "clients" | "groups" | "roles" | "users";
|
||||
|
||||
// The "Dashboard" link to the gated app home (/dashboard). One definition, reused by the in-screen
|
||||
// admin sidebar and the plugin-page chrome (chrome.ts) so the two can't drift. It targets a gated
|
||||
// route, so the chrome hides it from anonymous visitors (a non-signed-in click only dead-ends at /login).
|
||||
export const DASHBOARD_NAV: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" };
|
||||
|
||||
const ITEMS: { href: string; icon: string; id: AdminScreen; label: string }[] = [
|
||||
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
|
||||
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" },
|
||||
@@ -42,10 +47,7 @@ export function adminSection(current?: AdminScreen): NavNode {
|
||||
|
||||
// In-screen sidebar for the admin screens: a link home + the admin section (active item marked).
|
||||
export function adminNav(roles: string[], menu: MenuConfig, current: AdminScreen): NavNode[] {
|
||||
return composeNav([[
|
||||
{ href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" },
|
||||
adminSection(current),
|
||||
]], menu.override, roles);
|
||||
return composeNav([[DASHBOARD_NAV, adminSection(current)]], menu.override, roles);
|
||||
}
|
||||
|
||||
// The shared gate for every admin screen: a signed-in admin only. Throws GuardError that app.ts maps
|
||||
|
||||
@@ -60,6 +60,7 @@ test("the dashboard at /dashboard: the app-shell People list, gated to a session
|
||||
assert.match(html, /<table class="table"/);
|
||||
assert.match(html, /<footer class="pager"/);
|
||||
assert.match(html, /Avery Kline/); // a mock person on page 1
|
||||
assert.match(html, /Starter dashboard/); // the default flags itself a demo to replace with a `dashboard` plugin (§10)
|
||||
|
||||
// The Sign-out POST form carries a CSRF token matching the Set-Cookie issued for the page (§4).
|
||||
const csrfCookie = (res.headers.get("set-cookie") ?? "").match(/plainpages_csrf=([^;]+)/)?.[1];
|
||||
|
||||
20
src/app.ts
20
src/app.ts
@@ -163,17 +163,15 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret).
|
||||
const verifyCsrf = (submitted: string | null | undefined): boolean =>
|
||||
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
|
||||
// Chrome (brand/global-nav/user/theme/csrf) is built lazily and at most once per request —
|
||||
// only plugin routes (and an onRequest short-circuit) read it, so the hot path stays free and
|
||||
// a matched plugin request doesn't re-compose the whole menu for the onRequest + route ctx.
|
||||
// Chrome (brand/global-nav/user/theme/csrf) composes the whole menu, so it's resolved lazily and
|
||||
// at most once per request: this app-level memo shares it across the contexts below, and each
|
||||
// ctx.chrome getter only triggers it when a handler actually reads it (a json/redirect handler,
|
||||
// or the public "/" with a standalone home, never composes the menu).
|
||||
let chromeMemo: PageChrome | undefined;
|
||||
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
|
||||
|
||||
// base context (no route params yet); reused for onRequest.
|
||||
const ctx = buildContext(req, res, {
|
||||
log: reqLog, user, verifyCsrf,
|
||||
...(anyRequestHooks ? { chrome: chrome() } : {}),
|
||||
});
|
||||
// base context (no route params yet); reused for onRequest + the built-in admin screens.
|
||||
const ctx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
|
||||
|
||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||
if (anyRequestHooks) {
|
||||
@@ -192,7 +190,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// CSRF cookie is set so those forms have a valid double-submit token.
|
||||
const match = matchRoute(plugins, method, pathname);
|
||||
if (match) {
|
||||
const routeCtx = buildContext(req, res, { chrome: chrome(), log: reqLog, params: match.params, user, verifyCsrf });
|
||||
const routeCtx = buildContext(req, res, { chrome, log: reqLog, params: match.params, user, verifyCsrf });
|
||||
if (!isAuthorized(match.route, routeCtx.roles)) {
|
||||
// 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.
|
||||
@@ -440,7 +438,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// any form it ships). Else the built-in intro page with prominent sign-in / register links.
|
||||
if (homePlugin) {
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
const homeCtx = buildContext(req, res, { chrome: chrome(), log: reqLog, user, verifyCsrf });
|
||||
const homeCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
|
||||
const result = (await homePlugin.home(homeCtx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, homeCtx, result);
|
||||
await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data));
|
||||
@@ -460,7 +458,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// A plugin may fully own the dashboard (§10): render its handler against its own views, native
|
||||
// shell via ctx.chrome — same path as a plugin route. Else the built-in mock-data People list.
|
||||
if (dashboardPlugin) {
|
||||
const dashCtx = buildContext(req, res, { chrome: chrome(), log: reqLog, user, verifyCsrf });
|
||||
const dashCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
|
||||
const result = (await dashboardPlugin.dashboard(dashCtx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, dashCtx, result);
|
||||
await sendResult(res, result, (view, data) => renderView(dashboardPlugin.id, view, data));
|
||||
|
||||
@@ -13,22 +13,32 @@ const scheduling: Plugin = {
|
||||
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 }] };
|
||||
|
||||
const labels = (nodes: NavNode[]): string[] => nodes.map((n) => n.label);
|
||||
|
||||
test("anonymous: brand from menu, Guest user, gated plugin + admin nav filtered out", () => {
|
||||
const chrome = buildPluginChrome({ menu: DEFAULT_MENU, plugins: [scheduling] });
|
||||
test("anonymous: brand from menu, Guest user; the gated Dashboard link is hidden, a public node still shows", () => {
|
||||
const chrome = buildPluginChrome({ menu: DEFAULT_MENU, plugins: [scheduling, portal] });
|
||||
assert.equal(chrome.brand.name, DEFAULT_MENU.branding.name);
|
||||
assert.equal(chrome.user.name, "Guest");
|
||||
assert.deepEqual(labels(chrome.nav), ["Dashboard"]); // Scheduling (gated child) + Admin dropped
|
||||
// Dashboard points at the gated /dashboard — showing it to an anonymous visitor only dead-ends them
|
||||
// at /login, so it's dropped. Scheduling's only child is gated (dropped), admin gated (dropped);
|
||||
// the explicitly public Portal node remains.
|
||||
assert.deepEqual(labels(chrome.nav), ["Portal"]);
|
||||
});
|
||||
|
||||
test("a permission holder sees the plugin nav; current path opens the active leaf", () => {
|
||||
test("anonymous shell Sign-in link carries the current page as return_to", () => {
|
||||
assert.equal(buildPluginChrome({ menu: DEFAULT_MENU }).signInHref, "/login"); // no path known
|
||||
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", () => {
|
||||
const chrome = buildPluginChrome({
|
||||
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
|
||||
user: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] },
|
||||
});
|
||||
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]);
|
||||
assert.deepEqual(labels(chrome.nav), ["Dashboard", "Scheduling"]); // Dashboard shown to a signed-in user
|
||||
const section = chrome.nav.find((n) => n.label === "Scheduling")!;
|
||||
assert.equal(section.open, true); // ancestor of the current leaf opened
|
||||
assert.equal(section.children!.find((c) => c.label === "Shifts")!.current, true);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// nav is the global menu — Dashboard + every plugin's fragment + the gated admin section — run
|
||||
// through composeNav (override + per-user filter) and current-marked for the request path.
|
||||
|
||||
import { adminSection } from "./admin-nav.ts";
|
||||
import { adminSection, DASHBOARD_NAV } from "./admin-nav.ts";
|
||||
import type { User } from "./context.ts";
|
||||
import { type MenuConfig } from "./menu-config.ts";
|
||||
import { composeNav, type NavNode } from "./nav.ts";
|
||||
@@ -15,12 +15,11 @@ export interface PageChrome {
|
||||
brand: { logo?: string; name: string; sub?: string };
|
||||
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms
|
||||
nav: NavNode[]; // global menu, composed + role-filtered + current-marked, ready for nav-tree.ejs
|
||||
signInHref: string; // where the shell's anonymous "Sign in" link points — carries this page as return_to
|
||||
theme?: string;
|
||||
user: ShellUser;
|
||||
}
|
||||
|
||||
const HOME: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" };
|
||||
|
||||
export interface ChromeOptions {
|
||||
csrfToken?: string;
|
||||
currentPath?: string; // request pathname; the matching nav leaf is marked current
|
||||
@@ -30,7 +29,9 @@ export interface ChromeOptions {
|
||||
}
|
||||
|
||||
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
const fragments: NavNode[][] = [[HOME]];
|
||||
// The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an
|
||||
// anonymous visitor (a public page in the shell, §10) it would only dead-end at /login.
|
||||
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
|
||||
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
|
||||
fragments.push([adminSection()]);
|
||||
|
||||
@@ -43,6 +44,8 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
|
||||
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: b.sub } : {}) },
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
nav,
|
||||
// Anonymous "Sign in" returns to the current page (it's host-relative, our own pathname).
|
||||
signInHref: opts.currentPath ? `/login?return_to=${encodeURIComponent(opts.currentPath)}` : "/login",
|
||||
...(b.theme != null ? { theme: b.theme } : {}),
|
||||
user: shellUser(opts.user),
|
||||
};
|
||||
|
||||
@@ -35,7 +35,10 @@ export interface RequestContext {
|
||||
}
|
||||
|
||||
export interface BuildContextOptions {
|
||||
chrome?: PageChrome;
|
||||
// Lazy chrome factory: composing the global menu is only paid for if the handler actually reads
|
||||
// ctx.chrome (a json/redirect handler, or the public "/" with a standalone home, pays nothing).
|
||||
// The host's factory is memoised, so the menu composes at most once per request across contexts.
|
||||
chrome?: () => PageChrome;
|
||||
log?: Log;
|
||||
params?: Record<string, string>;
|
||||
user?: User | null;
|
||||
@@ -43,7 +46,7 @@ export interface BuildContextOptions {
|
||||
}
|
||||
|
||||
// Anonymous default chrome — used until the host supplies a real one (built-in routes, tests).
|
||||
const ANON_CHROME: PageChrome = { brand: { name: "Plainpages" }, csrfToken: "", nav: [], user: { email: "", initials: "G", name: "Guest" } };
|
||||
const ANON_CHROME: PageChrome = { brand: { name: "Plainpages" }, csrfToken: "", nav: [], signInHref: "/login", user: { email: "", initials: "G", name: "Guest" } };
|
||||
// Silent default logger — used off the request path (built-in routes built ad hoc, tests) until the
|
||||
// host supplies the real request logger. One instance, no output, negligible cost.
|
||||
const SILENT_LOG = createLogger({ level: "none" });
|
||||
@@ -55,8 +58,10 @@ export function buildContext(
|
||||
): RequestContext {
|
||||
const url = new URL(req.url ?? "/", "http://localhost");
|
||||
const user = options.user ?? null;
|
||||
const buildChrome = options.chrome;
|
||||
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
|
||||
return {
|
||||
chrome: options.chrome ?? ANON_CHROME,
|
||||
get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
|
||||
log: options.log ?? SILENT_LOG,
|
||||
params: options.params ?? {},
|
||||
query: url.searchParams,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Dashboard view model (todo §1): the gated "/dashboard" app-shell "People" list. Pure — turns a
|
||||
// request URL into the data the building-block partials render, wiring the §1 helpers end-to-end:
|
||||
// parseListQuery → filter/sort/paginate a mock dataset → composeNav. Mock data stands in for
|
||||
// upstream until §4; the filter form, sortable headers and pager all round-trip the URL (zero-JS).
|
||||
// parseListQuery → filter/sort/paginate a mock dataset → composeNav. This is the built-in *demo* home
|
||||
// over mock data (a plugin owns the real one via a `dashboard` handler, §10); the filter form,
|
||||
// sortable headers and pager all round-trip the URL (zero-JS).
|
||||
|
||||
import { adminSection } from "./admin-nav.ts";
|
||||
import type { User } from "./context.ts";
|
||||
@@ -122,8 +123,8 @@ export function buildDashboardModel(url: URL | URLSearchParams | string, roles:
|
||||
export type DashboardModel = ReturnType<typeof buildDashboardModel>;
|
||||
|
||||
// Sidebar: the demo "Directory" fragment, then each discovered plugin's own nav fragment (so a
|
||||
// plugin is reachable from "/"; gated nodes stay invisible to non-admins), then the gated admin
|
||||
// section. composeNav applies the central override + per-user role filter.
|
||||
// plugin is reachable from the dashboard; gated nodes stay invisible to non-admins), then the gated
|
||||
// admin section. composeNav applies the central override + per-user role filter.
|
||||
function nav(roles: string[], override: NavOverride, plugins: Plugin[]): NavNode[] {
|
||||
const pluginFragments = plugins.filter((p) => p.nav?.length).map((p) => p.nav as NavNode[]);
|
||||
return composeNav([[
|
||||
|
||||
@@ -7,9 +7,12 @@ import {
|
||||
HOST_API_VERSION,
|
||||
isValidPluginId,
|
||||
parseSemver,
|
||||
RESERVED_PLUGIN_IDS,
|
||||
type Plugin,
|
||||
type PluginManifest,
|
||||
} from "./plugin.ts";
|
||||
import { ADMIN_CLIENTS_BASE, ADMIN_GROUPS_BASE, ADMIN_ROLES_BASE, ADMIN_USERS_BASE } from "./admin-nav.ts";
|
||||
import { AUTH_FLOWS } from "./flow-view.ts";
|
||||
|
||||
// A representative manifest exercising every field — its existence type-checks the contract.
|
||||
// `apiVersion` is a literal: a plugin pins the version it was built against, so importing
|
||||
@@ -109,3 +112,23 @@ test("findConflicts: each single slot (`home`/`dashboard`) may have one owner
|
||||
// One owner of each (even both on one plugin) is fine.
|
||||
assert.deepEqual(findConflicts([p({ id: "a", dashboard: handler, home: handler }), p({ id: "b" })]).filter((c) => c.kind === "home" || c.kind === "dashboard"), []);
|
||||
});
|
||||
|
||||
// Drift guard: RESERVED_PLUGIN_IDS is a hand-maintained mirror of the host's own top-level mounts —
|
||||
// a folder claiming one would silently shadow a built-in route. Derive the segments from the real
|
||||
// route constants so adding a new auth flow or admin screen without reserving its id fails here.
|
||||
test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / field) is NOT reserved", () => {
|
||||
const seg = (path: string): string => path.split("/")[1] ?? ""; // first segment of "/x/y"
|
||||
const builtins = new Set<string>([
|
||||
...Object.keys(AUTH_FLOWS).map(seg), // /login, /recovery, /registration, /settings, /verification
|
||||
seg(ADMIN_USERS_BASE), seg(ADMIN_GROUPS_BASE), seg(ADMIN_ROLES_BASE), seg(ADMIN_CLIENTS_BASE), // → admin
|
||||
"auth", // /auth/complete (login completion)
|
||||
"logout", // POST /logout
|
||||
"oauth2", // /oauth2/login · /consent · /logout (Hydra provider)
|
||||
"dashboard", // the gated app home (§10)
|
||||
"public", // static assets
|
||||
]);
|
||||
for (const id of builtins) assert.ok(RESERVED_PLUGIN_IDS.has(id), `built-in mount "${id}" must be a reserved plugin id`);
|
||||
// "/" is owned by the `home` manifest field (not a /<id> route), so it cannot be shadowed and is
|
||||
// deliberately not reserved — a plugin folder named "home" is legal.
|
||||
assert.equal(RESERVED_PLUGIN_IDS.has("home"), false);
|
||||
});
|
||||
|
||||
@@ -14,11 +14,13 @@ test("buildShellContext maps branding + breadcrumbs, omitting unset optional fie
|
||||
assert.equal(bare.theme, undefined);
|
||||
assert.equal(bare.csrfToken, "");
|
||||
assert.equal(bare.user.name, "Guest");
|
||||
assert.equal(bare.signInHref, undefined); // omitted unless supplied (a public built-in screen would set it)
|
||||
|
||||
const full = buildShellContext({
|
||||
breadcrumbs: [{ href: "/", label: "Home" }, { label: "Users" }],
|
||||
csrfToken: "tok.sig",
|
||||
menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} },
|
||||
signInHref: "/login?return_to=%2Fx",
|
||||
title: "Users",
|
||||
user: { email: "a@b.c", id: "u1", roles: ["admin"] },
|
||||
});
|
||||
@@ -26,4 +28,5 @@ test("buildShellContext maps branding + breadcrumbs, omitting unset optional fie
|
||||
assert.equal(full.theme, "dark");
|
||||
assert.equal(full.csrfToken, "tok.sig");
|
||||
assert.equal(full.breadcrumbs?.length, 2);
|
||||
assert.equal(full.signInHref, "/login?return_to=%2Fx");
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface ShellModel {
|
||||
brand: { logo?: string; name: string; sub?: string };
|
||||
breadcrumbs?: { href?: string; label: string }[];
|
||||
csrfToken: string;
|
||||
signInHref?: string; // anonymous "Sign in" target (mirrors PageChrome.signInHref); a gated screen omits it
|
||||
theme?: string;
|
||||
title: string;
|
||||
user: ShellUser;
|
||||
@@ -33,6 +34,7 @@ export function buildShellContext(opts: {
|
||||
breadcrumbs?: { href?: string; label: string }[];
|
||||
csrfToken?: string;
|
||||
menu: MenuConfig;
|
||||
signInHref?: string;
|
||||
title: string;
|
||||
user?: User | null;
|
||||
}): ShellModel {
|
||||
@@ -41,6 +43,7 @@ export function buildShellContext(opts: {
|
||||
brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: b.sub } : {}) },
|
||||
...(opts.breadcrumbs ? { breadcrumbs: opts.breadcrumbs } : {}),
|
||||
csrfToken: opts.csrfToken ?? "",
|
||||
...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}),
|
||||
...(b.theme != null ? { theme: b.theme } : {}),
|
||||
title: opts.title,
|
||||
user: shellUser(opts.user),
|
||||
|
||||
@@ -44,9 +44,13 @@ test("app shell renders sidebar, topbar and the content slot", async () => {
|
||||
});
|
||||
|
||||
test("app shell offers Sign in (not Sign out) to an anonymous visitor — so a public page in the shell works (§10)", async () => {
|
||||
const html = await render({ title: "Overview", brand: { name: "Acme" }, nav: "", body: "x" }); // no user → Guest
|
||||
assert.match(html, /href="\/login"[^>]*>[\s\S]*?Sign in/); // a path to sign in
|
||||
const html = await render({ title: "Overview", brand: { name: "Acme" }, nav: "", body: "x" }); // no user, no signInHref → default
|
||||
assert.match(html, /href="\/login"[^>]*>[\s\S]*?Sign in/); // a path to sign in (default target)
|
||||
assert.doesNotMatch(html, /action="\/logout"/); // a guest has no session to end
|
||||
|
||||
// When chrome supplies signInHref (the current page as return_to), the link carries it.
|
||||
const withReturn = await render({ title: "Overview", brand: { name: "Acme" }, nav: "", body: "x", signInHref: "/login?return_to=%2Fscheduling" });
|
||||
assert.match(withReturn, /href="\/login\?return_to=%2Fscheduling"[^>]*>[\s\S]*?Sign in/);
|
||||
});
|
||||
|
||||
test("app shell renders a configured logo + default theme, falls back to the brand mark", async () => {
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
sortable headers and pager are real GET links — q/sort/page round-trip the URL, zero-JS.
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: model.nav });
|
||||
// The default home is a demo over mock data (its rows/actions/links are illustrative, not wired) —
|
||||
// say so up front so the inert affordances read as a starter to replace, not a half-built product.
|
||||
const note = include("partials/alert", { text: "The built-in demo home over mock data. Replace it by exporting a dashboard handler from a plugin.", title: "Starter dashboard" });
|
||||
const filters = include("partials/filter-bar", model.filterBar);
|
||||
const table = include("partials/data-table", model.table);
|
||||
const pager = include("partials/pagination", model.pagination);
|
||||
@@ -14,7 +17,7 @@
|
||||
-%>
|
||||
<%- include("partials/shell", {
|
||||
actions,
|
||||
body: filters + table + pager,
|
||||
body: note + filters + table + pager,
|
||||
brand: model.shell.brand,
|
||||
breadcrumbs: model.shell.breadcrumbs,
|
||||
csrfToken: model.shell.csrfToken,
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
`actions` (topbar buttons), `body` (page content); `styles` is an optional array of
|
||||
extra stylesheet hrefs (e.g. a plugin's own /public/<id>/x.css). Text locals: `title`, `brand`
|
||||
({ name, logo?, sub? } — logo image else the default mark), `theme` (default for the
|
||||
theme-switch), `user`, `breadcrumbs`, `csrfToken` (the Sign-out POST form's hidden field).
|
||||
theme-switch), `user`, `breadcrumbs`, `csrfToken` (the Sign-out POST form's hidden field),
|
||||
`signInHref` (anonymous "Sign in" target, carries return_to; default /login).
|
||||
Branding comes from config/menu.ts; `user`/`csrfToken` from §4 auth.
|
||||
%><%
|
||||
const title = locals.title || "Plainpages";
|
||||
@@ -66,8 +67,9 @@
|
||||
</div>
|
||||
</details>
|
||||
<% } else { %>
|
||||
<%# anonymous (a public page in the shell, §10): no session to end — offer a way in instead %>
|
||||
<a class="btn btn-primary" href="/login" style="flex:1 1 auto"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-user" /></svg>Sign in</a>
|
||||
<%# anonymous (a public page in the shell, §10): no session to end — offer a way in instead.
|
||||
signInHref carries this page as return_to (chrome.signInHref); falls back to bare /login. %>
|
||||
<a class="btn btn-primary" href="<%= locals.signInHref || '/login' %>" style="flex:1 1 auto"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-user" /></svg>Sign in</a>
|
||||
<% } %>
|
||||
|
||||
<%- include("menu", {
|
||||
|
||||
Reference in New Issue
Block a user