Commit Graph

100 Commits

Author SHA1 Message Date
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). 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. 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. 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. 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. 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. 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. 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. 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). 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. 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. 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. 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). 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. 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). 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. 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. 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. 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. 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. 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. 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. 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. 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 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 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. 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 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 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 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 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 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 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 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 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 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) 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 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 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) 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 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 2026-06-17 17:55:56 +02:00
2a64cfd409 Add Keto fetch client (todo §4); createKetoClient(): check / list / expand relations + write / delete tuples 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) 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 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 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 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 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 2026-06-17 16:32:54 +02:00
4d65665063 Bootstrap: print first-run login banner (URL + seeded creds + change-before-prod warning) 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 2026-06-17 16:18:21 +02:00