§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:
2026-06-20 17:43:01 +02:00
parent 2eb5b84ccf
commit 7787ed4ea4
16 changed files with 262 additions and 134 deletions

View File

@@ -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.