Move test-only tooling to src/test-tooling, split positive, warn on undeclared gated permissions #214

Merged
lilleman merged 7 commits from item-51 into main 2026-11-08 14:26:41 +01:00
20 changed files with 143 additions and 48 deletions
+2 -2
View File
@@ -75,8 +75,8 @@ Revisit only if the stated reason stops holding.
### Structure & contracts
- **`src/` is grouped by concern**, not flat — `http/`, `auth/`, `i18n/`, `plugin-host/`, `ui/`,
with `server.ts`/`config.ts`/`logger.ts` and the topology-guard `*.test.ts` at the root; tests are
co-located. Add a new module to the folder owning its concern. The core ships **no domain
and `test-tooling/` (modules only tests import), with `server.ts`/`config.ts`/`logger.ts` and
the topology-guard `*.test.ts` at the root; tests are co-located. Add a new module to the folder owning its concern. The core ships **no domain
screens** — even the admin GUI is a drop-in plugin (`examples/plugins/admin/`).
- **Plugins and config import the host only through a barrel** — `@plainpages/plugin-api` →
`plugin-api/index.ts` → `src/plugin-host/plugin-api.ts`, `#menu-config` → `src/ui/menu-config.ts`,
+6
View File
@@ -6,6 +6,12 @@ at boot. Entries start at 0.3.0.
## Unreleased
### Discovery warns when a gate names a permission no plugin declares
A route or nav node that gates on a permission no installed plugin declares now logs a boot warning
naming the plugin and the permission. The admin screens offer only declared permissions, so nobody can
be granted such a gate from them. Add the name to the plugin's `permissions` to silence the warning.
### Discovery refuses a plugin with the id `error`
`/error` is the page Kratos sends a failed self-service flow to, and a plugin named `error` would
+5 -3
View File
@@ -730,7 +730,7 @@ The host detects collisions across all discovered plugins with `findConflicts` a
| `route` | error | Two routes resolve to the same `method` + full path. Cross-plugin routes can't collide (the `/<id>` prefix is unique), so this catches a plugin duplicating one of its own. |
| `nav-id` | error | A nav node `id` is used more than once — the central override targets ids, so they must be unique. |
| `home` / `dashboard` | error | More than one plugin declares `home` (or `dashboard`). Each landing page is a single slot, so only one may own it ([The landing pages](#the-landing-pages-home--dashboard)). |
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. |
| `permission` | warn | A permission name is declared by more than one plugin. Sharing is legitimate; pick a more specific [`<resource>`](#naming-a-permission) if unintended. Also warned: a route or nav node gates on a permission no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's `permissions`. |
Mount-path uniqueness needs no rule of its own — it follows from the id check. Discovery also
rejects **per-manifest shape errors**: a non-array `nav`/`routes`/`permissions`, a non-function
@@ -1064,7 +1064,7 @@ stylesheet using a feature Chromium 87, Safari 15 or Firefox 115 lacks, unless a
around it names that feature. It checks every stylesheet in `public/`, `examples/` and `plugins/`,
so mount your plugin in `plugins/<id>/` and run it. A few constructs fail even when guarded, such as
`@layer`, nested rules and media range syntax, while a media feature passes unguarded, since a
browser without it skips the query; `src/ui/stylesheet-floor.ts` lists the rules.
browser without it skips the query; `src/test-tooling/stylesheet-floor.ts` lists the rules.
**A field whose options depend on another field is asked for in steps, one GET form each**, so every
choice is in the URL, and nothing typed is lost to one because nothing is typed until they are made.
@@ -1830,7 +1830,7 @@ unnecessary here — the signature itself is already invalid.
src/ The app — strict tsc, no build step. *.test.ts sit beside their module.
server.ts Entry point; config.ts env loader; logger.ts structured log + trace span + tracedFetch
*.test.ts Guards with no source counterpart (compose/kratos/keto/hydra/postgres/ci-gate/function-limits)
http/ Request pipeline: app.ts (createApp), builtin-routes, context, body, cookie,
http/ Request pipeline: app.ts (createApp), builtin-route, context, body, cookie,
static, safe-url (safeUrl + localPath), security-headers
auth/ Identity + the session-JWT hot path: jwt · jwt-middleware · jwks · gen-jwks (CLI) ·
login · guards · csrf · denylist · flow-view · oauth-login · oauth-consent ·
@@ -1843,6 +1843,8 @@ src/ The app — strict tsc, no build step. *.test.ts sit beside
storage-provisioning (the DDL; bootstrap-only, holds the driver)
ui/ chrome (`ctx.chrome`: menu, brand, user) · nav (composeNav) · menu-config
(`#menu-config`) · icons (lucide sprite builder) · list-query · paginate
test-tooling/ Test-only: app-harness (starts `createApp` over fakes for the `src/http` tests) ·
stylesheet-floor (the browser-floor check)
plugin-api/ The `@plainpages/plugin-api` package — the author barrel, linked into /node_modules
views/ Core EJS in the one app shell: home, index, auth, oauth-consent, error, 403/404/500/503,
+25
View File
@@ -290,3 +290,28 @@ Inherited architect, would restructure first:
- Locality, Shape, Overall: `src/http/app.ts:185` `handleRequest`: stage order lives only in line order, in a grandfathered complexity-43 function
- Shape, Navigation: `src/auth/routes.ts:275` `buildAuthRoutes`: 19 flat modules in `src/auth/`, and the next flow lands here
- Shape, Navigation: `src/ui/stylesheet-floor.ts:15` `FLOOR`: the 3am rule's home is a test-only module under `ui/`
## 2026-11-08T13:22:47Z, PR #214 at 84368ea, against c2389f8
Ruling: better
| Seat | Navigation | Locality | Shape | Self-sufficiency | Overall |
|---|---|---|---|---|---|
| Mid A | better | same | better | better | better |
| Inherited architect | better | same | better | better | better |
Mid A, decided by:
- Navigation, Shape, Overall: `src/test-tooling/stylesheet-floor.ts:1` `floorViolations`: test-only modules no longer sit beside runtime code in `ui/` and `http/`, and `AGENTS.md:77` names the folder
- Self-sufficiency: `src/auth/login.ts:40` `readPermissions`: the comment now names `#granted`, which the code queries
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: grouping and the per-group decision are separate named units
- Self-sufficiency: `src/test-tooling/stylesheet-floor.ts:10` file header: says why a custom property's value is checked for functions and units
- Self-sufficiency: `src/plugin-host/plugin.ts:234` `undeclaredGates`: its comment says why an undeclared gate matters
Inherited architect, decided by:
- Self-sufficiency, Shape, Overall: `src/auth/login.ts:40` `readPermissions`: the comment now names the relation the code queries
- Self-sufficiency: `src/auth/bootstrap.ts:31` `permissionTuple`: the contradicting comment is gone
- Navigation: `src/test-tooling/stylesheet-floor.ts:1` `floorViolations`: the folder is named in AGENTS.md and the README file map
- Shape: `src/test-tooling/stylesheet-floor.ts:122` `positive`: one loop split into `parenGroups`, `Group` and `keepGroup`
- Self-sufficiency: `src/auth/routes.ts:117` `codeFlowSubmit`: a comment says which `flowFailure` branch can fire there
+17
View File
@@ -320,3 +320,20 @@ Read most (opens and searches):
- `src/config.ts` 1
- `src/plugin-host/storage-provisioning.ts` 1
- `src/plugin-host/storage.ts` 1
## Reading cost, chunk 80e22d10c810, finished 2026-11-08T13:06:23Z
130001 new input tokens before the first commit.
Read most (opens and searches):
- `README.md` 6
- `todo.md` 4
- `examples/plugins/admin/admin-groups.ts` 2
- `src/auth/bootstrap.ts` 2
- `src/auth/login.ts` 2
- `src/http/app-admin.test.ts` 2
- `AGENTS.md` 1
- `CHANGELOG.md` 1
- `examples/plugins/admin/i18n/sv-SE.ts` 1
- `src/auth/routes.ts` 1
+2 -2
View File
@@ -1,10 +1,10 @@
// The built-in auth/OAuth2 endpoints as named handlers on the host's internal route table
// (src/http/builtin-routes.ts): the themed Kratos self-service pages and their code submits, the Hydra OAuth2
// (src/http/builtin-route.ts): the themed Kratos self-service pages and their code submits, the Hydra OAuth2
// login/consent/logout challenges, login completion, the first-party logout, and Kratos'
// flow-error sink. buildAuthRoutes registers only what the wired clients support — a missing
// client means the path simply doesn't exist (404). `view` results render the core views.
import { readFormBody } from "../http/body.ts";
import type { BuiltinRoute, RequestCsrf } from "../http/builtin-routes.ts";
import type { BuiltinRoute, RequestCsrf } from "../http/builtin-route.ts";
import type { RequestContext } from "../http/context.ts";
import { CSRF_FIELD } from "./csrf.ts";
import { chosenLocale } from "../i18n/locale.ts";
+1 -1
View File
@@ -16,7 +16,7 @@ import type { Plugin } from "../plugin-host/plugin.ts";
import adminManifest from "../../examples/plugins/admin/plugin.ts";
import { createI18n } from "../i18n/runtime.ts";
import { loadI18n } from "../i18n/load.ts";
import { ecJwk, fakeKeto, matchesTuple, mintJwt, stubAdmin, stubHydra } from "./app-harness.ts";
import { ecJwk, fakeKeto, matchesTuple, mintJwt, stubAdmin, stubHydra } from "../test-tooling/app-harness.ts";
// The HTTP-level admin tests mount the example plugin via createApp — stub Ory clients on
// ctx.system, views from examples/plugins — exactly as an operator would after copying it in.
+1 -1
View File
@@ -10,7 +10,7 @@ import { KratosError, type Flow, type FlowSubmission, type FlowType, type Kratos
import { SESSION_COOKIE } from "../auth/login.ts";
import { createI18n } from "../i18n/runtime.ts";
import { loadI18n } from "../i18n/load.ts";
import { ecJwk, fakeKeto, mintJwt, mockKratos, OAUTH_SUBJECT, stubAdmin, stubHydra, withWhoami } from "./app-harness.ts";
import { ecJwk, fakeKeto, mintJwt, mockKratos, OAUTH_SUBJECT, stubAdmin, stubHydra, withWhoami } from "../test-tooling/app-harness.ts";
// A re-rendered login flow: csrf hidden, themed fields, a submit, and a failed-attempt message.
const node = (attrs: Record<string, unknown>, label?: string): UiNode => ({ attributes: attrs, group: "default", messages: [], meta: label ? { label: { id: 1, text: label, type: "info" } } : {}, type: "input" });
+1 -1
View File
@@ -11,7 +11,7 @@ import type { Plugin } from "../plugin-host/plugin.ts";
import { createI18n } from "../i18n/runtime.ts";
import type { MenuConfig } from "../ui/menu-config.ts";
import { loadI18n } from "../i18n/load.ts";
import { ecJwk, mintJwt } from "./app-harness.ts";
import { ecJwk, mintJwt } from "../test-tooling/app-harness.ts";
// ---- language (i18n) ----
+1 -1
View File
@@ -14,7 +14,7 @@ import type { Identity } from "../auth/kratos-admin.ts";
import { KratosError, type Session } from "../auth/kratos-public.ts";
import { SESSION_COOKIE } from "../auth/login.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import { ecJwk, fakeKeto, mintJwt, startApp, stubAdmin, withWhoami } from "./app-harness.ts";
import { ecJwk, fakeKeto, mintJwt, startApp, stubAdmin, withWhoami } from "../test-tooling/app-harness.ts";
// A test plugin exercising each RouteResult shape, a path param, and the permission gate.
const demoPlugin: Plugin = {
+1 -1
View File
@@ -14,7 +14,7 @@ import { CSRF_COOKIE } from "../auth/csrf.ts";
import { staticJwks } from "../auth/jwks.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
import { ENGLISH_LOCALS } from "../i18n/view-locals.ts";
import { ecJwk, session, startApp } from "./app-harness.ts";
import { ecJwk, session, startApp } from "../test-tooling/app-harness.ts";
const viewsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "views");
+1 -1
View File
@@ -3,7 +3,7 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import ejs from "ejs";
import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type RequestCsrf } from "./builtin-routes.ts";
import { type BuiltinRoute, matchBuiltinRoute, type PluginContextFactory, type RequestCsrf } from "./builtin-route.ts";
import { buildPluginChrome, type PageChrome } from "../ui/chrome.ts";
import { buildContext, type RequestContext, type User } from "./context.ts";
import { csrfCookie, ensureCsrfToken, verifyCsrfRequest } from "../auth/csrf.ts";
@@ -2,7 +2,7 @@
// router), method-distinct entries on the same path.
import { test } from "node:test";
import assert from "node:assert/strict";
import { type BuiltinRoute, matchBuiltinRoute } from "./builtin-routes.ts";
import { type BuiltinRoute, matchBuiltinRoute } from "./builtin-route.ts";
const entry = (method: "GET" | "POST", path: string): BuiltinRoute => ({ handler: () => null, method, path });
+24
View File
@@ -129,6 +129,30 @@ test("findConflicts: duplicate nav id is an error, a shared permission name only
assert.ok(permissionDup.some((c) => c.kind === "permission" && c.level === "warn"));
});
const noopHandler = () => {};
test("findConflicts: a route or nav node gating on a permission no plugin declares warns once per plugin and name, sorted", () => {
const gated = findConflicts([
p({ id: "a", nav: [{ children: [{ id: "a:x", label: "X", permission: "navonly:read" }], id: "a:root", label: "A", session: true }], routes: [
{ handler: noopHandler, method: "GET", path: "/x", permission: "routeonly:read" },
{ handler: noopHandler, method: "GET", path: "/y", permission: "both:read" },
{ handler: noopHandler, method: "POST", path: "/y", permission: "both:read" },
{ handler: noopHandler, method: "GET", path: "/z", permission: "declared:write" },
], permissions: [] }),
p({ id: "b", nav: [{ id: "b:y", label: "Y", permission: "both:read" }], permissions: [{ name: "declared:write" }] }),
]);
assert.ok(gated.every((c) => c.kind === "permission" && c.level === "warn"));
assert.deepEqual(gated.map((c) => c.plugins), [["a"], ["a"], ["a"], ["b"]]);
assert.deepEqual(gated.map((c) => /"([^"]+:[a-z]+)"/.exec(c.message)?.[1]), ["both:read", "navonly:read", "routeonly:read", "both:read"]);
});
test("findConflicts: a gate on a permission another plugin declares, or on none, stays quiet", () => {
assert.deepEqual(findConflicts([
p({ id: "a", permissions: [{ name: "a:read" }], routes: [{ handler: noopHandler, method: "GET", path: "/x", permission: "a:read" }] }),
p({ id: "b", routes: [{ handler: noopHandler, method: "GET", path: "/y", permission: "a:read" }, { handler: noopHandler, method: "GET", path: "/z", session: true }] }),
]), []);
});
test("findConflicts: each single slot (`home`/`dashboard`) may have one owner — two is a loud error", () => {
const handler = () => ({ html: "x" });
const homeDup = findConflicts([p({ id: "a", home: handler }), p({ id: "b", home: handler })]);
+26
View File
@@ -218,6 +218,8 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
if (owners.length > 1) out.push({ kind: "permission", level: "warn", message: `permission "${name}" declared by ${uniq(owners).length} plugins; pick a more specific "<resource>" unless shared on purpose`, plugins: uniq(owners) });
});
out.push(...undeclaredGates(plugins));
// Both the id's dashes and the key's camel humps become underscores, so plugin "a-b" key "c" and
// plugin "a" key "bC" name one variable — one plugin would silently read the other's value.
collect(plugins, (plugin, push) => {
@@ -229,6 +231,30 @@ export function findConflicts(plugins: Plugin[]): PluginConflict[] {
return out;
}
// Declaring is optional, but the admin screens offer only declared names: a route gated on one nobody
// declares answers 403 (a nav node is hidden) until the Keto tuple is written by hand or
// ADMIN_PERMISSIONS seeds it.
function undeclaredGates(plugins: Plugin[]): PluginConflict[] {
const declared = new Set(declaredPermissions(plugins).map((decl) => decl.name));
const out: PluginConflict[] = [];
for (const plugin of plugins) {
const gated = new Set<string>();
for (const route of plugin.routes ?? []) if (route.permission != null) gated.add(route.permission);
collectNavPermissions(plugin.nav, (name) => gated.add(name));
for (const name of [...gated].sort()) {
if (!declared.has(name)) out.push({ kind: "permission", level: "warn", message: `plugin "${plugin.id}" gates on "${name}", which no installed plugin declares, so the admin screens can't grant it; declare it in that plugin's \`permissions\``, plugins: [plugin.id] });
}
}
return out;
}
function collectNavPermissions(nodes: NavNode[] | undefined, push: (name: string) => void): void {
for (const node of nodes ?? []) {
if (node.permission != null) push(node.permission);
collectNavPermissions(node.children, push);
}
}
// Map each emitted key → the plugin ids that emitted it (repeats kept, so within-plugin dups count).
function collect(plugins: Plugin[], emit: (plugin: Plugin, push: (key: string) => void) => void): Map<string, string[]> {
const owners = new Map<string, string[]>();
@@ -1,12 +1,12 @@
import { generateKeyPairSync, sign, type JsonWebKey } from "node:crypto";
import type { AddressInfo } from "node:net";
import type { TestContext } from "node:test";
import { createApp } from "./app.ts";
import type { HydraAdmin } from "../auth/hydra-admin.ts";
import type { KetoClient, RelationTuple, SubjectSet } from "../auth/keto-client.ts";
import type { KratosAdmin } from "../auth/kratos-admin.ts";
import type { FlowType, KratosPublic } from "../auth/kratos-public.ts";
import { SESSION_COOKIE } from "../auth/login.ts";
import { createApp } from "../http/app.ts";
import type { Plugin } from "../plugin-host/plugin.ts";
// A session JWT signed with a throwaway test key; `staticJwks([ecJwk])` is the matching verify side.
@@ -7,7 +7,9 @@
// as false; media range and `or` syntax fail. A partial implementation counts as support, since MDN
// marks edge gaps that way, such as `outline` ignoring `border-radius` before Chrome 94. A
// vendor-prefixed name is one engine's by design and is skipped, as are `font-family` names: an engine
// skips a family it lacks.
// skips a family it lacks. A custom property's value is checked for functions and units, though not
// keywords: one a floor engine can't parse is invalid at computed-value time, so every declaration
// reading it through `var()` computes as `unset`, and a fallback declared before it doesn't help.
// Not checked: the body of an at-rule other than @media and @supports.
import bcd from "@mdn/browser-compat-data" with { type: "json" };
import type { CompatStatement, Identifier, SimpleSupportStatement, SupportBlock } from "@mdn/browser-compat-data";
@@ -114,23 +116,37 @@ function mediaQuery(sheet: Sheet, guards: string[], condition: string, offset: n
}
}
// The parts of an @supports condition that must hold for its block to apply: neither a `not (…)`
// group nor an operand of an `or`, at any depth.
// The parts of an @supports condition that must hold for its block to apply, as text a
// `Feature.guard` can search: the condition without its `not (…)` groups and without any group
// joined by `or`, at every depth.
function positive(condition: string): string {
let top = "";
let kept = "";
const { groups, tail } = parenGroups(condition);
if (OR.test(groups.map((g) => g.head).join("") + tail)) return "";
return groups.map(keepGroup).join("") + tail;
}
interface Group {
// The text before this group's "(", back to the previous group or the start of the condition.
head: string;
inner: string;
}
// The top-level parenthesised groups of `condition`, and the text after the last.
function parenGroups(condition: string): { groups: Group[]; tail: string } {
const groups: Group[] = [];
let from = 0;
for (let open = condition.indexOf("("); open >= 0; open = condition.indexOf("(", from)) {
const close = closing(condition, open);
const head = condition.slice(from, open);
const inner = condition.slice(open + 1, close);
top += head;
if (NEGATED.test(head)) kept += head.replace(NEGATED, "");
else if (/[\w-]$/.test(head)) kept += `${head}(${inner})`; // a function such as selector()
else kept += `${head}(${positive(inner)})`;
groups.push({ head: condition.slice(from, open), inner: condition.slice(open + 1, close) });
from = close + 1;
}
return OR.test(top + condition.slice(from)) ? "" : kept + condition.slice(from);
return { groups, tail: condition.slice(from) };
}
function keepGroup({ head, inner }: Group): string {
if (NEGATED.test(head)) return head.replace(NEGATED, "");
if (/[\w-]$/.test(head)) return `${head}(${inner})`; // a function such as selector(): its argument names the feature
return `${head}(${positive(inner)})`;
}
const OR = /(?<![\w-])or\s/i;
-21
View File
@@ -12,9 +12,6 @@
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 51 | 0.5.0 | | | **Move the test-only tooling out of the runtime folders `src/ui/` and `src/http/`, and keep the README file map true.** | 2 | 1 | 1 | 2 | 3 | code health by 4 | 18.0 |
| 52 | 0.5.0 | 51 | | **Make `positive` in `stylesheet-floor.ts` readable without tracing it by hand, and state why a custom property's value is checked.** | 2 | 1 | 1 | 2 | 3 | code health by 5 | 22.5 |
| 6 | 0.5.0 | | | **Warn at discovery when a route or nav node gates on a permission no plugin declares.** | 2 | 1 | 1 | 2 | 2 | 7 by 3, 9 by 3 | 8.0 |
| 23 | 0.5.0 | | | **Give list pages one view-model helper from query to pagination, instead of ~40 hand-written lines each.** | 3 | 1 | 2 | 4 | 5 | 1 by 4, 9 by 2, code health by 3 | 7.9 |
| 43 | 0.5.0 | | | **Let a plugin version the scripts and other `/public/` files its own markup links, so they are cached as immutable too.** | 2 | 2 | 1 | 3 | 3 | 8 by 3, 7 by 2 | 5.0 |
| 57 | 0.5.0 | | owner | **Put the caller's IP, user agent and host on every request's span and `request` log line, under OpenTelemetry's HTTP semantic conventions, reading the IP through a proxy only where configuration names it trusted.** | 2 | 2 | 2 | 4 | 3 | 1 by 3 | 3.3 |
@@ -35,24 +32,6 @@
## Details
### 51. Move the test-only tooling out of the runtime folders `src/ui/` and `src/http/`, and keep the README file map true.
`src/ui/stylesheet-floor.ts` imports a devDependency and only its test uses it; `src/http/app-harness.ts`
is a test fixture. Both sit beside runtime modules, and the README file map omits them. Seats also
named `src/http/builtin-routes.ts`, which holds no routes.
### 52. Make `positive` in `stylesheet-floor.ts` readable without tracing it by hand, and state why a custom property's value is checked.
Most scoring seats named `positive` (`:119`) with `walk` and `property` as opaque. Split it
into named steps, say in the header that an unsupported value in a custom property blanks every
`var()` that reads it, and keep the README's "Two browser floors" pointing here. It is the home of
the 3am answer.
### 6. Warn at discovery when a route or nav node gates on a permission no plugin declares.
Declaring stays optional on purpose, but the picker is built from declarations only, so such a route
is ungrantable from the GUI and answers a permanent 403. A warning, not an error.
### 23. Give list pages one view-model helper from query to pagination, instead of ~40 hand-written lines each.
Every list screen bridges `parseListQuery`/`paginate` to the partials by hand. At minimum a