§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:
2026-06-20 17:18:30 +02:00
parent df53106a5a
commit 2eb5b84ccf
14 changed files with 192 additions and 41 deletions

View File

@@ -50,6 +50,10 @@ 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).
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
permissions?: PermissionDecl[];
@@ -134,7 +138,7 @@ export function checkApiVersion(pluginVersion: unknown, hostVersion: string = HO
}
export interface PluginConflict {
kind: "id" | "nav-id" | "permission" | "route";
kind: "home" | "id" | "nav-id" | "permission" | "route";
level: "error" | "warn";
message: string;
plugins: string[]; // unique ids involved
@@ -153,6 +157,10 @@ 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) });
collect(plugins, (plugin, push) => {
for (const route of plugin.routes ?? []) push(`${route.method} ${fullPath(plugin.id, route.path)}`);
}).forEach((owners, key) => {