Give the paging loop, Link-header parser and FieldConfig one home each, rename buildPluginChrome to buildPageChrome, and delete a group from its parents
CI / full-gate (push) Successful in 3m24s

This commit is contained in:
2026-11-09 01:16:55 +01:00
parent a4740c87ae
commit cdb5a00705
17 changed files with 146 additions and 119 deletions
+1 -7
View File
@@ -3,6 +3,7 @@
// *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);
// Hydra mints the tokens.
import { nextPageToken } from "./link-header.ts";
export interface OAuth2Client {
client_id?: string;
@@ -101,13 +102,6 @@ export interface HydraAdmin {
rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>;
}
// Hydra paginates with a Link header; pull the page_token of rel="next" (the href is relative, so
// resolve against a throwaway base to read the query param). Mirrors kratos-admin's helper.
function nextPageToken(link: string | null): string | null {
const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1];
return href ? new URL(href, "http://hydra").searchParams.get("page_token") : null;
}
export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): HydraAdmin {
const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch;
+1 -7
View File
@@ -3,6 +3,7 @@
// completion projects Keto permissions into (README). Built-in `fetch` only, no SDK dep (AGENTS.md);
// `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
import { KratosError } from "./kratos-public.ts";
import { nextPageToken } from "./link-header.ts";
export interface Identity {
id: string;
@@ -41,13 +42,6 @@ export interface KratosAdmin {
updateMetadataPublic(id: string, metadata: unknown): Promise<Identity>;
}
// Kratos paginates with a Link header; pull the page_token of rel="next" (the href is a
// relative path, so resolve it against a throwaway base just to read the query param).
function nextPageToken(link: string | null): string | null {
const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1];
return href ? new URL(href, "http://kratos").searchParams.get("page_token") : null;
}
export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosAdmin {
const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch;
+14
View File
@@ -0,0 +1,14 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { nextPageToken } from "./link-header.ts";
test("nextPageToken reads the page_token of the rel=next entry among several", () => {
const link = `</admin/identities?page_size=2&page_token=NEXT>; rel="next",</admin/identities?page_size=2>; rel="first"`;
assert.equal(nextPageToken(link), "NEXT");
});
test("nextPageToken is null with no header, no next entry, or a next href without a page_token", () => {
assert.equal(nextPageToken(null), null);
assert.equal(nextPageToken(`</admin/clients?page_size=2>; rel="first"`), null);
assert.equal(nextPageToken(`</admin/clients?page_size=2>; rel="next"`), null);
});
+7
View File
@@ -0,0 +1,7 @@
// Kratos and Hydra both paginate with an RFC 8288 `Link` header. Returns the `page_token` of its
// `rel="next"` entry, or null on the last page. The href is a relative path, so it is resolved
// against a throwaway base just to read the query parameter.
export function nextPageToken(link: string | null): string | null {
const href = link?.match(/<([^>]+)>\s*;\s*rel="next"/)?.[1];
return href ? new URL(href, "http://link-header.invalid").searchParams.get("page_token") : null;
}
+19
View File
@@ -427,6 +427,25 @@ test("admin group delete: a Keto failure leaves the group's grants and members i
assert.equal(tuples.length, 2);
});
test("admin group delete: removes the group from every parent, so re-creating the name restores no nesting", async (t) => {
const ada = randomUUID();
const identities: Identity[] = [{ id: ada, traits: { email: "ada@example.com" } }];
const member = (object: string, subject: Partial<RelationTuple>): RelationTuple => ({ namespace: "Group", object, relation: "members", ...subject } as RelationTuple);
const inEng = { subject_set: { namespace: "Group", object: "eng", relation: "members" } };
const tuples: RelationTuple[] = [
member("eng", { subject_id: `user:${ada}` }),
member("org", { subject_id: `user:${ada}` }),
member("org", inEng),
member("staff", inEng),
member("staff", { subject_id: "user:other" }),
];
const kratosAdmin = stubAdmin({ getIdentity: async (id) => identities.find((i) => i.id === id) ?? null, listIdentities: async () => ({ identities, nextPageToken: null }) });
const { post, token } = await adminHarness(t, { keto: fakeKeto(tuples), kratosAdmin });
assert.equal((await post("/auth-admin/groups/eng/delete", `_csrf=${token}`)).status, 303);
assert.deepEqual(tuples, [member("org", { subject_id: `user:${ada}` }), member("staff", { subject_id: "user:other" })]);
});
// A double-clicked Save posts twice from one page; the second post finds the first one's result.
// A save that changes nothing has nothing to refuse and nothing to revoke.
test("admin permission grants: a save that changes nothing is not refused for a stale page and leaves live tokens alone", async (t) => {
+2 -2
View File
@@ -4,7 +4,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import ejs from "ejs";
import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type PluginOwned, type RequestCsrf } from "./builtin-route.ts";
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { buildPageChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type RequestContext, type User } from "./context.ts";
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
import type { Denylist } from "../auth/denylist.ts";
@@ -274,7 +274,7 @@ async function prepareRequest(host: Host, req: IncomingMessage, res: ServerRespo
// Chrome composes the whole menu, so it is memoized and resolved lazily — a json/redirect
// handler, or the public "/" with a standalone home, never pays for it.
let chromeMemo: PageChrome | undefined;
const chrome = (): PageChrome => (chromeMemo ??= buildPluginChrome({
const chrome = (): PageChrome => (chromeMemo ??= buildPageChrome({
csrfToken: csrf.token, currentPath: url.pathname, localeHref: carryLocale, menu: host.menu, plugins: host.plugins, t,
translatorFor: (id) => i18n.translator(locale, id), user,
}));
+11 -11
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import test from "node:test";
import { buildPluginChrome, shellUser } from "./chrome.ts";
import { buildPageChrome, shellUser } from "./chrome.ts";
import { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
@@ -31,7 +31,7 @@ const adminLike: Plugin = {
const labels = (nodes: NavNode[]): string[] => nodes.map((n) => n.label);
test("anonymous: brand from menu, Guest user; the gated Dashboard link is hidden, a public node still shows", () => {
const chrome = buildPluginChrome({ menu: DEFAULT_MENU, plugins: [scheduling, portal] });
const chrome = buildPageChrome({ menu: DEFAULT_MENU, plugins: [scheduling, portal] });
assert.equal(chrome.brand.name, DEFAULT_MENU.branding.name);
assert.equal(chrome.user.name, "Guest");
// Dashboard points at the gated /dashboard — showing it to an anonymous visitor only dead-ends them
@@ -41,12 +41,12 @@ test("anonymous: brand from menu, Guest user; the gated Dashboard link is hidden
});
test("anonymous shell Sign-in link carries the current page as return_to", () => {
assert.equal(buildPluginChrome({ menu: DEFAULT_MENU }).signInHref, "/login"); // no path known
assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal");
assert.equal(buildPageChrome({ menu: DEFAULT_MENU }).signInHref, "/login"); // no path known
assert.equal(buildPageChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal");
});
test("a permission holder sees the Dashboard link + plugin nav; current path opens the active leaf", () => {
const chrome = buildPluginChrome({
const chrome = buildPageChrome({
currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
user: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] },
});
@@ -58,7 +58,7 @@ test("a permission holder sees the Dashboard link + plugin nav; current path ope
});
test("a gated section (like the admin plugin) shows to a holder; a sub-path marks its base leaf current", () => {
const chrome = buildPluginChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", permissions: ["users:read"] } });
const chrome = buildPageChrome({ currentPath: "/admin/users/new", menu: DEFAULT_MENU, plugins: [adminLike], user: { email: "a@b.c", id: "u1", permissions: ["users:read"] } });
const admin = chrome.nav.find((n) => n.label === "Admin")!;
assert.ok(admin); // gated section visible to an admin
assert.equal(admin.open, true); // ancestor of the current leaf opened
@@ -69,7 +69,7 @@ test("a gated section (like the admin plugin) shows to a holder; a sub-path mark
test("branding logo + default theme flow through when set", () => {
const menu: MenuConfig = { branding: { logo: "/logo.svg", name: "Acme", theme: "dark" }, override: {} };
const chrome = buildPluginChrome({ menu });
const chrome = buildPageChrome({ menu });
assert.equal(chrome.brand.logo, "/logo.svg");
assert.equal(chrome.brand.name, "Acme");
assert.equal(chrome.theme, "dark");
@@ -77,10 +77,10 @@ test("branding logo + default theme flow through when set", () => {
test("branding name and sub are translated; an unknown key renders as itself", () => {
const t = (key: string): string => (key === "brand.sub" ? "Undertitel" : key);
const chrome = buildPluginChrome({ menu: { branding: { name: "Acme", sub: "brand.sub" }, override: {} }, t });
const chrome = buildPageChrome({ menu: { branding: { name: "Acme", sub: "brand.sub" }, override: {} }, t });
assert.equal(chrome.brand.name, "Acme");
assert.equal(chrome.brand.sub, "Undertitel");
assert.equal(buildPluginChrome({ menu: { branding: { name: "Acme" }, override: {} } }).brand.sub, undefined);
assert.equal(buildPageChrome({ menu: { branding: { name: "Acme" }, override: {} } }).brand.sub, undefined);
});
test("shellUser derives the profile from the real user; anonymous → Guest", () => {
@@ -97,7 +97,7 @@ const account: Plugin = {
],
};
const entries = (opts: { permissions?: string[]; signedIn?: boolean }): { href: string; icon?: string; label: string }[] =>
buildPluginChrome({
buildPageChrome({
localeHref: (href) => `${href}?locale=sv-SE`,
menu: DEFAULT_MENU,
plugins: [account],
@@ -115,7 +115,7 @@ test("profile menu entries are gated like nav nodes, translated by their plugin
});
test("the central override hides and renames a profile-menu entry by its id; the host translates the new label", () => {
const links = buildPluginChrome({
const links = buildPageChrome({
menu: { ...DEFAULT_MENU, override: { hide: ["help"], rename: { settings: "Preferences" } } },
plugins: [{
apiVersion: "1.0.0", id: "account",
+23 -21
View File
@@ -52,9 +52,26 @@ export interface ChromeOptions {
user?: User | null;
}
export function buildPluginChrome(opts: ChromeOptions): PageChrome {
// The chrome of every page the host renders, its own screens and a plugin's alike.
export function buildPageChrome(opts: ChromeOptions): PageChrome {
const t = opts.t ?? ENGLISH;
const carryLocale = opts.localeHref ?? ((href: string) => href);
// The sign-in link keeps the visitor's locale, and brings it back afterwards via return_to.
const returnTo = opts.currentPath ? `/login?return_to=${encodeURIComponent(carryLocale(opts.currentPath))}` : "/login";
const { theme, ...brand } = branding(opts.menu, t);
return {
brand,
csrfToken: opts.csrfToken ?? "",
nav: carryLocaleInto(currentMarkedNav(opts, t), carryLocale),
signInHref: carryLocale(returnTo),
...(theme != null ? { theme } : {}),
user: { ...shellUser(opts.user, t), profileLinks: profileLinks(opts, t, carryLocale) },
};
}
// Dashboard plus every plugin's fragment, composed and filtered for the user, with the current leaf
// marked. Marked before the locale rides along, so an href still matches the plain request path.
function currentMarkedNav(opts: ChromeOptions, t: Translate): NavNode[] {
const fragments: NavNode[][] = [[DASHBOARD_NAV]];
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's
// translator before merging. composeNav then runs the core one over the result; already-translated
@@ -62,27 +79,12 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
for (const p of opts.plugins ?? []) {
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t));
}
const nav = composeNav(fragments, opts.menu.override, opts.user ?? null, t);
if (opts.currentPath) {
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
// /auth-admin/users/new marks the Users base leaf (/auth-admin/users) and the dashboard marks Dashboard.
// Marked before the locale rides along, so an href still matches the plain request path.
const target = bestHref(nav, opts.currentPath);
if (target) markCurrent(nav, target);
}
// The sign-in link keeps the visitor's locale, and brings it back afterwards via return_to.
const returnTo = opts.currentPath ? `/login?return_to=${encodeURIComponent(carryLocale(opts.currentPath))}` : "/login";
const { theme, ...brand } = branding(opts.menu, t);
return {
brand,
csrfToken: opts.csrfToken ?? "",
nav: carryLocaleInto(nav, carryLocale),
signInHref: carryLocale(returnTo),
...(theme != null ? { theme } : {}),
user: { ...shellUser(opts.user, t), profileLinks: profileLinks(opts, t, carryLocale) },
};
// The *best* (longest) href that is the path or a parent of it, so a sub-path like
// /auth-admin/users/new marks the Users base leaf (/auth-admin/users) and the dashboard marks Dashboard.
const target = opts.currentPath ? bestHref(nav, opts.currentPath) : null;
if (target) markCurrent(nav, target);
return nav;
}
function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] {