Skip to content

chore: upgrade FastAPI/Starlette, add security response headers - #6

Merged
kilianmc merged 1 commit into
devfrom
chore/deps-and-security-headers
Aug 14, 2026
Merged

kilianmc merged 1 commit into
devfrom
chore/deps-and-security-headers

Conversation

@kilianmc

Copy link
Copy Markdown
Owner

Raises the six Dependabot-alerted versions and adds the security response header baseline (item 9 of the end-to-end security verification pass). Version 1.3.0.

Dependencies

Package From To
fastapi 0.128.8 0.141.1
starlette 0.52.1 1.6.0
pytest 9.0.1 9.1.1
ruff 0.15.1 0.16.3
uvicorn 0.39.0 0.52.3

Every other pin was checked against PyPI and is already at the latest published version.

The alerts will not clear on this merge. Dependabot evaluates the default branch, which is main, and main still carries starlette 0.52.1. They clear at the first promotion.

Two things the upgrade found

  1. uv lock did not move starlette. Bumping fastapi reported success and left the transitive pin in place — the alerts were against starlette. It needed --upgrade-package starlette. A PR claiming to close these alerts could have closed none of them, with a green gate.
  2. FastAPI 0.137 broke the route-enumeration security test silently. include_router now stores a tree node rather than copying routes onto the parent, so walking app.routes returned 3 routes instead of 9 — and still passed, because a walk over nothing finds no unprotected endpoints. Fixed with iter_route_contexts, plus a canary asserting a known-protected route is visible in the walk and absent from PUBLIC_ROUTES, so it cannot be neutered by making that route public.

Also noted for PR #6: FastAPI 0.132 enables strict_content_type by default, so a POST without content-type: application/json now 422s — apiFetch currently sends only accept.

Headers

