Render Kratos self-service flows as themed pages (todo §4); buildFlowView + views/auth.ejs + login/registration/recovery/verification/settings routes

This commit is contained in:
2026-06-17 17:55:56 +02:00
parent 2a64cfd409
commit 0928f9dd39
11 changed files with 405 additions and 4 deletions

View File

@@ -493,12 +493,13 @@ mid-response, so container restarts are clean.
```
src/server.ts Entry point — starts the HTTP server (reads PORT, default 3000)
src/app.ts Request routing + EJS rendering
src/app.ts Request routing + EJS rendering (incl. the themed Kratos self-service routes, §4)
src/static.ts Static file serving (path-traversal protection) + routePublic(): /public/<id>/ → a plugin's public/
src/jwt.ts JWS signature verify via node:crypto, no jose; claims+JWKS are §4
src/kratos-public.ts createKratosPublic(): Kratos public-API fetch client — self-service flow init/get/submit, whoami, session→JWT tokenize (§4)
src/kratos-admin.ts createKratosAdmin(): Kratos admin-API fetch client — identity CRUD + surgical metadata_admin update (login role projection, §4)
src/keto-client.ts createKetoClient(): Keto fetch client — check / list / expand relations (read API) + write / delete tuples (write API) (§4)
src/flow-view.ts buildFlowView(): Kratos self-service Flow → themed view model (fields, hidden csrf, buttons, tone-mapped messages) for views/auth.ejs (§4)
src/gen-jwks.ts generateJwks() + CLI: mint the ES256 session-tokenizer signing JWKS (§3); see JWT signing key & rotation
src/bootstrap.ts One-command bootstrap (§3): idempotent first-boot seed — JWKS-if-absent, demo admin in Kratos, admin role in Keto
src/cookie.ts Cookie parse + secure Set-Cookie build (session/CSRF cookies, §4)
@@ -514,7 +515,7 @@ src/discovery.ts discoverPlugins(): scan plugins/, import + validate each pl
src/router.ts matchRoute()/allowedMethods()/isAuthorized(): map method+path → plugin route, params, permission gate (§2)
src/view-resolver.ts renderPluginView(): render plugins/<id>/views/<view>.ejs; plugin views can include() core partials (§2)
src/menu-config.ts loadMenuConfig()/defineMenu(): read config/menu.ts (central override + branding), validated at boot (§2)
views/ Core EJS templates (index = the app-shell People dashboard, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, menu/popover, theme switch, icon sprite)
views/ Core EJS templates (index = the app-shell People dashboard, auth = themed Kratos self-service page, 403/404/500, partials/ incl. app shell, nav tree, filter bar, data table, pagination, form field, auth card, alert, flow body, menu/popover, theme switch, icon sprite)
public/ Static assets under /public/ (css/styles.css + auth.css, favicon, robots.txt)
config/menu.ts Central menu override + branding (optional; defaults apply if absent)
ory/ Ory service config (kratos/: identity schema, kratos.yml, oidc/ SSO claims mapper, tokenizer/ session→JWT claims mapper + dev signing JWKS; keto/: keto.yml + namespaces.keto.ts OPL — role/group/resource; hydra/hydra.yml: OAuth2 issuer + login/consent URLs) + storage init (postgres/init/init.sql: one DB per service)

View File

@@ -592,6 +592,20 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
.badge.warn { color: var(--warn); background: var(--warn-bg); border-color: var(--warn-bd); }
.badge.info { color: var(--info); background: var(--info-bg); border-color: var(--info-bd); }
/* alert / notice banner (tone tokens) — auth flows + admin screens */
.alert {
display: flex; gap: 8px; align-items: flex-start;
padding: 10px 12px; border-radius: var(--radius);
border: 1px solid var(--border); font-size: var(--fz-sm);
}
.alert > .ico { flex: 0 0 auto; margin-top: 1px; }
.alert-body { display: flex; flex-direction: column; gap: 2px; }
.alert-body strong { font-weight: 600; }
.alert.alert-pos { color: var(--pos); background: var(--pos-bg); border-color: var(--pos-bd); }
.alert.alert-neg { color: var(--neg); background: var(--neg-bg); border-color: var(--neg-bd); }
.alert.alert-warn { color: var(--warn); background: var(--warn-bg); border-color: var(--warn-bd); }
.alert.alert-info { color: var(--info); background: var(--info-bg); border-color: var(--info-bd); }
/* row kebab */
.col-actions { width: 44px; text-align: center; }
.kebab summary { width: 26px; height: 26px; border-radius: var(--radius);

View File

@@ -6,6 +6,7 @@ import { dirname, join } from "node:path";
import { after, before, test, type TestContext } from "node:test";
import { fileURLToPath } from "node:url";
import { createApp } from "./app.ts";
import { KratosError, type Flow, type FlowType, type KratosPublic, type UiNode } from "./kratos-public.ts";
import type { Plugin } from "./plugin.ts";
import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
@@ -199,6 +200,67 @@ test("plugin hooks: onRequest can short-circuit a request and onResponse observe
assert.ok(seen.includes("/hooked/ok:handler ran"));
});
// A re-rendered login flow: csrf hidden, themed fields, a submit, and a failed-attempt message.
const node = (attrs: Record<string, unknown>, label?: string): UiNode => ({ attributes: attrs, group: "default", messages: [], meta: label ? { label: { id: 1, text: label, type: "info" } } : {}, type: "input" });
const loginFlow = (id: string): Flow => ({
id,
ui: {
action: `http://127.0.0.1:4433/self-service/login?flow=${id}`,
messages: [{ id: 4000006, text: "The provided credentials are invalid.", type: "error" }],
method: "post",
nodes: [
node({ name: "csrf_token", type: "hidden", value: "tok" }),
node({ name: "identifier", required: true, type: "email" }, "E-Mail"),
node({ name: "password", required: true, type: "password" }, "Password"),
node({ name: "method", type: "submit", value: "password" }, "Sign in"),
],
},
});
function mockKratos(getFlow: KratosPublic["getFlow"]): KratosPublic {
return {
getFlow,
initBrowserFlow: async (_t: FlowType) => ({ flow: { id: "new1", ui: { action: "", method: "post", nodes: [] } }, setCookie: ["csrf_token=abc; Path=/; HttpOnly"] }),
submitFlow: async () => { throw new Error("unused"); },
whoami: async () => null,
};
}
test("themed flow init: no ?flow= initialises one, relays Kratos' CSRF cookie, and an expired flow restarts", async (t) => {
const app = createApp({ kratos: mockKratos(async (_t, id) => { if (id === "stale") throw new KratosError("gone", 410, ""); return loginFlow(id); }) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const url = `http://localhost:${(app.address() as AddressInfo).port}`;
const init = await fetch(url + "/login", { redirect: "manual" });
assert.equal(init.status, 303);
assert.equal(init.headers.get("location"), "/login?flow=new1");
assert.match(init.headers.get("set-cookie") ?? "", /csrf_token=abc/);
// A stale flow id (Kratos 410) bounces back to a fresh init.
const stale = await fetch(url + "/login?flow=stale", { redirect: "manual" });
assert.equal(stale.status, 303);
assert.equal(stale.headers.get("location"), "/login");
});
test("renders a fetched flow as the themed auth page: fields post straight to Kratos, errors surface", async (t) => {
const app = createApp({ kratos: mockKratos(async (_t, id) => loginFlow(id)) });
await new Promise<void>((r) => app.listen(0, r));
t.after(() => app.close());
const html = await (await fetch(`http://localhost:${(app.address() as AddressInfo).port}/login?flow=f1`)).text();
// The form posts to flow.ui.action (Kratos owns CSRF); csrf rides as a hidden input.
assert.match(html, /<form class="auth-card" method="post" action="http:\/\/127\.0\.0\.1:4433\/self-service\/login\?flow=f1"/);
assert.match(html, /<input type="hidden" name="csrf_token" value="tok">/);
assert.match(html, /name="identifier"/);
assert.match(html, /name="password"[^>]*type="password"/);
assert.match(html, /<button type="submit"[^>]*name="method" value="password">Sign in<\/button>/);
assert.match(html, /<a href="\/registration">Create one<\/a>/); // alt link to register
// The flow-level error renders as an alert.
assert.match(html, /class="alert alert-neg"/);
assert.match(html, /The provided credentials are invalid\./);
});
test("resolveStaticPath blocks traversal and control chars, allows nested files", () => {
assert.equal(resolveStaticPath("/srv/public", "../app.ts"), null);
assert.equal(resolveStaticPath("/srv/public", "a\x00b"), null);

View File

@@ -5,7 +5,9 @@ import * as ejs from "ejs";
import { buildContext } from "./context.ts";
import { buildDashboardModel } from "./dashboard.ts";
import { PLUGINS_DIR } from "./discovery.ts";
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
import { runRequestHooks, runResponseHooks } from "./hooks.ts";
import { KratosError, type KratosPublic } from "./kratos-public.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { Plugin, RouteResult } from "./plugin.ts";
import { allowedMethods, isAuthorized, matchRoute } from "./router.ts";
@@ -18,6 +20,7 @@ export interface AppOptions {
// Cache compiled templates; caller decides (server passes config.cacheTemplates).
// Off by default so edits show live; the app itself never inspects the environment.
cache?: boolean;
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4)
menu?: MenuConfig; // central override + branding (config/menu.ts); defaults to DEFAULT_MENU
plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
@@ -27,6 +30,7 @@ export interface AppOptions {
export function createApp(options: AppOptions = {}): Server {
const cache = options.cache ?? false;
const kratos = options.kratos;
const menu = options.menu ?? DEFAULT_MENU;
const plugins = options.plugins ?? [];
const pluginIds = new Set(plugins.map((p) => p.id));
@@ -85,6 +89,29 @@ export function createApp(options: AppOptions = {}): Server {
return;
}
// Themed Kratos self-service pages (login/registration/recovery/verification/settings).
const flowType = AUTH_FLOWS[pathname];
if (kratos && flowType && (method === "GET" || method === "HEAD")) {
const cookie = req.headers.cookie;
const flowId = ctx.url.searchParams.get("flow");
if (!flowId) {
// No flow yet: init one server-side, relay Kratos' CSRF cookie, bounce to ?flow=<id>.
const { flow, setCookie } = await kratos.initBrowserFlow(flowType, cookie ? { cookie } : {});
res.writeHead(303, { location: `${pathname}?flow=${flow.id}`, ...(setCookie.length ? { "set-cookie": setCookie } : {}) }).end();
return;
}
try {
const flow = await kratos.getFlow(flowType, flowId, cookie ? { cookie } : {});
sendHtml(res, 200, await render("auth", { brand: menu.branding.name, flow: buildFlowView(flow, flowType) }));
} catch (err) {
// Expired/unknown flow → restart by re-initialising (drop the stale ?flow=).
if (err instanceof KratosError && [403, 404, 410].includes(err.status)) {
res.writeHead(303, { location: pathname }).end();
} else throw err;
}
return;
}
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
// Mock data + no roles until auth (§4) lands; branding/override come from config/menu.ts.
sendHtml(res, 200, await render("index", { model: buildDashboardModel(ctx.url, [], menu) }));

103
src/flow-view.test.ts Normal file
View File

@@ -0,0 +1,103 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { AUTH_FLOWS, buildFlowView } from "./flow-view.ts";
import type { Flow, UiNode } from "./kratos-public.ts";
// Concise UiNode builder mirroring Kratos' shape.
function node(attrs: Record<string, unknown>, opts: { group?: string; label?: string; error?: string } = {}): UiNode {
return {
attributes: attrs,
group: opts.group ?? "default",
messages: opts.error ? [{ id: 4000002, text: opts.error, type: "error" }] : [],
meta: opts.label ? { label: { id: 1, text: opts.label, type: "info" } } : {},
type: "input",
};
}
function flow(nodes: UiNode[], extra: Partial<Flow["ui"]> = {}): Flow {
return { id: "f1", ui: { action: "http://127.0.0.1:4433/self-service/login?flow=f1", method: "post", nodes, ...extra } };
}
test("maps a password login flow: csrf hidden, themed email/password fields, a submit button + chrome", () => {
const view = buildFlowView(
flow([
node({ name: "csrf_token", type: "hidden", value: "tok123" }),
node({ name: "identifier", type: "email", required: true, autocomplete: "username", value: "" }, { label: "E-Mail", group: "password" }),
node({ name: "password", type: "password", required: true, autocomplete: "current-password" }, { label: "Password", group: "password" }),
node({ name: "method", type: "submit", value: "password" }, { label: "Sign in", group: "password" }),
]),
"login",
);
// Form posts straight to Kratos (it owns CSRF); csrf travels as a hidden input.
assert.equal(view.action, "http://127.0.0.1:4433/self-service/login?flow=f1");
assert.equal(view.method, "post");
assert.deepEqual(view.hidden, [{ name: "csrf_token", value: "tok123" }]);
// Visible fields carry label, type, required, autocomplete + a themed input icon.
assert.equal(view.fields.length, 2);
assert.deepEqual(view.fields[0], { autocomplete: "username", icon: "i-mail", id: "field-identifier", label: "E-Mail", name: "identifier", required: true, type: "email" });
assert.equal(view.fields[1]?.icon, "i-lock");
assert.equal(view.fields[1]?.type, "password");
// One submit button carrying its method name/value.
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
// Chrome derived from the flow type.
assert.equal(view.title, "Sign in");
assert.equal(view.alt?.href, "/registration");
assert.equal(view.messages.length, 0);
});
test("maps field errors and flow-level messages by tone", () => {
const view = buildFlowView(
flow(
[
node({ name: "identifier", type: "email", value: "taken@example.com" }, { label: "E-Mail", error: "This email is already in use." }),
node({ name: "method", type: "submit", value: "password" }, { label: "Sign in" }),
],
{ messages: [{ id: 4000006, text: "The provided credentials are invalid.", type: "error" }, { id: 1, text: "Check your email.", type: "info" }] },
),
"login",
);
// Submitted value is preserved; the node's error rides on the field.
assert.equal(view.fields[0]?.value, "taken@example.com");
assert.deepEqual(view.fields[0]?.error, { text: "This email is already in use." });
// Flow messages map error→neg, info→info (success→pos covered by the tone map).
assert.deepEqual(view.messages, [
{ text: "The provided credentials are invalid.", tone: "neg" },
{ text: "Check your email.", tone: "info" },
]);
});
test("skips oidc (SSO) nodes but keeps the default-group csrf — SSO buttons are a later item", () => {
const view = buildFlowView(
flow([
node({ name: "csrf_token", type: "hidden", value: "tok" }),
node({ name: "provider", type: "submit", value: "google" }, { label: "Sign in with Google", group: "oidc" }),
node({ name: "method", type: "submit", value: "password" }, { label: "Sign in", group: "password" }),
]),
"login",
);
assert.deepEqual(view.hidden, [{ name: "csrf_token", value: "tok" }]);
assert.deepEqual(view.buttons, [{ label: "Sign in", name: "method", value: "password" }]);
});
test("chrome varies per flow type: registration alt, recovery back link", () => {
const reg = buildFlowView(flow([]), "registration");
assert.equal(reg.title, "Create account");
assert.equal(reg.alt?.href, "/login");
const rec = buildFlowView(flow([]), "recovery");
assert.equal(rec.back?.href, "/login");
});
test("AUTH_FLOWS maps each themed path to its Kratos flow type", () => {
assert.equal(AUTH_FLOWS["/login"], "login");
assert.equal(AUTH_FLOWS["/registration"], "registration");
assert.equal(AUTH_FLOWS["/recovery"], "recovery");
assert.equal(AUTH_FLOWS["/verification"], "verification");
assert.equal(AUTH_FLOWS["/settings"], "settings");
});

128
src/flow-view.ts Normal file
View File

@@ -0,0 +1,128 @@
// Kratos flow → themed view model (todo §4). Pure: turns a fetched self-service Flow
// (src/kratos-public.ts) into the data views/auth.ejs renders — hidden inputs (incl. the
// CSRF token), themed fields, submit buttons, and tone-mapped messages. The form posts
// straight back to `flow.ui.action`, so Kratos owns its CSRF; we only render and map errors.
// SSO/oidc buttons are skipped here — they're derived per provider in the next §4 item.
import type { Flow, FlowType, UiNode } from "./kratos-public.ts";
export interface FlowField {
autocomplete?: string;
error?: { text: string };
icon?: string; // Lucide sprite id for the input
id: string;
label: string;
name: string;
required?: boolean;
type: string;
value?: string;
}
export interface FlowButton {
label: string;
name?: string;
value?: string;
}
export interface FlowMessage {
text: string;
tone: "info" | "neg" | "pos" | "warn";
}
interface FlowChrome {
alt?: { href: string; label: string; text: string };
back?: { href: string; label: string };
sub?: string;
title: string;
}
export interface FlowView extends FlowChrome {
action: string;
buttons: FlowButton[];
fields: FlowField[];
hidden: { name: string; value: string }[];
messages: FlowMessage[];
method: string;
}
// Themed route → Kratos flow type. The routes mirror kratos.yml's flow ui_urls.
export const AUTH_FLOWS: Record<string, FlowType> = {
"/login": "login",
"/recovery": "recovery",
"/registration": "registration",
"/settings": "settings",
"/verification": "verification",
};
const CHROME: Record<FlowType, FlowChrome> = {
login: { alt: { href: "/registration", label: "Create one", text: "Don't have an account?" }, sub: "Welcome back. Enter your details to continue.", title: "Sign in" },
recovery: { alt: { href: "/login", label: "Sign in", text: "Remembered it?" }, back: { href: "/login", label: "Back to sign in" }, sub: "Enter your email and we'll send you a recovery code.", title: "Reset password" },
registration: { alt: { href: "/login", label: "Sign in", text: "Already have an account?" }, sub: "Get started — it only takes a minute.", title: "Create account" },
settings: { sub: "Update your account details.", title: "Account settings" },
verification: { back: { href: "/login", label: "Back to sign in" }, sub: "Enter the code we sent you.", title: "Verify your email" },
};
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
// Themed input icon by field semantics; undefined ⇒ no icon.
function iconFor(name: string, type: string): string | undefined {
if (type === "email" || name === "identifier" || name.endsWith(".email")) return "i-mail";
if (type === "password") return "i-lock";
if (name.includes("name")) return "i-user";
if (name === "code") return "i-shield";
return undefined;
}
function tone(type: string): FlowMessage["tone"] {
if (type === "error") return "neg";
if (type === "success") return "pos";
return "info";
}
function toField(node: UiNode, name: string, type: string): FlowField {
const value = str(node.attributes["value"]);
const autocomplete = str(node.attributes["autocomplete"]);
const icon = iconFor(name, type);
const errorMsg = node.messages.find((m) => m.type === "error");
return {
id: "field-" + name.replace(/[^a-z0-9]+/gi, "-"),
label: node.meta.label?.text ?? name,
name,
type,
...(autocomplete ? { autocomplete } : {}),
...(errorMsg ? { error: { text: errorMsg.text } } : {}),
...(icon ? { icon } : {}),
...(node.attributes["required"] === true ? { required: true } : {}),
...(value ? { value } : {}),
};
}
export function buildFlowView(flow: Flow, type: FlowType): FlowView {
const hidden: { name: string; value: string }[] = [];
const fields: FlowField[] = [];
const buttons: FlowButton[] = [];
for (const node of flow.ui.nodes) {
if (node.type !== "input" || node.group === "oidc") continue; // SSO buttons: next §4 item
const name = str(node.attributes["name"]) ?? "";
const inputType = str(node.attributes["type"]) ?? "text";
if (inputType === "hidden") {
hidden.push({ name, value: str(node.attributes["value"]) ?? "" });
} else if (inputType === "submit" || inputType === "button") {
const value = str(node.attributes["value"]);
buttons.push({ label: node.meta.label?.text ?? "Continue", ...(name ? { name } : {}), ...(value != null ? { value } : {}) });
} else {
fields.push(toField(node, name, inputType));
}
}
return {
action: flow.ui.action,
buttons,
fields,
hidden,
messages: (flow.ui.messages ?? []).map((m) => ({ text: m.text, tone: tone(m.type) })),
method: flow.ui.method || "post",
...CHROME[type],
};
}

View File

@@ -2,16 +2,18 @@ import { createApp } from "./app.ts";
import { loadConfig } from "./config.ts";
import { discoverPlugins } from "./discovery.ts";
import { runBootHooks } from "./hooks.ts";
import { createKratosPublic } from "./kratos-public.ts";
import { loadMenuConfig } from "./menu-config.ts";
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
const menu = await loadMenuConfig(); // config/menu.ts override + branding — fails loud if malformed
const kratos = createKratosPublic({ baseUrl: config.kratosPublicUrl }); // themed self-service routes (§4)
const plugins = await discoverPlugins(); // scans plugins/, validates — fails loud on a bad plugin
console.log(`Discovered ${plugins.length} plugin(s)${plugins.length ? `: ${plugins.map((p) => p.id).join(", ")}` : ""}`);
await runBootHooks(plugins); // plugin onBoot — after discovery, before listen; a throw aborts boot
const server = createApp({ cache: config.cacheTemplates, menu, plugins }).listen(config.port, () => {
const server = createApp({ cache: config.cacheTemplates, kratos, menu, plugins }).listen(config.port, () => {
console.log(`Listening on http://localhost:${config.port}`);
});

View File

@@ -78,7 +78,7 @@ everything via Docker.
- [x] Kratos public client (fetch): init/get/submit flows, `whoami`, `whoami?tokenize_as=plainpages`. → `src/kratos-public.ts` (`createKratosPublic({baseUrl, fetchImpl})`): typed `fetch` wrappers over Kratos' public API, no SDK dep (built-in `fetch`), `fetchImpl`-injectable like `bootstrap.ts`. `initBrowserFlow(type, {cookie?, returnTo?})` GETs `/self-service/<type>/browser` with `Accept: json` (so Kratos returns the flow + CSRF `Set-Cookie` to relay, not a redirect); `getFlow(type, id, {cookie?})` reads `/self-service/<type>/flows?id=` forwarding the browser cookie; `submitFlow(action, {body, contentType?, cookie?})` POSTs urlencoded to the flow's `ui.action` (manual redirect) → `{ok, status, body, location, setCookie}` (200 success / 400 re-rendered flow-with-errors, no throw / 303 Location or 422 `redirect_browser_to`); `whoami({cookie?, tokenizeAs?})` reads `/sessions/whoami``Session|null` (401⇒null), with `?tokenize_as=plainpages` returning the session's `tokenized` JWT. Fail-loud `KratosError` carries `.status` (so §4 line 81 can re-init on an expired 404/410). Flow `ui.nodes` typed loosely — rendering/field-error mapping is §4's renderer. Tests-first (`kratos-public.test.ts`, mock fetch: URLs/JSON-accept/cookie relay/Set-Cookie/tokenize query + 410/500 errors + 400 validation + redirect targets). Building block — no route/E2E yet (the themed flow pages + login completion are the next §4 items). README **Layout** lists it. typecheck + 159 units green.
- [x] Kratos admin client (fetch): identity CRUD + `metadata_admin` update. → `src/kratos-admin.ts` (`createKratosAdmin({baseUrl, fetchImpl})`): typed `fetch` wrappers over Kratos' admin API (admin port), no SDK, `fetchImpl`-injectable like `kratos-public.ts`; reuses that module's `KratosError` (carries `.status`). `createIdentity` (POST, 201), `getIdentity` (GET, 404⇒`null`), `listIdentities({credentialsIdentifier?, ids?, pageSize?, pageToken?})``{identities, nextPageToken}` (parses the keyset cursor from the `Link` rel="next" header for the §5 users list), `updateIdentity` (full PUT), `deleteIdentity` (DELETE, 204), and `updateMetadataAdmin` — the key login-completion method: `PATCH` JSON-Patch `add /metadata_admin` so it sets the roles projection whether the field is absent/null/set and never clobbers traits/state. Building block — no route/E2E yet (login completion §4 line 83 wires it; the projection feeds the tokenizer's `metadata_admin` mapper, §3). Tests-first (`kratos-admin.test.ts`, mock fetch: URLs/method/JSON-Patch body/query+pagination/Link parsing + 201/200/404/409 mapping). README **Layout** lists it. typecheck + 167 units green.
- [x] Keto client (fetch): `check`, list/expand relations, write/delete tuples. → `src/keto-client.ts` (`createKetoClient({readUrl, writeUrl, fetchImpl})`): typed `fetch` wrappers over Keto's relation-tuple APIs, no SDK, `fetchImpl`-injectable like the kratos clients; read (`check`/`listRelations`/`expand`) and write (`writeTuple`/`deleteTuple`) split onto the two ports config.ts targets (4466/4467). `RelationTuple` (subject_id xor subject_set; mirrors bootstrap's roleTuple) is the wire shape for writes + the filter shape for reads via `tupleParams` (subject sets → dotted `subject_set.*` keys). `check` returns a `bool` reading `allowed` from **both** 200 (allowed) and 403 (denied) — Keto answers a denial with 403, not 200 (caught in boot-verify); other statuses fail loud via `KetoError` (carries `.status`, parallels KratosError). `writeTuple` PUTs (idempotent), `deleteTuple` DELETEs by query, `listRelations` parses `next_page_token`, `expand` returns the loose tree. Building block — no route/E2E yet (login completion §4 line 83 + guards line 86 wire it). Tests-first (`keto-client.test.ts`, mock fetch: URLs/ports/method/query+body/subject forms/allowed mapping/pagination/errors). README **Layout** lists it. Boot-verified live: full round-trip against a real keto (check false → write → true → list → expand → delete → false). typecheck + 174 units green.
- [ ] Render Kratos flows: fetch flow → render fields against our themed pages → POST to `flow.ui.action` (Kratos handles its CSRF), map field errors/messages.
- [x] Render Kratos flows: fetch flow → render fields against our themed pages → POST to `flow.ui.action` (Kratos handles its CSRF), map field errors/messages.`src/flow-view.ts` (pure `buildFlowView(flow, type)`): maps a fetched self-service `Flow` → themed view model — hidden inputs (incl. `csrf_token`), themed fields (label from `meta.label`, type/required/autocomplete from attributes, an input icon by field semantics, node-level error message), submit buttons (name/value preserved), and tone-mapped flow messages (error→neg/success→pos/info→info); `oidc` nodes skipped (SSO is the next item). Per-flow chrome (title/sub/back/alt) + `AUTH_FLOWS` path→type map. `views/auth.ejs` renders it into the html-css-foundation auth layout, reusing the `auth-card` + `field` partials and capturing `partials/flow-body.ejs` (messages + hidden + fields + buttons) into the card body; new reusable `partials/alert.ejs` + an `.alert` design-system component (styles.css, tone tokens). `app.ts` serves the five routes via an injectable `kratos` client (server.ts builds it from `config.kratosPublicUrl`): no `?flow=` ⇒ init server-side + relay Kratos' CSRF `Set-Cookie` + 303 to `?flow=<id>`; `?flow=<id>``getFlow` (forwarding the browser cookie) → render; an expired/unknown flow (403/404/410) re-inits. The browser POSTs the form straight to `flow.ui.action` (Kratos owns CSRF) — no server-side `submitFlow`. Tests-first: `flow-view.test.ts` (mapping matrix: hidden/fields/buttons/icons/errors/tone/oidc-skip/chrome/AUTH_FLOWS) + `app.test.ts` integration (init 303 + CSRF relay + expired restart; rendered page posts to Kratos with the live fields + error alert) — mock `KratosPublic`. typecheck + 181 units green. Boot-verified the whole chain on the live stack: `/login` 303 → `?flow=` relaying the real `csrf_token_…` cookie, the page posts to `127.0.0.1:4433` with the live token + identifier/password + submit; registration renders the real `traits.*` fields; recovery/verification chrome correct; a stale flow id 303s back to re-init; torn down. Browser-submittable end-to-end (dev http Secure-cookie posture, login completion → our JWT cookie) is the next §4 items (lines 83/89); the full live-stack login Playwright E2E is owned by §8.
- [ ] SSO buttons → Kratos OIDC flows. **Render per configured provider only**: derive the list from Kratos' enabled OIDC providers (no creds ⇒ no button); hide the whole SSO section when none are configured. No code change needed to add/remove a provider — config only.
- [ ] Login completion: read roles from Keto → write `metadata_admin` projection → tokenize → set JWT cookie.
- [ ] JWT middleware: verify signature via cached JWKS, validate `exp`/`iss`/`aud` (+clock skew), build context (user, roles).

39
views/auth.ejs Normal file
View File

@@ -0,0 +1,39 @@
<%#
Themed Kratos self-service page (todo §4): sign-in / register / reset / verify / settings.
Renders a FlowView (src/flow-view.ts) into the html-css-foundation auth layout, reusing the
auth-card + field partials. The form posts straight to flow.ui.action — Kratos owns its CSRF.
Auto theme follows the OS (styles.css), so no theme switch is shown here.
%><%
const brand = locals.brand || "Plainpages";
const body = include("partials/flow-body", { flow });
%><!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title><%= flow.title %></title>
<link rel="stylesheet" href="/public/css/styles.css" />
<link rel="stylesheet" href="/public/css/auth.css" />
<link rel="icon" href="/public/favicon.svg" />
</head>
<body>
<%- include("partials/icons") %>
<main class="auth-stage">
<div class="auth">
<div class="auth-brand">
<span class="brand-mark"><svg class="ico ico-sm"><use href="#i-box" /></svg></span>
<span class="brand-name"><%= brand %></span>
</div>
<%- include("partials/auth-card", {
action: flow.action,
alt: flow.alt,
back: flow.back,
body,
method: flow.method,
sub: flow.sub,
title: flow.title,
}) %>
</div>
</main>
</body>
</html>

8
views/partials/alert.ejs Normal file
View File

@@ -0,0 +1,8 @@
<%#
Inline alert / notice banner (design-system tone tokens). Config:
text, tone? ∈ pos|neg|info|warn (default info), title?
%><%
const tone = locals.tone || "info";
const icon = tone === "pos" ? "i-check-circle" : "i-alert";
-%>
<div class="alert alert-<%= tone %>" role="<%= tone === "neg" ? "alert" : "status" %>"><svg class="ico ico-sm" aria-hidden="true"><use href="#<%= icon %>"/></svg><div class="alert-body"><% if (locals.title) { %><strong><%= locals.title %></strong><% } %><span><%= locals.text %></span></div></div>

View File

@@ -0,0 +1,17 @@
<%#
Kratos flow form body (todo §4): the inner of the auth-card form — flow messages,
hidden inputs (incl. csrf_token), themed fields, then submit button(s). `flow` is a
FlowView (src/flow-view.ts). Captured by views/auth.ejs and handed to auth-card's body.
-%>
<% flow.messages.forEach((m) => { -%>
<%- include("alert", m) %>
<% }) -%>
<% flow.hidden.forEach((h) => { -%>
<input type="hidden" name="<%= h.name %>" value="<%= h.value %>">
<% }) -%>
<% flow.fields.forEach((field) => { -%>
<%- include("field", field) %>
<% }) -%>
<% flow.buttons.forEach((b, i) => { -%>
<button type="submit" class="btn btn-block<%= i === 0 ? " btn-primary" : "" %>"<% if (b.name) { %> name="<%= b.name %>"<% } %><% if (b.value != null) { %> value="<%= b.value %>"<% } %>><%= b.label %></button>
<% }) -%>