Files
SluijsensandClaude Opus 5 29a93ef873 Separates website and admin roots, adds /health, hardens the availability gate
Prepares the single-host layout for deployment. The customer's public
website moves from wwwroot/ to wwwroot/web/, so a CMS deploy can no
longer overwrite content it does not own: with the website in its own
directory, the release directory can be swapped without touching it.

Each front-end gets its own file provider, and both tolerate a missing
directory at startup — a fresh deployment has no website until a
separate workspace deploys one, and the CMS must still serve /admin and
the API. When the website's index.html is absent, an embedded
placeholder is served instead of a 404, which also doubles as proof the
CMS itself is running. The placeholder is embedded in the assembly
rather than shipped into wwwroot/web/, because that directory is owned
and overwritten by the website workspace.

Adds GET /health for uptime monitoring. It reports infrastructure
liveness only and is deliberately NOT the same thing as
/api/v1/Availability/status or /api/v1/System/capabilities: those are
CMS domain state that also serve the master/slave protocol. A healthy
instance can be switched off by design, and a switched-on instance can
be unhealthy, so conflating them would alert on business state and stay
silent on real outages. /health is on the availability gate's bypass
list for the same reason.

Fixes a real defect in the gate's admin bypass. It parsed the bearer
token with ReadJwtToken, which reads claims without verifying the
signature, so an unauthenticated caller could forge an unsigned token
carrying an Owner role claim and bypass the gate that suspends a
customer's site. Protected endpoints still rejected them, so nothing
leaked — but the gate itself was bypassable. The token is now fully
validated against the same parameters as the bearer scheme, resolved
from one shared source so the two cannot drift apart.

Host wiring for these changes lands with the data-durability commit,
since both units touch the same lines of Program.cs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
2026-07-28 00:00:13 +02:00

9.1 KiB

Business Rules — U1 Hosting & Serving


Rule Categories

graph TD
    start["Request or startup event"]
    cat1{"Path resolution ?"}
    cat2{"Availability gate ?"}
    cat3{"Health reporting ?"}
    cat4{"Startup validation ?"}
    r1["BR-U1-01 to BR-U1-08"]
    r2["BR-U1-09 to BR-U1-14"]
    r3["BR-U1-15 to BR-U1-19"]
    r4["BR-U1-20 to BR-U1-22"]

    start --> cat1
    start --> cat2
    start --> cat3
    start --> cat4
    cat1 --> r1
    cat2 --> r2
    cat3 --> r3
    cat4 --> r4

    classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
    classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
    classDef rules fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
    class start entry;
    class cat1,cat2,cat3,cat4 decision;
    class r1,r2,r3,r4 rules;

Text alternative: rules divide into four groups — path resolution, availability gating, health reporting and startup validation.


Path Resolution Rules

ID Rule
BR-U1-01 The /admin mount is evaluated before the root mount. A path beginning /admin is never resolved against wwwroot/web/.
BR-U1-02 wwwroot/admin/ serves paths under /admin; wwwroot/web/ serves paths under /. Neither mount may serve files from outside its own directory.
BR-U1-03 A request for the exact path /admin (no trailing slash) returns a redirect to /admin/. Deeper paths are unaffected.
BR-U1-04 A request whose path contains a file extension and matches no file returns 404. It is never given an index.html.
BR-U1-05 A request whose path contains no file extension and matches no file falls back to an index.html: wwwroot/admin/index.html when the path begins /admin, otherwise the website's.
BR-U1-06 When the website fallback is required but wwwroot/web/index.html does not exist, the built-in placeholder page is returned with status 200.
BR-U1-07 Directory browsing is disabled on both mounts. A request for a directory path returns its default file or falls through to the fallback rules — never a file listing.
BR-U1-08 Static-file responses short-circuit the pipeline. Any behaviour that must apply to them — notably security headers — must be registered before the static-file middleware.

Rationale for BR-U1-04: distinguishing "missing asset" from "client-side route" is what keeps a broken deployment visible. Without it, a missing JavaScript bundle would receive an HTML page, and the browser error would point at a parse failure rather than the real cause.


Availability Gate Rules

ID Rule
BR-U1-09 /health is on the bypass prefix list. The availability gate never blocks it.
BR-U1-10 The existing bypass prefixes are retained unchanged: /api/v1/Availability/status, /api/v1/Auth/, /api/v1/Setup/status, /api/v1/master/, /api/v1/SlaveStatus.
BR-U1-11 The admin bypass applies only when the bearer token validates successfully against the same TokenValidationParameters used by the JWT bearer scheme — signature, issuer, audience and lifetime.
BR-U1-12 A token that fails validation for any reason grants no bypass. The request proceeds to normal availability evaluation as if no token were present.
BR-U1-13 A validated token grants bypass only when it carries the role Owner or Administrator.
BR-U1-14 Token validation failure is never itself an error response. The gate does not return 401; that remains the responsibility of the authentication middleware on protected endpoints.

