§10 gate the dashboard + make "/" replaceable by a plugin (todo §10); "/" is now gated to a signed-in session (anonymous → /login via loginRedirect, query preserved as return_to) and fully replaceable via a new optional home?: RouteHandler on PluginManifest — a handler with the same signature as any route (the most ergonomic shape). The app.ts "/" branch gates first, then renders the single home plugin's handler against its own views/ with the native shell via ctx.chrome (HEAD / void-return / response-hook parity with a plugin route), else the built-in mock-data People list. home mounts at the root above the /<id> namespace, so it can't shadow or be shadowed by a built-in route. Single-slot + loud: findConflicts errors on >1 home (new "home" kind), discovery rejects a non-function home — never last-write-wins. Tests-first (338 → 344 units): app.test.ts gate + home-override; plugin.test.ts home conflict; discovery.test.ts home validation. Docs: plugin-contract.md (manifest table + "The dashboard (home)" section + conflict row), README. E2E: visual.spec plants a dev-signed session (the anonymous plugin-gate probe uses the cookie-free request fixture); all e2e web/gateway healthchecks repointed from the gated "/" to /public/css/styles.css. stability-reviewer: APPROVE, no Critical/High/Medium. typecheck + 344 units + visual(9) + full-flow(7) E2E green.
This commit is contained in:
19
src/app.ts
19
src/app.ts
@@ -29,7 +29,7 @@ import { clearSessionCookie, completeLogin, remintSession, sessionCookie } from
|
||||
import { resolveLoginChallenge } from "./oauth-login.ts";
|
||||
import { acceptConsent, rejectConsent, resolveConsentChallenge } from "./oauth-consent.ts";
|
||||
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
|
||||
import type { Plugin, RouteResult } from "./plugin.ts";
|
||||
import type { Plugin, RouteHandler, RouteResult } from "./plugin.ts";
|
||||
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
|
||||
import { securityHeaders } from "./security-headers.ts";
|
||||
import { localPath } from "./safe-url.ts";
|
||||
@@ -79,6 +79,9 @@ 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.
|
||||
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "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);
|
||||
@@ -430,9 +433,21 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
}
|
||||
|
||||
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
|
||||
// Roles from the verified JWT (anonymous ⇒ []); branding/override come from config/menu.ts.
|
||||
// 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 "/").
|
||||
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));
|
||||
return;
|
||||
}
|
||||
// Roles from the verified JWT; branding/override come from config/menu.ts.
|
||||
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, ctx.roles, menu, csrf.token, user, plugins) }));
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user