Correct the stale and false comments and docs the comprehension seats flagged
CI / full-gate (push) Successful in 3m40s

This commit is contained in:
2026-11-09 00:11:47 +01:00
parent c744759869
commit 42644ba0f5
15 changed files with 46 additions and 20 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 tuple naming something no installed plugin declares is not
nothing. offered by the admin screens, though a plugin may still gate on 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")"
+1 -1
View File
@@ -165,7 +165,7 @@ 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 http issuer via --dev
# (compose.override.yml); prod sets an https issuer via env (URLS_SELF_ISSUER). # (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
+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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -15,7 +15,7 @@ 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 (docs + Keto seeding), named `<resource>:<action>`.
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 },
+4
View File
@@ -20,6 +20,10 @@
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 a floor engine 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.
src/test-tooling/stylesheet-floor.ts fails its test on one.
------------------------------------------------------------- */ ------------------------------------------------------------- */
: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.
+3 -2
View File
@@ -4,8 +4,9 @@ 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 (see AGENTS.md). Signature only: the caller
// (exp/iss/aud, clock skew), JWKS-by-`kid` fetch/cache/rotation, and `token` bounds. // (`jwt-middleware.ts`) adds claim checks (exp/iss/aud, clock skew) and picks the key by `kid`
// from the cached JWKS (`jwks.ts` fetches and rotates it).
// 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
+1 -1
View File
@@ -40,7 +40,7 @@ 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 per-permission checks are cheap and run in parallel. // request; permission count is small, so the 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}`;
+2 -1
View File
@@ -1,7 +1,8 @@
// 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. // Specificity: the route with fewer `:param` segments wins (/users/new beats /users/:id). A tie, such
// as /a/:x/b against /a/b/:y, falls to discovery order.
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;