§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:
43
README.md
43
README.md
@@ -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,
|
||||
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
|
||||
[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.
|
||||
|
||||
## 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`)
|
||||
only where the platform leaves a gap (see [AGENTS.md](AGENTS.md)).
|
||||
|
||||
> **Status.** This README describes the target architecture. Built today (see `todo.md`):
|
||||
> the Node 24 + EJS server, the zero-JS **design system** (app shell, nav tree, data table,
|
||||
> filters, pagination, forms — extracted from `html-css-foundation/`), the **plugin host**
|
||||
> (discovery, router, per-plugin views + static, the `config/menu.ts` override + branding), and the
|
||||
> **Ory stack** wiring — Postgres, Kratos (+ session→JWT tokenizer) and Keto (authorization, OPL
|
||||
> namespaces) and Hydra (OAuth2 provider: issuer + login/consent URLs). The **auth** wiring that
|
||||
> consumes these — and Hydra's login/consent handlers — are the roadmap; sections marked
|
||||
> _(planned)_ are not built yet.
|
||||
> **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, filters,
|
||||
> pagination, forms), the **plugin host** (discovery, router, per-plugin views + static, the
|
||||
> `config/menu.ts` override + branding), the **Ory stack** (Postgres, Kratos + the session→JWT
|
||||
> tokenizer, Keto, Hydra), the **auth** wiring that consumes it (themed sign-in / register / reset /
|
||||
> SSO, the session→JWT hot path, the users/groups/roles admin screens) and **Hydra's login / consent
|
||||
> / logout handlers** — all driven end-to-end by the Playwright suites. What's left is mainly
|
||||
> **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
|
||||
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
|
||||
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
|
||||
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,
|
||||
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
|
||||
([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
|
||||
|
||||
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
|
||||
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
|
||||
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
|
||||
@@ -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
|
||||
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
|
||||
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
|
||||
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)
|
||||
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
|
||||
partials; reference the stylesheets in public/css/.
|
||||
```
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
# docker compose -f compose.yml -f compose.e2e-auth.yml down -v # tear down after
|
||||
services:
|
||||
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
|
||||
# expired the instant its TTL lapses (no 60s leeway) so the re-mint fires promptly.
|
||||
environment:
|
||||
|
||||
@@ -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.
|
||||
async function loginPassword(page: Page): Promise<void> {
|
||||
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="password"]', ADMIN_PASSWORD);
|
||||
await page.locator('.auth-form button[type="submit"]').click();
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "plainpages-e2e",
|
||||
"version": "0.1.0",
|
||||
"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",
|
||||
"scripts": {
|
||||
"test": "playwright test"
|
||||
|
||||
@@ -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 button { background: none; border: 0; padding: 0; font: inherit; cursor: pointer; }
|
||||
.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 */
|
||||
.auth-foot { margin: 0; text-align: center; font-size: var(--fz-xs); color: var(--text-faint); }
|
||||
|
||||
@@ -558,6 +558,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; }
|
||||
.cell-mono { font-variant-numeric: tabular-nums;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace; font-size: var(--fz-sm); }
|
||||
|
||||
|
||||
49
scripts/ci.sh
Executable file
49
scripts/ci.sh
Executable 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"
|
||||
@@ -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>/);
|
||||
});
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
@@ -49,6 +49,7 @@ test("maps a password login flow: csrf hidden, themed email/password fields, a s
|
||||
// Chrome derived from the flow type.
|
||||
assert.equal(view.title, "Sign in");
|
||||
assert.equal(view.alt?.href, "/registration");
|
||||
assert.equal(view.recoverHref, "/recovery"); // login offers a path to password reset
|
||||
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");
|
||||
assert.equal(reg.title, "Create account");
|
||||
assert.equal(reg.alt?.href, "/login");
|
||||
assert.equal(reg.recoverHref, undefined); // only login shows the reset link
|
||||
|
||||
const rec = buildFlowView(flow([]), "recovery");
|
||||
assert.equal(rec.back?.href, "/login");
|
||||
|
||||
@@ -51,6 +51,7 @@ export interface FlowView extends FlowChrome {
|
||||
hidden: { name: string; value: string }[];
|
||||
messages: FlowMessage[];
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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) })),
|
||||
method: flow.ui.method || "post",
|
||||
sso,
|
||||
...(type === "login" ? { recoverHref: "/recovery" } : {}),
|
||||
...CHROME[type],
|
||||
};
|
||||
}
|
||||
|
||||
4
todo.md
4
todo.md
@@ -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] **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.
|
||||
- [ ] 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 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
|
||||
- [ ] 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.
|
||||
@@ -14,6 +14,7 @@
|
||||
const withActions = !!locals.actions;
|
||||
const columns = locals.columns || [];
|
||||
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">
|
||||
<table class="table">
|
||||
@@ -38,6 +39,9 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<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) => { -%>
|
||||
<tr>
|
||||
<% if (selectable) { -%>
|
||||
|
||||
@@ -12,6 +12,9 @@
|
||||
<% flow.fields.forEach((field) => { -%>
|
||||
<%- include("field", field) %>
|
||||
<% }) -%>
|
||||
<% if (flow.recoverHref) { -%>
|
||||
<p class="auth-aside"><a href="<%= flow.recoverHref %>">Forgot password?</a></p>
|
||||
<% } -%>
|
||||
<% 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>
|
||||
<% }) -%>
|
||||
|
||||
Reference in New Issue
Block a user