Give the paging loop, Link-header parser and FieldConfig one home each, and delete a group from its parents #235

Merged
lilleman merged 6 commits from item-70 into main 2026-11-09 01:42:19 +01:00
19 changed files with 295 additions and 125 deletions
+7
View File
@@ -30,6 +30,13 @@ every grant as it was and answers with the error page.
A Keto failure while deleting a group now leaves the group as it was, with its grants and members, A Keto failure while deleting a group now leaves the group as it was, with its grants and members,
and answers with the error page. It used to leave some grants gone and the members still listed. and answers with the error page. It used to leave some grants gone and the members still listed.
### The admin example removes a deleted group from the groups it was nested in
Deleting a group now also removes it from every group it was a member of, in the same Keto
transaction as the rest. It used to stay listed there, so a group re-created with the same name was
back in every parent. A parent whose only member was the deleted group is left with no members, and
so no longer exists as a group.
### The admin example keeps a user's permissions section when it refuses a self-deactivate or self-delete ### The admin example keeps a user's permissions section when it refuses a self-deactivate or self-delete
The refusal page used to drop the permissions section of the user form. The refusal page used to drop the permissions section of the user form.
+2 -2
View File
@@ -1024,7 +1024,7 @@ whenever a child does. The rendered tree is **filtered per user** from the sessi
per-request authz call), so the menu only shows what that person can reach. per-request authz call), so the menu only shows what that person can reach.
Branding (name, logo, default theme) renders in the app shell. Branding (name, logo, default theme) renders in the app shell.
**One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPluginChrome`) renders **One menu, one shell, everywhere.** A single menu (`src/ui/chrome.ts` `buildPageChrome`) renders
in the same app shell on **every** page — dashboard, plugin pages, and the login / registration / in the same app shell on **every** page — dashboard, plugin pages, and the login / registration /
recovery / front pages — so it looks identical signed in or out and just shows fewer items to an recovery / front pages — so it looks identical signed in or out and just shows fewer items to an
anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a
@@ -1777,7 +1777,7 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
auth/ Identity + the session-JWT hot path: jwt · jwt-middleware · jwks · gen-jwks (CLI) · auth/ Identity + the session-JWT hot path: jwt · jwt-middleware · jwks · gen-jwks (CLI) ·
login · guards · csrf · denylist · flow-view · oauth-login · oauth-consent · login · guards · csrf · denylist · flow-view · oauth-login · oauth-consent ·
routes · bootstrap, and the Ory REST clients (kratos-public/admin, keto, hydra, routes · bootstrap, and the Ory REST clients (kratos-public/admin, keto, hydra,
fetch-timeout) link-header, fetch-timeout)
i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime · i18n/ catalog (parity rules) · locale (resolution) · translate · load · runtime ·
english · view-locals · locales/ (the core en-US + sv-SE catalogs) english · view-locals · locales/ (the core en-US + sv-SE catalogs)
plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts plugin-host/ plugin.ts (the contract) · plugin-api.ts (the `@plainpages/plugin-api` barrel) · system.ts
+93
View File
@@ -507,3 +507,96 @@ Inherited architect, would restructure first:
- Locality: `src/auth/bootstrap.ts:170` `provisionPluginStorage`: plugin storage lives in three places, across `storage.ts`, `storage-provisioning.ts` and `bootstrap.ts` - Locality: `src/auth/bootstrap.ts:170` `provisionPluginStorage`: plugin storage lives in three places, across `storage.ts`, `storage-provisioning.ts` and `bootstrap.ts`
- Locality, Shape: `src/http/app.ts:183` `handleRequest`: every new manifest field or slot adds a stage here and a field to `Host` - Locality, Shape: `src/http/app.ts:183` `handleRequest`: every new manifest field or slot adds a stage here and a field to `Host`
- Self-sufficiency: `src/plugin-host/system.ts:1` header: calls the built-in admin screens the reference consumer - Self-sufficiency: `src/plugin-host/system.ts:1` header: calls the built-in admin screens the reference consumer
## 2026-11-09T00:38:11Z, item-70 at 5a5ce54
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 6 | 7 | 6.5 | 6.5 |
| Mid A | 7.5 | 6.5 | 7 | 6.5 | 7 |
| Maintainability senior | 7 | 6 | 6.5 | 6.5 | 6.5 |
| Inherited architect | 7 | 7 | 6 | 7.5 | 6.5 |
| Mean | 7.13 | 6.38 | 6.63 | 6.75 | 6.63 |
Junior A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:67` `createApp`: a 130-line closure whose 19-field `Host` bag is read by every stage, and `switchBase` reads `builtinRoutes` declared 44 lines later
- Locality: `src/http/app.ts:263` `prepareRequest`: a lazily memoized chrome, `shared` and `contextFor` in one function, with two context paths whose reason is implicit
- Self-sufficiency: `src/plugin-host/settings.ts:35` `ValueOfDecl`: nested conditional types that only the README's settings example explains
- Locality: `examples/plugins/auth-admin/admin-groups.ts:270` `lostToSelf`: the self-lockout guard has three entry points that each decide "is the actor in this group" differently
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos status meanings that the code and README do not explain
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
Mid A, hardest first:
- Locality, Overall: `examples/plugins/auth-admin/admin-groups.ts:273` `lostToSelf`: one guard spread over four functions and three call sites, and it over-refuses on purpose
- Locality: `src/i18n/load.ts:43` `loadI18n`: four catalog sources and their precedence, explained only in the README
- Locality, Navigation: `src/http/app.ts:118` `switchBase`: where the language picker points after a POST needs `app.ts` and two i18n files
- Locality: `src/ui/chrome.ts:86` `profileLinks`: re-implements `hide` and `rename` outside `composeNav`, so the override rules have two homes
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
- Self-sufficiency: `src/server.ts:23` trust-boundary comment: cites `docs/decisions.md` for its reason
Maintainability senior, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:67` `createApp`: 133 lines and complexity 26 (grandfathered), and `switchBase` reads a table declared later
- Locality: `src/http/app.ts:349` `answerBuiltinRoute`: the CSRF cookie and the response-hook context differ from `answerPluginRoute` without a comment saying so
- Locality: `examples/plugins/auth-admin/admin-groups.ts:273` `lostToSelf`: the guard assumes Keto's transitive model, and `removesSelf` checks one level of nesting
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: each branch rests on Ory behaviour that only a comment states
- Locality: `src/ui/chrome.ts:86` `profileLinks`: the menu override has two homes
- Locality: `src/i18n/runtime.ts:39` `chain`: a plugin that shadows a core key in English gives a Swedish visitor the English text
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
Inherited architect, would restructure first:
- Shape, Navigation, Overall: `public/css/auth.css:84` `.field`: the generic form building-block rules live in a file named for one feature
- Shape, Navigation: `src/auth/bootstrap.ts:1` `src/auth`: 21 modules across Ory clients, session crypto and flow handlers in one flat directory
- Shape: `src/http/builtin-route.ts:23` `BuiltinRoute`: a second route contract beside the plugin `Route`
- Locality: `src/http/app.ts:202` `Host`: 19 fields, and each new capability adds one here, in `prepareRequest` and in `context.ts`
- Locality: `views/partials/shell.ejs:61` profile popover: hand-copied from `menu.ejs` and kept in step by a comment
- Self-sufficiency: `views/dashboard.ejs:15` card: hard-codes `apiVersion: "0.5.0"`, which nothing ties to `HOST_API_VERSION`
## 2026-11-09T00:38:15Z, item-70 rerun at 5a5ce54
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Junior A | 7 | 6 | 6.5 | 7 | 6.5 |
| Mid A | 7 | 6 | 7 | 6.5 | 6.5 |
| Maintainability senior | 7.5 | 6.5 | 7 | 7 | 7 |
| Inherited architect | 7 | 6.5 | 6 | 7.5 | 6.5 |
| Mean | 7.13 | 6.25 | 6.63 | 7.00 | 6.63 |
Junior A, hardest first:
- Locality, Shape, Overall: `src/http/app.ts:67` `createApp`: about 130 lines of closure setup with a forward reference from `switchBase` to `builtinRoutes`, and a 19-field `Host` bag
- Locality: `src/http/app.ts:263` `prepareRequest`: reserved locals exist only through the spread order of an object literal
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos status branches that cannot be checked from the code
- Shape: `src/plugin-host/settings.ts:43` `SettingsOf`: conditional types that took several rereads
- Shape: `src/i18n/load.ts:43` `loadI18n`: two baselines and two catalog sources, with a dense tuple line at `:78`
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
- Shape: `src/plugin-host/discovery.ts:26` `discoverPlugins`: a long loop of `continue` exits
Mid A, hardest first:
- Locality, Overall: `src/ui/chrome.ts:72` `currentMarkedNav`: labels are translated twice, marking must precede the locale, and `markCurrent` mutates nodes
- Locality, Overall: `src/http/app.ts:118` `switchBase`: a forward reference to `builtinRoutes` that only works because the closure runs later
- Locality: `src/plugin-host/plugin.ts:129` `RESERVED_PLUGIN_IDS`: kept in step with `buildAuthRoutes` by hand, and nothing says so
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:446` `groupsRemoveMember`: the Keto subject-set model is never explained in this file
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos statuses that the comments name but the code cannot confirm
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
Maintainability senior, hardest first:
- Locality, Overall: `src/http/app.ts:263` `prepareRequest`: the lazy `ctx.chrome` is memoized here and in `buildContext`, and only a comment guards it
- Locality: `src/ui/chrome.ts:160` `markCurrent`: mutates nodes, safe only because `composeNav` returns fresh objects
- Locality: `examples/plugins/auth-admin/admin-groups.ts:273` `lostToSelf`: over-approximates deliberately, spread over four helpers
- Self-sufficiency: `src/auth/routes.ts:58` `flowFailure`: Kratos status meanings that no readable document states
- Shape: `views/partials/filter-bar.ejs:33` include loops: pass `align: null, up: false, width: null` to stop EJS locals leaking, which reads as noise
- Self-sufficiency: `examples/plugins/auth-admin/admin-groups.ts:434` `groupsDelete`: its comment ends with a pointer to `todo.md`
Inherited architect, would restructure first:
- Shape, Navigation, Overall: `src/auth/bootstrap.ts:1` `src/auth`: 21 files over five concerns, with the lying names `jwt-middleware.ts` and `bootstrap.ts`
- Locality, Shape: `src/http/builtin-route.ts:23` `BuiltinRoute`: a second route shape, so `switchBase` at `src/http/app.ts:118` consults two tables
- Locality: `src/http/app.ts:202` `Host`: 19 fields, and a new request capability touches four files
- Shape, Self-sufficiency: `examples/plugins/auth-admin/admin-shared.ts:107` `FieldConfig`: the type for a core partial's option shape lives in an example plugin
- Locality: `src/auth/login.ts:48` `readPermissions`: a second copy of the Keto paging loop that nothing says the barrel boundary forces
+17
View File
@@ -564,3 +564,20 @@ Read most (opens and searches):
- `src/auth/routes.ts` 1 - `src/auth/routes.ts` 1
- `src/http/builtin-route.test.ts` 1 - `src/http/builtin-route.test.ts` 1
- `src/http/builtin-route.ts` 1 - `src/http/builtin-route.ts` 1
## Reading cost, chunk 1241ed10b1c1, finished 2026-11-09T00:14:18Z
69646 new input tokens before the first commit.
Read most (opens and searches):
- `todo.md` 7
- `src/auth/bootstrap.test.ts` 4
- `src/auth/bootstrap.ts` 4
- `src/auth/keto-client.ts` 4
- `src/config.ts` 3
- `src/auth/kratos-admin.ts` 2
- `src/config.test.ts` 2
- `src/function-limits.test.ts` 2
- `AGENTS.md` 1
- `CHANGELOG.md` 1
+1 -2
View File
@@ -6,8 +6,7 @@
// per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded. // per-route handlers (keyed on ctx.params) over a shared `withClients` gate — admin-only, CSRF-guarded.
import { buildPaginationModel, can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api"; import { buildPaginationModel, can, type HydraAdmin, HydraError, type OAuth2Client, paginate, parseListQuery, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_CLIENTS_BASE, ADMIN_EN, type AdminAction, buildConfirmModel, type FieldConfig, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100]; const PAGE_SIZES = [25, 50, 100];
+3 -8
View File
@@ -8,6 +8,7 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api"; import type { KetoClient, PermissionDecl, RelationTuple, SubjectSet, Translate } from "@plainpages/plugin-api";
import { pagedTuples } from "./admin-shared.ts";
const PERMISSION_NS = "Permission"; const PERMISSION_NS = "Permission";
const GRANTED = "granted"; const GRANTED = "granted";
@@ -36,14 +37,8 @@ export function grantTuple(permission: string, subject: GrantSubject): RelationT
// The permissions this subject holds *directly* — one Keto read filtered by the subject, not one per // The permissions this subject holds *directly* — one Keto read filtered by the subject, not one per
// declared name. This is the edge the picker edits; `effectivePermissions` adds what a group confers. // declared name. This is the edge the picker edits; `effectivePermissions` adds what a group confers.
export async function heldPermissions(keto: KetoClient, subject: GrantSubject): Promise<string[]> { export async function heldPermissions(keto: KetoClient, subject: GrantSubject): Promise<string[]> {
const held = new Set<string>(); const tuples = await pagedTuples(keto, { namespace: PERMISSION_NS, relation: GRANTED, ...subject });
let pageToken: string | undefined; return [...new Set(tuples.map((tuple) => tuple.object))].sort();
do {
const page = await keto.listRelations({ namespace: PERMISSION_NS, relation: GRANTED, ...subject, ...(pageToken ? { pageToken } : {}) });
for (const tuple of page.tuples) held.add(tuple.object);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return [...held].sort();
} }
// Every declared permission the subject effectively holds — direct grants *plus* anything reached // Every declared permission the subject effectively holds — direct grants *plus* anything reached
+13 -21
View File
@@ -2,14 +2,13 @@
// A group is a Keto subject set `Group:<name>#members`; a member is a user or a nested group (see // 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 // 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 // exists exactly while it has ≥1 member, so create writes its first-member tuple and delete removes
// every member tuple. Pure builders turn tuples + the request URL into view models; below them are thin // every tuple that makes it a group or puts it in another. Pure builders turn tuples + the request URL into view models; below them are thin
// per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded, // per-route handlers (keyed on ctx.params) over a shared `withGroups` gate — admin-only, CSRF-guarded,
// each returning a RouteResult. // each returning a RouteResult.
import { buildPaginationModel, can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api"; import { buildPaginationModel, can, type KetoClient, type KratosAdmin, paginate, parseListQuery, type RelationTuple, type RequestContext, type RouteHandler, type RouteResult, type SubjectSet, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, SAVED_NOTICE, userSubject } from "./admin-grants.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, grantTuple, groupSubject, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_EN, type AdminAction, ADMIN_GROUPS_BASE, buildConfirmModel, type FieldConfig, guardedForm, notFound, pagedTuples, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
import type { FieldConfig } from "./admin-users.ts";
const GROUP_NS = "Group"; const GROUP_NS = "Group";
const MEMBERS = "members"; const MEMBERS = "members";
@@ -243,18 +242,6 @@ export function buildGroupDetailModel(opts: {
// ---- request handler (imperative shell) ---- // ---- request handler (imperative shell) ----
// Drain every page of a relation-tuple query.
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
const out: RelationTuple[] = [];
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ ...query, ...(pageToken ? { pageToken } : {}) });
out.push(...page.tuples);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return out;
}
// Build the member-picker options (every user by email + every existing group) and the id→email map // Build the member-picker options (every user by email + every existing group) and the id→email map
// detail rows render with. One Kratos page + one Keto scan; ample for an admin tool. // detail rows render with. One Kratos page + one Keto scan; ample for an admin tool.
export async function memberCandidates(keto: KetoClient, kratosAdmin: KratosAdmin): Promise<{ emailById: Map<string, string>; options: MemberOption[] }> { export async function memberCandidates(keto: KetoClient, kratosAdmin: KratosAdmin): Promise<{ emailById: Map<string, string>; options: MemberOption[] }> {
@@ -432,20 +419,25 @@ export const groupsDeleteConfirm = withGroupName(async (deps, name) => {
}) }, view: "confirm" }; }) }, view: "confirm" };
}, "write"); }, "write");
// POST /auth-admin/groups/:name/delete — one Keto transaction removes the group's member tuples // POST /auth-admin/groups/:name/delete — one Keto transaction removes the group's member tuples,
// and the grants it holds, so a Keto failure leaves the group as it was. // the tuples naming it as a member of a parent group, and the grants it holds, so a Keto failure
// leaves the group as it was.
export const groupsDelete = withGroupName(async (deps, name) => { export const groupsDelete = withGroupName(async (deps, name) => {
const { ctx, keto, user } = deps; const { ctx, keto, user } = deps;
await guardedForm(ctx); // CSRF-verify the POST await guardedForm(ctx); // CSRF-verify the POST
const lost = await lostOnDelete(deps, name); const lost = await lostOnDelete(deps, name);
if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost); if (lost.length > 0) return refuseSelfLockout(deps, name, "delete", lost);
// A Keto set exists only through its tuples, so leaving the grants behind would resurrect every // A Keto set exists only through its tuples, so leaving the grants or the parents' membership
// permission the moment someone re-created a group with the same name. // tuples behind would resurrect every permission and parent membership the moment someone
// re-created a group with the same name. A parent that had only this group as a member is left
// with none and so ceases to exist, as when its last member is removed by hand; its own grants
// and parent memberships stay (todo.md).
const subject = groupSubject(name); const subject = groupSubject(name);
const held = await heldPermissions(keto, subject); const held = await heldPermissions(keto, subject);
const grants = held.map((permission) => grantTuple(permission, subject)); const grants = held.map((permission) => grantTuple(permission, subject));
const members = await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS }); const members = await pagedTuples(keto, { namespace: GROUP_NS, object: name, relation: MEMBERS });
await keto.transactTuples({ delete: [...grants, ...members], insert: [] }); const inParents = await pagedTuples(keto, { namespace: GROUP_NS, relation: MEMBERS, ...subject });
await keto.transactTuples({ delete: [...grants, ...members, ...inParents], insert: [] });
ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") }); ctx.log.info("admin: group deleted", { actor: user.id, group: name, revoked: held.join(",") });
return { redirect: ADMIN_GROUPS_BASE }; return { redirect: ADMIN_GROUPS_BASE };
}); });
@@ -6,8 +6,8 @@ import assert from "node:assert/strict";
import type { IncomingMessage, ServerResponse } from "node:http"; import type { IncomingMessage, ServerResponse } from "node:http";
import { Readable } from "node:stream"; import { Readable } from "node:stream";
import { test } from "node:test"; import { test } from "node:test";
import { GuardError, isValidPermissionName, type Log, type PageChrome, type RequestContext, type User } from "@plainpages/plugin-api"; import { GuardError, isValidPermissionName, type KetoClient, type Log, type PageChrome, type RequestContext, type User } from "@plainpages/plugin-api";
import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, permissionName, requirePermission } from "./admin-shared.ts"; import { ADMIN_EN, ADMIN_NAV, ADMIN_USERS_BASE, actionForMethod, buildConfirmModel, guardedForm, pagedTuples, permissionName, requirePermission } from "./admin-shared.ts";
const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] }; const reader: User = { email: "ada@x.io", id: "u1", permissions: ["users:read"] };
const writer: User = { email: "cy@x.io", id: "u3", permissions: ["users:read", "users:write"] }; const writer: User = { email: "cy@x.io", id: "u3", permissions: ["users:read", "users:write"] };
@@ -100,3 +100,23 @@ test("buildConfirmModel wires the danger action, message, breadcrumbs and title
assert.equal(model.title, "Delete user"); assert.equal(model.title, "Delete user");
assert.deepEqual(model.breadcrumbs.at(-1), { label: "Delete" }); assert.deepEqual(model.breadcrumbs.at(-1), { label: "Delete" });
}); });
test("pagedTuples follows the page token to the last page and keeps the query on every page", async () => {
const queries: unknown[] = [];
const tuple = (object: string) => ({ namespace: "Group", object, relation: "members", subject_id: "user:a" });
const keto = {
listRelations: async (q: { pageToken?: string } = {}) => {
queries.push(q);
if (q.pageToken === "p2") return { nextPageToken: "p3", tuples: [tuple("b")] };
if (q.pageToken === "p3") return { nextPageToken: null, tuples: [tuple("c")] };
return { nextPageToken: "p2", tuples: [tuple("a")] };
},
} as unknown as KetoClient;
const all = await pagedTuples(keto, { namespace: "Group", relation: "members" });
assert.deepEqual(all.map((t) => t.object), ["a", "b", "c"]);
assert.deepEqual(queries, [
{ namespace: "Group", relation: "members" },
{ namespace: "Group", pageToken: "p2", relation: "members" },
{ namespace: "Group", pageToken: "p3", relation: "members" },
]);
});
+29 -3
View File
@@ -1,8 +1,7 @@
// Shared plumbing for the admin example plugin: the section nav fragment, the screen gate, the // Shared plumbing for the admin example plugin's screens.
// CSRF-guarded form reader, the destructive-confirm model builder, and small RouteResult helpers.
// Everything imports the host only through the @plainpages/plugin-api barrel. // Everything imports the host only through the @plainpages/plugin-api barrel.
import { can, CSRF_FIELD, englishTranslator, GuardError, type NavNode, readFormBody, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "@plainpages/plugin-api"; import { can, CSRF_FIELD, englishTranslator, GuardError, type KetoClient, type NavNode, readFormBody, type RelationQuery, type RelationTuple, type RequestContext, requireSession, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import enUS from "./i18n/en-US.ts"; import enUS from "./i18n/en-US.ts";
// This plugin's English — its catalog, then the host's — for a view model built outside a request, // This plugin's English — its catalog, then the host's — for a view model built outside a request,
@@ -103,3 +102,30 @@ export function buildConfirmModel(opts: {
title: opts.title, title: opts.title,
}; };
} }
// The config one text input takes in the core `field` partial.
export interface FieldConfig {
autocomplete?: string;
hint?: string;
icon?: string;
id: string;
label: string;
name: string;
optional?: boolean;
readonly?: boolean;
required?: boolean;
type?: string;
value?: string;
}
// Drain every page of a relation-tuple query.
export async function pagedTuples(keto: KetoClient, query: RelationQuery): Promise<RelationTuple[]> {
const out: RelationTuple[] = [];
let pageToken: string | undefined;
do {
const page = await keto.listRelations({ ...query, ...(pageToken ? { pageToken } : {}) });
out.push(...page.tuples);
pageToken = page.nextPageToken ?? undefined;
} while (pageToken);
return out;
}
+1 -15
View File
@@ -5,7 +5,7 @@
import { buildPaginationModel, can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api"; import { buildPaginationModel, can, type Identity, type KetoClient, type KratosAdmin, KratosError, paginate, parseListQuery, type RecoveryCode, type RequestContext, type RouteHandler, type RouteResult, type Translate, type User } from "@plainpages/plugin-api";
import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, SAVED_NOTICE, userSubject } from "./admin-grants.ts"; import { applyGrants, buildPermissionPicker, effectivePermissions, grantDiff, heldPermissions, grantsNotice, isStale, type PermissionPicker, PERMISSIONS_FIELD, REMOVE_FIELD, removeUndeclaredGrant, REMOVED_NOTICE, SAVED_NOTICE, userSubject } from "./admin-grants.ts";
import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts"; import { ADMIN_EN, type AdminAction, ADMIN_USERS_BASE, buildConfirmModel, type FieldConfig, guardedForm, notFound, permissionName, requirePermission, unavailable } from "./admin-shared.ts";
const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id const SCHEMA_ID = "default"; // matches kratos.yml identity.default_schema_id
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
@@ -185,20 +185,6 @@ function listFilterBar(state: ListState, total: number, t: Translate) {
}; };
} }
export interface FieldConfig {
autocomplete?: string;
hint?: string;
icon?: string;
id: string;
label: string;
name: string;
optional?: boolean;
readonly?: boolean;
required?: boolean;
type?: string;
value?: string;
}
export function buildUserFormModel(opts: { export function buildUserFormModel(opts: {
canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance canWrite?: boolean; // false ⇒ a `users:read` holder: show the state, render no write affordance
csrfToken?: string; csrfToken?: string;
+1 -7
View File
@@ -3,6 +3,7 @@
// *through* us with. Built-in `fetch` only, no SDK dep (AGENTS.md); `fetchImpl`-injectable // *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); // like the kratos/keto clients. We authenticate the user (login) and grant scopes (consent);
// Hydra mints the tokens. // Hydra mints the tokens.
import { nextPageToken } from "./link-header.ts";
export interface OAuth2Client { export interface OAuth2Client {
client_id?: string; client_id?: string;
@@ -101,13 +102,6 @@ export interface HydraAdmin {
rejectLoginRequest(challenge: string, body: RejectRequest): Promise<Completed>; 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 { export function createHydraAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): HydraAdmin {
const base = config.baseUrl.replace(/\/+$/, ""); const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch; 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); // 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`). // `fetchImpl`-injectable, reuses kratos-public.ts's `KratosError` (branch on `.status`).
import { KratosError } from "./kratos-public.ts"; import { KratosError } from "./kratos-public.ts";
import { nextPageToken } from "./link-header.ts";
export interface Identity { export interface Identity {
id: string; id: string;
@@ -41,13 +42,6 @@ export interface KratosAdmin {
updateMetadataPublic(id: string, metadata: unknown): Promise<Identity>; 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 { export function createKratosAdmin(config: { baseUrl: string; fetchImpl?: typeof fetch }): KratosAdmin {
const base = config.baseUrl.replace(/\/+$/, ""); const base = config.baseUrl.replace(/\/+$/, "");
const http = config.fetchImpl ?? fetch; 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);
});
+6
View File
@@ -0,0 +1,6 @@
// The `page_token` of a `Link` header's `rel="next"` entry (Kratos and Hydra paginate with it), or
// null when there is none. The href is relative, so it is resolved against a throwaway base.
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); 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 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. // 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) => { 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 { fileURLToPath } from "node:url";
import ejs from "ejs"; import ejs from "ejs";
import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type PluginOwned, type RequestCsrf } from "./builtin-route.ts"; 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 { buildContext, type RequestContext, type User } from "./context.ts";
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts"; import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
import type { Denylist } from "../auth/denylist.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 // 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. // handler, or the public "/" with a standalone home, never pays for it.
let chromeMemo: PageChrome | undefined; 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, csrfToken: csrf.token, currentPath: url.pathname, localeHref: carryLocale, menu: host.menu, plugins: host.plugins, t,
translatorFor: (id) => i18n.translator(locale, id), user, translatorFor: (id) => i18n.translator(locale, id), user,
})); }));
+21 -11
View File
@@ -1,6 +1,6 @@
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import test from "node:test"; 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 { DEFAULT_MENU, type MenuConfig } from "./menu-config.ts";
import type { NavNode } from "./nav.ts"; import type { NavNode } from "./nav.ts";
import type { Plugin } from "../plugin-host/plugin.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); 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", () => { 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.brand.name, DEFAULT_MENU.branding.name);
assert.equal(chrome.user.name, "Guest"); assert.equal(chrome.user.name, "Guest");
// Dashboard points at the gated /dashboard — showing it to an anonymous visitor only dead-ends them // 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", () => { 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(buildPageChrome({ menu: DEFAULT_MENU }).signInHref, "/login"); // no path known
assert.equal(buildPluginChrome({ currentPath: "/portal", menu: DEFAULT_MENU }).signInHref, "/login?return_to=%2Fportal"); 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", () => { 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], currentPath: "/scheduling/shifts", menu: DEFAULT_MENU, plugins: [scheduling],
user: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] }, 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", () => { 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")!; const admin = chrome.nav.find((n) => n.label === "Admin")!;
assert.ok(admin); // gated section visible to an admin assert.ok(admin); // gated section visible to an admin
assert.equal(admin.open, true); // ancestor of the current leaf opened assert.equal(admin.open, true); // ancestor of the current leaf opened
@@ -67,9 +67,19 @@ test("a gated section (like the admin plugin) shows to a holder; a sub-path mark
assert.equal(admin.children!.find((c) => c.label === "Groups")!.current, undefined); assert.equal(admin.children!.find((c) => c.label === "Groups")!.current, undefined);
}); });
test("a path no nav leaf owns marks nothing current and opens nothing", () => {
const chrome = buildPageChrome({
currentPath: "/elsewhere", menu: DEFAULT_MENU, plugins: [scheduling],
user: { email: "ada@x.io", id: "u1", permissions: ["scheduling:read"] },
});
const section = chrome.nav.find((n) => n.label === "Scheduling")!;
assert.equal(section.open, undefined);
assert.equal(section.children!.some((c) => c.current), false);
});
test("branding logo + default theme flow through when set", () => { test("branding logo + default theme flow through when set", () => {
const menu: MenuConfig = { branding: { logo: "/logo.svg", name: "Acme", theme: "dark" }, override: {} }; 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.logo, "/logo.svg");
assert.equal(chrome.brand.name, "Acme"); assert.equal(chrome.brand.name, "Acme");
assert.equal(chrome.theme, "dark"); assert.equal(chrome.theme, "dark");
@@ -77,10 +87,10 @@ test("branding logo + default theme flow through when set", () => {
test("branding name and sub are translated; an unknown key renders as itself", () => { test("branding name and sub are translated; an unknown key renders as itself", () => {
const t = (key: string): string => (key === "brand.sub" ? "Undertitel" : key); 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.name, "Acme");
assert.equal(chrome.brand.sub, "Undertitel"); 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", () => { test("shellUser derives the profile from the real user; anonymous → Guest", () => {
@@ -97,7 +107,7 @@ const account: Plugin = {
], ],
}; };
const entries = (opts: { permissions?: string[]; signedIn?: boolean }): { href: string; icon?: string; label: string }[] => const entries = (opts: { permissions?: string[]; signedIn?: boolean }): { href: string; icon?: string; label: string }[] =>
buildPluginChrome({ buildPageChrome({
localeHref: (href) => `${href}?locale=sv-SE`, localeHref: (href) => `${href}?locale=sv-SE`,
menu: DEFAULT_MENU, menu: DEFAULT_MENU,
plugins: [account], plugins: [account],
@@ -115,7 +125,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", () => { 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" } } }, menu: { ...DEFAULT_MENU, override: { hide: ["help"], rename: { settings: "Preferences" } } },
plugins: [{ plugins: [{
apiVersion: "1.0.0", id: "account", apiVersion: "1.0.0", id: "account",
+18 -20
View File
@@ -52,9 +52,24 @@ export interface ChromeOptions {
user?: User | null; user?: User | null;
} }
export function buildPluginChrome(opts: ChromeOptions): PageChrome { export function buildPageChrome(opts: ChromeOptions): PageChrome {
const t = opts.t ?? ENGLISH; const t = opts.t ?? ENGLISH;
const carryLocale = opts.localeHref ?? ((href: string) => href); 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 ?? "",
// Marked before the locale rides along, so an href still matches the plain request path.
nav: carryLocaleInto(currentMarkedNav(opts, t), carryLocale),
signInHref: carryLocale(returnTo),
...(theme != null ? { theme } : {}),
user: { ...shellUser(opts.user, t), profileLinks: profileLinks(opts, t, carryLocale) },
};
}
function currentMarkedNav(opts: ChromeOptions, t: Translate): NavNode[] {
const fragments: NavNode[][] = [[DASHBOARD_NAV]]; const fragments: NavNode[][] = [[DASHBOARD_NAV]];
// A plugin's nav labels are keys in *its* catalog, so translate each fragment with that plugin's // 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 // translator before merging. composeNav then runs the core one over the result; already-translated
@@ -62,27 +77,10 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
for (const p of opts.plugins ?? []) { for (const p of opts.plugins ?? []) {
if (p.nav?.length) fragments.push(translateNav(p.nav, opts.translatorFor?.(p.id) ?? t)); 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); const nav = composeNav(fragments, opts.menu.override, opts.user ?? null, t);
if (opts.currentPath) { const target = opts.currentPath ? bestHref(nav, opts.currentPath) : undefined;
// 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); if (target) markCurrent(nav, target);
} return nav;
// 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) },
};
} }
function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] { function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] {
+24 -24
View File
@@ -6,17 +6,16 @@
`Bar = 1` `Bar = 1`
`Next ID = 72` `Next ID = 73`
## Items ## Items
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score | | ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|---|
| 70 | 0.5.0 | | | **Give the duplicated paging loop, Link-header parser and `FieldConfig` type one home each, and rename `buildPluginChrome` to `buildPageChrome`.** | 2 | 1 | 1 | 2 | 3 | code health by 2 | 9.0 |
| 71 | 0.5.0 | | | **Delete, with a group, every membership tuple in another group that names it, so re-creating the name restores no nested membership.** | 1 | 2 | 2 | 3 | 2 | 2 by 3 | 4.2 |
| 19 | 0.6.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 | | 19 | 0.6.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
| 16 | 0.6.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 | | 16 | 0.6.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.5 |
| 65 | 0.6.0 | | | **Export `KetoError` from `@plainpages/plugin-api`, so a plugin can tell a Keto failure apart with `instanceof`.** | 1 | 2 | 1 | 2 | 1 | 7 by 3, 1 by 1 | 6.7 | | 65 | 0.6.0 | | | **Export `KetoError` from `@plainpages/plugin-api`, so a plugin can tell a Keto failure apart with `instanceof`.** | 1 | 2 | 1 | 2 | 1 | 7 by 3, 1 by 1 | 6.7 |
| 72 | 0.6.0 | | | **Delete a group's grants and parent memberships when its last member leaves, by group delete or by removing the member, so re-creating the name restores none.** | 2 | 2 | 2 | 4 | 3 | 2 by 4 | 4.2 |
| 67 | 0.6.0 | | | **Group `src/auth`'s modules into one subfolder per concern, with `bootstrap.ts` staying at `src/auth/bootstrap.ts`.** | 3 | 1 | 2 | 4 | 6 | code health by 4 | 4.0 | | 67 | 0.6.0 | | | **Group `src/auth`'s modules into one subfolder per concern, with `bootstrap.ts` staying at `src/auth/bootstrap.ts`.** | 3 | 1 | 2 | 4 | 6 | code health by 4 | 4.0 |
| 14 | 0.6.0 | | | **Drop a double-clicked duplicate submit, with no client-side JavaScript.** | 4 | 2 | 3 | 7 | 7 | 2 by 7, 1 by 4 | 1.4 | | 14 | 0.6.0 | | | **Drop a double-clicked duplicate submit, with no client-side JavaScript.** | 4 | 2 | 3 | 7 | 7 | 2 by 7, 1 by 4 | 1.4 |
@@ -27,27 +26,6 @@
## Details ## Details
### 70. Give the duplicated paging loop, Link-header parser and `FieldConfig` type one home each, and rename `buildPluginChrome` to `buildPageChrome`.
- `pagedTuples` (`examples/plugins/auth-admin/admin-groups.ts` ~L246) and `heldPermissions`
(`admin-grants.ts` ~L38) each hand-write the Keto page-token loop; let `heldPermissions` use the
one loop.
- `nextPageToken` is copied in `src/auth/kratos-admin.ts` ~L46 and `src/auth/hydra-admin.ts` ~L106.
- `FieldConfig` lives in `admin-users.ts`, and `admin-groups.ts` and `admin-clients.ts` import it
from there; move it to `admin-shared.ts`.
- `buildPluginChrome` (`src/ui/chrome.ts:55`) builds the chrome for every page, core pages
included; rename it, its test and the README ~L1027 mention. Make `buildPluginChrome` easier to
read, a place the seats found hard.
### 71. Delete, with a group, every membership tuple in another group that names it, so re-creating the name restores no nested membership.
Groups nest: a parent holds `Group:<parent>#members` with `subject_set` `Group:<child>#members`.
`groupsDelete` in `examples/plugins/auth-admin/admin-groups.ts` drops the child's own member tuples
and held grants, but not those parent tuples, so a group re-created with the deleted name is back
in every parent, the orphan class its comment already guards against for grants. List them with
`pagedTuples` filtered on `subject_set` (`namespace: GROUP_NS`, `object: name`, `relation: MEMBERS`)
and add them to the same `transactTuples` delete; extend the comment to say so.
### 19. Run the documented Docker commands on macOS and fix whatever misbehaves. ### 19. Run the documented Docker commands on macOS and fix whatever misbehaves.
Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is Needs a Mac. Two suspects, both from `--user "$(id -u):$(id -g)"`: a macOS `id -g` is `20`, which is
@@ -66,6 +44,28 @@ surface, so this adds to the plugin API: it ships in a minor, and `HOST_API_VERS
`0.6.0` when 0.6.0 is tagged (AGENTS.md → Rules). Adding is safe for installed plugins; removing it `0.6.0` when 0.6.0 is tagged (AGENTS.md → Rules). Adding is safe for installed plugins; removing it
later would take a major. later would take a major.
### 72. Delete a group's grants and parent memberships when its last member leaves, by group delete or by removing the member, so re-creating the name restores none.
In `examples/plugins/auth-admin/admin-groups.ts` a group exists exactly while it has a
`Group:<name>#members` tuple. Two paths empty a group but keep its other tuples, so re-creating the
name silently hands every new member the old permissions and parent memberships:
1. `groupsDelete` of a group that is a parent's only member: the parent ceases to exist but keeps
its `Permission:*#granted@Group:<parent>#members` grants and its memberships in grandparents.
2. `groupsRemoveMember` removing a group's last member, which today is a lone `deleteTuple`.
Fix both in one `transactTuples` with the removal: when the removal leaves a group with no members,
add its grants and the tuples naming it as a member of other groups to the delete. The cascade is
transitive: a grandparent emptied by its parent vanishing is the same bug one level up. The walk
stops at a group that keeps any member, so a nesting cycle (`groupsAddMember` refuses only a
self-nest) ends it. An emptied group grants nobody, so the cascade cannot change what
`lostToSelf` computes. Test it with nesting two levels deep and with a cycle.
When it ships, rewrite the comment at `groupsDelete` that points here, and the CHANGELOG entry "The
admin example removes a deleted group from the groups it was nested in", which then says a parent
left with no members no longer exists as a group. `lostToSelf`, in this file, is a place the seats
found hard; if the change touches it, make it easier to read.
### 67. Group `src/auth`'s modules into one subfolder per concern, with `bootstrap.ts` staying at `src/auth/bootstrap.ts`. ### 67. Group `src/auth`'s modules into one subfolder per concern, with `bootstrap.ts` staying at `src/auth/bootstrap.ts`.
`src/auth` holds 19 modules over several concerns: the per-request session path (`jwt*`, `jwks`, `src/auth` holds 19 modules over several concerns: the per-request session path (`jwt*`, `jwks`,