Rationale for BR-U1-11 and BR-U1-12: this is the FR-24 fix. Previously the token was parsed but not verified, so an unauthenticated caller could present a self-made token carrying an Owner claim and bypass the gate. Protected endpoints still rejected them, so no data was exposed — but the gate itself, the mechanism that suspends a customer's site, was bypassable by anyone who knew the claim name.

Rationale for BR-U1-14: the gate's job is to decide whether to serve, not to authenticate. Returning 401 from the gate would change the response for anonymous endpoints that are legitimately reachable, such as /api/v1/Setup/status.

Single source for validation parameters: the parameters must be resolved from one shared definition used by both the bearer scheme and the gate. Copying them would allow the two to drift, and a drift in which the gate is more permissive than the scheme silently re-opens the hole this rule closes.


Health Reporting Rules

ID Rule
BR-U1-15 /health performs no database call and probes no external dependency. Its answer is derived entirely from in-process state.
BR-U1-16 A running process always answers 200. The unhealthy signal is the absence of a response, produced by fail-fast startup (U2).
BR-U1-17 The response body reports status, timestamp, application version and the names of loaded modules.
BR-U1-18 /health is anonymous. It must never expose configuration values, connection strings, environment variable contents, file paths, or stack traces.
BR-U1-19 /health is never presented as, aliased to, or documented as equivalent to /api/v1/Availability/status or /api/v1/System/capabilities. Those report CMS domain state; /health reports infrastructure liveness.

Rationale for BR-U1-17: the module list exists because ModuleOrchestrator logs rather than throws when a module fails to load. An instance can therefore start "successfully" with a missing capability, and NFR-06 requires that to be detectable after a deploy without host access. The version field serves the same purpose for the deploy itself — confirming which build is actually running.

Disclosure note: module names are already publicly available from /api/v1/System/capabilities, which is anonymous, so BR-U1-17 adds no new disclosure there. The version field is new disclosure. It is accepted deliberately: verifying which build is live is the primary reason the endpoint exists, and the alternative — an authenticated health endpoint — would not work with UptimeRobot. Recorded as a conscious trade-off rather than an oversight.


Startup Validation Rules

ID Rule
BR-U1-20 A missing wwwroot/web/ directory never prevents startup.
BR-U1-21 A missing wwwroot/web/ directory is logged as a warning at startup, including the absolute path that was expected.
BR-U1-22 A missing wwwroot/admin/ directory is logged as a warning but likewise does not prevent startup — it indicates a publish problem, not a reason to refuse traffic to /api/v1.

Rationale for BR-U1-21: normal on a fresh installation, alarming on a running production instance where it means the customer's website has disappeared. A warning is visible in Sentry and the console without cost in the normal case.


Error and Edge-Case Scenarios

Scenario Expected behaviour
Fresh install, no website deployed, visitor requests / Placeholder page, 200
Fresh install, visitor requests /admin Redirect to /admin/, then the admin SPA
Website deployed but index.html missing Placeholder page, 200. The directory existing is not proof of a valid site
Request for /assets/app.js that does not exist 404, never HTML
Request for /admin/assets/app.js that does not exist 404, never HTML
Request for /some/client/route with no extension Website index.html, or placeholder if absent
Request for /admin/dashboard wwwroot/admin/index.html
Instance disabled, request for /health 200 with the health report — the gate does not apply
Instance disabled, request for / where the website exists The website is served — static files short-circuit before the gate
Instance disabled, request for /admin/dashboard 503 ProblemDetails — the fallback is an endpoint, so the gate applies
Instance disabled, valid Owner token Bypass granted, request proceeds
Instance disabled, forged unsigned token claiming Owner No bypass. 503. This is the FR-24 fix
Instance disabled, expired but genuine Owner token No bypass — lifetime validation is part of BR-U1-11
Instance disabled, no token 503 ProblemDetails
Malformed Authorization header Treated as no token; no exception surfaces to the caller
Path traversal attempt, e.g. /../appsettings.json Rejected by the file provider; never resolves outside its mount root

Security Compliance for U1

Rule Status Notes
SECURITY-05 Compliant /health accepts no input. Path traversal is prevented by the file providers
SECURITY-08 Improved BR-U1-11 to BR-U1-14 close the forged-token bypass. /health is deliberately anonymous and exposes no resource data
SECURITY-09 Compliant Directory browsing disabled; BR-U1-18 forbids exposing internals; the version disclosure in BR-U1-17 is documented and justified
SECURITY-15 Compliant The gate fails closed — a token that cannot be validated grants nothing