Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ flowchart TD
CLI -->|yes| EXIT["run, then exit — the HTTP<br/>pipeline is never registered"]
CLI -->|no| EDGE

EDGE["forwarded headers · security headers · cache defaults<br/>HSTS <i>(not in Development)</i> · exception handler<br/>HTTPS redirect · static files · request logging"]
EDGE["forwarded headers · security headers · cache defaults<br/>HSTS <i>(not in Development)</i> · exception handler<br/>HTTPS redirect · SPA shell · static files · request logging"]
EDGE --> LIMITS["rate limiter · per-endpoint body caps"]
LIMITS --> AUTHN["UseAuthentication<br/><i>JWT → HttpContext.User</i>"]
AUTHN --> AMBIENT["AmbientPrincipalMiddleware<br/><i>blanks the ambient principal for endpoints that must ignore a bearer</i>"]
Expand All @@ -33,17 +33,18 @@ flowchart TD
EPOCH --> MCP["MustChangePasswordMiddleware<br/><i>403s everything but change-password + logout</i>"]
MCP --> AUTHZ["UseAuthorization"]
AUTHZ --> IDEM["IdempotencyMiddleware"]
IDEM --> ENDPOINT["endpoint · /health · SPA fallback"]
IDEM --> ENDPOINT["endpoint · /health · SPA shell fallback"]
```

Four positions in that chain are decisions, not accidents:
Five positions in that chain are decisions, not accidents:

| Placement | Why | Break it and |
|---|---|---|
| `FlockScopeResolutionMiddleware` **after** tenant/user resolution, **before** endpoint queries | It resolves the caller's live `UserRoleAssignment` flock scope for the whole request; it explicitly skips resolution when `IExceptionHandlerFeature` is present (a `/error` re-execution) so a database fault can render `/error` without another assignment query | Moving or removing it lets a restricted Worker read unassigned flock rows (#388); removing the re-execution skip makes error rendering itself fail whenever the original failure was the database, so the client gets no mapped `ProblemDetails` response |
| `CredentialEpochMiddleware` **after** tenant resolution | It reads the user's current epoch from the tenant's database | A revoked credential keeps working (#364) |
| `MustChangePasswordMiddleware` **before** `UseAuthorization` | The gate then applies uniformly, whatever policy tier an endpoint carries | An endpoint's own policy decides whether a forced reset is enforced (#283) |
| `IdempotencyMiddleware` **after** `UseAuthorization` | A replay returns a cached response *without invoking the endpoint* | A role-denied caller replaying someone else's key gets the cached response instead of a 403 |
| `SpaShell` **before** the static-file middleware | It templates `/` and `/index.html` with this response's CSP nonce (#873), and the static middleware would otherwise serve the untemplated file from `wwwroot` first | MUI's Emotion styles are refused by the browser and nothing says so — worst on the clients that installed the service worker, which precaches `/index.html` and answers every navigation from it |

The epoch check is a database round trip on **every authenticated request**, on
purpose — the round trip *is* the fail-closed guarantee. Do not cache it.
Expand Down
66 changes: 66 additions & 0 deletions docs/decisions/674-ui-component-library.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,3 +199,69 @@ time.
a separate entry, and adopting them costs +118.9 KiB of precache (+24.1 KiB on the latin subset
a phone fetches). So `font-variation-settings: "opsz" 32` on today's face does nothing. #835
owns the swap and is charged against #825's ceiling.

## Amendment (2026-09-14): CSP — MUI's styles need a nonce, and the policy stays strict

**Issue:** #873 · **Found by:** #871, on the sim harness at 390px.

### The finding

This app ships `style-src 'self'` with no `'unsafe-inline'`. MUI styles through Emotion,
which injects a `<style>` element at runtime, and the browser refuses to parse it under that
policy. Nothing in the app reports it: the element sits in `<head>` with `sheet === null`,
React is happy, and the only complaint is one console line. Measured in #871 — `html` computed
`box-sizing: content-box` where `CssBaseline` sets `border-box`, and `/daily-entry` laid out
419px wide in a 390px frame. Every `sx`, `styled()` and `styleOverrides` value travels that
same path, so **no MUI styling reached the screen at all**. It stayed invisible until #871
only because no MUI component had rendered yet.

### The three options, and why the third won

| Option | What it costs |
| --- | --- |
| `'unsafe-inline'` on `style-src` | Reopens CSS injection, attribute-selector exfiltration included. A security regression this record would then have to own, to spare a library from carrying a nonce. |
| Build-time CSS extraction | Fights the decision above: the palette is read off the *document* at runtime so the four farm palettes (#149/#586) reach MUI with no per-palette configuration. Extraction wants the values at build time, which is the opposite. |
| **A per-response nonce** | One RNG draw per response, one templated document, one `CacheProvider`. The policy stays exactly as strict as it was. |

The owner chose the third (2026-09-14). `script-src` is untouched and must stay that way: the
pre-paint theme script was externalised in #144 precisely so scripts need no nonce.

### The invariant

**Every Emotion cache in this app carries the page's nonce.** There is one today —
`web/src/theme/FarmThemeProvider.tsx` builds it at module load from
`<meta name="csp-nonce">` and hands it to `CacheProvider`. A second cache created anywhere,
for any reason, is a second thing that must read the same meta; a cache without the nonce
injects styles the browser silently discards, and the screen looks *almost* right, which is
the worst way for this to fail.

A missing meta yields `nonce: undefined`, deliberately. That is the Vite dev server's document,
where no policy applies. In a production build it means the header's nonce reached nobody and
the styles are blocked — the fail-closed outcome. Do not add a fallback that loosens it.

### Two consequences worth knowing before you touch this

**`index.html` is no longer a static file.** `SpaShell` (`src/Cluckwork.Api/Hosting/`) reads it
once at boot, splits it at `<head>`, and writes the nonce meta into every response;
`SecurityHeaders.GetOrCreateNonce` is what both the document and the header read, so they cannot
disagree. It carries no `ETag` and no `Last-Modified` — a validator derived from the file would
tell a client its cached copy is fresh while the live header no longer admits that copy's nonce.
`#141`'s `no-cache` survives unchanged, and hashed `/assets/*` are untouched.