Two layers on purpose: vercel.json for what the CDN serves, and a plain ASGI middleware for /api/* in process, so the set also holds under bare uvicorn and is assertable in CI. The middleware wraps send rather than subclassing BaseHTTPMiddleware — that is what puts headers on responses no endpoint produced (401/403/404/422), which the tests cover.

Deliberate choices, each with a test or a comment:

  • No unsafe-* token in the document CSP. React sets inline styles through CSSOM, which CSP does not govern, so style-src stays 'self'.
  • HSTS left to Vercel, which already sends max-age=63072000; a duplicate field is ignored rather than merged.
  • Cross-Origin-Resource-Policy never set — same-origin would block the shell loading remoteEntry.js in PR chore: stop the migrate job printing the Neon endpoint (main-side twin) #5. This, not frame-ancestors, is the header that would break the federated mount; CLAUDE.md's item 9 said otherwise and is corrected. The MF mount loads this app as a script, not an iframe.
  • Permissions-Policy lists only what it restricts, leaving screen-wake-lock, fullscreen and autoplay at their defaults for the session player. Asserted by test.
  • The /api/docs CSP exemption is derived from the configured docs URLs, not a literal, so the exempt set is empty in production. Asserted by test.
  • The /(.*) edge rule intentionally overlaps /api/*: a 500 from an unhandled exception comes from ServerErrorMiddleware, built outside the user middleware stack, so the in-process layer cannot reach it. Whether Vercel overwrites or appends on function responses is unverified — item 9 must curl -sI an /api/* path on the real deploy and confirm no header appears twice.

Dependabot

.github/dependabot.yml for uv, npm (/web) and github-actions, weekly and grouped, with @types/node held at the Node 24 major.

Two traps, both verified against GitHub's docs. The config is read from the default branch only, so a byte-identical twin on main is required or this file does nothing — same failure mode as migrate.yml. And target-branch: dev (needed, since main takes only promotion PRs) means security updates would not use this config; that is currently moot because automated-security-fixes is disabled on the repo. The file carries no comments so the two copies cannot drift.

Also corrected: CLAUDE.md described dev as the default branch. It is main.

Gate

ruff clean with no ignores widened, ruff format clean, mypy 33 files, 80 tests passed (was 70), build OK. One warning: starlette.testclient now prefers httpx2 over httpx — a package swap, not a version bump, deferred as its own change before Starlette 2.0.

🤖 Generated with Claude Code

FastAPI 0.128.8 -> 0.141.1 and Starlette 0.52.1 -> 1.6.0, which is what
the six open Dependabot alerts needed; also pytest 9.1.1, ruff 0.16.3 and
uvicorn 0.52.3. Alerts are evaluated against the default branch, so they
clear at the first promotion to main, not on this merge.

Bumping fastapi alone did not move starlette: uv reported success and
left the pinned transitive version in place, so it needed an explicit
--upgrade-package. Worth knowing before trusting a lockfile diff.

The jump broke the route-enumeration security test silently. Since 0.137
include_router stores a tree node instead of copying routes onto the
parent, so walking app.routes returned three routes where it returned
nine — and still passed, because a walk over nothing finds no
unprotected endpoints. The walk now goes through iter_route_contexts, and
a canary asserts a known-protected route is visible in it and absent from
PUBLIC_ROUTES, so this file cannot go vacuous unnoticed again.

Headers arrive in two layers. vercel.json covers what the CDN serves; a
plain ASGI middleware covers /api/* in process, so the set also holds
under bare uvicorn and can be asserted in CI. It wraps send rather than
subclassing BaseHTTPMiddleware, which is what puts headers on the
responses no endpoint produced: 401, 403, 404, 422. The edge rule
deliberately overlaps /api/*, because a 500 from an unhandled exception
comes from ServerErrorMiddleware, built outside the user middleware
stack, and never reaches the wrapper.

The document policy carries no unsafe-* token: React sets inline styles
through CSSOM, which CSP does not govern, so style-src stays 'self'.
Strict-Transport-Security is left to Vercel, which already sends it — a
duplicate field is ignored rather than merged. Cross-Origin-Resource-
Policy is never set, since same-origin would stop the shell loading
remoteEntry.js in PR #5. Permissions-Policy lists only what it
restricts, leaving screen-wake-lock, fullscreen and autoplay at their
defaults for the session player. The /api/docs CSP exemption is derived
from the configured docs URLs rather than written as a literal, so it is
empty in production where they are None.

Adds .github/dependabot.yml for uv, npm and github-actions, grouped and
weekly, with @types/node held at the Node 24 major. Dependabot reads the
config from the default branch only, so a byte-identical twin on main is
required before it does anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Aug 14, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
climb-trainer Ready Ready Preview Aug 14, 2026 9:16am

@kilianmc
kilianmc merged commit e06f44d into dev Aug 14, 2026
5 checks passed
@kilianmc
kilianmc deleted the chore/deps-and-security-headers branch August 14, 2026 09:25
kilianmc added a commit that referenced this pull request Aug 14, 2026
#14)

* feat: router + query + dual entry (browser/memory history) + MF expose

Frontend infrastructure for every screen that follows. One route tree, two
histories, and the remote contract the shell will consume in PR #5. Every route
body is a placeholder; what this PR establishes is the wiring.

- web/src/router.tsx — createAppRouter(history) + createQueryClient(). The
  history is the ONLY difference between the two mounts, so every other default
  lives here and cannot drift. defaultPreload: 'intent',
  defaultPreloadStaleTime: 0 (Query owns staleness; a second cache with its own
  expiry would disagree with it), pendingMs 300 / pendingMinMs 500.
- web/src/main.tsx — standalone: browser history, StrictMode, and createRoot's
  onCaughtError/onUncaughtError so a failed mount is not a blank page and a
  silent console. Still the only file that may ever register a service worker.
- web/src/remote.tsx — federated: createMemoryHistory, default-exported
  component, router and QueryClient per mount instance rather than per module so
  state does not survive shell navigations. No StrictMode (the host owns that).
- web/src/routes/ — __root (the one .ct-app element), index, login, four lazy
  leaves, and a catch-all. routeTree.gen.ts is committed deliberately.
- vite.config.ts — @tanstack/router-plugin + @module-federation/vite exposing
  './App' as climbTrainer, react/react-dom singletons at ^19.0.0 plus the scoped
  'react/' and 'react-dom/' shares, build.target chrome89 for top-level await.
- vercel.json — Access-Control-Allow-Origin: * on /remoteEntry.js and /assets/*
  only. The MF chunks load as modules, which are always CORS-mode; /api/* keeps
  its allowlist.
- styles split by mount: app.scss from __root.tsx (both mounts, zero :root
  rules), global.scss from main.tsx (the document reset). This is what makes the
  .ct-app rule mechanical instead of a convention.

Two things learned that would otherwise be re-derived, both in CLAUDE.md:

createLazyFileRoute inside a plain plan.tsx builds, warns nothing and bundles
EAGERLY. Only the <route>.lazy.tsx filename makes the generator emit a dynamic
import, so renaming one of those files silently deletes its code-splitting.

routeTree.gen.ts must stay committed: vitest.config.ts replaces vite.config.ts
rather than merging it, so the router plugin never runs under Vitest and cannot
regenerate the tree. Verified by deleting it and watching vitest fail.

Tests cover the router factory under memory history (render, a lazy hop, the
catch-all) and guard the three federated-mount rules at runtime — no service
worker, no history mutation, no un-namespaced localStorage. The placeholder
bodies are untested on purpose, per the testing policy.

No query-cache persistence: it needs the demo-scope exclusion, which needs auth
state that does not exist until PR #6. It lands in PR #14.

Opens 1.6.0.

Co-authored-by: Kilian Mateo <13885240+kilianmc@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: harden the remote guard, which passed while all three rules were violated

remote.guard.test.tsx went green against the most likely PR #7 regression.
Three jsdom facts caused it, each confirmed by probe:

- document.readyState is already 'complete' when a test runs, so a listener
  added during render never fires. virtual:pwa-register (vite-plugin-pwa)
  registers from exactly such a window 'load' listener. Now dispatched.
- localStorage.foo = 'x' writes the value while bypassing
  Storage.prototype.setItem, so the spy missed it. Now asserts on
  Object.keys(localStorage).
- clear() and removeItem() were not spied at all, and leave no trace in the
  final state. A remote calling clear() wipes the portfolio's storage.

Module-scope side effects also ran before any spy existed, so the entry is
imported dynamically with resetModules per test. Adds a positive control
asserting each detector can see its own violation, since every storage
assertion otherwise passes on an empty set.

Converts two documented "don't touch this" rules into assertions:

- mf-contract.test.ts: vercel.json must keep ACAO on /remoteEntry.js and
  /assets/*, and nowhere else. Both are load-bearing off-repo — remoteEntry.js
  statically imports /assets/virtual_mf-REMOTE_ENTRY_ID*.js, and the exposed
  chunk preloads its own CSS — so deleting either kills the federated mount
  with a fully green gate.
- routeTree.lazy.test.ts: the four heavy leaves must be code-split. Renaming
  plan.lazy.tsx to plan.tsx passes lint, typecheck, test AND build while
  emitting no separate chunk.

Two comments corrected rather than left to become ground truth:

- build.target 'chrome89' removed. Vite 8's default is already chrome111+ and
  keeps MF's top-level await; the pin only lowered the baseline (it also
  forced lightningcss color-scheme fallbacks).
- the nav's "44px touch targets" claim was false on three of six links
  (31.1/37.7/43.8px wide). min-inline-size added; measured 44x44 in Chrome at
  320px and 390px, row gap 4px -> 8px.

---------

Co-authored-by: Kilian Mateo <13885240+kilianmc@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kilianmc added a commit that referenced this pull request Aug 17, 2026
Addresses the independent review on #20.

The first pass only fixed `errorComponent`. But a root-level not-found, a pending
root match, and the router's two root-level Suspense fallbacks (Matches and
MatchView both build theirs from the ROOT route's pendingComponent) all replace
RootLayout the same way, taking the `.ct-app` element with them — so they would
have rendered unstyled into kilianmc.com's document too. Unreachable today, since
the root has no loader or beforeLoad, but PR #6's auth bootstrap is exactly what
makes it live.

The wrapper therefore moves into ui/status.tsx and covers all three renders. It
is conditional: `.ct-app` carries padding, a max inline size and a background, so
wrapping unconditionally would inset the layout twice for a leaf error inside the
outlet. RootLayout marks its outlet with CtAppScope and the status renders skip
their own wrapper inside it. RootError is gone — one mechanism, three slots.

Also, `git diff --exit-code` exits 0 on a pathspec that matches nothing, so the
route-tree check could have rotted invisibly on a rename; the file's existence is
now asserted first. The check stays CI-only on purpose: locally the diff is
worktree-vs-index, so any branch that legitimately adds a route file would be red
until the regenerated tree is staged, and a gate that is red by default gets
ignored. CLAUDE.md's local-vs-CI check counts are corrected instead, with that
residue written down.

CLAUDE.md: the bridge-failure log is four lines, one per shared key, against a
production build; the dev-server arm splits differently, which is why the rule is
to grep the string rather than assert a count.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
kilianmc added a commit that referenced this pull request Aug 20, 2026
Cross-file deduplication and size reduction across CLAUDE.md, the README and
the private notes. NOT the prose-verbosity trim, which stays deferred until the
app is finished. Rule applied throughout: trim status, keep rationale.

- CLAUDE.md gains a heading-anchored index grouped by task, so a coder starting
  a PR can find one rule without reading 2000 lines. Anchored to heading text,
  never line numbers, which rot.
- Eight contradictions resolved against the code rather than guessed. The
  significant one was public: CLAUDE.md and the README both claimed V-scale,
  Font and French grades are directly comparable. server/domain/grades.py puts
  boulder in the 1000-band and rope in the 2000-band and raises
  CrossDisciplineError, so V5 and 7A compare but 6c+ does not.
- README no longer calls dev the default branch (main is), and points at
  CLAUDE.md instead of restating the testing policy, CI internals and auth
  internals. Headings de-versioned so anchors stop rotting.
- The byte-identical-twin rule's add/add half is marked expired; the
  "inert until the next promotion" half stands.
- REPLAY_GRACE keeps the operative one-line constraint here; the analysis moved
  to private notes.
- The query-cache persistence ban keeps its rule and loses its expired reason.
  The hazard is that localStorage belongs to the shell, not PR #6 sequencing.
  It does not constrain the outbox.
- Adds two rules that were missing: migrate production BEFORE promoting, and
  hand the migrate.yml dispatch to Kilian rather than attempting it.
- migrate.yml is comments only: drops the revision numbers that went stale at
  0003, keeps the ref-vs-environment distinction.

No version bump, matching #32.

Co-authored-by: Kilian Mateo <13885240+kilianmc@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — 030679d1 Deployed Aug 14, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant