§8 review checkpoint (todo §8); ran the architecture + product reviewers on the whole project and addressed findings. Critical (arch): "Testing & CI" shipped no CI automation — added scripts/ci.sh, the whole gate in one command (pin-lockstep check → typecheck → units (count guard) → the 4 E2E suites, each on its own named fresh stack with guaranteed down -v + non-zero exit on first failure). The gate immediately caught a latent bug: the auth-refresh suite booted Hydra (inherited §6 web→hydra dep) but the e2e overlays don't run Hydra with --dev, so it never went healthy — dropped Hydra from the auth suite's web deps (it never needed it). Product 🔴: the README Status note claimed auth/Hydra were unbuilt (false after §4/§6/§8) — corrected it + dropped the now-false _(planned)_ markers on the Auth/MVP sections. Product 🟡: added a login-only "Forgot password?" link (the recovery flow was unreachable from /login) and a data-table empty-state row (blank list tables, recurring deferral) — both tests-first. Docs: README Layout e2e line + e2e/package.json updated for the §8 suites. Stability-reviewer APPROVE-with-nits; addressed both (per-suite compose project names; grep || true) and fixed a project-name dot bug it introduced. Corrected a reviewer error (bootstrap uses restart on-failure:5, not unless-stopped). typecheck + 306 units green; scripts/ci.sh green end-to-end (visual 9 · auth 1 · oauth 2 · full 6), all stacks torn down. Deferred to §9: the app.ts internal route-table (raised urgency), visual-parity for admin/consent screens, a key-rotation E2E; L3 (plugin-api barrel in shifts.test) → the §8 test-cleanup item.

This commit is contained in:
2026-06-19 20:08:48 +02:00
parent 9d77f6ad17
commit bd20d00714
13 changed files with 120 additions and 17 deletions

View File

