Return the landing slots' results to the request pipeline #233

Merged
lilleman merged 7 commits from item-68 into main 2026-11-09 00:59:08 +01:00
8 changed files with 102 additions and 49 deletions
+17
View File
@@ -530,3 +530,20 @@ Read most (opens and searches):
- `docs/reading-cost.md` 2 - `docs/reading-cost.md` 2
- `examples/plugins/auth-admin/admin-grants.ts` 2 - `examples/plugins/auth-admin/admin-grants.ts` 2
- `examples/plugins/auth-admin/i18n/en-US.ts` 2 - `examples/plugins/auth-admin/i18n/en-US.ts` 2
## Reading cost, chunk a2d8600ec28b, finished 2026-11-08T23:23:17Z
66451 new input tokens before the first commit.
Read most (opens and searches):
- `src/auth/bootstrap.ts` 4
- `src/auth/jwt-middleware.ts` 4
- `README.md` 3
- `ci.sh` 3
- `examples/plugins/scheduling/plugin.ts` 3
- `src/auth/jwt.ts` 3
- `src/ui/chrome.ts` 3
- `compose.yml` 2
- `examples/plugins/auth-admin/admin-groups.ts` 2
- `examples/plugins/auth-admin/views/partials/group-detail-body.ejs` 2
+1 -1
View File
@@ -13,7 +13,7 @@ const LIMITS = { lines: 50, complexity: 20 };
// Each function over a limit, at its measurement. A ceiling follows its function down, and a metric // Each function over a limit, at its measurement. A ceiling follows its function down, and a metric
// leaves the entry once it fits, so a function that shrank can't grow back. // leaves the entry once it fits, so a function that shrank can't grow back.
const grandfathered: Record<string, { lines?: number; complexity?: number }> = { const grandfathered: Record<string, { lines?: number; complexity?: number }> = {
"src/http/app.ts createApp": { lines: 144, complexity: 26 }, "src/http/app.ts createApp": { lines: 133, complexity: 26 },
"src/auth/flow-view.ts buildFlowView": { complexity: 24 }, "src/auth/flow-view.ts buildFlowView": { complexity: 24 },
"src/auth/hydra-admin.ts createHydraAdmin": { lines: 87 }, "src/auth/hydra-admin.ts createHydraAdmin": { lines: 87 },
"src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 }, "src/auth/kratos-admin.ts createKratosAdmin": { lines: 68 },
+16 -2
View File
@@ -78,12 +78,15 @@ test("a plugin that owns a landing page, or short-circuits a hook, translates fr
mkdirSync(join(dir, "demo", "i18n"), { recursive: true }); mkdirSync(join(dir, "demo", "i18n"), { recursive: true });
t.after(() => rmSync(dir, { force: true, recursive: true })); t.after(() => rmSync(dir, { force: true, recursive: true }));
writeFileSync(join(dir, "demo", "i18n", "en-US.ts"), 'const m = { "demo.hello": "Hello from the plugin" };\nexport default m;\n'); writeFileSync(join(dir, "demo", "i18n", "en-US.ts"), 'const m = { "demo.hello": "Hello from the plugin" };\nexport default m;\n');
mkdirSync(join(dir, "demo", "views"), { recursive: true });
writeFileSync(join(dir, "demo", "views", "landing.ejs"), '<%= t("demo.hello") %>');
// Every plugin-owned render path: the public landing, the gated dashboard, and a hook short-circuit. // Every plugin-owned render path: the public landing, the gated dashboard, and a hook short-circuit.
// The landings return a view, so the host's choice of views and context shows in the output.
const demo: Plugin = { const demo: Plugin = {
apiVersion: "1.0.0", apiVersion: "1.0.0",
dashboard: (ctx) => ({ html: ctx.t("demo.hello") }), dashboard: () => ({ view: "landing" }),
home: (ctx) => ({ html: ctx.t("demo.hello") }), home: () => ({ view: "landing" }),
hooks: { onRequest: (ctx) => (ctx.url.pathname === "/hooked" ? { html: ctx.t("demo.hello") } : undefined) }, hooks: { onRequest: (ctx) => (ctx.url.pathname === "/hooked" ? { html: ctx.t("demo.hello") } : undefined) },
id: "demo", id: "demo",
}; };
@@ -100,6 +103,17 @@ test("a plugin that owns a landing page, or short-circuits a hook, translates fr
assert.equal(await (await fetch(`${url}/dashboard`, { headers: { cookie } })).text(), "Hello from the plugin"); assert.equal(await (await fetch(`${url}/dashboard`, { headers: { cookie } })).text(), "Hello from the plugin");
}); });
test("a redirect from a plugin-owned landing keeps the visitor's chosen locale", async (t) => {
const demo: Plugin = { apiVersion: "1.0.0", home: () => ({ redirect: "/demo/next" }), id: "demo" };
const app = createApp({ i18n: createI18n(await loadI18n()), plugins: [demo] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const res = await fetch(`${url}/?locale=sv-SE`, { redirect: "manual" });
assert.equal(res.status, 303);
assert.equal(res.headers.get("location"), "/demo/next?locale=sv-SE");
});
test("an error page renders without composing the menu — it exists for when the shell's data is what failed", async (t) => { test("an error page renders without composing the menu — it exists for when the shell's data is what failed", async (t) => {
// The chrome getter is lazy on purpose; a render that reads no chrome must not trigger it, or a // The chrome getter is lazy on purpose; a render that reads no chrome must not trigger it, or a
// broken menu takes the error pages down with it. // broken menu takes the error pages down with it.
+24 -1
View File
@@ -15,7 +15,7 @@ import { KratosError, type Session } from "../auth/kratos-public.ts";
import { SESSION_COOKIE } from "../auth/login.ts"; import { SESSION_COOKIE } from "../auth/login.ts";
import { createLogger } from "../logger.ts"; import { createLogger } from "../logger.ts";
import type { Plugin } from "../plugin-host/plugin.ts"; import type { Plugin } from "../plugin-host/plugin.ts";
import { ecJwk, fakeKeto, mintJwt, startApp, stubAdmin, withWhoami } from "../test-tooling/app-harness.ts"; import { ecJwk, fakeKeto, mintJwt, session, startApp, stubAdmin, withWhoami } from "../test-tooling/app-harness.ts";
// A test plugin exercising each RouteResult shape, a path param, and the permission gate. // A test plugin exercising each RouteResult shape, a path param, and the permission gate.
const demoPlugin: Plugin = { const demoPlugin: Plugin = {
@@ -350,6 +350,29 @@ test("plugin hooks: onRequest can short-circuit a request and onResponse observe
assert.ok(seen.includes("/hooked/ok:handler ran")); assert.ok(seen.includes("/hooked/ok:handler ran"));
}); });
test("plugin hooks: onResponse observes the result of a plugin-owned `/` and `/dashboard`", async (t) => {
const seen: string[] = [];
const owner: Plugin = {
apiVersion: "1.0.0",
dashboard: () => ({ html: "owned dashboard" }),
home: () => ({ html: "owned home" }),
id: "owner",
};
const observer: Plugin = {
apiVersion: "1.0.0",
hooks: { onResponse: (c, r) => void seen.push(`${c.url.pathname}:${r && "html" in r ? r.html : "?"}`) },
id: "observer",
};
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [owner, observer] });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
assert.match(await (await fetch(url + "/")).text(), /owned home/);
assert.match(await (await fetch(url + "/dashboard", { headers: { cookie: session() } })).text(), /owned dashboard/);
assert.deepEqual(seen, ["/:owned home", "/dashboard:owned dashboard"]);
});
test("plugin hooks: the request routes by the method and path it arrived with, whatever onRequest does to req", async (t) => { test("plugin hooks: the request routes by the method and path it arrived with, whatever onRequest does to req", async (t) => {
const rewriter: Plugin = { const rewriter: Plugin = {
apiVersion: "1.0.0", apiVersion: "1.0.0",
+33 -28
View File
@@ -3,7 +3,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import { dirname, join } from "node:path"; import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import ejs from "ejs"; import ejs from "ejs";
import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type RequestCsrf } from "./builtin-route.ts"; import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type PluginOwned, type RequestCsrf } from "./builtin-route.ts";
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts"; import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type RequestContext, type User } from "./context.ts"; import { buildContext, type RequestContext, type User } from "./context.ts";
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts"; import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
@@ -140,32 +140,21 @@ export function createApp(options: AppOptions = {}): Server {
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href }); const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)), assetHref: assets.href });
// The public landing "/", ungated. A plugin may own it via `home`; else the built-in intro page. // The public landing "/", ungated. A plugin may own it via `home`; else the built-in intro page.
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => { const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | PluginOwned> => {
csrf.setCookie(); csrf.setCookie();
if (homePlugin) { if (!homePlugin) return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
// The plugin owns this page, so it runs on its own context — its catalog first, then core. const pluginCtx = contextFor(homePlugin.id);
const pluginCtx = contextFor(homePlugin.id); return { ctx: pluginCtx, pluginId: homePlugin.id, result: (await homePlugin.home(pluginCtx)) ?? null };
const result = (await homePlugin.home(pluginCtx)) ?? null;
if (anyResponseHooks) await runResponseHooks(plugins, contextFor, result);
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, homePlugin.id), pluginCtx.localeHref);
return null;
}
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
}; };
// "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in // "/dashboard", gated to a signed-in user. A plugin may own it via `dashboard`; else the built-in
// starter page. // starter page.
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => { const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | PluginOwned> => {
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent. // The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
csrf.setCookie(); csrf.setCookie();
if (dashboardPlugin) { if (!dashboardPlugin) return { data: { chrome: ctx.chrome }, view: "dashboard" };
const pluginCtx = contextFor(dashboardPlugin.id); // as serveHome: the owner's own translator const pluginCtx = contextFor(dashboardPlugin.id);
const result = (await dashboardPlugin.dashboard(pluginCtx)) ?? null; return { ctx: pluginCtx, pluginId: dashboardPlugin.id, result: (await dashboardPlugin.dashboard(pluginCtx)) ?? null };
if (anyResponseHooks) await runResponseHooks(plugins, contextFor, result);
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
return null;
}
return { data: { chrome: ctx.chrome }, view: "dashboard" };
}; };
// The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/ // The internal route table, matched after plugin routes: the auth/OAuth2 group (src/auth/
@@ -279,17 +268,16 @@ async function prepareRequest(host: Host, req: IncomingMessage, res: ServerRespo
const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null); const carryLocale = (href: string): string => localeHref(href, explicit ? locale : null);
const t = i18n.translator(locale); const t = i18n.translator(locale);
const user = await resolveUser(host, req, res, reqLog); const user = await resolveUser(host, req, res, reqLog);
const { fresh, token } = ensureCsrfToken(req.headers.cookie, host.csrfSecret); const csrf = requestCsrf(host, req, res);
const csrf: RequestCsrf = {
setCookie: (): void => { if (fresh) res.appendHeader("set-cookie", csrfCookie(token, { secure: host.secureCookies })); },
token,
};
const verifyCsrf = (submitted: string | null | undefined): boolean => const verifyCsrf = (submitted: string | null | undefined): boolean =>
verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: host.csrfSecret, submitted }); verifyCsrfRequest({ cookieHeader: req.headers.cookie, secret: host.csrfSecret, submitted });
// Chrome composes the whole menu, so it is memoized and resolved lazily — a json/redirect // Chrome composes the whole menu, so it is memoized and resolved lazily — a json/redirect
// handler, or the public "/" with a standalone home, never pays for it. // handler, or the public "/" with a standalone home, never pays for it.
let chromeMemo: PageChrome | undefined; let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({ csrfToken: token, currentPath: url.pathname, localeHref: carryLocale, menu: host.menu, plugins: host.plugins, t, translatorFor: (id) => i18n.translator(locale, id), user })); const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({
csrfToken: csrf.token, currentPath: url.pathname, localeHref: carryLocale, menu: host.menu, plugins: host.plugins, t,
translatorFor: (id) => i18n.translator(locale, id), user,
}));
const shared = { assetHref: host.assets.href, chrome, declaredPermissions: host.permissionCatalog, declaredSettings: host.settingsCatalog, locale, localeHref: carryLocale, locales: i18n.available, log: reqLog, user, verifyCsrf, ...(host.system ? { system: host.system } : {}) }; const shared = { assetHref: host.assets.href, chrome, declaredPermissions: host.permissionCatalog, declaredSettings: host.settingsCatalog, locale, localeHref: carryLocale, locales: i18n.available, log: reqLog, user, verifyCsrf, ...(host.system ? { system: host.system } : {}) };
return { return {
carryLocale, carryLocale,
@@ -302,6 +290,14 @@ async function prepareRequest(host: Host, req: IncomingMessage, res: ServerRespo
}; };
} }
function requestCsrf(host: Host, req: IncomingMessage, res: ServerResponse): RequestCsrf {
const { fresh, token } = ensureCsrfToken(req.headers.cookie, host.csrfSecret);
return {
setCookie: (): void => { if (fresh) res.appendHeader("set-cookie", csrfCookie(token, { secure: host.secureCookies })); },
token,
};
}
// A lapsed token still backed by a live Kratos session is silently re-minted — "stay signed // A lapsed token still backed by a live Kratos session is silently re-minted — "stay signed
// in". The only place the hot path touches Ory. // in". The only place the hot path touches Ory.
async function resolveUser(host: Host, req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<User | null> { async function resolveUser(host: Host, req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<User | null> {
@@ -355,8 +351,17 @@ async function answerBuiltinRoute(host: Host, state: RequestState): Promise<bool
const builtin = matchBuiltinRoute(host.builtinRoutes, state.method, state.pathname); const builtin = matchBuiltinRoute(host.builtinRoutes, state.method, state.pathname);
if (!builtin) return false; if (!builtin) return false;
ctx.log.context["http.route"] = builtin.path; ctx.log.context["http.route"] = builtin.path;
if (!allows(builtin, ctx.user)) await refuse(host, state, builtin, ctx); if (!allows(builtin, ctx.user)) {
else await sendResult(ctx.res, await builtin.handler(ctx, csrf, contextFor), host.viewsFor(ctx), carryLocale); await refuse(host, state, builtin, ctx);
return true;
}
const answer = await builtin.handler(ctx, csrf, contextFor);
if ("pluginId" in answer) {
if (host.anyResponseHooks) await runResponseHooks(host.plugins, contextFor, answer.result);
await sendResult(ctx.res, answer.result, host.pluginViewsFor(answer.ctx, answer.pluginId), carryLocale);
} else {
await sendResult(ctx.res, answer, host.viewsFor(ctx), carryLocale);
}
return true; return true;
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { type BuiltinRoute, matchBuiltinRoute } from "./builtin-route.ts"; import { type BuiltinRoute, matchBuiltinRoute } from "./builtin-route.ts";
const entry = (method: "GET" | "POST", path: string): BuiltinRoute => ({ handler: () => null, method, path }); const entry = (method: "GET" | "POST", path: string): BuiltinRoute => ({ handler: () => ({ html: "" }), method, path });
test("matches exact path + method; GET also answers HEAD", () => { test("matches exact path + method; GET also answers HEAD", () => {
const get = entry("GET", "/error"); const get = entry("GET", "/error");
+10 -6
View File
@@ -1,8 +1,6 @@
// The host's internal route table: each built-in endpoint (the auth/OAuth2 group, the landing // The host's internal route table: each built-in endpoint (the auth/OAuth2 group, the landing
// slots, /error) is a named handler with the plugin RouteHandler shape, plus the request's CSRF // slots, /error) is a named handler with the plugin RouteHandler shape, plus the request's CSRF
// mint (host-only — a plugin reads the token via ctx.chrome instead). app.ts matches this table // mint (host-only — a plugin reads the token via ctx.chrome instead).
// after plugin routes — exact path, a GET route also answering HEAD like the plugin router — and
// pipes the result through sendResult against the core views.
import type { Gate } from "../auth/gate.ts"; import type { Gate } from "../auth/gate.ts";
import type { RequestContext } from "./context.ts"; import type { RequestContext } from "./context.ts";
import type { RouteResult } from "../plugin-host/plugin.ts"; import type { RouteResult } from "../plugin-host/plugin.ts";
@@ -20,10 +18,16 @@ export interface RequestCsrf {
// own context — otherwise the plugin's keys render as bare keys on the pages it owns. // own context — otherwise the plugin's keys render as bare keys on the pages it owns.
export type PluginContextFactory = (pluginId: string) => RequestContext; export type PluginContextFactory = (pluginId: string) => RequestContext;
// What a landing slot returns when a plugin owns it. The host renders `result` with that plugin's
// views and `ctx`, and passes it to every plugin's onResponse hook, as it does for a plugin route.
export interface PluginOwned {
ctx: RequestContext; // the owner's own context, from PluginContextFactory
pluginId: string;
result: RouteResult | null;
}
export interface BuiltinRoute extends Gate { export interface BuiltinRoute extends Gate {
// Returns a RouteResult, or null when the handler wrote to ctx.res itself handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | PluginOwned> | RouteResult | PluginOwned;
// (the landing slots dispatch a plugin's own result against that plugin's views).
handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | null> | RouteResult | null;
method: "GET" | "POST"; // a GET route also answers HEAD, like plugin routes method: "GET" | "POST"; // a GET route also answers HEAD, like plugin routes
path: string; // exact pathname path: string; // exact pathname
} }
-10
View File
@@ -12,7 +12,6 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|---|
| 68 | 0.5.0 | | | **Make `serveHome` and `serveDashboard` return their result to the request pipeline, as every other stage does, instead of sending it themselves.** | 2 | 1 | 3 | 4 | 3 | code health by 3 | 13.5 |
| 66 | 0.5.0 | | | **Make `src/auth/bootstrap.ts` read its settings through `src/config.ts` and call Kratos and Keto through their client modules.** | 2 | 1 | 3 | 4 | 4 | code health by 3 | 9.0 | | 66 | 0.5.0 | | | **Make `src/auth/bootstrap.ts` read its settings through `src/config.ts` and call Kratos and Keto through their client modules.** | 2 | 1 | 3 | 4 | 4 | code health by 3 | 9.0 |
| 70 | 0.5.0 | | | **Give the duplicated paging loop, Link-header parser and `FieldConfig` type one home each, and rename `buildPluginChrome` to `buildPageChrome`.** | 2 | 1 | 1 | 2 | 3 | code health by 2 | 9.0 | | 70 | 0.5.0 | | | **Give the duplicated paging loop, Link-header parser and `FieldConfig` type one home each, and rename `buildPluginChrome` to `buildPageChrome`.** | 2 | 1 | 1 | 2 | 3 | code health by 2 | 9.0 |
| 71 | 0.5.0 | | | **Delete, with a group, every membership tuple in another group that names it, so re-creating the name restores no nested membership.** | 1 | 2 | 2 | 3 | 2 | 2 by 3 | 4.2 | | 71 | 0.5.0 | | | **Delete, with a group, every membership tuple in another group that names it, so re-creating the name restores no nested membership.** | 1 | 2 | 2 | 3 | 2 | 2 by 3 | 4.2 |
@@ -29,15 +28,6 @@
## Details ## Details
### 68. Make `serveHome` and `serveDashboard` return their result to the request pipeline, as every other stage does, instead of sending it themselves.
In `src/http/app.ts`, both (~L143-169) call `sendResult` with the owning plugin's renderer and
return `null` when a plugin owns the slot, and return a `RouteResult` otherwise, so a reader of
`handleRequest` (~L183) and `prepareRequest` (~L274) meets two return conventions. Return the
result with whatever names its renderer and let the one send stage render it. Make
`prepareRequest` and `handleRequest` easier to read, places the seats found hard. The 19-field
`Host` bag is left alone: no checkable target for it came out of the scoring run.
### 66. Make `src/auth/bootstrap.ts` read its settings through `src/config.ts` and call Kratos and Keto through their client modules. ### 66. Make `src/auth/bootstrap.ts` read its settings through `src/config.ts` and call Kratos and Keto through their client modules.
`seedAdminAndPermissions` (~L221-233) reads `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `KETO_WRITE_URL`, `seedAdminAndPermissions` (~L221-233) reads `ADMIN_EMAIL`, `ADMIN_PASSWORD`, `KETO_WRITE_URL`,