Add i18n support: per-locale catalogs, URL-driven locale, translated core and examples #44
@@ -74,6 +74,25 @@ test.describe.serial("authenticated admin journey", () => {
|
|||||||
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A POST that re-renders a page: the write must keep the language, and the picker must not offer
|
||||||
|
// a link to a URL that only answers POST.
|
||||||
|
test("a write keeps the visitor's language, and offers no language link on the POST-rendered page", async () => {
|
||||||
|
await page.goto("/admin/users?locale=sv-SE");
|
||||||
|
await page.getByRole("link", { name: "Ny användare" }).click();
|
||||||
|
await page.fill('input[name="email"]', `lang-${suffix}@plainpages.local`);
|
||||||
|
await page.getByRole("button", { name: "Skapa användare" }).click();
|
||||||
|
await expect(page).toHaveURL(/locale=sv-SE/); // the POST → redirect → GET keeps it
|
||||||
|
await expect(page.locator("html")).toHaveAttribute("lang", "sv-SE");
|
||||||
|
|
||||||
|
// Open the new user's edit page the way the CRUD test does — the row's Edit link carries the id.
|
||||||
|
const row = page.locator("tr", { hasText: `lang-${suffix}@plainpages.local` });
|
||||||
|
const editHref = await row.locator('a[href^="/admin/users/"]').first().getAttribute("href");
|
||||||
|
await page.goto(`${editHref}`);
|
||||||
|
await page.getByRole("button", { name: "Skapa återställningskod" }).click(); // POST-only route
|
||||||
|
await expect(page.getByText("Återställningskod skapad")).toBeVisible();
|
||||||
|
await expect(page.locator('summary[aria-label="Språk"]')).toHaveCount(0); // no dead-end link offered
|
||||||
|
});
|
||||||
|
|
||||||
test("menu filters by permission: an admin sees the gated Admin section + the plugin", async () => {
|
test("menu filters by permission: 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
|
// 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).
|
// in the menu (collapsed by default → assert they're in the DOM, not necessarily visible).
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<p><%= locals.message %></p>
|
<p><%= locals.message %></p>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<a class="btn" href="<%= localeHref(locals.cancelHref) %>"><%= t("common.cancel") %></a>
|
<a class="btn" href="<%= localeHref(locals.cancelHref) %>"><%= t("common.cancel") %></a>
|
||||||
<form method="post" action="<%= locals.confirm.action %>"><input type="hidden" name="_csrf" value="<%= locals.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= locals.confirm.label %></button></form>
|
<form method="post" action="<%= localeHref(locals.confirm.action) %>"><input type="hidden" name="_csrf" value="<%= locals.csrfToken %>"><button class="btn btn-danger" type="submit"><svg class="ico ico-sm" aria-hidden="true"><use href="#i-trash"/></svg><%= locals.confirm.label %></button></form>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+13
-9
@@ -14,7 +14,7 @@ import { GuardError, loginRedirect } from "../auth/guards.ts";
|
|||||||
import { ENGLISH_I18N } from "../i18n/english.ts";
|
import { ENGLISH_I18N } from "../i18n/english.ts";
|
||||||
import type { I18n } from "../i18n/runtime.ts";
|
import type { I18n } from "../i18n/runtime.ts";
|
||||||
import { localeHref } from "../i18n/locale.ts";
|
import { localeHref } from "../i18n/locale.ts";
|
||||||
import { ENGLISH_LOCALS, i18nLocals } from "../i18n/view-locals.ts";
|
import { ENGLISH_LOCALS, i18nLocals, type I18nRequest } from "../i18n/view-locals.ts";
|
||||||
import { runRequestHooks, runResponseHooks } from "../plugin-host/hooks.ts";
|
import { runRequestHooks, runResponseHooks } from "../plugin-host/hooks.ts";
|
||||||
import type { HydraAdmin } from "../auth/hydra-admin.ts";
|
import type { HydraAdmin } from "../auth/hydra-admin.ts";
|
||||||
import type { JwksProvider } from "../auth/jwks.ts";
|
import type { JwksProvider } from "../auth/jwks.ts";
|
||||||
@@ -120,8 +120,9 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
// it. A plugin's context carries that plugin's translator, so its own catalog wins in its own views.
|
// it. A plugin's context carries that plugin's translator, so its own catalog wins in its own views.
|
||||||
// They are merged LAST: these names are reserved (README → Building plugins), and a handler that
|
// They are merged LAST: these names are reserved (README → Building plugins), and a handler that
|
||||||
// happens to use one loses that key rather than breaking the shell that renders around it.
|
// happens to use one loses that key rather than breaking the shell that renders around it.
|
||||||
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(ctx) });
|
const localsOf = (ctx: RequestContext): I18nRequest => ({ ...ctx, method: ctx.req.method ?? "GET" });
|
||||||
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(ctx) });
|
const viewsFor = (ctx: RequestContext): ViewRenderer => (view, data) => render(view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
||||||
|
const pluginViewsFor = (ctx: RequestContext, id: string): ViewRenderer => (view, data) => renderView(id, view, { ...data, ...i18nLocals(localsOf(ctx)) });
|
||||||
|
|
||||||
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
|
||||||
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
res.writeHead(status, { "content-type": "text/html; charset=utf-8" });
|
||||||
@@ -138,7 +139,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
// 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;
|
const result = (await homePlugin.home(pluginCtx)) ?? null;
|
||||||
if (anyResponseHooks) await runResponseHooks(plugins, pluginCtx, result);
|
if (anyResponseHooks) await runResponseHooks(plugins, contextFor, result);
|
||||||
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, homePlugin.id), pluginCtx.localeHref);
|
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, homePlugin.id), pluginCtx.localeHref);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -156,7 +157,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
if (dashboardPlugin) {
|
if (dashboardPlugin) {
|
||||||
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;
|
const result = (await dashboardPlugin.dashboard(pluginCtx)) ?? null;
|
||||||
if (anyResponseHooks) await runResponseHooks(plugins, pluginCtx, result);
|
if (anyResponseHooks) await runResponseHooks(plugins, contextFor, result);
|
||||||
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
|
await sendResult(ctx.res, result, pluginViewsFor(pluginCtx, dashboardPlugin.id), pluginCtx.localeHref);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -186,9 +187,6 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
// Set before any branch so every response — static/redirect/error included — inherits them
|
// 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).
|
// (writeHead merges these with its own headers; a plugin's RouteResult.headers can override).
|
||||||
for (const [name, value] of secHeaderEntries) res.setHeader(name, value);
|
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")) {
|
if (pathname.startsWith("/public/") && (method === "GET" || method === "HEAD")) {
|
||||||
// /public/<id>/… serves a plugin's public/; everything else the core public/.
|
// /public/<id>/… serves a plugin's public/; everything else the core public/.
|
||||||
@@ -198,6 +196,12 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Rendered pages content-negotiate on Accept-Language, so a cache in front of us must key on
|
||||||
|
// it — otherwise the first visitor's language is served to everyone. Set after the static
|
||||||
|
// branch above: an asset is the same bytes in every language, and a Vary there would fragment
|
||||||
|
// its cache entry per raw header string.
|
||||||
|
res.setHeader("vary", "accept-language");
|
||||||
|
|
||||||
// Canonical host (APP_URL): a visitor who reached us on a different host (localhost vs
|
// Canonical host (APP_URL): a visitor who reached us on a different host (localhost vs
|
||||||
// 127.0.0.1, a secondary domain) is sent to the configured origin, path + query preserved, so
|
// 127.0.0.1, a secondary domain) is sent to the configured origin, path + query preserved, so
|
||||||
// the browser, the themed forms, and the cross-origin Kratos POST all share one cookie host —
|
// the browser, the themed forms, and the cross-origin Kratos POST all share one cookie host —
|
||||||
@@ -303,7 +307,7 @@ export function createApp(options: AppOptions = {}): Server {
|
|||||||
}
|
}
|
||||||
csrfMint.setCookie();
|
csrfMint.setCookie();
|
||||||
const result = (await match.route.handler(routeCtx)) ?? null;
|
const result = (await match.route.handler(routeCtx)) ?? null;
|
||||||
if (anyResponseHooks) await runResponseHooks(plugins, routeCtx, result); // observers; a throw → 500
|
if (anyResponseHooks) await runResponseHooks(plugins, contextFor, result); // observers; a throw → 500
|
||||||
await sendResult(res, result, pluginViewsFor(routeCtx, match.plugin.id), carryLocale);
|
await sendResult(res, result, pluginViewsFor(routeCtx, match.plugin.id), carryLocale);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -24,7 +24,7 @@ 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 — with the script
|
// 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
|
// subtag when the language needs one (sr-Latn-RS). Anything else in the folder is a mistake worth
|
||||||
// stopping for.
|
// stopping for.
|
||||||
const LOCALE_FILE = /^([a-z]{2,3}(?:-[A-Z][a-z]{3})?-[A-Z]{2})\.ts$/;
|
const LOCALE_FILE = /^([a-z]{2,3}(?:-[A-Z][a-z]{3})?-(?:[A-Z]{2}|[0-9]{3}))\.ts$/;
|
||||||
|
|
||||||
export interface LoadI18nOptions {
|
export interface LoadI18nOptions {
|
||||||
localesDir?: string;
|
localesDir?: string;
|
||||||
@@ -90,7 +90,7 @@ async function readSet(dir: string, label: string, errors: string[]): Promise<Ma
|
|||||||
if (entry.isDirectory() || entry.name.startsWith(".")) continue;
|
if (entry.isDirectory() || entry.name.startsWith(".")) continue;
|
||||||
const locale = LOCALE_FILE.exec(entry.name)?.[1];
|
const locale = LOCALE_FILE.exec(entry.name)?.[1];
|
||||||
if (locale === undefined) {
|
if (locale === undefined) {
|
||||||
errors.push(`${label}: "${entry.name}" is not a locale catalog — name it <language>-<REGION>.ts (e.g. sv-SE.ts)`);
|
errors.push(`${label}: "${entry.name}" is not a locale catalog — name it <language>-<REGION>.ts (sv-SE.ts, es-419.ts, sr-Latn-RS.ts)`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mod: { default?: unknown };
|
let mod: { default?: unknown };
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const request = (overrides: Partial<I18nRequest> = {}): I18nRequest => ({
|
|||||||
locale: "sv-SE",
|
locale: "sv-SE",
|
||||||
localeHref: (href) => href,
|
localeHref: (href) => href,
|
||||||
locales: ["en-US", "sv-SE"],
|
locales: ["en-US", "sv-SE"],
|
||||||
|
method: "GET",
|
||||||
t: ENGLISH,
|
t: ENGLISH,
|
||||||
url: new URL("http://localhost/admin/users?q=ada"),
|
url: new URL("http://localhost/admin/users?q=ada"),
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -30,3 +31,9 @@ test("dir follows the locale's script", () => {
|
|||||||
assert.equal(i18nLocals(request()).dir, "ltr");
|
assert.equal(i18nLocals(request()).dir, "ltr");
|
||||||
assert.equal(i18nLocals(request({ locale: "ar-EG" })).dir, "rtl");
|
assert.equal(i18nLocals(request({ locale: "ar-EG" })).dir, "rtl");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("a page rendered from a POST offers no language links — that URL may have no GET at all", () => {
|
||||||
|
// Following one would dead-end on a 405 (a POST-only route), or silently discard a re-rendered
|
||||||
|
// form's input. The picker renders nothing below two choices, so an empty list hides it.
|
||||||
|
assert.deepEqual(i18nLocals(request({ method: "POST" })).localeSwitch, []);
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ export interface I18nRequest {
|
|||||||
locale: string;
|
locale: string;
|
||||||
localeHref: (href: string) => string;
|
localeHref: (href: string) => string;
|
||||||
locales: string[];
|
locales: string[];
|
||||||
|
method: string; // a page rendered in response to a POST has no linkable URL — see localeSwitch
|
||||||
t: Translate;
|
t: Translate;
|
||||||
url: URL;
|
url: URL;
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,10 @@ export const ENGLISH_LOCALS: I18nLocals = {
|
|||||||
|
|
||||||
export function i18nLocals(ctx: I18nRequest): I18nLocals {
|
export function i18nLocals(ctx: I18nRequest): I18nLocals {
|
||||||
const here = `${ctx.url.pathname}${ctx.url.search}`;
|
const here = `${ctx.url.pathname}${ctx.url.search}`;
|
||||||
|
// The picker links to this same page in another language. After a POST that page's URL often has
|
||||||
|
// no GET at all (the admin's recovery-code screen, say), so linking there would dead-end on a 405
|
||||||
|
// — and on a re-rendered form it would silently discard what the user typed. Offer nothing.
|
||||||
|
const linkable = ctx.method === "GET" || ctx.method === "HEAD";
|
||||||
// ctx.localeHref is a no-op unless the URL asked for a locale, so it is also the honest answer to
|
// ctx.localeHref is a no-op unless the URL asked for a locale, so it is also the honest answer to
|
||||||
// "did it?" — asking the function that decides keeps the two from drifting apart.
|
// "did it?" — asking the function that decides keeps the two from drifting apart.
|
||||||
const carried = ctx.localeHref("/") === "/" ? null : ctx.locale;
|
const carried = ctx.localeHref("/") === "/" ? null : ctx.locale;
|
||||||
@@ -58,7 +63,7 @@ export function i18nLocals(ctx: I18nRequest): I18nLocals {
|
|||||||
locale: ctx.locale,
|
locale: ctx.locale,
|
||||||
localeHref: (href) => ctx.localeHref(href),
|
localeHref: (href) => ctx.localeHref(href),
|
||||||
localeParam: carried,
|
localeParam: carried,
|
||||||
localeSwitch: ctx.locales.map((tag) => ({ current: tag === ctx.locale, href: localeHref(here, tag), label: localeLabel(tag), tag })),
|
localeSwitch: linkable ? ctx.locales.map((tag) => ({ current: tag === ctx.locale, href: localeHref(here, tag), label: localeLabel(tag), tag })) : [],
|
||||||
locales: ctx.locales,
|
locales: ctx.locales,
|
||||||
t: ctx.t,
|
t: ctx.t,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -44,11 +44,12 @@ test("runRequestHooks short-circuits on the first RouteResult (with its plugin);
|
|||||||
|
|
||||||
test("runResponseHooks runs every onResponse as an observer with the result; a throw fails", async () => {
|
test("runResponseHooks runs every onResponse as an observer with the result; a throw fails", async () => {
|
||||||
const seen: unknown[] = [];
|
const seen: unknown[] = [];
|
||||||
|
const contextFor = () => ctx; // each observer gets a context scoped to its own plugin
|
||||||
await runResponseHooks([
|
await runResponseHooks([
|
||||||
plugin("a", { onResponse: (_c, r) => void seen.push(r) }),
|
plugin("a", { onResponse: (_c, r) => void seen.push(r) }),
|
||||||
plugin("b", {}), // no onResponse → skipped
|
plugin("b", {}), // no onResponse → skipped
|
||||||
], ctx, { html: "ok" });
|
], contextFor, { html: "ok" });
|
||||||
assert.deepEqual(seen, [{ html: "ok" }]);
|
assert.deepEqual(seen, [{ html: "ok" }]);
|
||||||
|
|
||||||
await assert.rejects(runResponseHooks([plugin("x", { onResponse: () => { throw new Error("boom"); } })], ctx, null), /boom/);
|
await assert.rejects(runResponseHooks([plugin("x", { onResponse: () => { throw new Error("boom"); } })], contextFor, null), /boom/);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,7 +29,14 @@ export async function runRequestHooks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// After a route handler produces its result. Observers only — the return value is ignored, so a
|
// After a route handler produces its result. Observers only — the return value is ignored, so a
|
||||||
// hook cannot change the response; a throw fails the request.
|
// hook cannot change the response; a throw fails the request. Each observer gets a context scoped to
|
||||||
export async function runResponseHooks(plugins: Plugin[], ctx: RequestContext, result: RouteResult | null): Promise<void> {
|
// its own plugin, like onRequest, so `ctx.t` is never another plugin's translator.
|
||||||
for (const plugin of plugins) await plugin.hooks?.onResponse?.(ctx, result);
|
export async function runResponseHooks(
|
||||||
|
plugins: Plugin[],
|
||||||
|
contextFor: (pluginId: string) => RequestContext,
|
||||||
|
result: RouteResult | null,
|
||||||
|
): Promise<void> {
|
||||||
|
for (const plugin of plugins) {
|
||||||
|
if (plugin.hooks?.onResponse) await plugin.hooks.onResponse(contextFor(plugin.id), result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
- [x] The human developer understands the security model in the auth in this project. (Two README sections. [Users, groups & permissions](README.md#users-groups--permissions) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `permission:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.)
|
- [x] The human developer understands the security model in the auth in this project. (Two README sections. [Users, groups & permissions](README.md#users-groups--permissions) carries the weight: the entity model, a worked graph, a per-route can/cannot walkthrough, and the trap that a per-row grant never widens a coarse gate — placed before Building plugins because a manifest's `permission:` gate is unreadable without it. [Security model](README.md#security-model) is deliberately short, only the facts a deployment gets wrong without them: the private network as the *only* guard on the Ory APIs, signed-not-encrypted claims, the 30-day Kratos session behind the ~10m JWT, and non-instant offboarding. The first attempt answered the *threat* model instead — a 12-row attack/defense table — which was the wrong question and mostly restated code readable at its source; cut. Also corrected the hardening checklist: `REQUIRE_SECURE_SECRETS` guards only `CSRF_SECRET`, so the committed Kratos/Hydra/Postgres/demo-admin secrets are now listed in "What you must supply". The mandatory-`exp` guard gained a test in `src/auth/jwt-middleware.test.ts`.)
|
||||||
- [x] Add i18n support. (Catalogs are TS modules per locale — `src/i18n/locales/<tag>.ts` for the host, `plugins/<id>/i18n/<tag>.ts` for a plugin, looked up plugin-first then core; en-US + sv-SE ship. A request is served by `?locale=sv-SE` → `Accept-Language` → `en-US`, exact on a full tag but a lone language takes the first regional catalog; no cookie — when the URL asked, the host carries `?locale` onto the links it renders and `ctx.localeHref()` does it for a plugin's. `ctx.t(key, vars)` plus `t`/`locale`/`locales`/`localeHref`/`dir` merged into every view (any include depth); `{{var}}` interpolation, plurals via `Intl.PluralRules`, an unknown key renders as itself — which is what makes a nav label either a key or plain text. Every catalog is checked against its set's en-US at boot (keys, kind, plural categories) and a mismatch stops startup. Kratos' own flow text is mapped by its numeric id (only ids verified against the live stack; its generic trait-label id is deliberately unmapped, field labels key on the input name instead). Zero-JS language picker in the shell + the auth/consent pages, `<html lang dir>` from the locale. Core, both example plugins and their views translated; unit tests + `e2e-tests/language.spec.ts` in the visual gate; documented in README → Languages, decisions in AGENTS.md.)
|
- [x] Add i18n support. (Catalogs are TS modules per locale — `src/i18n/locales/<tag>.ts` for the host, `plugins/<id>/i18n/<tag>.ts` for a plugin, looked up plugin-first then core; en-US + sv-SE ship. A request is served by `?locale=sv-SE` → `Accept-Language` → `en-US`, exact on a full tag but a lone language takes the first regional catalog; no cookie — when the URL asked, the host carries `?locale` onto the links it renders and `ctx.localeHref()` does it for a plugin's. `ctx.t(key, vars)` plus `t`/`locale`/`locales`/`localeHref`/`dir` merged into every view (any include depth); `{{var}}` interpolation, plurals via `Intl.PluralRules`, an unknown key renders as itself — which is what makes a nav label either a key or plain text. Every catalog is checked against its set's en-US at boot (keys, kind, plural categories) and a mismatch stops startup. Kratos' own flow text is mapped by its numeric id (only ids verified against the live stack; its generic trait-label id is deliberately unmapped, field labels key on the input name instead). Zero-JS language picker in the shell + the auth/consent pages, `<html lang dir>` from the locale. Core, both example plugins and their views translated; unit tests + `e2e-tests/language.spec.ts` in the visual gate; documented in README → Languages, decisions in AGENTS.md.)
|
||||||
- [x] Settle the identity-vs-user vocabulary. (Plainpages says **user** everywhere — Keto namespace `User`, subjects `user:<kratos-id>`, `ctx.user`. Ory calls the record an "identity", but its own docs say it uses that term interchangeably with "users"/"accounts", so this is house style rather than a renamed concept, and "user" is the word readers know (Nielsen heuristic #2). README → Auth carries one note recording the mapping; the only place Ory's spelling survives is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors the Kratos wire shape. Recorded in AGENTS.md.)
|
- [x] Settle the identity-vs-user vocabulary. (Plainpages says **user** everywhere — Keto namespace `User`, subjects `user:<kratos-id>`, `ctx.user`. Ory calls the record an "identity", but its own docs say it uses that term interchangeably with "users"/"accounts", so this is house style rather than a renamed concept, and "user" is the word readers know (Nielsen heuristic #2). README → Auth carries one note recording the mapping; the only place Ory's spelling survives is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors the Kratos wire shape. Recorded in AGENTS.md.)
|
||||||
|
- [ ] Decide the caching contract for rendered pages. Responses now carry `Vary: Accept-Language` (they content-negotiate), but nothing sets `Cache-Control` — so a shared cache in front of the app has no instruction, and a signed-in page is not marked `private`. Pre-existing, surfaced by the i18n review 2026-08-03: either set the headers deliberately (public pages cacheable, gated pages `private, no-store`) or record in AGENTS.md that the reverse proxy owns this.
|
||||||
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule (`utils`, `helpers`, `misc`) applied to namespaces. Raised 2026-08-03; a design question, not a naming one.
|
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule (`utils`, `helpers`, `misc`) applied to namespaces. Raised 2026-08-03; a design question, not a naming one.
|
||||||
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer.
|
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin (a sibling subdomain, or a plaintext hop with `SECURE_COOKIES=false`) can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md → "Deliberate architectural deviations" and in README → Security model under "Not guaranteed"; not accepted ⇒ bind the nonce to `sub` (small change). Raised by review 2026-08-02; left undecided because it is a maintainer call, and an undocumented exception reads as a bug to the next reviewer.
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,6 @@
|
|||||||
<%- include("menu", {
|
<%- include("menu", {
|
||||||
up: locals.up !== false,
|
up: locals.up !== false,
|
||||||
trigger: { class: "btn icon-btn", icon: "i-globe", label: t("locale.label") },
|
trigger: { class: "btn icon-btn", icon: "i-globe", label: t("locale.label") },
|
||||||
items: [{ head: t("locale.label") }, ...choices.map((c) => ({ current: c.current, href: c.href, hreflang: c.tag, label: c.label }))],
|
items: [{ head: t("locale.label") }, ...choices.map((c) => ({ current: c.current, href: c.href, hreflang: c.tag, label: c.label, ownLocale: true }))],
|
||||||
}) %>
|
}) %>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
kebab? boolean bare kebab trigger (adds .kebab)
|
kebab? boolean bare kebab trigger (adds .kebab)
|
||||||
width? number|string popover min-width (number ⇒ px)
|
width? number|string popover min-width (number ⇒ px)
|
||||||
items: Item[] popover content, top→bottom
|
items: Item[] popover content, top→bottom
|
||||||
Item ∈ { head } · { sep } · { label, icon?, href? ⇒ <a>, hreflang?, current?, danger? } (default: menu-item button)
|
Item ∈ { head } · { sep } · { label, icon?, href? ⇒ <a>, hreflang?, ownLocale?, current?, danger? } (default: menu-item button)
|
||||||
|
ownLocale: the href already states its language (the picker) — don't carry the current one onto it
|
||||||
· { group: { legend?, name, control?(="checkbox"|"radio"), options:{value,label,checked?}[] } }
|
· { group: { legend?, name, control?(="checkbox"|"radio"), options:{value,label,checked?}[] } }
|
||||||
%><%
|
%><%
|
||||||
const trigger = locals.trigger || {}; // not `t` — that name is the translator in every view
|
const trigger = locals.trigger || {}; // not `t` — that name is the translator in every view
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
<% } else if (it.group) { const g = it.group; -%>
|
<% } else if (it.group) { const g = it.group; -%>
|
||||||
<fieldset class="menu-field"><% if (g.legend) { %><legend class="menu-head"><%= g.legend %></legend><% } %><% g.options.forEach((o) => { %><label class="menu-check"><input type="<%= g.control || "checkbox" %>" name="<%= g.name %>" value="<%= o.value %>"<%= o.checked ? " checked" : "" %>><%= o.label %></label><% }) %></fieldset>
|
<fieldset class="menu-field"><% if (g.legend) { %><legend class="menu-head"><%= g.legend %></legend><% } %><% g.options.forEach((o) => { %><label class="menu-check"><input type="<%= g.control || "checkbox" %>" name="<%= g.name %>" value="<%= o.value %>"<%= o.checked ? " checked" : "" %>><%= o.label %></label><% }) %></fieldset>
|
||||||
<% } else if (it.href) { -%>
|
<% } else if (it.href) { -%>
|
||||||
<a class="menu-item<%= it.danger ? " danger" : "" %>" href="<%= it.hreflang ? it.href : localeHref(it.href) %>"<% if (it.hreflang) { %> hreflang="<%= it.hreflang %>" lang="<%= it.hreflang %>"<% } %><% if (it.current) { %> aria-current="true"<% } %>><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></a>
|
<a class="menu-item<%= it.danger ? " danger" : "" %>" href="<%= it.ownLocale ? it.href : localeHref(it.href) %>"<% if (it.hreflang) { %> hreflang="<%= it.hreflang %>" lang="<%= it.hreflang %>"<% } %><% if (it.current) { %> aria-current="true"<% } %>><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></a>
|
||||||
<% } else { -%>
|
<% } else { -%>
|
||||||
<button class="menu-item<%= it.danger ? " danger" : "" %>" type="button"><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></button>
|
<button class="menu-item<%= it.danger ? " danger" : "" %>" type="button"><% if (it.icon) { %><svg class="ico"><use href="#<%= it.icon %>"/></svg><% } %><%= it.label %></button>
|
||||||
<% } -%>
|
<% } -%>
|
||||||
|
|||||||
Reference in New Issue
Block a user