Skip to content

feat(ui): The API image serves the UI with production caching and security headers (#359) - #378

Merged
bitbiter-dev merged 5 commits into
masterfrom
feat/359-serve-ui
Oct 30, 2026
Merged

bitbiter-dev merged 5 commits into
masterfrom
feat/359-serve-ui

Conversation

@bitbiter-dev

Copy link
Copy Markdown
Owner

Closes #359. Part of #344.

Summary

The API image builds the UI and serves it on the same origin as /api/v1. Every response gets security headers, and a new E2E job checks the image built from this PR in three browsers.

request
  UseSecurityHeaders         # first; sets headers in OnStarting, so every status gets them
    outside /api → full CSP + require-trusted-types-for 'script', Referrer-Policy, nosniff, Permissions-Policy
    /api         → nosniff, frame-ancestors 'none'
  ... forwarded headers, CORS
  UseUi                      # only when routing matched no endpoint
    /api/**                  → 404 application/problem+json
    GET/HEAD, no extension   → rewrite to /index.html
    static files             → /assets/* immutable for a year, everything else no-cache
  ... rate limiter, auth, endpoints (a known route with the wrong method stays 405)
 src/
 ├── Anichron.API/
 │   ├── Dockerfile                  # + Node 26.13.0 stage on $BUILDPLATFORM → /app/wwwroot
 │   │                               # + configuration/ owned by the app user (see below)
 │   └── Infrastructure/
+│       ├── UiServing.cs
+│       └── SecurityHeaders.cs
+├── docker-compose.e2e.yml          # API image + PostgreSQL on tmpfs, no Worker
 └── Anichron.UI/
+    ├── playwright.config.ts        # WebKit iPhone 16, Chromium Pixel 9, Firefox 393×852
+    └── e2e/smoke.spec.ts
+docs/architecture/ui-serving.md     # the serving contract

The fallbacks are middleware, not MapFallback endpoints. A catch-all endpoint matches every method, so it would turn a 405 on a known /api route into a 404.

Startup fix: on master the image crashed at start (Access to the path '/app/configuration' is denied): it runs as $APP_UID and could not write app.ini. The final stage now creates that folder for the app user.

Evidence

  • Before: docker run anichron-api built from master exits with UnauthorizedAccessException on /app/configuration. There was no UI in the image and no headers.
    After: the image starts. GET /login returns 200 text/html with no-cache and every header, and GET /api/v1/nope returns 404 application/problem+json.
  • Seam 1: UiServingTests (20) and SecurityHeadersTests (11) run the in-process host on a fixture web root. The API unit project passes 716/716. scripts/mutation-changed.sh kills 25/25 mutants (100%), and the UI scoped mutation is at 100%.
  • Seam 4: run locally in the Playwright Linux image against the locally built image.
    ✓ [chromium-pixel] the placeholder loads with its headers and requests nothing but its own origin
    ✓ [webkit-iphone]  the placeholder loads with its headers and requests nothing but its own origin
    ✓ [firefox-phone]  the placeholder loads with its headers and requests nothing but its own origin
    ✓ (×3)             an unknown API path is a JSON 404 with the API headers, never the app
    6 passed
    
    A throwaway probe showed the detector fails on an innerHTML sink and on a third-party <img>. Trusted Types does not break today's bundle, so no directive was dropped. The bundle has no route chunks yet, so the first lazy route will exercise Vite's preload helper.

Merge Danger

Door: two-way

Everything here is additive. Reverting it removes the UI from the image and brings back the startup crash.

Blast Radius: API image

  • Every response now carries the new headers. A future screen that needs a browser permission (fullscreen, say) adds it to Permissions-Policy.
  • A client route whose last segment has a dot (/people/j.doe) is treated as a file and gets a 404.
  • E2E joins the CI gate.
    • The registry cache comes from :edge, which is published with cache-to: type=inline, so only final-stage layers are cached and the UI and .NET build stages rebuild on each run.
    • Changing how images are published is out of scope here.
  • A Dependabot bump of the Node image tag must also move .nvmrc, engines and devEngines.runtime.version before it merges.

🤖 Generated with Claude Code

bitbiter-dev and others added 5 commits October 30, 2026 16:56
…eaders (#359)

Hashed /assets are immutable, every other file is no-cache, and a GET or
HEAD to a path without an extension serves index.html. An unknown /api
path is a JSON 404 and a known route with the wrong method stays 405,
since the fallbacks run as middleware after routing rather than as
endpoints. Every response carries the security headers: the full CSP
with Trusted Types outside /api, nosniff and frame-ancestors on /api.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…owsers (#359)

The API Dockerfile builds the UI in a Node stage on the build platform
and copies it to the web root. The final stage creates the INI folder
for the app user: without it the non-root image could not write app.ini
and did not start.

A Playwright smoke test loads the placeholder from the image, started
with PostgreSQL on tmpfs by the e2e compose file, in WebKit (iPhone),
Chromium (Pixel) and Firefox (phone viewport). It asserts the headers,
the assets' caching, no CSP or Trusted Types violation, no request to
another origin and no axe violation. The E2E job runs it against the
amd64 image built from the PR and is in the CI gate's needs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ers (#359)

Pins the middleware order in Program.cs, which the in-process tests
compose themselves. Also notes that a client route never ends in a dot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…Types check covers (#359)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bitbiter-dev
bitbiter-dev merged commit 3b87c9a into master Oct 30, 2026
20 checks passed
@bitbiter-dev
bitbiter-dev deleted the feat/359-serve-ui branch October 30, 2026 06:46
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.

The API image serves the UI with production caching and security headers

1 participant