§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:
2026-06-21 01:19:40 +02:00
parent 7bdeb24b7f
commit 58398481ca
17 changed files with 117 additions and 46 deletions

View File

@@ -21,8 +21,9 @@ time, not in production.
> reachable via `include()`), **per-plugin static serving** (`/public/<id>/` → the plugin's > reachable via `include()`), **per-plugin static serving** (`/public/<id>/` → the plugin's
> `public/`, `routePublic` in `src/static.ts`), and the **central menu override + branding** > `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 — > (`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 > rendered in the app shell) are wired and in use by the built-in screens and the reference plugin.
> items are a project-wide review and comment/test cleanup. > 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 ## 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, **`ctx.chrome`** is the page chrome the host builds per request — `{ brand, csrfToken, nav, signInHref,
user }`. Hand it to `partials/shell` so a `view` result renders the **native app shell** (the same 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 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, 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 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 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/`.) 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. 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 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.user` is `null`, the shell shows a **Sign in** link (`chrome.signInHref`, returning to this page)
`ctx.roles` is empty (read a role with `can(ctx, …)` to branch). The reference plugin's `/scheduling` 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, **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`. while the actual shifts list stays behind `scheduling:read`.

View File

@@ -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. // The public overview is reachable with no session (200), not bounced to sign in.
const pub = await request.get("/scheduling", { maxRedirects: 0 }); const pub = await request.get("/scheduling", { maxRedirects: 0 });
expect(pub.status()).toBe(200); 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); // 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). // assert the gate's 303 with the requested page preserved as return_to (§9).

View File

