Compare commits

..

10 Commits

Author SHA1 Message Date
lilleman e8dbb2a809 Record answers to the 0.4.21 questions
CI / full-gate (push) Successful in 4s
Mirror / github-mirror (push) Successful in 4s
2026-10-30 21:02:10 +01:00
lilleman 79f26491db Answer product-owner round 1: 0 🔴, 0 🟡 (0 misread); add a changelog entry
CI / full-gate (push) Successful in 3m2s
Mirror / github-mirror (push) Successful in 3s
2026-10-30 20:50:33 +01:00
lilleman 77b7e6143f Answer stability round 1: 0 🔴, 1 🟡 (0 misread); file the scheduling example's inset 2026-10-30 20:50:33 +01:00
lilleman 18b6a5a64a Answer prose round 1: 1 🔴, 0 🟡 (0 misread); name the boxes --pad-x governs 2026-10-30 20:50:33 +01:00
lilleman ad3bb3198e Answer security round 1: 0 🔴, 0 🟡 (0 misread); no findings 2026-10-30 20:50:33 +01:00
lilleman 74ebf2e7ec Merge remote-tracking branch 'origin/main' into pad-x-insets 2026-10-30 20:48:16 +01:00
lilleman 53e996394f Add a goal for users of the building blocks, and plan todo.md as a scored table
CI / full-gate (push) Successful in 4s
Mirror / github-mirror (push) Successful in 4s
2026-10-30 20:45:24 +01:00
lilleman 3491e660e9 Say the host stores nothing, rather than that no database exists
CI / full-gate (push) Successful in 4s
Mirror / github-mirror (push) Successful in 4s
2026-10-30 20:37:00 +01:00
lilleman 667d589401 State the project's goals and audience in the README
CI / full-gate (push) Successful in 4s
2026-10-30 20:32:28 +01:00
lilleman 5a968fd076 Inset the topbar, filter bar, pager, form page and empty table by --pad-x
CI / full-gate (push) Successful in 3m11s
2026-10-30 15:22:25 +01:00
5 changed files with 260 additions and 80 deletions
+1 -1
View File
@@ -367,7 +367,7 @@ docker compose -f compose.yml up --build -d # production
stack up and a *minimal* plugin live. Nothing comes before Quick start. Keep its commands
copy-pasteable; deeper detail lives in its own section, linked.
2. **Returning developer (rest).** A **Contents** ToC right after Quick start, then sections ordered
by **what an adopter reaches for first**, not by architectural layering: Overview → Users, groups
by **what an adopter reaches for first**, not by architectural layering: Goals → Audience → Overview → Users, groups
& permissions → Building plugins → menu/blocks/interactivity → Configuration → Auth → Email →
Architecture → Testing → Production → Observability → JWT-rotation runbook → Project-layout file
map → Extending. Place a new section by how early an adopter needs it. **Users, groups &
+8
View File
@@ -4,6 +4,14 @@ The release version **is** the plugin contract version (`HOST_API_VERSION`), so
contract break: a plugin's `apiVersion` must match the host's `major.minor` or discovery refuses it
at boot. Entries start at 0.3.0.
## Unreleased
### One horizontal inset across the content pane
The topbar, filter bar, pager, form page and empty-table message inset their content by `--pad-x`
(12px), as table cells do, instead of 16px. A plugin that overrides `--pad-x` now moves all of them
together.
## 0.4.0
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
+61 -5
View File
@@ -70,6 +70,8 @@ From here, render real pages against the app shell and fetch upstream data — s
## Contents
- [Goals](#goals)
- [Audience](#audience)
- [Overview](#overview)
- [Users, groups & permissions](#users-groups--permissions)
- [naming a permission](#naming-a-permission)
@@ -121,6 +123,62 @@ From here, render real pages against the app shell and fetch upstream data — s
- [Project layout](#project-layout)
- [Extending the core](#extending-the-core)
## Goals
1. **Achieve:** Less boilerplate for a server-rendered web GUI: a new project starts at
its own domain, not at sign-in, a menu or a table.
- Signal: a new plugin reaches a working, gated page with a menu, tables, filters and
forms built only from the core building blocks.
- Counter-signal: the core grows features only one project needs.
- Why: boilerplate removed by absorbing a project's domain only moves it into the core.
2. **Achieve:** Every building block works for the person using it: a non-technical user
gets what they meant on the first try, and can't hurt themselves by accident.
- Signal: bugs users hit in a shipped flow get fixed and stay fixed, such as a pasted
code being refused, a double click acting twice, or an admin locking themselves out.
- Counter-signal: a fix adds client-side JavaScript, or a setting every plugin has to
know about.
- Why: a fix that breaks zero-JS or simplicity moves the irritation from users to every
plugin author.
3. **Maintain:** Plainpages solves only the parts every project shares: auth, the GUI
building blocks (menu, tables, filters, forms) and the plugin structure. Everything
else is a plugin.
4. **Maintain:** Every page is a server-rendered document that works with zero JavaScript.
5. **Maintain:** State lives in the URL. Only state the URL can't carry lives in the
database, never in local storage or JavaScript. A cookie carries only what must be bound
to the browser: the session and its CSRF token.
6. **Maintain:** The frontend is semantic: the HTML says what each thing is, and the CSS
styles meaning, not looks. That keeps every page accessible.
7. **Achieve:** Hackable: a plugin can change anything, and Plainpages stays out of its way.
- Signal: a plugin replaces any page, partial, menu entry or string without forking the
host, and fewer plugins need a host change to get unblocked.
- Counter-signal: plugins break on upgrade with no version signal, or a plugin mistake
shows up at runtime instead of at boot.
- Why: freedom that breaks silently costs more than the boilerplate it saved.
8. **Achieve:** Speed: pages render fast and stay small.
- Signal: server time per page and bytes sent per page fall.
- Counter-signal: a building block loses a feature or accessibility to save bytes.
- Why: a fast page that does less only moves the work back into each plugin.
9. **Achieve:** LLM-friendly: an LLM builds a correct plugin from the README and the
examples alone.
- Signal: an agent given only those writes a plugin that passes discovery and its E2E
test on the first try.
- Counter-signal: the README grows to restate what the code already shows.
- Why: docs that pass the test by growing cost every later reader, human or LLM.
10. **Maintain:** Every behaviour is an explicit configuration choice, the same in every
environment.
## Audience
- **Developers starting a web GUI project, and the LLMs that build plugins with them.**
They may rely on `@plainpages/plugin-api`, the core partials and the packaging promises,
all versioned by `apiVersion`. A change that breaks a plugin takes a major bump.
- **Operators** who self-host Plainpages. They may rely on semver-tagged images, on
configuration through environment variables and the drop-in mounts (`plugins/`,
`config/`, `locales/`), and on upgrades within a minor version that need no change on
their side.
- **Visitors** to a site built on Plainpages. They may rely on pages that load fast and
work without JavaScript, with a keyboard and a screen reader.
## Overview
Plainpages gives you the boring-but-hard parts of a web app — a design system, a menu,
@@ -136,11 +194,9 @@ public front with an authenticated area behind it.
- **You add:** everything else domain-specific, as **plugins** — built from the same building
blocks the admin plugin uses.
**Priorities:** simplicity, few dependencies, strict TypeScript, no build step, Docker-only,
environment-agnostic (no `NODE_ENV` — every behaviour is an explicit config toggle). Identity,
sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar services. The shape
doesn't change as it grows: every plugin is the same self-contained folder, the hot path is the
same I/O-free JWT check, and there is no app database.
Identity, sessions, SSO, OAuth2 and permission checks are delegated to **Ory** sidecar
services. The shape doesn't change as it grows: every plugin is the same self-contained folder,
the hot path is the same I/O-free JWT check, and the host stores nothing of its own.
**Plugins are the extension model.** The plugin API is the product's main surface: powerful,
predictable, and overloadable — a plugin can take over as much of a page as it wants. The host
+6 -6
View File
@@ -58,7 +58,7 @@
--radius: 5px;
--nav-w: 264px;
--row-h: 34px;
--pad-x: 12px;
--pad-x: 12px; /* side padding of the content pane's bars and form page, and of every table cell, so their text lines up */
--fz: 13px;
--fz-sm: 12px;
--fz-xs: 11px;
@@ -336,7 +336,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.topbar {
flex: 0 0 auto; height: 48px;
display: flex; align-items: center; gap: 12px;
padding: 0 16px; border-bottom: 1px solid var(--border);
padding: 0 var(--pad-x); border-bottom: 1px solid var(--border);
background: var(--surface);
}
.hamburger { display: none; } /* shown only on narrow */
@@ -352,7 +352,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
.filters {
flex: 0 0 auto; background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 10px 16px;
padding: 10px var(--pad-x);
display: flex; flex-direction: column; gap: 9px;
}
.filter-row {
@@ -584,7 +584,7 @@ table.table {
.col-num { text-align: right; }
.cell-strong { font-weight: 550; }
.cell-muted { color: var(--text-muted); }
.table-empty { text-align: center; color: var(--text-muted); padding: 28px 12px; }
.table-empty { text-align: center; color: var(--text-muted); padding: 28px var(--pad-x); }
.cell-mono { font-variant-numeric: tabular-nums;
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); }
@@ -648,7 +648,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
.pager {
flex: 0 0 auto;
display: flex; align-items: center; gap: 12px;
padding: 8px 16px; border-top: 1px solid var(--border);
padding: 8px var(--pad-x); border-top: 1px solid var(--border);
background: var(--surface); font-size: var(--fz-sm); color: var(--text-muted);
}
.pager .spacer { flex: 1 1 auto; }
@@ -696,7 +696,7 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
#nav-toggle { position: fixed; top: 0; left: 0; opacity: 0; pointer-events: none; }
/* admin forms: create/edit user, account actions */
.form-page { padding: 16px; display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
.form-page { padding: 16px var(--pad-x); display: flex; flex-direction: column; gap: 14px; max-width: 560px; }
.form-card {
display: flex; flex-direction: column; gap: 14px;
padding: 18px; background: var(--surface);
+184 -68
View File
@@ -1,75 +1,191 @@
# Primary todo
# todo
## Unfinnished work
## Scoring
- [ ] **Publish every release to Docker Hub, and give the Hub page its short description.** Today Hub's newest tag is 0.4.0 (2026-09-10), while git has v0.4.1–v0.4.12, and the overview still says 0.4.0. No `release.yml` run exists for the tags auto-release pushes as `renovate-bot`, so neither the images nor the overview reach Hub; find out why Gitea doesn't fire `push: tags` for them, fix it, and backfill the missing tags. The short description is empty. fejkdata already did this (`../fejkdata`, commits `51d924a` and `34c8234`), and two parts port directly: a `dockerhub/description.txt`-style one-liner, which here can ride the existing PATCH as its `description` field; and a unit test that the description opens a README sentence and every command the overview shows appears in the README. fejkdata also syncs the page on any push to `main` that changes it, with its own Read/Write/Delete token in an environment only `main` may use, so the image-push token needs no Delete scope. Decide whether that fits here, where `{{VERSION}}` must render a released tag.
- [ ] Rename the plugin "admin" to something less generic, like "auth-admin" or "users-groups-admin".
- [ ] Guard the group paths against self-lockout, or accept them explicitly. The self-revoke guard covers only your own *direct* grants on the Users screen; unticking a permission on a group you belong to, removing yourself from that group, or deleting it can all still strip your own effective access with no warning. Recorded in AGENTS.md as a known gap — the robust fix is a "last effective holder" check, which needs a reverse Keto query.
- [ ] The permission picker has no concurrency baseline, so two operators editing the same user/group silently discard each other's change. Sketch: post the rendered set as a hidden baseline; if it no longer matches Keto, re-render with "this changed while you had the page open" rather than applying.
- [ ] A grant whose plugin was uninstalled is invisible and unremovable in the GUI. `grantDiff` deliberately never revokes an undeclared name, but nothing *shows* it either — so it can't be audited or cleaned, and reinstalling that plugin silently reactivates access nobody remembers granting. Sketch: a read-only "held, but no installed plugin offers this" list with a remove action.
- [ ] A plugin may gate a route on a permission it never declares — declaring stays optional on purpose. The cost is a dead end: the picker is built from declarations only, so that route is ungrantable from the GUI with no boot error and a permanent 403 as the operator's only clue. Sketch: a discovery *warning* (not an error) naming the gated-but-undeclared permission.
- [ ] Saving permissions gives no confirmation, and a partial failure is silent. `applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a half-applied set behind the generic error page; and a successful save is indistinguishable from "nothing changed". The `alert alert-pos` pattern the recovery-code banner uses is already available.
- [ ] In Playwright tests, try different resolutions and sizes, from BIG desktop down to tiny phone.
- [ ] Decide whether `e2e-tests/` should be typechecked. It is outside `tsconfig.include`, so the gate never checks its most logic-bearing file (`console-guard.ts`). Including it needs the DOM lib and `@playwright/test` present wherever `npm run typecheck` runs, which today is the `web` image that installs neither.
- [ ] Decide whether Playwright's `workers` should be pinned. Unset, it sizes the pool from `os.cpus()`, which reports the host's cores regardless of a container CPU quota — and with `retries: 0` a starved runner turns a slow test straight into a red gate. Fine on the current act_runner; revisit if CI ever runs constrained.
- [ ] Record the browser floor Plainpages requires, and whether the fallback is the contract or a courtesy. The stylesheet needs `:has()` (Dec 2023); the menus need the popover API (Safari 17) and CSS anchor positioning for placement (newer still, and unguarded — the `@supports` test covers popover only). An iPadOS 16 tablet, capped at Safari 16, therefore gets panels flowing inline rather than working menus. Either state a supported floor or accept the fallback for those devices; nobody has rendered that path on real hardware.
- [ ] Decide whether the profile dropdown still earns a dropdown. It holds one item, Sign out, behind a click, and its "Signed in as X" head repeats what the trigger already shows.
- [ ] Trim whitespace around the verification code — a copy+pasted code from the email fails, today as a browser `pattern` refusal rather than a Kratos rejection, now carrying a digits-only hint so that refusal isn't bare. The paste still fails. **Own session.** No zero-JS fix exists: `pattern` is rejective and cannot transform, so loosening it only lets the untrimmed value through. The real fix is a host-side proxy of the flow POST, which is feasible and half-built — `submitFlow` (`src/auth/kratos-public.ts:132`) already relays cookies and normalises a 422 `redirect_browser_to`, and has no callers outside tests. CSRF improves rather than breaks: the host already writes Kratos' cookie onto its own origin (`src/auth/routes.ts:77`), so the POST becomes same-origin, and `form-action 'self'` could finally join the CSP. Two risks: one `flow.ui.action` serves all five flows, so this rewrites the sign-in path, not just code entry; and Go's nosurf checks Referer only over https, which no test here covers. **Check first:** `kratos-admin.ts:75` returns a `recovery_link` alongside the code — if the stock courier mail carries one (unverified), a template change fixes the UX with no host code. Code entry has no E2E coverage today either way.
- [ ] Guard against the double-clicked submit, without client-side JavaScript. A non-technical user clicks a button twice when nothing happens fast enough, so a second identical POST is an expected event, not misuse — today it creates two users, mints two recovery codes, or registers two OAuth2 clients. Constraints: HTML/CSS only, and it must not break an action that is *legitimately* repeatable. Sketch: a CSS-only affordance so the second click has nothing to hit, paired with the host recognising a duplicate on the server — same session, route and payload within a short window — then logging and dropping it. Open questions: what identifies "the same submission" (a one-time token minted into each rendered form beats hashing the payload, and the CSRF plumbing already mints per-request tokens), the window length, where the record lives given the app is stateless, and how a plugin declares a route repeatable.
- [ ] Decide the caching contract for rendered pages. Responses carry `Vary: Accept-Language` but nothing sets `Cache-Control`, so a shared cache has no instruction and a signed-in page is not marked `private`. Either set the headers deliberately (public cacheable, gated `private, no-store`) or record in AGENTS.md that the reverse proxy owns this.
- [ ] **Cache every stylesheet and script in the browser until its content changes, so a page view downloads none of them again.** Nothing under `/public/` carries a `Cache-Control` header (`serveStatic`, `src/http/static.ts`), so every page view downloads `styles.css`, `auth.css` and each plugin's stylesheet again: about 50 kB on a plugin's issue page. On a GPRS connection that took seconds per page (measured in Firefox, 2026-10-30), and in those seconds a browser extension that forces layout early makes Firefox paint the page unstyled ("Layout was forced before the page was fully loaded"). Sketch: put a content version in each asset's URL, as a query (`?v=<hash of the file>`) or in the path, and answer a versioned URL with `Cache-Control: public, max-age=31536000, immutable`, so a release still reaches every browser at once. The shell's `<link>`s, the error pages' and the `styles` and script hrefs a plugin passes in (`views/partials/shell.ejs`) all need to come out versioned, so a plugin names its asset once and the host adds the version. Open: whether the host versions a plugin's hrefs itself or hands plugins a helper, and what an unversioned `/public/` URL answers (a short `max-age` with revalidation is the safe default). Requested by plainpages-plugin-fastjira, whose item 142 waits on the release that ships this.
- [ ] Decide whether the single generic Keto `Resource` namespace should become per-domain namespaces (`Shift`, `Document`, …), as Ory's own examples model it. One global `Resource` bucket is the project's own "no catch-all names" rule applied to namespaces. A design question, not a naming one.
- [ ] Decide what `ICON_NAMES` (`src/ui/icons.ts`) actually is. `i-chart`, `i-copy`, `i-download` and `i-sliders` have no caller anywhere — so either they go, or the comment should say the palette is curated and may carry an id ahead of its first use. Not cosmetic: the sprite is inlined into every page, and the rule decides whether a future removal is routine cleanup or a plugin-facing regression.
- [ ] Decide (once) whether the CSRF token staying unbound to `sub`/session is accepted. `src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so any validly-signed token passes for any user — an attacker who can write cookies on the origin can fix a token they know. Standard for unbound signed double-submit and plausibly fine behind `SameSite=Lax` + HSTS. Accepted ⇒ record it in AGENTS.md and README → Security model; not accepted ⇒ bind the nonce to `sub`.
- [ ] Verify the documented Docker commands on macOS and fix whatever misbehaves — **macOS is a supported dev host**, but nothing here has been run on one. Two suspects, both from the `--user "$(id -u):$(id -g)"` idiom: a macOS `id -g` is `20`, which is `dialout` inside the noble image rather than a user group, and Docker Desktop remaps bind-mount ownership in its own VM layer. The same question covers rootless Docker, where README already says to *drop* the flag.
- [ ] Map Kratos' 401 on a self-service flow init, so an anonymous `GET /settings` with no `?flow` renders instead of 500ing. `flowPage` (`src/auth/routes.ts`) maps 403/404/410 → restart the flow, 400 `session_already_available` → `/auth/complete`, and ≥500 → the themed 503, then rethrows everything else — and Kratos answers the settings-flow init with 401 when there is no session. `/settings` is correctly `public` (the recovery flow lands there with a live Kratos session but no app JWT), so the gate is not the fix: a 401 should redirect to `/login` with the page as `return_to`. No E2E covers an anonymous hit on a flow page that needs a session.
`Score = 100 * V / (2**S + 8)`
### Architectural review findings (2026-07-02)
`Bar = 1`
Prioritized. Overall verdict: architecture is sound; these are refinements.
`Next ID = 29`
- [ ] **MEDIUM — Add complexity/method-size static analysis to the CI gate.** Only `tsc --strict` today; a size/complexity rule would have caught the `app.ts` growth.
- [ ] **LOW — The users list offers a pencil "Edit" row action to a `users:read` holder.** The link is harmless (it opens the read-only detail page), but the label contradicts what the reader can do. Needs `canWrite` threaded into `listTable` plus a `common.view` core catalog key and an `i-eye` entry in `ICON_NAMES`.
- [ ] **MEDIUM→LOW — Add a list-page view-model helper in `src/ui/`.** Every list screen hand-rewrites the same ~40 lines bridging `parseListQuery`/`paginate` to the EJS partials; at minimum a `buildPaginationModel(page, hrefFor)` block.
- [ ] **LOW→MEDIUM — Retire `src/ui/shell-context.ts`.** `ShellModel`/`buildShellContext` has one consumer left (dashboard) and duplicates `PageChrome` on almost every field. Fold the dashboard onto `ctx.chrome` + title/breadcrumbs; keep `shellUser` as the shared primitive.
- [ ] **LOW — Fix stale doc references to removed `docs/plugin-contract.md`** in `views/index.ejs` (user-visible dashboard text; also links /scheduling as if pre-installed) and `examples/plugins/scheduling/views/shifts.ejs`.
- [ ] **LOW — Decide (once) on a `ctx.system` facade.** `@plainpages/plugin-api` exposes raw Ory client shapes, so an Ory client refactor is a major `apiVersion` bump. AGENTS.md accepts this; revisit only if external plugin authors appear.
- [ ] **LOW — README/AGENTS.md gaps:** state the intended lifetime/horizon explicitly, add a short domain glossary (host, manifest, chrome, nav fragment, permission token, system plugin, denylist…), and note the expected plugin-author population and plugin count — per-plugin dependency isolation prices N dependency trees on disk and in RSS, and that number is what says whether the trade needs revisiting (build-time dedupe for baked images stays open).
## Items
## Finnished work
| ID | Release | Blocked by | Exempt | Item | L | E | I | R | S | Moves | Score |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 11 | 0.4.21 | | question | **Record the browser floor Plainpages supports, and whether the fallback below it is a contract or a courtesy.** | 1 | 1 | 1 | 1 | 2 | 2 by 2, 1 by 1 | 4.3 |
| 19 | 0.4.21 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
| 15 | 0.4.21 | | security | **Set `Cache-Control` on every rendered page from the host: `private, no-store` on a gated or signed-in page, a cacheable value on a public one.** | 2 | 2 | 2 | 4 | 3 | 8 by 3 | 3.0 |
| 26 | 0.4.21 | | | **Add a domain glossary and the project's intended horizon to the README.** | 1 | 2 | 1 | 2 | 3 | 9 by 3, code health by 2 | 11.9 |
| 8 | 0.4.21 | | | **Run the visual E2E specs at viewports from a large desktop down to a small phone.** | 2 | 1 | 1 | 2 | 4 | code health by 3 | 9.0 |
| 9 | 0.4.21 | | | **Typecheck `e2e-tests/` in the gate.** | 1 | 1 | 1 | 1 | 3 | code health by 2 | 9.0 |
| 24 | 0.4.21 | | | **Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.** | 2 | 1 | 2 | 3 | 4 | code health by 3 | 9.0 |
| 17 | 0.4.21 | | | **Remove the icons no caller uses from `ICON_NAMES` and the sprite.** | 1 | 1 | 1 | 1 | 1 | 8 by 1, code health by 1 | 8.8 |
| 10 | 0.4.21 | | | **Pin Playwright's `workers` to a fixed number.** | 1 | 1 | 1 | 1 | 1 | code health by 1 | 7.2 |
| 18 | 0.4.21 | | | **Record in AGENTS.md and README → Security model that the CSRF token is deliberately unbound to the session, and why.** | 1 | 1 | 1 | 1 | 1 | code health by 1 | 7.2 |
| 21 | 0.4.21 | | | **Fail the gate on a function that exceeds a size or complexity limit.** | 3 | 1 | 1 | 3 | 5 | code health by 4 | 7.2 |
| 27 | 0.4.21 | | | **Inset the scheduling example's lead text, shift count, alerts and call-to-action by `--pad-x`, like the bars around them.** | 1 | 2 | 1 | 2 | 1 | 9 by 2, 2 by 1 | 4.8 |
| 25 | 0.4.21 | | | **Point the views' references to `docs/plugin-contract.md` at the README.** | 1 | 2 | 1 | 2 | 1 | 9 by 3 | 4.6 |
| 20 | 0.4.21 | | | **Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.** | 2 | 2 | 2 | 4 | 3 | 2 by 4 | 4.2 |
| 4 | 0.4.21 | | | **Refuse a permission save whose baseline no longer matches Keto, so concurrent edits are not silently lost.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 |
| 7 | 0.4.21 | | | **Confirm a successful permission save, and report a partial failure instead of hiding it.** | 2 | 2 | 2 | 4 | 4 | 2 by 4 | 2.8 |
| 22 | 0.4.21 | | | **Offer a `users:read` holder a View row action on the users list, not Edit.** | 2 | 2 | 1 | 3 | 3 | 2 by 2 | 2.1 |
| 3 | 0.4.21 | | | **Guard the group paths against self-lockout, or accept the gap explicitly.** | 3 | 2 | 2 | 5 | 6 | 2 by 6 | 1.4 |
| 1 | 0.4.21 | | owner | **Publish every release to Docker Hub, and give the Hub page its short description.** | 3 | 3 | 2 | 6 | 5 | 1 by 3 | 1.3 |
| 13 | 0.4.21 | | | **Accept a verification code pasted with surrounding whitespace.** | 4 | 2 | 3 | 7 | 7 | 2 by 8 | 1.0 |
| 16 | 0.5.0 | | question | **Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.** | 3 | 2 | 3 | 6 | 6 | 7 by 2 | 0.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 |
| 28 | 0.5.0 | | | **Let a plugin add entries to the profile menu through the plugin API.** | 2 | 2 | 2 | 4 | 4 | 7 by 4, 1 by 2 | 4.2 |
| 2 | 0.5.0 | | | **Rename the admin example plugin to a name that says what it administers.** | 3 | 1 | 2 | 4 | 6 | 9 by 3, code health by 2 | 2.6 |
| 5 | 0.5.0 | | | **List grants that no installed plugin declares, with a remove action.** | 2 | 2 | 2 | 4 | 4 | 2 by 3 | 2.1 |
| 14 | 0.5.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 |
- [x] Configure a plugin at install time: the manifest declares `settings`, the operator sets one `PLUGIN_SETTING_<ID>_<KEY>` variable per key, and the resolved values arrive on `onBoot` typed from the declaration. Read-only admin screen at `/admin/plugin-settings`.
- [x] Give a plugin persistent storage: `storage: true` provisions a Postgres database + login role named `plugin_<id>`, credentials arrive on `onBoot`, passwords are derived from `PLUGIN_DB_SECRET` rather than stored.
- [x] Refuse a stray `package.json`/`node_modules` in `config/` by name, as plugin folders already are.
- [x] Let Renovate reach the example plugins' manifests (`ignorePaths` overrides `config:recommended`).
- [x] The seeded admin is granted each permission once — `seedPermissions` dedupes and the grant PUT is idempotent.
- [x] Run the E2E runner as the invoking user so its artifacts aren't root-owned.
- [x] Install node_modules above `WORKDIR /app` so no mount leaves a root-owned dir in the checkout.
- [x] Enforce `<resource>:<action>` permission names at discovery; split `admin` per screen.
- [x] Make the declared-permission catalog the fixed list; delete the Permissions screen, move granting onto Users and Groups.
- [x] Fail a Playwright test on any browser console warning, error or uncaught exception, in every engine.
- [x] Skip the CI gate when only markdown changed.
- [x] Replace the `<details>` popup menus with the Popover API so an outside click dismisses them.
- [x] Organize the files in src into folders.
- [x] Move docs/plugin-contract.md into README.md and remove the docs folder.
- [x] Move the scheduling example out of `plugins/` into `examples/`.
- [x] Make `config/` an empty drop-in mount with the defaults as fallback.
- [x] Turn the built-in admin pages into a drop-in example plugin.
- [x] CI/CD — test on push to any branch except main.
- [x] CI/CD — require a PR to main, gated on a green build, fast-forward-only.
- [x] CI/CD — force-push mirror to GitHub after every merge to main.
- [x] CI/CD — build and push the app image, tagged with the commit hash, as part of the gate.
- [x] CI/CD — re-tag the hash image to semver on a `vX.Y.Z` tag.
- [x] CI/CD — sync released tags to Docker Hub.
- [x] Write README-dockerhub.md for the Docker Hub overview.
- [x] CI/CD — set up the Renovate bot.
- [x] CI/CD — give Renovate a read-only `GITHUB_COM_TOKEN` so github.com lookups aren't rate-limited.
- [x] CI/CD — auto-release on Renovate updates, versioned from the `Release-Bump:` trailer.
- [x] Add an e2e test for the admin plugin's OAuth2-clients (Hydra) screen.
- [x] Document the auth security model in the README.
- [x] Add i18n support.
- [x] Settle the identity-vs-user vocabulary.
- [x] Use one uniform verb per action in the English UI (sign in / sign out / create account).
- [x] Remove the dead "Profile" link from the sidebar profile menu.
- [x] Remove the unspecified "Settings"/"Preferences" cog from the sidebar footer.
- [x] **HIGH — Split `handleRequest` in `src/http/app.ts`** — extract the built-in endpoints into named handlers on an internal route table.
## Details
### 11. Record the browser floor Plainpages supports, and whether the fallback below it is a contract or a courtesy.
The stylesheet needs `:has()` (Dec 2023); the menus need the popover API (Safari 17) and CSS anchor
positioning for placement, which is newer and unguarded, since the `@supports` test covers popover
only. An iPadOS 16 tablet gets panels flowing inline rather than working menus. Nobody has rendered
that path on real hardware.
### 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
`dialout` inside the noble image, and Docker Desktop remaps bind-mount ownership in its VM.
### 15. Set `Cache-Control` on every rendered page from the host: `private, no-store` on a gated or signed-in page, a cacheable value on a public one.
The host owns it, not the reverse proxy. Today responses carry `Vary: Accept-Language` but no
`Cache-Control`, so a shared cache in front of `web` may store a signed-in page, with that user's
data, and serve it to the next visitor.
### 26. Add a domain glossary and the project's intended horizon to the README.
Glossary: host, manifest, chrome, nav fragment, permission token, system plugin, denylist. Also the
expected plugin count, since per-plugin dependency isolation prices N dependency trees on disk and
in RSS.
### 24. Retire `src/ui/shell-context.ts` and render the dashboard from `ctx.chrome`.
`ShellModel`/`buildShellContext` has one consumer, the dashboard, and duplicates `PageChrome` on
almost every field. Keep `shellUser` as the shared primitive.
### 17. Remove the icons no caller uses from `ICON_NAMES` and the sprite.
`i-chart`, `i-copy` and `i-download` have no caller, and the sprite is inlined into every page.
AGENTS.md → "`ICON_NAMES` is a host-owned registry" already makes this routine cleanup.
### 18. Record in AGENTS.md and README → Security model that the CSRF token is deliberately unbound to the session, and why.
`src/auth/csrf.ts` signs `<nonce>.<HMAC(secret, nonce)>` with no session binding, so an attacker who
can write cookies on the origin can fix a token they know. Accepted: it is standard for unbound
signed double-submit, and `SameSite=Lax` plus HSTS cover it. Valid while the session cookie stays
`SameSite=Lax` or stricter.
### 21. Fail the gate on a function that exceeds a size or complexity limit.
Only `tsc --strict` runs today. A new tool is a new dependency, so justify it.
### 27. Inset the scheduling example's lead text, shift count, alerts and call-to-action by `--pad-x`, like the bars around them.
They sit flush against the content pane's edge while the filter bar, table and pager sit 12px in.
The fix belongs in `examples/plugins/scheduling/public/scheduling.css`; check each page in a browser.
### 25. Point the views' references to `docs/plugin-contract.md` at the README.
`examples/plugins/scheduling/views/shifts.ejs` still cites it.
### 20. Redirect an anonymous hit on a session-needing flow page to sign-in, instead of a 500.
`flowPage` (`src/auth/routes.ts`) rethrows a 401, which Kratos answers to a settings-flow init with no
session. `/settings` stays `public`, since recovery lands there with a Kratos session but no app JWT;
a 401 should redirect to `/login` with the page as `return_to`. Add the missing E2E.
### 4. Refuse a permission save whose baseline no longer matches Keto, so concurrent edits are not silently lost.
Sketch: post the rendered set as a hidden baseline; when it no longer matches Keto, re-render with
"this changed while you had the page open" rather than applying.
### 7. Confirm a successful permission save, and report a partial failure instead of hiding it.
`applyGrants` loops writes then deletes with no transaction, so a Keto error midway leaves a
half-applied set behind the generic error page. The `alert alert-pos` pattern of the recovery-code
banner is available.
### 22. Offer a `users:read` holder a View row action on the users list, not Edit.
The pencil opens the read-only detail page, so it is harmless, but the label contradicts what the
reader can do. Needs `canWrite` threaded into `listTable`, a `common.view` core catalog key and an
`i-eye` icon.
### 3. Guard the group paths against self-lockout, or accept the gap explicitly.
The self-revoke guard covers only your own direct grants on the Users screen. Unticking a permission
on a group you belong to, leaving that group, or deleting it can still strip your own access with no
warning. AGENTS.md records this as a known gap. The robust fix is a "last effective holder" check,
which needs a reverse Keto query.
### 1. Publish every release to Docker Hub, and give the Hub page its short description.
Hub's newest tag is 0.4.0, while git has v0.4.1 onwards, and the overview still says 0.4.0. No
`release.yml` run exists for the tags auto-release pushes as `renovate-bot`; find out why Gitea
doesn't fire `push: tags` for them, fix it, and backfill the missing tags. fejkdata did this
(`../fejkdata`, commits `51d924a` and `34c8234`), and two parts port directly: a one-line
description riding the existing PATCH as its `description` field, and a unit test that the
description opens a README sentence and every command the overview shows appears in the README.
fejkdata also syncs the page on any push to `main`, with its own token in an environment only `main`
may use; decide whether that fits here, where `{{VERSION}}` must render a released tag.
### 13. Accept a verification code pasted with surrounding whitespace.
A pasted code fails the browser's `pattern` check. No zero-JS fix exists: `pattern`
cannot transform, so loosening it only lets the untrimmed value through to Kratos. **Check first:**
`kratos-admin.ts` returns a `recovery_link` alongside the code; if the stock courier mail can carry
one, a template change fixes this with no host code. Otherwise proxy the flow POST through the host:
`submitFlow` (`src/auth/kratos-public.ts`) already relays cookies and normalises a 422
`redirect_browser_to`, and has no callers outside tests. CSRF improves, since the POST becomes
same-origin and `form-action 'self'` can join the CSP. Risks: one `flow.ui.action` serves all five
flows, so this rewrites the sign-in path; and Go's nosurf checks Referer only over https, which no
test covers. Code entry has no E2E coverage today.
### 16. Decide whether the single Keto `Resource` namespace splits into per-domain namespaces.
Ory's own examples model `Shift`, `Document` and so on. One global `Resource` bucket is the
project's "no catch-all names" rule applied to namespaces.
### 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
`buildPaginationModel(page, hrefFor)`. Exported through the barrel, so it is contract surface.
### 28. Let a plugin add entries to the profile menu through the plugin API.
The profile dropdown stays a dropdown: Sign out is its only entry today, and it will gain more.
`views/partials/shell.ejs` hand-rolls it (AGENTS.md → UI, the dropdown rule), so the entries need a
manifest field that discovery checks, gated like a nav node.
### 2. Rename the admin example plugin to a name that says what it administers.
Candidates: `auth-admin`, `users-groups-admin`. The id is also the mount path, so the compose files,
E2E stacks and README move with it.
### 5. List grants that no installed plugin declares, with a remove action.
`grantDiff` never revokes an undeclared name, and nothing shows one either, so it can't be audited or
cleaned, and reinstalling its plugin silently reactivates access nobody remembers granting.
### 14. Drop a double-clicked duplicate submit, with no client-side JavaScript.
A second identical POST is expected from a non-technical user, and today creates two
users, mints two recovery codes, or registers two OAuth2 clients. It must not break a legitimately
repeatable action. Sketch: a CSS-only affordance so the second click has nothing to hit, plus the
host recognising a duplicate (same session, route and payload within a short window), logging and
dropping it. Open: what identifies "the same submission" (a one-time token minted into each form
beats hashing the payload), the window, where the record lives given the host is stateless, and how
a plugin declares a route repeatable.