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
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 |