@@ -10,7 +10,7 @@ import {
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 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 { 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"); const url = new URL(opts.url ?? "http://localhost/scheduling/shifts");

View File

@@ -17,6 +17,7 @@
breadcrumbs, breadcrumbs,
csrfToken: chrome.csrfToken, csrfToken: chrome.csrfToken,
nav: navHtml, nav: navHtml,
signInHref: chrome.signInHref,
styles: ["/public/scheduling/scheduling.css"], styles: ["/public/scheduling/scheduling.css"],
theme: chrome.theme, theme: chrome.theme,
title, title,

View File

@@ -20,6 +20,11 @@ export const ADMIN_CLIENTS_BASE = "/admin/clients";
export type AdminScreen = "clients" | "groups" | "roles" | "users"; 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 }[] = [ const ITEMS: { href: string; icon: string; id: AdminScreen; label: string }[] = [
{ href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" }, { href: ADMIN_USERS_BASE, icon: "i-users", id: "users", label: "Users" },
{ href: ADMIN_GROUPS_BASE, icon: "i-layers", id: "groups", label: "Groups" }, { 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). // 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[] { export function adminNav(roles: string[], menu: MenuConfig, current: AdminScreen): NavNode[] {
return composeNav([[ return composeNav([[DASHBOARD_NAV, adminSection(current)]], menu.override, roles);
{ href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" },
adminSection(current),
]], menu.override, roles);
} }
// The shared gate for every admin screen: a signed-in admin only. Throws GuardError that app.ts maps // The shared gate for every admin screen: a signed-in admin only. Throws GuardError that app.ts maps

View File

@@ -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, /<table class="table"/);
assert.match(html, /<footer class="pager"/); assert.match(html, /<footer class="pager"/);
assert.match(html, /Avery Kline/); // a mock person on page 1 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). // 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]; const csrfCookie = (res.headers.get("set-cookie") ?? "").match(/plainpages_csrf=([^;]+)/)?.[1];

View File

@@ -163,17 +163,15 @@ export function createApp(options: AppOptions = {}): Server {
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret). // Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret).
const verifyCsrf = (submitted: string | null | undefined): boolean => const verifyCsrf = (submitted: string | null | undefined): boolean =>
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted }); verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: csrfSecret, submitted });
// Chrome (brand/global-nav/user/theme/csrf) is built lazily and at most once per request — // Chrome (brand/global-nav/user/theme/csrf) composes the whole menu, so it's resolved lazily and
// only plugin routes (and an onRequest short-circuit) read it, so the hot path stays free and // at most once per request: this app-level memo shares it across the contexts below, and each
// a matched plugin request doesn't re-compose the whole menu for the onRequest + route ctx. // 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; let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user })); const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: csrf.token, currentPath: pathname, menu, plugins, user }));
// base context (no route params yet); reused for onRequest. // base context (no route params yet); reused for onRequest + the built-in admin screens.
const ctx = buildContext(req, res, { const ctx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
log: reqLog, user, verifyCsrf,
...(anyRequestHooks ? { chrome: chrome() } : {}),
});
// Plugin onRequest hooks run before routing and may short-circuit the request. // Plugin onRequest hooks run before routing and may short-circuit the request.
if (anyRequestHooks) { 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. // CSRF cookie is set so those forms have a valid double-submit token.
const match = matchRoute(plugins, method, pathname); const match = matchRoute(plugins, method, pathname);
if (match) { 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)) { if (!isAuthorized(match.route, routeCtx.roles)) {
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as // 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. // 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. // any form it ships). Else the built-in intro page with prominent sign-in / register links.
if (homePlugin) { if (homePlugin) {
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies })); 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; const result = (await homePlugin.home(homeCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, homeCtx, result); if (anyResponseHooks) await runResponseHooks(plugins, homeCtx, result);
await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data)); 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 // 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. // shell via ctx.chrome — same path as a plugin route. Else the built-in mock-data People list.
if (dashboardPlugin) { 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; const result = (await dashboardPlugin.dashboard(dashCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, dashCtx, result); if (anyResponseHooks) await runResponseHooks(plugins, dashCtx, result);
await sendResult(res, result, (view, data) => renderView(dashboardPlugin.id, view, data)); await sendResult(res, result, (view, data) => renderView(dashboardPlugin.id, view, data));

View File

@@ -13,22 +13,32 @@ const scheduling: Plugin = {
icon: "i-cal", id: "scheduling", label: "Scheduling", 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); const labels = (nodes: NavNode[]): string[] => nodes.map((n) => n.label);
test("anonymous: brand from menu, Guest user, gated plugin + admin nav filtered out", () => { 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] }); const chrome = buildPluginChrome({ menu: DEFAULT_MENU, plugins: [scheduling, portal] });
assert.equal(chrome.brand.name, DEFAULT_MENU.branding.name); assert.equal(chrome.brand.name, DEFAULT_MENU.branding.name);
assert.equal(chrome.user.name, "Guest"); 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({ const chrome = buildPluginChrome({
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling], currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
user: { email: "ada@x.io", id: "u1", roles: ["scheduling:read"] }, 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")!; const section = chrome.nav.find((n) => n.label === "Scheduling")!;
assert.equal(section.open, true); // ancestor of the current leaf opened assert.equal(section.open, true); // ancestor of the current leaf opened
assert.equal(section.children!.find((c) => c.label === "Shifts")!.current, true); assert.equal(section.children!.find((c) => c.label === "Shifts")!.current, true);

View File

@@ -4,7 +4,7 @@
// nav is the global menu — Dashboard + every plugin's fragment + the gated admin section — run // 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. // 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 { User } from "./context.ts";
import { type MenuConfig } from "./menu-config.ts"; import { type MenuConfig } from "./menu-config.ts";
import { composeNav, type NavNode } from "./nav.ts"; import { composeNav, type NavNode } from "./nav.ts";
@@ -15,12 +15,11 @@ export interface PageChrome {
brand: { logo?: string; name: string; sub?: string }; brand: { logo?: string; name: string; sub?: string };
csrfToken: string; // double-submit token for the shell's Sign-out form + a plugin's own forms 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 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; theme?: string;
user: ShellUser; user: ShellUser;
} }
const HOME: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" };
export interface ChromeOptions { export interface ChromeOptions {
csrfToken?: string; csrfToken?: string;
currentPath?: string; // request pathname; the matching nav leaf is marked current currentPath?: string; // request pathname; the matching nav leaf is marked current
@@ -30,7 +29,9 @@ export interface ChromeOptions {
} }
export function buildPluginChrome(opts: ChromeOptions): PageChrome { 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); for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
fragments.push([adminSection()]); 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 } : {}) }, brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: b.sub } : {}) },
csrfToken: opts.csrfToken ?? "", csrfToken: opts.csrfToken ?? "",
nav, 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 } : {}), ...(b.theme != null ? { theme: b.theme } : {}),
user: shellUser(opts.user), user: shellUser(opts.user),
}; };

