renovate-bot
aea568ea1c
chore(deps): update renovate/renovate docker tag to v43.252.1
...
CI / full-gate (push) Successful in 2m26s
Mirror / github-mirror (push) Successful in 3s
Release-Bump: minor
v0.0.3
2026-07-06 04:17:39 +00:00
lilleman
145db5b4cd
CI: auto-release a version tag when Renovate merges a dependency update
CI / full-gate (push) Successful in 2m33s
Mirror / github-mirror (push) Successful in 2s
2026-07-05 20:49:49 +02:00
renovate-bot
7c39056188
chore(deps): update node.js to v24.18.0
CI / full-gate (push) Successful in 2m28s
Mirror / github-mirror (push) Successful in 3s
2026-07-05 07:02:10 +00:00
renovate-bot
9719586f51
chore(deps): update axllent/mailpit docker tag to v1.30.3
CI / full-gate (push) Successful in 2m29s
Mirror / github-mirror (push) Successful in 3s
2026-07-05 06:56:22 +00:00
lilleman
67d8a095a5
CI: Renovate for gated, self-hosted dependency updates
CI / full-gate (push) Successful in 2m30s
Mirror / github-mirror (push) Successful in 3s
2026-07-04 21:51:04 +02:00
lilleman
476ef6fce2
README-dockerhub: clone-free quick start - self-contained compose, Ory config extracted from the image
CI / full-gate (push) Successful in 2m30s
Mirror / github-mirror (push) Successful in 0s
2026-07-04 17:45:44 +02:00
lilleman
93fa751d6d
Add README-dockerhub.md - the hand-maintained Docker Hub repo description
CI / full-gate (push) Successful in 2m29s
2026-07-04 17:37:02 +02:00
lilleman
cc886936ed
CI: sync release images to Docker Hub on vX.Y.Z tag push
CI / full-gate (push) Successful in 2m31s
Mirror / github-mirror (push) Successful in 2s
Release / retag-image (push) Successful in 14s
v0.0.2
2026-07-04 17:08:31 +02:00
lilleman
e3e582afef
README: document the one-time org-package-to-repo link for the Packages tab
CI / full-gate (push) Successful in 2m29s
Mirror / github-mirror (push) Successful in 0s
2026-07-04 09:39:10 +02:00
lilleman
0644ec8f5a
CI: nightly registry cleanup - prune hash images not release-tagged nor a branch head
CI / full-gate (push) Successful in 2m40s
Mirror / github-mirror (push) Successful in 0s
2026-07-04 09:18:07 +02:00
lilleman
058280934b
CI: re-tag the gated image as semver + latest on vX.Y.Z tag push
CI / full-gate (push) Successful in 3m36s
Mirror / github-mirror (push) Successful in 2s
Release / retag-image (push) Successful in 2s
v0.0.1
2026-07-04 08:35:48 +02:00
lilleman
50006dd1a7
CI: gate builds + pushes app image to Gitea registry tagged with the commit hash
CI / full-gate (push) Successful in 2m32s
Mirror / github-mirror (push) Successful in 0s
2026-07-03 16:50:37 +02:00
lilleman
6e60df7008
CI: mirror main + tags to GitHub after every merge to main; note true home in README
CI / full-gate (push) Successful in 2m28s
Mirror / github-mirror (push) Successful in 2s
2026-07-03 15:47:56 +02:00
lilleman
c8981c12d3
CLAUDE.md: import AGENTS.md so Claude Code loads it every session
CI / full-gate (push) Successful in 2m28s
2026-07-03 14:55:13 +02:00
lilleman
7a3161d3ff
README: document the merge gate on main (PR-only, required CI, fast-forward-only); check off todo
CI / full-gate (push) Successful in 2m33s
2026-07-03 14:50:54 +02:00
lilleman
c78770a713
AGENTS.md: document the todo.md task workflow (interview scope, stability-review loop, PR flow)
2026-07-03 14:50:54 +02:00
lilleman
94654a2adc
CI: Gitea Actions runs the full gate (ci.sh) on push to any branch except main; document runner setup
CI / full-gate (push) Failing after 0s
2026-07-02 14:24:19 +02:00
lilleman
8afaeccf2c
AGENTS.md: sharpen simplicity/typing/comment guidance, prefer state-in-URL over POST
2026-07-02 13:05:54 +02:00
lilleman
535902e69b
Split handleRequest into pipeline + internal route table; auth/OAuth2 endpoints become named handlers in src/auth/routes.ts
2026-07-02 13:05:54 +02:00
lilleman
8621cf24b6
todo: note the missing e2e for the admin plugin's OAuth2-clients (Hydra) screen
2026-07-02 09:10:55 +02:00
lilleman
e8ea911b80
Move admin screens (users/groups/roles/oauth2-clients) into a drop-in example plugin; add the ctx.system capability surface
2026-07-02 08:01:15 +02:00
lilleman
2202bdbaa0
config/ becomes an empty drop-in mount; examples/ mirrors the mount dirs; plugins & config import the host via #plugin-api/#menu-config subpath imports
2026-07-01 23:19:48 +02:00
lilleman
4adf14f386
docs: restructure README around the plugin-author path, add examples/README, genericize the plugin walkthrough
2026-06-30 23:30:13 +02:00
lilleman
fe97c3854a
Move reference plugin to examples/scheduling-plugin; plugins/ ships empty as a drop-in mount point, e2e bind-mounts the example
2026-06-27 00:02:26 +02:00
lilleman
d8cf257940
Move plugin-contract.md into README's Building plugins section; remove docs/, repoint all references
2026-06-26 23:12:40 +02:00
lilleman
2b88bf1c0d
Add todo.md: src reorg done, plus logger redundancy + i18n follow-ups
2026-06-24 00:24:52 +02:00
lilleman
de22f51c12
Organize src/ into concern folders (http, auth, admin, plugin-host, ui); co-locate tests, move plugin-api barrel into plugin-host, sync docs + AGENTS layout
2026-06-24 00:23:55 +02:00
lilleman
6d316c4888
Drop .mjs: rename e2e/dev mock servers to .ts, update refs, document the convention in AGENTS.md
2026-06-23 23:54:42 +02:00
lilleman
913bd6813a
Consolidate E2E into e2e-tests/ (Dockerfile + compose.{visual,auth,oauth,full,devstack}.yml, WORKDIR /e2e-tests); move ci.sh to repo root
2026-06-23 23:48:05 +02:00
lilleman
bb612baa2c
Reorganize README for two readers: first-run Quick start + nested ToC, overview-first ordering; document the convention in AGENTS.md
2026-06-23 23:15:24 +02:00
lilleman
a9f25a7692
Remove completed todo.md + html-css-foundation mockups; strip dead §N phase refs from comments/docs (simplify visual E2E to drop the mockup-comparison oracle)
2026-06-23 22:49:28 +02:00
lilleman
e22d24aa8a
§10 - one menu everywhere (buildPluginChrome) + shell on every page; instructional starter dashboard; Kratos-native email docs
...
Collapse the three nav builders into buildPluginChrome: chrome.bestHref does longest-prefix matching so deep admin routes mark their leaf. Delete adminNav; buildConfirmModel and the admin model builders take the resolved nav. The same role-filtered sidebar now renders signed in or out, collapsing to a burger on narrow screens.
shell.ejs gains menu (default true; menu:false -> single-column .app-bare), docTitle (separate <title> from the topbar, so the body keeps the single <h1>), and hideSignIn (suppress the footer Sign-in on auth pages to avoid a login loop). auth/home/landing now render inside the shell.
Dashboard is a replaceable instructional starter (definePlugin snippet, no mock data). Email stays delegated to Kratos: documented its built-in courier.template_override_path instead of adding web-side SMTP.
2026-06-23 21:26:00 +02:00
lilleman
af097a8885
Verification/recovery: guard the OTP code field against a pasted space (numeric inputmode + digits-only pattern + one-time-code autofill); Kratos doesn't trim, so a space-padded code was rejected as 'invalid or already used'
2026-06-21 23:26:23 +02:00
lilleman
166f38d5dd
Merge pull request 'appurl-canonical-host' ( #1 ) from appurl-canonical-host into main
...
Reviewed-on: #1
2026-06-21 22:18:46 +02:00
lilleman
c8b4c3c23b
Fix 500 on /login when a Kratos session exists but no app JWT (session_already_available)
...
Repro: register a new account, then click back from the password/verification step to /login →
500. Registration's `session` hook signs the user in at Kratos but routes to the verification UI,
not /auth/complete, so they hold a Kratos session with NO app JWT — ctx.user is null, the "already
signed in -> /dashboard" short-circuit can't fire, and initialising a login flow makes Kratos return
400 `session_already_available`. The flow-init catch only handled 403/404/410 and 5xx, so the 400
fell through to `throw` -> catch-all 500.
Recover instead: on `session_already_available` (already authenticated at Kratos), 303 to
/auth/complete to mint the JWT from the live session, preserving return_to. A genuinely unexpected
Kratos 400 still surfaces as 500. Verified live: /login (Kratos session, no JWT) now 303s to
/auth/complete, which mints plainpages_jwt and lands on /dashboard.
Also default LOG_LEVEL to debug in the dev override (compose.override.yml) for verbose local logs.
Tests-first: app.test asserts the session-race recovers to /auth/complete (return_to carried) while
an unrelated 400 stays a 500. typecheck + 361 units green.
2026-06-21 22:04:55 +02:00
lilleman
1d198acc97
Canonical host via APP_URL: stop login dumping users on /error from a host mismatch
...
The from-scratch dev login was broken: open the banner's http://localhost:3000 , sign
in as the seeded admin, and you landed on http://127.0.0.1:3000/error "Page not found".
Root cause: the banner/APP_URL said localhost but kratos.yml hard-coded 127.0.0.1, and a
host-scoped Kratos CSRF cookie can't cross localhost<->127.0.0.1, so the cross-host login
POST lost it; Kratos redirected to its error sink, which the app had no route for (404).
Make APP_URL the single source of truth for the public host:
- Canonical-host redirect (app.ts): when APP_URL is set, an off-host GET/HEAD visitor is
308'd to it (path+query kept) before a flow starts, so the browser, the themed forms and
the cross-origin Kratos POST share one cookie host. After /public/ so static/health
checks stay host-agnostic; GET/HEAD only so a 308 never replays a cross-host POST.
- Opt-in (no NODE_ENV / no magic default): unset => no redirect, so a prod deploy that
forgets APP_URL can't bounce real users to a stale default. The dev stack sets it.
- kratos.yml browser URLs default to localhost (match APP_URL's dev value) and derive from
${APP_URL} via compose.override.yml; SERVE_PUBLIC_BASE_URL keeps the dev Ory port.
- Real /error page (views/error.ejs) replaces the catch-all 404 for genuine flow errors.
Tests-first: config (opt-in/validated), app (308 on mismatch, no-redirect on match, static
host-agnostic, POST untouched, /error page), updated kratos.test host pins. New devstack
regression (e2e/devstack-login.spec + compose.e2e-devstack.yml) drives the plain
docker-compose-up topology on the host network: login from localhost works and 127.0.0.1 is
canonicalised; wired into scripts/ci.sh. typecheck + 360 units + full ci.sh (visual 10 ·
auth 1 · oauth 2 · full 7 · devstack 2) green.
2026-06-21 21:52:20 +02:00
lilleman
58398481ca
§10 review pass: address the architecture + product reviewers (todo §10); hide the gated Dashboard nav node from anonymous visitors in buildPluginChrome (a no-permission link to /dashboard only dead-ended them at /login) and dedup it into a shared DASHBOARD_NAV (admin-nav.ts, reused by chrome + adminNav). New chrome.signInHref bakes the current page in as return_to for the shell's anonymous Sign-in link (shell.ejs + reference overview.ejs), mirrored as optional ShellModel.signInHref so the typed builder is complete. ctx.chrome is now a lazy, memoized getter (context.ts chrome option = a factory) so a json/redirect handler or the public "/" with a standalone home never composes the global menu — app.ts passes the app-level memoized factory at every site. Default /dashboard prints a "Starter dashboard" note framing the mock-data home as a replaceable demo (signals its inert affordances); stale "until §4" comments fixed. RESERVED_PLUGIN_IDS drift-guard test derives the built-in segments from AUTH_FLOWS + ADMIN_*_BASE + host literals (home stays deliberately unreserved). Refreshed the stale plugin-contract status blurb and documented the chrome.*→partials/shell mapping. Reviewers: architecture + product APPROVE (no addressable findings remain), stability APPROVE (no Critical/High/Medium). typecheck + 356 units + visual(10) + full-flow(7) E2E green.
2026-06-21 01:19:40 +02:00
lilleman
7bdeb24b7f
§10 public pages + menu items, the blessed explicit alias (todo §10); a plugin may mark a page and its menu option public. A no-permission route/nav node is already anonymous-reachable, so per the human's pick this BLESSES that as a first-class, explicit choice (keep the default; add an explicit alias — not a secure-by-default flip). New optional public?: boolean on Route (src/plugin.ts) + NavNode (src/nav.ts) = "open to everyone, signed in or not", honored outright in isAuthorized (router.ts) + filterByRoles (nav.ts), and MUTUALLY EXCLUSIVE with permission — discovery shapeError recursively rejects a route/nav node setting both, failing the boot loud (never silently picks one). public is filter-only (toRenderNode never emits it). The shell (views/partials/shell.ejs) now renders a Sign in link instead of the profile/sign-out block for an anonymous visitor, so a public page in the native shell (ctx.chrome; ctx.user may be null) isn't a broken "Guest / Sign out". Reference plugin demos it: a public /scheduling Overview route + a public "Overview" nav child (the "Scheduling" header now shows for everyone), the shifts list still behind scheduling:read. Hardened the latent gap the shell newly leans on: claimsToUser rejects an empty email like it does an empty sub. Tests-first (348 → 354 units): router/nav/discovery (public open + reject-both + loads), shell (anon → Sign in, no logout form), app (public route anon-200), shifts (overview handler), jwt-middleware (empty email). Docs: plugin-contract.md ("Public pages & menu items" + route shape + shape-error note) + README (menu system + reference snippet). E2E: visual.spec asserts the public Overview is anon-200 + shown in the member's nav while the gated Shifts redirects/filters. stability-reviewer: APPROVE, no Critical/High/Medium (addressed its one Low — the empty-email hardening). typecheck + 354 units + full scripts/ci.sh gate (visual 10 · auth 1 · oauth 2 · full 7) green.
2026-06-20 18:12:46 +02:00
lilleman
7787ed4ea4
§10 split landing into a public "/" + gated "/dashboard", both plugin-replaceable (todo §10 follow-up); per human feedback, "/" is now an ungated public landing (default views/home.ejs: brand + intro + prominent Log in / Create account links, or "go to dashboard" when signed in) and "/dashboard" is the gated post-login app home (anonymous → /login?return_to=/dashboard). Both are fully replaceable via two optional RouteHandlers on PluginManifest — home? (public /) and dashboard? (gated /dashboard) — rendered against the plugin's own views with the native shell via ctx.chrome (full route parity: HEAD, void-return, response hooks, fresh CSRF cookie; a home handler is public so ctx.user may be null). Single-slot + loud: findConflicts errors on >1 owner of either slot (new "home"/"dashboard" kinds), discovery rejects a non-function handler, and "dashboard" is reserved so a plugin folder can't shadow it ("/" can't be shadowed — route paths carry the /<id> prefix). Post-login + already-signed-in redirects and the global Dashboard/People nav hrefs moved to /dashboard. Tests-first (348 units): public-/ + gated-/dashboard + dual plugin-override in app.test; per-slot conflict in plugin.test; non-function/reserved/two-owners in discovery.test. Docs: plugin-contract "The landing pages" section + README. E2E: visual.spec plants a session for /dashboard design-system tests + a cookie-free public-landing test; full-flow repointed to /dashboard. stability-reviewer: APPROVE, no Critical/High/Medium. typecheck + 348 units + visual(10) + full-flow(7) green.
2026-06-20 17:43:01 +02:00
lilleman
2eb5b84ccf
§10 gate the dashboard + make "/" replaceable by a plugin (todo §10); "/" is now gated to a signed-in session (anonymous → /login via loginRedirect, query preserved as return_to) and fully replaceable via a new optional home?: RouteHandler on PluginManifest — a handler with the same signature as any route (the most ergonomic shape). The app.ts "/" branch gates first, then renders the single home plugin's handler against its own views/ with the native shell via ctx.chrome (HEAD / void-return / response-hook parity with a plugin route), else the built-in mock-data People list. home mounts at the root above the /<id> namespace, so it can't shadow or be shadowed by a built-in route. Single-slot + loud: findConflicts errors on >1 home (new "home" kind), discovery rejects a non-function home — never last-write-wins. Tests-first (338 → 344 units): app.test.ts gate + home-override; plugin.test.ts home conflict; discovery.test.ts home validation. Docs: plugin-contract.md (manifest table + "The dashboard (home)" section + conflict row), README. E2E: visual.spec plants a dev-signed session (the anonymous plugin-gate probe uses the cookie-free request fixture); all e2e web/gateway healthchecks repointed from the gated "/" to /public/css/styles.css. stability-reviewer: APPROVE, no Critical/High/Medium. typecheck + 344 units + visual(9) + full-flow(7) E2E green.
2026-06-20 17:18:30 +02:00
lilleman
df53106a5a
§9 test cleanup (todo §9); dropped the one genuine §9-era test overlap. app.test.ts had two /login?return_to= tests for the same surface — the §6 "bakes the return target into the Kratos flow init (OAuth bounce)" and the §9 "first-party deep link wrapped through /auth/complete; absolute target passes through as-is". The §9 test subsumes it: its middle assertion already proves an absolute /oauth2/login?login_challenge= target is handed to initBrowserFlow unchanged (the exact §6 OAuth-bounce contract, labeled as such in the test name + inline comment), plus the new host-relative-wrap + protocol-relative cases. Removed the redundant standalone §6 test, zero coverage lost. The §9 unit files (security-headers/denylist/logger/safe-url + gen-jwks rotateJwks) and the per-field config toggles (SERVICE_NAME/LOG_*/OTLP_*/REVOCATION_*/JWT_CLOCK_SKEW/ORY_TIMEOUT) are one-concern matrices following the file's per-field pattern — no fat (§3 don't-merge-across-distinct-concerns rule). Tests-only, no production code (per the §6/§7/§8 precedent, no stability reviewer). 339 → 338 units; typecheck + tests green.
2026-06-20 16:50:26 +02:00
lilleman
c28b3056ca
§9 comment-density pass over the §9 accretion; two targeted dedupes (the rest authored dense, per the §6/§7/§8 precedent). (1) logger.ts: the requestStore declaration comment restated the file header (ambient per-request Log → deep modules join the trace without threading) and the accessor docs below it — collapsed to a one-line label. (2) security-headers.ts: dropped the redundant 'applied to every response' note above securityHeaders() (the file header + function name already say it). No stale forward-refs remain; README §9 sections left untouched (concise, _(planned)_ already swept in §8). Docs/comments-only; typecheck + 339 units green.
2026-06-20 16:45:58 +02:00
lilleman
66ea68a91b
§9 whole-project arch+product review pass (todo §9); ran systems-architect + product-owner on the whole project (no Critical/High — a converged scaffold) and addressed the in-scope §9 customer-facing/security findings. (1) return_to deep-link login, open-redirect-safe: a gated request hit while signed out (plugin-route gate, requireSession, requireAdmin) bounces to /login?return_to=<host-relative path> via new loginRedirect(ctx) (GET/HEAD, skips /); /login bakes it into the Kratos flow — a host-relative target is wrapped through <origin>/auth/complete?return_to=<path> so the JWT mints before landing, an absolute target (§6 OAuth2 login challenge) passes to Kratos as-is; /auth/complete redirects to the requested page. (2) safeUrl()+localPath() in new pure src/safe-url.ts: safeUrl sanitises an untrusted href/src to relative-or-http(s) (else "#"), exported via plugin-api.ts (closes the contract's "planned for §9" pointer); localPath is the host-relative redirect-allowlist guard for return_to, re-checked at both /login and /auth/complete. (3) honest 503 on Ory-unreachable sign-in (views/503.ejs) instead of the misattributed catch-all 500; expired-flow 4xx still restarts. Tests-first throughout; stability-reviewer APPROVE (addressed its Medium — scoped the 503 catch so a template bug hits the 500 with a stack, not a 503). typecheck + 339 units + full scripts/ci.sh gate green. Deferred with justification: the app.ts route-table refactor (standalone change + §10 prereq), mock dashboard + public-page blessing (§10 lines 139/140), success-flash (known).
2026-06-20 16:39:09 +02:00
lilleman
1118d7a9f7
§9 refresh README Layout (todo §9); the _(planned)_ markers were already dropped as each piece landed (none remain; Status paragraph reflects the built state). Refreshed the drifted Layout block: added the three source modules it was missing — fetch-timeout.ts (withTimeout, the Ory outbound-call deadline wrapper, §8), guards.ts (requireSession/can/check in-handler authz + GuardError, §4), hooks.ts (runBoot/Request/ResponseHooks plugin lifecycle, §2) — plus scripts/ci.sh (the full CI gate, §8). Cross-checked mechanically: every non-test src/*.ts and every top-level dir (bar node_modules) now has a line; public/plugins/examples descriptions still match their contents. Docs-only.
2026-06-20 15:58:37 +02:00
lilleman
3c633e5ebd
§9 JWT signing-key rotation runbook (todo §9); turned the README's 3-line rotation note into an operational runbook and closed the tooling gap that made its documented steps unrunnable — the old "prepend a key / drop it later" meant hand-editing a JSON file holding a private signing key. Tests-first: new pure rotateJwks(current,{prune}) in gen-jwks.ts — --prepend puts a fresh ES256 key first (Kratos signs with keys[0], the old keys still verify in-flight JWTs) and keeps the rest in order; --prune keeps only the newest (drop superseded post-TTL). CLI reads the existing set from a path arg and writes the new set to stdout (header documents the temp-file redirect so the shell's > can't truncate the input). gen-jwks.test.ts covers prepend (length+1, fresh kid first, old set preserved) + prune (→ 1 key). Runbook documents the two-sided install (Kratos signer env/mount + web JWKS_URL; file:// hot-reloads, base64:// immutable), why it's zero-downtime (sign-with-first + verify-by-kid), the scheduled path (prepend → restart kratos → verify new kid → wait ~12min = 10m TTL + skew → prune; rollback before prune) and the emergency path (replace with a single key → every leaked-key token fails signature → forced re-login; the §9 denylist is moot since the signature is already invalid). Verified the CLI live against the committed dev JWKS (bare→1, --prepend→2 with old kid second, --prune→1); jwks.json untouched. Docs/CLI-only, covered by units (per the §9 precedent, no new browser E2E). README Status + Layout updated. typecheck + 335 units green (333 → 335).
2026-06-20 15:54:17 +02:00
lilleman
bea9a71d6f
§9 trace all fetch + ENV service name + leveled logging (todo §9 follow-up); route every outbound fetch through the request logger, make the OTLP service name implementer-configurable, and add proper leveled logging throughout. An AsyncLocalStorage<Log> makes the per-request logger ambient (runWithLog/currentLog), so all outbound fetch traces with no signature churn: tracedFetch (a typeof fetch) routes through the active request log (client span + propagated W3C traceparent) for string/URL inputs, else plain fetch; server.ts wires it under the Ory timeout into every Kratos/Keto/Hydra + JWKS call (timeout still honoured — log.fetch spreads {...init,headers}). RequestContext gained ctx.log (request logger; additive/contract-stable, silent default) so a handler/plugin logs in-trace and ctx.log.fetch(url) traces upstream calls; the reference plugin's createUpstream defaults to tracedFetch and its handlers log via ctx.log; plugin-api.ts exports tracedFetch + the Log class. SERVICE_NAME (config + createLogger({serviceName})) brands the OTLP service.name. Leveled logging: who-did-what audit info lines on every admin write (user/group/role/client create·delete·assign — actor/target, no secrets), info on login (session mint) + logout, warn on missing-role 403 + CSRF rejections + Ory-unreachable, debug on a JWKS kid-miss reload. app.ts's handler body was extracted to handleRequest run inside runWithLog; end() now fires exactly once after BOTH the handler unwinds AND the response closes, so a client abort mid-handler can't end the log out from under a still-running ctx.log/tracedFetch (regression-tested) and the happy-path access line is never dropped. bootstrap.ts wraps main in runWithLog + traces the seed calls. Tests extended (logger: serviceName/runWithLog/currentLog/tracedFetch-continues-trace; config: SERVICE_NAME; context: ctx.log default+passthrough; app: ctx.log in-trace + ctx.log.fetch propagation + the abort race; plugin-api: tracedFetch+Log). Stability-reviewer: APPROVE, no Critical/High (fixed the abort-race end(); green nits addressed). docs/plugin-contract.md (ctx.log/ctx.log.fetch/tracedFetch) + README (config, Observability tracing/serviceName, plugin note, Layout) updated. typecheck + 333 units + the full scripts/ci.sh E2E gate green (326 → 333).
2026-06-20 15:46:48 +02:00
lilleman
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
lilleman
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
lilleman
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
lilleman
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