Correct the stale and false comments and docs the comprehension seats flagged #232

Merged
lilleman merged 4 commits from item-63 into main 2026-11-09 00:22:51 +01:00
16 changed files with 51 additions and 57 deletions
+4 -4
View File
@@ -347,8 +347,8 @@ curl -X PUT http://keto:4467/admin/relation-tuples -H 'content-type: application
**A permission's name is authored in plugin code; only its *grants* live in Keto.** A plugin **A permission's name is authored in plugin code; only its *grants* live in Keto.** A plugin
declares the permissions it gates on (`permissions:` in the manifest) and the host collects them declares the permissions it gates on (`permissions:` in the manifest) and the host collects them
into one catalog — `ctx.declaredPermissions` — which is the fixed list the admin screens offer. into one catalog — `ctx.declaredPermissions` — which is the fixed list the admin screens offer.
Nothing in a GUI invents a name, and a tuple naming something no installed plugin declares gates Nothing in a GUI invents a name. A grant of a name that no installed plugin declares still reaches
nothing. the user's JWT, so a plugin may gate on that name without declaring it.
A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant A change takes effect on the user's **next login or JWT re-mint** (~10 min) — see [Instant
revoke](#instant-revoke-the-optional-denylist) when you need it sooner. revoke](#instant-revoke-the-optional-denylist) when you need it sooner.
@@ -1363,8 +1363,8 @@ be `metadata_public`, not `metadata_admin` — the session Kratos hands the toke
public metadata, and the user can already read these permissions in their own JWT. The projection is public metadata, and the user can already read these permissions in their own JWT. The projection is
a per-login cache, authoritative nowhere; a stale one self-heals on the next login. a per-login cache, authoritative nowhere; a stale one self-heals on the next login.
Login resolves both direct grants and group membership, so the JWT `permissions` match what the Login resolves both direct grants and group membership, so the JWT `permissions` match what Keto
admin **Effective access** view shows. Cost: **a handful of Keto reads + one identity refresh per says the user holds. Cost: **a handful of Keto reads + one identity refresh per
login**, never per request. JWKS is cached, so signature verification hits the network only on key login**, never per request. JWKS is cached, so signature verification hits the network only on key
rotation. rotation.
+1 -1
View File
@@ -6,7 +6,7 @@
# bash ci.sh # bash ci.sh
# #
# Exits non-zero on the first failure. Each E2E suite OWNS a clean stack — never point two suites at # Exits non-zero on the first failure. Each E2E suite OWNS a clean stack — never point two suites at
# one backend (auth-refresh revokes the admin's sessions; full-flow writes users/groups/roles to Keto). # one backend (auth-refresh revokes the admin's sessions; full-flow writes users, groups and grants).
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
+3 -2
View File
@@ -165,8 +165,9 @@ services:
# Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README). # Ory Hydra — OAuth2/OIDC provider (other apps log in *through* plainpages; README).
# DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml. web implements the # DSN is its own `hydra` DB (init.sql); config in ory/hydra/hydra.yml. web implements the
# login challenge at /oauth2/login (consent next). Dev permits the http issuer via --dev # login challenge at /oauth2/login and the consent challenge at /oauth2/consent. Dev permits the
# (compose.override.yml); prod sets an https issuer via env (URLS_SELF_ISSUER). # http issuer via --dev (compose.override.yml); prod sets an https issuer via env
# (URLS_SELF_ISSUER).
hydra-migrate: hydra-migrate:
image: oryd/hydra:v26.2.0 image: oryd/hydra:v26.2.0
depends_on: depends_on:
+17
View File
@@ -513,3 +513,20 @@ Read most (opens and searches):
- `AGENTS.md` 1 - `AGENTS.md` 1
- `README.md` 1 - `README.md` 1
- `docs/decisions.md` 1 - `docs/decisions.md` 1
## Reading cost, chunk f0194a598d2a, finished 2026-11-08T23:10:24Z
81419 new input tokens before the first commit.
Read most (opens and searches):
- `src/http/app-admin.test.ts` 11
- `examples/plugins/auth-admin/admin-users.ts` 7
- `todo.md` 6
- `examples/plugins/auth-admin/admin-groups.test.ts` 4
- `README.md` 3
- `examples/plugins/auth-admin/admin-groups.ts` 3
- `CHANGELOG.md` 2
- `docs/reading-cost.md` 2
- `examples/plugins/auth-admin/admin-grants.ts` 2
- `examples/plugins/auth-admin/i18n/en-US.ts` 2
+4 -4
View File
@@ -35,9 +35,9 @@ screen degrades to a themed 503 rather than crashing. Everything else is an ordi
folder-discovered, gated per route by its screen's `<resource>:<action>` permission, rendering the folder-discovered, gated per route by its screen's `<resource>:<action>` permission, rendering the
core building blocks in `views/`. core building blocks in `views/`.
Each screen is its own resource — `users`, `groups`, `oauth2-clients` — split into `:read` and Each screen is its own resource — `users`, `groups`, `oauth2-clients`, `plugin-settings` — split
`:write`, so a helpdesk account can be given `users:read` alone. Holding none of the six hides the into `:read` and `:write` (`plugin-settings` has only `:read`), so a helpdesk account can be given
Admin section entirely. `users:read` alone. Holding none of the seven hides the Admin section entirely.
There is **no Permissions screen**. Permission names are declared in plugin code, not created in a There is **no Permissions screen**. Permission names are declared in plugin code, not created in a
GUI, so the host's catalog (`ctx.declaredPermissions`) is the fixed list — and holding one is a GUI, so the host's catalog (`ctx.declaredPermissions`) is the fixed list — and holding one is a
@@ -46,7 +46,7 @@ A grant of a name no installed plugin declares is listed apart on the same page,
## Layout ## Layout
- `plugin.ts` — the manifest: the Admin nav fragment, the six permissions the plugin declares, and - `plugin.ts` — the manifest: the Admin nav fragment, the seven permissions the plugin declares, and
the route table — one thin handler per method+path, gated via `permissionName(resource, actionForMethod(method))` the route table — one thin handler per method+path, gated via `permissionName(resource, actionForMethod(method))`
so a GET needs `:read` and a POST `:write`. so a GET needs `:read` and a POST `:write`.
- `admin-grants.ts` — the permission picker and the grant diff, shared by the Users and Groups - `admin-grants.ts` — the permission picker and the grant diff, shared by the Users and Groups
+2 -1
View File
@@ -15,7 +15,8 @@ const GROUP_NS = "Group";
const MEMBERS = "members"; const MEMBERS = "members";
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
const PAGE_SIZES = [25, 50, 100]; const PAGE_SIZES = [25, 50, 100];
// One Keto page of candidate users is fetched for the member pickers (mirrors admin-users). // One Kratos `listIdentities` page of candidate users is fetched for the member pickers (mirrors
// admin-users).
const LIST_FETCH_SIZE = 250; const LIST_FETCH_SIZE = 250;
const GROUP_NAME = /^[a-z0-9][a-z0-9_-]*$/; // URL-safe; doubles as the path segment const GROUP_NAME = /^[a-z0-9][a-z0-9_-]*$/; // URL-safe; doubles as the path segment
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // a Kratos identity id const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; // a Kratos identity id
@@ -1,9 +1,11 @@
<%# <%#
Admin group membership body, captured into the shell content slot. Config: Admin group membership body, captured into the shell content slot. Config:
group { name } group { name }
members { action, rows: { kind:"group"|"identity", label, subject }[] } action = remove-member endpoint members { action, rows: { kind:"group"|"user", label, subject }[] } action = remove-member endpoint
add { action, options: {label,value}[] } action = add-member endpoint add { action, options: {label,value}[] } action = add-member endpoint
del { action } delete the whole group del { action } delete the whole group
canWrite? false (a `groups:read` holder) drops the add, remove and delete forms
permissions? PermissionPicker; when set, the group's permission picker is rendered
csrfToken, error? csrfToken, error?
%><% %><%
const group = locals.group; const group = locals.group;
+1 -1
View File
@@ -34,7 +34,7 @@ export default definePlugin({
public: true, // the header gates nothing; each child names its own gate, and an empty header is dropped public: true, // the header gates nothing; each child names its own gate, and an empty header is dropped
}], }],
// Roles this plugin introduces (docs + Keto seeding). Namespaced `<id>:<action>`. // Permissions this plugin declares: the admin picker offers them and bootstrap grants them to the demo admin.
permissions: [ permissions: [
{ description: "View shifts", name: READ }, { description: "View shifts", name: READ },
{ description: "Create and edit shifts", name: WRITE }, { description: "Create and edit shifts", name: WRITE },
+5
View File
@@ -20,6 +20,11 @@
resolution is unreliable across engines. color-scheme is still resolution is unreliable across engines. color-scheme is still
set as a hint for native widgets (date picker, scrollbars, set as a hint for native widgets (date picker, scrollbars,
selects). selects).
Footgun: a token value an engine of the oldest browser floor can't parse (a
function or unit it lacks) is invalid at computed-value time, so every
declaration reading it through var() computes as `unset`; a fallback declared
before it doesn't help. The test in src/test-tooling/stylesheet-floor.ts
fails on such a value.
------------------------------------------------------------- */ ------------------------------------------------------------- */
:root { :root {
/* ---- raw LIGHT palette ---- */ /* ---- raw LIGHT palette ---- */
+3 -2
View File
@@ -1,8 +1,9 @@
// One-command bootstrap (the MVP bar). One-shot compose service: runs after // One-command bootstrap (the MVP bar). One-shot compose service: runs after
// kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`: // kratos+keto are healthy (web waits on it), idempotent on every `docker compose up`:
// 1. generate the JWKS signing key if absent (committed dev key makes this a safety net); // 1. generate the JWKS signing key if absent (committed dev key makes this a safety net);
// 2. seed a demo admin (admin@plainpages.local / admin) in Kratos; // 2. create a database and login role for each discovered plugin that declares storage;
// 3. grant it its permissions in Keto so menu/permission checks resolve out of the box — every // 3. seed a demo admin (admin@plainpages.local / admin) in Kratos;
// 4. grant it its permissions in Keto so menu/permission checks resolve out of the box — every
// discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so a dropped-in // discovered plugin's declared permission names (plus any ADMIN_PERMISSIONS), so a dropped-in
// plugin is usable by the demo admin with no host config edit (the host stays plugin-agnostic). // plugin is usable by the demo admin with no host config edit (the host stays plugin-agnostic).
// Then prints a first-run banner; fails loud on any unexpected upstream error. // Then prints a first-run banner; fails loud on any unexpected upstream error.
+2 -2
View File
@@ -4,8 +4,8 @@ import type { JsonWebKey } from "./jwks.ts";
// JWS signature verification with the Node stdlib — no `jose`/JWT dep: // JWS signature verification with the Node stdlib — no `jose`/JWT dep:
// `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures // `createPublicKey({format:"jwk"})` imports a JWK and verifies the RS*/ES* signatures
// the Kratos tokenizer produces (see AGENTS.md). Signature only — adds claim checks // the Kratos tokenizer produces. Signature only: `jwt-middleware.ts`
// (exp/iss/aud, clock skew), JWKS-by-`kid` fetch/cache/rotation, and `token` bounds. // picks the key by `kid` and checks the claims.
// JOSE `alg` → Node verify parameters. ES* signatures are raw r‖s (IEEE P1363), not DER. // JOSE `alg` → Node verify parameters. ES* signatures are raw r‖s (IEEE P1363), not DER.
// Extend this map to widen support. Security invariant: never add `HS*`/`none` — this map // Extend this map to widen support. Security invariant: never add `HS*`/`none` — this map
+2 -2
View File
@@ -40,8 +40,8 @@ export interface CompletedLogin {
// The coarse permissions a user holds — directly (`Permission:<name>#granted@user:<id>`) or transitively via a // The coarse permissions a user holds — directly (`Permission:<name>#granted@user:<id>`) or transitively via a
// group granted the permission. Enumerates the defined permissions (the distinct objects in the Permission // group granted the permission. Enumerates the defined permissions (the distinct objects in the Permission
// namespace) and asks Keto to check each grant, so a permission granted to a group reaches the JWT — // namespace) and asks Keto to check each grant, so a permission granted to a group reaches the JWT —
// matching the OPL model and the admin "Effective access" view. At login/refresh only, never per // matching the OPL model. At login/refresh only, never per request; permission count is small, so the
// request; permission count is small, so the per-permission checks are cheap and run in parallel. // per-permission checks are cheap and run in parallel.
export async function readPermissions(keto: KetoClient, userId: string): Promise<string[]> { export async function readPermissions(keto: KetoClient, userId: string): Promise<string[]> {
const subject_id = `user:${userId}`; const subject_id = `user:${userId}`;
const names = new Set<string>(); const names = new Set<string>();
-1
View File
@@ -1,7 +1,6 @@
// Router: pure core mapping method + pathname → a discovered plugin route. I/O-free; // Router: pure core mapping method + pathname → a discovered plugin route. I/O-free;
// app.ts is the shell (build context, gate, call handler, render RouteResult). A route mounts at // app.ts is the shell (build context, gate, call handler, render RouteResult). A route mounts at
// `/<id>` + its path (fullPath, shared with conflict detection); `:name` segments → path params. // `/<id>` + its path (fullPath, shared with conflict detection); `:name` segments → path params.
// Specificity: a literal segment beats a `:param` (/users/new wins /users/:id), order-independent.
import { fullPath, type Plugin, type Route } from "./plugin.ts"; import { fullPath, type Plugin, type Route } from "./plugin.ts";
+2 -2
View File
@@ -1,5 +1,5 @@
// System capabilities: privileged host services a first-party/system plugin (the built-in admin // System capabilities: privileged host services a first-party/system plugin (the `auth-admin`
// screens are the reference consumer) needs but an ordinary domain plugin does not — the Ory admin // example plugin is the reference consumer) needs but an ordinary domain plugin does not — the Ory admin
// clients and the instant-revoke hook. Exposed on ctx.system and re-exported via @plainpages/plugin-api. // clients and the instant-revoke hook. Exposed on ctx.system and re-exported via @plainpages/plugin-api.
// //
// Every field is optional: it is present only when the host wired that dependency (Ory configured, // Every field is optional: it is present only when the host wired that dependency (Ory configured,
+2 -2
View File
@@ -66,7 +66,7 @@ export function buildPluginChrome(opts: ChromeOptions): PageChrome {
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) { if (opts.currentPath) {
// Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like // Mark by the *best* (longest) href that is the path or a parent of it, so a sub-path like
// /admin/users/new marks the Users base leaf (/admin/users) and the dashboard marks Dashboard. // /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. // Marked before the locale rides along, so an href still matches the plain request path.
const target = bestHref(nav, opts.currentPath); const target = bestHref(nav, opts.currentPath);
if (target) markCurrent(nav, target); if (target) markCurrent(nav, target);
@@ -140,7 +140,7 @@ function carryLocaleInto(nodes: NavNode[], carryLocale: (href: string) => string
} }
// The href of the leaf that owns `path`: an exact match, else the longest href that is a parent of // The href of the leaf that owns `path`: an exact match, else the longest href that is a parent of
// it (href + "/" prefixes path), so /admin/users/123 resolves to the /admin/users leaf. "/" never // it (href + "/" prefixes path), so /auth-admin/users/123 resolves to the /auth-admin/users leaf. "/" never
// counts as a parent (it would own everything). Returns undefined when nothing matches. // counts as a parent (it would own everything). Returns undefined when nothing matches.
function bestHref(nodes: NavNode[], path: string): string | undefined { function bestHref(nodes: NavNode[], path: string): string | undefined {
let best: string | undefined; let best: string | undefined;
-32
View File
@@ -12,7 +12,6 @@
| 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 |
|---|---|---|---|---|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|---|---|---|---|
| 63 | 0.5.0 | | | **Correct the stale and false comments and docs the comprehension seats flagged, so each states what the code does today.** | 2 | 2 | 1 | 3 | 3 | code health by 3, 9 by 2 | 15.4 |
| 68 | 0.5.0 | | | **Make `serveHome` and `serveDashboard` return their result to the request pipeline, as every other stage does, instead of sending it themselves.** | 2 | 1 | 3 | 4 | 3 | code health by 3 | 13.5 | | 68 | 0.5.0 | | | **Make `serveHome` and `serveDashboard` return their result to the request pipeline, as every other stage does, instead of sending it themselves.** | 2 | 1 | 3 | 4 | 3 | code health by 3 | 13.5 |
| 66 | 0.5.0 | | | **Make `src/auth/bootstrap.ts` read its settings through `src/config.ts` and call Kratos and Keto through their client modules.** | 2 | 1 | 3 | 4 | 4 | code health by 3 | 9.0 | | 66 | 0.5.0 | | | **Make `src/auth/bootstrap.ts` read its settings through `src/config.ts` and call Kratos and Keto through their client modules.** | 2 | 1 | 3 | 4 | 4 | code health by 3 | 9.0 |
| 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 | | 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 |
@@ -30,37 +29,6 @@
## Details ## Details
### 63. Correct the stale and false comments and docs the comprehension seats flagged, so each states what the code does today.
Prose only; no behaviour changes. Make each listed place easier to read, since the seats named
most of them.
- `src/auth/login.ts:43` and `README.md` ~L1367 name an admin "Effective access" view that doesn't
exist.
- `src/plugin-host/router.ts:4` header says specificity is order-independent; `matchRoute` sorts by
`:param` count only, so a tie (`/a/:x/b` against `/a/b/:y`) falls to discovery order.
- `ci.sh:9` ("users/groups/roles") and `examples/plugins/scheduling/plugin.ts:37` ("Roles", and
`<id>:<action>` where the rule is `<resource>:<action>`) use words the project doesn't have.
- `src/plugin-host/system.ts:1-2` calls "the built-in admin screens" the reference consumer; they
are the `auth-admin` example plugin.
- `examples/plugins/auth-admin/admin-groups.ts:18` says a Keto page; the call is Kratos
`listIdentities`.
- `compose.yml` ~L166 says consent comes next; `src/auth/oauth-consent.ts` implements it.
- `src/auth/jwt.ts:6-7`: "Signature only — adds claim checks…" lacks its subject (the caller,
`jwt-middleware.ts`, adds them).
- `src/ui/chrome.ts` ~L69 and ~L143 use `/admin/users` as example paths; admin lives at
`/auth-admin/users`.
- `README.md` ~L350 says a tuple naming an undeclared permission "gates nothing"; declaring stays
optional, so a plugin may gate on it. `examples/plugins/auth-admin/README.md:49` says six
permissions; `plugin.ts` declares seven.
- `examples/plugins/auth-admin/views/partials/group-detail-body.ejs:2-7` documents member
`kind:"identity"` where the model says `"user"`, and omits the `canWrite` and `permissions`
locals.
- `src/auth/bootstrap.ts:1-8` header lists the JWKS, admin and Keto steps, not the plugin-storage
step.
- The custom-property `unset` footgun is stated only in `src/test-tooling/stylesheet-floor.ts:10-12`;
say it beside the tokens at `public/css/styles.css:9-23`, where an author writes one.
### 68. Make `serveHome` and `serveDashboard` return their result to the request pipeline, as every other stage does, instead of sending it themselves. ### 68. Make `serveHome` and `serveDashboard` return their result to the request pipeline, as every other stage does, instead of sending it themselves.
In `src/http/app.ts`, both (~L143-169) call `sendResult` with the owning plugin's renderer and In `src/http/app.ts`, both (~L143-169) call `sendResult` with the owning plugin's renderer and