View File

@@ -35,7 +35,10 @@ export interface RequestContext {
} }
export interface BuildContextOptions { 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; log?: Log;
params?: Record<string, string>; params?: Record<string, string>;
user?: User | null; 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). // 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 // 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. // host supplies the real request logger. One instance, no output, negligible cost.
const SILENT_LOG = createLogger({ level: "none" }); const SILENT_LOG = createLogger({ level: "none" });
@@ -55,8 +58,10 @@ export function buildContext(
): RequestContext { ): RequestContext {
const url = new URL(req.url ?? "/", "http://localhost"); const url = new URL(req.url ?? "/", "http://localhost");
const user = options.user ?? null; const user = options.user ?? null;
const buildChrome = options.chrome;
let chromeMemo: PageChrome | undefined; // resolve the factory at most once per context
return { return {
chrome: options.chrome ?? ANON_CHROME, get chrome(): PageChrome { return (chromeMemo ??= buildChrome ? buildChrome() : ANON_CHROME); },
log: options.log ?? SILENT_LOG, log: options.log ?? SILENT_LOG,
params: options.params ?? {}, params: options.params ?? {},
query: url.searchParams, query: url.searchParams,

View File

@@ -1,7 +1,8 @@
// Dashboard view model (todo §1): the gated "/dashboard" app-shell "People" list. Pure — turns a // 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: // 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 // parseListQuery → filter/sort/paginate a mock dataset → composeNav. This is the built-in *demo* home
// upstream until §4; the filter form, sortable headers and pager all round-trip the URL (zero-JS). // 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 { adminSection } from "./admin-nav.ts";
import type { User } from "./context.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>; export type DashboardModel = ReturnType<typeof buildDashboardModel>;
// Sidebar: the demo "Directory" fragment, then each discovered plugin's own nav fragment (so a // 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 // plugin is reachable from the dashboard; gated nodes stay invisible to non-admins), then the gated
// section. composeNav applies the central override + per-user role filter. // admin section. composeNav applies the central override + per-user role filter.
function nav(roles: string[], override: NavOverride, plugins: Plugin[]): NavNode[] { function nav(roles: string[], override: NavOverride, plugins: Plugin[]): NavNode[] {
const pluginFragments = plugins.filter((p) => p.nav?.length).map((p) => p.nav as NavNode[]); const pluginFragments = plugins.filter((p) => p.nav?.length).map((p) => p.nav as NavNode[]);
return composeNav([[ return composeNav([[

View File

@@ -7,9 +7,12 @@ import {
HOST_API_VERSION, HOST_API_VERSION,
isValidPluginId, isValidPluginId,
parseSemver, parseSemver,
RESERVED_PLUGIN_IDS,
type Plugin, type Plugin,
type PluginManifest, type PluginManifest,
} from "./plugin.ts"; } 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. // 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 // `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. // 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"), []); 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);
});

View File

@@ -14,11 +14,13 @@ test("buildShellContext maps branding + breadcrumbs, omitting unset optional fie
assert.equal(bare.theme, undefined); assert.equal(bare.theme, undefined);
assert.equal(bare.csrfToken, ""); assert.equal(bare.csrfToken, "");
assert.equal(bare.user.name, "Guest"); 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({ const full = buildShellContext({
breadcrumbs: [{ href: "/", label: "Home" }, { label: "Users" }], breadcrumbs: [{ href: "/", label: "Home" }, { label: "Users" }],
csrfToken: "tok.sig", csrfToken: "tok.sig",
menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} }, menu: { branding: { logo: "/l.svg", name: "Acme", sub: "Ops", theme: "dark" }, override: {} },
signInHref: "/login?return_to=%2Fx",
title: "Users", title: "Users",
user: { email: "a@b.c", id: "u1", roles: ["admin"] }, 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.theme, "dark");
assert.equal(full.csrfToken, "tok.sig"); assert.equal(full.csrfToken, "tok.sig");
assert.equal(full.breadcrumbs?.length, 2); assert.equal(full.breadcrumbs?.length, 2);
assert.equal(full.signInHref, "/login?return_to=%2Fx");
}); });

View File

@@ -18,6 +18,7 @@ export interface ShellModel {
brand: { logo?: string; name: string; sub?: string }; brand: { logo?: string; name: string; sub?: string };
breadcrumbs?: { href?: string; label: string }[]; breadcrumbs?: { href?: string; label: string }[];
csrfToken: string; csrfToken: string;
signInHref?: string; // anonymous "Sign in" target (mirrors PageChrome.signInHref); a gated screen omits it
theme?: string; theme?: string;
title: string; title: string;
user: ShellUser; user: ShellUser;
@@ -33,6 +34,7 @@ export function buildShellContext(opts: {
breadcrumbs?: { href?: string; label: string }[]; breadcrumbs?: { href?: string; label: string }[];
csrfToken?: string; csrfToken?: string;
menu: MenuConfig; menu: MenuConfig;
signInHref?: string;
title: string; title: string;
user?: User | null; user?: User | null;
}): ShellModel { }): 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 } : {}) }, brand: { ...(b.logo != null ? { logo: b.logo } : {}), name: b.name, ...(b.sub != null ? { sub: b.sub } : {}) },
...(opts.breadcrumbs ? { breadcrumbs: opts.breadcrumbs } : {}), ...(opts.breadcrumbs ? { breadcrumbs: opts.breadcrumbs } : {}),
csrfToken: opts.csrfToken ?? "", csrfToken: opts.csrfToken ?? "",
...(opts.signInHref != null ? { signInHref: opts.signInHref } : {}),
...(b.theme != null ? { theme: b.theme } : {}), ...(b.theme != null ? { theme: b.theme } : {}),
title: opts.title, title: opts.title,
user: shellUser(opts.user), user: shellUser(opts.user),

View File

@@ -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 () => { 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 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 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 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 () => { test("app shell renders a configured logo + default theme, falls back to the brand mark", async () => {

View File

@@ -5,6 +5,9 @@
sortable headers and pager are real GET links — q/sort/page round-trip the URL, zero-JS. 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 }); 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 filters = include("partials/filter-bar", model.filterBar);
const table = include("partials/data-table", model.table); const table = include("partials/data-table", model.table);
const pager = include("partials/pagination", model.pagination); const pager = include("partials/pagination", model.pagination);
@@ -14,7 +17,7 @@
-%> -%>
<%- include("partials/shell", { <%- include("partials/shell", {
actions, actions,
body: filters + table + pager, body: note + filters + table + pager,
brand: model.shell.brand, brand: model.shell.brand,
breadcrumbs: model.shell.breadcrumbs, breadcrumbs: model.shell.breadcrumbs,
csrfToken: model.shell.csrfToken, csrfToken: model.shell.csrfToken,

View File

@@ -4,7 +4,8 @@
`actions` (topbar buttons), `body` (page content); `styles` is an optional array of `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` 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 ({ 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. Branding comes from config/menu.ts; `user`/`csrfToken` from §4 auth.
%><% %><%
const title = locals.title || "Plainpages"; const title = locals.title || "Plainpages";
@@ -66,8 +67,9 @@
</div> </div>
</details> </details>
<% } else { %> <% } else { %>
<%# anonymous (a public page in the shell, §10): no session to end — offer a way in instead %> <%# 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> 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", { <%- include("menu", {