Stability fixes: plugin-scoped contexts for owned pages, absent-href guard, checked locale mounts
CI / full-gate (push) Successful in 2m37s
CI / full-gate (push) Successful in 2m37s
This commit is contained in:
@@ -117,13 +117,25 @@ them. Revisit only if the stated reason stops holding.
|
||||
whole query string and no href wrapper can reach it. Putting the obligation on each call site was
|
||||
tried first and missed five of eight sites inside one commit — including the admin screens.
|
||||
`ctx.localeHref` remains for hrefs a plugin's own markup emits (the admin example's delete links).
|
||||
**A form's `action` counts as a link** — a POST replaces the URL as completely as a GET submit, so
|
||||
the sign-out, consent and auth-card forms carry it too; without that, picking a language and then
|
||||
saving anything drops back to `Accept-Language`. The one round-trip that cannot carry it is the
|
||||
Kratos sign-in POST, whose action is an absolute off-site URL.
|
||||
Decided 2026-08-03 after an architecture review; a second pass then found breadcrumbs still raw,
|
||||
so: when a link renders from the core chrome, it is the chrome's job to carry the locale.
|
||||
- **`locale` is a host-owned query param.** It is in `parseListQuery`'s reserved set (`list-query.ts`),
|
||||
so a localized list page doesn't hand a plugin a phantom `locale` filter; the i18n view locals (`t`, `locale`, `locales`, `localeHref`,
|
||||
`localeParam`, `localeSwitch`, `dir`) are likewise reserved names, merged after a handler's `data`
|
||||
so a collision loses the key instead of breaking the shell.
|
||||
- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`.** A catalog there
|
||||
- **A plugin-owned render always runs on that plugin's context.** The landing slots (`home`,
|
||||
`dashboard`) and an `onRequest` short-circuit dispatch a plugin's handler, so they build the
|
||||
context with `contextFor(pluginId)` exactly as a plugin route does — otherwise `ctx.t` is the core
|
||||
translator and the plugin's own keys render as bare keys on the pages it owns. Found by review
|
||||
2026-08-03 after all three paths shipped with the host's context.
|
||||
- **`locales/` at the repo root is a drop-in mount, like `plugins/` and `config/`.** The SHIPPED
|
||||
`en-US` stays the parity baseline even when the mount replaces it, so a mounted catalog is checked
|
||||
rather than trusted (a mounted `en-US` compared only against itself would boot green with the
|
||||
whole UI rendering keys). A catalog there
|
||||
for a new tag adds a language; one for a tag the image ships replaces that catalog wholesale, held
|
||||
to the same parity check. Adding a language must not require forking the image.
|
||||
- **An unknown translation key renders as itself.** That single rule is what lets a nav label,
|
||||
|
||||
@@ -1001,8 +1001,10 @@ Three rules worth knowing:
|
||||
- **The core building blocks carry the locale for you** — every href they render (menu, breadcrumbs,
|
||||
pagination, sort headers, row actions, the auth card's links) goes through `localeHref`, and their
|
||||
GET forms carry it as a hidden field, since a GET submit replaces the whole query string.
|
||||
`ctx.localeHref` is for hrefs your own markup emits, and `localeParam` (a view local: the tag, or
|
||||
null) for your own GET forms. `locale` is reserved: `parseListQuery` never returns it as a filter.
|
||||
`ctx.localeHref` is for hrefs and form actions your own markup emits (a POST replaces the URL just
|
||||
as a GET submit does), and `localeParam` (a view local: the tag, or null) for your own GET forms.
|
||||
`locale` is reserved: `parseListQuery` never returns it as a filter. Responses carry
|
||||
`Vary: Accept-Language`, so a cache in front of the app keys on the language too.
|
||||
- **Reuse the core words.** Generic UI verbs live in the core catalog — `common.add/cancel/delete/
|
||||
edit/new/remove/save`, `filter.*`, `pagination.*`, `table.*` — and a plugin's lookup falls through
|
||||
to them. Keep your catalog for your domain words, so N plugins don't re-translate "Cancel" N times.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<% if (locals.error) { -%>
|
||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||
<%- include("partials/field", form.nameField) %>
|
||||
<div class="field">
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
<% if (members.rows.length) { -%>
|
||||
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: group.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody>
|
||||
<% members.rows.forEach((m) => { -%>
|
||||
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("common.remove") %></button></form></td></tr>
|
||||
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= localeHref(members.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("common.remove") %></button></form></td></tr>
|
||||
<% }) -%>
|
||||
</tbody></table></div>
|
||||
<% } else { -%>
|
||||
@@ -31,7 +31,7 @@
|
||||
<section class="form-card" aria-labelledby="add-h">
|
||||
<h2 class="card-title" id="add-h"><%= t("admin.groups.addMember") %></h2>
|
||||
<% if (add.options.length) { -%>
|
||||
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("common.add") %></button></form>
|
||||
<form class="inline-form" method="post" action="<%= localeHref(add.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("common.add") %></button></form>
|
||||
<% } else { -%>
|
||||
<p class="cell-muted"><%= t("admin.groups.allMembers") %></p>
|
||||
<% } -%>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<% if (locals.error) { -%>
|
||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||
<%- include("partials/field", form.nameField) %>
|
||||
<div class="field">
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
<% if (members.rows.length) { -%>
|
||||
<div class="table-wrap"><table class="table"><caption class="sr-only"><%= t("admin.groups.membersOf", { name: permission.name }) %></caption><thead><tr><th scope="col"><%= t("admin.common.member") %></th><th scope="col"><%= t("admin.common.type") %></th><th class="col-actions" scope="col"><span class="sr-only"><%= t("table.actions") %></span></th></tr></thead><tbody>
|
||||
<% members.rows.forEach((m) => { -%>
|
||||
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= members.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("admin.permissions.revoke") %></button></form></td></tr>
|
||||
<tr><th scope="row"><span class="cell-strong"><%= m.label %></span></th><td><span class="badge info"><span class="dot"></span><%= m.kind === "group" ? t("admin.common.group") : t("admin.common.user") %></span></td><td class="col-actions"><form method="post" action="<%= localeHref(members.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><input type="hidden" name="member" value="<%= m.subject %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-x"/></svg><%= t("admin.permissions.revoke") %></button></form></td></tr>
|
||||
<% }) -%>
|
||||
</tbody></table></div>
|
||||
<% } else { -%>
|
||||
@@ -46,7 +46,7 @@
|
||||
<section class="form-card" aria-labelledby="add-h">
|
||||
<h2 class="card-title" id="add-h"><%= t("admin.permissions.assign") %></h2>
|
||||
<% if (add.options.length) { -%>
|
||||
<form class="inline-form" method="post" action="<%= add.action %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("admin.permissions.assignAction") %></button></form>
|
||||
<form class="inline-form" method="post" action="<%= localeHref(add.action) %>"><input type="hidden" name="_csrf" value="<%= csrf %>"><label class="sr-only" for="add-member"><%= t("admin.common.member") %></label><span class="select"><select id="add-member" name="member" required><option value="" disabled selected><%= t("admin.common.chooseMember") %></option><% add.options.forEach((o) => { %><option value="<%= o.value %>"><%= o.label %></option><% }) %></select></span><button class="btn btn-primary" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-plus"/></svg><%= t("admin.permissions.assignAction") %></button></form>
|
||||
<% } else { -%>
|
||||
<p class="cell-muted"><%= t("admin.permissions.allAssigned") %></p>
|
||||
<% } -%>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<% if (locals.error) { -%>
|
||||
<%- include("partials/alert", { text: locals.error, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||
<%- include("partials/field", form.nameField) %>
|
||||
<div class="field">
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<% if (recovery) { -%>
|
||||
<div class="alert alert-pos" role="status"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-check-circle"/></svg><div class="alert-body"><strong><%= t("admin.users.recovery.title") %></strong><span><%- t("admin.users.recovery.body") %></span><% if (recovery.code) { %><span class="recovery-code"><code><%= recovery.code %></code></span><% } %></div></div>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||
<% form.fields.forEach((field) => { -%>
|
||||
<%- include("partials/field", field) %>
|
||||
@@ -28,8 +28,8 @@
|
||||
</form>
|
||||
<% if (edit) { -%>
|
||||
<section class="form-card admin-actions" aria-label="<%= t("admin.users.actions") %>">
|
||||
<form method="post" action="<%= edit.recoveryAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
|
||||
<form method="post" action="<%= edit.stateAction %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
|
||||
<form method="post" action="<%= localeHref(edit.recoveryAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-mail"/></svg><%= t("admin.users.recovery.generate") %></button></form>
|
||||
<form method="post" action="<%= localeHref(edit.stateAction) %>"><input type="hidden" name="_csrf" value="<%= form.csrfToken %>"><button class="btn" type="submit"><%= edit.nextLabel %></button></form>
|
||||
<a class="btn btn-danger" href="<%= localeHref(edit.deleteAction) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= t("admin.users.delete") %></a>
|
||||
</section>
|
||||
<% } -%>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<% if (locals.formError) { -%>
|
||||
<%- include("partials/alert", { text: locals.formError, tone: "neg" }) %>
|
||||
<% } -%>
|
||||
<form class="form-card" method="post" action="<%= form.action %>">
|
||||
<form class="form-card" method="post" action="<%= localeHref(form.action) %>">
|
||||
<input type="hidden" name="_csrf" value="<%= form.csrfToken %>">
|
||||
<% form.fields.forEach((field) => { -%>
|
||||
<%- include("partials/field", field) %>
|
||||
|
||||
@@ -1459,3 +1459,30 @@ test("the error pages speak the visitor's language too", async (t) => {
|
||||
assert.match(html, /<html lang="sv-SE"/);
|
||||
assert.match(html, /Sidan hittades inte/);
|
||||
});
|
||||
|
||||
test("a plugin that owns a landing page, or short-circuits a hook, translates from its own catalog", async (t) => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "pp-i18n-plugin-"));
|
||||
mkdirSync(join(dir, "demo", "i18n"), { 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');
|
||||
|
||||
// Every plugin-owned render path: the public landing, the gated dashboard, and a hook short-circuit.
|
||||
const demo: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
dashboard: (ctx) => ({ html: ctx.t("demo.hello") }),
|
||||
home: (ctx) => ({ html: ctx.t("demo.hello") }),
|
||||
hooks: { onRequest: (ctx) => (ctx.url.pathname === "/hooked" ? { html: ctx.t("demo.hello") } : undefined) },
|
||||
id: "demo",
|
||||
};
|
||||
const i18n = createI18n(await loadI18n({ pluginIds: ["demo"], pluginsDir: dir }));
|
||||
const app = createApp({ i18n, jwks: staticJwks([ecJwk]), plugins: [demo], pluginsDir: dir });
|
||||
await new Promise<void>((r) => app.listen(0, r));
|
||||
t.after(() => app.close());
|
||||
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
|
||||
const nowSec = Math.floor(Date.now() / 1000);
|
||||
const cookie = `${SESSION_COOKIE}=${mintJwt({ email: "a@b", exp: nowSec + 600, permissions: [], sub: "u1" })}`;
|
||||
|
||||
assert.equal(await (await fetch(`${url}/`)).text(), "Hello from the plugin");
|
||||
assert.equal(await (await fetch(`${url}/hooked`)).text(), "Hello from the plugin");
|
||||
assert.equal(await (await fetch(`${url}/dashboard`, { headers: { cookie } })).text(), "Hello from the plugin");
|
||||
});
|
||||
|
||||
+24
-14
@@ -3,7 +3,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import ejs from "ejs";
|
||||
import { type BuiltinRoute, matchBuiltinRoute, type RequestCsrf } from "./builtin-routes.ts";
|
||||
import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type RequestCsrf } from "./builtin-routes.ts";
|
||||
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
|
||||
import { buildContext, type RequestContext, type User } from "./context.ts";
|
||||
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
|
||||
@@ -132,12 +132,14 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// (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
|
||||
// (`user` picks "go to dashboard" vs sign-in; the shell's Sign-out form needs the CSRF cookie).
|
||||
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult | null> => {
|
||||
const serveHome = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
||||
csrf.setCookie();
|
||||
if (homePlugin) {
|
||||
const result = (await homePlugin.home(ctx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, ctx, result);
|
||||
await sendResult(ctx.res, result, pluginViewsFor(ctx, homePlugin.id), ctx.localeHref);
|
||||
// The plugin owns this page, so it runs on its own context — its catalog first, then core.
|
||||
const pluginCtx = contextFor(homePlugin.id);
|
||||
const result = (await homePlugin.home(pluginCtx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, pluginCtx, result);
|
||||
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, homePlugin.id), pluginCtx.localeHref);
|
||||
return null;
|
||||
}
|
||||
return { data: { chrome: ctx.chrome, user: ctx.user }, view: "home" };
|
||||
@@ -147,14 +149,15 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// in, remembering /dashboard as return_to. A plugin may fully own it via `dashboard` — its
|
||||
// handler renders against its own views, same path as a plugin route. Else the built-in
|
||||
// mock-data People list with the one global menu (ctx.chrome.nav) + branding from config/menu.ts.
|
||||
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf): Promise<RouteResult | null> => {
|
||||
const serveDashboard = async (ctx: RequestContext, csrf: RequestCsrf, contextFor: PluginContextFactory): Promise<RouteResult | null> => {
|
||||
if (!ctx.user) return { redirect: loginRedirect(ctx), status: 303 };
|
||||
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
|
||||
csrf.setCookie();
|
||||
if (dashboardPlugin) {
|
||||
const result = (await dashboardPlugin.dashboard(ctx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, ctx, result);
|
||||
await sendResult(ctx.res, result, pluginViewsFor(ctx, dashboardPlugin.id), ctx.localeHref);
|
||||
const pluginCtx = contextFor(dashboardPlugin.id); // as serveHome: the owner's own translator
|
||||
const result = (await dashboardPlugin.dashboard(pluginCtx)) ?? null;
|
||||
if (anyResponseHooks) await runResponseHooks(plugins, pluginCtx, result);
|
||||
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
|
||||
return null;
|
||||
}
|
||||
return { data: { model: buildDashboardModel({ csrfToken: csrf.token, menu, user: ctx.user, nav: ctx.chrome.nav, t: ctx.t }) }, view: "index" };
|
||||
@@ -183,6 +186,9 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// Set before any branch so every response — static/redirect/error included — inherits them
|
||||
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
|
||||
for (const [name, value] of secHeaderEntries) res.setHeader(name, value);
|
||||
// The same URL renders in different languages depending on Accept-Language, so a cache in
|
||||
// front of us must key on it — otherwise the first visitor's language is served to everyone.
|
||||
res.setHeader("vary", "accept-language");
|
||||
|
||||
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
||||
// /public/<id>/… serves a plugin's public/; everything else the core public/.
|
||||
@@ -261,18 +267,22 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
t: pluginId === undefined ? t : i18n.translator(locale, pluginId),
|
||||
});
|
||||
|
||||
// base context (no route params yet); reused for onRequest hooks and the landing routes.
|
||||
// base context (no route params yet); reused for the built-in routes. A plugin-owned render
|
||||
// (a landing slot, a hook short-circuit, a plugin route) gets `contextFor(id)` instead, so its
|
||||
// own catalog is what `ctx.t` reads.
|
||||
const ctx = buildContext(req, res, { chrome, user, ...i18nFor(), log: reqLog, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const contextFor = (pluginId: string, params?: Record<string, string>): RequestContext =>
|
||||
buildContext(req, res, { chrome, user, ...i18nFor(pluginId), log: reqLog, ...(params ? { params } : {}), verifyCsrf, ...(system ? { system } : {}) });
|
||||
renderPage = viewsFor(ctx);
|
||||
|
||||
// Plugin onRequest hooks run before routing and may short-circuit the request.
|
||||
if (anyRequestHooks) {
|
||||
const short = await runRequestHooks(plugins, ctx);
|
||||
const short = await runRequestHooks(plugins, contextFor);
|
||||
if (short) {
|
||||
// Set the fresh CSRF cookie like every other page-emitting path, so a form the hook
|
||||
// renders (its token is in ctx.chrome.csrfToken) has the matching double-submit cookie.
|
||||
csrfMint.setCookie();
|
||||
await sendResult(res, short.result, pluginViewsFor(ctx, short.plugin.id), carryLocale);
|
||||
await sendResult(res, short.result, pluginViewsFor(short.ctx, short.plugin.id), carryLocale);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -282,7 +292,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// CSRF cookie is set so those forms have a valid double-submit token.
|
||||
const match = matchRoute(plugins, method, pathname);
|
||||
if (match) {
|
||||
const routeCtx = buildContext(req, res, { chrome, user, ...i18nFor(match.plugin.id), log: reqLog, params: match.params, verifyCsrf, ...(system ? { system } : {}) });
|
||||
const routeCtx = contextFor(match.plugin.id, match.params);
|
||||
if (!isAuthorized(match.route, routeCtx.permissions)) {
|
||||
// Anonymous → sign in (like the built-in screens' requireSession), remembering the page as
|
||||
// return_to; a signed-in user who simply lacks the permission gets the 403 page.
|
||||
@@ -303,7 +313,7 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// null means the handler wrote to ctx.res itself.
|
||||
const builtin = matchBuiltinRoute(builtinRoutes, method, pathname);
|
||||
if (builtin) {
|
||||
await sendResult(res, await builtin.handler(ctx, csrfMint), viewsFor(ctx), carryLocale);
|
||||
await sendResult(res, await builtin.handler(ctx, csrfMint, contextFor), viewsFor(ctx), carryLocale);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,10 +14,15 @@ export interface RequestCsrf {
|
||||
token: string;
|
||||
}
|
||||
|
||||
// A context scoped to a plugin: same request, but `t` reads that plugin's catalog first. The
|
||||
// landing slots run a plugin's handler, so they must hand it one of these rather than the host's
|
||||
// own context — otherwise the plugin's keys render as bare keys on the pages it owns.
|
||||
export type PluginContextFactory = (pluginId: string) => RequestContext;
|
||||
|
||||
export interface BuiltinRoute {
|
||||
// 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) => Promise<RouteResult | null> | RouteResult | 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
|
||||
path: string; // exact pathname
|
||||
}
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ export function checkCatalog({ baseline, baselineLocale, catalog, locale }: Pari
|
||||
}
|
||||
|
||||
for (const key of Object.keys(catalog)) {
|
||||
if (!(key in baseline)) problems.push(`unknown key "${key}" — add it to ${baselineLocale} first`);
|
||||
if (!Object.hasOwn(baseline, key)) problems.push(`unknown key "${key}" — add it to ${baselineLocale} first`);
|
||||
}
|
||||
|
||||
return problems;
|
||||
|
||||
+14
-9
@@ -21,9 +21,10 @@ const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
export const LOCALES_DIR = join(dirname(fileURLToPath(import.meta.url)), "locales");
|
||||
export const MOUNTED_LOCALES_DIR = join(rootDir, "locales");
|
||||
|
||||
// A catalog file is named for the full locale it holds — sv-SE.ts, never sv.ts. Anything else in
|
||||
// the folder is a mistake worth stopping for.
|
||||
const LOCALE_FILE = /^([a-z]{2,3}-[A-Z]{2})\.ts$/;
|
||||
// A catalog file is named for the full locale it holds — sv-SE.ts, never sv.ts — with the script
|
||||
// subtag when the language needs one (sr-Latn-RS). Anything else in the folder is a mistake worth
|
||||
// stopping for.
|
||||
const LOCALE_FILE = /^([a-z]{2,3}(?:-[A-Z][a-z]{3})?-[A-Z]{2})\.ts$/;
|
||||
|
||||
export interface LoadI18nOptions {
|
||||
localesDir?: string;
|
||||
@@ -46,10 +47,15 @@ export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18
|
||||
const logger = options.logger ?? console;
|
||||
const errors: string[] = [];
|
||||
|
||||
const core = await readSet(localesDir, "core", errors);
|
||||
const shipped = await readSet(localesDir, "core", errors);
|
||||
// The SHIPPED en-US stays the baseline even when the mount replaces it — otherwise a mounted
|
||||
// en-US would only ever be compared against itself, and a one-key rewording would boot green with
|
||||
// the whole UI rendering bare keys.
|
||||
const baseline = shipped.get(DEFAULT_LOCALE);
|
||||
if (!baseline) errors.push(`core: no ${DEFAULT_LOCALE}.ts — it is the baseline every other locale is checked against`);
|
||||
const core = new Map(shipped);
|
||||
for (const [locale, catalog] of await readSet(mountedDir, "locales", errors)) core.set(locale, catalog);
|
||||
if (!core.has(DEFAULT_LOCALE)) errors.push(`core: no ${DEFAULT_LOCALE}.ts — it is the baseline every other locale is checked against`);
|
||||
checkSet(core, "core", errors);
|
||||
checkSet(core, "core", baseline, errors);
|
||||
const available = [...core.keys()].sort();
|
||||
|
||||
const plugins = new Map<string, Map<string, Catalog>>();
|
||||
@@ -62,7 +68,7 @@ export async function loadI18n(options: LoadI18nOptions = {}): Promise<LoadedI18
|
||||
for (const locale of set.keys()) {
|
||||
if (!available.includes(locale)) errors.push(`plugins/${id}: ${locale} is not installed — add src/i18n/locales/${locale}.ts first`);
|
||||
}
|
||||
checkSet(set, `plugins/${id}`, errors);
|
||||
checkSet(set, `plugins/${id}`, set.get(DEFAULT_LOCALE), errors);
|
||||
// Legitimate — the plugin's strings fall back to en-US on that page — but an operator who
|
||||
// installed a locale should hear about the gap at deploy time, not see English islands later.
|
||||
const gaps = available.filter((locale) => !set.has(locale));
|
||||
@@ -103,8 +109,7 @@ async function readSet(dir: string, label: string, errors: string[]): Promise<Ma
|
||||
return set;
|
||||
}
|
||||
|
||||
function checkSet(set: Map<string, Catalog>, label: string, errors: string[]): void {
|
||||
const baseline = set.get(DEFAULT_LOCALE);
|
||||
function checkSet(set: Map<string, Catalog>, label: string, baseline: Catalog | undefined, errors: string[]): void {
|
||||
if (baseline === undefined) return; // already reported; nothing to compare against
|
||||
for (const [locale, catalog] of set) {
|
||||
for (const problem of checkCatalog({ baseline, baselineLocale: DEFAULT_LOCALE, catalog, locale })) {
|
||||
|
||||
@@ -59,6 +59,10 @@ test("localeHref carries the locale on host-relative links only", () => {
|
||||
assert.equal(localeHref("https://example.com/x", "sv-SE"), "https://example.com/x"); // off-site
|
||||
assert.equal(localeHref("//example.com/x", "sv-SE"), "//example.com/x"); // protocol-relative is off-site too
|
||||
assert.equal(localeHref("", "sv-SE"), "");
|
||||
// The building blocks document href as optional (an unlinked page item, a header with no sort
|
||||
// target) — an absent one must not throw, or the page breaks only for visitors who chose a language.
|
||||
assert.equal(localeHref(undefined as unknown as string, "sv-SE"), undefined);
|
||||
assert.equal(localeHref(null as unknown as string, "sv-SE"), null);
|
||||
});
|
||||
|
||||
test("textDirection reads the script direction, defaulting to ltr", () => {
|
||||
|
||||
+10
-3
@@ -58,10 +58,17 @@ export function resolveLocale({ acceptLanguage, available, param }: ResolveInput
|
||||
// Carry `locale` on a host-relative link. Off-site and protocol-relative URLs are left alone — the
|
||||
// locale is ours to state, not theirs. `locale` null (the visitor never asked for one) ⇒ unchanged.
|
||||
export function localeHref(href: string, locale: string | null): string {
|
||||
if (locale === null || href === "" || !href.startsWith("/") || href.startsWith("//")) return href;
|
||||
const url = new URL(href, "http://localhost");
|
||||
// An absent href is a shape the building blocks document as optional (a non-linked page item, a
|
||||
// header with no sort target) — it must not throw here, or a page renders for every visitor
|
||||
// except the ones who chose a language.
|
||||
if (locale === null || !href || href.startsWith("//")) return href;
|
||||
// A query-only href ("?" — the filter bar's documented "clear" target) keeps that shape; anything
|
||||
// else must be host-relative, or it is someone else's URL to state a language for.
|
||||
const queryOnly = href.startsWith("?");
|
||||
if (!queryOnly && !href.startsWith("/")) return href;
|
||||
const url = new URL(href, "http://localhost/");
|
||||
url.searchParams.set("locale", locale);
|
||||
return `${url.pathname}${url.search}${url.hash}`;
|
||||
return queryOnly ? `${url.search}${url.hash}` : `${url.pathname}${url.search}${url.hash}`;
|
||||
}
|
||||
|
||||
// Both are asked for on every render (the <html> tag, the language picker) but depend only on the
|
||||
|
||||
@@ -45,7 +45,6 @@ const messages = {
|
||||
"common.edit": "Edit",
|
||||
"common.new": "New",
|
||||
"common.remove": "Remove",
|
||||
"common.save": "Save",
|
||||
|
||||
"consent.allow": "Allow",
|
||||
"consent.deny": "Deny",
|
||||
|
||||
@@ -36,7 +36,6 @@ const messages: CoreMessages = {
|
||||
"common.edit": "Redigera",
|
||||
"common.new": "Ny",
|
||||
"common.remove": "Ta bort",
|
||||
"common.save": "Spara",
|
||||
|
||||
"consent.allow": "Tillåt",
|
||||
"consent.deny": "Neka",
|
||||
|
||||
@@ -24,6 +24,9 @@ const pluralRules = new Map<string, Intl.PluralRules>();
|
||||
export function createTranslator({ catalogs, locale }: TranslatorOptions): Translate {
|
||||
return (key, vars) => {
|
||||
for (const catalog of catalogs) {
|
||||
// Own keys only: `t("toString")` must fall through to the key itself like any other unknown
|
||||
// one, not pick up Object.prototype.
|
||||
if (!Object.hasOwn(catalog, key)) continue;
|
||||
const message = catalog[key];
|
||||
if (message === undefined) continue;
|
||||
return interpolate(isPluralMessage(message) ? selectPlural(message, locale, vars?.["count"]) : message, vars);
|
||||
@@ -56,6 +59,7 @@ function rulesFor(locale: string): Intl.PluralRules {
|
||||
function interpolate(text: string, vars: TranslateVars | undefined): string {
|
||||
if (vars === undefined) return text;
|
||||
return text.replace(PLACEHOLDER, (whole, name: string) => {
|
||||
if (!Object.hasOwn(vars, name)) return whole;
|
||||
const value = vars[name];
|
||||
return value === undefined ? whole : String(value);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { ENGLISH } from "./english.ts";
|
||||
import { localeHref } from "./locale.ts";
|
||||
import { i18nLocals, type I18nRequest } from "./view-locals.ts";
|
||||
|
||||
const request = (overrides: Partial<I18nRequest> = {}): I18nRequest => ({
|
||||
locale: "sv-SE",
|
||||
localeHref: (href) => href,
|
||||
locales: ["en-US", "sv-SE"],
|
||||
t: ENGLISH,
|
||||
url: new URL("http://localhost/admin/users?q=ada"),
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("localeSwitch offers this same page in every installed locale, marking the current one", () => {
|
||||
const locals = i18nLocals(request());
|
||||
assert.deepEqual(locals.localeSwitch.map((c) => c.href), ["/admin/users?q=ada&locale=en-US", "/admin/users?q=ada&locale=sv-SE"]);
|
||||
assert.deepEqual(locals.localeSwitch.map((c) => c.current), [false, true]);
|
||||
assert.match(locals.localeSwitch[1]?.label ?? "", /svenska/i); // named in its own language
|
||||
});
|
||||
|
||||
test("localeParam is the tag only when the URL asked — it is what the GET forms carry", () => {
|
||||
// The probe asks the very function that decides, so the two can't drift apart.
|
||||
assert.equal(i18nLocals(request()).localeParam, null); // identity localeHref ⇒ nothing was chosen
|
||||
assert.equal(i18nLocals(request({ localeHref: (href) => localeHref(href, "sv-SE") })).localeParam, "sv-SE");
|
||||
});
|
||||
|
||||
test("dir follows the locale's script", () => {
|
||||
assert.equal(i18nLocals(request()).dir, "ltr");
|
||||
assert.equal(i18nLocals(request({ locale: "ar-EG" })).dir, "rtl");
|
||||
});
|
||||
@@ -24,18 +24,22 @@ test("runBootHooks runs each onBoot in order, skips plugins without one, and a t
|
||||
|
||||
test("runRequestHooks short-circuits on the first RouteResult (with its plugin); later hooks skipped", async () => {
|
||||
const calls: string[] = [];
|
||||
const scoped: string[] = []; // each hook is handed a context built for its own plugin
|
||||
const contextFor = (pluginId: string) => { scoped.push(pluginId); return ctx; };
|
||||
const short = await runRequestHooks([
|
||||
plugin("a", { onRequest: () => void calls.push("a") }), // returns void → continue
|
||||
plugin("b", { onRequest: () => { calls.push("b"); return { html: "stop" }; } }),
|
||||
plugin("c", { onRequest: () => void calls.push("c") }), // never reached
|
||||
], ctx);
|
||||
], contextFor);
|
||||
|
||||
assert.deepEqual(short?.result, { html: "stop" });
|
||||
assert.equal(short?.plugin.id, "b"); // the owning plugin (so a `view` result resolves correctly)
|
||||
assert.equal(short?.ctx, ctx); // …and the context it ran on, for rendering its view
|
||||
assert.deepEqual(calls, ["a", "b"]);
|
||||
assert.deepEqual(scoped, ["a", "b"]); // a plugin without the hook never builds a context
|
||||
|
||||
// No hook short-circuits → null (proceed with normal routing).
|
||||
assert.equal(await runRequestHooks([plugin("a", { onRequest: () => {} })], ctx), null);
|
||||
assert.equal(await runRequestHooks([plugin("a", { onRequest: () => {} })], contextFor), null);
|
||||
});
|
||||
|
||||
test("runResponseHooks runs every onResponse as an observer with the result; a throw fails", async () => {
|
||||
|
||||
@@ -13,11 +13,17 @@ export async function runBootHooks(plugins: Plugin[]): Promise<void> {
|
||||
|
||||
// Before route matching. The first hook to return a RouteResult short-circuits the request — its
|
||||
// result becomes the response and later hooks + the route handler are skipped. Returns that result
|
||||
// with its owning plugin (so a `view` result resolves against that plugin's views), or null to proceed.
|
||||
export async function runRequestHooks(plugins: Plugin[], ctx: RequestContext): Promise<{ plugin: Plugin; result: RouteResult } | null> {
|
||||
// with its owning plugin (so a `view` result resolves against that plugin's views), or null to
|
||||
// proceed. Each hook gets a context scoped to its own plugin, so `ctx.t` reads that plugin's catalog.
|
||||
export async function runRequestHooks(
|
||||
plugins: Plugin[],
|
||||
contextFor: (pluginId: string) => RequestContext,
|
||||
): Promise<{ ctx: RequestContext; plugin: Plugin; result: RouteResult } | null> {
|
||||
for (const plugin of plugins) {
|
||||
const result = await plugin.hooks?.onRequest?.(ctx);
|
||||
if (result != null) return { plugin, result };
|
||||
if (!plugin.hooks?.onRequest) continue;
|
||||
const ctx = contextFor(plugin.id);
|
||||
const result = await plugin.hooks.onRequest(ctx);
|
||||
if (result != null) return { ctx, plugin, result };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ export interface ShellModel {
|
||||
export function shellUser(user: User | null | undefined, t: Translate = ENGLISH): ShellUser {
|
||||
if (!user) {
|
||||
const guest = t("shell.guest");
|
||||
return { email: "", initials: guest.slice(0, 1).toUpperCase(), name: guest };
|
||||
return { email: "", initials: ([...guest][0] ?? "?").toUpperCase(), name: guest }; // by character: the word is translated
|
||||
}
|
||||
const local = user.email.split("@")[0] || user.email;
|
||||
return { email: user.email, initials: (local.slice(0, 2) || "U").toUpperCase(), name: local };
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
handler; this renders until then. Data: model { nav, shell }.
|
||||
%><%
|
||||
const nav = include("partials/nav-tree", { nodes: model.nav });
|
||||
// These four messages carry markup (<code> spans), so they render raw — the documented
|
||||
// markup-carrying case. They interpolate nothing, so there is no untrusted data to escape.
|
||||
const body = `
|
||||
<div class="form-page">
|
||||
<section class="form-card">
|
||||
|
||||
@@ -25,14 +25,14 @@
|
||||
<span class="brand-name"><%= brand %></span>
|
||||
</div>
|
||||
<%- include("partials/auth-card", {
|
||||
action: "/oauth2/consent",
|
||||
action: localeHref("/oauth2/consent"),
|
||||
body,
|
||||
method: "post",
|
||||
sub: t("consent.sub", { client: consent.client }),
|
||||
title: t("consent.title", { client: consent.client }),
|
||||
}) %>
|
||||
<% if (consent.account) { %>
|
||||
<form class="auth-alt" method="post" action="/logout">
|
||||
<form class="auth-alt" method="post" action="<%= localeHref("/logout") %>">
|
||||
<input type="hidden" name="<%= csrfField %>" value="<%= csrfToken %>">
|
||||
<%= t("consent.notYou") %> <button type="submit"><%= t("shell.signOut") %></button>
|
||||
</form>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
const providers = (sso && sso.providers) || [];
|
||||
const alt = locals.alt;
|
||||
-%>
|
||||
<form class="auth-card" method="<%= method %>"<% if (locals.action) { %> action="<%= locals.action %>"<% } %>>
|
||||
<form class="auth-card" method="<%= method %>"<% if (locals.action) { %> action="<%= localeHref(locals.action) %>"<% } %>>
|
||||
<div class="auth-head"><% if (back) { %><a class="auth-back" href="<%= localeHref(back.href) %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-arrow-left"/></svg><%= back.label %></a><% } %><h1><%= locals.title %></h1><% if (locals.sub) { %><p class="auth-sub"><%= locals.sub %></p><% } %></div>
|
||||
<% if (providers.length) { -%>
|
||||
<div class="sso" aria-label="<%= sso.label || t("auth.sso.label") %>">
|
||||
|
||||
@@ -11,14 +11,14 @@
|
||||
Item ∈ { head } · { sep } · { label, icon?, href? ⇒ <a>, hreflang?, current?, danger? } (default: menu-item button)
|
||||
· { group: { legend?, name, control?(="checkbox"|"radio"), options:{value,label,checked?}[] } }
|
||||
%><%
|
||||
const t = locals.trigger || {};
|
||||
const sumCls = "class" in t ? t.class : "btn";
|
||||
const trigger = locals.trigger || {}; // not `t` — that name is the translator in every view
|
||||
const sumCls = "class" in trigger ? trigger.class : "btn";
|
||||
const items = locals.items || [];
|
||||
const popCls = "menu-pop" + (locals.align === "left" ? " left" : "") + (locals.up ? " up" : "");
|
||||
const width = locals.width;
|
||||
-%>
|
||||
<details class="menu<%= locals.kebab ? " kebab" : "" %>"<%= locals.open ? " open" : "" %>>
|
||||
<summary<% if (sumCls) { %> class="<%= sumCls %>"<% } %><% if (t.label) { %> aria-label="<%= t.label %>"<% } %>><% if (t.html != null) { %><%- t.html %><% } else { if (t.icon) { %><svg class="ico ico-sm"><use href="#<%= t.icon %>"/></svg><% } if (t.text) { %><%= t.text %><% } } %></summary>
|
||||
<summary<% if (sumCls) { %> class="<%= sumCls %>"<% } %><% if (trigger.label) { %> aria-label="<%= trigger.label %>"<% } %>><% if (trigger.html != null) { %><%- trigger.html %><% } else { if (trigger.icon) { %><svg class="ico ico-sm"><use href="#<%= trigger.icon %>"/></svg><% } if (trigger.text) { %><%= trigger.text %><% } } %></summary>
|
||||
<div class="<%= popCls %>"<% if (width != null) { %> style="min-width:<%= typeof width === "number" ? width + "px" : width %>"<% } %>>
|
||||
<% items.forEach((it) => { -%>
|
||||
<% if (it.head != null) { -%>
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
<div class="menu-head"><%= t("shell.signedInAs", { name: user.name }) %></div>
|
||||
<button class="menu-item" type="button"><svg class="ico"><use href="#i-user" /></svg><%= t("shell.profile") %></button>
|
||||
<%# Sign out is a state change → a POST form (not a GET link), CSRF-guarded by app.ts %>
|
||||
<form class="menu-item-form" method="post" action="/logout">
|
||||
<form class="menu-item-form" method="post" action="<%= localeHref("/logout") %>">
|
||||
<input type="hidden" name="_csrf" value="<%= locals.csrfToken || '' %>" />
|
||||
<button class="menu-item danger" type="submit"><svg class="ico"><use href="#i-logout" /></svg><%= t("shell.signOut") %></button>
|
||||
</form>
|
||||
|
||||
Reference in New Issue
Block a user