Give the paging loop, Link-header parser and FieldConfig one home each, and delete a group from its parents #235
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
+19
-21
@@ -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
|
if (target) markCurrent(nav, target);
|
||||||
// /auth-admin/users/new marks the Users base leaf (/auth-admin/users) and the dashboard marks Dashboard.
|
return nav;
|
||||||
// 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) },
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] {
|
function profileLinks(opts: ChromeOptions, t: Translate, carryLocale: (href: string) => string): ProfileLink[] {
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
Reference in New Issue
Block a user