Return the landing slots' results to the request pipeline instead of sending them
CI / full-gate (push) Successful in 3m22s
CI / full-gate (push) Successful in 3m22s
This commit is contained in:
@@ -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: 135, 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 },
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
+38
-30
@@ -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,22 @@ 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.
|
// 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);
|
||||||
const result = (await homePlugin.home(pluginCtx)) ?? null;
|
return { ctx: pluginCtx, pluginId: homePlugin.id, 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); // as serveHome: the owner's own translator
|
||||||
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/
|
||||||
@@ -178,8 +168,9 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
|
|
||||||
const host: Host = { anyRequestHooks, anyResponseHooks, assets, authOptions, builtinRoutes, csrfSecret, i18n, jwks, keto, kratos, kratosAdmin, menu, permissionCatalog, pluginViewsFor, plugins, secureCookies, settingsCatalog, system, viewsFor };
|
const host: Host = { anyRequestHooks, anyResponseHooks, assets, authOptions, builtinRoutes, csrfSecret, i18n, jwks, keto, kratos, kratosAdmin, menu, permissionCatalog, pluginViewsFor, plugins, secureCookies, settingsCatalog, system, viewsFor };
|
||||||
|
|
||||||
// A stage that returns a boolean returns true once it has answered the request, and false to
|
// Static files, the canonical-host redirect and prepareRequest come first; then the answering
|
||||||
// pass the request to the next stage.
|
// stages in order — onRequest hook, plugin route, built-in route, else 404/405. A stage that
|
||||||
|
// returns a boolean returns true once it has answered the request, and false to pass it on.
|
||||||
const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
|
const handleRequest = async (req: IncomingMessage, res: ServerResponse, reqLog: Log): Promise<void> => {
|
||||||
// Error pages can render before this request has a context at all (a throw on the way to one),
|
// Error pages can render before this request has a context at all (a throw on the way to one),
|
||||||
// so they start on the built-in English and switch to the visitor's locale once it is resolved.
|
// so they start on the built-in English and switch to the visitor's locale once it is resolved.
|
||||||
@@ -279,17 +270,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 +292,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 +353,18 @@ 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 (answer && "pluginId" in answer) {
|
||||||
|
// A plugin-owned landing slot answers as a plugin route does: its hooks observe it, its views render it.
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// 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). app.ts matches this table
|
||||||
// after plugin routes — exact path, a GET route also answering HEAD like the plugin router — and
|
// 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.
|
// sends the result against the core views, or against the owning plugin's where it is a PluginOwned.
|
||||||
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 +20,17 @@ 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;
|
||||||
|
|
||||||
|
// A landing slot's result when a plugin owns it: rendered against that plugin's views, and
|
||||||
|
// observed by the response hooks, on `ctx`.
|
||||||
|
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
|
// Returns a RouteResult, or null when the handler wrote to ctx.res itself.
|
||||||
// (the landing slots dispatch a plugin's own result against that plugin's views).
|
handler: (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory) => Promise<RouteResult | PluginOwned | null> | RouteResult | PluginOwned | null;
|
||||||
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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user