**For a client running the service worker, the nonce is per precache entry, not per request.**
`navigateFallback` (#142) answers navigations from the cached shell, online and offline alike, so
that client keeps one nonce until the worker updates. That is safe for the reason the offline
assertion in `tools/simulation/ui/specs/csp-nonce.spec.ts` checks rather than assumes: workbox
caches one whole `Response`, so the header and the body it stores were minted together and stay
together. What survives is "the nonce is unguessable and specific to this client's cached shell",
not "fresh on every navigation" — and the alternative, refusing to precache the shell, would
retire the offline guarantee #142 exists for.

**No MUI component renders in production today, and nothing here stands in for one.** An earlier
draft of #873 had `web/src/routes/Login.tsx` render one hidden `Chip`, solely so the Playwright
spec had a real Emotion-styled element to read a computed background from. The owner's #874 review
(2026-09-14) removed it: production markup that exists only for a test is a cost this record does
not accept paying. `csp-nonce.spec.ts` still proves the invariant above — the nonce the document
carries matches its own response header, online and after an offline reload — but not that MUI's
styles visibly apply anywhere; #864 tracks the first slice that renders a real component and picks
that assertion back up there.
172 changes: 172 additions & 0 deletions src/Cluckwork.Api/Hosting/SpaShell.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
namespace Cluckwork.Api.Hosting;

using System.Text;
using Cluckwork.Api.Security;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Primitives;
using Microsoft.Net.Http.Headers;

// #873 — the built SPA's index.html, served as a TEMPLATED response rather than
// as a static file, because one value in it has to match the response's own
// Content-Security-Policy header: the style nonce Emotion needs before MUI's
// runtime styles are allowed to apply (#674).
//
// WHY A SPLIT AT BOOT RATHER THAN A REPLACE PER REQUEST. The document never
// changes while the process lives — it is baked into the image — so the only
// per-request work is writing two frozen halves around a 24-character value.
// A per-request read or Replace would re-scan several kilobytes on every
// navigation for a result that cannot differ.
//
// WHY NO ETag AND NO Last-Modified. Every response now carries a different
// nonce, so a validator derived from the FILE would tell a client its stale
// copy is still fresh, and that copy names a nonce the new response's header
// does not admit — MUI would silently lose its styles on exactly the clients
// that cached best. #141's policy is otherwise unchanged: `no-cache`, never
// `immutable`, so the update-detection path the PWA relies on (#142) still
// revalidates on every navigation.
public sealed class SpaShell
{
// Matched case-insensitively because HTML tag names are, even though Vite
// emits a lower-case one and has no reason to stop.
private const string HeadOpenTag = "<head>";

private readonly string _head;
private readonly string _tail;

private SpaShell(string head, string tail)
{
_head = head;
_tail = tail;
}

/// <summary>
/// The shell for this environment, or <c>null</c> when there is no built SPA
/// to serve — Development, where Vite serves the app, and the test host.
/// </summary>
public static SpaShell? Load(IWebHostEnvironment environment)
{
var webRoot = environment.WebRootPath;
if (string.IsNullOrEmpty(webRoot))
return null;

var indexPath = Path.Combine(webRoot, "index.html");
return File.Exists(indexPath) ? FromDocument(File.ReadAllText(indexPath)) : null;
}

/// <summary>Splits a document around the one place the nonce meta goes.</summary>
/// <exception cref="InvalidOperationException">
/// The document has no &lt;head&gt;. Thrown at boot, in the serving process
/// only (a one-shot verb exits before the HTTP pipeline is built), because
/// the alternative is a container that starts, passes its health probe and
/// serves an app with no MUI styling at all — the #510 shape: a check that
/// belongs at boot must not be deferred into a per-request surprise.
/// </exception>
public static SpaShell FromDocument(string document)
{
var head = document.IndexOf(HeadOpenTag, StringComparison.OrdinalIgnoreCase);
if (head < 0)
throw new InvalidOperationException(
"The built SPA's index.html has no <head>, so the CSP nonce (#873) cannot be "
+ "written into it. Serving it untemplated would drop every MUI style silently.");

var insertAt = head + HeadOpenTag.Length;
return new SpaShell(document[..insertAt], document[insertAt..]);
}

/// <summary>Writes the shell carrying this response's own style nonce.</summary>
public Task WriteAsync(HttpContext context)
{
// The SAME value the security-headers callback will put in the policy:
// both go through GetOrCreateNonce, which mints once per request.
var nonce = SecurityHeaders.GetOrCreateNonce(context);

var response = context.Response;
response.ContentType = "text/html; charset=utf-8";
// Indexer, not TryAdd: this is the deliberate policy #312's default
// defers to, exactly as the static-file callback's assignment was.
response.Headers[HeaderNames.CacheControl] = StaticAssetCaching.AlwaysRevalidate;

// The nonce is base64 of this process's own RNG output, so it carries no
// character the attribute would have to escape.
return response.WriteAsync(
$"{_head}<meta name=\"{SecurityHeaders.NonceMetaName}\" content=\"{nonce}\">{_tail}",
Encoding.UTF8);
}
}

public static class SpaShellExtensions
{
/// <summary>
/// Answers <c>/</c> and <c>/index.html</c> with the templated shell.
/// </summary>
/// <remarks>
/// Registered BEFORE the static-file middleware, which is the whole point:
/// left to itself it would serve the untemplated file from wwwroot. Both
/// paths matter and neither is redundant. <c>/</c> is what a person opens,
/// and it used to be reached through UseDefaultFiles, which this replaces.
/// <c>/index.html</c> is what WORKBOX fetches to precache, and the service
/// worker then answers every navigation from that cached response (#142) —
/// so an untemplated copy there would mean the app loses its MUI styling for
/// every client that installed the worker, online and offline alike.
/// </remarks>
public static IApplicationBuilder UseSpaShell(this IApplicationBuilder app, SpaShell shell) =>
app.Use(async (context, next) =>
{
// Ordinal, mirroring the case-sensitive file lookup this replaces on
// Linux: /INDEX.HTML 404s here exactly as it did when the static
// middleware owned the path.
var path = context.Request.Path.Value;
var method = context.Request.Method;
// GET/HEAD only (#874 review, local Codex pass): the static-file
// middleware this replaced only ever served GET/HEAD, and falling
// through here (rather than answering 405 directly) lets the SAME
// 405 come from the MapFallback registration in Program.cs, whose
// HttpMethodMetadata is the one place that decision is made.
if ((path == "/" || string.Equals(path, "/index.html", StringComparison.Ordinal))
&& (HttpMethods.IsGet(method) || HttpMethods.IsHead(method)))
{
await shell.WriteAsync(context);
return;
}

await next();
});
}

/// <summary>
/// Hides the shell's own source document from static-file serving (#874 review,
/// local Codex pass).
/// </summary>
/// <remarks>
/// <see cref="SpaShellExtensions.UseSpaShell"/>'s exact-match check is a literal
/// compare against <c>Request.Path.Value</c>. Kestrel does not collapse a raw
/// "GET //index.html" request-target the way it collapses a "/./" segment (the
/// latter is resolved before the app ever sees it; the former is not), so that
/// request misses the exact match and falls through to <c>UseStaticFiles</c> —
/// whose <c>PhysicalFileProvider</c> still resolves it to the same on-disk
/// index.html, serving the untemplated build artifact with no nonce meta under a
/// CSP header that now requires one. Wrapping the file provider makes the
/// document unreachable through static serving no matter how many leading
/// slashes or what case the request spells it with; the request then falls
/// through further to <c>MapFallback</c>, which answers with the templated shell
/// exactly as it already does for any other client-side route.
/// </remarks>
public sealed class IndexHtmlHidingFileProvider(IFileProvider inner) : IFileProvider
{
public IDirectoryContents GetDirectoryContents(string subpath) => inner.GetDirectoryContents(subpath);

public IFileInfo GetFileInfo(string subpath)
{
// Split rather than compare the raw string: collapses any number of
// leading or doubled slashes away, so "//index.html" and "/index.html"
// resolve to the same single segment. Case-insensitive because the goal
// is "this file is never servable raw" — not a second copy of the
// case-sensitive Linux lookup UseSpaShell already relies on, so
// /INDEX.HTML still 404s either way, just earlier.
var segments = subpath.Split('/', StringSplitOptions.RemoveEmptyEntries);
var isIndexHtml = segments is [var only] && string.Equals(only, "index.html", StringComparison.OrdinalIgnoreCase);
return isIndexHtml ? new NotFoundFileInfo(subpath) : inner.GetFileInfo(subpath);
}

public IChangeToken Watch(string filter) => inner.Watch(filter);
}
47 changes: 40 additions & 7 deletions src/Cluckwork.Api/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
using Cluckwork.Infrastructure.RateLimiting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Serilog;
Expand Down Expand Up @@ -208,10 +209,25 @@
// public — mounted before auth. API routes and the SPA fallback are wired below.
// #141 — hashed /assets/* are immutable-forever, index.html revalidates, so a
// fronting CDN (and browsers) can cache aggressively without serving a stale app.
app.UseDefaultFiles();
//
// #873 — index.html is NOT one of those static assets any more. It carries a
// per-response CSP nonce, so it is read and split once here and written by
// SpaShell below; `null` means there is no built SPA (Development, the test
// host), and everything then behaves exactly as it did before.
var spaShell = SpaShell.Load(app.Environment);
if (spaShell is not null)
app.UseSpaShell(spaShell);
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = StaticAssetCaching.ApplyCacheHeaders
OnPrepareResponse = StaticAssetCaching.ApplyCacheHeaders,
// #874 review (local Codex pass) — null (the middleware's own default,
// env.WebRootFileProvider) when there is no built SPA to template in the
// first place; wrapped only when SpaShell owns index.html, so the raw file
// can never leak through an alternate path spelling UseSpaShell's exact
// compare misses (e.g. a raw "GET //index.html").
FileProvider = spaShell is not null
? new IndexHtmlHidingFileProvider(app.Environment.WebRootFileProvider)
: null
});

