Files
..
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
§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).
2026-06-20 15:46:48 +02:00
§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).
2026-06-20 15:46:48 +02:00
§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).
2026-06-20 16:39:09 +02:00
§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).
2026-06-20 15:46:48 +02:00
§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).
2026-06-20 15:46:48 +02:00
§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).
2026-06-20 15:46:48 +02:00
§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
§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).
2026-06-20 15:46:48 +02:00
§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).
2026-06-20 15:46:48 +02:00
§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).
2026-06-20 15:46:48 +02:00
§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
§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
§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
§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.
2026-06-20 18:12:46 +02:00
§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.
2026-06-20 18:12:46 +02:00
2026-06-20 00:42:23 +02:00
2026-06-20 00:42:23 +02:00
§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
§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
§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).
2026-06-20 15:54:17 +02:00
§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).
2026-06-20 15:54:17 +02:00
§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).
2026-06-20 16:39:09 +02:00
§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).
2026-06-20 16:39:09 +02:00
§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
§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
2026-06-20 00:42:23 +02:00
§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).
2026-06-20 15:46:48 +02:00
§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.
2026-06-20 18:12:46 +02:00
§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.
2026-06-20 18:12:46 +02:00
§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).
2026-06-20 15:46:48 +02:00
§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).
2026-06-20 15:46:48 +02:00
§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.
2026-06-20 18:12:46 +02:00
§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.
2026-06-20 18:12:46 +02:00
§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
§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
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
§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).
2026-06-20 16:39:09 +02:00
§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).
2026-06-20 16:39:09 +02:00
§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.
2026-06-20 18:12:46 +02:00
§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.
2026-06-20 18:12:46 +02:00
§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.
2026-06-20 18:12:46 +02:00
§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).
2026-06-20 16:39:09 +02:00
§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).
2026-06-20 16:39:09 +02:00
§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
§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).
2026-06-20 15:46:48 +02:00
§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