Compare commits
90 Commits
9912dd64f1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 6f11a9928b | |||
| 070d745a4b | |||
| 9370fb36f2 | |||
| 3c5c0c911c | |||
| 44a777dc3f | |||
| 1dfc6aac3a | |||
| 0c0a3bc905 | |||
| 935674bc49 | |||
| e8dbb2a809 | |||
| 79f26491db | |||
| 77b7e6143f | |||
| 18b6a5a64a | |||
| ad3bb3198e | |||
| 74ebf2e7ec | |||
| 53e996394f | |||
| 3491e660e9 | |||
| 667d589401 | |||
| 5a968fd076 | |||
| b81c136fe5 | |||
| c9e25f2852 | |||
| 603e29e8f1 | |||
| 055bfd6506 | |||
| 80cc692634 | |||
| ebf34deda4 | |||
| 937a0739ca | |||
| 44813be8a7 | |||
| 785230673b | |||
| 80981eb791 | |||
| 0097481b4c | |||
| b09f8b7eb6 | |||
| fb464acf54 | |||
| 8220b34fd0 | |||
| 194557fb49 | |||
| 4a0fc2b493 | |||
| 946c45663f | |||
| 41ab968b20 | |||
| ff548a30d2 | |||
| f80c6c6426 | |||
| 3658797764 | |||
| 23f1158211 | |||
| bf9302ce8e | |||
| 2ed134fc86 | |||
| e07edeb6ae | |||
| e9cd7ba2b3 | |||
| a49bd4f7b0 | |||
| e680552961 | |||
| 12977bc296 | |||
| dac189b124 | |||
| c9e2f1a9e4 | |||
| 7a17ec11de | |||
| ec1579a039 | |||
| 153e77174d | |||
| 6e21eb2d4b | |||
| d8a31def52 | |||
| 5b49bce690 | |||
| 584a383d22 | |||
| 28dbc1a0ff | |||
| 0f0a842f34 | |||
| 88c5357268 | |||
| 3bd9adc73b | |||
| 51c95b32c1 | |||
| 3593b39e43 | |||
| 5f9d74ae4f | |||
| 79cee25b66 | |||
| 2993b462a1 | |||
| 7645f0302a | |||
| e5531a44cd | |||
| 3b1cd891ed | |||
| f06040b511 | |||
| 58767cb53e | |||
| 16873662b9 | |||
| 53ab35bfb8 | |||
| 60aa8a888e | |||
| 2f29853121 | |||
| 41aa59d05d | |||
| cb4f8d6e98 | |||
| 696da397e7 | |||
| 7f2c0cc2c2 | |||
| b9129fba08 | |||
| cb4468ff77 | |||
| 5ffa682fe7 | |||
| fbc1633a1a | |||
| bebefeafde | |||
| 465055e699 | |||
| db56a32e29 | |||
| 51219a3ee5 | |||
| f5c3d93837 | |||
| ea320f6a28 | |||
| d3151f222a | |||
| 54956fe627 |
@@ -19,4 +19,4 @@ jobs:
|
||||
run: |
|
||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||
-e REGISTRY_TOKEN -e REGISTRY_USER -e REPO_TOKEN -e REPOSITORY -e SERVER_URL \
|
||||
node:24.20.0-alpine3.24 node registry-cleanup/cleanup.ts
|
||||
node:24.22.2-alpine3.24 node registry-cleanup/cleanup.ts
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
GIT_TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
|
||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||
node release-tooling/contract-version.ts "$GIT_TAG" src/plugin-host/plugin.ts
|
||||
- name: Promote the commit-hash image to semver + latest
|
||||
env:
|
||||
@@ -98,9 +98,9 @@ jobs:
|
||||
VERSION=${INPUT_VERSION:-${GIT_TAG#v}}
|
||||
VERSION=${VERSION#v}
|
||||
# An empty dispatch input falls back to the branch name, so gate this like a tag.
|
||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
|
||||
docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||
node release-tooling/contract-version.ts "$VERSION" src/plugin-host/plugin.ts
|
||||
docker run --rm -v "$PWD:/repo" -w /repo \
|
||||
-e DOCKERHUB_REPO -e DOCKERHUB_TOKEN -e DOCKERHUB_USER \
|
||||
node:24.20.0-alpine3.24 \
|
||||
node:24.22.2-alpine3.24 \
|
||||
node release-tooling/dockerhub-overview.ts "$VERSION"
|
||||
|
||||
@@ -21,7 +21,7 @@ jobs:
|
||||
-e RENOVATE_PLATFORM=gitea \
|
||||
-e RENOVATE_REPOSITORIES=${{ github.repository }} \
|
||||
-e RENOVATE_TOKEN \
|
||||
renovate/renovate:44.59.3
|
||||
renovate/renovate:44.186.6
|
||||
|
||||
# After the renovate job, cut ONE tag covering the renovate-bot commits merged to main since the
|
||||
# last tag (batch per run). Targets origin/main — the real post-merge tip; the checkout SHA is the
|
||||
@@ -58,11 +58,11 @@ jobs:
|
||||
if [ -z "$BUMPS" ]; then
|
||||
echo "Renovate commits since ${LATEST}, but none carry Release-Bump — nothing reached a running Plainpages; skipping"; exit 0
|
||||
fi
|
||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
|
||||
NEXT=$(docker run --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||
node release-tooling/next-version.ts "$LATEST" $BUMPS)
|
||||
# Read the constant off origin/main, not the checkout, which lags the merges this run made.
|
||||
git show origin/main:src/plugin-host/plugin.ts \
|
||||
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.20.0-alpine3.24 \
|
||||
| docker run -i --rm -v "$PWD:/repo" -w /repo node:24.22.2-alpine3.24 \
|
||||
node release-tooling/contract-version.ts "$NEXT" -
|
||||
echo "Releasing $LATEST -> $NEXT"
|
||||
git tag "$NEXT" origin/main
|
||||
|
||||
@@ -36,6 +36,15 @@ branch, create a PR and merge it when the CI/CD turns green.
|
||||
## Project priorities (do not erode)
|
||||
|
||||
1. **Simplicity** — prefer the solution that is easiest to understand, smallest, and most readable.
|
||||
**A page is a document**: it scrolls, and the chrome scrolls with it. Nothing may bound the
|
||||
viewport to hold content still — no `height: 100dvh` frame, no `overflow: hidden` on `body`, no
|
||||
`position: sticky` header. Each such box buys an app-like look with CSS the next reader has to
|
||||
reverse-engineer, and is one more thing to undo before the content under it can be reached.
|
||||
Overlays are not this: the skip link, the mobile off-canvas nav and its scrim sit *above* the
|
||||
document rather than holding it still, and have no other spelling — the document keeps scrolling
|
||||
behind the open nav, accepted rather than overlooked. Only the document scroller gets keyboard
|
||||
paging unconditionally and back/forward scroll restoration, and a page a box clips fails silently:
|
||||
nothing in a test or a console says content is unreachable below the fold.
|
||||
2. **Few dependencies** — runtime deps stay minimal (today `ejs`, `lucide-static`, `@larvit/log`,
|
||||
`postgres`). Prefer the Node standard library; justify any new dependency; do not add frameworks.
|
||||
The **host is stateless — it owns no schema and stores nothing of its own**; a plugin may own a
|
||||
@@ -212,6 +221,9 @@ Revisit only if the stated reason stops holding.
|
||||
adds you to any group, including one holding every permission; `users:write` mints a recovery code
|
||||
for any account. The containment the split buys is real on the **read** half only (`users:read` is
|
||||
a safe helpdesk grant). Don't let the per-resource naming imply otherwise in docs.
|
||||
- **The CSRF token is deliberately unbound to the session** (`src/auth/csrf.ts`; README → Security
|
||||
model): the usual cost of a signed double-submit token, covered by `SameSite=Lax` and HSTS.
|
||||
**Valid while the session cookie stays `SameSite=Lax` or stricter.**
|
||||
- **Plainpages says "user" everywhere; Ory's word is "identity".** House style, not a renamed
|
||||
concept. The single exception is the `Identity` DTO in `src/auth/kratos-admin.ts`, which mirrors
|
||||
Kratos' wire shape — don't rename it.
|
||||
@@ -278,6 +290,10 @@ Revisit only if the stated reason stops holding.
|
||||
it means disclosure rather than popup: the nav tree. `shell.ejs` hand-rolls the same block for the
|
||||
profile menu (its trigger composes escaped user values and its one item is a CSRF POST form) — keep
|
||||
the two in step.
|
||||
- **One scroller, the document** (priority 1). `.app` is `min-height: 100dvh`. `.nav`'s
|
||||
`overflow-y: auto` and `.side-footer`'s `flex: 0 0 auto` are not leftovers of a bounded frame:
|
||||
they are what makes the off-canvas panel usable with a long tree. `#nav-toggle` is `position: fixed` —
|
||||
a label click focuses it, and a browser scrolls a focused element into view.
|
||||
- **`ICON_NAMES` (`src/ui/icons.ts`) is a host-owned registry, not a frozen plugin contract**, so it
|
||||
is deliberately not re-exported from `@plainpages/plugin-api`. The palette may narrow when the last reference
|
||||
to an id goes, and a plugin needing one gets it re-registered in the same change. Accepted cost: an
|
||||
@@ -354,7 +370,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 &
|
||||
@@ -434,9 +450,12 @@ one-time setup. A file-map or table row gets a clause, not a paragraph.
|
||||
same rule in their own language. An unmapped Kratos id renders Kratos' own wording — map the id when
|
||||
it matters. **Held by the author, never by a test:** slightly different wording is often the right
|
||||
call, and a build-failing check takes that judgment away.
|
||||
- Use well formed, standard compliant, rich URIs. Prefer state in the URL over POSTing it, for
|
||||
example on list pages with filters and pagination. Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not
|
||||
`ids=x,y`.
|
||||
- Use well formed, standard compliant, rich URIs. **State lives in the URL or on the server, never in
|
||||
a cookie.** Prefer state in the URL over POSTing it, for example on list pages with filters and
|
||||
pagination. A message for the page a redirect lands on rides its query string — `info-msg`,
|
||||
`warn-msg`, `error-msg` — since a fragment never reaches the server. A cookie carries only what
|
||||
must be bound to the browser: the session (`plainpages_jwt`) and the CSRF token (`plainpages_csrf`).
|
||||
Do `ids=x&ids=y`, not `ids[]=x&ids[]=y` and not `ids=x,y`.
|
||||
|
||||
## Comments
|
||||
|
||||
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# Changelog
|
||||
|
||||
The release version **is** the plugin contract version (`HOST_API_VERSION`), so a minor is a
|
||||
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.
|
||||
|
||||
### Pages are not kept by a shared cache
|
||||
|
||||
Every page and JSON response now carries `Cache-Control: private, no-store`, so a cache or reverse
|
||||
proxy in front of Plainpages can't store a signed-in page and serve it to the next visitor. A route
|
||||
whose page is the same for everyone can still set its own `cache-control` through
|
||||
`RouteResult.headers`; assets are unchanged.
|
||||
|
||||
## 0.4.0
|
||||
|
||||
**Breaking.** Set `apiVersion: "0.4.0"`. The app shell no longer bounds the content column, so a page
|
||||
that relied on filling it scrolls the document instead.
|
||||
|
||||
### The document scrolls, and the chrome scrolls with it
|
||||
|
||||
`.app` was a `100dvh` box with `overflow: hidden`, so a page was only reachable below the fold if its
|
||||
own wrapper was a flex child with `overflow-y: auto`. `.table-wrap` and `.shell-auth` were; nothing
|
||||
else was, and a long page in `.form-page` clipped everything past the window in every engine.
|
||||
|
||||
Now the shell is `min-height: 100dvh` and nothing bounds the viewport. The sidebar and topbar scroll
|
||||
with the page, and keyboard paging, back/forward scroll restoration and find-in-page work without a
|
||||
page doing anything.
|
||||
|
||||
The sticky `thead` on `data-table` goes with it: a header only sticks to a scrollport that moves, and
|
||||
there is no longer one. A plugin that wants a full-height pane owns that in its own stylesheet; the
|
||||
shell offers no opt-out, per the simplicity priority in `AGENTS.md`.
|
||||
|
||||
### Upgrading a plugin
|
||||
|
||||
1. Set `apiVersion: "0.4.0"`.
|
||||
2. A page that scrolled the whole window needs no change — it now scrolls the document.
|
||||
3. A page holding a region that filled the content column (`flex: 1 1 auto; min-height: 0` with its
|
||||
own `overflow`) no longer gets a bounded column to fill, so that region grows and the page scrolls.
|
||||
Either let it, or give the region its own height in the plugin's stylesheet.
|
||||
4. A `data-table` no longer scrolls its rows in a bounded region: the page scrolls, and the header
|
||||
scrolls with it.
|
||||
|
||||
The sidebar stretches the whole document, so on a long page its footer — theme, language, profile and
|
||||
**Sign out** — sits at the end of that page rather than the bottom of the screen.
|
||||
|
||||
## 0.3.0
|
||||
|
||||
**Breaking.** Set `apiVersion: "0.3.0"`, and name a gate on every route and nav node.
|
||||
|
||||
### A session is a gate of its own
|
||||
|
||||
`session: true` takes any signed-in user, with no grant to hold — for a page whose data is the
|
||||
visitor's own (their upstream account, their own tokens), where there is no distinction a permission
|
||||
could name. An anonymous visitor is bounced to `/login` with the page as `return_to`, exactly as a
|
||||
permission gate does.
|
||||
|
||||
Every route and nav node now names **exactly one** of `public: true`, `session: true` or
|
||||
`permission: "<resource>:<action>"`, and a gate is spelled `true`:
|
||||
|
||||
- Naming **none** is refused. It used to mean public, so a forgotten gate published a page; it now
|
||||
fails the boot instead.
|
||||
- Naming **two** is refused, as before.
|
||||
- Spelling one anything but `true` is refused — `public: false` and `session: "yes"` both set no gate
|
||||
while reading as if they set one.
|
||||
|
||||
A section header gates nothing itself, so it takes `public: true` and lets each child decide; the
|
||||
host still drops a header whose children all filtered out.
|
||||
|
||||
`Gate` is exported from `@plainpages/plugin-api`, and `Route` and `NavNode` extend it.
|
||||
|
||||
### Filter bars take a multi-select
|
||||
|
||||
The `filter-bar` partial gains a `multiselect` control — the same checkboxes on the same query
|
||||
parameter as `chips`, but behind a button once the list is too long to lay on the bar. Config is
|
||||
`{ name, legend?, note?, value?, options }`, and the panel says what a capped list left out.
|
||||
|
||||
### Fixed
|
||||
|
||||
- An identity carrying no email no longer yields a session at all. Login used to mint a JWT for one,
|
||||
which every later request then rejected as anonymous — leaving the browser holding a dead cookie
|
||||
and no way to tell why.
|
||||
|
||||
### Dependencies
|
||||
|
||||
- Node 24.20.0.
|
||||
|
||||
### Upgrading a plugin
|
||||
|
||||
1. Set `apiVersion: "0.3.0"`.
|
||||
2. Give every route and nav node a gate. Anything that relied on omitting one was public — say
|
||||
`public: true` outright.
|
||||
|
||||
A page that scopes rows to the signed-in visitor should join on `ctx.user.id`. An email address is
|
||||
user-changeable and can be reassigned to someone else, who would then inherit the previous holder's
|
||||
rows. The reference plugin's new `/scheduling/mine` page shows the shape.
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Node 24 runs TypeScript directly (type stripping) — no build step. Pinned exact tag.
|
||||
FROM node:24.20.0-alpine3.24
|
||||
FROM node:24.22.2-alpine3.24
|
||||
|
||||
# Above WORKDIR so dev's `.:/app` bind mount can't shadow them; a volume at /app/node_modules
|
||||
# instead leaves a root-owned dir in the checkout (the daemon creates mount destinations as root).
|
||||
|
||||
@@ -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,64 @@ 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,
|
||||
on old and embedded browsers too. A newer browser only makes it nicer.
|
||||
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, on any browser, including the older engines in
|
||||
TVs, kiosks, thin clients and embedded panels. 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 +196,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
|
||||
@@ -626,6 +684,7 @@ provider/consumer semantics in `checkApiVersion`:
|
||||
The plugin pins one exact version (no ranges, per the project's pinning rules); the *host* supplies
|
||||
the compatibility. One digit carries the whole release, so a **minor** means either the plugin
|
||||
contract changed or a dependency moved far enough to warrant one.
|
||||
[`CHANGELOG.md`](CHANGELOG.md) is what a minor sends you to: what broke, and what to change.
|
||||
|
||||
|
||||
### Conflict rules
|
||||
@@ -932,6 +991,11 @@ recovery / front pages — so it looks identical signed in or out and just shows
|
||||
anonymous visitor. The sidebar collapses to a burger on a narrow screen; a page wanting a
|
||||
chrome-free layout opts out with the shell's `menu: false`.
|
||||
|
||||
**The document scrolls, and the chrome scrolls with it.** Nothing bounds the viewport, so a page is
|
||||
reachable below the fold without adding a scroll region of its own, and browser paging, scroll
|
||||
restoration and find-in-page work without a page doing anything. A plugin that wants a full-height
|
||||
pane owns that in its own stylesheet.
|
||||
|
||||
## Building blocks
|
||||
|
||||
Plainpages is a **component library, not a page generator** — reusable EJS partials + TS helpers,
|
||||
@@ -955,6 +1019,23 @@ underneath it — cramped, but nothing is unreachable. List state
|
||||
(`?q=…&status=…&sort=…&page=…`) lives **in the URL**, so a view is bookmarkable and shareable; the
|
||||
URL is the only state the UI keeps.
|
||||
|
||||
**Two browser floors** serve goal 4. Every page's content and actions stay reachable on
|
||||
Chromium 87, Safari 15 and Firefox 115, the engines in years-old TVs, kiosks and Qt panels. Only
|
||||
features [Baseline Widely available](https://web-platform-dx.github.io/baseline/) (in every
|
||||
major engine for 30 months) may shape how a page looks; anything newer, like popovers or anchor
|
||||
positioning, only enhances. A feature the floor engines lack, such as `:has()` or `oklch()`, may
|
||||
decorate a page but never decide whether something shows or can be read.
|
||||
|
||||
**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.
|
||||
Once the query names a value, that field renders read-only (`field` with `readonly`, and a `link` back
|
||||
to the URL without it) above the next step's form, which carries the earlier choices as hidden inputs,
|
||||
plus `locale` from `localeParam`. The form that writes comes last and posts to the URL naming every
|
||||
choice, so its handler reads them from `ctx.query`, never from the body; a value the query names that
|
||||
the step does not offer is that step's error, never a silent fallback. Don't redraw a half-filled form
|
||||
through a submit that writes nothing instead: a `required` field blocks it, and the choice never
|
||||
reaches the URL.
|
||||
|
||||
Plugins that genuinely need it — live dashboards, bulk actions, client-side validation — may **opt
|
||||
into progressive enhancement** (htmx, Alpine, vanilla JS) on top of working server-rendered HTML.
|
||||
The baseline never depends on it.
|
||||
@@ -1303,6 +1384,11 @@ authorization graph, so treat it the same. `compose.yml` publishes none of the s
|
||||
so a stolen cookie jar is worth 30 days of re-mintable access, not ten minutes. Only our two cookies
|
||||
obey `SECURE_COOKIES`; the Kratos one takes its flags from Kratos' own config.
|
||||
|
||||
**The CSRF token isn't bound to the session.** It is a signed double-submit token, so an attacker who
|
||||
can write cookies on your origin can plant one they know. `SameSite=Lax` keeps a cross-site page
|
||||
out, and HSTS (`SECURE_COOKIES=true`) a network attacker. A hostile page on a sibling subdomain can
|
||||
still set cookies, so host nothing untrusted on one.
|
||||
|
||||
**Offboarding is not instant by default** — a revoked permission or deactivated identity lands
|
||||
within one token TTL, unless the [denylist](#instant-revoke-the-optional-denylist) is on.
|
||||
|
||||
@@ -1564,6 +1650,11 @@ Every response carries security headers (`src/http/security-headers.ts`): a stri
|
||||
so a branding logo must live under `/public/` or be a `data:` URI; a plugin route can override any
|
||||
header per-response via `RouteResult.headers`.
|
||||
|
||||
Every page and JSON response is `Cache-Control: private, no-store`, so a cache in front of `web`
|
||||
never keeps one user's page for the next. A route may override it only for a page that is the same for
|
||||
every visitor: no user, no CSRF token, no `Set-Cookie`, since the host adds a fresh CSRF cookie to
|
||||
every plugin route.
|
||||
|
||||
A deep link reached while signed out — or after the session JWT lapses mid-task — bounces to the
|
||||
themed sign-in and returns to the **page that was requested** (`return_to`, validated host-relative
|
||||
by `localPath` in `src/http/safe-url.ts`, so a crafted value can't turn login completion into an open
|
||||
@@ -1730,6 +1821,7 @@ e2e-tests/ Playwright specs + their Dockerfile and compose.{visual,aut
|
||||
release-tooling/ Everything the release runs: next-version (the bump math), contract-version
|
||||
(the HOST_API_VERSION↔tag gate), dockerhub-overview (+ its .md.tmpl)
|
||||
registry-cleanup/ Nightly image pruning — the Gitea client plus what survives (select-versions.ts)
|
||||
CHANGELOG.md What changed per release, and how to upgrade a plugin across a minor
|
||||
ci.sh The full gate: typecheck → unit tests → every E2E suite on a fresh stack
|
||||
.gitea/workflows/ Gitea Actions — see CI/CD
|
||||
```
|
||||
|
||||
@@ -49,7 +49,7 @@ services:
|
||||
# backs it (PLUGIN_SETTING_SCHEDULING_UPSTREAM above points here). Stand-in for the customer's real service —
|
||||
# stdlib-only, in-memory, no auth. Prod points PLUGIN_SETTING_SCHEDULING_UPSTREAM at the real backend instead.
|
||||
shifts-upstream:
|
||||
image: node:24.20.0-alpine3.24
|
||||
image: node:24.22.2-alpine3.24
|
||||
command: node /srv/server.ts
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
@@ -58,7 +58,7 @@ services:
|
||||
# Dev mail catcher — Kratos recovery/verification emails land here (web UI on 8025).
|
||||
# kratos.yml points the courier at smtp://mailpit:1025; prod uses a real SMTP via env.
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.31.0
|
||||
image: axllent/mailpit:v1.32.0
|
||||
ports:
|
||||
- "8025:8025"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Playwright runner — browsers preinstalled, pinned to match @playwright/test in e2e-tests/.
|
||||
# Built/run via e2e-tests/compose.visual.yml; targets the `web` service over the network.
|
||||
FROM mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
FROM mcr.microsoft.com/playwright:v1.64.1-noble
|
||||
|
||||
WORKDIR /e2e-tests
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ services:
|
||||
|
||||
# The reference plugin's upstream (examples/shifts-upstream) so /scheduling/shifts shows real rows.
|
||||
shifts-upstream:
|
||||
image: node:24.20.0-alpine3.24
|
||||
image: node:24.22.2-alpine3.24
|
||||
command: ["node", "/server.ts"]
|
||||
volumes:
|
||||
- ./examples/shifts-upstream/server.ts:/server.ts:ro
|
||||
@@ -66,7 +66,7 @@ services:
|
||||
# Mock OIDC provider for the SSO login test — stdlib Node, auto-approves, signs an id_token Kratos
|
||||
# verifies via its jwks. Reachable as the same host (mock-oidc:9000) by both the browser and Kratos.
|
||||
mock-oidc:
|
||||
image: node:24.20.0-alpine3.24
|
||||
image: node:24.22.2-alpine3.24
|
||||
command: ["node", "/mock-oidc.ts"]
|
||||
environment:
|
||||
ISSUER: http://mock-oidc:9000
|
||||
@@ -81,7 +81,7 @@ services:
|
||||
|
||||
# Same-origin gateway: Kratos-owned paths → kratos, everything else → web (e2e-tests/proxy.ts).
|
||||
proxy:
|
||||
image: node:24.20.0-alpine3.24
|
||||
image: node:24.22.2-alpine3.24
|
||||
command: ["node", "/proxy.ts"]
|
||||
depends_on:
|
||||
web:
|
||||
|
||||
Generated
+12
-30
@@ -6,17 +6,17 @@
|
||||
"": {
|
||||
"name": "plainpages-e2e",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1"
|
||||
"@playwright/test": "1.64.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
|
||||
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
|
||||
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.62.1"
|
||||
"playwright": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -25,44 +25,26 @@
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
|
||||
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz",
|
||||
"integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.62.1"
|
||||
"playwright-core": "1.64.1"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.62.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
|
||||
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
|
||||
"version": "1.64.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz",
|
||||
"integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"test": "playwright test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "1.62.1"
|
||||
"@playwright/test": "1.64.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ export default defineConfig({
|
||||
testDir: ".",
|
||||
outputDir: "artifacts/test-output",
|
||||
fullyParallel: true,
|
||||
workers: 4, // Playwright's default is half the cores, so a run's parallelism would depend on the machine
|
||||
forbidOnly: true,
|
||||
reporter: [["list"], ["html", { open: "never", outputFolder: "artifacts/report" }]],
|
||||
use: {
|
||||
|
||||
@@ -28,6 +28,45 @@ test.beforeEach(async ({ context }) => {
|
||||
await context.addCookies([{ name: SESSION_COOKIE, url: BASE_URL, value: devSession() }]);
|
||||
});
|
||||
|
||||
// A key press, not scrollIntoView (a script can scroll a box no reader can) and not the wheel
|
||||
// (Firefox's synthetic event never reaches the document).
|
||||
for (const [name, path, tail] of [
|
||||
["the starter dashboard", "/dashboard", ".form-actions .btn"],
|
||||
["the public landing", "/", ".landing-actions .btn"],
|
||||
] as const) {
|
||||
for (const width of [1280, 390]) {
|
||||
test(`${name} scrolls to its end at ${width}px wide`, async ({ page }) => {
|
||||
await page.setViewportSize({ width, height: 200 });
|
||||
await page.goto(path);
|
||||
|
||||
const overflows = await page.evaluate(() => document.documentElement.scrollHeight > window.innerHeight);
|
||||
expect(overflows, "the page must overflow, or it proves nothing").toBe(true);
|
||||
await page.keyboard.press("End");
|
||||
await expect(page.locator(tail).last()).toBeInViewport({ ratio: 1 });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Green only while #nav-toggle is position: fixed — a label tap focuses it, and focus scrolls into view.
|
||||
test("closing the mobile drawer leaves the reader where the scrim found them", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 200 });
|
||||
await page.goto("/dashboard");
|
||||
|
||||
await page.locator(".hamburger").click();
|
||||
await expect(page.locator("#nav-toggle")).toBeChecked();
|
||||
// Scripted, because a key press with focus on the toggle does not scroll in every engine — and
|
||||
// what is under test is closing the drawer, not how the reader got down the page.
|
||||
await page.evaluate(() => window.scrollTo(0, 120));
|
||||
const at = await page.evaluate(() => window.scrollY);
|
||||
expect(at, "the page must have somewhere to scroll behind the scrim").toBeGreaterThan(0);
|
||||
|
||||
// The exposed strip beside the 264px panel: the scrim spans the viewport, so its centre is under
|
||||
// the drawer and a centre click lands on the panel instead.
|
||||
await page.locator(".scrim").click({ position: { x: 340, y: 100 } });
|
||||
await expect(page.locator("#nav-toggle")).not.toBeChecked();
|
||||
expect(await page.evaluate(() => window.scrollY), "closing the drawer must not move the page").toBe(at);
|
||||
});
|
||||
|
||||
test("captures the live pages for review", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await expect(page.locator(".sidebar")).toBeVisible();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Scheduling · Shifts list (reference plugin). The handler fetched the rows from the upstream
|
||||
service; this view renders them with the core building blocks inside the native app shell
|
||||
(ctx.chrome). `include()` reaches the core partials (shell, nav-tree, filter-bar, data-table,
|
||||
alert) — see docs/plugin-contract.md. Zero-JS: search round-trips the URL.
|
||||
alert) — see the README. Zero-JS: search round-trips the URL.
|
||||
Data: chrome, title, breadcrumbs, count, filterBar, table, canWrite, newHref, error?
|
||||
%><%
|
||||
const navHtml = include("partials/nav-tree", { nodes: chrome.nav });
|
||||
|
||||
Generated
+12
-12
@@ -8,12 +8,12 @@
|
||||
"dependencies": {
|
||||
"@larvit/log": "2.3.0",
|
||||
"ejs": "6.0.1",
|
||||
"lucide-static": "1.34.0",
|
||||
"lucide-static": "1.64.0",
|
||||
"postgres": "3.4.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/node": "24.19.2",
|
||||
"typescript": "7.0.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -37,13 +37,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "24.13.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz",
|
||||
"integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==",
|
||||
"version": "24.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.2.tgz",
|
||||
"integrity": "sha512-93d49wCWJkCyN87MxLpNN6KXPCwFw7bVUg79mGUV1OS40JGWwVCr8n61DXCSqz7CV6/VfPkb7xec7uP2M+QHWQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.18.0"
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
@@ -399,9 +399,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-static": {
|
||||
"version": "1.34.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.34.0.tgz",
|
||||
"integrity": "sha512-pSUvFhfhvDnhXN1fXerZEMgKLQQ3DneKwFYlqB5ji8OEAN0iPi/qgwQvCkcL519QgMeR66IpS/pT342VyT/g4g==",
|
||||
"version": "1.64.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-static/-/lucide-static-1.64.0.tgz",
|
||||
"integrity": "sha512-UxWmMteOXbKHI5E4f2qVtxCAAsI9gsInYK1lNw0nrOA0w4FkJeOVZ+UOV6aGA2377FZe3qyiFYq3lqjBKZ6OuA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/postgres": {
|
||||
@@ -453,9 +453,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.18.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
|
||||
"integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
}
|
||||
|
||||
+2
-2
@@ -18,12 +18,12 @@
|
||||
"dependencies": {
|
||||
"@larvit/log": "2.3.0",
|
||||
"ejs": "6.0.1",
|
||||
"lucide-static": "1.34.0",
|
||||
"lucide-static": "1.64.0",
|
||||
"postgres": "3.4.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/ejs": "3.1.5",
|
||||
"@types/node": "24.13.3",
|
||||
"@types/node": "24.19.2",
|
||||
"typescript": "7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
+12
-15
@@ -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;
|
||||
@@ -111,7 +111,6 @@ html:has(#theme-light:checked) {
|
||||
|
||||
/* ---------- 2. RESET ---------------------------------------- */
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body { margin: 0; background: var(--bg); color: var(--text);
|
||||
-webkit-font-smoothing: antialiased; }
|
||||
button { font: inherit; color: inherit; }
|
||||
@@ -151,8 +150,7 @@ summary { list-style: none; cursor: pointer; }
|
||||
.app {
|
||||
display: grid;
|
||||
grid-template-columns: var(--nav-w) minmax(0, 1fr);
|
||||
height: 100dvh;
|
||||
overflow: hidden;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* ---------- 4. SIDEBAR -------------------------------------- */
|
||||
@@ -160,7 +158,6 @@ summary { list-style: none; cursor: pointer; }
|
||||
grid-column: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
background: var(--surface);
|
||||
border-right: 1px solid var(--border);
|
||||
}
|
||||
@@ -331,7 +328,7 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.content {
|
||||
grid-column: 2;
|
||||
display: flex; flex-direction: column;
|
||||
min-width: 0; min-height: 0;
|
||||
min-width: 0;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
@@ -339,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 */
|
||||
@@ -355,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 {
|
||||
@@ -559,13 +556,12 @@ span.nav-self { cursor: default; } /* static / non-clickable */
|
||||
.pill-clear:hover { text-decoration: underline; }
|
||||
|
||||
/* ---------- 9. TABLE --------------------------------------- */
|
||||
.table-wrap { flex: 1 1 auto; min-height: 0; overflow: auto; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table.table {
|
||||
width: 100%; border-collapse: separate; border-spacing: 0;
|
||||
font-size: var(--fz); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.table thead th {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
background: var(--surface-3);
|
||||
border-bottom: 1px solid var(--border-2);
|
||||
color: var(--text-muted); font-weight: 600; font-size: var(--fz-xs);
|
||||
@@ -588,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); }
|
||||
|
||||
@@ -652,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; }
|
||||
@@ -697,10 +693,10 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
||||
}
|
||||
|
||||
/* the nav-toggle checkbox itself is visually hidden but focusable */
|
||||
#nav-toggle { position: absolute; opacity: 0; pointer-events: none; }
|
||||
#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);
|
||||
@@ -722,5 +718,6 @@ th[aria-sort="descending"] .sort-ico { transform: rotate(180deg); }
|
||||
/* Chromeless shell: a page may drop the sidebar for a focused single column. */
|
||||
.app-bare { grid-template-columns: minmax(0, 1fr); }
|
||||
.app-bare .content { grid-column: 1; }
|
||||
|
||||
/* Auth/landing rendered inside the app shell: a roomy, centered column in the content area. */
|
||||
.shell-auth { flex: 1 1 auto; overflow-y: auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
|
||||
.shell-auth { flex: 1 1 auto; display: flex; justify-content: center; align-items: flex-start; padding: 40px 20px 80px; }
|
||||
|
||||
@@ -131,7 +131,7 @@ services:
|
||||
|
||||
# Catches Kratos' recovery/verification emails — UI on http://localhost:8025
|
||||
mailpit:
|
||||
image: axllent/mailpit:v1.31.0
|
||||
image: axllent/mailpit:v1.32.0
|
||||
ports:
|
||||
- "8025:8025"
|
||||
restart: unless-stopped
|
||||
|
||||
@@ -284,6 +284,39 @@ test("every response carries the security headers; HSTS follows SECURE_COOKIES",
|
||||
assert.match(res.headers.get("strict-transport-security") ?? "", /max-age=\d+/);
|
||||
});
|
||||
|
||||
test("Cache-Control: pages and JSON are private, no-store unless the route sets its own; assets are left alone", async (t) => {
|
||||
const NO_STORE = "private, no-store";
|
||||
const csrf = (await fetch(base + "/")).headers.get("set-cookie")?.split(";")[0] ?? "";
|
||||
assert.match(csrf, new RegExp(`^${CSRF_COOKIE}=`));
|
||||
|
||||
// The signed-out landing is no-store as well: it and the signed-in page share one URL.
|
||||
assert.equal((await fetch(base + "/")).headers.get("cache-control"), NO_STORE);
|
||||
assert.equal((await fetch(base + "/", { headers: { cookie: csrf } })).headers.get("cache-control"), NO_STORE);
|
||||
assert.equal((await fetch(base + "/", { headers: { cookie: csrf }, method: "HEAD" })).headers.get("cache-control"), NO_STORE);
|
||||
assert.equal((await fetch(base + "/", { headers: { cookie: `${csrf}; ${session()}` } })).headers.get("cache-control"), NO_STORE);
|
||||
|
||||
assert.equal((await fetch(base + "/dashboard", { headers: { cookie: session() } })).headers.get("cache-control"), NO_STORE);
|
||||
assert.equal((await fetch(base + "/dashboard", { redirect: "manual" })).headers.get("cache-control"), NO_STORE); // sign-in bounce
|
||||
assert.equal((await fetch(base + "/nope", { headers: { cookie: csrf } })).headers.get("cache-control"), NO_STORE); // 404
|
||||
assert.equal((await fetch(base + "/public/css/styles.css")).headers.get("cache-control"), null);
|
||||
|
||||
const demo: Plugin = {
|
||||
apiVersion: "1.0.0",
|
||||
home: () => ({ html: "<p>plugin home</p>" }),
|
||||
id: "demo",
|
||||
routes: [
|
||||
{ handler: () => ({ html: "open" }), method: "GET", path: "/open", public: true },
|
||||
{ handler: () => ({ headers: { "Cache-Control": "public, max-age=600" }, html: "same" }), method: "GET", path: "/same", public: true },
|
||||
{ handler: () => ({ json: { ok: true } }), method: "GET", path: "/data", public: true },
|
||||
],
|
||||
};
|
||||
const url = await startApp(t, [demo]);
|
||||
for (const path of ["/demo/open", "/demo/data", "/"]) {
|
||||
assert.equal((await fetch(url + path)).headers.get("cache-control"), NO_STORE, path);
|
||||
}
|
||||
assert.equal((await fetch(url + "/demo/same")).headers.get("cache-control"), "public, max-age=600");
|
||||
});
|
||||
|
||||
// Production caches compiled templates; rendering must stay correct across repeated requests.
|
||||
test("renders correctly with template caching enabled", async () => {
|
||||
const app = createApp({ cache: true });
|
||||
|
||||
@@ -36,6 +36,8 @@ import { localPath } from "./safe-url.ts";
|
||||
import { routePublic, serveStatic } from "./static.ts";
|
||||
import { renderPluginView } from "../plugin-host/view-resolver.ts";
|
||||
|
||||
const NO_STORE = "private, no-store";
|
||||
|
||||
const rootDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
export interface AppOptions {
|
||||
@@ -203,6 +205,11 @@ export function createApp(options: AppOptions = {}): Server {
|
||||
// A cache in front of us must key on the language. Set after the static branch: an asset is
|
||||
// the same bytes in every language, and a Vary there fragments its entry per raw header.
|
||||
res.setHeader("vary", "accept-language");
|
||||
// Fail closed: a cache in front of us must not keep a page that may carry a user's data. Not
|
||||
// even the signed-out `/` is shared: it and the signed-in page have one URL, and a cache keyed
|
||||
// on URL and Accept-Language would hand the signed-out one to a signed-in visitor. A route that
|
||||
// is the same for everyone sets its own value in RouteResult.headers; the host doesn't police it.
|
||||
res.setHeader("cache-control", NO_STORE);
|
||||
|
||||
// Canonical host (APP_URL): send an off-host visitor to the configured origin so the browser,
|
||||
// the themed forms and the cross-origin Kratos POST share one cookie host — otherwise the
|
||||
|
||||
@@ -1,73 +1,200 @@
|
||||
# Primary todo
|
||||
# todo
|
||||
|
||||
## Unfinnished work
|
||||
## Scoring
|
||||
|
||||
- [ ] 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.
|
||||
- [ ] 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 = 34`
|
||||
|
||||
- [ ] **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 |
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
| 30 | 0.4.21 | | defect | **Give every `oklch()` colour a fallback, so primary buttons stay readable on an engine without it.** | 1 | 2 | 2 | 3 | 2 | 4 by 5 | 29.9 |
|
||||
| 29 | 0.4.21 | | defect | **Show the whole nav tree on an engine without `:has()`, and fold it only where `:has()` works.** | 2 | 2 | 2 | 4 | 3 | 4 by 6 | 26.9 |
|
||||
| 31 | 0.4.21 | | | **Delete the design-preview rules in `public/css/auth.css` that no view uses.** | 1 | 1 | 1 | 1 | 2 | code health by 2, 8 by 1 | 13.3 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 32 | 0.4.21 | 29, 30 | | **Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.** | 3 | 1 | 1 | 3 | 4 | code health by 2 | 6.0 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 19 | 0.5.0 | | question | **Run the documented Docker commands on macOS and fix whatever misbehaves.** | 2 | 2 | 1 | 3 | 3 | 1 by 3 | 3.3 |
|
||||
| 33 | 0.5.0 | | question | **Decide whether any public page gets a shared-cache `Cache-Control`, now that the host sends `private, no-store` on every page.** | 2 | 3 | 4 | 7 | 3 | 8 by 2 | 2.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
|
||||
|
||||
### 30. Give every `oklch()` colour a fallback, so primary buttons stay readable on an engine without it.
|
||||
|
||||
The accent, focus and status tokens in `public/css/styles.css` are `oklch()`, which needs Chromium
|
||||
111. Below it (Tizen 2024, webOS TV 24) `--accent` is invalid, so `.btn-primary` is white text on
|
||||
no background: the sign-in button reads as blank.
|
||||
|
||||
### 29. Show the whole nav tree on an engine without `:has()`, and fold it only where `:has()` works.
|
||||
|
||||
README → "Two browser floors": Chromium 87 must reach every page. `public/css/styles.css` hides
|
||||
`.nav-children` and only a `:has()` rule reveals them, so below Chromium 105 (webOS TV 22 and 23)
|
||||
no nested entry can be reached. Hide the children only inside `@supports selector(:has(a))`. The
|
||||
theme switch is inert there too, since every theme rule is a `:has()`: hide it under the same guard.
|
||||
|
||||
### 31. Delete the design-preview rules in `public/css/auth.css` that no view uses.
|
||||
|
||||
`.auth-view`, the `:target` screen switch, `.tpl-*`, the `fstate`/`rstate` radios and the
|
||||
forgot/register alerts match nothing in `views/` or `examples/`.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 32. Fail the gate on a stylesheet feature the browser floor lacks, unless it is guarded.
|
||||
|
||||
Nothing checks README → "Two browser floors", and no E2E engine is old enough to show a break.
|
||||
Lint every stylesheet, the examples' included, against Chromium 87, Safari 15 and Firefox 115, and
|
||||
allow a feature only inside an `@supports` that guards 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 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.
|
||||
|
||||
### 33. Decide whether any public page gets a shared-cache `Cache-Control`, now that the host sends `private, no-store` on every page.
|
||||
|
||||
Every non-asset response is `private, no-store`. The built-in signed-out `/` and its signed-in
|
||||
variant share one URL, so a shared cache keyed on URL and `Accept-Language` would serve the
|
||||
signed-out page to a signed-in visitor for the cache lifetime, and a cache that bypasses on cookies
|
||||
would never store it. Options: leave it, so operators cache only assets; add `Vary: Cookie`; or give
|
||||
the signed-out page its own URL. A plugin route can already opt in through `RouteResult.headers`.
|
||||
I is 4 because a wrong rule caches a signed-in page for everyone, exposing PII.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<symbol id="i-box" viewBox="0 0 24 24"><path d="M21 8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16Z" /><path d="m3.3 7 8.7 5 8.7-5" /><path d="M12 22V12" /></symbol>
|
||||
<symbol id="i-cal" viewBox="0 0 24 24"><path d="M8 2v3" /><path d="M16 2v3" /><rect x="3" y="3" width="18" height="18" rx="2" /><path d="M3 9h18" /></symbol>
|
||||
<symbol id="i-chart" viewBox="0 0 24 24"><path d="M5 21v-6" /><path d="M12 21V3" /><path d="M19 21V9" /></symbol>
|
||||
<symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m9 12 2 2 4-4" /></symbol>
|
||||
<symbol id="i-check-circle" viewBox="0 0 24 24"><circle cx="12" cy="12" r="10" /><path d="m16 9-5.5 5.5L8 12" /></symbol>
|
||||
<symbol id="i-chev" viewBox="0 0 24 24"><path d="m9 18 6-6-6-6" /></symbol>
|
||||
<symbol id="i-cols" viewBox="0 0 24 24"><rect width="18" height="18" x="3" y="3" rx="2" /><path d="M9 3v18" /><path d="M15 3v18" /></symbol>
|
||||
<symbol id="i-copy" viewBox="0 0 24 24"><rect width="14" height="14" x="8" y="8" rx="2" ry="2" /><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2" /></symbol>
|
||||
|
||||
Reference in New Issue
Block a user