// One structured completion line per request (#214): method, path, status,
Expand Down Expand Up @@ -563,12 +579,29 @@
// index.html. Lowest route priority, so the /api/v1 endpoints and /health above
// always match first. No-op in dev (no wwwroot) — dev uses the Vite server.
// #141 — the fallback ALWAYS serves index.html, so it unconditionally emits
// no-cache (AlwaysRevalidateHeader): a new deploy propagates immediately even
// through a fronting CDN, and a missing /assets/x can never be pinned immutable.
app.MapFallbackToFile("index.html", new StaticFileOptions
// no-cache: a new deploy propagates immediately even through a fronting CDN,
// and a missing /assets/x can never be pinned immutable.
if (spaShell is not null)
{
OnPrepareResponse = StaticAssetCaching.AlwaysRevalidateHeader
});
// GET/HEAD only (#874 review, local Codex pass): the StaticFileMiddleware +
// MapFallbackToFile pair this replaced served only GET/HEAD (the former
// skips other methods outright, the latter carries its own GET/HEAD
// HttpMethodMetadata) — measured directly against that pre-#873 pipeline,
// which answers 405 with "Allow: GET, HEAD" for e.g. POST / or DELETE
// /index.html. Without this metadata spaShell.WriteAsync has no method
// restriction of its own and those same requests get a 200 HTML shell.
// A named local function rather than the method group: the #872 adapter-reach
// walk resolves a handler through its declaring type, and a method group on
// a local variable has none it can see.
Task WriteSpaShell(HttpContext context) => spaShell.WriteAsync(context);
app.MapFallback(WriteSpaShell)
.WithMetadata(new HttpMethodMetadata([HttpMethods.Get, HttpMethods.Head]));
}
else
app.MapFallbackToFile("index.html", new StaticFileOptions
{
OnPrepareResponse = StaticAssetCaching.AlwaysRevalidateHeader
});

app.Run();
return 0;
Expand Down
Loading
Loading