§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

@@ -63,8 +63,9 @@ The bar for a first usable release: **clone, run one command, get a working
register/login, and start building your own plugin** — no manual key generation, no
hand-edited Ory config, no separate database. That command brings up the whole stack
(web + Ory + Postgres), generates signing keys, seeds an admin on first boot, and drops
you at a login screen; from there you copy the example plugin folder and write your own
page. SSO and the OAuth2-provider role (Hydra) come after — not required to start.
you at a public landing page with a one-click path to sign in (the gated dashboard lives at
`/dashboard`); from there you copy the example plugin folder and write your own page. SSO and
the OAuth2-provider role (Hydra) come after — not required to start.
## Architecture
@@ -361,9 +362,11 @@ hooks, and the dev/test story — is **[docs/plugin-contract.md](docs/plugin-con
a CSRF-guarded form forwarding writes upstream, and permission-gated nav. Copy it and
adapt. The sketch below is the shape.
The landing page `/` (the **dashboard**) is gated to a signed-in session; a plugin can **fully
replace** the built-in default by exporting a `home` handler in its manifest (one plugin may own it).
See the contract's [dashboard section](docs/plugin-contract.md#the-dashboard-home).
There are two replaceable landing slots: `/` is a **public** front page (default: an intro with
sign-in / register links) and `/dashboard` is the **gated** post-login app home (default: the People
list). A plugin owns either by exporting a `home` (public `/`) or `dashboard` (gated `/dashboard`)
handler — one owner each. See the contract's
[landing pages section](docs/plugin-contract.md#the-landing-pages-home--dashboard).
```
plugins/scheduling/ # folder name = the plugin id; mounted at /scheduling
@@ -745,7 +748,7 @@ src/logger.ts createLogger()/requestLogger() + the ambient request log (r
src/body.ts readFormBody(): read + size-cap an x-www-form-urlencoded request body (CSRF gate + §5 forms)
src/context.ts RequestContext handed to handlers + buildContext()
src/config.ts Env loader — Ory endpoints, cookie/CSRF secrets, JWKS, port; validated at boot
src/dashboard.ts buildDashboardModel(): the built-in "/" People list view model (mock data, wires the §1 helpers); "/" is gated to a session and replaceable by a plugin `home` handler (§10)
src/dashboard.ts buildDashboardModel(): the built-in "/dashboard" People list view model (mock data, wires the §1 helpers); /dashboard is gated to a session, "/" is the public landing — both replaceable by a plugin `dashboard`/`home` handler (§10)
src/admin-users.ts Built-in Users admin screen (§5): list Kratos identities (filter/sort/paginate) + create/edit/deactivate/delete/recovery; gated + CSRF-guarded
src/admin-groups.ts Built-in Groups admin screen (§5): list Keto subject sets + create/delete + membership (add/remove users & nested groups); writes only to Keto, gated + CSRF-guarded
src/admin-roles.ts Built-in Roles admin screen (§5): list/create/delete Keto roles + assign to users/groups + "effective access" (Keto expand → transitive members); reuses the Groups membership helpers, writes only to Keto, gated + CSRF-guarded
@@ -765,7 +768,7 @@ src/guards.ts requireSession()/can()/check(): in-handler authorization (
src/hooks.ts runBootHooks()/runRequestHooks()/runResponseHooks(): invoke a plugin's optional lifecycle hooks in discovery order (§2); no sandbox (a throwing hook fails loud), skipped when no plugin declares one
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2)
src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2)
views/ Core EJS templates: index (app-shell dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent screen), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, flow + consent + admin bodies, menu/popover, theme switch, icon sprite)
views/ Core EJS templates: home (public "/" landing), index (app-shell dashboard at /dashboard), admin/ (Users/Groups/Roles/Clients lists + create/edit/detail + delete-confirm), auth (themed Kratos flows), oauth-consent (OAuth2 consent screen), 403/404/500/503 (503 = Ory-unreachable on sign-in), partials/ (shell, nav tree, filter bar, data table, pagination, field, auth card, alert, flow + consent + admin bodies, menu/popover, theme switch, icon sprite)
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
config/menu.ts Central menu override + branding (optional; defaults apply if absent)
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs → /oauth2/*) + storage init (postgres/init/init.sql: one DB per service)

View File

@@ -43,11 +43,11 @@ anywhere; no uppercase, underscores, dots, or slashes); the host rejects a malfo
at discovery. The id also namespaces the plugin's `views/`, its `/public/<id>/` assets, and (by
convention) its nav/permission tokens.
A handful of ids are **reserved** for the host's own first-party mounts — the Kratos auth flows
(`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`), the `admin`
screens, the `oauth2` provider routes, and `public` (static). Since plugin routes resolve first, a
folder claiming one would silently shadow a built-in route, so discovery refuses it loud
(`RESERVED_PLUGIN_IDS`).
A handful of ids are **reserved** for the host's own first-party mounts — the gated `dashboard`, the
Kratos auth flows (`auth`, `login`, `logout`, `recovery`, `registration`, `settings`, `verification`),
the `admin` screens, the `oauth2` provider routes, and `public` (static). Since plugin routes resolve
first, a folder claiming one would silently shadow a built-in route, so discovery refuses it loud
(`RESERVED_PLUGIN_IDS`). (`/` is owned by the `home` field, not a route, so it needs no reservation.)
Installing a plugin is "drop the folder, restart." Removing one is "delete the folder, restart."
Nothing else references it; the operator stays in control through the central menu override
@@ -98,7 +98,8 @@ there is **no `id` or `basePath`** in the manifest — both come from the folder
| Field | Required | Notes |
| --- | --- | --- |
| `apiVersion` | yes | Semver the plugin was built against — a **literal**, not `HOST_API_VERSION`. See [Versioning](#contract-versioning). |
| `home` | no | A `RouteHandler` that owns the dashboard `/` (the post-login landing page). At most one plugin may declare it. See [The dashboard](#the-dashboard-home). |
| `home` | no | A `RouteHandler` that owns the **public** landing `/`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `dashboard` | no | A `RouteHandler` that owns the **gated** app home `/dashboard`. At most one plugin may declare it. See [The landing pages](#the-landing-pages-home--dashboard). |
| `nav` | no | `NavNode[]` fragment (same shape `composeNav` consumes). `icon` is a Lucide sprite id (`src/icons.ts`); node `id`s must be globally unique. |
| `permissions` | no | Tokens this plugin introduces; declared for docs, conflict detection, and bootstrap seeding (see [Nav & permissions](#nav--permissions)). |
| `routes` | no | See [Routes & handlers](#routes--handlers). |
@@ -176,32 +177,43 @@ safety of the data it renders**:
return { view: "list", data: { rows: rows.map((r) => ({ ...r, href: safeUrl(r.href) })) } };
```
## The dashboard ("home")
## The landing pages (`home` & `dashboard`)
`/` is the **post-login landing page**. The host gates it to a **signed-in session** (an anonymous
visitor is redirected to `/login`) and, by default, renders a built-in mock-data dashboard. A plugin
**fully replaces** it by exporting a `home` handler:
The host has two replaceable landing slots, and a plugin may own either or both:
| Slot | Path | Gate | Default |
| --- | --- | --- | --- |
| `home` | `/` | **public** — anyone | An intro page with prominent sign-in / register links. |
| `dashboard` | `/dashboard` | **signed-in session** (anonymous → `/login`, with `/dashboard` as `return_to`) | The built-in mock-data People list. |
```ts
import { definePlugin } from "../../src/plugin-api.ts";
import { dashboard } from "./dashboard.ts";
import { landing, board } from "./pages.ts";
export default definePlugin({
apiVersion: "1.0.0",
home: dashboard, // owns "/" — the post-login landing page
home: landing, // owns "/" — the public front page
dashboard: board, // owns "/dashboard" — the post-login app home
});
```
`home` is a `RouteHandler` like any route's — it receives the [`RequestContext`](#requestcontext)
and returns a `RouteResult`, typically a `view` rendered from the plugin's own `views/` against the
native app shell (`ctx.chrome`), exactly as a route handler does. The host enforces the session gate
first, so `ctx.user` is non-null; branch on `ctx.roles` *inside* to tailor the page per role. Don't
gate `home` itself behind a single permission — there's no second dashboard to fall back to, so a
user lacking it would land on a 403 instead of a home page. (`GET /` also answers `HEAD`.)
Each is a `RouteHandler` like any route's — it receives the [`RequestContext`](#requestcontext) and
returns a `RouteResult`, typically a `view` from the plugin's own `views/`. A `dashboard` handler
renders against the native app shell via `ctx.chrome` exactly as a route handler does; a `home`
handler is a **public** page, so `ctx.user` may be `null` (use it to show a "go to dashboard" link to
a signed-in visitor, or sign-in / register to an anonymous one). After login the user lands on
`/dashboard` (or the `return_to` they were headed to), and the global menu's **Dashboard** link
points there.
Only **one** plugin may own the dashboard: two declaring `home` is a boot-stopping conflict
([below](#conflict-rules)), never last-write-wins. The plugin needs no `routes` entry for `/` — the
host mounts `home` at the root, above the `/<id>` route namespace.
For the gated `dashboard`, the host enforces the session gate first, so `ctx.user` is non-null;
branch on `ctx.roles` *inside* to tailor the page per role. Don't gate `dashboard` itself behind a
single permission — there's no second dashboard to fall back to, so a user lacking it would land on a
403. (Both slots answer `GET` and `HEAD`.)
Only **one** plugin may own each slot: two declaring `home` (or two declaring `dashboard`) is a
boot-stopping conflict ([below](#conflict-rules)), never last-write-wins. Neither needs a `routes`
entry — the host mounts them above the `/<id>` route namespace, and `/` can't be shadowed by a plugin
route at all (route paths always carry the `/<id>` prefix).
## RequestContext
@@ -306,7 +318,7 @@ with `findConflicts` and resolves them **loudly — never last-write-wins**. `er
| `id` | error | Two plugins share an `id` (folder name). Ids must be globally unique — they namespace the mount path, views/static, and the override target. |
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
| `home` | error | More than one plugin declares `home`. The dashboard `/` is a single slot, so only one may own it ([The dashboard](#the-dashboard-home)). |
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
| `permission` | warn | A permission token is declared by more than one plugin. Sharing is legitimate (shared role); namespace as `<id>:<action>` if unintended. |
There is **no separate `basePath` rule**: the mount path is the derived `/<id>`, so its

View File

@@ -39,7 +39,7 @@ test.describe.serial("authenticated admin journey", () => {
test("menu filters by role: an admin sees the gated Admin section + the plugin", async () => {
// The signed-in admin holds admin + scheduling:read/write, so both gated sections are present
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
await page.goto("/");
await page.goto("/dashboard");
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(1);
await expect(page.locator('.sidebar a[href="/scheduling/shifts"]')).toHaveCount(1);
});
@@ -92,12 +92,13 @@ test.describe.serial("authenticated admin journey", () => {
});
test("logout: signing out ends the session and returns to the login page", async () => {
await page.goto("/");
await page.goto("/dashboard");
await page.locator("summary.profile").click(); // open the profile dropdown
await page.locator('form[action="/logout"] button[type="submit"]').click();
await page.waitForURL(/\/login(\?|$)/);
// The session is gone: the dashboard no longer shows the admin nav.
await page.goto("/");
// The session is gone: /dashboard is gated, so it bounces back to the login page (no admin nav).
await page.goto("/dashboard");
await expect(page).toHaveURL(/\/login(\?|$)/);
await expect(page.locator('.sidebar a[href="/admin/users"]')).toHaveCount(0);
});
});

View File

@@ -35,20 +35,20 @@ test.beforeEach(async ({ context }) => {
});
test("captures live pages + reference mockups for side-by-side review", async ({ page }) => {
await page.goto("/");
await page.goto("/dashboard");
await expect(page.locator(".sidebar")).toBeVisible();
await expect(page.locator("table.table tbody tr").first()).toBeVisible();
await shot(page, "live-01-dashboard");
await page.goto("/?sort=-name&status=active");
await page.goto("/dashboard?sort=-name&status=active");
await shot(page, "live-02-sorted-filtered");
await page.goto("/");
await page.goto("/dashboard");
await page.locator("#theme-dark").check({ force: true }); // visually-hidden radio
await shot(page, "live-03-dark");
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
await page.goto("/dashboard");
await shot(page, "live-04-mobile");
await page.setViewportSize({ width: 1280, height: 800 });
@@ -68,7 +68,7 @@ const styleOf = (page: Page, selector: string): Promise<Record<string, string>>
}, PROPS as unknown as string[]);
test("live components compute the same design-system styles as the reference mockup", async ({ page, context }) => {
await page.goto("/");
await page.goto("/dashboard");
const ref = await context.newPage();
await ref.goto(APP_SHELL);
@@ -79,7 +79,7 @@ test("live components compute the same design-system styles as the reference moc
});
test("every icon <use> resolves to a defined <symbol> (no broken graphics)", async ({ page }) => {
await page.goto("/");
await page.goto("/dashboard");
const missing = await page.evaluate(() => {
const ids = new Set([...document.querySelectorAll("symbol[id]")].map((s) => s.id));
return [...document.querySelectorAll("use")]
@@ -90,14 +90,14 @@ test("every icon <use> resolves to a defined <symbol> (no broken graphics)", asy
});
test("sorting and search drive the list through the URL (zero-JS)", async ({ page }) => {
await page.goto("/");
await page.goto("/dashboard");
const total = await page.locator("tbody tr").count();
await page.getByRole("link", { name: /Name/ }).first().click();
await expect(page).toHaveURL(/sort=name/);
await expect(page.locator("thead th").filter({ hasText: "Name" })).toHaveAttribute("aria-sort", "ascending");
await page.goto("/");
await page.goto("/dashboard");
await page.locator('input[name="q"]').fill("Avery");
await page.getByRole("button", { name: /Apply filters/ }).click();
await expect(page).toHaveURL(/q=Avery/);
@@ -105,7 +105,7 @@ test("sorting and search drive the list through the URL (zero-JS)", async ({ pag
});
test("theme switch flips the palette with no JavaScript", async ({ page }) => {
await page.goto("/");
await page.goto("/dashboard");
const light = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
await page.locator("#theme-dark").check({ force: true });
const dark = await page.evaluate(() => getComputedStyle(document.body).backgroundColor);
@@ -114,7 +114,7 @@ test("theme switch flips the palette with no JavaScript", async ({ page }) => {
test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/");
await page.goto("/dashboard");
await expect(page.locator(".hamburger")).toBeVisible();
const offCanvas = await page.locator(".sidebar").evaluate((el) => {
@@ -125,10 +125,10 @@ test("mobile layout hides the sidebar off-canvas behind the hamburger", async ({
});
test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a tokenless POST is refused", async ({ page }) => {
await page.goto("/");
await page.goto("/dashboard");
// The page issues a CSRF cookie and embeds the same token in the Sign-out form (double-submit).
const cookie = (await page.context().cookies()).find((c) => c.name === "plainpages_csrf");
expect(cookie?.value, "GET / issues a plainpages_csrf cookie").toBeTruthy();
expect(cookie?.value, "GET /dashboard issues a plainpages_csrf cookie").toBeTruthy();
const field = await page.locator('form[action="/logout"] input[name="_csrf"]').getAttribute("value");
expect(field).toBe(cookie!.value);
@@ -137,6 +137,16 @@ test("Sign-out is a CSRF-guarded POST form: the token is issued on the page, a t
expect(res.status()).toBe(403);
});
test("the public landing at / is ungated and links to sign in + register (§10)", async ({ page, context }) => {
await context.clearCookies(); // visit "/" as a logged-out visitor (drop the beforeEach session)
await page.goto("/");
await expect(page.locator(".landing")).toBeVisible(); // the standalone landing, not the app shell
await expect(page.locator(".sidebar")).toHaveCount(0);
await expect(page.getByRole("link", { name: "Log in" })).toHaveAttribute("href", "/login");
await expect(page.getByRole("link", { name: "Create account" })).toHaveAttribute("href", "/registration");
await shot(page, "live-05-public-landing");
});
test("unknown routes serve the 404 page (a real user-facing flow, covered end-to-end)", async ({ page }) => {
const res = await page.goto("/no-such-page");
expect(res?.status()).toBe(404);
@@ -156,7 +166,7 @@ test("the reference plugin is permission-gated: anonymous → redirect to /login
expect(res.headers()["location"]).toBe("/login?return_to=%2Fscheduling%2Fshifts");
// The signed-in member (no scheduling role) sees the dashboard, but the gated leaf is filtered out.
await page.goto("/");
await page.goto("/dashboard");
await expect(page.locator(".sidebar")).toContainText("People"); // dashboard nav renders
await expect(page.locator(".sidebar")).not.toContainText("Scheduling"); // gated leaf filtered out
});

View File

@@ -23,6 +23,17 @@
}
.auth-brand .brand-name { font-size: 16px; }
/* public landing — the ungated "/" (§10): centered intro + prominent sign-in/register actions */
.landing {
width: 100%; max-width: 560px;
display: flex; flex-direction: column; align-items: center; gap: 18px;
text-align: center;
}
.landing-title { margin: 6px 0 0; font-size: 27px; font-weight: 700; letter-spacing: -.02em; }
.landing-lead { margin: 0; max-width: 480px; color: var(--text-muted); font-size: var(--fz); line-height: 1.65; }
.landing-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 4px; }
.landing-actions .btn { height: 40px; padding: 0 20px; font-size: var(--fz); }
/* ---- screen switching via :target (login is the default) ---- */
.auth-view { display: none; }
#login { display: block; }

View File

@@ -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);
}

View File

@@ -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({}) }));

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.

View File

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

View File

@@ -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" },

View File

@@ -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) => {

View File

@@ -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;
}

View File

@@ -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"), []);
});

View File

@@ -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)}`);

View File

@@ -136,5 +136,5 @@ everything via Docker.
- [x] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. → Pass over the §9 test accretion (`security-headers`/`denylist`/`logger`/`safe-url` units, `gen-jwks` `rotateJwks`, + the §9 additions across `config`/`app`/`jwt-middleware`/`context`/`plugin-api`/`guards`). The new unit files are one-concern-per-test matrices (logger severity/level/format/trace-continuation, denylist iat-cutoff/TTL-evict, safe-url scheme/host-relative, security-headers strict-CSP) and the per-field `config` toggles (`SERVICE_NAME`/`LOG_*`/`OTLP_*`/`REVOCATION_*`/`JWT_CLOCK_SKEW`/`ORY_TIMEOUT`) follow the file's existing per-field validation pattern — no fat, and force-merging distinct fields/surfaces would only hurt readability (the §3 "don't merge across distinct concerns" rule). **One genuine §9-era overlap:** `app.test.ts` carried two `/login?return_to=…` tests for the *same* surface — the §6 "bakes the return target into the Kratos flow init (OAuth bounce)" and the §9 "a first-party deep link is wrapped through `/auth/complete`; an absolute target passes through as-is". The §9 test subsumes the §6 one: its middle assertion already proves an absolute `/oauth2/login?login_challenge=` target is passed to `initBrowserFlow` **unchanged** (the exact §6 OAuth-bounce contract — and it's labeled as such in the test name + inline comment), plus the new host-relative-wrap + protocol-relative cases the §6 test never had. Removed the now-redundant standalone §6 test (zero coverage lost). Pure test refactor, no production code (per the §6/§7/§8 precedent, no stability reviewer). 339 → 338 units; typecheck + tests green.
## 10. User added stuff
- [x] The dashboard, the first landing page after logging in, should be gated to only logged in users. It should also be replaceable fully from a plugin. It is important that the ergonomics for the plugin writer is great. → `/` is now **gated to a signed-in session** (anonymous → `/login` via `loginRedirect`, query preserved as `return_to`), and **fully replaceable by a plugin**: a new optional `home?: RouteHandler` on `PluginManifest` (`src/plugin.ts`) — the most ergonomic possible shape, a handler with the same signature as any route. The host (`app.ts` `/` branch) gates first, then renders the single home plugin's handler against its own `views/` with the native shell via `ctx.chrome` (same path/parity as a plugin route: HEAD, `void`-return, response hooks), else the built-in mock-data People list. Identity stays folder-derived; `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 when >1 plugin declares `home` (new `"home"` conflict kind), `discovery.shapeError` rejects a non-function `home` — never last-write-wins. Tests-first (344 units, was 338): `app.test.ts` gate (anon→303 /login) + home-override integration (plugin dashboard replaces the People list, still gated); `plugin.test.ts` home conflict; `discovery.test.ts` home-not-a-function + two-homes + valid-home-loads. Docs: `docs/plugin-contract.md` (manifest table + a "The dashboard (home)" section + conflict-rule row), README (Building-a-plugin note + Layout). **E2E:** the Ory-free `visual.spec.ts` now plants a dev-signed session (signs with the committed tokenizer key, bind-mounted into the runner; the anonymous plugin-gate probe uses the cookie-free `request` fixture); all five e2e web/gateway healthchecks repointed from the now-gated `/` to the auth-free `/public/css/styles.css`. stability-reviewer on the prod diff: **APPROVE, no Critical/High/Medium** (verified no shadowing either direction, gate↔re-mint ordering, HEAD/void/hook parity, open-redirect-safe). typecheck + 344 units + visual (9) + full-flow (7) E2E green, stacks torn down.
- [x] The dashboard, the first landing page after logging in, should be gated to only logged in users. It should also be replaceable fully from a plugin. It is important that the ergonomics for the plugin writer is great. → **Two replaceable landing slots** (per the human follow-up: `/` public, `/dashboard` gated): `/` is now an **ungated public landing** (default `views/home.ejs`: brand + a short "what plainpages is" intro + prominent **Log in**/`/login` & **Create account**/`/registration` links, or **Go to your dashboard** when already signed in), and `/dashboard` is the **gated post-login app home** (anonymous → `/login?return_to=/dashboard` via `loginRedirect`; default = the mock-data People list). Both **fully replaceable by a plugin** via two optional `RouteHandler`s on `PluginManifest` (`src/plugin.ts`) — `home?` (public `/`) and `dashboard?` (gated `/dashboard`), the most ergonomic shape (same signature as any route). The host renders each against the plugin's own `views/` with the native shell via `ctx.chrome` (full parity with a plugin route: HEAD, `void`-return, response hooks, fresh CSRF cookie); a `home` handler is public so `ctx.user` may be null. **Single-slot, loud:** `findConflicts` errors when >1 plugin claims either slot (new `"home"`/`"dashboard"` conflict kinds), `discovery.shapeError` rejects a non-function handler, and `"dashboard"` is added to `RESERVED_PLUGIN_IDS` so a plugin folder can't shadow the built-in route (`/` can't be shadowed — route paths always carry the `/<id>` prefix). Post-login + already-signed-in redirects now target `/dashboard`; the global "Dashboard"/"People" nav hrefs moved `/``/dashboard` (chrome/admin-nav/dashboard). Tests-first (348 units, was 338): `app.test.ts` public-`/` (anon 200 + login/register, no gate) + gated-`/dashboard` (anon→303 return_to) + dual plugin-override; `plugin.test.ts` per-slot conflict; `discovery.test.ts` non-function + reserved-id + two-owners + valid-load. Docs: `docs/plugin-contract.md` ("The landing pages (home & dashboard)" section + manifest/conflict/reserved updates), README. **E2E:** the Ory-free `visual.spec.ts` plants a dev-signed session for the `/dashboard` design-system tests + a cookie-free public-`/` landing test (login/register links, screenshot verified); `full-flow.spec.ts` repointed its app-shell navigations to `/dashboard`; all five e2e healthchecks moved off the (now-public-but-formless) `/` to the auth-free `/public/css/styles.css`. stability-reviewer on the prod diff (both iterations): **APPROVE, no Critical/High/Medium** (gate moved correctly + stays closed, open-redirect-safe, public `/` prints no protected data, no shadowing either direction, render-branch parity). typecheck + 348 units + visual (10) + full-flow (7) E2E green, stacks torn down.
- [ ] Make some pages optionally available publicly. A plugin should be able to set the permissions of a page (including the menu option) to publicly available.

43
views/home.ejs Normal file
View File

@@ -0,0 +1,43 @@
<%#
Public landing page (todo §10): the ungated "/", what an anonymous visitor sees. A brief intro to
the product and prominent paths to sign in / register — or to the app dashboard when already signed
in. Standalone (no app shell — the sidebar/menu belong to the signed-in app). A plugin may replace
this via its `home` handler. Auto theme follows the OS (styles.css). Data: brand, user (or null).
%><%
const brand = locals.brand || "Plainpages";
%><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= brand %></title>
<link rel="stylesheet" href="/public/css/styles.css" />
<link rel="stylesheet" href="/public/css/auth.css" />
<link rel="icon" href="/public/favicon.svg" />
</head>
<body>
<%- include("partials/icons") %>
<main class="auth-stage">
<div class="landing">
<div class="auth-brand">
<span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box" /></svg></span>
<span class="brand-name"><%= brand %></span>
</div>
<h1 class="landing-title">Operational web apps, without the boilerplate.</h1>
<p class="landing-lead">
<%= brand %> is a self-hostable foundation for admin and operational UIs — sign-in,
a config-driven menu, and a server-rendered, zero-JS design system. You add the
domain-specific screens by dropping in plugin folders.
</p>
<div class="landing-actions">
<% if (locals.user) { %>
<a class="btn btn-primary" href="/dashboard">Go to your dashboard</a>
<% } else { %>
<a class="btn btn-primary" href="/login">Log in</a>
<a class="btn" href="/registration">Create account</a>
<% } %>
</div>
</div>
</main>
</body>
</html>