Commit Graph

  • 58398481ca §10 review pass: address the architecture + product reviewers (todo §10); hide the gated Dashboard nav node from anonymous visitors in buildPluginChrome (a no-permission link to /dashboard only dead-ended them at /login) and dedup it into a shared DASHBOARD_NAV (admin-nav.ts, reused by chrome + adminNav). New chrome.signInHref bakes the current page in as return_to for the shell's anonymous Sign-in link (shell.ejs + reference overview.ejs), mirrored as optional ShellModel.signInHref so the typed builder is complete. ctx.chrome is now a lazy, memoized getter (context.ts chrome option = a factory) so a json/redirect handler or the public "/" with a standalone home never composes the global menu — app.ts passes the app-level memoized factory at every site. Default /dashboard prints a "Starter dashboard" note framing the mock-data home as a replaceable demo (signals its inert affordances); stale "until §4" comments fixed. RESERVED_PLUGIN_IDS drift-guard test derives the built-in segments from AUTH_FLOWS + ADMIN_*_BASE + host literals (home stays deliberately unreserved). Refreshed the stale plugin-contract status blurb and documented the chrome.*→partials/shell mapping. Reviewers: architecture + product APPROVE (no addressable findings remain), stability APPROVE (no Critical/High/Medium). typecheck + 356 units + visual(10) + full-flow(7) E2E green. main lilleman 2026-06-21 01:19:40 +02:00
  • 7bdeb24b7f §10 public pages + menu items, the blessed explicit alias (todo §10); a plugin may mark a page and its menu option public. A no-permission route/nav node is already anonymous-reachable, so per the human's pick this BLESSES that as a first-class, explicit choice (keep the default; add an explicit alias — not a secure-by-default flip). New optional public?: boolean on Route (src/plugin.ts) + NavNode (src/nav.ts) = "open to everyone, signed in or not", honored outright in isAuthorized (router.ts) + filterByRoles (nav.ts), and MUTUALLY EXCLUSIVE with permission — discovery shapeError recursively rejects a route/nav node setting both, failing the boot loud (never silently picks one). public is filter-only (toRenderNode never emits it). The shell (views/partials/shell.ejs) now renders a Sign in link instead of the profile/sign-out block for an anonymous visitor, so a public page in the native shell (ctx.chrome; ctx.user may be null) isn't a broken "Guest / Sign out". Reference plugin demos it: a public /scheduling Overview route + a public "Overview" nav child (the "Scheduling" header now shows for everyone), the shifts list still behind scheduling:read. Hardened the latent gap the shell newly leans on: claimsToUser rejects an empty email like it does an empty sub. Tests-first (348 → 354 units): router/nav/discovery (public open + reject-both + loads), shell (anon → Sign in, no logout form), app (public route anon-200), shifts (overview handler), jwt-middleware (empty email). Docs: plugin-contract.md ("Public pages & menu items" + route shape + shape-error note) + README (menu system + reference snippet). E2E: visual.spec asserts the public Overview is anon-200 + shown in the member's nav while the gated Shifts redirects/filters. stability-reviewer: APPROVE, no Critical/High/Medium (addressed its one Low — the empty-email hardening). typecheck + 354 units + full scripts/ci.sh gate (visual 10 · auth 1 · oauth 2 · full 7) green. lilleman 2026-06-20 18:12:46 +02:00
  • 7787ed4ea4 §10 split landing into a public "/" + gated "/dashboard", both plugin-replaceable (todo §10 follow-up); per human feedback, "/" is now an ungated public landing (default views/home.ejs: brand + intro + prominent Log in / Create account links, or "go to dashboard" when signed in) and "/dashboard" is the gated post-login app home (anonymous → /login?return_to=/dashboard). Both are fully replaceable via two optional RouteHandlers on PluginManifest — home? (public /) and dashboard? (gated /dashboard) — rendered against the plugin's own views with the native shell via ctx.chrome (full route parity: HEAD, void-return, response hooks, fresh CSRF cookie; a home handler is public so ctx.user may be null). Single-slot + loud: findConflicts errors on >1 owner of either slot (new "home"/"dashboard" kinds), discovery rejects a non-function handler, and "dashboard" is reserved so a plugin folder can't shadow it ("/" can't be shadowed — route paths carry the /<id> prefix). Post-login + already-signed-in redirects and the global Dashboard/People nav hrefs moved to /dashboard. Tests-first (348 units): public-/ + gated-/dashboard + dual plugin-override in app.test; per-slot conflict in plugin.test; non-function/reserved/two-owners in discovery.test. Docs: plugin-contract "The landing pages" section + README. E2E: visual.spec plants a session for /dashboard design-system tests + a cookie-free public-landing test; full-flow repointed to /dashboard. stability-reviewer: APPROVE, no Critical/High/Medium. typecheck + 348 units + visual(10) + full-flow(7) green. lilleman 2026-06-20 17:43:01 +02:00
  • 2eb5b84ccf §10 gate the dashboard + make "/" replaceable by a plugin (todo §10); "/" is now gated to a signed-in session (anonymous → /login via loginRedirect, query preserved as return_to) and fully replaceable via a new optional home?: RouteHandler on PluginManifest — a handler with the same signature as any route (the most ergonomic shape). The app.ts "/" branch gates first, then renders the single home plugin's handler against its own views/ with the native shell via ctx.chrome (HEAD / void-return / response-hook parity with a plugin route), else the built-in mock-data People list. home mounts at the root above the /<id> namespace, so it can't shadow or be shadowed by a built-in route. Single-slot + loud: findConflicts errors on >1 home (new "home" kind), discovery rejects a non-function home — never last-write-wins. Tests-first (338 → 344 units): app.test.ts gate + home-override; plugin.test.ts home conflict; discovery.test.ts home validation. Docs: plugin-contract.md (manifest table + "The dashboard (home)" section + conflict row), README. E2E: visual.spec plants a dev-signed session (the anonymous plugin-gate probe uses the cookie-free request fixture); all e2e web/gateway healthchecks repointed from the gated "/" to /public/css/styles.css. stability-reviewer: APPROVE, no Critical/High/Medium. typecheck + 344 units + visual(9) + full-flow(7) E2E green. lilleman 2026-06-20 17:18:30 +02:00
  • df53106a5a §9 test cleanup (todo §9); dropped the one genuine §9-era test overlap. app.test.ts had two /login?return_to= tests for the same surface — the §6 "bakes the return target into the Kratos flow init (OAuth bounce)" and the §9 "first-party deep link wrapped through /auth/complete; absolute target passes through as-is". The §9 test subsumes it: its middle assertion already proves an absolute /oauth2/login?login_challenge= target is handed to initBrowserFlow unchanged (the exact §6 OAuth-bounce contract, labeled as such in the test name + inline comment), plus the new host-relative-wrap + protocol-relative cases. Removed the redundant standalone §6 test, zero coverage lost. The §9 unit files (security-headers/denylist/logger/safe-url + gen-jwks rotateJwks) and the per-field config toggles (SERVICE_NAME/LOG_*/OTLP_*/REVOCATION_*/JWT_CLOCK_SKEW/ORY_TIMEOUT) are one-concern matrices following the file's per-field pattern — no fat (§3 don't-merge-across-distinct-concerns rule). Tests-only, no production code (per the §6/§7/§8 precedent, no stability reviewer). 339 → 338 units; typecheck + tests green. lilleman 2026-06-20 16:50:26 +02:00
  • c28b3056ca §9 comment-density pass over the §9 accretion; two targeted dedupes (the rest authored dense, per the §6/§7/§8 precedent). (1) logger.ts: the requestStore declaration comment restated the file header (ambient per-request Log → deep modules join the trace without threading) and the accessor docs below it — collapsed to a one-line label. (2) security-headers.ts: dropped the redundant 'applied to every response' note above securityHeaders() (the file header + function name already say it). No stale forward-refs remain; README §9 sections left untouched (concise, _(planned)_ already swept in §8). Docs/comments-only; typecheck + 339 units green. lilleman 2026-06-20 16:45:58 +02:00
  • 66ea68a91b §9 whole-project arch+product review pass (todo §9); ran systems-architect + product-owner on the whole project (no Critical/High — a converged scaffold) and addressed the in-scope §9 customer-facing/security findings. (1) return_to deep-link login, open-redirect-safe: a gated request hit while signed out (plugin-route gate, requireSession, requireAdmin) bounces to /login?return_to=<host-relative path> via new loginRedirect(ctx) (GET/HEAD, skips /); /login bakes it into the Kratos flow — a host-relative target is wrapped through <origin>/auth/complete?return_to=<path> so the JWT mints before landing, an absolute target (§6 OAuth2 login challenge) passes to Kratos as-is; /auth/complete redirects to the requested page. (2) safeUrl()+localPath() in new pure src/safe-url.ts: safeUrl sanitises an untrusted href/src to relative-or-http(s) (else "#"), exported via plugin-api.ts (closes the contract's "planned for §9" pointer); localPath is the host-relative redirect-allowlist guard for return_to, re-checked at both /login and /auth/complete. (3) honest 503 on Ory-unreachable sign-in (views/503.ejs) instead of the misattributed catch-all 500; expired-flow 4xx still restarts. Tests-first throughout; stability-reviewer APPROVE (addressed its Medium — scoped the 503 catch so a template bug hits the 500 with a stack, not a 503). typecheck + 339 units + full scripts/ci.sh gate green. Deferred with justification: the app.ts route-table refactor (standalone change + §10 prereq), mock dashboard + public-page blessing (§10 lines 139/140), success-flash (known). lilleman 2026-06-20 16:38:57 +02:00
  • 1118d7a9f7 §9 refresh README Layout (todo §9); the _(planned)_ markers were already dropped as each piece landed (none remain; Status paragraph reflects the built state). Refreshed the drifted Layout block: added the three source modules it was missing — fetch-timeout.ts (withTimeout, the Ory outbound-call deadline wrapper, §8), guards.ts (requireSession/can/check in-handler authz + GuardError, §4), hooks.ts (runBoot/Request/ResponseHooks plugin lifecycle, §2) — plus scripts/ci.sh (the full CI gate, §8). Cross-checked mechanically: every non-test src/*.ts and every top-level dir (bar node_modules) now has a line; public/plugins/examples descriptions still match their contents. Docs-only. lilleman 2026-06-20 15:58:37 +02:00
  • 3c633e5ebd §9 JWT signing-key rotation runbook (todo §9); turned the README's 3-line rotation note into an operational runbook and closed the tooling gap that made its documented steps unrunnable — the old "prepend a key / drop it later" meant hand-editing a JSON file holding a private signing key. Tests-first: new pure rotateJwks(current,{prune}) in gen-jwks.ts — --prepend puts a fresh ES256 key first (Kratos signs with keys[0], the old keys still verify in-flight JWTs) and keeps the rest in order; --prune keeps only the newest (drop superseded post-TTL). CLI reads the existing set from a path arg and writes the new set to stdout (header documents the temp-file redirect so the shell's > can't truncate the input). gen-jwks.test.ts covers prepend (length+1, fresh kid first, old set preserved) + prune (→ 1 key). Runbook documents the two-sided install (Kratos signer env/mount + web JWKS_URL; file:// hot-reloads, base64:// immutable), why it's zero-downtime (sign-with-first + verify-by-kid), the scheduled path (prepend → restart kratos → verify new kid → wait ~12min = 10m TTL + skew → prune; rollback before prune) and the emergency path (replace with a single key → every leaked-key token fails signature → forced re-login; the §9 denylist is moot since the signature is already invalid). Verified the CLI live against the committed dev JWKS (bare→1, --prepend→2 with old kid second, --prune→1); jwks.json untouched. Docs/CLI-only, covered by units (per the §9 precedent, no new browser E2E). README Status + Layout updated. typecheck + 335 units green (333 → 335). lilleman 2026-06-20 15:54:17 +02:00
  • bea9a71d6f §9 trace all fetch + ENV service name + leveled logging (todo §9 follow-up); route every outbound fetch through the request logger, make the OTLP service name implementer-configurable, and add proper leveled logging throughout. An AsyncLocalStorage<Log> makes the per-request logger ambient (runWithLog/currentLog), so all outbound fetch traces with no signature churn: tracedFetch (a typeof fetch) routes through the active request log (client span + propagated W3C traceparent) for string/URL inputs, else plain fetch; server.ts wires it under the Ory timeout into every Kratos/Keto/Hydra + JWKS call (timeout still honoured — log.fetch spreads {...init,headers}). RequestContext gained ctx.log (request logger; additive/contract-stable, silent default) so a handler/plugin logs in-trace and ctx.log.fetch(url) traces upstream calls; the reference plugin's createUpstream defaults to tracedFetch and its handlers log via ctx.log; plugin-api.ts exports tracedFetch + the Log class. SERVICE_NAME (config + createLogger({serviceName})) brands the OTLP service.name. Leveled logging: who-did-what audit info lines on every admin write (user/group/role/client create·delete·assign — actor/target, no secrets), info on login (session mint) + logout, warn on missing-role 403 + CSRF rejections + Ory-unreachable, debug on a JWKS kid-miss reload. app.ts's handler body was extracted to handleRequest run inside runWithLog; end() now fires exactly once after BOTH the handler unwinds AND the response closes, so a client abort mid-handler can't end the log out from under a still-running ctx.log/tracedFetch (regression-tested) and the happy-path access line is never dropped. bootstrap.ts wraps main in runWithLog + traces the seed calls. Tests extended (logger: serviceName/runWithLog/currentLog/tracedFetch-continues-trace; config: SERVICE_NAME; context: ctx.log default+passthrough; app: ctx.log in-trace + ctx.log.fetch propagation + the abort race; plugin-api: tracedFetch+Log). Stability-reviewer: APPROVE, no Critical/High (fixed the abort-race end(); green nits addressed). docs/plugin-contract.md (ctx.log/ctx.log.fetch/tracedFetch) + README (config, Observability tracing/serviceName, plugin note, Layout) updated. typecheck + 333 units + the full scripts/ci.sh E2E gate green (326 → 333). lilleman 2026-06-20 15:46:48 +02:00
  • a9e3dedbb4 §9 structured logging + OTLP observability (todo §9); structured, OTLP-native logging on @larvit/log (2.3.0, pinned; itself zero-dependency — the one new runtime dep). New pure src/logger.ts: createLogger() builds one app Log tagged service.name=plainpages (level/format/OTLP from config, injectable stdout/stderr); requestLogger() clones it per request (own root trace, inheriting level/format/streams/OTLP) into a "request" span, adopting an inbound W3C traceparent so a request continues an upstream proxy's distributed trace (malformed ⇒ fresh trace; clone honours a passed traceparent while dropping the parent's, unlike parentLog). app.ts builds the per-request log at the top of the handler and on res "close" (fires on completion AND abort, unlike "finish") emits one access line (method/path-without-query/status/ms/requestId, guarded) then end()s to flush the span (fire-and-forget .catch — a flaky collector never crashes a served request); the catch-all 500 + Ory-unreachable re-mint now log via reqLog.error/warn; static.ts mid-stream error takes an injected onError. server.ts builds the app logger, logs discovery/listen/shutdown, end()-flushes on SIGTERM/SIGINT (re-entry-guarded). bootstrap.ts events go structured (the human first-run banner stays raw). Config (environment-agnostic, fail-loud): LOG_LEVEL (info), LOG_FORMAT (text; prod compose → json), OTLP_ENDPOINT (unset ⇒ console-only; set ⇒ export logs + spans to an OTel Collector), OTLP_PROTOCOL (http/json|http/protobuf). compose: base sets LOG_FORMAT=json, dev override flips it to text. Tests-first: logger.test.ts (service.name/severity/level-gate/format, OTLP-only-when-endpoint, a stubbed-fetch proof it POSTs /v1/logs, requestLogger context-merge/own-root-trace/traceparent-continue/malformed-ignored), config.test.ts (4 toggles + validation), app.test.ts (live request emits the JSON access line), compose.test.ts (prod json / dev text). Stability-reviewer: APPROVE, no Critical/High (addressed both yellow nits — guarded access line + "finish"→"close" so aborted requests log; shutdown re-entry guard — and the green ones). README (config table, new Observability section, Status, Layout, runtime-deps) + AGENTS (deps) updated. typecheck + 326 units green (317 → 326). lilleman 2026-06-20 02:11:10 +02:00
  • a8a018f3e5 §9 optional revocation denylist (todo §9); closes the documented ~10m role/session lag for security-critical revoke, off by default (REVOCATION_DENYLIST, zero hot-path cost + zero behaviour change when off). New pure src/denylist.ts (createDenylist({ttlSec})): an in-memory, auto-evicting Map<sub, revokedAt> — revoke(sub) records now, isRevoked(sub, iat) rejects a subject's tokens minted at/before the revoke (iat <= revokedAt; missing iat fails closed), so a fresh re-login (iat after the revoke) passes while a downgrade lands immediately. Entries self-evict after REVOCATION_TTL_SEC (default 900 ≥ the 10m tokenizer TTL + skew), so it stays a bounded cache like JWKS — no database, Keto stays off the hot path. Wired: jwt-middleware.ts takes the denylist in VerifyOptions and throws TokenError(expired) on a revoked sub, so resolveSession routes it through the existing §4 re-mint (live session → fresh post-revoke JWT with current Keto roles; dead/deactivated → cleared cookie). app.ts merges it into authOptions (the same resolveSession hot-path call) and hands a bound revoke to the Users + Roles admin deps; admin-users.ts revokes on deactivate/delete, admin-roles.ts revokes a direct user: member on assign/unassign (a group:/whole-role change is transitive → left to lag, documented). server.ts builds it only when the toggle is on. Tests-first: denylist.test.ts (iat semantics, cutoff-advance, TTL eviction), jwt-middleware.test.ts (revoked→expired→re-mint, fresh passes), config.test.ts (toggle + posint TTL), app.test.ts (hot-path reject + fresh-login pass; admin deactivate/role-assign/unassign record the revoke). Stability-reviewer on the diff: APPROVE, no Critical/High/Medium (addressed its one Low). Per the §9 security-headers precedent, covered by unit + app-HTTP integration (no new browser E2E — no new user-facing page). README (Auth trade-off + new "Instant revoke" subsection, config table, Layout) updated. typecheck + 317 units green. lilleman 2026-06-20 01:38:20 +02:00
  • 9d22c75016 §9 response security headers (todo §9); the cookies/CSRF/clock-skew parts of this item all landed in §4 (HttpOnly/SameSite/Secure cookies in cookie.ts, the signed double-submit in csrf.ts, JWT_CLOCK_SKEW_SEC leeway on exp+nbf in jwt-middleware) — the open gap was response security headers, now closed. New pure src/security-headers.ts (securityHeaders({secure})): a strict CSP for the zero-JS core — script-src 'self' with NO 'unsafe-inline' (an injected <script> can't run; core ships none, a plugin may still serve its own /public/<id>/*.js), style-src adds 'unsafe-inline' for the partials' inline style=, img-src 'self' data:, frame-ancestors 'none', object-src 'none'; form-action deliberately omitted (the themed login POSTs to Kratos' often-cross-origin action URL) — plus X-Content-Type-Options nosniff, X-Frame-Options DENY, Referrer-Policy strict-origin-when-cross-origin, Cross-Origin-Opener-Policy same-origin, and HSTS only when secureCookies (https; ignored on dev http). Wired in app.ts: precomputed once at boot, res.setHeader'd at the very top of the handler before any branch, so every response (page/json/redirect/static/error/plugin) inherits them via writeHead's merge; a plugin overrides per-route via RouteResult.headers. Verified no view/CSS loads cross-origin (no <script> anywhere, no external fonts/CDNs), so default-src 'self' breaks nothing. Tests-first: security-headers.test.ts (strict defaults, script-src has no 'unsafe-inline', HSTS-only-on-secure) + an app.test.ts integration (the page and a static asset both carry the headers; HSTS toggles with SECURE_COOKIES). Stability-reviewer on the diff: APPROVE, no Critical/High (Low: a CDN/absolute branding logo would be CSP-blocked → documented the same-origin-logo constraint). README Status + Production + Layout updated. typecheck + 312 units green. lilleman 2026-06-20 01:18:24 +02:00
  • b3b51db52b §9 prod compose secrets (todo §9); the base compose.yml was already the full prod stack (web + Postgres + Kratos/Keto/Hydra + migrations + bootstrap, no source mount) but set REQUIRE_SECURE_SECRETS=true without ever passing CSRF_SECRET into web, so docker compose -f compose.yml up couldn't boot. Wired CSRF_SECRET: ${CSRF_SECRET:-dev-insecure-csrf-secret} — env-supplied with the throwaway as the only fallback; config.ts's existing REQUIRE_SECURE_SECRETS logic rejects that throwaway so a forgotten prod secret fails loud (verified prod-unset→reject, prod-set→real, dev→throwaway+toggle-off→boots). Used :- not :? because compose interpolates the base per-file before merging the dev override (confirmed empirically), so :? would also break the zero-config dev up. Tests-first: compose.test.ts guards secret-via-env + no-source-mount + prod/dev toggle split + postgres-creds-via-env. README prod section corrected (dropped the stale planned note). typecheck + 310 units green. lilleman 2026-06-20 01:05:15 +02:00
  • 56047815a0 §8 test cleanup (todo §8); pass over the §8 test accretion. Two genuine combines, the rest of §8's changes were already woven into existing tests as assertions (recoverHref→flow-view, parseJwks key-shape→jwks, ORY_TIMEOUT_SEC→config, empty-state→data-table) — no fat. (1) Deferred L3: plugins/scheduling/shifts.test.ts imported four deep src/* internals (chrome/context/guards/plugin), none the documented-stable surface — repointed all four to the src/plugin-api.ts barrel (the one contract boundary, which re-exports them), so the test models the dev/test story the contract preaches, exactly like shifts.ts does (no coverage change). (2) app.test.ts had two adjacent tests for the same surface (themed-auth GET dispatch): "themed flow init" + "already-signed-in sent home", the latter literally re-asserting the former's anonymous-init — merged into one "themed auth GET: anonymous inits a flow …; a signed-in user is sent home, except /settings", all assertions preserved. Left separate: the four distinct-stack E2E suites + app.test.ts's one-per-surface integration tests. Pure test refactor, no production code (no stability reviewer, per the §6/§7 precedent). 310 → 309 units; typecheck + tests green. lilleman 2026-06-20 00:55:30 +02:00
  • 3900df87d6 §8 comment cleanup (todo §8); targeted density pass over the §8 accretion. The §8 modules (scripts/ci.sh, the browser-E2E harness — proxy.mjs/mock-oidc.mjs/full-flow.spec.ts + the e2e overlays — fetch-timeout.ts, and the §8-review comment additions across app/config/jwks/admin-users/flow-view/data-table) were authored dense, each comment carrying a real non-obvious why, so two wins: fixed a stale forward-ref (config/menu.ts logo said 'next §2 item' but §2 wired branding into the shell long ago → 'rendered in the sidebar brand'); trimmed a locally-duplicated restatement (full-flow.spec.ts header re-listed coverage already enumerated by the test() titles below + the README — collapsed to point at them, kept the non-derivable gateway/fresh-stack/parallel-safety framing). README §8 additions already concise; Status/_(planned)_/Layout refresh stays §9. Docs/comments-only; typecheck + 310 units green. lilleman 2026-06-20 00:49:19 +02:00
  • a20f3507e0 §8 review convergence (todo §8); re-ran the architecture + product reviewers to convergence — 5 rounds, until both returned zero new actionable findings. Fixed across rounds 1-4 (tests-first): bounded every outbound Ory fetch with a timeout (src/fetch-timeout.ts withTimeout + ORY_TIMEOUT_SEC default 5, incl. the http JWKS fetch) so a hung Ory can't park a request handler; anonymous on a permission-gated plugin route now 303→/login (was a dead-end 403; signed-in-without-role still 403); an already-signed-in user is sent home from /login + /registration; the onRequest hook short-circuit now sets the fresh CSRF cookie; admin-users malformed :id → 404 (was 500) via safeDecode; parseJwks validates key element shape (fails loud at load); removed the dead COOKIE_SECRET (loaded + enforced + documented but never read); documented HYDRA_ADMIN_URL; admin recovery shows the code + links to the public /recovery instead of the browser-unreachable admin-API link; reference-plugin breadcrumb-label + pagination/datetime README notes; corrected the contract doc to not over-promise a post-login "retry". Declined: unconditional base-ctx chrome (would build the menu per request, regressing the lazy hot path). Deferred → §9: return_to-preservation for deep-link login. Stability-reviewer on the cumulative diff: APPROVE, no Critical/High (addressed its Low nits). typecheck + 310 units + the full scripts/ci.sh gate (visual 9 · auth 1 · oauth 2 · full 6) green. lilleman 2026-06-20 00:42:23 +02:00
  • bd20d00714 §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. lilleman 2026-06-19 20:08:48 +02:00
  • 9d77f6ad17 §8 full browser E2E (todo §8); the real Playwright UI against the live stack — the browser-UI flows the earlier full-stack suites deferred here. New e2e/full-flow.spec.ts + compose.e2e-full.yml covering password login, mocked SSO, menu filtering by role, users/groups/roles CRUD, a permission-gated plugin page, and logout (6/6 green on a clean stack, then torn down). Same-origin gateway (e2e/proxy.mjs, stdlib reverse proxy) fronts web + Kratos on one host so the browser's cookies round-trip (the themed form posts straight to Kratos); ory/kratos/e2e-proxy.yml points Kratos at it + --dev so cookies aren't Secure over http. SSO backed by a stdlib mock OIDC provider (e2e/mock-oidc.mjs, RS256 id_token, nonce-bound codes). Found + fixed a real bug the E2E surfaced: the SSO submit button shares the form with the required email/password fields, so HTML5 validation blocked it — added formnovalidate to the SSO buttons (auth-card.ejs), tests-first. Stability-reviewer APPROVE, no Critical/High (every dev/insecure knob is e2e-overlay-scoped, base/prod compose unaffected). typecheck + 305 units green. Also marks the §8 E2E-harness item (full stack up + seeded admin/Keto roles + tear-down). lilleman 2026-06-19 19:28:17 +02:00
  • 1961a4c163 §8 unit coverage audit (todo §8); node --test units across helpers/router/nav/auth. Built tests-first through §0-§7, coverage was already near-complete — every helper/router/nav/auth module carries direct units (static.ts via app.test.ts). Closed the one genuine gap: admin-nav.ts's pure nav helpers (adminSection/adminNav) and security-critical auth gates (requireAdmin/guardedForm, the shared gate+CSRF preamble for every admin write) were only exercised indirectly via the admin HTTP tests. New src/admin-nav.test.ts: adminSection (gated header + current/open), adminNav (Dashboard prepend + role-filter), requireAdmin (401/login, 403, user), guardedForm (valid double-submit / bad-token-403 / non-POST-undefined), buildConfirmModel. Only server.ts (entry-point composition root) has no dedicated unit. 300 → 305 units; typecheck + tests green. Tests-only, no production code. lilleman 2026-06-19 15:53:20 +02:00
  • 29737d65a0 §7 test-cleanup (todo §7); pass over the §7 test accretion. Written tests-first across four small commits, so unlike §6 there was no boilerplate triplication — the per-module matrices are one-contract-per-test and the reference plugin's shifts.test deliberately unit-tests its pure builders, so those stay. One genuine merge: dashboard.test had two sibling tests asserting the same contract (buildDashboardModel role-filters a gated nav source via composeNav) on two sources — the §5 Admin section + the §7 plugin fragments. Combined into one "dashboard role-filters the gated Admin section and plugin fragments, each independently" that also strengthens coverage with cross-gating assertions (admin doesn't see the plugin section, a scheduling:read holder doesn't see Admin) neither original checked; all prior assertions preserved, 3 model builds vs 4. Left separate (distinct functions/levels): the chrome-unit vs dashboard-unit vs app-HTTP plugin-nav tests, and the two app.test plugin integration tests (RouteResult shapes vs chrome+CSRF). Pure test refactor, no production code. 301 → 300 units; typecheck + tests green. lilleman 2026-06-19 15:46:17 +02:00
  • 98784a3239 §7 comment cleanup (todo §7); targeted density pass over the §7 accretion. The §7 modules were authored dense (the reference plugin is a teaching artifact, the host additions concise), so two wins: tightened chrome.ts's module header (7→5 lines, dropped the input-list duplicated by ChromeOptions + the nav-composition restatement already carried by the nav field/markCurrent comments); fixed a stale forward-ref in docs/plugin-contract.md (the safeUrl() helper said "§5/§7" but §7 deferred it to §9). Left intact: the reference plugin's instructive comments, the EJS view config-doc headers, and the contract doc + plugin README (authored concise in §7). README Status/_(planned)_/Layout refresh stays §9. Docs/comments-only; typecheck + 301 units green. lilleman 2026-06-19 15:38:36 +02:00
  • 4e97fb619e §7 review checkpoint (todo §7); ran the architecture + product reviewers on the whole project and addressed findings, no Critical from either. Made permissions honest + decoupled the host from the plugin: new pure seedRoles + bootstrap discoverPlugins() seeds the demo admin admin(/ADMIN_ROLES) ∪ every discovered plugin's declared tokens, dropped the hardcoded scheduling:* from compose ADMIN_ROLES (clean-clone unchanged); docs now state a route/nav permission is a coarse role granted as Keto Role:<token>#members. Added src/plugin-api.ts — the stable author barrel the reference plugin now imports from instead of deep src/* (the contract boundary in code). Made per-plugin CSS usable: shell styles slot + plugins/scheduling/public/scheduling.css linked from the views. Reference now demonstrates hooks.onBoot validating SCHEDULING_UPSTREAM fail-loud (assertHttpUrl). Build ctx.chrome at most once per request (memoized). Doc honesty: fixed the false visual.spec coverage comment, softened the "every plugin ships a Playwright test" claim (authed flow = §8), added an Upstream contract block to the plugin README. Added LICENSE (MIT). Stability-reviewer APPROVE, no Critical/High; addressed both Low nits. typecheck + 301 units green. Deferred: internal route-table (M1)→§9, safeUrl()→§9, data-table empty-state + success-flash→§8/polish, apiVersion-literal enforcement (prose), permission→requireRole rename (future minor). lilleman 2026-06-19 15:31:53 +02:00
  • 45d9b2ede9 §7 verify plugin contract end-to-end vs README (todo §7); cross-checked every contract claim in README/docs/plugin-contract.md/plugins/scheduling against the implementation + shipped reference plugin — the contract holds (derived id/mount, apiVersion+checkApiVersion, gated nav/routes, all RouteResult shapes, ctx.chrome native shell, ctx.verifyCsrf, can()/GuardError, view-resolver including core + own partials, registered icons, upstream-fetch statelessness). Fixed one doc drift: docs/plugin-contract.md reserved-id list named 8 ids but RESERVED_PLUGIN_IDS has 10 — the §5 admin + §6 oauth2 mounts were missing (discovery refuses them, test-covered), so the doc now lists all 10. Docs-only; typecheck + 296 units green. lilleman 2026-06-19 15:02:59 +02:00
  • f189f88942 §7 reference plugin (todo §7); plugins/scheduling is the worked example of the plugin contract — a list page fetching upstream data, a CSRF-guarded form forwarding writes upstream, permission-gated nav. shifts.ts: an injectable-fetch upstream REST client (stateless stand-in for the customer backend) + thin handler factories (list filters by ?q + degrades to a recoverable page on upstream-down; create CSRF-guards via ctx.verifyCsrf, validates, forwards, PRG, 502 on upstream 4xx). plugin.ts: apiVersion literal, namespaced scheduling:read/write perms, nav gated so the whole Scheduling header vanishes for non-holders. Views compose the core building blocks around the native app shell, incl. the plugin's own partials/shift-form. New host capability so a plugin page is native + secure (src/chrome.ts buildPluginChrome): ctx.chrome = brand/global-nav/user/theme/csrf for partials/shell (global menu = Dashboard + every plugin nav fragment + gated admin section, role-filtered + current-marked); ctx.verifyCsrf = the host's bound double-submit verifier (secret stays in the host). Both added to RequestContext (defaulted in buildContext), built per plugin route in app.ts (CSRF cookie set when fresh). Dashboard merges plugin nav fragments too (gated => invisible to anonymous, visual E2E byte-identical). Out of the box: bootstrap grants the demo admin scheduling:read/write (seedAdmin generalized to a roles list, env ADMIN_ROLES); dev compose runs a tiny stdlib mock upstream (examples/shifts-upstream, SCHEDULING_UPSTREAM). plugins/ added to tsconfig + the npm test glob. Tests-first across shifts/chrome/app/dashboard/bootstrap. README Building-a-plugin + Layout and docs/plugin-contract.md (ctx.chrome/verifyCsrf, upstream pattern) updated. typecheck + 296 units + the Ory-free visual E2E green (plugin discovered at boot, routes/nav gated, dashboard unchanged); live full-stack boot-verified (stack up with plugin + mock upstream serving the seeded shifts, bootstrap grants in real Keto all allowed:true) then torn down. apiVersion stays 1.0.0 (contract still assembled in §7). Authenticated browser happy-path deferred to §8 full E2E (line 114). lilleman 2026-06-19 14:48:27 +02:00
  • ec7dcafecd §6 test-cleanup (todo §6); unified the genuine OAuth2/Hydra test overlaps. The stale-4xx→400 / outage-5xx→500 degrade was triplicated across the app.test.ts /oauth2/login, /consent, /logout tests with near-identical app-spin-up boilerplate — production aims for byte-identical degrade across the three, so it's now one parametrized test iterating the three endpoints × {410→400, 503→500} (removes ~27 lines, makes the shared contract explicit/enforced); the three endpoint tests keep their happy-path + missing-challenge→400. oauth-consent.test.ts: merged the two consent-screen view tests (account named when signed in / omitted otherwise — same view surface) and the two acceptConsent grant tests (scope re-read + id_token on subject-match / omitted on mismatch — same method's grant body). Pure test refactor, no production code touched, every assertion preserved. The per-module matrices (hydra-admin/oauth-login/admin-clients, one contract per test) carry no fat. 279 → 278 units; typecheck + tests green. lilleman 2026-06-19 12:02:13 +02:00
  • d011bf2589 §6 comment cleanup (todo §6); tightened the OAuth2 handler comments in app.ts — the three sibling /oauth2/{login,consent,logout} blocks had drifted toward repeating the same prose. Trimmed the login block header (dropped derivable "looked up over Hydra's admin API" + condensed the provider-role line to "Provider-only.", matching the consent/logout blocks) and collapsed the consent 4xx→400/5xx→500 degrade comment to the one-liner the logout sibling already uses, so the canonical explanation lives once (the login block). §6 modules (hydra-admin/oauth-login/oauth-consent/admin-clients) were authored dense — no changes. Left intact: the logout GET-accept safety rationale (reviewer-requested), the EJS view config-doc headers, and the §6 README sections (authored concise in §6). README Status/_(planned)_/Layout refresh stays §9. typecheck + 279 units green. lilleman 2026-06-19 11:53:20 +02:00
  • 521c09fa2d §6 review checkpoint (todo §6); ran the architecture + product reviewers on the whole project (weighted to the Hydra OAuth2 surfaces) and addressed their findings — no Critical from either. Fixed tests-first: (HIGH, arch) /oauth2/logout was published to Hydra (hydra.yml urls.logout) and asserted in hydra.test.ts but had no handler — a dead/published contract; added hydra-admin.acceptLogoutRequest (PUT logout/accept via the shared reqUrl(kind…)) + a GET /oauth2/logout branch that accepts the RP-initiated logout_challenge → 303 to Hydra's post-logout redirect (missing→400, stale 4xx→recoverable 400, 5xx→500, byte-identical degrade to the login/consent siblings; GET-accept is safe since the challenge is Hydra-minted+single-use; the first-party POST /logout still owns ending the Kratos session + JWT cookie). (HIGH, arch) added oauth2 to RESERVED_PLUGIN_IDS so a plugins/oauth2/ folder can't silently shadow the provider routes (the route surface the §4 reserved-id fix missed; discovery now refuses it loud). (Product Blocker) the third-party consent screen now names the signed-in account — "Signed in as <email>" (ConsentView.account from whoami) — plus a CSRF-guarded "Not you? Sign out" form, so consent is informed on shared devices. (MEDIUM, arch) consent accept() now projects id_token claims only when the live Kratos session subject === the challenge subject Hydra bound at login, never leaking a mismatched session's email/name into the issued token (guards the auto-accept path too). (Product nits) register-form confidential-vs-public guidance + a client-detail "delete and re-register / secret shown once" note (no-edit friction + lost-secret). New tests across discovery (reserved oauth2), hydra-admin (acceptLogoutRequest contract), oauth-consent (subject-match + account-in-view), app.test (logout 303/400/500 matrix, consent identity+sign-out, client form/detail copy); e2e/oauth-login.spec asserts the consent screen names the account. Stability-reviewer run as a local PR: APPROVE, no Critical/High — addressed its doc/comment follow-ups (README §6 documents the logout handler + consent identity line; a comment notes the GET-accept is Hydra-validated). Deferred (reviewer-scoped): the host internal route-table (arch M1, now a pure dedup once H1/H2 are point-fixed) → §9; the RP-initiated-logout browser/live E2E → §8; redirect-URI scheme allowlist + safeUrl() → §7; full client edit / empty-list state / success-flash → §8/polish. typecheck + 279 units green; full-stack OAuth2 login+consent E2E verified live against real Hydra v26.2.0 then torn down. lilleman 2026-06-19 11:47:06 +02:00
  • 1c324b18e3 Built-in OAuth2 client-registration admin screen (todo §6); /admin/clients lists/registers/deletes the Hydra OAuth2 clients other apps log in through us with. New src/admin-clients.ts (pure builders + handleAdminClients, mirroring the §5 Users/Roles screens): list (search/paginate over one fetched Hydra page), register (GET form + POST), read-only detail, delete-confirm. src/hydra-admin.ts gains the client half of the admin API — createClient/listClients/getClient/deleteClient over /admin/clients (+ a nextPageToken Link parser like kratos-admin) and the registration fields on OAuth2Client. Register builds a standard authorization-code client (+ refresh_token), confidential (client_secret_basic) or public (PKCE/none), with an optional first-party auto-consent flag; Hydra returns the client_secret once, so the register POST renders the new client's detail page with the one-time secret directly (no PRG) and it is never re-shown (getClient carries no secret; the detail test asserts it). Writes go only to Hydra; gated admin-only (anon->/login, non-admin->403) + every mutation CSRF-guarded via requireAdmin/guardedForm like §5; a Hydra 4xx (bad redirect/scope) re-renders the form (400), a 5xx -> 500 (mirrors oauth-login.ts); :id via safeDecode (malformed->404). Wired into app.ts (/admin/clients, gated on the hydra client present) and the shared adminSection (Users.Groups.Roles.OAuth2 clients, i-globe) so it shows for admins and is invisible otherwise. New views (admin/clients, client-form, client-detail + partials/client-{form,detail}-body) reuse the shell/filter-bar/data-table/field blocks; one .detail-list CSS rule; README Layout/§6 updated. Tests-first: hydra-admin.test.ts (client CRUD contracts incl. Link pagination/404->null/204), admin-clients.test.ts (builder/validation/payload matrix), app.test.ts HTTP integration (gate/list/register-shows-secret-once/invalid+CSRF-reject/detail-hides-secret/delete + malformed-%->404). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one nit (dropped a dead URL.protocol check in validateClientInput). Boot-verified the client CRUD live against real Hydra v26.2.0 (create->201 w/ one-time secret -> list finds it -> get -> delete -> get null); torn down. typecheck + 274 units green. lilleman 2026-06-19 11:23:27 +02:00
  • 0900bf49bd Built-in OAuth2 consent-challenge handler (todo §6); /oauth2/consent grants scopes to a client logging in through us. New src/oauth-consent.ts (pure, sibling of oauth-login.ts): resolveConsentChallenge auto-accepts a first-party client (Hydra metadata.first_party===true) or a Hydra-skipped one, else returns a view to show the themed consent screen; acceptConsent re-reads the challenge so scopes/audience are never client-supplied; rejectConsent → access_denied. The grant carries an OIDC session.id_token with email/name projected from the Kratos identity (whoami traits, omitted when absent). src/hydra-admin.ts gains the consent half (get/accept/reject consent + types; login/consent URL builder folded into one reqUrl(kind,…) + shared put()). Wired in app.ts at GET|POST /oauth2/consent (gated on hydra+kratos): GET shows/auto-accepts (sets the CSRF cookie when fresh), POST is CSRF-guarded (same signed double-submit as /logout) and dispatches allow→accept / else→reject → 303 to Hydra; a stale/consumed challenge (Hydra 4xx) degrades to a recoverable 400, a real outage (5xx) → 500 (mirrors /oauth2/login). views/oauth-consent.ejs + partials/consent-body.ejs reuse the auth-card, listing the requested scopes (friendly labels for the standard OIDC ones) with Allow/Deny submit buttons. Tests-first: hydra-admin consent contracts + oauth-consent skip/first-party/third-party/audience/id_token/refetch/reject matrix + app HTTP integration (auto-accept / screen+CSRF cookie / allow+deny / forged-CSRF→403 / missing→400 / stale→400 / outage→500). Stability-reviewer run as a local PR: APPROVE, no Critical/High. Extended e2e/oauth-login.spec.ts to drive the whole authorization-code flow against real Hydra — login accept → follow login_verifier through Hydra → web's consent screen (third-party e2e-login, scopes listed) → Allow → consent_verifier → client callback with a real code (per-host cookie jars, Hydra resume URLs rebased onto the compose host). typecheck + 262 units + 8 visual + OAuth login+consent E2E green. OAuth2 client registration is the next §6 item. lilleman 2026-06-19 10:53:21 +02:00
  • 3c8090e8e3 Built-in OAuth2 login-challenge handler (todo §6); /oauth2/login resolves a Hydra login challenge via the Kratos session — skip→accept(subject), live session→accept(identity id), no session→bounce to /login?return_to back here so Kratos lands on the challenge once signed in. New src/hydra-admin.ts (fetch client: get/accept/reject login request + HydraError, mirrors the kratos/keto clients) + src/oauth-login.ts (pure resolveLoginChallenge); wired in app.ts (the absolute return URL derives from the request Host + the SECURE_COOKIES scheme — a spoofed Host can't escape, Kratos validates return_to against its allow-list; /login now bakes return_to into the flow init), config.hydraAdminUrl (default http://hydra:4445), server builds the client, compose web now gates on hydra healthy (the app consumes it). A stale/invalid/consumed challenge (Hydra 4xx — back button, slow login) degrades to a recoverable 400, not a 500; a genuine Hydra 5xx outage still surfaces as 500. Tests-first: hydra-admin/oauth-login units + app/config/compose HTTP integration + full-stack e2e/oauth-login.spec.ts (compose.e2e-oauth.yml — registers an OAuth2 client, starts an auth flow, asserts the unauthenticated bounce and the authenticated accept; boot-verified then torn down). Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its one warning (4xx→400 degrade). Deferred §9: document that prod allowed_return_urls entries must be exact origins with a trailing /. typecheck + 253 units + 8 visual + oauth-login E2E green. Consent handler + client registration are the next §6 items. lilleman 2026-06-18 21:45:24 +02:00
  • bfc9f80b61 Unify the §5 admin-test duplication (todo §5 test cleanup); the three admin-screen HTTP tests (Users/Groups/Roles) in app.test.ts repeated an identical ~13-line harness preamble (createApp+listen+url+CSRF token+admin cookie+get/post), an identical gate block, and a stateful in-memory KetoClient defined 3× (trivial stubKeto + two byte-identical inline fakes). Extracted adminHarness(t,opts)→{url,token,get,post}, assertAdminGate(url,get,path), and one fakeKeto(tuples?,over?) that subsumes stubKeto (login tests now fakeKeto([],…)) and both admin fakes (fakeKeto(tuples) / fakeKeto(tuples,{expand})); hoisted shared sameSet/matchesTuple up beside it. Per-module unit files keep their matrix pattern (no force-merge across modules; build*ListModel stays per-file). −30 net lines, zero coverage lost; typecheck + 244 units green. lilleman 2026-06-18 19:34:39 +02:00
  • 0a5eafd2f8 Tighten §5 admin comments + README (todo §5 cleanup); compress the three near-identical admin module headers (drop restatement the README/code already carry), shorten the README Layout views/ run-on + add the missing delete-confirm view. Also bank a pre-existing AGENTS.md tweak: skip the stability-reviewer for purely doc/comment changes. Docs/comments-only — typecheck + 244 units green. lilleman 2026-06-18 19:25:02 +02:00
  • c78e95889c Address whole-project architecture + product reviews (todo §5): make readRoles transitive so group→role grants reach the JWT (matches the Roles 'Effective access' view + OPL model; per-login only), per the user's call; add a zero-JS server-rendered confirm step for delete user/group/role (views/admin/confirm.ejs + shared buildConfirmModel; the Delete control is now a GET link, the delete stays a CSRF-guarded POST); self-lockout guards — no self-delete/deactivate (Users), no self-revoke of the direct admin grant + no delete of the admin role (Roles), each → 400 + inline error (direct-grant paths incl. the seeded admin; group-only-admin lockout = robust last-effective-admin check deferred §9); extract the gate+CSRF preamble copied across the 3 admin handlers into admin-nav.ts requireAdmin/guardedForm; shellUser keeps the email (name = local part, full email beneath). Reviewers: architecture no Critical/High, product 2 Critical + 1 High (all fixed). Deferred (scoped): host route-table→§6, list/template dedup→§5 cleanup, success-flash/empty-states/dangling-refs→§5 polish/§8, safeUrl→§7, 413/https/§N-drift→§9. Tests-first (extended the 3 admin HTTP tests + login/shell-context units); typecheck + 244 units + 8 visual + auth-refresh E2E green; stability-reviewer APPROVE lilleman 2026-06-18 19:18:50 +02:00
  • 6920751cb8 Wire built-in admin screens into the global menu (todo §5); extract adminSection() = one permission-gated 'Admin' header (Users/Groups/Roles), reused by both the home dashboard menu and the in-screen adminNav so they can't drift. composeNav drops the whole gated header+subtree for a non-admin/anonymous (cosmetic — the admin routes stay independently GuardError(403)-gated); narrowed AdminScreen to groups|roles|users. Reuses existing sprite icons (no icon-guard change); default anonymous / render byte-equivalent so visual E2E unaffected. Tests-first: dashboard model gating (admin→3 hrefs, non-admin→absent) + app HTTP (admin JWT→/admin/users link, anon→absent). Stability-reviewer run as a local PR: APPROVE, no Critical/High/Medium. README Layout updated. 242→244 units + typecheck green lilleman 2026-06-18 18:33:19 +02:00
  • a016a0131e Built-in Roles & permissions admin screen (todo §5); /admin/roles list (search/sort/paginate) + create/delete + assign-to-users/groups + "effective access" (Keto expand → transitive members), writing only to Keto — gated admin-only + CSRF-guarded like Users/Groups (Kratos read only to label members). A role = Keto subject set Role:<name>#members; reuses the Groups membership helpers (now-exported pagedTuples/memberCandidates/safeDecode); added a Roles nav entry (i-shield) + a .plain-list CSS rule. Stability-reviewer run as a local PR: APPROVE, no Critical/High; addressed its explicit-expand-depth nit. Live boot-verify caught a real bug the tests missed — Keto v26.2.0 nests the expand subject under tuple (not node top-level as the §4 ExpandTree type guessed), so expandToEffectiveUsers returned []; fixed type+walker+fixtures, re-verified a group-only member surfaces in effective access. 237→243 units + typecheck green; expand chain boot-verified live then torn down. lilleman 2026-06-18 18:18:18 +02:00
  • 32e5e2f7eb Built-in Groups admin screen (todo §5); /admin/groups list (search/sort/paginate) + create/delete + membership (add/remove users & nested groups), writing only to Keto — gated admin-only + CSRF-guarded like Users (Kratos read only to label pickers). A group = Keto subject set Group:<name>#members, exists while it has ≥1 member: create writes the first-member tuple, delete removes all by partial-filter. Extracted shared admin-nav.ts (Dashboard·Users·Groups); new generic rowHeader <th scope=row> data-table cell. Stability-reviewer run as a local PR: symmetric subject UUID-validation, duplicate-name rejection, malformed-%→404. 228→237 units + typecheck green; core Keto interactions boot-verified live lilleman 2026-06-18 17:40:36 +02:00
  • 79cfa2ee7f Built-in Users admin screen (todo §5); /admin/users list (filter/sort/paginate) + create/edit/deactivate/delete + trigger-recovery, writing only to Kratos via the admin client — gated admin-only (anon→/login, non-admin→403) and CSRF-guarded like logout. New kratosAdmin.createRecoveryCode; reserved the "admin" plugin id; views:[viewsDir] so subfolder views reuse partials/. Reviewer §5 opener: extracted shell-context.ts (buildShellContext/shellUser) shared by dashboard+admin, threading the real signed-in user (drops the hardcoded demo profile). 217→228 units + 8 visual E2E green; boot-verified full CRUD+recovery live on the Ory stack lilleman 2026-06-18 12:26:19 +02:00
  • cb050bd4c1 Combine/unify §4 auth tests (todo §4); fold authenticate's matrix into resolveSession (it's the .user wrapper) and the two /auth/complete HTTP tests into one — 219→217, zero coverage lost lilleman 2026-06-18 12:00:02 +02:00
  • d1fbf8fa1f Comment/README cleanup (todo §4); tighten the kratos/keto client module-headers (drop forward-refs + caller-listings, keep rationale), retarget the stale safeUrl() ref in plugin-contract.md to §5/§7 lilleman 2026-06-18 11:52:49 +02:00
  • caadaf5da3 Reviewer-run fixes (todo §4); re-mint try/catch degrades an Ory outage to anonymous (not 500), RESERVED_PLUGIN_IDS refuses a plugin folder that would shadow a host route lilleman 2026-06-18 11:45:04 +02:00
  • b5af4ba6cd E2E for token timeout + refresh (todo §4); full-stack auth-refresh.spec.ts (real Ory stack): a lapsed session JWT is silently re-minted from the live Kratos session (roles re-read from Keto), and cleared once the session is revoked; ory/kratos/e2e.yml shortens the tokenizer ttl to 8s + adds JWT_CLOCK_SKEW_SEC config so re-mint fires at expiry; scope visual suite to visual.spec.ts lilleman 2026-06-18 11:32:23 +02:00
  • 4b2173cb84 Secure cookie flags + CSRF for our own POST forms (todo §4); SECURE_COOKIES toggle on session/CSRF cookies; csrf.ts signed double-submit token + body.ts form reader; logout is now a CSRF-guarded POST form lilleman 2026-06-18 11:12:32 +02:00
  • dec55f85a6 Logout (todo §4); GET /logout clears plainpages_jwt + revokes the Kratos session (createLogoutFlow → redirect to Kratos logout URL → /login); wire shell Sign out link lilleman 2026-06-18 10:35:07 +02:00
  • 4f6b60463b Session re-mint on TTL expiry (todo §4); resolveSession flags a lapsed token, app.ts hot path re-mints via remintSession (roles re-read from Keto → fresh cookie) only when a live Kratos session backs it; a dead session clears the stale cookie lilleman 2026-06-18 10:25:05 +02:00
  • 228a206469 Auth guards (todo §4); guards.ts: requireSession/can/check + GuardError, app.ts maps GuardError → 303 /login or 403 (never 500) lilleman 2026-06-18 10:10:15 +02:00
  • 24eb6b1c68 JWKS fetch + cache + rotation (todo §4); cachingJwks: TTL cache + rotation-on-miss reload (throttled, last-good on error), createJwksProvider routes file/base64/http + primes at boot lilleman 2026-06-18 10:01:40 +02:00
  • c8b56b85eb JWT session middleware (todo §4); authenticate(): verify the session cookie via cached JWKS (key by kid) → exp/nbf/iss/aud claims (clock skew) → ctx.user/roles; iss/aud opt-in; fail-closed lilleman 2026-06-18 09:53:37 +02:00
  • 38157605d0 Login completion (todo §4); /auth/complete: roles from Keto → metadata_public projection → tokenize → plainpages_jwt cookie; fix tokenizer projection metadata_admin→metadata_public (whoami strips admin metadata) lilleman 2026-06-17 23:15:28 +02:00
  • 26a7821611 Render SSO buttons per configured Kratos OIDC provider (todo §4); flow-view collects oidc nodes → auth-card submit buttons, server-side visibility, drop mockup #sso-toggle CSS lilleman 2026-06-17 18:20:45 +02:00
  • 0928f9dd39 Render Kratos self-service flows as themed pages (todo §4); buildFlowView + views/auth.ejs + login/registration/recovery/verification/settings routes lilleman 2026-06-17 17:55:56 +02:00
  • 2a64cfd409 Add Keto fetch client (todo §4); createKetoClient(): check / list / expand relations + write / delete tuples lilleman 2026-06-17 17:33:59 +02:00
  • 5e96678fda Add Kratos admin-API fetch client (todo §4); createKratosAdmin(): identity CRUD + surgical metadata_admin update (login role projection) lilleman 2026-06-17 17:22:02 +02:00
  • 898dc7f2cf Add Kratos public-API fetch client (todo §4); createKratosPublic(): self-service flow init/get/submit, whoami, session→JWT tokenize lilleman 2026-06-17 17:15:50 +02:00
  • fcf042fa66 Unify §3 test overlaps (todo §3); fold the 5× image-pin checks into one compose.test.ts scan + same-version sidecar test, drop the duplicate committed-JWKS re-validation in config.test.ts lilleman 2026-06-17 17:07:39 +02:00
  • 360449e76b Tighten §3 comments (todo §3); drop stale 'next §3 item' forward-refs, condense compose/Ory/bootstrap headers lilleman 2026-06-17 17:00:47 +02:00
  • e83cf4da88 Address project-wide review (todo §3); fix JWKS_URL default → tokenizer signing key + read-only web mount, cap bootstrap restart, --no-deps for unit commands lilleman 2026-06-17 16:49:37 +02:00
  • 1fc6b42156 Document the only manual prep (todo §3); README 'What you must supply' — production secrets + optional SSO creds, everything else auto-generated lilleman 2026-06-17 16:32:54 +02:00
  • 4d65665063 Bootstrap: print first-run login banner (URL + seeded creds + change-before-prod warning) lilleman 2026-06-17 16:22:48 +02:00
  • a6900217cb One-command bootstrap (todo §3); idempotent first-boot seed: JWKS-if-absent, demo admin in Kratos, admin role in Keto lilleman 2026-06-17 16:18:21 +02:00
  • 4af090f803 Split dev/prod compose wiring (todo §3); Ory readiness healthchecks, web gated on kratos+keto, dev-only host ports, Ory-free E2E lilleman 2026-06-17 16:06:05 +02:00
  • 93e62d8661 Add Hydra service + migrate (todo §3); pin oryd/hydra:v26.2.0, OAuth2 issuer + login/consent URLs → our app routes lilleman 2026-06-17 15:45:37 +02:00
  • fa87280f46 Add Keto service + migrate (todo §3); OPL role/group/resource namespaces, fine-grained resource permits lilleman 2026-06-17 15:12:01 +02:00
  • 6640dfc84e Generate + mount the JWT signing JWKS (todo §3); ES256 gen-jwks tool, committed dev key, key-rotation docs lilleman 2026-06-17 13:24:31 +02:00
  • 95c759d773 Wire Kratos session tokenizer template (todo §3); plainpages JWT (sub/email/roles), 10m TTL, Jsonnet claims mapper reading metadata_admin lilleman 2026-06-17 12:02:21 +02:00
  • 0313f48112 Configure Kratos session settings (todo §3); branded cookie, 720h lifespan, 24h sliding-refresh window lilleman 2026-06-17 11:27:56 +02:00
  • d6960c9bad Add optional env-activated Kratos OIDC/SSO providers (todo §3); off by default, committed claims mapper, SAML via OIDC bridge note lilleman 2026-06-17 10:58:31 +02:00
  • f2898696e6 Wire Kratos self-service flows to themed routes (todo §3); enable recovery/verification via email code, add mailpit dev courier + --watch-courier lilleman 2026-06-17 10:19:29 +02:00
  • 120e1a0929 Add kratos service + migrate (todo §3); pin oryd/kratos:v26.2.0, identity schema (email, name), bootable password config lilleman 2026-06-16 23:24:32 +02:00
  • bc15f00c44 Add postgres service (todo §3); pin postgres:18.4-alpine3.23, one DB per Kratos/Keto/Hydra via init.sql lilleman 2026-06-16 17:13:40 +02:00
  • a602f794d1 Consolidate tests (todo §2); merge HTTP static tests, fold 403 render into the live gated route, unify resolveViewPath cases lilleman 2026-06-16 16:42:46 +02:00
  • 9489bd124b Tighten code comments + README (todo §2); trim verbose §2 headers, drop stale planned/next-item markers, correct README status lilleman 2026-06-16 16:31:57 +02:00
  • a8ebf81588 Address whole-project review (todo §2); wire plugin hooks (onBoot/onRequest/onResponse), document template trust boundary, tidy discovery lilleman 2026-06-16 16:23:08 +02:00
  • ff7b55be4c Wire branding into the app shell (todo §2); render config logo + default theme, fall back to the brand mark lilleman 2026-06-16 16:07:24 +02:00
  • 952dd03cc2 Add config/menu.ts central override + branding (todo §2); loadMenuConfig validates+merges, override applied to nav, branding into shell lilleman 2026-06-16 15:52:03 +02:00
  • 3cdefff233 Serve per-plugin static assets (todo §2); /public/<id>/ → plugins/<id>/public/ via routePublic, core public/ unaffected lilleman 2026-06-16 15:18:20 +02:00
  • fe89dd1c06 Add per-plugin view resolver (todo §2); render plugins/<id>/views/<view>.ejs with nested names + traversal guard, core partials reachable via include() lilleman 2026-06-16 13:41:02 +02:00
  • 9b6684c653 Mount plugin routes via the router (todo §2); match method+path under /<id>, resolve :params, permission gate, RouteResult→response lilleman 2026-06-16 12:22:15 +02:00
  • ca3f6ba8ce Discover plugins at boot (todo §2); scan plugins/, import + validate each plugin.ts default export, fail loud on bad plugin/conflict lilleman 2026-06-16 12:11:04 +02:00
  • 0ebe8144c2 Document how plugins get into the container (README); bind-mount to /app/plugins/<id> or bake in, front-and-center under Building a plugin lilleman 2026-06-16 11:58:28 +02:00
  • 09d616ddd3 Loosen plugin id rule (todo §2); allow digits and dashes anywhere (^[a-z0-9-]+$) lilleman 2026-06-16 11:53:14 +02:00
  • 1623a81ddc Refine plugin contract (todo §2); derive id/mount from folder (isValidPluginId), apiVersion literal not HOST_API_VERSION, nav icon = Lucide, drop redundant basePath lilleman 2026-06-16 10:58:29 +02:00
  • a0d39ef624 Make checkApiVersion semver-based (todo §2); strict parseSemver via official semver regex (no dep), major/minor compatibility rules lilleman 2026-06-16 10:46:02 +02:00
  • 3be67ff8e4 Specify the plugin contract (todo §2); typed manifest + version/conflict rules in src/plugin.ts, authoritative docs/plugin-contract.md lilleman 2026-06-15 17:07:55 +02:00
  • f91e08c2a6 Add Full, parallel E2E principle (todo §1.1); AGENTS §6 + README, 404 E2E coverage, --build the runner so spec edits apply lilleman 2026-06-15 16:58:26 +02:00
  • 645a316419 Make markup semantic + add semantic DOM principle (todo §1); page <h1>, skip link, row-header <th scope=row>, descriptive error pages lilleman 2026-06-15 16:53:07 +02:00
  • 6f590148af Add dockerized Playwright E2E (todo §1); screenshot live pages + foundation mockups, assert shared design-system styles match lilleman 2026-06-15 16:37:21 +02:00
  • 947851b4ff Replace placeholder index with the app-shell People dashboard (todo §1); wire parseListQuery/paginate/composeNav + partials into a real zero-JS list page lilleman 2026-06-15 15:57:42 +02:00
  • c06429e4d5 Add paginate helper (todo §1); page model with row window + ellipsis sequence for pagination.ejs, clamped/guarded inputs lilleman 2026-06-15 13:50:15 +02:00
  • 20f49c1df7 Add parseListQuery helper (todo §1); read a list URL into { q, filters, sort, page, pageSize }, defaults+clamp, zero-throw lilleman 2026-06-15 13:38:34 +02:00
  • c2bcce9845 Add composeNav helper (todo §1); merge plugin nav fragments + central override (rename/group/order/hide), role-filter to a nav-tree model lilleman 2026-06-15 13:33:54 +02:00
  • bddc1f891d Add menu/popover + theme-switch partials (todo §1); data-driven .menu (items/check-groups/positioning), Light/Auto/Dark switch, shell reuses both lilleman 2026-06-15 13:27:44 +02:00
  • 7716e38d84 Add field + auth-card partials (todo §1); data-driven .field (label/icon/hint/server error) and auth-card shell (head/SSO/body/alt) lilleman 2026-06-15 13:16:36 +02:00
  • fcf2abdf17 Add data-driven pagination partial (todo §1); rows-per-page GET form + page-number links, zero-JS, query-param driven lilleman 2026-06-15 13:10:24 +02:00
  • cf1b74f09d Add data-driven data-table partial (todo §1); sortable header links, row-select, typed cells/badges, kebab actions lilleman 2026-06-15 13:04:19 +02:00
  • 637d5cf66d Add data-driven filter-bar partial (todo §1); GET form: search/segmented/select/chips/daterange + applied pills lilleman 2026-06-15 12:04:25 +02:00
  • 67743cad23 Add recursive nav-tree partial (todo §1); header/leaf × clickable/static, counts + aria-current lilleman 2026-06-15 11:59:26 +02:00
  • 672b831f8c Add app-shell partial (todo §1); sidebar + topbar + content/nav slots, reuses mockup classes + icon sprite lilleman 2026-06-15 11:51:44 +02:00
  • 265704a7eb Add lucide icon sprite partial (todo §1); src/icons.ts generates only-used symbols from pinned lucide-static lilleman 2026-06-15 11:44:40 +02:00
  • 30db8216e6 Move foundation CSS into public/css (todo §1); drop placeholder style.css, repoint views + mockups lilleman 2026-06-15 11:25:43 +02:00