Remove completed todo.md + html-css-foundation mockups; strip dead §N phase refs from comments/docs (simplify visual E2E to drop the mockup-comparison oracle)

This commit is contained in:
2026-06-23 22:49:28 +02:00
parent e22d24aa8a
commit a9f25a7692
143 changed files with 394 additions and 1538 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
// Built-in OAuth2 clients admin screen (§6): the pure view-model + Hydra-payload builders. A client
// Built-in OAuth2 clients admin screen: the pure view-model + Hydra-payload builders. A client
// is an Ory Hydra OAuth2 client (apps that log in *through* us); writes go only to Hydra. The
// HTTP routing/gate/CSRF + live Hydra calls (incl. the one-time secret) are exercised in app.test.ts.
import assert from "node:assert/strict";
+3 -3
View File
@@ -1,5 +1,5 @@
// Built-in OAuth2 clients admin screen (todo §6): register / list / delete the OAuth2 clients other
// apps log in *through* us with (Ory Hydra, the §6 login+consent handlers). A client is an Ory Hydra
// Built-in OAuth2 clients admin screen: register / list / delete the OAuth2 clients other
// apps log in *through* us with (Ory Hydra, the login+consent handlers). A client is an Ory Hydra
// OAuth2 client; writes go only to Hydra. Hydra returns the client_secret once, on create — so the
// register POST renders the new client's detail page (with the one-time secret) directly instead of a
// PRG redirect (mirrors the Users "trigger recovery" one-time code). `handleAdminClients` is the
@@ -310,7 +310,7 @@ export async function handleAdminClients(ctx: RequestContext, csrfToken: string,
created = await hydra.createClient(clientPayload(input));
} catch (err) {
// A Hydra 4xx (bad redirect/scope it rejects) is the operator's input — re-render the form;
// a 5xx (Hydra down) rethrows → 500. Mirrors the §6 challenge-handler degrade.
// a 5xx (Hydra down) rethrows → 500. Mirrors the challenge-handler degrade.
if (err instanceof HydraError && err.status < 500) {
return { ...(await renderForm({ error: "Hydra rejected the client — check the redirect URIs and scopes.", values: input })), status: 400 };
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Built-in Groups admin screen (§5): the pure view-model + Keto-tuple builders. A group is a
// Built-in Groups admin screen: the pure view-model + Keto-tuple builders. A group is a
// Keto subject set (Group:<name>#members); membership tuples carry users (subject_id) or nested
// groups (subject_set). The HTTP routing/gate/CSRF + live Keto/Kratos calls are exercised over
// HTTP in app.test.ts.
+1 -1
View File
@@ -1,4 +1,4 @@
// Built-in Groups admin screen (todo §5): list / create / delete Keto groups and manage membership.
// Built-in Groups admin screen: list / create / delete Keto groups and manage membership.
// A group is a Keto subject set `Group:<name>#members`; a member is a user or a nested group (see
// parseSubject). Writes go only to Keto (README "stateless"). Keto has no "create object" — a group
// exists exactly while it has ≥1 member, so create writes its first-member tuple and delete removes
+2 -2
View File
@@ -1,4 +1,4 @@
// Direct units for the admin section's pure nav + auth helpers (todo §8). They're security-critical
// Direct units for the admin section's pure nav + auth helpers. They're security-critical
// (requireAdmin/guardedForm gate every admin write) and reused across all four admin screens, so pin
// the contract here in isolation — the admin-*.test.ts HTTP tests exercise them only end-to-end.
import assert from "node:assert/strict";
@@ -45,7 +45,7 @@ test("adminSection: gated Admin header over the four screens; current marks the
assert.equal(onRoles.children?.find((c) => c.id === "users")?.current, undefined);
});
// (The in-screen admin sidebar is gone in §10 — every page renders the one global menu, built by
// (The in-screen admin sidebar is gone in — every page renders the one global menu, built by
// buildPluginChrome; see chrome.test.ts. adminSection above is that menu's gated Admin fragment.)
// ---- auth gates ----
+2 -2
View File
@@ -1,6 +1,6 @@
// The built-in admin section of the menu (todo §5). `adminSection()` is the one definition of the
// The built-in admin section of the menu. `adminSection()` is the one definition of the
// permission-gated "Admin" header (Users/Groups/Roles/clients) + its gate, composed into the single
// global menu (`buildPluginChrome`, §10) — composeNav drops the whole header + subtree for a
// global menu (`buildPluginChrome`) — composeNav drops the whole header + subtree for a
// non-admin. Every page (dashboard, admin, plugin, auth) renders that one menu, so there's no
// separate admin sidebar to drift.
+1 -1
View File
@@ -1,4 +1,4 @@
// Built-in Roles & permissions admin screen (§5): the pure view-model + Keto builders. A role is a
// Built-in Roles & permissions admin screen: the pure view-model + Keto builders. A role is a
// Keto subject set (Role:<name>#members); members are users (subject_id) or groups (subject_set) —
// "assign roles to users/groups". The "effective access" view flattens a Keto `expand` tree into the
// distinct set of users who hold the role directly or transitively via a group. The HTTP
+5 -5
View File
@@ -1,4 +1,4 @@
// Built-in Roles & permissions admin screen (todo §5): list / create / delete Keto roles and assign
// Built-in Roles & permissions admin screen: list / create / delete Keto roles and assign
// them to users and groups. A role is a Keto subject set `Role:<name>#members` (OPL: members are users
// or groups, resolved transitively) — the source of truth for the JWT `roles` claim. It shares the
// Groups screen's membership model, so the pure helpers (parseSubject, member pickers, tuple paging)
@@ -276,10 +276,10 @@ export interface AdminRolesDeps {
kratosAdmin: KratosAdmin;
menu: MenuConfig;
render: (view: string, data: Record<string, unknown>) => Promise<string>;
revoke?: (sub: string) => void; // optional instant-revoke (§9): assigning/unassigning a *user* kills their live tokens
revoke?: (sub: string) => void; // optional instant-revoke: assigning/unassigning a *user* kills their live tokens
}
// §9 instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that
// instant-revoke: a role change for a `user:<id>` member must take effect now, so revoke that
// user's live tokens (a re-mint then re-reads roles from Keto). A `group:<name>` change is
// transitive across many users — left to lag (documented), so only direct user members revoke.
function revokeUserMember(deps: AdminRolesDeps, member: string): void {
@@ -378,7 +378,7 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
if (seg.length === 2 && seg[1] === "delete" && method === "POST") {
if (name === ADMIN_PERMISSION) return renderDetail(name, "The admin role can't be deleted — it would remove all admin access.");
await keto.deleteTuple({ namespace: ROLE_NS, object: name, relation: MEMBERS }); // removes every member tuple
// §9: a whole-role delete drops many members at once — left to lag like a group change; the
// a whole-role delete drops many members at once — left to lag like a group change; the
// per-member unassign above is the instant-revoke path.
ctx.log.info("admin: role deleted", { actor: user.id, role: name });
return { redirect: ADMIN_ROLES_BASE };
@@ -386,7 +386,7 @@ export async function handleAdminRoles(ctx: RequestContext, csrfToken: string, d
if (seg.length === 3 && seg[1] === "members" && seg[2] === "delete" && method === "POST") {
const member = (form!.get("member") ?? "").trim();
// Self-protection: don't let an admin revoke their own *direct* admin grant (would lock them out).
// Admin held only via a group isn't covered here — the robust "last effective admin" check is §9.
// Admin held only via a group isn't covered here — the robust "last effective admin" check is deferred.
if (name === ADMIN_PERMISSION && member === `user:${user.id}`) return renderDetail(name, "You can't revoke your own admin access.");
const tuple = roleMemberTuple(name, member);
if (tuple) { await keto.deleteTuple(tuple); revokeUserMember(deps, member); ctx.log.info("admin: role unassigned", { actor: user.id, member, role: name }); }
+1 -1
View File
@@ -1,4 +1,4 @@
// Built-in Users admin screen (§5): the pure view-model + Kratos-payload builders. The HTTP
// Built-in Users admin screen: the pure view-model + Kratos-payload builders. The HTTP
// routing/gate/CSRF + live Kratos calls are exercised over HTTP in app.test.ts.
import assert from "node:assert/strict";
import { test } from "node:test";
+4 -4
View File
@@ -1,4 +1,4 @@
// Built-in Users admin screen (todo §5): list Kratos identities (filter/sort/paginate) +
// Built-in Users admin screen: list Kratos identities (filter/sort/paginate) +
// create/edit/deactivate/delete/trigger-recovery. Writes go only to Kratos via the admin client
// (README "stateless"). Pure builders turn identities + the request URL into building-block view
// models; `handleAdminUsers` is the imperative shell app.ts dispatches to — gated admin-only,
@@ -289,7 +289,7 @@ export interface AdminUsersDeps {
kratosAdmin: KratosAdmin;
menu: MenuConfig;
render: (view: string, data: Record<string, unknown>) => Promise<string>;
revoke?: (sub: string) => void; // optional instant-revoke (§9): kill the target's live tokens on deactivate/delete
revoke?: (sub: string) => void; // optional instant-revoke: kill the target's live tokens on deactivate/delete
}
function readUserInput(form: URLSearchParams): UserInput {
@@ -378,14 +378,14 @@ export async function handleAdminUsers(ctx: RequestContext, csrfToken: string, d
if (isSelf) return { ...(await renderForm({ error: "You can't deactivate your own account.", identity })), status: 400 };
const nextState = identity.state === "inactive" ? "active" : "inactive";
await kratosAdmin.updateIdentity(targetId, setStatePayload(identity, nextState));
if (nextState === "inactive") deps.revoke?.(targetId); // §9: a deactivation takes effect now, not after the JWT TTL
if (nextState === "inactive") deps.revoke?.(targetId); // a deactivation takes effect now, not after the JWT TTL
ctx.log.info("admin: user state changed", { actor: user.id, state: nextState, target: targetId });
return { redirect: back };
}
if (seg[1] === "delete") {
if (isSelf) return { ...(await renderForm({ error: "You can't delete your own account.", identity })), status: 400 };
await kratosAdmin.deleteIdentity(targetId);
deps.revoke?.(targetId); // §9: the account is gone — reject its live tokens immediately
deps.revoke?.(targetId); // the account is gone — reject its live tokens immediately
ctx.log.info("admin: user deleted", { actor: user.id, target: targetId });
return { redirect: ADMIN_USERS_BASE };
}
+33 -33
View File
@@ -24,9 +24,9 @@ import { contentTypeFor, resolveStaticPath, routePublic } from "./static.ts";
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "views");
// A session JWT signed with a throwaway test key — the §4 verify path. Wired into the shared
// A session JWT signed with a throwaway test key — the verify path. Wired into the shared
// `server` (and the per-test apps) so a request can present a valid session; the dashboard and the
// gated routes need one (§10). `staticJwks([ecJwk])` is the matching verify side.
// gated routes need one. `staticJwks([ecJwk])` is the matching verify side.
const ec = generateKeyPairSync("ec", { namedCurve: "P-256" });
const ecJwk: JsonWebKey = { ...(ec.publicKey.export({ format: "jwk" }) as JsonWebKey), alg: "ES256", kid: "test-kid" };
const b64url = (i: Buffer | string): string => Buffer.from(i).toString("base64url");
@@ -49,12 +49,12 @@ before(async () => {
after(() => server.close());
test("the dashboard at /dashboard: the instructional starter in the unified shell, gated to a session", async () => {
// The dashboard is gated to a signed-in user (§10), so present a session.
// The dashboard is gated to a signed-in user, so present a session.
const res = await fetch(base + "/dashboard", { headers: { cookie: session() } });
assert.equal(res.status, 200);
assert.match(res.headers.get("content-type") ?? "", /text\/html/);
const html = await res.text();
// The unified app shell (§10): the same sidebar/menu every page renders.
// The unified app shell: the same sidebar/menu every page renders.
assert.match(html, /Plainpages/); // sidebar brand
assert.match(html, /<aside class="sidebar"/);
// The default is a short instructional starter, not a mock-data list.
@@ -63,7 +63,7 @@ test("the dashboard at /dashboard: the instructional starter in the unified shel
assert.doesNotMatch(html, /<form class="filters"/); // the old mock People list is gone
assert.doesNotMatch(html, /Avery Kline/);
// The Sign-out POST form carries a CSRF token matching the Set-Cookie issued for the page (§4).
// The Sign-out POST form carries a CSRF token matching the Set-Cookie issued for the page.
const csrfCookie = (res.headers.get("set-cookie") ?? "").match(/plainpages_csrf=([^;]+)/)?.[1];
assert.ok(csrfCookie, "GET /dashboard issues a CSRF cookie");
assert.match(res.headers.get("set-cookie") ?? "", /plainpages_csrf=[^;]+;.*HttpOnly/);
@@ -71,24 +71,24 @@ test("the dashboard at /dashboard: the instructional starter in the unified shel
assert.match(html, new RegExp(`name="_csrf" value="${csrfCookie!.replace(/[.]/g, "\\.")}"`));
});
test("/ is the public landing (§10): anonymous → 200 with intro + sign-in/register links, in the unified shell", async () => {
test("/ is the public landing: anonymous → 200 with intro + sign-in/register links, in the unified shell", async () => {
const res = await fetch(base + "/", { redirect: "manual" });
assert.equal(res.status, 200); // public — no redirect to sign in
const html = await res.text();
assert.match(html, /href="\/login"/); // a prominent path to sign in
assert.match(html, /href="\/registration"/); // and to register
// §10: the same app shell every page renders — the menu shows even when signed out (role-filtered).
// the same app shell every page renders — the menu shows even when signed out (role-filtered).
assert.match(html, /<aside class="sidebar"/);
assert.match(html, /class="landing-title"/); // the landing hero owns the page's single <h1>
});
test("/dashboard is gated (§10): an anonymous visitor is bounced to sign in (return_to kept)", async () => {
test("/dashboard is gated: an anonymous visitor is bounced to sign in (return_to kept)", async () => {
const res = await fetch(base + "/dashboard", { redirect: "manual" });
assert.equal(res.status, 303);
assert.equal(res.headers.get("location"), "/login?return_to=%2Fdashboard");
});
test("plugins replace either landing (§10): `home` owns the public /, `dashboard` owns the gated /dashboard", async (t) => {
test("plugins replace either landing: `home` owns the public /, `dashboard` owns the gated /dashboard", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-home-"));
mkdirSync(join(dir, "portal", "views"), { recursive: true });
writeFileSync(join(dir, "portal", "views", "welcome.ejs"), `<h1>Welcome to <%= brand %></h1><a href="/login">Sign in</a>`);
@@ -134,7 +134,7 @@ test("renders branding from the menu config into the shell: logo + default theme
assert.match(html, /id="theme-dark"\s+checked/); // config default theme reaches the switch
});
test("emits a structured access-log line per request (the injected §9 logger)", async (t) => {
test("emits a structured access-log line per request (the injected logger)", async (t) => {
const lines: string[] = [];
const app = createApp({ log: createLogger({ format: "json", level: "info", stderr: () => {}, stdout: (m) => lines.push(m) }) });
await new Promise<void>((r) => app.listen(0, r));
@@ -159,7 +159,7 @@ test("emits a structured access-log line per request (the injected §9 logger)",
assert.ok(rec.requestId, "carries a requestId for log↔trace correlation");
});
test("ctx.log: a handler logs in the request trace, and ctx.log.fetch continues the inbound trace (§9)", async (t) => {
test("ctx.log: a handler logs in the request trace, and ctx.log.fetch continues the inbound trace", async (t) => {
const lines: string[] = [];
const upstream: { traceparent: string | undefined; url: string }[] = [];
const realFetch = globalThis.fetch;
@@ -207,7 +207,7 @@ test("ctx.log: a handler logs in the request trace, and ctx.log.fetch continues
assert.equal(up!.traceparent!.split("-")[1], inbound, "the upstream call continues the inbound trace");
});
test("ctx.log after a client abort doesn't throw: the request log is ended only once the handler unwinds (§9)", async (t) => {
test("ctx.log after a client abort doesn't throw: the request log is ended only once the handler unwinds", async (t) => {
// The request span is ended on response "close", which also fires on a premature client abort.
// The handler keeps running after that — its ctx.log must not throw "already ended", so end() is
// deferred until the handler settles (regression for the abort race).
@@ -260,7 +260,7 @@ test("static serving: GET sends body + content-type, HEAD headers only, unsafe p
assert.equal((await fetch(base + "/public/%00")).status, 403);
});
test("every response carries the security headers; HSTS follows SECURE_COOKIES (§9)", async (t) => {
test("every response carries the security headers; HSTS follows SECURE_COOKIES", async (t) => {
// Default app (secureCookies off): a page (the public "/") and a static asset both carry the
// hardening headers, proving they're set once up front and survive each writeHead (paths merge).
for (const path of ["/", "/public/css/styles.css"]) {
@@ -388,7 +388,7 @@ const demoPlugin: Plugin = {
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data" },
{ handler: () => ({ redirect: "/demo/hello/world" }), method: "POST", path: "/go" },
{ handler: () => ({ html: "secret" }), method: "GET", path: "/secret", permission: "demo:read" },
{ handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // §10 blessed public
{ handler: () => ({ html: "open to all" }), method: "GET", path: "/public-page", public: true }, // blessed public
{ handler: () => ({ data: { who: "Plainpages" }, view: "page" }), method: "GET", path: "/page" },
],
};
@@ -443,7 +443,7 @@ test("mounts plugin routes: params, html/json/redirect/view results, and the per
assert.equal(denied.status, 303);
assert.equal(denied.headers.get("location"), "/login?return_to=%2Fdemo%2Fsecret");
// a route marked public (§10) is reachable anonymously — no gate, no redirect.
// a route marked public is reachable anonymously — no gate, no redirect.
const open = await fetch(url + "/demo/public-page", { redirect: "manual" });
assert.equal(open.status, 200);
assert.match(await open.text(), /open to all/);
@@ -455,7 +455,7 @@ test("mounts plugin routes: params, html/json/redirect/view results, and the per
assert.equal((await fetch(url + "/demo/nope")).status, 404);
});
test("a plugin view renders the native chrome; its forms are CSRF-guarded via ctx.verifyCsrf (§7)", async (t) => {
test("a plugin view renders the native chrome; its forms are CSRF-guarded via ctx.verifyCsrf", async (t) => {
const dir = mkdtempSync(join(tmpdir(), "pp-plugins-"));
mkdirSync(join(dir, "panelkit", "views"), { recursive: true });
// The view composes the core shell from ctx.chrome — branding, the global nav — and its own
@@ -510,7 +510,7 @@ test("a plugin view renders the native chrome; its forms are CSRF-guarded via ct
assert.equal(ok.status, 303);
});
// JWT middleware (§4): a verified session cookie populates ctx.user/roles, which the gate reads.
// JWT middleware: a verified session cookie populates ctx.user/roles, which the gate reads.
// The key + mintJwt + session() helper are hoisted above the shared `server` (top of file).
test("a verified session JWT authorizes a role-gated route; no cookie / expired token → sign in", async (t) => {
const app = createApp({ jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
@@ -533,7 +533,7 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
assert.equal((await secret(`${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec - 600, roles: ["demo:read"], sub: "u1" })}`)).status, 303);
// The dashboard wires in the permission-gated Admin section: an admin's roles surface the links;
// anonymous is bounced to sign in before any page renders (§10 gate on /dashboard).
// anonymous is bounced to sign in before any page renders (gate on /dashboard).
const admin = await fetch(url + "/dashboard", { headers: { cookie: `${SESSION_COOKIE}=${mintJwt({ email: "a@b.c", exp: nowSec + 600, roles: ["admin"], sub: "u1" })}` } });
assert.match(await admin.text(), /href="\/admin\/users"/);
const anonDash = await fetch(url + "/dashboard", { redirect: "manual" });
@@ -541,7 +541,7 @@ test("a verified session JWT authorizes a role-gated route; no cookie / expired
assert.equal(anonDash.headers.get("location"), "/login?return_to=%2Fdashboard");
});
test("revocation denylist (§9): a revoked subject's token stops authorizing on the hot path; a fresh re-login passes", async (t) => {
test("revocation denylist: a revoked subject's token stops authorizing on the hot path; a fresh re-login passes", async (t) => {
const denylist = createDenylist(); // no Ory clients ⇒ a revoked token drops straight to anonymous (no re-mint)
const app = createApp({ denylist, jwks: staticJwks([ecJwk]), plugins: [demoPlugin] });
await new Promise<void>((r) => app.listen(0, r));
@@ -745,7 +745,7 @@ test("themed auth GET: an existing Kratos session (no app JWT yet) recovers via
assert.equal((await fetch(url2 + "/login", { redirect: "manual" })).status, 500);
});
// return_to (§9): a deep-link login lands back on the requested page. The gate redirects to
// return_to: a deep-link login lands back on the requested page. The gate redirects to
// /login?return_to=<host-relative path>; /login bakes that into the Kratos flow so completion
// returns there — but a first-party path must route via /auth/complete first (to mint the JWT).
test("login return_to: a first-party deep link is wrapped through /auth/complete; an absolute target passes through as-is", async (t) => {
@@ -764,7 +764,7 @@ test("login return_to: a first-party deep link is wrapped through /auth/complete
await fetch(url + "/login?return_to=" + encodeURIComponent("/admin/users?q=1"), { redirect: "manual" });
assert.match(lastReturnTo ?? "", /^http:\/\/[^/]+\/auth\/complete\?return_to=%2Fadmin%2Fusers%3Fq%3D1$/);
// An absolute target (the §6 OAuth2 login challenge) is passed to Kratos unchanged — Kratos
// An absolute target (the OAuth2 login challenge) is passed to Kratos unchanged — Kratos
// allow-lists it. A protocol-relative "//evil.com" is likewise not wrapped (Kratos rejects it).
const abs = "http://localhost/oauth2/login?login_challenge=abc";
await fetch(url + "/login?return_to=" + encodeURIComponent(abs), { redirect: "manual" });
@@ -818,7 +818,7 @@ test("renders a fetched flow as the themed auth page: fields post straight to Kr
assert.match(html, /The provided credentials are invalid\./);
});
// Login completion (§4): /auth/complete is where Kratos lands the browser after login.
// Login completion: /auth/complete is where Kratos lands the browser after login.
const stubAdmin = (over: Partial<KratosAdmin>): KratosAdmin => ({
createIdentity: async () => { throw new Error("unused"); },
createRecoveryCode: async () => ({ code: "000000", link: "http://kratos/recover" }),
@@ -848,7 +848,7 @@ const fakeKeto = (tuples: RelationTuple[] = [], over: Partial<KetoClient> = {}):
});
const withWhoami = (whoami: KratosPublic["whoami"]): KratosPublic => ({ ...mockKratos(async () => { throw new Error("unused"); }), whoami });
// Shared harness for the §5 admin-screen HTTP tests: an app on a random port with an admin JWT +
// Shared harness for the admin-screen HTTP tests: an app on a random port with an admin JWT +
// CSRF cookie. get(path, roles)/post(path, body) carry them; `token` is the matching CSRF field.
const ADMIN_CSRF = "admin-secret";
async function adminHarness(t: TestContext, opts: AppOptions = {}) {
@@ -892,7 +892,7 @@ test("login completion (/auth/complete): a live session mints the JWT cookie; no
assert.match(ok.headers.get("set-cookie") ?? "", /^plainpages_jwt=h\.p\.s;.*HttpOnly/);
assert.deepEqual(projected, { roles: ["admin"] }); // Keto roles projected onto the identity for the tokenizer
// return_to (§9): a safe host-relative target lands the user back where they were headed; an
// return_to: a safe host-relative target lands the user back where they were headed; an
// off-origin one is ignored (open-redirect guard) and falls back to the dashboard.
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "/admin/users?q=1")).headers.get("location"), "/admin/users?q=1");
assert.equal((await complete(createApp({ keto, kratos, kratosAdmin }), "plainpages_session=s", "//evil.com")).headers.get("location"), "/dashboard");
@@ -935,7 +935,7 @@ test("logout (CSRF-guarded POST): valid token revokes the Kratos session + clear
assert.equal((await post("", `_csrf=${token}`)).status, 403); // no cookie to match
});
// OAuth2 login challenge (§6): another app logs in *through* us; Hydra hands the browser here.
// OAuth2 login challenge: another app logs in *through* us; Hydra hands the browser here.
const stubHydra = (over: Partial<HydraAdmin> = {}): HydraAdmin => ({
acceptConsentRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?consent_verifier=v" }),
acceptLoginRequest: async () => ({ redirect: "http://127.0.0.1:4444/oauth2/auth?login_verifier=v" }),
@@ -1074,7 +1074,7 @@ test("OAuth2 challenge endpoints degrade identically: stale Hydra 4xx → 400, o
}
});
// Built-in Users admin screen (§5): gate + every CRUD action over HTTP against a mock Kratos admin.
// Built-in Users admin screen: gate + every CRUD action over HTTP against a mock Kratos admin.
test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, recovery (CSRF-guarded)", async (t) => {
const mk = (email: string, over: Partial<Identity> = {}): Identity =>
({ id: randomUUID(), schema_id: "default", state: "active", traits: { email, name: { first: "Ada", last: "Lovelace" } }, ...over });
@@ -1088,7 +1088,7 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
listIdentities: async () => ({ identities: store, nextPageToken: null }),
updateIdentity: async (id, payload) => { const it = store.find((x) => x.id === id)!; Object.assign(it, payload); return it; },
});
const denylist = createDenylist(); // §9: a deactivate/delete should revoke the target's live tokens instantly
const denylist = createDenylist(); // a deactivate/delete should revoke the target's live tokens instantly
const { get, post, token, url } = await adminHarness(t, { denylist, kratosAdmin });
await assertAdminGate(url, get, "/admin/users");
@@ -1120,7 +1120,7 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
assert.equal(updated.status, 303);
assert.deepEqual((target.traits as { name: unknown }).name, { first: "Ada", last: "King" });
// Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once (§9).
// Deactivate (state toggle): active → inactive, and the target's live tokens are revoked at once.
await post(`/admin/users/${target.id}/state`, `_csrf=${token}`);
assert.equal(target.state, "inactive");
assert.equal(denylist.isRevoked(target.id, 0), true);
@@ -1152,7 +1152,7 @@ test("admin Users screen: gate, list/filter, create, edit, deactivate, delete, r
assert.equal((await get("/admin/users/%ZZ")).status, 404);
});
// Built-in Groups admin screen (§5): gate + list/create/membership/delete over HTTP against a
// Built-in Groups admin screen: gate + list/create/membership/delete over HTTP against a
// fakeKeto (tuples are the only state) and a stub Kratos admin (resolves member emails).
test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-guarded)", async (t) => {
const ada = "01902d5e-7b6c-7e3a-9f21-3c8d1e0a4b01";
@@ -1208,7 +1208,7 @@ test("admin Groups screen: gate, list, create, detail/membership, delete (CSRF-g
assert.equal((await get("/admin/groups/%ZZ")).status, 404);
});
// Built-in Roles & permissions admin screen (§5): gate + list/create/assign/revoke/delete over HTTP
// Built-in Roles & permissions admin screen: gate + list/create/assign/revoke/delete over HTTP
// against a fake in-memory Keto whose `expand` mirrors Keto's transitive resolution, so the
// effective-access view surfaces a user reachable only through a group.
test("admin Roles screen: gate, list, create, assign user/group, effective access (expand), revoke, delete", async (t) => {
@@ -1233,7 +1233,7 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
});
const keto = fakeKeto(tuples, { expand: async (set) => expandSet(set) });
const kratosAdmin = stubAdmin({ listIdentities: async () => ({ identities, nextPageToken: null }) });
const denylist = createDenylist(); // §9: granting/revoking a *user's* role revokes their live tokens (a group change is transitive → left to lag)
const denylist = createDenylist(); // granting/revoking a *user's* role revokes their live tokens (a group change is transitive → left to lag)
const { get, post, token, url } = await adminHarness(t, { denylist, keto, kratosAdmin });
await assertAdminGate(url, get, "/admin/roles");
@@ -1275,7 +1275,7 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=group:eng`);
assert.ok(!tuples.some((tp) => tp.namespace === "Role" && tp.object === "editor" && tp.subject_set?.object === "eng"));
// Unassigning a *user* membership likewise revokes that user's live token (§9), so the loss of access is immediate.
// Unassigning a *user* membership likewise revokes that user's live token, so the loss of access is immediate.
await post("/admin/roles/editor/members", `_csrf=${token}&member=user:${grace}`);
await post("/admin/roles/editor/members/delete", `_csrf=${token}&member=user:${grace}`);
assert.equal(denylist.isRevoked(grace, 0), true);
@@ -1299,7 +1299,7 @@ test("admin Roles screen: gate, list, create, assign user/group, effective acces
assert.equal((await get("/admin/roles/%ZZ")).status, 404);
});
// Built-in OAuth2 clients admin screen (§6): gate + list/register/detail/delete over HTTP against an
// Built-in OAuth2 clients admin screen: gate + list/register/detail/delete over HTTP against an
// in-memory Hydra. Registration shows the one-time client_secret on the post-create page (no PRG).
test("admin OAuth2 clients screen: gate, list, register (one-time secret), detail, delete (CSRF-guarded)", async (t) => {
const store: OAuth2Client[] = [
+26 -26
View File
@@ -45,15 +45,15 @@ export interface AppOptions {
// Off by default so edits show live; the app itself never inspects the environment.
cache?: boolean;
csrfSecret?: string; // HMAC key for the double-submit CSRF token (config.csrfSecret); random if omitted
denylist?: Denylist; // optional instant-revoke (§9); the hot path rejects revoked subjects, admin writes record revokes
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge (§6)
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles (§4); absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion (§4)
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes (§4)
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion (§4)
log?: Log; // app-level logger (§9); per-request access log + trace span. Default: silent (tests)
denylist?: Denylist; // optional instant-revoke; the hot path rejects revoked subjects, admin writes record revokes
hydra?: HydraAdmin; // Hydra admin client; with kratos enables the OAuth2 login challenge
jwks?: JwksProvider; // verify the session JWT → ctx.user/roles; absent ⇒ always anonymous
keto?: KetoClient; // Keto client; with kratos+kratosAdmin enables login completion
kratos?: KratosPublic; // Kratos public client; enables the themed self-service routes
kratosAdmin?: KratosAdmin; // Kratos admin client; with kratos+keto enables login completion
log?: Log; // app-level logger; per-request access log + trace span. Default: silent (tests)
menu?: MenuConfig; // central override + branding (config/menu.ts); defaults to DEFAULT_MENU
plugins?: Plugin[]; // discovered manifests to mount (router); empty until §2 discovery runs
plugins?: Plugin[]; // discovered manifests to mount (router); empty until discovery runs
pluginsDir?: string; // where plugin views/static live; defaults to the scanned plugins/
publicDir?: string;
secureCookies?: boolean; // set Secure on our session/CSRF cookies (config.secureCookies; off in dev http)
@@ -86,7 +86,7 @@ export function createApp(options: AppOptions = {}): Server {
const plugins = options.plugins ?? [];
const pluginIds = new Set(plugins.map((p) => p.id));
// A plugin may fully replace the public landing "/" (`home`) or the gated dashboard "/dashboard"
// (`dashboard`) — §10. Discovery's findConflicts guarantees at most one of each, so `find` is
// (`dashboard`) — Discovery's findConflicts guarantees at most one of each, so `find` is
// unambiguous; the predicates narrow the slot to defined.
const homePlugin = plugins.find((p): p is Plugin & { home: RouteHandler } => typeof p.home === "function");
const dashboardPlugin = plugins.find((p): p is Plugin & { dashboard: RouteHandler } => typeof p.dashboard === "function");
@@ -108,13 +108,13 @@ export function createApp(options: AppOptions = {}): Server {
// building-block partials (resolved from viewsDir) and their own partials/subfolders.
const renderView = renderPluginView({ cache, coreViewsDir: viewsDir, pluginsDir });
// Built-in admin screens (§5) — wired only when their Ory clients are present (the writes go
// Built-in admin screens — wired only when their Ory clients are present (the writes go
// there). They render core views via `render` and are gated/CSRF-guarded inside the handler.
// Users writes to Kratos; Groups writes to Keto and reads users from Kratos for the pickers.
const adminDeps: AdminUsersDeps | null = kratosAdmin ? { csrfSecret, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
const adminGroupsDeps: AdminGroupsDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render } : null;
const adminRolesDeps: AdminRolesDeps | null = kratosAdmin && keto ? { csrfSecret, keto, kratosAdmin, menu, render, ...(revoke ? { revoke } : {}) } : null;
// OAuth2 clients (§6) write to Hydra; wired only when the Hydra admin client is present.
// OAuth2 clients write to Hydra; wired only when the Hydra admin client is present.
const adminClientsDeps: AdminClientsDeps | null = hydra ? { csrfSecret, hydra, menu, render } : null;
const sendHtml = (res: ServerResponse, status: number, html: string): void => {
@@ -158,7 +158,7 @@ export function createApp(options: AppOptions = {}): Server {
// Verify the session JWT once (cached JWKS) → ctx.user/roles; none/invalid ⇒ anonymous.
// If the token has lapsed but a live Kratos session still backs it (and we have the Ory
// clients), silently re-mint it — "stay signed in" (§4): re-read roles from Keto, re-tokenize,
// clients), silently re-mint it — "stay signed in": re-read roles from Keto, re-tokenize,
// and set the fresh cookie via setHeader so it rides whatever response this request produces
// (a dead session clears the stale cookie). This is the only place the hot path touches Ory.
let user: User | null = null;
@@ -178,7 +178,7 @@ export function createApp(options: AppOptions = {}): Server {
}
}
// CSRF token for this request's first-party forms: reuse a genuine cookie token, else mint
// one (the form page below Set-Cookies it). Verified on our own state-changing routes (§4).
// one (the form page below Set-Cookies it). Verified on our own state-changing routes.
const csrf = ensureCsrfToken(req.headers.cookie, csrfSecret);
// Bound CSRF verifier handed to plugins via ctx.verifyCsrf (the host owns the secret).
const verifyCsrf = (submitted: string | null | undefined): boolean =>
@@ -226,7 +226,7 @@ export function createApp(options: AppOptions = {}): Server {
return;
}
// Built-in admin screens (§5). Each handler gates (admin only; throws GuardError the catch
// Built-in admin screens. Each handler gates (admin only; throws GuardError the catch
// maps), CSRF-guards mutations, and returns html/redirect. Set the page's CSRF cookie when
// freshly minted (its forms carry the matching token); null ⇒ unknown subpath → 404.
if (adminDeps && pathname.startsWith(ADMIN_USERS_BASE)) {
@@ -282,7 +282,7 @@ export function createApp(options: AppOptions = {}): Server {
// A `return_to` is baked into the flow so Kratos lands there after login instead of the
// default completion route. A first-party deep link (host-relative, from the gate's
// return_to) is wrapped through /auth/complete so the session JWT is minted before the
// user reaches the page; an absolute target (the §6 OAuth2 login challenge) is passed
// user reaches the page; an absolute target (the OAuth2 login challenge) is passed
// as-is — Kratos allow-lists it. localPath rejects an off-origin "//evil.com".
const raw = ctx.url.searchParams.get("return_to");
const local = localPath(raw);
@@ -324,14 +324,14 @@ export function createApp(options: AppOptions = {}): Server {
}
throw err; // any other Kratos 4xx → the catch-all (genuinely unexpected)
}
// Rendered inside the unified app shell (§10), so set a fresh CSRF cookie when minted — the
// Rendered inside the unified app shell, so set a fresh CSRF cookie when minted — the
// shell's Sign-out form (shown on /settings, where the user is signed in) needs the token.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
sendHtml(res, 200, await render("auth", { chrome: ctx.chrome, flow: buildFlowView(flow, flowType) }));
return;
}
// OAuth2 login challenge (§6): Hydra hands the browser here when another app logs in
// OAuth2 login challenge: Hydra hands the browser here when another app logs in
// *through* us. Resolve it via the Kratos session and accept; an unauthenticated user
// bounces to our themed login and returns here once signed in. Provider-only.
if (hydra && kratos && pathname === "/oauth2/login" && (method === "GET" || method === "HEAD")) {
@@ -358,7 +358,7 @@ export function createApp(options: AppOptions = {}): Server {
return;
}
// OAuth2 consent challenge (§6): after login Hydra hands the browser here. A first-party
// OAuth2 consent challenge: after login Hydra hands the browser here. A first-party
// (or Hydra-skipped) client is auto-granted its scopes; a third-party client gets the themed
// consent screen, whose CSRF-guarded POST accepts (Allow) or rejects (Deny). Provider-only.
if (hydra && kratos && pathname === "/oauth2/consent") {
@@ -408,7 +408,7 @@ export function createApp(options: AppOptions = {}): Server {
}
}
// OAuth2 RP-initiated logout (§6): Hydra hands the browser here to end the OAuth2 session
// OAuth2 RP-initiated logout: Hydra hands the browser here to end the OAuth2 session
// (hydra.yml urls.logout). Accept the challenge and resume to Hydra's post-logout redirect;
// the first-party POST /logout (below) owns the Kratos session + our JWT cookie. Provider-only.
// GET-accept is safe (like the login/consent handlers): the challenge is Hydra-minted +
@@ -433,7 +433,7 @@ export function createApp(options: AppOptions = {}): Server {
// Login completion: where Kratos lands the browser after authenticating (kratos.yml).
// Mint our session JWT — read roles from Keto, project onto the identity, tokenize —
// and store it as the cookie; no active session bounces back to sign in (§4).
// and store it as the cookie; no active session bounces back to sign in.
if (pathname === "/auth/complete" && method === "GET" && kratos && kratosAdmin && keto) {
const completed = await completeLogin({ keto, kratosAdmin, kratosPublic: kratos }, req.headers.cookie);
if (!completed) {
@@ -442,7 +442,7 @@ export function createApp(options: AppOptions = {}): Server {
}
res.appendHeader("set-cookie", sessionCookie(completed.jwt, { secure: secureCookies }));
// Land on the deep link the user was headed to (return_to, validated host-relative so a
// crafted ?return_to= can't make this an open redirect), else the gated dashboard (§9/§10).
// crafted ?return_to= can't make this an open redirect), else the gated dashboard.
res.writeHead(303, { location: localPath(ctx.url.searchParams.get("return_to")) ?? "/dashboard" }).end();
return;
}
@@ -476,7 +476,7 @@ export function createApp(options: AppOptions = {}): Server {
}
if (pathname === "/" && (method === "GET" || method === "HEAD")) {
// The public landing (§10): ungated — anyone may see it. A plugin may fully own it via `home`
// The public landing: ungated — anyone may see it. A plugin may fully own it via `home`
// (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.
if (homePlugin) {
@@ -487,7 +487,7 @@ export function createApp(options: AppOptions = {}): Server {
await sendResult(res, result, (view, data) => renderView(homePlugin.id, view, data));
return;
}
// Default landing in the unified app shell (§10): `user` picks "go to dashboard" vs sign-in,
// Default landing in the unified app shell: `user` picks "go to dashboard" vs sign-in,
// and the shell's Sign-out form (when signed in) needs a fresh CSRF cookie.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
sendHtml(res, 200, await render("home", { chrome: ctx.chrome, user }));
@@ -495,12 +495,12 @@ export function createApp(options: AppOptions = {}): Server {
}
if (pathname === "/dashboard" && (method === "GET" || method === "HEAD")) {
// The post-login app home, gated to a signed-in user (§10): anonymous bounces to sign in,
// The post-login app home, gated to a signed-in user: anonymous bounces to sign in,
// remembering /dashboard as return_to.
if (!user) { res.writeHead(303, { location: loginRedirect(ctx) }).end(); return; }
// The page carries the Sign-out form, so Set-Cookie a fresh CSRF token here when absent.
if (csrf.fresh) res.appendHeader("set-cookie", csrfCookie(csrf.token, { secure: secureCookies }));
// A plugin may fully own the dashboard (§10): render its handler against its own views, native
// A plugin may fully own the dashboard: render its handler against its own views, native
// shell via ctx.chrome — same path as a plugin route. Else the built-in mock-data People list.
if (dashboardPlugin) {
const dashCtx = buildContext(req, res, { chrome, log: reqLog, user, verifyCsrf });
@@ -543,7 +543,7 @@ export function createApp(options: AppOptions = {}): Server {
};
return createServer((req, res) => {
// Per-request log + trace span (§9): a "request" span, continuing an upstream W3C traceparent
// Per-request log + trace span: a "request" span, continuing an upstream W3C traceparent
// when present (distributed tracing across a proxy). "close" (not "finish") fires on both a
// completed response and a premature disconnect/abort, so an aborted/truncated request is still
// logged and its span flushed.
+2 -2
View File
@@ -1,6 +1,6 @@
// Read an application/x-www-form-urlencoded request body (todo §4). Our own POST forms are
// Read an application/x-www-form-urlencoded request body. Our own POST forms are
// tiny, so cap the size and reject anything larger rather than buffer unbounded. Consumes the
// stream once; never throws on an empty body. The CSRF gate + §5 admin forms read fields here.
// stream once; never throws on an empty body. The CSRF gate + admin forms read fields here.
import type { IncomingMessage } from "node:http";
const DEFAULT_LIMIT = 1024 * 1024; // 1 MiB
+1 -1
View File
@@ -1,4 +1,4 @@
// One-command bootstrap (§3): idempotent first-boot seeding. Guards the pure payload
// One-command bootstrap: idempotent first-boot seeding. Guards the pure payload
// builders (Kratos create-identity body + Keto role tuple), the idempotent seedAdmin
// orchestration (fresh 201 vs existing 409 → reuse id), and the JWKS generate-if-absent
// safety net. Live boot is verified by running the stack; these catch contract drift.
+2 -2
View File
@@ -1,4 +1,4 @@
// One-command bootstrap (todo §3, the MVP bar). One-shot compose service: runs after
// One-command bootstrap (the MVP bar). One-shot compose service: runs after
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos;
@@ -134,7 +134,7 @@ export function firstRunBanner(opts: { appUrl: string; email: string; password:
async function main() {
const env = process.env;
// Structured like the web app (§9) so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME.
// Structured like the web app so prod logs stay uniform; honour LOG_FORMAT/SERVICE_NAME.
const log = createLogger({
format: env["LOG_FORMAT"] === "json" ? "json" : "text",
...(env["SERVICE_NAME"] ? { serviceName: env["SERVICE_NAME"] } : {}),
+2 -2
View File
@@ -1,4 +1,4 @@
// Page chrome for plugin pages (todo §7): the brand / global-nav / user / theme / csrf block a
// Page chrome for plugin pages: the brand / global-nav / user / theme / csrf block a
// plugin view hands to partials/shell so its page looks native — the same shell the dashboard and
// admin screens render. Pure; the host builds it per plugin request and exposes it on ctx.chrome.
// nav is the global menu — Dashboard + every plugin's fragment + the gated admin section — run
@@ -30,7 +30,7 @@ export interface ChromeOptions {
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
// The Dashboard link targets the gated /dashboard, so show it only to a signed-in user — to an
// anonymous visitor (a public page in the shell, §10) it would only dead-end at /login.
// anonymous visitor (a public page in the shell) it would only dead-end at /login.
const fragments: NavNode[][] = opts.user ? [[DASHBOARD_NAV]] : [];
for (const p of opts.plugins ?? []) if (p.nav?.length) fragments.push(p.nav);
fragments.push([adminSection()]);
+5 -5
View File
@@ -1,4 +1,4 @@
// Guards the dev/prod compose split + stack ordering (§3): every image is pinned to an
// Guards the dev/prod compose split + stack ordering: every image is pinned to an
// exact version (AGENTS.md), long-running Ory services carry readiness healthchecks so
// `depends_on: service_healthy` works, the web app waits for the services it talks to
// (kratos + keto + hydra), prod publishes no internal Ory ports while dev exposes
@@ -42,7 +42,7 @@ test("long-running Ory services declare readiness healthchecks", () => {
test("web waits for kratos, keto and hydra to be healthy before starting", () => {
assert.match(webBlock, /depends_on:/, "web declares dependencies");
// hydra: the §6 OAuth2 login/consent handler talks to its admin API.
// hydra: the OAuth2 login/consent handler talks to its admin API.
for (const svc of ["kratos", "keto", "hydra"])
assert.match(webBlock, new RegExp(`${svc}:\\s*\\n\\s*condition:\\s*service_healthy`),
`web waits for ${svc} healthy`);
@@ -58,7 +58,7 @@ test("prod base publishes no internal Ory ports; dev exposes the host-facing one
});
test("prod base supplies the app secret via env and mounts no source; dev override flips it", () => {
// §9 prod compose: CSRF_SECRET comes from the environment (dev-throwaway fallback that
// prod compose: CSRF_SECRET comes from the environment (dev-throwaway fallback that
// REQUIRE_SECURE_SECRETS rejects in prod — see config.ts); the base never bind-mounts the
// source tree (runs the built image), while the dev override does for live editing.
assert.match(webBlock, /CSRF_SECRET:\s*\$\{CSRF_SECRET\b/, "base wires CSRF_SECRET from env");
@@ -67,7 +67,7 @@ test("prod base supplies the app secret via env and mounts no source; dev overri
// Secret/cookie hardening: enforced in prod, off in dev so the throwaway + http cookies pass.
assert.match(webBlock, /REQUIRE_SECURE_SECRETS:\s*"true"/, "base enforces real secrets");
assert.match(override, /REQUIRE_SECURE_SECRETS:\s*"false"/, "dev allows the throwaway");
// §9 observability: prod emits structured JSON logs; dev flips it to human-readable text.
// observability: prod emits structured JSON logs; dev flips it to human-readable text.
assert.match(webBlock, /LOG_FORMAT:\s*"json"/, "prod logs structured JSON");
assert.match(override, /LOG_FORMAT:\s*"text"/, "dev logs human-readable text");
// Postgres credentials are env-supplied (dev default), never a baked-in literal.
@@ -75,7 +75,7 @@ test("prod base supplies the app secret via env and mounts no source; dev overri
});
test("a one-shot bootstrap seeds the stack before web starts", () => {
// §3 MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin +
// MVP bar: `bootstrap` runs after kratos+keto are healthy, seeds the admin +
// JWKS, then exits; web waits for it to complete. Live seeding is boot-verified.
const boot = compose.slice(compose.indexOf("\n bootstrap:"));
assert.match(boot, /node src\/bootstrap\.ts/, "bootstrap runs the seed script");
+5 -5
View File
@@ -21,9 +21,9 @@ test("loads dev defaults when the environment is empty", () => {
assert.equal(c.hydraAdminUrl, "http://hydra:4445");
assert.match(c.csrfSecret, /dev-insecure/);
assert.equal(c.jwtClockSkewSec, 60); // default exp/nbf leeway for Kratos↔web clock drift
assert.equal(c.revocationDenylist, false); // instant-revoke is opt-in (§9)
assert.equal(c.revocationDenylist, false); // instant-revoke is opt-in
assert.equal(c.revocationTtlSec, 900); // ≥ tokenizer TTL (10m) + skew
assert.equal(c.logLevel, "info"); // §9 observability defaults
assert.equal(c.logLevel, "info"); // observability defaults
assert.equal(c.logFormat, "text"); // human-readable in dev; prod compose sets json
assert.equal(c.otlpEndpoint, undefined); // OTLP export opt-in; console-only by default
assert.equal(c.otlpProtocol, "http/json");
@@ -38,12 +38,12 @@ test("APP_URL is the canonical public URL: opt-in (unset ⇒ no redirect), honou
assert.throws(() => loadConfig({ APP_URL: "not a url" }), /APP_URL/);
});
test("SERVICE_NAME is overridable so an implementer brands their own logs/traces (§9)", () => {
test("SERVICE_NAME is overridable so an implementer brands their own logs/traces", () => {
assert.equal(loadConfig({ SERVICE_NAME: "acme-ops" }).serviceName, "acme-ops");
assert.equal(loadConfig({ SERVICE_NAME: "" }).serviceName, "plainpages"); // empty ⇒ default
});
test("LOG_LEVEL/LOG_FORMAT/OTLP_PROTOCOL are validated enums; OTLP_ENDPOINT an optional URL (§9)", () => {
test("LOG_LEVEL/LOG_FORMAT/OTLP_PROTOCOL are validated enums; OTLP_ENDPOINT an optional URL", () => {
assert.equal(loadConfig({ LOG_LEVEL: "debug" }).logLevel, "debug");
assert.equal(loadConfig({ LOG_LEVEL: "none" }).logLevel, "none");
assert.throws(() => loadConfig({ LOG_LEVEL: "trace" }), /LOG_LEVEL/);
@@ -64,7 +64,7 @@ test("REVOCATION_DENYLIST: opt-in toggle (off by default) + REVOCATION_TTL_SEC m
test("JWKS_URL defaults to the committed Kratos tokenizer signing key, not an http endpoint", () => {
// The session JWT is signed by the tokenizer key (kratos.yml jwks_url); Kratos does NOT
// republish it at /.well-known/jwks.json, so the §4 verifier reads that same file://.
// republish it at /.well-known/jwks.json, so the verifier reads that same file://.
// gen-jwks.test.ts owns that the file is a valid ES256 signing key with a kid.
const url = new URL(loadConfig({}).jwksUrl);
assert.equal(url.protocol, "file:");
+13 -13
View File
@@ -1,4 +1,4 @@
// Config loaded once from the environment at boot (todo §0): Ory endpoints, cookie/CSRF
// Config loaded once from the environment at boot: Ory endpoints, cookie/CSRF
// secrets, JWKS location, listen port, behaviour toggles. Fail-loud — a bad value, a
// missing enforced secret, a bad URL, or an out-of-range port throws here, never at
// request time.
@@ -25,16 +25,16 @@ export interface Config {
ketoWriteUrl: string;
kratosAdminUrl: string;
kratosPublicUrl: string;
logFormat: "json" | "text"; // §9: console/OTLP entry format (json for structured prod logs)
logLevel: LogLevel; // §9: minimum severity emitted
logFormat: "json" | "text"; // console/OTLP entry format (json for structured prod logs)
logLevel: LogLevel; // minimum severity emitted
oryTimeoutSec: number; // per-call timeout for outbound Kratos/Keto/Hydra fetches (bounds a hung Ory)
otlpEndpoint: string | undefined; // §9: OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
otlpProtocol: "http/json" | "http/protobuf"; // §9: OTLP wire format (protobuf for json-averse collectors)
otlpEndpoint: string | undefined; // OTLP/HTTP collector base URI; unset ⇒ console-only (no export)
otlpProtocol: "http/json" | "http/protobuf"; // OTLP wire format (protobuf for json-averse collectors)
port: number;
revocationDenylist: boolean; // §9: enable the optional instant role/session revoke denylist
revocationDenylist: boolean; // enable the optional instant role/session revoke denylist
revocationTtlSec: number; // how long a revoke entry lives; keep ≥ tokenizer TTL + clock skew
secureCookies: boolean;
serviceName: string; // §9: OTLP service.name — an implementer brands their own logs/traces
serviceName: string; // OTLP service.name — an implementer brands their own logs/traces
}
type Env = Record<string, string | undefined>;
@@ -59,7 +59,7 @@ function readBool(env: Env, key: string, devDefault: boolean): boolean {
}
// An optional pinned value: present only when set non-empty. Unset ⇒ the matching claim
// check is skipped (clean clone — the dev tokenizer sets no iss/aud; §4 verifier).
// check is skipped (clean clone — the dev tokenizer sets no iss/aud; verifier).
function readOptional(env: Env, key: string): string | undefined {
return env[key] || undefined;
}
@@ -134,9 +134,9 @@ export function loadConfig(env: Env = process.env): Config {
appUrl: readOptionalUrl(env, "APP_URL"),
cacheTemplates: readBool(env, "CACHE_TEMPLATES", false),
csrfSecret: readSecret(env, "CSRF_SECRET", "dev-insecure-csrf-secret", requireSecure),
// Hydra admin API — the OAuth2 login/consent challenge handshake (§6); not on the first-party path.
// Hydra admin API — the OAuth2 login/consent challenge handshake; not on the first-party path.
hydraAdminUrl: readUrl(env, "HYDRA_ADMIN_URL", "http://hydra:4445"),
// §4 verifier reads the same key the Kratos tokenizer signs with (kratos.yml jwks_url).
// verifier reads the same key the Kratos tokenizer signs with (kratos.yml jwks_url).
// Kratos doesn't republish it over HTTP, so default to a file:// of the tokenizer JWKS
// mounted into web (compose.yml). Prod overrides with a real key (README: rotation).
jwksUrl: readUrl(env, "JWKS_URL", "file:///etc/config/kratos/tokenizer/jwks.json"),
@@ -149,7 +149,7 @@ export function loadConfig(env: Env = process.env): Config {
ketoWriteUrl: readUrl(env, "KETO_WRITE_URL", "http://keto:4467"),
kratosAdminUrl: readUrl(env, "KRATOS_ADMIN_URL", "http://kratos:4434"),
kratosPublicUrl: readUrl(env, "KRATOS_PUBLIC_URL", "http://kratos:4433"),
// §9 observability. Console-only by default (clean clone). Setting OTLP_ENDPOINT to an
// observability. Console-only by default (clean clone). Setting OTLP_ENDPOINT to an
// OpenTelemetry Collector exports structured logs + per-request spans there (Loki/Tempo).
logFormat: readEnum(env, "LOG_FORMAT", ["json", "text"] as const, "text"),
logLevel: readEnum(env, "LOG_LEVEL", LOG_LEVELS, "info"),
@@ -157,13 +157,13 @@ export function loadConfig(env: Env = process.env): Config {
otlpEndpoint: readOptionalUrl(env, "OTLP_ENDPOINT"),
otlpProtocol: readEnum(env, "OTLP_PROTOCOL", ["http/json", "http/protobuf"] as const, "http/json"),
port: readPort(env),
// Optional instant-revoke (§9), off by default. When on, an admin deactivate/delete or role
// Optional instant-revoke, off by default. When on, an admin deactivate/delete or role
// change revokes the subject's live tokens at once; the entry lives ttl seconds (≥ the 10m
// tokenizer TTL + skew, so it outlasts any pre-revoke token).
revocationDenylist: readBool(env, "REVOCATION_DENYLIST", false),
revocationTtlSec: readPosInt(env, "REVOCATION_TTL_SEC", 900),
// Set Secure on our session/CSRF cookies. Off by default (dev runs http); prod (https) sets it.
secureCookies: readBool(env, "SECURE_COOKIES", false),
serviceName: env["SERVICE_NAME"] || "plainpages", // §9 OTLP service.name; empty ⇒ default
serviceName: env["SERVICE_NAME"] || "plainpages", // OTLP service.name; empty ⇒ default
};
}
+1 -1
View File
@@ -46,7 +46,7 @@ test("buildContext defaults a missing request URL to /", () => {
assert.equal(buildContext(req, res).url.pathname, "/");
});
test("buildContext provides a logger: a silent default, or the host's request logger (§9)", () => {
test("buildContext provides a logger: a silent default, or the host's request logger", () => {
const { req, res } = reqRes("/");
assert.equal(typeof buildContext(req, res).log.info, "function"); // always present (silent default)
const log = createLogger({ level: "none" });
+3 -3
View File
@@ -3,10 +3,10 @@ import type { PageChrome } from "./chrome.ts"; // type-only: no runtime import,
import { createLogger, type Log } from "./logger.ts";
// The request context threaded to every route handler (plugin + built-in), built once
// per request by `buildContext`: the router supplies matched path `params`, the §4 JWT
// per request by `buildContext`: the router supplies matched path `params`, the JWT
// middleware supplies `user` (null until then). The host's single handler argument.
// The authenticated user, projected from verified session JWT claims (§4):
// The authenticated user, projected from verified session JWT claims:
// `id` = `sub`, plus `email` and the coarse `roles` carried in the token.
export interface User {
email: string;
@@ -18,7 +18,7 @@ export interface RequestContext {
// Page chrome (brand/global-nav/user/theme/csrf) a plugin view hands to partials/shell so its
// page renders the native app shell; the host builds it per request (anonymous default otherwise).
chrome: PageChrome;
// Request-scoped logger (§9): structured, in the request's trace. `log.info/warn/error(...)` to
// Request-scoped logger: structured, in the request's trace. `log.info/warn/error(...)` to
// log; `log.fetch(url)` for an upstream call (a client span continuing the trace). Correlates by
// requestId. Additive, stable per the contract; defaults to a silent logger off the request path.
log: Log;
+1 -1
View File
@@ -1,5 +1,5 @@
// Cookie helpers — parse the request `Cookie` header, build secure-by-default
// `Set-Cookie` headers. Stdlib only (no `cookie` dep); §4 stores/clears the session
// `Set-Cookie` headers. Stdlib only (no `cookie` dep); stores/clears the session
// JWT + CSRF token here. Values round-trip via percent-encoding; JWT `-_.` chars are
// URI-unreserved, so JWTs stay readable.
+1 -1
View File
@@ -1,4 +1,4 @@
// CSRF protection for our own POST forms (todo §4). Stateless signed double-submit token:
// CSRF protection for our own POST forms. Stateless signed double-submit token:
// the token is `<nonce>.<HMAC(secret, nonce)>`, set as a cookie *and* echoed in a hidden form
// field. A request passes iff the cookie is a genuine signature (can't be forged without the
// secret) and the submitted field equals it. SameSite=Lax already blocks the cross-site POST
+1 -1
View File
@@ -3,7 +3,7 @@ import { test } from "node:test";
import { buildDashboardModel } from "./dashboard.ts";
import type { NavNode } from "./nav.ts";
// The default /dashboard is an instructional starter (todo §10): no mock data, just the unified
// The default /dashboard is an instructional starter: no mock data, just the unified
// menu + shell. The host passes the one global menu (ctx.chrome.nav); the model passes it through.
const NAV: NavNode[] = [{ href: "/dashboard", label: "Dashboard" }, { children: [{ href: "/admin/users", label: "Users" }], label: "Admin" }];
+1 -1
View File
@@ -1,4 +1,4 @@
// Dashboard view model (todo §10): the gated "/dashboard" app home. By default a short instructional
// Dashboard view model: the gated "/dashboard" app home. By default a short instructional
// starter — what the dashboard is and how to replace it by exporting a `dashboard` handler from a
// plugin (views/index.ejs holds the prose). A plugin owns the real one (PluginManifest.dashboard);
// this placeholder renders until then. Pure: `nav` is the one global menu (ctx.chrome.nav), built
+1 -1
View File
@@ -1,4 +1,4 @@
// Optional revocation denylist (todo §9): instant role/session revoke without putting Keto
// Optional revocation denylist: instant role/session revoke without putting Keto
// back on the hot path. Off by default — enable with REVOCATION_DENYLIST=true.
//
// The hot path verifies a short-lived (~10m) session JWT in-process, so a revoked role or a
+4 -4
View File
@@ -51,8 +51,8 @@ const badCases: Array<{ name: string; files: Record<string, string>; match: RegE
{ name: "non-function dashboard", files: { "weirddash/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: "nope" };` }, match: /weirddash.*dashboard.*function/s },
{ name: "reserved dashboard id shadows the gated dashboard", files: { "dashboard/plugin.ts": full("dashboard") }, match: /dashboard.*reserved/s },
{ name: "duplicate nav id across plugins", files: { "a/plugin.ts": full("a").replace("a:root", "dup"), "b/plugin.ts": full("b").replace("b:root", "dup") }, match: /nav id "dup"/ },
{ name: "a route marked public AND permission is contradictory (§10)", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
{ name: "a nav node marked public AND permission is contradictory (§10)", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x" }] };` }, match: /contranav.*public.*permission/s },
{ name: "a route marked public AND permission is contradictory", files: { "contra/plugin.ts": `export default { apiVersion: "1.0.0", routes: [{ method: "GET", path: "/", public: true, permission: "x", handler: () => ({ html: "x" }) }] };` }, match: /contra.*public.*permission/s },
{ name: "a nav node marked public AND permission is contradictory", files: { "contranav/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ id: "n", label: "N", public: true, permission: "x" }] };` }, match: /contranav.*public.*permission/s },
{ name: "two plugins claim the public home", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ html: "b" }) };` }, match: /home/ },
{ name: "two plugins claim the gated dashboard", files: { "a/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "a" }) };`, "b/plugin.ts": `export default { apiVersion: "1.0.0", dashboard: () => ({ html: "b" }) };` }, match: /dashboard/ },
];
@@ -63,7 +63,7 @@ for (const c of badCases) {
});
}
test("a route + nav node may be marked public (§10) and load fine", async (t) => {
test("a route + nav node may be marked public and load fine", async (t) => {
const dir = scaffold(t, { "pub/plugin.ts": `export default { apiVersion: "1.0.0", nav: [{ href: "/pub", id: "n", label: "N", public: true }], routes: [{ method: "GET", path: "/", public: true, handler: () => ({ html: "x" }) }] };` });
const plugins = await discoverPlugins({ dir });
assert.equal(plugins.length, 1);
@@ -71,7 +71,7 @@ test("a route + nav node may be marked public (§10) and load fine", async (t) =
assert.equal(plugins[0]?.nav?.[0]?.public, true);
});
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers (§10)", async (t) => {
test("a plugin may declare `home` (public /) and `dashboard` (gated /dashboard) handlers", async (t) => {
const dir = scaffold(t, { "portal/plugin.ts": `export default { apiVersion: "1.0.0", home: () => ({ view: "home" }), dashboard: () => ({ view: "dash" }) };` });
const plugins = await discoverPlugins({ dir });
assert.equal(plugins.length, 1);
+4 -4
View File
@@ -1,4 +1,4 @@
// Plugin discovery (todo §2): scan plugins/, import each folder's plugin.ts default export,
// Plugin discovery: scan plugins/, import each folder's plugin.ts default export,
// validate it, assemble the loaded Plugin[]. The imperative shell over plugin.ts's pure rules
// (isValidPluginId, checkApiVersion, findConflicts). Fails loud: every per-plugin problem and
// error-level conflict is collected into one boot-stopping Error; warn-level diagnostics
@@ -88,12 +88,12 @@ function shapeError(manifest: PluginManifest): string | null {
for (const field of ["nav", "permissions", "routes"] as const) {
if (manifest[field] !== undefined && !Array.isArray(manifest[field])) return `"${field}" must be an array`;
}
// `home` / `dashboard` (the §10 landing-page overrides) are route handlers; the host calls them, so
// `home` / `dashboard` (the landing-page overrides) are route handlers; the host calls them, so
// a non-function fails loud.
for (const slot of ["home", "dashboard"] as const) {
if (manifest[slot] !== undefined && typeof manifest[slot] !== "function") return `"${slot}" must be a function (a route handler)`;
}
// `public` and `permission` are contradictory on the same route/nav node (§10) — "open to all" vs
// `public` and `permission` are contradictory on the same route/nav node — "open to all" vs
// "needs this role". Refuse rather than silently pick one, so the author's intent is unambiguous.
for (const route of Array.isArray(manifest.routes) ? manifest.routes : []) {
if (route?.public === true && route.permission != null) return `route "${route.method} ${route.path}" sets both public and permission — they are mutually exclusive`;
@@ -103,7 +103,7 @@ function shapeError(manifest: PluginManifest): string | null {
return null;
}
// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory (§10).
// Recurse the nav fragment: a node that is both `public` and `permission`-gated is contradictory.
function findPublicNavContradiction(nodes: PluginManifest["nav"]): string | null {
for (const node of Array.isArray(nodes) ? nodes : []) {
if (node?.public === true && node.permission != null) return `nav node "${node.label ?? node.id ?? "?"}" sets both public and permission — they are mutually exclusive`;
+1 -1
View File
@@ -1,4 +1,4 @@
// Bound every outbound Ory call (todo §8 review): a reachable-but-silent host — a hung container, a
// Bound every outbound Ory call: a reachable-but-silent host — a hung container, a
// black-holed socket, an LB holding the connection — would otherwise park a request handler forever
// (and exhaust the pool under load). Wrap the injected `fetch` so each call aborts after `ms` unless
// the caller already passed its own signal. server.ts wires this into the Kratos/Keto/Hydra clients.
+1 -1
View File
@@ -1,4 +1,4 @@
// Kratos flow → themed view model (todo §4). Pure: turns a fetched self-service Flow
// Kratos flow → themed view model. 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, tone-mapped messages, and one SSO button per
// configured `oidc` provider. The form posts straight back to `flow.ui.action`, so Kratos
+3 -3
View File
@@ -1,6 +1,6 @@
// Guards the session-tokenizer signing key (§3): generateJwks() emits a fresh ES256
// Guards the session-tokenizer signing key: generateJwks() emits a fresh ES256
// EC private signing key, the committed dev JWKS is a valid such key, and a token signed
// with it verifies through our own verifier (src/jwt.ts) — so what Kratos signs, §4 reads.
// with it verifies through our own verifier (src/jwt.ts) — so what Kratos signs, reads.
import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
@@ -45,7 +45,7 @@ test("rotateJwks --prune keeps only the newest (first) key, dropping superseded
assert.deepEqual(pruned.keys, [twoKeys.keys[0]], "only the active signing key remains");
});
test("a JWS signed with a generated key verifies via our own verifier (§4 reads what Kratos signs)", () => {
test("a JWS signed with a generated key verifies via our own verifier (reads what Kratos signs)", () => {
const key = generateJwks().keys[0]!;
const head = b64url(JSON.stringify({ alg: "ES256", kid: key.kid }));
const body = b64url(JSON.stringify({ email: "a@b.c", roles: [], sub: key.kid }));
+1 -1
View File
@@ -2,7 +2,7 @@ import { generateKeyPairSync, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
// ES256 signing JWKS for the Kratos session tokenizer (§3) — Ory-recommended and the
// ES256 signing JWKS for the Kratos session tokenizer — Ory-recommended and the
// verifier's preferred alg (src/jwt.ts). Rotation runbook: README, JWT signing key.
// CLI (prod supplies its own key; the committed one is a dev throwaway):
// gen-jwks.ts → a fresh one-key set (mint/replace; emergency rotation)
+2 -2
View File
@@ -1,4 +1,4 @@
// Auth guards (todo §4): in-handler authorization, the imperative counterpart to the
// Auth guards: in-handler authorization, the imperative counterpart to the
// declarative route `permission` gate. The middleware already verified the session JWT and put
// the User on ctx; these read it. `requireSession` asserts (throws GuardError, which app.ts maps
// to a response); `can`/`check` are predicates a handler branches on. `check` is the one live
@@ -8,7 +8,7 @@ import type { KetoClient } from "./keto-client.ts";
import { localPath } from "./safe-url.ts";
// Build the sign-in redirect for a gated request, preserving where the user was headed as
// `return_to` so login can land them back there (§9). Only a safe GET/HEAD navigation to a
// `return_to` so login can land them back there. Only a safe GET/HEAD navigation to a
// non-home, host-relative path is remembered (a POST or "/" ⇒ a bare /login); the target is
// validated host-relative (localPath) so it can't become an open redirect.
export function loginRedirect(ctx: RequestContext): string {
+1 -1
View File
@@ -1,4 +1,4 @@
// Plugin lifecycle hooks (todo §2): the host invokes the optional PluginHooks a plugin may declare
// Plugin lifecycle hooks: the host invokes the optional PluginHooks a plugin may declare
// (docs/plugin-contract.md → Hooks). No sandbox — a throwing hook fails loud (boot for onBoot, the
// request for the others). Hooks run in discovery order (plugins sorted by id). app.ts skips these
// entirely when no plugin declares the hook, so the no-hooks hot path stays free.
+2 -2
View File
@@ -1,4 +1,4 @@
// Hydra admin-API client (§6): typed fetch wrappers over Ory Hydra's OAuth2 login/consent
// Hydra admin-API client: typed fetch wrappers over Ory Hydra's OAuth2 login/consent
// challenge handshake. Guards the request contracts (URLs, method, login_challenge query,
// JSON body) and the result mapping (200 → request/redirect, non-2xx → HydraError). Live
// wiring is verified by the OAuth login E2E.
@@ -93,7 +93,7 @@ test("a non-2xx response throws a HydraError carrying the status", async () => {
);
});
// OAuth2 client registration (§6): create/list/get/delete clients over Hydra's admin API.
// OAuth2 client registration: create/list/get/delete clients over Hydra's admin API.
test("createClient POSTs the client and returns it (incl. the one-time client_secret)", async () => {
const created = { client_id: "c1", client_name: "Acme", client_secret: "s3cr3t", redirect_uris: ["https://acme/cb"] };
const { calls, fetchImpl } = recorder(() => res(201, created));
+4 -4
View File
@@ -1,4 +1,4 @@
// Hydra admin-API client (todo §6): typed `fetch` wrappers over Ory Hydra's OAuth2 admin
// Hydra admin-API client: typed `fetch` wrappers over Ory Hydra's OAuth2 admin
// endpoints (internal admin port) — the login/consent challenge handshake other apps log in
// *through* us with. Built-in `fetch` only, no SDK dep (AGENTS.md); `fetchImpl`-injectable
// like the kratos/keto clients. We authenticate the user (login) and grant scopes (consent);
@@ -9,7 +9,7 @@ export interface OAuth2Client {
client_name?: string;
client_secret?: string; // write-only: Hydra returns it once, on create, for a confidential client
grant_types?: string[];
metadata?: Record<string, unknown>; // arbitrary client metadata; `first_party: true` ⇒ auto-consent (§6)
metadata?: Record<string, unknown>; // arbitrary client metadata; `first_party: true` ⇒ auto-consent
redirect_uris?: string[];
response_types?: string[];
scope?: string; // space-separated
@@ -90,7 +90,7 @@ export class HydraError extends Error {
export interface HydraAdmin {
acceptConsentRequest(challenge: string, body: AcceptConsent): Promise<Completed>;
acceptLoginRequest(challenge: string, body: AcceptLogin): Promise<Completed>;
acceptLogoutRequest(challenge: string): Promise<Completed>; // RP-initiated logout (§6): confirm + resume
acceptLogoutRequest(challenge: string): Promise<Completed>; // RP-initiated logout: confirm + resume
createClient(client: OAuth2Client): Promise<OAuth2Client>;
deleteClient(id: string): Promise<void>;
getClient(id: string): Promise<OAuth2Client | null>;
@@ -145,7 +145,7 @@ export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof f
return put("accept logout", reqUrl("logout", challenge, "/accept"), {});
},
// OAuth2 client registration (§6, admin screen). Hydra generates the client_id/secret when
// OAuth2 client registration (admin screen). Hydra generates the client_id/secret when
// omitted; the secret rides the 201 body and is never retrievable afterwards.
async createClient(client) {
const res = await http(clientsUrl, { body: JSON.stringify(client), headers: json, method: "POST" });
+1 -1
View File
@@ -1,4 +1,4 @@
// Guards the Ory Hydra config (§3): migrations run before the server (hydra-migrate →
// Guards the Ory Hydra config: migrations run before the server (hydra-migrate →
// hydra), the DSN targets the hydra database, the server listens on the public/admin
// ports, and the issuer + login/consent/logout URLs point at our app. Version pinning is
// in compose.test.ts. Real boot is verified by running the stack; this catches edits.
+1 -1
View File
@@ -29,5 +29,5 @@ test("icons partial inlines exactly the used lucide-static icons", async () => {
assert.match(symbolInner(built, "i-x"), /M18 6 6 18/);
assert.match(symbolInner(built, "i-search"), /circle cx="11" cy="11" r="8"/);
assert.match(symbolInner(built, "i-kebab"), /circle cx="12" cy="12" r="1"/);
assert.match(symbolInner(built, "i-bell"), /M10\.268 21/); // lucide v1.18 path, not the mockup's older one
assert.match(symbolInner(built, "i-bell"), /M10\.268 21/); // lucide v1.18 path, not an older one
});
+2 -2
View File
@@ -3,7 +3,7 @@ import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { currentLog } from "./logger.ts";
// JWKS provider: resolve the JWT verify key by the JWS `kid` (todo §4). The middleware calls
// JWKS provider: resolve the JWT verify key by the JWS `kid`. The middleware calls
// `getKey` per request. `staticJwks` holds a fixed set; `cachingJwks` fetches over the network
// (or re-reads a mounted file), caches for a TTL, and reloads once on a `kid` miss so a rotated-
// in key is picked up without a restart (README: zero-downtime rotation). `createJwksProvider`
@@ -98,7 +98,7 @@ export function cachingJwks(load: () => Promise<JsonWebKey[]>, opts: JwksCacheOp
// Build the verify-key provider from the configured JWKS URL and prime it at boot (fail loud):
// `base64://` → immutable inline set; `file://` → re-readable cache (rotation by remount/edit);
// `http(s)://` → fetched, cached, rotation-on-miss. The §4 middleware sees only `getKey`.
// `http(s)://` → fetched, cached, rotation-on-miss. The middleware sees only `getKey`.
export async function createJwksProvider(jwksUrl: string, opts: JwksCacheOptions = {}): Promise<JwksProvider> {
if (jwksUrl.startsWith("base64://")) return staticJwks(loadJwks(jwksUrl));
const { protocol } = new URL(jwksUrl);
+2 -2
View File
@@ -72,7 +72,7 @@ test("resolveSession classifies the cookie; authenticate is its fail-closed user
// A valid token → the user, not expired.
assert.deepEqual(await resolveSession(cookie(), jwks, { now: NOW }), { expired: false, user });
// Present but past exp → the §4 re-mint trigger (expired flagged, no user).
// Present but past exp → the re-mint trigger (expired flagged, no user).
assert.deepEqual(await resolveSession(cookie({ exp: NOW - 999 }), jwks, { now: NOW }), { expired: true, user: null });
// No cookie / non-ours / garbage / bad-signature are NOT re-mint candidates (no Ory round-trip).
assert.deepEqual(await resolveSession(undefined, jwks, { now: NOW }), { expired: false, user: null });
@@ -90,7 +90,7 @@ test("verifyToken honours an optional denylist: a revoked subject's token reject
// Deny u1's tokens minted at/before NOW; a token minted after passes (a fresh re-login).
const denylist = { isRevoked: (sub: string, iat: number | undefined) => sub === "u1" && (iat === undefined || iat <= NOW) };
// Revoked: thrown as *expired* so resolveSession flags it for the §4 re-mint (re-read Keto / clear).
// Revoked: thrown as *expired* so resolveSession flags it for the re-mint (re-read Keto / clear).
await assert.rejects(verifyToken(mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 }), jwks, { denylist, now: NOW }), /revoked/);
assert.deepEqual(await resolveSession(`${SESSION_COOKIE}=${mint(k1.privateKey, "k1", { ...valid, iat: NOW - 5 })}`, jwks, { denylist, now: NOW }), { expired: true, user: null });
// A token minted after the revoke (fresh login) is accepted; a different subject is untouched.
+6 -6
View File
@@ -1,4 +1,4 @@
// JWT session middleware (todo §4): verify our session cookie in-process on every request —
// JWT session middleware: verify our session cookie in-process on every request —
// the hot path that never calls Ory. Select the verify key by `kid` from the cached JWKS,
// check the signature (src/jwt.ts), validate the time/issuer/audience claims, project the
// User onto the request context. `authenticate` fails closed: any bad/expired token ⇒ null
@@ -16,14 +16,14 @@ const DEFAULT_CLOCK_SKEW_SEC = 60;
export interface VerifyOptions {
audience?: string | undefined; // if set, the token `aud` must include it (else skipped)
clockSkewSec?: number | undefined;
denylist?: Pick<Denylist, "isRevoked"> | undefined; // optional instant-revoke (§9); a revoked sub is rejected like an expiry
denylist?: Pick<Denylist, "isRevoked"> | undefined; // optional instant-revoke; a revoked sub is rejected like an expiry
issuer?: string | undefined; // if set, the token `iss` must equal it (else skipped)
now?: number | undefined; // unix seconds; injectable for tests
}
// A rejected token (bad signature, expired, wrong iss/aud, malformed claims). `authenticate`
// swallows it to anonymous; a caller wanting the reason can catch it. `expired` is set only for
// a lapsed-but-otherwise-intact token — the §4 re-mint trigger (see resolveSession).
// a lapsed-but-otherwise-intact token — the re-mint trigger (see resolveSession).
export class TokenError extends Error {
expired: boolean;
constructor(message: string, expired = false) {
@@ -79,14 +79,14 @@ export async function verifyToken(token: string, jwks: JwksProvider, options: Ve
const verified = verifyJws(token, jwk); // throws on a bad signature / disallowed alg
validateClaims(verified.payload, options);
const user = claimsToUser(verified.payload);
// Instant revoke (§9): a denylisted subject's pre-revoke token is rejected as *expired* so
// resolveSession routes it through the §4 re-mint (fresh roles from Keto, or a cleared session).
// Instant revoke: a denylisted subject's pre-revoke token is rejected as *expired* so
// resolveSession routes it through the re-mint (fresh roles from Keto, or a cleared session).
if (options.denylist?.isRevoked(user.id, num(verified.payload, "iat"))) throw new TokenError("token revoked", true);
return user;
}
export interface SessionAuth {
expired: boolean; // a token was present but rejected as *expired* → a re-mint candidate (§4)
expired: boolean; // a token was present but rejected as *expired* → a re-mint candidate
user: User | null;
}
+4 -4
View File
@@ -1,9 +1,9 @@
import { createPublicKey, verify } from "node:crypto";
import type { JsonWebKey, KeyObject } from "node:crypto";
// JWS signature verification with the Node stdlib — no `jose`/JWT dep (todo §0):
// JWS signature verification with the Node stdlib — no `jose`/JWT dep:
// `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures
// the Kratos tokenizer produces (see AGENTS.md). Signature only — §4 adds claim checks
// the Kratos tokenizer produces (see AGENTS.md). Signature only — adds claim checks
// (exp/iss/aud, clock skew), JWKS-by-`kid` fetch/cache/rotation, and `token` bounds.
// JOSE `alg` → Node verify parameters. ES* signatures are raw r‖s (IEEE P1363), not DER.
@@ -27,7 +27,7 @@ export interface DecodedJws {
}
// Unpadded base64url alphabet — `Buffer.from(_,"base64url")` is lax (drops junk, tolerates
// bad padding), so reject non-canonical segments up front. §4 reads `kid` from the still-
// bad padding), so reject non-canonical segments up front. reads `kid` from the still-
// unverified header, so this stops laundered bytes reaching key selection.
const base64url = /^[A-Za-z0-9_-]+$/;
@@ -65,7 +65,7 @@ export function decodeJws(token: string): DecodedJws {
}
// Verify a compact JWS against one JWK public key; returns the decoded JWS or throws.
// Signature only — caller validates claims. Returned header is post-verification, so §4
// Signature only — caller validates claims. Returned header is post-verification, so the caller
// can trust its `alg`/`kid` when logging.
export function verifyJws(token: string, jwk: JsonWebKey): DecodedJws {
const decoded = decodeJws(token);
+2 -2
View File
@@ -1,7 +1,7 @@
// Keto client (§4): typed fetch wrappers over Ory Keto's read (check/list/expand) and
// Keto client: typed fetch wrappers over Ory Keto's read (check/list/expand) and
// write (write/delete tuple) APIs. Guards the request contracts (URLs, ports, method,
// query/body shape, subject_id vs subject_set) and the result mapping (allowed bool, the
// next_page_token, 2xx/204/error). Live wiring is verified by login completion + guards (§4).
// next_page_token, 2xx/204/error). Live wiring is verified by login completion + guards.
import { test } from "node:test";
import assert from "node:assert/strict";
import { createKetoClient, KetoError } from "./keto-client.ts";
+2 -2
View File
@@ -1,4 +1,4 @@
// Keto client (todo §4): typed `fetch` wrappers over Ory Keto's relation-tuple APIs —
// Keto client: typed `fetch` wrappers over Ory Keto's relation-tuple APIs —
// `check` a permission, `listRelations`/`expand` to inspect them (read API), `writeTuple`/
// `deleteTuple` to grant/revoke them (write API). Built-in `fetch` only, no SDK dep (AGENTS.md);
// `fetchImpl`-injectable like the kratos clients. Read/write split onto the two ports config.ts
@@ -32,7 +32,7 @@ export interface RelationList {
// Keto's expand tree: a node is a set operation (union/…) or a leaf. The resolved subject
// (subject_id xor subject_set) rides on `tuple`, not the node itself — verified against Keto
// v26.2.0. A `subject_set` node carries its members as `children` (§5 "effective access" view).
// v26.2.0. A `subject_set` node carries its members as `children` ("effective access" view).
export interface ExpandTree {
children?: ExpandTree[];
tuple?: RelationTuple;
+1 -1
View File
@@ -1,4 +1,4 @@
// Guards the Ory Keto config (§3): migrations run before the server (keto-migrate →
// Guards the Ory Keto config: migrations run before the server (keto-migrate →
// keto), the DSN targets the keto database, read/write APIs serve on the ports config.ts
// points at, and the OPL declares the role/group/resource namespaces. Version pinning is
// in compose.test.ts. Real boot is verified by running the stack; this catches edits.
+2 -2
View File
@@ -1,7 +1,7 @@
// Kratos admin-API client (§4): typed fetch wrappers over Ory Kratos' admin endpoints —
// Kratos admin-API client: typed fetch wrappers over Ory Kratos' admin endpoints —
// identity CRUD + the surgical metadata_public update the login flow projects roles into.
// Guards the request contracts (URLs, method, JSON-Patch body, query/pagination) and the
// result mapping (201/200/404/4xx). Live wiring is verified by login completion (§4).
// result mapping (201/200/404/4xx). Live wiring is verified by login completion.
import { test } from "node:test";
import assert from "node:assert/strict";
import { createKratosAdmin } from "./kratos-admin.ts";
+2 -2
View File
@@ -1,4 +1,4 @@
// Kratos admin-API client (todo §4): typed `fetch` wrappers over Ory Kratos' admin endpoints
// Kratos admin-API client: typed `fetch` wrappers over Ory Kratos' admin endpoints
// (internal-only admin port) — identity CRUD + the surgical `metadata_public` update login
// completion projects Keto roles into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
@@ -25,7 +25,7 @@ export interface ListOptions {
pageToken?: string;
}
// A one-time recovery code + the self-service link wrapping it (admin "trigger recovery", §5).
// A one-time recovery code + the self-service link wrapping it (admin "trigger recovery").
export interface RecoveryCode {
code: string;
link: string;
+2 -2
View File
@@ -1,7 +1,7 @@
// Kratos public-API client (§4): typed fetch wrappers over Ory Kratos' public endpoints.
// Kratos public-API client: typed fetch wrappers over Ory Kratos' public endpoints.
// Guards the request contracts (URLs, JSON-accept, cookie relay) and the result mapping
// (200/401/4xx, validation-flow vs success, tokenized JWT). Live wiring is verified by the
// flow pages (§4); these catch contract drift with a mock fetch.
// flow pages; these catch contract drift with a mock fetch.
import { test } from "node:test";
import assert from "node:assert/strict";
import { createKratosPublic, KratosError } from "./kratos-public.ts";
+1 -1
View File
@@ -1,4 +1,4 @@
// Kratos public-API client (todo §4): typed `fetch` wrappers over Ory Kratos' public
// Kratos public-API client: typed `fetch` wrappers over Ory Kratos' public
// endpoints — self-service flow init/get/submit, browser logout, session `whoami`, and the
// session→JWT tokenizer (`whoami?tokenize_as`). Built-in `fetch` only, no SDK dep (AGENTS.md).
// Flow `ui.nodes` types stay loose — rendering + field-error mapping is flow-view.ts's job.
+5 -5
View File
@@ -1,4 +1,4 @@
// Guards the Ory Kratos config (§3): migrations run before the server (kratos-migrate →
// Guards the Ory Kratos config: migrations run before the server (kratos-migrate →
// kratos), the DSN targets the kratos database, and the identity schema carries email
// (password identifier) + name traits. Version pinning is in compose.test.ts. Real boot
// is verified by running the stack; this catches edits.
@@ -37,7 +37,7 @@ test("kratos config wires the identity schema", () => {
assert.match(kratosYml, /identity\.schema\.json/);
});
// The five self-service flows return the browser to our own themed routes (§4 renders them). The
// The five self-service flows return the browser to our own themed routes (renders them). The
// host is `localhost` — the dev/clean-clone host the stack sets APP_URL to, so the web app's canonical
// host matches the host the login form POSTs to (cookies share one host). Compose overrides these
// from ${APP_URL} for a custom host.
@@ -52,7 +52,7 @@ test("self-service flows return to our themed pages (on the localhost dev host)"
test("after a successful login Kratos returns to our /auth/complete route to mint the JWT", () => {
assert.match(kratosYml, /default_browser_return_url:\s*http:\/\/localhost:3000\/auth\/complete/,
"login completion (read roles → project → tokenize → set cookie) runs at /auth/complete (§4)");
"login completion (read roles → project → tokenize → set cookie) runs at /auth/complete");
});
test("recovery + verification run on email code, delivered by a courier", () => {
@@ -70,7 +70,7 @@ test("session settings: branded cookie, bounded lifespan, sliding refresh", () =
test("session tokenizer template 'plainpages' mints a short-lived signed JWT", () => {
// whoami(tokenize_as: plainpages) → a locally-verifiable JWT, so the hot path never
// calls Ory (§4). Signed with the committed tokenizer/jwks.json (gen-jwks.ts).
// calls Ory. Signed with the committed tokenizer/jwks.json (gen-jwks.ts).
assert.match(kratosYml, /tokenizer:\s*\n\s*templates:\s*\n\s*plainpages:/, "plainpages template defined");
assert.match(kratosYml, /ttl:\s*10m/, "~10m TTL — re-minted on refresh");
assert.match(kratosYml, /subject_source:\s*id/, "sub = the Kratos identity id");
@@ -84,7 +84,7 @@ test("the tokenizer claims mapper emits email + roles from the metadata_public p
// metadata (admin metadata is stripped), so the roles projection must live in metadata_public.
const mapper = read("ory/kratos/tokenizer/plainpages.jsonnet");
assert.match(mapper, /email:\s*session\.identity\.traits\.email/, "email ← identity trait");
assert.match(mapper, /metadata_public/, "roles ← metadata_public (the per-login Keto projection, §4)");
assert.match(mapper, /metadata_public/, "roles ← metadata_public (the per-login Keto projection)");
});
test("social sign-in is off by default — a clean clone stays password-only", () => {
+1 -1
View File
@@ -1,4 +1,4 @@
// parseListQuery (todo §1): read a list-page URL into the state the building blocks render
// parseListQuery: read a list-page URL into the state the building blocks render
// from — search, filters, sort, pagination. The URL is the only list state (README
// "Interactivity"), so this is the inverse of the filter-bar GET form, the sort links and the
// pagination links: bookmarkable, shareable, reproducible. Pure; never throws.
+1 -1
View File
@@ -1,4 +1,4 @@
// Structured logging + basic observability (todo §9), on @larvit/log (zero-dependency, OTLP-native).
// Structured logging + basic observability, on @larvit/log (zero-dependency, OTLP-native).
// One app-level Log holds the config (level/format/OTLP) and tags every line with service.name;
// each request clones it into a short-lived trace span. Console always; OTLP only when configured.
// An AsyncLocalStorage makes that per-request Log ambiently available, so every outbound `fetch`
+2 -2
View File
@@ -1,6 +1,6 @@
// Login completion (§4): turn a Kratos session into our session JWT — read roles from Keto,
// Login completion: turn a Kratos session into our session JWT — read roles from Keto,
// project them onto the identity, tokenize, build the cookie. Fakes the three Ory clients;
// the live, full-stack login is verified by the §8 Playwright E2E.
// the live, full-stack login is verified by the Playwright E2E.
import { test } from "node:test";
import assert from "node:assert/strict";
import type { KetoClient, RelationTuple } from "./keto-client.ts";
+3 -3
View File
@@ -1,4 +1,4 @@
// Login completion (todo §4): turn a fresh Kratos session into our locally-verifiable
// Login completion: turn a fresh Kratos session into our locally-verifiable
// session JWT — the one moment Ory is on the path (README: Login → session JWT):
// 1. whoami(cookie) → the identity (id, email); no active session ⇒ null
// 2. read roles from Keto → the source of truth for the `roles` claim
@@ -18,7 +18,7 @@ import type { KratosPublic } from "./kratos-public.ts";
export const SESSION_COOKIE = "plainpages_jwt";
// Mirrors kratos.yml session.lifespan (30d) so the cookie survives browser restarts; the
// JWT inside is short-lived (~10m) and re-minted on expiry by the §4 hot path (remintSession).
// JWT inside is short-lived (~10m) and re-minted on expiry by the hot path (remintSession).
const COOKIE_MAX_AGE = 60 * 60 * 24 * 30;
// The tokenizer template (kratos.yml session.whoami.tokenizer.templates.plainpages).
@@ -91,7 +91,7 @@ export async function remintSession(deps: LoginDeps, cookie: string | undefined,
}
// Build the Set-Cookie for our session JWT. HttpOnly + SameSite=Lax by default; `secure` is
// supplied by the caller (off in dev http; the §9 cookie hardening toggles it on for prod).
// supplied by the caller (off in dev http; the cookie hardening toggles it on for prod).
export function sessionCookie(jwt: string, options: { secure?: boolean } = {}): string {
const opts: CookieOptions = { httpOnly: true, maxAge: COOKIE_MAX_AGE, path: "/", sameSite: "Lax", ...(options.secure ? { secure: true } : {}) };
return serializeCookie(SESSION_COOKIE, jwt, opts);
+1 -1
View File
@@ -1,4 +1,4 @@
// Central menu config (todo §2): config/menu.ts lets an operator set branding (app name, logo,
// Central menu config: config/menu.ts lets an operator set branding (app name, logo,
// default theme) and reorder/rename/group/hide nav nodes across all plugins. The reorder/rename/
// group/hide part is the NavOverride composeNav already applies (the override always wins, before
// the per-user permission filter). Authored as TypeScript (defineMenu types it); loaded once at
+1 -1
View File
@@ -45,7 +45,7 @@ test("composeNav drops gated subtrees, empty headers, and (with no roles) all ga
assert.deepEqual(composeNav(), []);
});
test("composeNav keeps a node marked public for everyone — the blessed public alias (§10)", () => {
test("composeNav keeps a node marked public for everyone — the blessed public alias", () => {
// A header with one public child + one gated child: with no roles, the public child keeps the
// header alive (the gated child is filtered out) — so a plugin can show a public menu option to all.
const frag: NavNode[][] = [[{
+4 -4
View File
@@ -1,8 +1,8 @@
// composeNav (todo §1): merge each plugin's nav fragment into one tree, apply the central
// composeNav: merge each plugin's nav fragment into one tree, apply the central
// override, then permission-filter per user. Pure and I/O-free — menu gating reads the JWT
// `roles` claim (README "The menu system"), never Keto. A node is visible iff it is `public`, or
// declares no `permission`, or `roles` includes that permission token; a gated header hides its whole
// subtree, and a pure header left with no children is dropped. The §2 config/menu.ts supplies
// subtree, and a pure header left with no children is dropped. The config/menu.ts supplies
// the override (+ branding); this helper only transforms data, so its result is per-deployment
// up to the final role filter and emits clean nodes ready for nav-tree.ejs (no id/permission).
@@ -16,10 +16,10 @@ export interface NavNode {
label: string;
open?: boolean;
permission?: string; // required role token; consumed by the filter, never rendered
public?: boolean; // §10: show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both).
public?: boolean; // show to everyone, signed in or not — the blessed alias for "no permission", stated outright; consumed by the filter, never rendered. Mutually exclusive with permission (discovery refuses both).
}
// Central override (config/menu.ts, §2). Targets nodes by `id`; applied rename → group →
// Central override (config/menu.ts). Targets nodes by `id`; applied rename → group →
// order → hide, then the per-user permission filter runs last.
export interface NavOverride {
groups?: NavGroupSpec[]; // wrap top-level nodes (by id) under a new header
+1 -1
View File
@@ -1,4 +1,4 @@
// OAuth2 consent-challenge resolution (§6): given a Hydra consent challenge, auto-accept a
// OAuth2 consent-challenge resolution: given a Hydra consent challenge, auto-accept a
// first-party (or Hydra-skipped) client granting the requested scopes, else show a consent
// screen; on submit accept (allow) or reject (deny). id_token claims come from the Kratos identity.
import { test } from "node:test";
+1 -1
View File
@@ -1,4 +1,4 @@
// OAuth2 consent-challenge handler (todo §6): after login, Hydra hands the browser to
// OAuth2 consent-challenge handler: after login, Hydra hands the browser to
// /oauth2/consent?consent_challenge=… (hydra.yml urls.consent). A first-party client (or one
// Hydra already skipped) is auto-granted the requested scopes; a third-party client shows the
// themed consent screen, then accept (allow) / reject (deny). id_token claims (email/name) come
+1 -1
View File
@@ -1,4 +1,4 @@
// OAuth2 login-challenge resolution (§6): given a Hydra login challenge, authenticate the user
// OAuth2 login-challenge resolution: given a Hydra login challenge, authenticate the user
// via their Kratos session and accept — or bounce an unauthenticated user to the Kratos login UI.
import { test } from "node:test";
import assert from "node:assert/strict";
+1 -1
View File
@@ -1,4 +1,4 @@
// OAuth2 login-challenge handler (todo §6): when another app logs in *through* plainpages,
// OAuth2 login-challenge handler: when another app logs in *through* plainpages,
// Hydra hands the browser to /oauth2/login?login_challenge=… (hydra.yml urls.login). We
// authenticate the user with their existing Kratos session and accept the request; Hydra then
// proceeds to consent and mints the tokens. No first-party page needs this — it's the OAuth2
+1 -1
View File
@@ -1,4 +1,4 @@
// paginate (todo §1): pagination math → the model pagination.ejs renders. Pure and
// paginate: pagination math → the model pagination.ejs renders. Pure and
// URL-free (README signature `paginate(total, page, pageSize)`); the caller maps each
// page number to an href. Inputs are clamped/guarded so it never produces a broken model:
// page is pinned to [1, pageCount], total/pageSize coerced to sane integers.
+1 -1
View File
@@ -1,4 +1,4 @@
// The plugin author barrel (§7): the stable surface a plugin imports. Guards that the value exports
// The plugin author barrel: the stable surface a plugin imports. Guards that the value exports
// stay present — removing one is a breaking contract change. The types resolve via typecheck (the
// reference plugin imports them from here).
import assert from "node:assert/strict";
+2 -2
View File
@@ -1,4 +1,4 @@
// The plugin author surface (todo §7) — the ONE module a plugin imports. It re-exports exactly the
// The plugin author surface — the ONE module a plugin imports. It re-exports exactly the
// stable contract: definePlugin + the manifest/handler types, the RequestContext, the auth guards,
// and the request-body/CSRF/list-query helpers the blessed pattern needs. This barrel *is* the
// contract boundary in code — the host may refactor any other src/* freely as long as it holds, so
@@ -16,7 +16,7 @@ export { CSRF_FIELD } from "./csrf.ts";
// Sanitise an untrusted URL (upstream/user data) before rendering it in an href/src — partials
// escape text but not URL schemes, so a `javascript:`/`data:` URL would be live XSS (see docs).
export { safeUrl } from "./safe-url.ts";
// Observability (§9): `ctx.log` (RequestContext) is the request logger; `tracedFetch` is a drop-in
// Observability: `ctx.log` (RequestContext) is the request logger; `tracedFetch` is a drop-in
// `fetch` a plugin uses for upstream calls so they join the request's trace (client span + traceparent).
// The `Log` class is exported so a plugin can type/construct one (e.g. `new Log("none")` in a test).
export { Log, tracedFetch } from "./logger.ts";
+2 -2
View File
@@ -103,7 +103,7 @@ test("findConflicts: duplicate nav id is an error, a shared permission token onl
assert.ok(permDup.some((c) => c.kind === "permission" && c.level === "warn"));
});
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error (§10)", () => {
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
const handler = () => ({ html: "x" });
const homeDup = findConflicts([p({ id: "a", home: handler }), p({ id: "b", home: handler })]);
assert.ok(homeDup.some((c) => c.kind === "home" && c.level === "error" && c.plugins.includes("a") && c.plugins.includes("b")));
@@ -124,7 +124,7 @@ test("RESERVED_PLUGIN_IDS covers every built-in top-level mount; `home` (the / f
"auth", // /auth/complete (login completion)
"logout", // POST /logout
"oauth2", // /oauth2/login · /consent · /logout (Hydra provider)
"dashboard", // the gated app home (§10)
"dashboard", // the gated app home
"public", // static assets
]);
for (const id of builtins) assert.ok(RESERVED_PLUGIN_IDS.has(id), `built-in mount "${id}" must be a reserved plugin id`);
+7 -7
View File
@@ -1,4 +1,4 @@
// The plugin contract (todo §2) — the product's main API surface: the machine-readable types +
// The plugin contract — the product's main API surface: the machine-readable types +
// pure rules; `docs/plugin-contract.md` is the prose reference, discovery/router wire it to FS+HTTP.
// Powerful, predictable, fails loud at boot/discovery rather than sandboxing at runtime.
//
@@ -30,7 +30,7 @@ export interface Route {
method: HttpMethod;
path: string; // relative to the plugin's mount path `/<id>`; ":name" segments → ctx.params.name
permission?: string; // coarse gate (a role token); checked before the handler runs
// Mark the page reachable by anyone, signed in or not (§10). The same as omitting `permission`
// Mark the page reachable by anyone, signed in or not. The same as omitting `permission`
// — a no-permission route is already open — but stated outright, so "public" is a deliberate
// choice, not an accident. Mutually exclusive with `permission` (discovery refuses both).
public?: boolean;
@@ -54,11 +54,11 @@ export interface PluginHooks {
// host derives them from the folder name at discovery (see Plugin).
export interface PluginManifest {
apiVersion: string; // semver of the host contract this targets — write a literal, NOT HOST_API_VERSION (see docs)
// Take over the gated dashboard "/dashboard" — the post-login app home (§10). A handler like any
// Take over the gated dashboard "/dashboard" — the post-login app home. A handler like any
// route's; the host gates it to a signed-in session (anonymous → /login), then renders its own view
// via ctx.chrome. At most one plugin may declare it (findConflicts → error, never last-write-wins).
dashboard?: RouteHandler;
// Take over the public landing "/" — the ungated front page (§10). A handler like any route's,
// Take over the public landing "/" — the ungated front page. A handler like any route's,
// anyone may reach it. At most one plugin may declare it (findConflicts → error).
home?: RouteHandler;
hooks?: PluginHooks;
@@ -74,7 +74,7 @@ export interface Plugin extends PluginManifest {
}
// Identity helper: types the manifest, returns it unchanged. Validation happens at discovery
// (§2), so a plugin may equally be a plain typed object. Mirrors Vite's `defineConfig`.
//, so a plugin may equally be a plain typed object. Mirrors Vite's `defineConfig`.
export function definePlugin(manifest: PluginManifest): PluginManifest {
return manifest;
}
@@ -165,7 +165,7 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
if (n > 1) out.push({ kind: "id", level: "error", message: `${n} plugins share id "${id}"; ids must be globally unique`, plugins: [id] });
}
// The landing pages are single slots (§10): "/" (home) and "/dashboard" (dashboard) take one owner
// The landing pages are single slots: "/" (home) and "/dashboard" (dashboard) take one owner
// each — two plugins claiming either is a loud error, not a race.
for (const slot of ["home", "dashboard"] as const) {
const owners = plugins.filter((plugin) => plugin[slot]).map((plugin) => plugin.id);
@@ -206,7 +206,7 @@ function collectNavIds(nodes: NavNode[] | undefined, push: (id: string) => void)
}
// A route's full path = the plugin's mount path `/<id>` + the route path. The single source of
// truth for both conflict detection (here) and the §2 router, so they can't disagree.
// truth for both conflict detection (here) and the router, so they can't disagree.
export function fullPath(id: string, path: string): string {
return `/${id}${path.startsWith("/") ? path : `/${path}`}`;
}
+1 -1
View File
@@ -1,4 +1,4 @@
// Guards the Ory Postgres config (§3): each Ory service keeps its own database (the
// Guards the Ory Postgres config: each Ory service keeps its own database (the
// image pin is covered by compose.test.ts's global scan). Real container behaviour is
// verified by booting postgres in CI/e2e; this catches edits.
import { test } from "node:test";
+1 -1
View File
@@ -58,7 +58,7 @@ test("allowedMethods lists methods at a path (GET implies HEAD); empty when the
test("isAuthorized: open routes pass; gated routes require the role token; public is explicitly open", () => {
const open: Route = { handler: noop, method: "GET", path: "/" };
const gated: Route = { handler: noop, method: "GET", path: "/", permission: "x:read" };
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // §10 blessed public alias
const pub: Route = { handler: noop, method: "GET", path: "/", public: true }; // blessed public alias
assert.equal(isAuthorized(open, []), true);
assert.equal(isAuthorized(gated, []), false);
assert.equal(isAuthorized(gated, ["x:read"]), true);
+3 -3
View File
@@ -1,4 +1,4 @@
// Router (todo §2): pure core mapping method + pathname → a discovered plugin route. I/O-free;
// Router: pure core mapping method + pathname → a discovered plugin route. I/O-free;
// app.ts is the shell (build context, gate, call handler, render RouteResult). A route mounts at
// `/<id>` + its path (fullPath, shared with conflict detection); `:name` segments → path params.
// Specificity: a literal segment beats a `:param` (/users/new wins /users/:id), order-independent.
@@ -75,8 +75,8 @@ export function allowedMethods(plugins: Plugin[], pathname: string): string[] {
}
// Coarse permission gate: a route marked `public` (or one with no `permission`) is open; otherwise
// the user's roles (from the session JWT, §4) must include the token. The same rule composeNav uses
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both, §10).
// the user's roles (from the session JWT) must include the token. The same rule composeNav uses
// for the menu. `public` and `permission` are mutually exclusive (discovery refuses both).
export function isAuthorized(route: Route, roles: string[]): boolean {
return route.public === true || route.permission == null || roles.includes(route.permission);
}
+1 -1
View File
@@ -1,4 +1,4 @@
// URL safety helpers (todo §9). Two pure, dependency-free guards:
// URL safety helpers. Two pure, dependency-free guards:
//
// safeUrl(value) — sanitise an untrusted URL before rendering it in an href/src attribute.
// Partials escape *text*, but a URL field is emitted verbatim, so a
+1 -1
View File
@@ -1,4 +1,4 @@
// Response security headers (todo §9): set once per request in app.ts so every response — page,
// Response security headers: set once per request in app.ts so every response — page,
// JSON, redirect, static, or error — carries them (writeHead merges with setHeader). A plugin route
// may override any of them per-response via RouteResult.headers (e.g. relax the CSP to ship its own JS).
+5 -5
View File
@@ -13,7 +13,7 @@ import { createLogger, tracedFetch } from "./logger.ts";
import { loadMenuConfig } from "./menu-config.ts";
const config = loadConfig(); // validates the env (incl. enforced secrets) — fails loud at boot
// App-level logger (§9): structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
// App-level logger: structured, OTLP-capable when OTLP_ENDPOINT is set. The hot path clones it
// per request for access logging + a trace span (src/app.ts); console-only otherwise.
const log = createLogger({ format: config.logFormat, level: config.logLevel, otlpEndpoint: config.otlpEndpoint, otlpProtocol: config.otlpProtocol, serviceName: config.serviceName });
const menu = await loadMenuConfig(); // config/menu.ts override + branding — fails loud if malformed
@@ -21,16 +21,16 @@ const menu = await loadMenuConfig(); // config/menu.ts override + branding — f
// the trace + a propagated traceparent — tracedFetch) and bounded by the Ory timeout, so a hung/
// silent Ory can't park a request handler forever. Off the request path it's a plain timed fetch.
const oryFetch = withTimeout(tracedFetch, config.oryTimeoutSec * 1000);
// Ory clients for the themed self-service routes + login completion (§4).
// Ory clients for the themed self-service routes + login completion.
const kratos = createKratosPublic({ baseUrl: config.kratosPublicUrl, fetchImpl: oryFetch });
const kratosAdmin = createKratosAdmin({ baseUrl: config.kratosAdminUrl, fetchImpl: oryFetch });
const keto = createKetoClient({ fetchImpl: oryFetch, readUrl: config.ketoReadUrl, writeUrl: config.ketoWriteUrl });
// Hydra admin client for the OAuth2 login/consent challenge handshake (§6).
// Hydra admin client for the OAuth2 login/consent challenge handshake.
const hydra = createHydraAdmin({ baseUrl: config.hydraAdminUrl, fetchImpl: oryFetch });
// Session-JWT verify key: primed at boot from the configured JWKS (file mount, base64 inline,
// or fetched http), then served from cache with TTL refresh + rotation-on-miss (§4).
// or fetched http), then served from cache with TTL refresh + rotation-on-miss.
const jwks = await createJwksProvider(config.jwksUrl, { fetchImpl: oryFetch }); // bound an http JWKS fetch too
// Optional instant-revoke (§9), off unless REVOCATION_DENYLIST=true: an in-memory denylist the
// Optional instant-revoke, off unless REVOCATION_DENYLIST=true: an in-memory denylist the
// hot path consults and the admin screens populate on deactivate/delete/role-change.
const denylist = config.revocationDenylist ? createDenylist({ ttlSec: config.revocationTtlSec }) : undefined;
+2 -2
View File
@@ -1,6 +1,6 @@
// Shell view-model builder (todo §5): the brand/theme/user/title block every app-shell page
// Shell view-model builder: the brand/theme/user/title block every app-shell page
// (the home dashboard, the built-in admin screens) hands to shell.ejs. Pure. Extracted so the
// shell user is the *real* signed-in identity (§4) — no hardcoded demo profile — and branding is
// shell user is the *real* signed-in identity — no hardcoded demo profile — and branding is
// read from one place. The User carries no display name (the JWT holds only id/email/roles), so
// the profile shows the email's local part as the name with the full email beneath, initials from
// the local part; anonymous ⇒ "Guest".
+5 -5
View File
@@ -43,7 +43,7 @@ test("app shell renders sidebar, topbar and the content slot", async () => {
assert.match(html, /<use href="#i-menu"\s*\/?>/); // hamburger references the menu icon
});
test("app shell offers Sign in (not Sign out) to an anonymous visitor — so a public page in the shell works (§10)", async () => {
test("app shell offers Sign in (not Sign out) to an anonymous visitor — so a public page in the shell works", async () => {
const html = await render({ title: "Overview", brand: { name: "Acme" }, nav: "", body: "x" }); // no user, no signInHref → default
assert.match(html, /href="\/login"[^>]*>[\s\S]*?Sign in/); // a path to sign in (default target)
assert.doesNotMatch(html, /action="\/logout"/); // a guest has no session to end
@@ -52,7 +52,7 @@ test("app shell offers Sign in (not Sign out) to an anonymous visitor — so a p
const withReturn = await render({ title: "Overview", brand: { name: "Acme" }, nav: "", body: "x", signInHref: "/login?return_to=%2Fscheduling" });
assert.match(withReturn, /href="\/login\?return_to=%2Fscheduling"[^>]*>[\s\S]*?Sign in/);
// hideSignIn (the auth pages, §10): no footer Sign-in — a Sign-in on the login page only loops back.
// hideSignIn (the auth pages): no footer Sign-in — a Sign-in on the login page only loops back.
const onAuth = await render({ title: "", docTitle: "Sign in", brand: { name: "Acme" }, nav: "", body: "x", hideSignIn: true });
assert.doesNotMatch(onAuth, />[\s\S]*?Sign in<\/a>/);
});
@@ -77,7 +77,7 @@ test("app shell links extra per-page stylesheets via the styles slot (e.g. a plu
assert.equal((none.match(/rel="stylesheet"/g) ?? []).length, 1);
});
test("app shell can disable the menu (§10): no sidebar, focused single-column layout", async () => {
test("app shell can disable the menu: no sidebar, focused single-column layout", async () => {
const bare = await render({ menu: false, title: "Focus", body: '<section id="b">x</section>', nav: '<a href="/x">Overview</a>' });
assert.doesNotMatch(bare, /<aside class="sidebar"/); // sidebar dropped
assert.doesNotMatch(bare, /class="hamburger"/); // and its mobile toggle
@@ -86,12 +86,12 @@ test("app shell can disable the menu (§10): no sidebar, focused single-column l
assert.match(bare, /<section id="b">x<\/section>/);
});
test("app shell: an empty title yields no topbar <h1> so the body owns the single heading; docTitle sets <title> (§10)", async () => {
test("app shell: an empty title yields no topbar <h1> so the body owns the single heading; docTitle sets <title>", async () => {
// Auth/landing pass title:"" (their card/hero is the <h1>) + an explicit docTitle for the tab.
const html = await render({ title: "", docTitle: "Sign in", brand: { name: "Acme" }, body: "<h1>Sign in</h1>" });
assert.doesNotMatch(html, /<h1 class="page-title"/); // topbar carries no heading
assert.match(html, /<title>Sign in<\/title>/); // explicit document title
assert.match(html, /<aside class="sidebar"/); // menu still shown (the point of §10)
assert.match(html, /<aside class="sidebar"/); // menu still shown (the whole point)
const fallback = await render({ title: "", brand: { name: "Acme" } }); // no docTitle → brand
assert.match(fallback, /<title>Acme<\/title>/);
+1 -1
View File
@@ -54,7 +54,7 @@ function plain(res: ServerResponse, status: number, body: string): void {
}
// onError handles a mid-stream read failure (headers already sent); defaults to console.error so
// static.ts stays standalone, while app.ts passes the request logger for structured output (§9).
// static.ts stays standalone, while app.ts passes the request logger for structured output.
export async function serveStatic(dir: string, requestedPath: string, res: ServerResponse, head = false, onError: (err: Error) => void = (err) => console.error(err)): Promise<void> {
let decoded: string;
try {
+2 -2
View File
@@ -1,8 +1,8 @@
// Per-plugin view resolver (todo §2): render plugins/<id>/views/<view>.ejs and let a plugin view
// Per-plugin view resolver: render plugins/<id>/views/<view>.ejs and let a plugin view
// reuse the core building-block partials. EJS resolves an include() relative to the current file
// first, then against the `views` roots — so passing [plugin views, core views] makes both the
// plugin's own partials/subfolders and every core partial reachable (the plugin root first, so a
// plugin may deliberately shadow a core partial). The §2 router calls this for a `view` RouteResult.
// plugin may deliberately shadow a core partial). The router calls this for a `view` RouteResult.
import { isAbsolute, join, relative } from "node:path";
import * as ejs from "ejs";