§10 split landing into a public "/" + gated "/dashboard", both plugin-replaceable (todo §10 follow-up); per human feedback, "/" is now an ungated public landing (default views/home.ejs: brand + intro + prominent Log in / Create account links, or "go to dashboard" when signed in) and "/dashboard" is the gated post-login app home (anonymous → /login?return_to=/dashboard). Both are fully replaceable via two optional RouteHandlers on PluginManifest — home? (public /) and dashboard? (gated /dashboard) — rendered against the plugin's own views with the native shell via ctx.chrome (full route parity: HEAD, void-return, response hooks, fresh CSRF cookie; a home handler is public so ctx.user may be null). Single-slot + loud: findConflicts errors on >1 owner of either slot (new "home"/"dashboard" kinds), discovery rejects a non-function handler, and "dashboard" is reserved so a plugin folder can't shadow it ("/" can't be shadowed — route paths carry the /<id> prefix). Post-login + already-signed-in redirects and the global Dashboard/People nav hrefs moved to /dashboard. Tests-first (348 units): public-/ + gated-/dashboard + dual plugin-override in app.test; per-slot conflict in plugin.test; non-function/reserved/two-owners in discovery.test. Docs: plugin-contract "The landing pages" section + README. E2E: visual.spec plants a session for /dashboard design-system tests + a cookie-free public-landing test; full-flow repointed to /dashboard. stability-reviewer: APPROVE, no Critical/High/Medium. typecheck + 348 units + visual(10) + full-flow(7) green.
This commit is contained in:
@@ -43,7 +43,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: "/", icon: "i-grid", id: "dashboard", label: "Dashboard" },
|
||||
{ href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" },
|
||||
adminSection(current),
|
||||
]], menu.override, roles);
|
||||
}
|
||||
|
||||
105
src/app.test.ts
105
src/app.test.ts
@@ -47,9 +47,9 @@ before(async () => {
|
||||
|
||||
after(() => server.close());
|
||||
|
||||
test("serves the home page: the app-shell People dashboard, filterable via the URL", async () => {
|
||||
test("the dashboard at /dashboard: the app-shell People list, gated to a session, filterable via the URL", async () => {
|
||||
// The dashboard is gated to a signed-in user (§10), so present a session.
|
||||
const res = await fetch(base + "/", { headers: { cookie: session() } });
|
||||
const res = await fetch(base + "/dashboard", { headers: { cookie: session() } });
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
||||
const html = await res.text();
|
||||
@@ -63,33 +63,44 @@ test("serves the home page: the app-shell People dashboard, filterable via the U
|
||||
|
||||
// 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];
|
||||
assert.ok(csrfCookie, "GET / issues a CSRF cookie");
|
||||
assert.ok(csrfCookie, "GET /dashboard issues a CSRF cookie");
|
||||
assert.match(res.headers.get("set-cookie") ?? "", /plainpages_csrf=[^;]+;.*HttpOnly/);
|
||||
assert.match(html, /<form class="menu-item-form" method="post" action="\/logout">/);
|
||||
assert.match(html, new RegExp(`name="_csrf" value="${csrfCookie!.replace(/[.]/g, "\\.")}"`));
|
||||
|
||||
// A search query filters server-side: a no-match query drops every row.
|
||||
const empty = await fetch(base + "/?q=zzz-no-such-person", { headers: { cookie: session() } });
|
||||
const empty = await fetch(base + "/dashboard?q=zzz-no-such-person", { headers: { cookie: session() } });
|
||||
assert.doesNotMatch(await empty.text(), /Avery Kline/);
|
||||
});
|
||||
|
||||
test("the dashboard is gated (§10): an anonymous visitor is bounced to sign in, not shown the page", async () => {
|
||||
test("/ is the public landing (§10): anonymous → 200 with intro + sign-in/register links, no gate", async () => {
|
||||
const res = await fetch(base + "/", { redirect: "manual" });
|
||||
assert.equal(res.status, 303);
|
||||
assert.equal(res.headers.get("location"), "/login");
|
||||
assert.equal(res.status, 200); // public — no redirect to sign in
|
||||
const html = await res.text();
|
||||
assert.match(html, /href="\/login"/); // a prominent path to sign in
|
||||
assert.match(html, /href="\/registration"/); // and to register
|
||||
assert.doesNotMatch(html, /<aside class="sidebar"/); // standalone page, not the signed-in app shell
|
||||
});
|
||||
|
||||
test("a `home` plugin fully replaces the dashboard, rendered in the native shell from ctx.chrome; still gated (§10)", async (t) => {
|
||||
test("/dashboard is gated (§10): an anonymous visitor is bounced to sign in (return_to kept)", async () => {
|
||||
const res = await fetch(base + "/dashboard", { redirect: "manual" });
|
||||
assert.equal(res.status, 303);
|
||||
assert.equal(res.headers.get("location"), "/login?return_to=%2Fdashboard");
|
||||
});
|
||||
|
||||
test("plugins replace either landing (§10): `home` owns the public /, `dashboard` owns the gated /dashboard", async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-home-"));
|
||||
mkdirSync(join(dir, "portal", "views"), { recursive: true });
|
||||
// The home view renders the native app shell from ctx.chrome — the blessed plugin ergonomics:
|
||||
writeFileSync(join(dir, "portal", "views", "welcome.ejs"), `<h1>Welcome to <%= brand %></h1><a href="/login">Sign in</a>`);
|
||||
// The dashboard view renders the native app shell from ctx.chrome — the blessed plugin ergonomics:
|
||||
// its own title/body, the global menu (chrome.nav), the signed-in user, the Sign-out CSRF token.
|
||||
writeFileSync(join(dir, "portal", "views", "home.ejs"),
|
||||
`<%- include("partials/shell", { body: "<p>Welcome " + user.email + "</p>", brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), theme: chrome.theme, title: "My Portal", user: chrome.user }) %>`);
|
||||
writeFileSync(join(dir, "portal", "views", "board.ejs"),
|
||||
`<%- include("partials/shell", { body: "<p>Hi " + user.email + "</p>", brand: chrome.brand, csrfToken: chrome.csrfToken, nav: include("partials/nav-tree", { nodes: chrome.nav }), theme: chrome.theme, title: "My Portal", user: chrome.user }) %>`);
|
||||
t.after(() => rmSync(dir, { force: true, recursive: true }));
|
||||
const portal: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
home: (ctx) => ({ data: { chrome: ctx.chrome, user: ctx.user }, view: "home" }),
|
||||
dashboard: (ctx) => ({ data: { chrome: ctx.chrome, user: ctx.user }, view: "board" }),
|
||||
home: () => ({ data: { brand: "Acme" }, view: "welcome" }),
|
||||
id: "portal",
|
||||
};
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [portal], pluginsDir: dir });
|
||||
@@ -97,16 +108,18 @@ test("a `home` plugin fully replaces the dashboard, rendered in the native shell
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
|
||||
// Gate still applies — the home plugin doesn't open the page up.
|
||||
assert.equal((await fetch(url + "/", { redirect: "manual" })).status, 303);
|
||||
// `home` replaces the public landing — still ungated (anonymous sees it).
|
||||
const pub = await fetch(url + "/", { redirect: "manual" });
|
||||
assert.equal(pub.status, 200);
|
||||
assert.match(await pub.text(), /Welcome to Acme/);
|
||||
|
||||
// Signed in: the plugin's dashboard renders, fully replacing the built-in People list.
|
||||
const page = await fetch(url + "/", { headers: { cookie: session() } });
|
||||
assert.equal(page.status, 200);
|
||||
const html = await page.text();
|
||||
assert.match(html, /<h1 class="page-title">My Portal<\/h1>/); // the plugin's own title in the native shell
|
||||
assert.match(html, /Welcome a@b\.c/); // its handler rendered, with ctx.user
|
||||
assert.match(html, /<aside class="sidebar"/); // composed chrome (global nav) is available
|
||||
// `dashboard` replaces the gated dashboard — anonymous bounces, a session lands on the plugin's page.
|
||||
assert.equal((await fetch(url + "/dashboard", { redirect: "manual" })).status, 303);
|
||||
const board = await fetch(url + "/dashboard", { headers: { cookie: session() } });
|
||||
assert.equal(board.status, 200);
|
||||
const html = await board.text();
|
||||
assert.match(html, /<h1 class="page-title">My Portal<\/h1>/); // its own title in the native shell
|
||||
assert.match(html, /Hi a@b\.c/); // its handler rendered, with ctx.user
|
||||
assert.doesNotMatch(html, /Avery Kline/); // the built-in mock People list is gone — fully replaced
|
||||
});
|
||||
|
||||
@@ -114,7 +127,7 @@ test("renders branding from the menu config into the shell: logo + default theme
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), menu: { branding: { logo: "/public/brand/logo.svg", name: "Acme Ops", theme: "dark" }, override: {} } });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const html = await (await fetch(`http://localhost:${(app.address() as AddressInfo).port}/`, { headers: { cookie: session() } })).text();
|
||||
const html = await (await fetch(`http://localhost:${(app.address() as AddressInfo).port}/dashboard`, { headers: { cookie: session() } })).text();
|
||||
|
||||
assert.match(html, /<img class="brand-logo" src="\/public\/brand\/logo\.svg"/);
|
||||
assert.match(html, /Acme Ops/);
|
||||
@@ -123,10 +136,10 @@ test("renders branding from the menu config into the shell: logo + default theme
|
||||
|
||||
test("emits a structured access-log line per request (the injected §9 logger)", async (t) => {
|
||||
const lines: string[] = [];
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), log: createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (m) => lines.push(m) }) });
|
||||
const app = createApp({ log: createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (m) => lines.push(m) }) });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/?q=zz`, { headers: { cookie: session() } });
|
||||
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/?q=zz`); // the public "/" — no auth
|
||||
assert.equal(res.status, 200);
|
||||
await res.text(); // consume the body so the connection closes (the access line emits on close)
|
||||
|
||||
@@ -248,10 +261,10 @@ test("static serving: GET sends body + content-type, HEAD headers only, unsafe p
|
||||
});
|
||||
|
||||
test("every response carries the security headers; HSTS follows SECURE_COOKIES (§9)", async (t) => {
|
||||
// Default app (secureCookies off): a page and a static asset both carry the hardening headers,
|
||||
// proving they're set once up front and survive each writeHead (the html + static paths merge).
|
||||
// Default app (secureCookies off): a page (the public "/") and a static asset both carry the
|
||||
// hardening headers, proving they're set once up front and survive each writeHead (paths merge).
|
||||
for (const path of ["/", "/public/css/styles.css"]) {
|
||||
const res = await fetch(base + path, { headers: { cookie: session() } });
|
||||
const res = await fetch(base + path);
|
||||
assert.equal(res.headers.get("x-content-type-options"), "nosniff", path);
|
||||
assert.equal(res.headers.get("x-frame-options"), "DENY", path);
|
||||
assert.match(res.headers.get("content-security-policy") ?? "", /default-src 'self'/, path);
|
||||
@@ -259,21 +272,21 @@ test("every response carries the security headers; HSTS follows SECURE_COOKIES (
|
||||
}
|
||||
|
||||
// A https deployment (SECURE_COOKIES=true) adds HSTS.
|
||||
const secure = createApp({ jwks: staticJwks([ecJwk]), secureCookies: true });
|
||||
const secure = createApp({ secureCookies: true });
|
||||
await new Promise<void>((r) => secure.listen(0, r));
|
||||
t.after(() => secure.close());
|
||||
const res = await fetch(`http://localhost:${(secure.address() as AddressInfo).port}/`, { headers: { cookie: session() } });
|
||||
const res = await fetch(`http://localhost:${(secure.address() as AddressInfo).port}/`);
|
||||
assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/);
|
||||
});
|
||||
|
||||
// Production caches compiled templates; rendering must stay correct across repeated requests.
|
||||
test("renders correctly with template caching enabled", async () => {
|
||||
const app = createApp({ cache: true, jwks: staticJwks([ecJwk]) });
|
||||
const app = createApp({ cache: true });
|
||||
try {
|
||||
await new Promise<void>((resolve) => app.listen(0, resolve));
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}/`;
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}/`; // the public landing
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const res = await fetch(url, { headers: { cookie: session() } });
|
||||
const res = await fetch(url);
|
||||
assert.equal(res.status, 200);
|
||||
assert.match(await res.text(), /Plainpages/);
|
||||
}
|
||||
@@ -291,13 +304,13 @@ test("returns the 404 HTML page for unknown routes", async () => {
|
||||
|
||||
test("renders the 500 HTML page when a handler throws", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-views-"));
|
||||
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>");
|
||||
writeFileSync(join(dir, "index.ejs"), "<% throw new Error('boom'); %>"); // the dashboard view
|
||||
cpSync(join(viewsDir, "500.ejs"), join(dir, "500.ejs"));
|
||||
const app = createApp({ jwks: staticJwks([ecJwk]), viewsDir: dir });
|
||||
try {
|
||||
await new Promise<void>((resolve) => app.listen(0, resolve));
|
||||
// A session reaches the (throwing) index render; the gate would otherwise bounce anonymous to /login.
|
||||
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/`, { headers: { cookie: session() } });
|
||||
// A session reaches the (throwing) dashboard render; the gate would otherwise bounce to /login.
|
||||
const res = await fetch(`http://localhost:${(app.address() as AddressInfo).port}/dashboard`, { headers: { cookie: session() } });
|
||||
assert.equal(res.status, 500);
|
||||
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
|
||||
assert.match(await res.text(), /500/);
|
||||
@@ -453,12 +466,12 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
|
||||
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 303);
|
||||
|
||||
// The dashboard wires in the permission-gated Admin section: an admin's roles surface the links;
|
||||
// anonymous is bounced to sign in before any page renders (§10 gate).
|
||||
const admin = await fetch(url + "/", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
|
||||
// anonymous is bounced to sign in before any page renders (§10 gate on /dashboard).
|
||||
const admin = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
|
||||
assert.match(await admin.text(), /href="\/admin\/users"/);
|
||||
const anonHome = await fetch(url + "/", { redirect: "manual" });
|
||||
assert.equal(anonHome.status, 303);
|
||||
assert.equal(anonHome.headers.get("location"), "/login");
|
||||
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
|
||||
assert.equal(anonDash.status, 303);
|
||||
assert.equal(anonDash.headers.get("location"), "/login?return_to=%2Fdashboard");
|
||||
});
|
||||
|
||||
test("revocation denylist (§9): a revoked subject's token stops authorizing on the hot path; a fresh re-login passes", async (t) => {
|
||||
@@ -629,12 +642,12 @@ test("themed auth GET: anonymous inits a flow (CSRF relay, stale→restart); a s
|
||||
assert.equal(stale.status, 303);
|
||||
assert.equal(stale.headers.get("location"), "/login");
|
||||
|
||||
// Already signed in → /login + /registration short-circuit home; /settings stays reachable (inits its flow).
|
||||
// Already signed in → /login + /registration short-circuit to the app dashboard; /settings stays reachable.
|
||||
const signedIn = { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: Math.floor(Date.now() / 1000) + 600, roles: [], sub: "u1" })}` }, redirect: "manual" as const };
|
||||
for (const path of ["/login", "/registration"]) {
|
||||
const res = await fetch(url + path, signedIn);
|
||||
assert.equal(res.status, 303, `${path} while signed in → 303`);
|
||||
assert.equal(res.headers.get("location"), "/");
|
||||
assert.equal(res.headers.get("location"), "/dashboard");
|
||||
}
|
||||
assert.equal((await fetch(url + "/settings", signedIn)).headers.get("location"), "/settings?flow=new1");
|
||||
});
|
||||
@@ -779,17 +792,17 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
|
||||
return fetch(`http://localhost:${(app.address() as AddressInfo).port}/auth/complete${q}`, { headers: cookie ? { cookie } : {}, redirect: "manual" });
|
||||
};
|
||||
|
||||
// Live Kratos session: roles from Keto → projection → tokenize → JWT cookie, land on /.
|
||||
// Live Kratos session: roles from Keto → projection → tokenize → JWT cookie, land on the dashboard.
|
||||
const ok = await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s");
|
||||
assert.equal(ok.status, 303);
|
||||
assert.equal(ok.headers.get("location"), "/");
|
||||
assert.equal(ok.headers.get("location"), "/dashboard");
|
||||
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
|
||||
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles projected onto the identity for the tokenizer
|
||||
|
||||
// return_to (§9): a safe host-relative target lands the user back where they were headed; an
|
||||
// off-origin one is ignored (open-redirect guard) and falls back to /.
|
||||
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
|
||||
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "/admin/users?q=1")).headers.get("location"), "/admin/users?q=1");
|
||||
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "//evil.com")).headers.get("location"), "/");
|
||||
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "//evil.com")).headers.get("location"), "/dashboard");
|
||||
|
||||
// No Kratos session: nothing minted, bounce to /login with no cookie.
|
||||
const none = await complete(createApp({ keto: fakeKeto(), kratos: withWhoami(async () => null), kratosAdmin: stubAdmin({}) }));
|
||||
|
||||
47
src/app.ts
47
src/app.ts
@@ -79,9 +79,11 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
const menu = options.menu ?? DEFAULT_MENU;
|
||||
const plugins = options.plugins ?? [];
|
||||
const pluginIds = new Set(plugins.map((p) => p.id));
|
||||
// A plugin may fully replace the dashboard "/" by declaring `home` (§10). Discovery's findConflicts
|
||||
// guarantees at most one, so `find` is unambiguous; the predicate narrows `home` to defined.
|
||||
// A plugin may fully replace the public landing "/" (`home`) or the gated dashboard "/dashboard"
|
||||
// (`dashboard`) — §10. Discovery's findConflicts guarantees at most one of each, so `find` is
|
||||
// unambiguous; the predicates narrow the slot to defined.
|
||||
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
|
||||
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
|
||||
// Skip the hook pipeline entirely unless a plugin declares the hook (keeps the hot path free).
|
||||
const anyRequestHooks = plugins.some((p) => p.hooks?.onRequest);
|
||||
const anyResponseHooks = plugins.some((p) => p.hooks?.onResponse);
|
||||
@@ -245,10 +247,10 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// Themed Kratos self-service pages (login/registration/recovery/verification/settings).
|
||||
const flowType = AUTH_FLOWS[pathname];
|
||||
if (kratos && flowType && (method === "GET" || method === "HEAD")) {
|
||||
// Already signed in? Re-authenticating / re-registering is pointless — send them home.
|
||||
// (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
|
||||
// Already signed in? Re-authenticating / re-registering is pointless — send them to the app
|
||||
// dashboard. (/settings, /recovery, /verification stay reachable — a signed-in user can use those.)
|
||||
if (ctx.user && (pathname === "/login" || pathname === "/registration")) {
|
||||
res.writeHead(303, { location: "/" }).end();
|
||||
res.writeHead(303, { location: "/dashboard" }).end();
|
||||
return;
|
||||
}
|
||||
const cookie = req.headers.cookie;
|
||||
@@ -409,8 +411,8 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
}
|
||||
res.appendHeader("set-cookie", sessionCookie(completed.jwt, { secure: secureCookies }));
|
||||
// Land on the deep link the user was headed to (return_to, validated host-relative so a
|
||||
// crafted ?return_to= can't make this an open redirect), else home (§9).
|
||||
res.writeHead(303, { location: localPath(ctx.url.searchParams.get("return_to")) ?? "/" }).end();
|
||||
// crafted ?return_to= can't make this an open redirect), else the gated dashboard (§9/§10).
|
||||
res.writeHead(303, { location: localPath(ctx.url.searchParams.get("return_to")) ?? "/dashboard" }).end();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -433,18 +435,35 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
}
|
||||
|
||||
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
|
||||
// The dashboard is the post-login landing page, gated to a signed-in user (§10): anonymous
|
||||
// bounces to sign in (loginRedirect yields a bare /login for "/").
|
||||
// The public landing (§10): ungated — anyone may see it. A plugin may fully own it via `home`
|
||||
// (rendered against its own views, native shell via ctx.chrome, with a fresh CSRF cookie for
|
||||
// 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 result = (await homePlugin.home(homeCtx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, homeCtx, result);
|
||||
await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data));
|
||||
return;
|
||||
}
|
||||
// Default landing — no form, so no CSRF cookie. `user` lets it show "go to dashboard" vs sign in.
|
||||
sendHtml(res, 200, await render("home", { brand: menu.branding.name, user }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === "/dashboard" && (method === "GET" || method === "HEAD")) {
|
||||
// The post-login app home, gated to a signed-in user (§10): anonymous bounces to sign in,
|
||||
// remembering /dashboard as return_to.
|
||||
if (!user) { res.writeHead(303, { location: loginRedirect(ctx) }).end(); return; }
|
||||
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
||||
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
|
||||
// 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 (homePlugin) {
|
||||
const homeCtx = buildContext(req, res, { chrome: 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));
|
||||
if (dashboardPlugin) {
|
||||
const dashCtx = buildContext(req, res, { chrome: 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));
|
||||
return;
|
||||
}
|
||||
// Roles from the verified JWT; branding/override come from config/menu.ts.
|
||||
|
||||
@@ -19,7 +19,7 @@ export interface PageChrome {
|
||||
user: ShellUser;
|
||||
}
|
||||
|
||||
const HOME: NavNode = { href: "/", icon: "i-grid", id: "dashboard", label: "Dashboard" };
|
||||
const HOME: NavNode = { href: "/dashboard", icon: "i-grid", id: "dashboard", label: "Dashboard" };
|
||||
|
||||
export interface ChromeOptions {
|
||||
csrfToken?: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Dashboard view model (todo §1): the home "/" app-shell "People" list. Pure — turns a request
|
||||
// URL into the data the building-block partials render, wiring the §1 helpers end-to-end:
|
||||
// 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).
|
||||
|
||||
@@ -127,7 +127,7 @@ export type DashboardModel = ReturnType<typeof buildDashboardModel>;
|
||||
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([[
|
||||
{ count: PEOPLE.length, current: true, href: "/", icon: "i-users", id: "people", label: "People" },
|
||||
{ count: PEOPLE.length, current: true, href: "/dashboard", icon: "i-users", id: "people", label: "People" },
|
||||
{ href: "#teams", icon: "i-grid", id: "teams", label: "Teams" },
|
||||
{ children: [
|
||||
{ href: "#activity", id: "activity", label: "Activity" },
|
||||
|
||||
@@ -48,8 +48,11 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
|
||||
{ name: "incompatible apiVersion", files: { "future/plugin.ts": `export default { apiVersion: "2.0.0" };` }, match: /future.*apiVersion/s },
|
||||
{ name: "non-array routes", files: { "weird/plugin.ts": `export default { apiVersion: "1.0.0", routes: "nope" };` }, match: /weird.*routes.*array/s },
|
||||
{ name: "non-function home", files: { "weirdhome/plugin.ts": `export default { apiVersion: "1.0.0", home: "nope" };` }, match: /weirdhome.*home.*function/s },
|
||||
{ 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: "two plugins claim the dashboard 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 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/ },
|
||||
];
|
||||
|
||||
for (const c of badCases) {
|
||||
@@ -58,11 +61,12 @@ for (const c of badCases) {
|
||||
});
|
||||
}
|
||||
|
||||
test("a plugin may declare `home` (a function) to own the dashboard (§10)", async (t) => {
|
||||
const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }) };` });
|
||||
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers (§10)", async (t) => {
|
||||
const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` });
|
||||
const plugins = await discoverPlugins({ dir });
|
||||
assert.equal(plugins.length, 1);
|
||||
assert.equal(typeof plugins[0]?.home, "function");
|
||||
assert.equal(typeof plugins[0]?.dashboard, "function");
|
||||
});
|
||||
|
||||
test("a shared permission token only warns — both plugins still load", async (t) => {
|
||||
|
||||
@@ -88,8 +88,11 @@ function shapeError(manifest: PluginManifest): string | null {
|
||||
for (const field of ["nav", "permissions", "routes"] as const) {
|
||||
if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`;
|
||||
}
|
||||
// `home` (the §10 dashboard override) is a route handler; the host calls it, so a non-function fails loud.
|
||||
if (manifest.home !== undefined && typeof manifest.home !== "function") return `"home" must be a function (a route handler)`;
|
||||
// `home` / `dashboard` (the §10 landing-page overrides) are route handlers; the host calls them, so
|
||||
// a non-function fails loud.
|
||||
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)`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -100,10 +100,12 @@ test("findConflicts: duplicate nav id is an error, a shared permission token onl
|
||||
assert.ok(permDup.some((c) => c.kind === "permission" && c.level === "warn"));
|
||||
});
|
||||
|
||||
test("findConflicts: only one plugin may claim the dashboard (`home`) — two is a loud error (§10)", () => {
|
||||
const home = () => ({ html: "dash" });
|
||||
const dup = findConflicts([p({ id: "a", home }), p({ id: "b", home })]);
|
||||
assert.ok(dup.some((c) => c.kind === "home" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||
// One home (or none) is fine.
|
||||
assert.deepEqual(findConflicts([p({ id: "a", home }), p({ id: "b" })]).filter((c) => c.kind === "home"), []);
|
||||
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error (§10)", () => {
|
||||
const handler = () => ({ html: "x" });
|
||||
const homeDup = findConflicts([p({ id: "a", home: handler }), p({ id: "b", home: handler })]);
|
||||
assert.ok(homeDup.some((c) => c.kind === "home" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||
const dashDup = findConflicts([p({ id: "a", dashboard: handler }), p({ id: "b", dashboard: handler })]);
|
||||
assert.ok(dashDup.some((c) => c.kind === "dashboard" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
|
||||
// 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"), []);
|
||||
});
|
||||
|
||||
@@ -50,9 +50,12 @@ export interface PluginHooks {
|
||||
// host derives them from the folder name at discovery (see Plugin).
|
||||
export interface PluginManifest {
|
||||
apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs)
|
||||
// Take over the dashboard "/" — the post-login landing page (§10). A handler like any route's,
|
||||
// gated by the host to a signed-in session (anonymous → /login); render its own view via ctx.chrome.
|
||||
// At most one plugin may declare it (findConflicts → error, never last-write-wins).
|
||||
// Take over the gated dashboard "/dashboard" — the post-login app home (§10). A handler like any
|
||||
// route's; the host gates it to a signed-in session (anonymous → /login), then renders its own view
|
||||
// via ctx.chrome. At most one plugin may declare it (findConflicts → error, never last-write-wins).
|
||||
dashboard?: RouteHandler;
|
||||
// Take over the public landing "/" — the ungated front page (§10). A handler like any route's,
|
||||
// anyone may reach it. At most one plugin may declare it (findConflicts → error).
|
||||
home?: RouteHandler;
|
||||
hooks?: PluginHooks;
|
||||
nav?: NavNode[]; // fragment merged into the menu (composeNav); node `icon` is a Lucide sprite id (src/icons.ts), node ids must be globally unique
|
||||
@@ -81,12 +84,13 @@ export function isValidPluginId(id: string): boolean {
|
||||
return PLUGIN_ID.test(id);
|
||||
}
|
||||
|
||||
// Ids the host reserves for its own first-party mount segments (the auth flows, /auth/complete,
|
||||
// /logout, the /admin screens, the /oauth2 provider routes, the dashboard's /public/ static).
|
||||
// Ids the host reserves for its own first-party mount segments (the gated /dashboard, the auth flows,
|
||||
// /auth/complete, /logout, the /admin screens, the /oauth2 provider routes, the /public/ static).
|
||||
// Plugin routes resolve before these, so a folder named one of them would silently shadow a
|
||||
// built-in route — discovery refuses it, loud like any conflict.
|
||||
// built-in route — discovery refuses it, loud like any conflict. ("/" is owned by the `home` field,
|
||||
// not a route, so it can't be shadowed and needs no reservation.)
|
||||
export const RESERVED_PLUGIN_IDS: ReadonlySet<string> = new Set([
|
||||
"admin", "auth", "login", "logout", "oauth2", "public", "recovery", "registration", "settings", "verification",
|
||||
"admin", "auth", "dashboard", "login", "logout", "oauth2", "public", "recovery", "registration", "settings", "verification",
|
||||
]);
|
||||
|
||||
export interface Semver {
|
||||
@@ -138,7 +142,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
|
||||
}
|
||||
|
||||
export interface PluginConflict {
|
||||
kind: "home" | "id" | "nav-id" | "permission" | "route";
|
||||
kind: "dashboard" | "home" | "id" | "nav-id" | "permission" | "route";
|
||||
level: "error" | "warn";
|
||||
message: string;
|
||||
plugins: string[]; // unique ids involved
|
||||
@@ -157,9 +161,12 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
|
||||
if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] });
|
||||
}
|
||||
|
||||
// The dashboard "/" is a single slot (§10): two plugins claiming `home` is a loud error, not a race.
|
||||
const homeOwners = plugins.filter((plugin) => plugin.home).map((plugin) => plugin.id);
|
||||
if (homeOwners.length > 1) out.push({ kind: "home", level: "error", message: `${homeOwners.length} plugins claim the dashboard "home" (${homeOwners.join(", ")}); only one may`, plugins: uniq(homeOwners) });
|
||||
// The landing pages are single slots (§10): "/" (home) and "/dashboard" (dashboard) take one owner
|
||||
// each — two plugins claiming either is a loud error, not a race.
|
||||
for (const slot of ["home", "dashboard"] as const) {
|
||||
const owners = plugins.filter((plugin) => plugin[slot]).map((plugin) => plugin.id);
|
||||
if (owners.length > 1) out.push({ kind: slot, level: "error", message: `${owners.length} plugins claim "${slot}" (${owners.join(", ")}); only one may own that page`, plugins: uniq(owners) });
|
||||
}
|
||||
|
||||
collect(plugins, (plugin, push) => {
|
||||
for (const route of plugin.routes ?? []) push(`${route.method} ${fullPath(plugin.id, route.path)}`);
|
||||
|
||||
Reference in New Issue
Block a user