@@ -30,7 +30,7 @@ you'd rather assemble pages from building blocks than fight a framework or hand-
auth for the tenth time. Plainpages hands you the boring-but-hard parts (auth, authz, auth for the tenth time. Plainpages hands you the boring-but-hard parts (auth, authz,
menu, design system, plugin host) and stays out of your domain logic. It's not a menu, design system, plugin host) and stays out of your domain logic. It's not a
no-code tool and doesn't hide its moving parts: if "Ory is down ⇒ no logins" (see no-code tool and doesn't hide its moving parts: if "Ory is down ⇒ no logins" (see
[Auth](#auth-sessions--permissions-planned)) reads as obvious rather than a surprise, [Auth](#auth-sessions--permissions)) reads as obvious rather than a surprise,
you're the audience. you're the audience.
## Project goals ## Project goals
@@ -46,16 +46,17 @@ makes **semantic, accessible markup** a priority: real landmarks, one `<h1>` per
lists and tables with proper headers, a skip link, and ARIA (`aria-current`/`aria-sort`) lists and tables with proper headers, a skip link, and ARIA (`aria-current`/`aria-sort`)
only where the platform leaves a gap (see [AGENTS.md](AGENTS.md)). only where the platform leaves a gap (see [AGENTS.md](AGENTS.md)).
> **Status.** This README describes the target architecture. Built today (see `todo.md`): > **Status.** Nearly all of the architecture this README describes is built today (see `todo.md`):
> the Node 24 + EJS server, the zero-JS **design system** (app shell, nav tree, data table, > the Node 24 + EJS server, the zero-JS **design system** (app shell, nav tree, data table, filters,
> filters, pagination, forms — extracted from `html-css-foundation/`), the **plugin host** > pagination, forms), the **plugin host** (discovery, router, per-plugin views + static, the
> (discovery, router, per-plugin views + static, the `config/menu.ts` override + branding), and the > `config/menu.ts` override + branding), the **Ory stack** (Postgres, Kratos + the session→JWT
> **Ory stack** wiring — Postgres, Kratos (+ session→JWT tokenizer) and Keto (authorization, OPL > tokenizer, Keto, Hydra), the **auth** wiring that consumes it (themed sign-in / register / reset /
> namespaces) and Hydra (OAuth2 provider: issuer + login/consent URLs). The **auth** wiring that > SSO, the session→JWT hot path, the users/groups/roles admin screens) and **Hydra's login / consent
> consumes these — and Hydra's login/consent handlers — are the roadmap; sections marked > / logout handlers** — all driven end-to-end by the Playwright suites. What's left is mainly
> _(planned)_ are not built yet. > **production & ops hardening** (the prod compose profile, security headers, observability, a
> key-rotation runbook) — tracked in `todo.md` (§9).
## The MVP — "clone, one command, hack on a plugin" _(planned)_ ## The MVP — "clone, one command, hack on a plugin"
The bar for a first usable release: **clone, run one command, get a working The bar for a first usable release: **clone, run one command, get a working
register/login, and start building your own plugin** — no manual key generation, no register/login, and start building your own plugin** — no manual key generation, no
@@ -83,7 +84,7 @@ the menu and pages by **verifying the JWT in-process, with no per-request call t
Ory**. Keto answers the rarer fine-grained checks; Hydra is used only when the app Ory**. Keto answers the rarer fine-grained checks; Hydra is used only when the app
acts as an OAuth2 **login & consent provider** for other apps. It reaches the Ory acts as an OAuth2 **login & consent provider** for other apps. It reaches the Ory
services over their **REST APIs using Node's built-in `fetch`** — no SDK services over their **REST APIs using Node's built-in `fetch`** — no SDK
dependency. See [Auth, sessions & permissions](#auth-sessions--permissions-planned). dependency. See [Auth, sessions & permissions](#auth-sessions--permissions).
So the `web` app is **stateless** and its npm footprint stays tiny — a small, So the `web` app is **stateless** and its npm footprint stays tiny — a small,
pinned set of runtime deps (today **`ejs`** for templating and **`lucide-static`** pinned set of runtime deps (today **`ejs`** for templating and **`lucide-static`**
@@ -271,6 +272,20 @@ Screenshots + an HTML report land in `e2e/artifacts/` (git-ignored). Every user-
is covered end-to-end; tests are independent and run **fully in parallel** for speed is covered end-to-end; tests are independent and run **fully in parallel** for speed
([AGENTS.md](AGENTS.md) §6) — keep new tests side-effect-free so the suite stays fast. ([AGENTS.md](AGENTS.md) §6) — keep new tests side-effect-free so the suite stays fast.
### The full gate (one command)
`scripts/ci.sh` is the whole gate in one reproducible command — typecheck → unit tests → each E2E
suite against its own fresh stack, with a guaranteed `down -v` after each (even on failure) and a
non-zero exit on the first failure. Run it locally before a release, or wire it into your CI service:
```bash
bash scripts/ci.sh
```
Each E2E suite **owns a clean stack** — never point two suites at one backend (auth-refresh revokes
the admin's sessions; full-flow writes users/groups/roles to Keto), which is why the gate runs them
serially, one stack up/down per suite.
## Building a plugin ## Building a plugin
A plugin is a folder under `plugins/`. The host discovers it at boot — no A plugin is a folder under `plugins/`. The host discovers it at boot — no
@@ -385,7 +400,7 @@ The menu is **driven entirely by config** and assembled from two sources:
Every nav item may carry a `permission`; the rendered tree is **filtered per Every nav item may carry a `permission`; the rendered tree is **filtered per
user** by reading the roles in the session JWT (no per-request authz call — see user** by reading the roles in the session JWT (no per-request authz call — see
[Auth, sessions & permissions](#auth-sessions--permissions-planned)), so the menu [Auth, sessions & permissions](#auth-sessions--permissions)), so the menu
only ever shows what that person can reach. The markup is the recursive, zero-JS only ever shows what that person can reach. The markup is the recursive, zero-JS
nav tree from the design foundation (header/leaf × clickable/static, counts, nav tree from the design foundation (header/leaf × clickable/static, counts,
arbitrary depth). Branding (name, logo, default theme) renders in the app shell — the sidebar arbitrary depth). Branding (name, logo, default theme) renders in the app shell — the sidebar
@@ -419,7 +434,7 @@ Plugins that genuinely need it — live dashboards, bulk actions, client-side
validation — may **opt into progressive enhancement** (htmx, Alpine, or vanilla validation — may **opt into progressive enhancement** (htmx, Alpine, or vanilla
JS) on top of working server-rendered HTML. The baseline never depends on it. JS) on top of working server-rendered HTML. The baseline never depends on it.
## Auth, sessions & permissions _(planned)_ ## Auth, sessions & permissions
Identity comes from **Kratos**; the hot path stays I/O-free by carrying coarse Identity comes from **Kratos**; the hot path stays I/O-free by carrying coarse
authorization in a **locally-validated JWT**, and **Keto** is reserved for the rare authorization in a **locally-validated JWT**, and **Keto** is reserved for the rare
@@ -609,7 +624,7 @@ ory/ Ory service config (kratos/: identity schema, kratos.yml, o
plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships scheduling/ — the §7 reference plugin (list/form over an upstream + permission-gated nav) you copy plugins/ Drop-in plugin folders (scanned at /app/plugins; bind-mount or bake in). Ships scheduling/ — the §7 reference plugin (list/form over an upstream + permission-gated nav) you copy
examples/ Non-app helpers; shifts-upstream/ is the dev mock backend the reference plugin reads/writes (stand-in for your real service) examples/ Non-app helpers; shifts-upstream/ is the dev mock backend the reference plugin reads/writes (stand-in for your real service)
docs/ Reference docs (plugin-contract.md — the authoritative plugin API) docs/ Reference docs (plugin-contract.md — the authoritative plugin API)
e2e/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent → authorization code), full Ory stack; Dockerfile.e2e + compose.e2e[-auth|-oauth].yml run them e2e/ Playwright E2E: visual.spec (design system, Ory-free) + auth-refresh.spec (token timeout/re-mint) + oauth-login.spec (OAuth2 login + consent) + full-flow.spec (browser UI: password/SSO login, menu-by-role, admin CRUD, plugin page, logout); proxy.mjs (same-origin gateway) + mock-oidc.mjs (mock SSO provider) back full-flow. Dockerfile.e2e + compose.e2e[-auth|-oauth|-full].yml run them
html-css-foundation/ HTML design mockups — the source for the building-block html-css-foundation/ HTML design mockups — the source for the building-block
partials; reference the stylesheets in public/css/. partials; reference the stylesheets in public/css/.
``` ```

View File

@@ -6,6 +6,16 @@
# docker compose -f compose.yml -f compose.e2e-auth.yml down -v # tear down after # docker compose -f compose.yml -f compose.e2e-auth.yml down -v # tear down after
services: services:
web: web:
# This suite exercises only the Kratos session → JWT re-mint; it needs Kratos + Keto + bootstrap,
# not Hydra. Drop the base web→hydra dep so the leaner stack doesn't boot Hydra (which the e2e
# overlays don't run with --dev, so it would refuse its http issuer and never become healthy).
depends_on: !override
bootstrap:
condition: service_completed_successfully
kratos:
condition: service_healthy
keto:
condition: service_healthy
# Dev throwaways are fine for the test stack; the runner hits web over http; treat the JWT as # Dev throwaways are fine for the test stack; the runner hits web over http; treat the JWT as
# expired the instant its TTL lapses (no 60s leeway) so the re-mint fires promptly. # expired the instant its TTL lapses (no 60s leeway) so the re-mint fires promptly.
environment: environment:

View File

@@ -18,6 +18,7 @@ const suffix = randomUUID().slice(0, 8); // unique per run so re-runs don't coll
// Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in. // Drive the themed password login form → Kratos → /auth/complete → dashboard, signed in.
async function loginPassword(page: Page): Promise<void> { async function loginPassword(page: Page): Promise<void> {
await page.goto("/login"); await page.goto("/login");
await expect(page.getByRole("link", { name: "Forgot password?" })).toBeVisible(); // a path to password reset
await page.fill('input[name="identifier"]', ADMIN_EMAIL); await page.fill('input[name="identifier"]', ADMIN_EMAIL);
await page.fill('input[name="password"]', ADMIN_PASSWORD); await page.fill('input[name="password"]', ADMIN_PASSWORD);
await page.locator('.auth-form button[type="submit"]').click(); await page.locator('.auth-form button[type="submit"]').click();

View File

@@ -2,7 +2,7 @@
"name": "plainpages-e2e", "name": "plainpages-e2e",
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"description": "Playwright visual + functional E2E: live app vs the html-css-foundation design.", "description": "Playwright E2E: design-system parity (visual), auth refresh, OAuth2 login/consent, and the full browser flow (login/menu/CRUD/plugin/logout).",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "playwright test" "test": "playwright test"

View File

@@ -122,6 +122,10 @@ body:has(#forgot:target) #login { display: none; }
.auth-alt a, .auth-alt button { color: var(--accent); font-weight: 550; } .auth-alt a, .auth-alt button { color: var(--accent); font-weight: 550; }
.auth-alt button { background: none; border: 0; padding: 0; font: inherit; cursor: pointer; } .auth-alt button { background: none; border: 0; padding: 0; font: inherit; cursor: pointer; }
.auth-alt a:hover, .auth-alt button:hover { text-decoration: underline; } .auth-alt a:hover, .auth-alt button:hover { text-decoration: underline; }
/* "Forgot password?" — a small link under the fields, above the submit button */
.auth-aside { margin: -4px 0 0; text-align: right; font-size: var(--fz-sm); }
.auth-aside a { color: var(--accent); }
.auth-aside a:hover { text-decoration: underline; }
/* legal footnote */ /* legal footnote */
.auth-foot { margin: 0; text-align: center; font-size: var(--fz-xs); color: var(--text-faint); } .auth-foot { margin: 0; text-align: center; font-size: var(--fz-xs); color: var(--text-faint); }

View File

@@ -558,6 +558,7 @@ table.table {
.col-num { text-align: right; } .col-num { text-align: right; }
.cell-strong { font-weight: 550; } .cell-strong { font-weight: 550; }
.cell-muted { color: var(--text-muted); } .cell-muted { color: var(--text-muted); }
.table-empty { text-align: center; color: var(--text-muted); padding: 28px 12px; }
.cell-mono { font-variant-numeric: tabular-nums; .cell-mono { font-variant-numeric: tabular-nums;
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); } font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); }

49
scripts/ci.sh Executable file
View File

@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# The full CI gate (todo §8): typecheck → unit tests → every E2E suite, each against a FRESH stack
# that is always torn down. One reproducible command — run it locally or wire it into your CI
# service. Docker-only (it drives `docker compose`; node/npm/tsc run inside containers, never the host).
#
# bash scripts/ci.sh
#
# Exits non-zero on the first failure. Each E2E suite OWNS a clean stack — never point two suites at
# one backend (auth-refresh revokes the admin's sessions; full-flow writes users/groups/roles to Keto).
set -euo pipefail
cd "$(dirname "$0")/.."
step() { printf '\n\033[1;34m==> %s\033[0m\n' "$1"; }
# Pins that MUST move in lockstep: a browser/runner mismatch yields confusing E2E failures.
step "Playwright pin lockstep (Dockerfile.e2e image == e2e/package.json @playwright/test)"
# `|| true` so a no-match doesn't trip `set -e`/`pipefail` before the explicit check below can report.
img=$(grep -oE 'playwright:v[0-9.]+' Dockerfile.e2e | grep -oE '[0-9.]+$' || true)
pkg=$(grep -oE '"@playwright/test": "[0-9.]+"' e2e/package.json | grep -oE '[0-9.]+' || true)
[ -n "$img" ] && [ "$img" = "$pkg" ] || { echo "Playwright pin mismatch/unreadable: image v$img vs @playwright/test $pkg"; exit 1; }
echo "ok ($img)"
step "Typecheck"
docker compose run --rm --no-deps web npm run typecheck
step "Unit tests"
units=$(docker compose run --rm --no-deps web npm test 2>&1) || { echo "$units"; exit 1; }
echo "$units" | grep -E '^. (tests|pass|fail) ' || true
# Sanity floor: catch a glob that matches too few files (a full empty glob already exits non-zero above).
count=$(echo "$units" | grep -oE 'tests [0-9]+' | grep -oE '[0-9]+' | head -1 || true)
[ "${count:-0}" -ge 50 ] || { echo "only ${count:-0} unit tests ran — test glob broken?"; exit 1; }
# Run one E2E suite against its OWN named stack, then always tear it down (even on failure). The
# per-suite project name keeps a flaky teardown from leaking containers/volumes into the next suite.
e2e() {
step "E2E: $1"
local proj="plainpages-e2e-$(basename "$1" .yml | tr '.' '-')" # dots aren't valid in a compose project name
local rc=0
docker compose -p "$proj" -f compose.yml -f "$1" run --build --rm e2e || rc=$?
docker compose -p "$proj" -f compose.yml -f "$1" down -v >/dev/null 2>&1 || true
[ "$rc" -eq 0 ] || { echo "E2E suite $1 failed (exit $rc)"; exit "$rc"; }
}
e2e compose.e2e.yml # visual / design-system parity (Ory-free)
e2e compose.e2e-auth.yml # token timeout + silent re-mint
e2e compose.e2e-oauth.yml # OAuth2 login + consent
e2e compose.e2e-full.yml # full browser flow: login (password + SSO), menu, CRUD, plugin, logout
step "ALL GREEN"

View File

@@ -82,3 +82,15 @@ test("data-table renders a minimal table (plain string cells, no select/actions)
assert.match(flat(await render()), /<table class="table"><thead><tr><\/tr><\/thead><tbody><\/tbody><\/table>/); assert.match(flat(await render()), /<table class="table"><thead><tr><\/tr><\/thead><tbody><\/tbody><\/table>/);
}); });
test("data-table shows an empty-state row spanning all columns when there are no rows", async () => {
// colspan covers the data columns + the select + actions columns (2 + 1 + 1 = 4).
const html = flat(await render({ actions: true, columns: [{ label: "Name" }, { label: "Email" }], rows: [], selectable: true }));
assert.match(html, /<tbody><tr><td class="table-empty" colspan="4">Nothing here yet\.<\/td><\/tr><\/tbody>/);
// a caller-supplied message overrides the default
assert.match(flat(await render({ columns: [{ label: "Shift" }], emptyText: "No shifts yet.", rows: [] })), /<td class="table-empty" colspan="1">No shifts yet\.<\/td>/);
// a populated table has no empty-state row
assert.doesNotMatch(flat(await render({ columns: [{ label: "Name" }], rows: [{ cells: ["A"] }] })), /table-empty/);
});

View File

@@ -49,6 +49,7 @@ test("maps a password login flow: csrf hidden, themed email/password fields, a s
// Chrome derived from the flow type. // Chrome derived from the flow type.
assert.equal(view.title, "Sign in"); assert.equal(view.title, "Sign in");
assert.equal(view.alt?.href, "/registration"); assert.equal(view.alt?.href, "/registration");
assert.equal(view.recoverHref, "/recovery"); // login offers a path to password reset
assert.equal(view.messages.length, 0); assert.equal(view.messages.length, 0);
}); });
@@ -99,6 +100,7 @@ test("chrome varies per flow type: registration alt, recovery back link", () =>
const reg = buildFlowView(flow([]), "registration"); const reg = buildFlowView(flow([]), "registration");
assert.equal(reg.title, "Create account"); assert.equal(reg.title, "Create account");
assert.equal(reg.alt?.href, "/login"); assert.equal(reg.alt?.href, "/login");
assert.equal(reg.recoverHref, undefined); // only login shows the reset link
const rec = buildFlowView(flow([]), "recovery"); const rec = buildFlowView(flow([]), "recovery");
assert.equal(rec.back?.href, "/login"); assert.equal(rec.back?.href, "/login");

View File

@@ -51,6 +51,7 @@ export interface FlowView extends FlowChrome {
hidden: { name: string; value: string }[]; hidden: { name: string; value: string }[];
messages: FlowMessage[]; messages: FlowMessage[];
method: string; method: string;
recoverHref?: string; // login only: a "Forgot password?" link to the recovery flow
sso: SsoProvider[]; // one per configured oidc provider; empty ⇒ no SSO section sso: SsoProvider[]; // one per configured oidc provider; empty ⇒ no SSO section
} }
@@ -142,6 +143,7 @@ export function buildFlowView(flow: Flow, type: FlowType): FlowView {
messages: (flow.ui.messages ?? []).map((m) => ({ text: m.text, tone: tone(m.type) })), messages: (flow.ui.messages ?? []).map((m) => ({ text: m.text, tone: tone(m.type) })),
method: flow.ui.method || "post", method: flow.ui.method || "post",
sso, sso,
...(type === "login" ? { recoverHref: "/recovery" } : {}),
...CHROME[type], ...CHROME[type],
}; };
} }

View File

@@ -120,7 +120,7 @@ everything via Docker.
- [x] node --test units across helpers / router / nav / auth (tests-first throughout). → Audited unit coverage across the four areas; built tests-first through §0§7, it was already near-complete — **helpers** (`list-query`/`paginate`/`body`/`icons`/`config`/`context`/`flow-view`/`gen-jwks`/`hooks`/`shell-context`, `static` via `app.test.ts`), **router** (`router`/`view-resolver`/`plugin`/`discovery`), **nav** (`nav`/`nav-tree`/`chrome`/`menu-config`/dashboard merge), **auth** (`jwt`/`jwt-middleware`/`jwks`/`guards`/`login`/`csrf`/`cookie`/`kratos-*`/`keto-client`/`oauth-*`/`hydra-admin`) all carry direct `node --test` units. **One genuine gap closed:** `admin-nav.ts` — its pure nav helpers (`adminSection`/`adminNav`) and security-critical auth gates (`requireAdmin`/`guardedForm`, the shared gate+CSRF preamble for every admin write) were exercised only *indirectly* via the admin HTTP integration tests. Added `src/admin-nav.test.ts` (tests-first style, against the existing contract): `adminSection` (gated "Admin" header over the 4 screens, `current` marks+opens), `adminNav` (prepends Dashboard, role-filters the section — admin sees it, non-admin/anon get only Dashboard; asserts via `href` since composeNav strips `id` but keeps `current`), `requireAdmin` (anon→401→/login, non-admin→403, admin→user), `guardedForm` (valid double-submit→parsed body, missing/forged token→403, non-POST→undefined), `buildConfirmModel`. Only `server.ts` (entry-point composition root, exercised by every E2E boot) has no dedicated unit. 300 → 305 units; typecheck + tests green. Tests-only, no production code (no stability reviewer, per the §6/§7 test-cleanup precedent). - [x] node --test units across helpers / router / nav / auth (tests-first throughout). → Audited unit coverage across the four areas; built tests-first through §0§7, it was already near-complete — **helpers** (`list-query`/`paginate`/`body`/`icons`/`config`/`context`/`flow-view`/`gen-jwks`/`hooks`/`shell-context`, `static` via `app.test.ts`), **router** (`router`/`view-resolver`/`plugin`/`discovery`), **nav** (`nav`/`nav-tree`/`chrome`/`menu-config`/dashboard merge), **auth** (`jwt`/`jwt-middleware`/`jwks`/`guards`/`login`/`csrf`/`cookie`/`kratos-*`/`keto-client`/`oauth-*`/`hydra-admin`) all carry direct `node --test` units. **One genuine gap closed:** `admin-nav.ts` — its pure nav helpers (`adminSection`/`adminNav`) and security-critical auth gates (`requireAdmin`/`guardedForm`, the shared gate+CSRF preamble for every admin write) were exercised only *indirectly* via the admin HTTP integration tests. Added `src/admin-nav.test.ts` (tests-first style, against the existing contract): `adminSection` (gated "Admin" header over the 4 screens, `current` marks+opens), `adminNav` (prepends Dashboard, role-filters the section — admin sees it, non-admin/anon get only Dashboard; asserts via `href` since composeNav strips `id` but keeps `current`), `requireAdmin` (anon→401→/login, non-admin→403, admin→user), `guardedForm` (valid double-submit→parsed body, missing/forged token→403, non-POST→undefined), `buildConfirmModel`. Only `server.ts` (entry-point composition root, exercised by every E2E boot) has no dedicated unit. 300 → 305 units; typecheck + tests green. Tests-only, no production code (no stability reviewer, per the §6/§7 test-cleanup precedent).
- [x] **Playwright full E2E**: login (password + mocked SSO), menu filtering by role, users/groups/permissions CRUD, a plugin page, logout. → New browser-UI suite `e2e/full-flow.spec.ts` (`compose.e2e-full.yml`) — the real Playwright UI the earlier full-stack suites deferred here ("browser-UI login is owned by §8"). The themed login form posts straight to Kratos' action and cookies are host-scoped, so a tiny **stdlib reverse proxy** (`e2e/proxy.mjs`) fronts web + Kratos on **one origin** (the browser's only host), exactly like a prod reverse proxy; `ory/kratos/e2e-proxy.yml` points Kratos' base_url + every self-service URL at it, and Kratos runs `--dev` so cookies aren't marked Secure over http (Kratos marks them Secure for a non-loopback host like the gateway). Coverage (6 tests, all green): **password login** (themed form → Kratos → `/auth/complete` → dashboard); **mocked SSO** (a stdlib **mock OIDC provider** `e2e/mock-oidc.mjs` — RS256-signed id_token, nonce-bound single-use codes — wired via `SELFSERVICE_METHODS_OIDC_*` env + the committed claims jsonnet; click the provider button → auto-approve → identity created → signed in); **menu filtering by role** (the admin sees the gated Admin section + the plugin nav; anon/SSO-user don't); **users/groups/roles CRUD** (create → list → delete a user via the confirm step; create a group + role, each with a first member since a Keto set needs ≥1); the **permission-gated plugin page** (`/scheduling/shifts` renders the mock upstream's shifts in the native shell); **logout** (sign-out ends the session → back to /login, admin nav gone). **Found + fixed a real bug the E2E surfaced:** the SSO submit button sits in the same `<form>` as the `required` email/password fields, so clicking it tripped HTML5 validation ("Please fill out this field") and never submitted — added `formnovalidate` to the SSO buttons (`views/partials/auth-card.ejs`), tests-first (`auth-card.test.ts` + `app.test.ts`); password login still validates (separate button). Stability-reviewer run as a local PR: **APPROVE, no Critical/High** — every dev/insecure knob (`--dev`, `SECURE_COOKIES=false`, the OIDC provider + mock + proxy) is confined to the e2e overlay, base/prod compose unaffected; addressed its top follow-up (documented the file's parallel-safety invariant). typecheck + **305 units** green; full-flow **6/6 green on a clean stack**, then `down -v` torn down. README E2E section + suite count updated. - [x] **Playwright full E2E**: login (password + mocked SSO), menu filtering by role, users/groups/permissions CRUD, a plugin page, logout. → New browser-UI suite `e2e/full-flow.spec.ts` (`compose.e2e-full.yml`) — the real Playwright UI the earlier full-stack suites deferred here ("browser-UI login is owned by §8"). The themed login form posts straight to Kratos' action and cookies are host-scoped, so a tiny **stdlib reverse proxy** (`e2e/proxy.mjs`) fronts web + Kratos on **one origin** (the browser's only host), exactly like a prod reverse proxy; `ory/kratos/e2e-proxy.yml` points Kratos' base_url + every self-service URL at it, and Kratos runs `--dev` so cookies aren't marked Secure over http (Kratos marks them Secure for a non-loopback host like the gateway). Coverage (6 tests, all green): **password login** (themed form → Kratos → `/auth/complete` → dashboard); **mocked SSO** (a stdlib **mock OIDC provider** `e2e/mock-oidc.mjs` — RS256-signed id_token, nonce-bound single-use codes — wired via `SELFSERVICE_METHODS_OIDC_*` env + the committed claims jsonnet; click the provider button → auto-approve → identity created → signed in); **menu filtering by role** (the admin sees the gated Admin section + the plugin nav; anon/SSO-user don't); **users/groups/roles CRUD** (create → list → delete a user via the confirm step; create a group + role, each with a first member since a Keto set needs ≥1); the **permission-gated plugin page** (`/scheduling/shifts` renders the mock upstream's shifts in the native shell); **logout** (sign-out ends the session → back to /login, admin nav gone). **Found + fixed a real bug the E2E surfaced:** the SSO submit button sits in the same `<form>` as the `required` email/password fields, so clicking it tripped HTML5 validation ("Please fill out this field") and never submitted — added `formnovalidate` to the SSO buttons (`views/partials/auth-card.ejs`), tests-first (`auth-card.test.ts` + `app.test.ts`); password login still validates (separate button). Stability-reviewer run as a local PR: **APPROVE, no Critical/High** — every dev/insecure knob (`--dev`, `SECURE_COOKIES=false`, the OIDC provider + mock + proxy) is confined to the e2e overlay, base/prod compose unaffected; addressed its top follow-up (documented the file's parallel-safety invariant). typecheck + **305 units** green; full-flow **6/6 green on a clean stack**, then `down -v` torn down. README E2E section + suite count updated.
- [x] E2E harness: bring up the full compose stack, seed Keto roles + a test identity, **tear down after**. → Delivered by the §8 full-flow harness (`compose.e2e-full.yml`): one `run` brings up the whole stack (Postgres + Kratos + Keto + the one-command **bootstrap** + web + the same-origin gateway + the reference plugin's mock upstream + the mock OIDC provider), the bootstrap **seeds the demo admin identity in Kratos + its Keto roles** (`admin` + the plugin's declared `scheduling:read`/`write` tokens), the browser suite runs against that seeded identity, and `docker compose … down -v` **tears everything down** (run live, 6/6 green, torn down). The §4 auth-refresh + §6 oauth-login suites use the same full-stack-up / seed / tear-down pattern; this completes it for the browser-UI flows. - [x] E2E harness: bring up the full compose stack, seed Keto roles + a test identity, **tear down after**. → Delivered by the §8 full-flow harness (`compose.e2e-full.yml`): one `run` brings up the whole stack (Postgres + Kratos + Keto + the one-command **bootstrap** + web + the same-origin gateway + the reference plugin's mock upstream + the mock OIDC provider), the bootstrap **seeds the demo admin identity in Kratos + its Keto roles** (`admin` + the plugin's declared `scheduling:read`/`write` tokens), the browser suite runs against that seeded identity, and `docker compose … down -v` **tears everything down** (run live, 6/6 green, torn down). The §4 auth-refresh + §6 oauth-login suites use the same full-stack-up / seed / tear-down pattern; this completes it for the browser-UI flows.
- [ ] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. - [x] Run the architecture and the product reviewer agents on the _whole_ project, not just the latest changes, and address their issues. → Ran both on the whole project (weighted to the §8 Testing & CI surfaces). **Fixed now (tests-first where code):** (1) **CRITICAL (arch)** — §8 is "Testing & **CI**" but the CI automation was missing: the gate was five hand-run commands with manual teardown. Added **`scripts/ci.sh`** — the whole gate in one reproducible command: a Playwright pin-lockstep check (M4) → typecheck → units (with a count-floor guard, L1) → the four E2E suites, each on its **own named fresh stack** with a guaranteed `down -v` and a non-zero exit on first failure (documented in README). **The gate immediately earned its keep:** it surfaced a latent bug — the auth-refresh suite's `web` inherited the §6 `web→hydra` dep, but the e2e overlays don't run Hydra with `--dev`, so Hydra refused its http issuer and never went healthy → the suite couldn't boot. Fixed by dropping Hydra from the auth suite's `web` deps (`!override`, mirroring the full suite — it never needed Hydra). (2) **Product 🔴 (doc correctness)** — the README **Status** note still claimed auth + Hydra handlers were "the roadmap"/unbuilt (materially false after §4/§6/§8); rewrote it to reflect built reality and dropped the now-false `_(planned)_` markers from the **Auth** + **MVP** headings + fixed their anchor links. (3) **Product 🟡** — the recovery flow existed but nothing linked to it from `/login` (an end-user lockout gap): added a login-only **"Forgot password?"** link (`flow-view.ts` `recoverHref``flow-body.ejs`, `.auth-aside` CSS), tests-first (flow-view unit + a full-flow E2E assertion). (4) **Product 🟡 (recurring §5/§6/§7)** — empty list tables rendered blank: added an empty-state row to `data-table.ejs` (overridable `emptyText`, colspan over all columns, only when the table has columns) + `.table-empty` CSS — fixes all five list surfaces at once; tests-first (data-table unit). (5) **M3 (docs)** — README Layout `e2e/` line + `e2e/package.json` description updated for the §8 full-flow suite + harness files. Stability-reviewer run as a local PR: **APPROVE (with nits), no Critical/High**; addressed both robustness nits (per-suite compose **project names** so a flaky teardown can't cross-contaminate; `|| true` on the pin/count greps so `set -e`+pipefail doesn't abort before the diagnostic) — and caught a bug they introduced (a `.` in the derived project name is invalid → sanitized with `tr`). **Corrected a reviewer error:** arch claimed the bootstrap service uses `restart: unless-stopped` (infinite crash-loop); it actually uses `restart: "on-failure:5"` (bounded retries for transient Ory-not-ready blips) — defensible, left as-is. typecheck + **306 units** green; **`scripts/ci.sh` green end-to-end** (visual 9 · auth 1 · oauth 2 · full 6, every stack torn down). **Deferred (reviewer-scoped):** the host **internal route-table** (fold the admin/oauth if-ladder in `app.ts` into one `{method,prefix,handler}` table, derive `RESERVED_PLUGIN_IDS`/`allowedMethods` — arch H1, widened by §6/§7) → **§9** (top structural item, raised urgency); **visual-parity (computed-style) checks for the admin + consent screens** (visual.spec only covers the dashboard — arch M2) → §9/polish; a **JWT key-rotation E2E** (L2) → ships with the §9 rotation-runbook item; `shifts.test.ts` deep `src/*` imports → the plugin-api barrel (L3) → the §8 test-cleanup item below; success-flash after writes (product 🟢) → deferred (stateless, known); a **CI-service workflow** (Actions YAML calling `scripts/ci.sh`) → needs the operator's CI platform + Docker-capable runners (the portable gate script is in place).
- [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff. - [ ] Go over all comments in the code and the README and try to make it shorter and more information dense. Remove not strictly needed stuff.
- [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us. - [ ] Go over all tests and combine/unify ones that cover the same stuff or are very related and could be combined in a good way. Remove tests that aren't helping, we only want tests that are actually helpful to us.
@@ -137,4 +137,4 @@ everything via Docker.
## 10. User added stuff ## 10. User added stuff
- [ ] The dashboard, the first landing page after logging in, should be gated to only logged in users. It should also be replaceable fully from a plugin. It is important that the ergonomics for the plugin writer is great. - [ ] The dashboard, the first landing page after logging in, should be gated to only logged in users. It should also be replaceable fully from a plugin. It is important that the ergonomics for the plugin writer is great.
- [ ] Make some pages optionally available publicly. - [ ] Make some pages optionally available publicly. A plugin should be able to set the permissions of a page (including the menu option) to publicly available.

View File

@@ -14,6 +14,7 @@
const withActions = !!locals.actions; const withActions = !!locals.actions;
const columns = locals.columns || []; const columns = locals.columns || [];
const rows = locals.rows || []; const rows = locals.rows || [];
const emptyText = locals.emptyText || "Nothing here yet."; // shown when a table that has columns has no rows
-%> -%>
<div class="table-wrap"> <div class="table-wrap">
<table class="table"> <table class="table">
@@ -38,6 +39,9 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<% if (rows.length === 0 && columns.length) { -%>
<tr><td class="table-empty" colspan="<%= columns.length + (selectable ? 1 : 0) + (withActions ? 1 : 0) %>"><%= emptyText %></td></tr>
<% } -%>
<% rows.forEach((row) => { -%> <% rows.forEach((row) => { -%>
<tr> <tr>
<% if (selectable) { -%> <% if (selectable) { -%>

View File

@@ -12,6 +12,9 @@
<% flow.fields.forEach((field) => { -%> <% flow.fields.forEach((field) => { -%>
<%- include("field", field) %> <%- include("field", field) %>
<% }) -%> <% }) -%>
<% if (flow.recoverHref) { -%>
<p class="auth-aside"><a href="<%= flow.recoverHref %>">Forgot password?</a></p>
<% } -%>
<% flow.buttons.forEach((b, i) => { -%> <% flow.buttons.forEach((b, i) => { -%>
<button type="submit" class="btn btn-block<%= i === 0 ? " btn-primary" : "" %>"<% if (b.name) { %> name="<%= b.name %>"<% } %><% if (b.value != null) { %> value="<%= b.value %>"<% } %>><%= b.label %></button> <button type="submit" class="btn btn-block<%= i === 0 ? " btn-primary" : "" %>"<% if (b.name) { %> name="<%= b.name %>"<% } %><% if (b.value != null) { %> value="<%= b.value %>"<% } %>><%= b.label %></button>
<% }) -%> <% }) -%>