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
This commit is contained in:
2026-07-28 00:00:13 +02:00
co-authored by Claude Opus 5
parent 8568ca43c6
commit 29a93ef873
21 changed files with 1677 additions and 86 deletions
@@ -0,0 +1,149 @@
# Code Generation Plan — U1 Hosting & Serving
**This plan is the single source of truth for Code Generation of U1.** Generation executes exactly these steps in order; no step is added or skipped during execution.
---
## Unit Context
| Aspect | Detail |
|---|---|
| **Unit** | U1 Hosting & Serving |
| **Round** | R1 (with U2 Data Durability) |
| **Workspace root** | `K:\Development\Projects\SlpModularCms` |
| **Project type** | Brownfield — existing structure retained, files modified in place |
| **Requirements** | FR-07, FR-10, FR-24 |
| **Components** | C-04 health checks, C-10 static mounts, C-13 availability middleware, U1 portion of C-16 |
| **Business rules** | BR-U1-01 … BR-U1-22 |
| **Depends on** | Nothing. U1 and U2 are mutually independent |
| **Depended on by** | U3 (path layout for CSP scoping), U6 (deploys into this layout) |
| **New database entities** | **None** — U1 adds no table, migration or configuration section |
### Requirement traceability
| Requirement | Implemented by steps |
|---|---|
| FR-07 — serve `/` from `wwwroot/web/`, `/admin` from `wwwroot/admin/` | 4, 5, 6 |
| FR-10 — `/health` liveness endpoint on the availability bypass list | 2, 6, 7 |
| FR-24 — validate the token in the availability gate's admin bypass | 3, 7, 8 |
---
## Generation Steps
### Step 1: Shared JWT validation parameters (Core)
- [x] Create `src/SlpModularCms.Core/Hosting/JwtTokenValidation.cs` — a factory producing `TokenValidationParameters` from `JwtSettings`, with `ClockSkew.Zero`, matching the current inline configuration exactly
- [x] Modify `src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs` so `AddJwtBearer` consumes the factory instead of building parameters inline
- [x] Register the produced `TokenValidationParameters` as a singleton so the availability gate resolves the **same instance**
*Implements the BR-U1-11 single-source constraint: two copies could drift, and a gate more permissive than the bearer scheme would silently re-open the hole FR-24 closes.*
### Step 2: Health check registration (Core)
- [x] Create `src/SlpModularCms.Core/Hosting/Health/HealthCheckExtensions.cs` with `AddCmsHealthChecks()` and `MapCmsHealthChecks()`
- [x] Create the `HealthReport` response model — `status`, `timestamp`, `version`, `modules`
- [x] Compose the report from in-process state only: no database call, no dependency probe (BR-U1-15)
- [x] Read module names from the existing `ModuleOrchestrator`
- [x] Read the version from the assembly's informational version
- [x] Deliberately expose **no** options parameter, so adding a database check later is a visible code change rather than configuration drift
### Step 3: Admin token validator (Core)
- [x] Create `src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs` and `AdminTokenValidator.cs`
- [x] Validate the bearer token against the shared `TokenValidationParameters` from Step 1 — signature, issuer, audience and lifetime (BR-U1-11)
- [x] Return true only when validation succeeds **and** the principal carries role `Owner` or `Administrator` (BR-U1-13)
- [x] Return false — never throw — for absent, malformed, forged or expired tokens (BR-U1-12, BR-U1-14)
- [x] Register in `ServiceCollectionExtensions.AddCoreInfrastructure`
### Step 4: Static content composition (Api host)
- [x] Create `src/SlpModularCms.Api/Extensions/StaticContentExtensions.cs` with `UseCmsStaticContent()`
- [x] Register the `/admin` mount **first**, then the root mount, each with its own `PhysicalFileProvider` (BR-U1-01)
- [x] Enable default-file handling per mount; leave directory browsing disabled (BR-U1-07)
- [x] Tolerate a missing physical directory at startup for both mounts (BR-U1-20, BR-U1-22)
- [x] Log a warning naming the absolute expected path when a directory is absent (BR-U1-21)
- [x] Redirect the exact path `/admin` to `/admin/`, leaving deeper paths untouched (BR-U1-03)
### Step 5: Placeholder page (Api host)
- [x] Create `src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html` — states no website is deployed, names the expected target path, links to `/admin`
- [x] Contain no version, environment name, module list or configuration (domain-entities.md)
- [x] Modify `src/SlpModularCms.Api/SlpModularCms.Api.csproj` to embed it as an `EmbeddedResource`
- [x] Serve it with status `200` when the website fallback is needed and `wwwroot/web/index.html` is absent (BR-U1-06)
*Embedded rather than placed in `wwwroot/web/`, because that directory is owned and overwritten by a website workspace — a file there would be deleted by the first real deployment or mistaken for part of the customer's site.*
### Step 6: Api host composition
- [x] Modify `src/SlpModularCms.Api/Program.cs`:
- [x] Replace `UseDefaultFiles()` + `UseStaticFiles()` with `UseCmsStaticContent()`
- [x] Register `AddCmsHealthChecks()` alongside the existing service registrations
- [x] Map `MapCmsHealthChecks()` after `MapControllers()`
- [x] Retarget both `MapFallbackToFile` registrations to the two mounts, preserving the `nonfile` constraint (BR-U1-04, BR-U1-05)
### Step 7: Slave host composition
- [x] Modify `src/SlpModularCms.Api.Slave/Program.cs`:
- [x] Register `AddCmsHealthChecks()` and map `MapCmsHealthChecks()`
- [x] Add **no** static mounts — the Slave serves no static content (Q2 of Application Design = A)
### Step 8: Availability middleware (Modules.Availability)
- [x] Modify `src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs`:
- [x] Add `/health` to `_bypassPrefixes` (BR-U1-09), leaving the existing entries unchanged (BR-U1-10)
- [x] Replace the `JwtSecurityTokenHandler.ReadJwtToken` call in `IsAdminBypass` with the injected `IAdminTokenValidator`
- [x] Remove the now-unused `System.IdentityModel.Tokens.Jwt` usage
### Step 9: Core unit tests
- [x] Create `src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs` — valid Owner token accepted; valid Administrator accepted; valid User rejected; forged unsigned token rejected; expired token rejected; malformed header rejected; absent header rejected
- [x] Create `src/SlpModularCms.Core.Tests/Hosting/HealthReportTests.cs` — report composition; module names sourced from the orchestrator; no configuration or path values present
### Step 10: Availability module unit tests
- [x] Modify `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs` for the constructor change, and add: `/health` bypasses while the instance is disabled; a forged Owner token grants **no** bypass; a valid Owner token still bypasses
- [x] Modify `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs` for the constructor change
### Step 11: Static content unit tests — **DEVIATED, see generation-summary.md**
- [~] `StaticContentTests.cs` **not created**: the plan placed it in `SlpModularCms.Core.Tests`, but `StaticContentExtensions` lives in `SlpModularCms.Api`, which `Core.Tests` does not reference. `SlpModularCms.Api` has no test project by the same convention that gives `SlpModularCms.Api.Slave` none
- [x] Behaviour requiring a composed pipeline recorded in the unit summary as carried to the phase-level Build and Test stage
### Step 12: Documentation
- [x] Create `aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/code/generation-summary.md` — files created and modified, decisions taken, and any deviation from this plan
### Step 13: Build and test verification (automatic)
- [x] `dotnet build SlpModularCms.sln -c Release`
- [x] `dotnet test` for `SlpModularCms.Core.Tests` and `SlpModularCms.Modules.Availability.Tests`
- [x] Fix any failure directly and re-run until green
- [x] Record the outcome for the completion message
---
## Files Touched
### Created
| Path | Purpose |
|---|---|
| `src/SlpModularCms.Core/Hosting/JwtTokenValidation.cs` | Shared validation parameters |
| `src/SlpModularCms.Core/Hosting/Health/HealthCheckExtensions.cs` | Health registration and endpoint |
| `src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs` | Contract |
| `src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs` | Implementation |
| `src/SlpModularCms.Api/Extensions/StaticContentExtensions.cs` | Two-mount composition |
| `src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html` | Embedded placeholder |
| `src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs` | Tests |
| `src/SlpModularCms.Core.Tests/Hosting/HealthReportTests.cs` | Tests |
| `src/SlpModularCms.Core.Tests/Hosting/StaticContentTests.cs` | Tests |
### Modified
| Path | Change |
|---|---|
| `src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs` | Use the shared factory; register the validator |
| `src/SlpModularCms.Api/Program.cs` | Static content, health checks, retargeted fallbacks |
| `src/SlpModularCms.Api/SlpModularCms.Api.csproj` | Embed the placeholder |
| `src/SlpModularCms.Api.Slave/Program.cs` | Health checks only |
| `src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs` | `/health` bypass; validated admin bypass |
| `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs` | Constructor change plus new cases |
| `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs` | Constructor change |
**Brownfield rule**: every file above that exists is modified in place. No `*_new`, `*_modified` or parallel copies.
---
## Out of Scope for U1
- Security headers — U3
- Sentry, Umami, same-origin frontend config — U4
- Data Protection and automatic migrations — U2
- Anything under `.gitea/` — U5 and U6
- README and website contract — U7
@@ -0,0 +1,69 @@
# Functional Design Questions — U1 Hosting & Serving
Vul je keuze in achter elke `[Answer]:`-tag. Kies de laatste optie (`Anders`) als niets past.
---
## Question 1 — Hoe implementeren we de FR-24-fix?
**Context**: dit is het conflict uit § 5.2 van het applicatieontwerp. `AvailabilityMiddleware` wordt geïnstalleerd door `orchestrator.UseModules(app)`, wat **vóór** `app.UseAuthentication()` staat. Op dat moment is `HttpContext.User` dus nog leeg — de middleware kan niet simpelweg de al geauthenticeerde gebruiker uitlezen.
Dat is precies waarom de huidige code `ReadJwtToken` gebruikt: die werkt zonder authenticatie, maar valideert de handtekening niet.
A) Valideer het token in de middleware zelf, met dezelfde `TokenValidationParameters` als het bearer-schema — die parameters worden dan uit één gedeelde bron gehaald in plaats van gekopieerd. Afgebakend: alleen deze middleware verandert
B) Verplaats `app.UseAuthentication()` naar vóór `orchestrator.UseModules(app)`, zodat de middleware `HttpContext.User` kan gebruiken. Kleinere wijziging in regels code, maar verandert de pipeline voor élke module — ook toekomstige
C) Laat de gate-bypass helemaal vervallen en gebruik in plaats daarvan een vaste bypass-prefix voor de admin-endpoints — geen tokenlogica meer in de middleware
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]:A
---
## Question 2 — Wat gebeurt er als `wwwroot/web/` niet bestaat?
**Context**: bij een verse deploy is er nog geen publieke website — die komt uit een andere workspace. De CMS moet dan gewoon starten en `/admin` en `/api/v1` blijven serveren. Maar wat krijgt een bezoeker op `/` te zien?
A) Een standaard 404 — er is niets, dus dat is het eerlijke antwoord
B) Een ingebouwde placeholderpagina met de melding dat er nog geen website is geplaatst, plus een verwijzing naar `/admin` — handig bij een verse installatie, en meteen bewijs dat de CMS draait
C) Een redirect naar `/admin` — de enige zinvolle bestemming zolang er geen website is
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]:B
---
## Question 3 — Moet de applicatie hierover iets loggen bij het opstarten?
**Context**: een ontbrekende `wwwroot/web/` is bij een verse installatie normaal, maar op een draaiende productieomgeving zou het betekenen dat de website van de klant verdwenen is — precies het scenario dat we met de release-opzet proberen te voorkomen.
A) Waarschuwing bij opstarten als de map ontbreekt, met het verwachte pad erbij — zichtbaar in Sentry en de console, zonder het opstarten te blokkeren
B) Alleen een informatieregel — het is een normale toestand bij een verse installatie
C) Niets loggen — de 404 of placeholder zegt genoeg
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]:A
---
## Question 4 — Wat geeft `/health` terug?
**Context**: het framework-standaardantwoord is platte tekst `Healthy` met status 200, of `Unhealthy` met 503. UptimeRobot heeft aan de statuscode genoeg.
A) De standaard platte tekst — minimaal, snel, en geeft niets prijs over de applicatie
B) Een klein JSON-object met status en tijdstip — iets makkelijker te lezen bij handmatig controleren
C) JSON met status, tijdstip, versie en geladen modules — dan zie je meteen of alle modules geladen zijn na een deploy
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]:C
---
## Question 5 — Moet `/admin` zonder slash doorverwijzen naar `/admin/`?
**Context**: de admin-SPA is gebouwd met `base: '/admin/'`. Als iemand `/admin` intypt zonder afsluitende slash, worden relatieve verwijzingen in de pagina één niveau te hoog opgelost, waardoor de SPA stuk kan gaan. Een redirect naar `/admin/` voorkomt dat.
A) Ja, redirect `/admin` naar `/admin/` — voorkomt een categorie fouten die lastig te herkennen is
B) Nee, laat de SPA-fallback het afhandelen — minder magie in de pipeline
X) Anders (beschrijf hieronder na de [Answer]:-tag)
[Answer]:A
@@ -0,0 +1,47 @@
# Functional Design Plan — U1 Hosting & Serving
**Unit**: U1 Hosting & Serving
**Round**: R1 (with U2 Data Durability)
**Requirements**: FR-07, FR-10, FR-24
**Components**: C-04, C-10, C-13, U1 portion of C-16
---
## Step 1: Analyze unit context
- [x] Read the U1 definition from `unit-of-work.md`
- [x] Read the requirement assignment from `unit-of-work-story-map.md`
- [x] Read the carried-in design items — § 5.2 pipeline ordering, missing-directory startup behaviour
## Step 2: Design static-file serving behaviour
- [x] Define mount registration order and request-path resolution for the two mounts
- [x] Define default-file handling per mount
- [x] Define SPA fallback precedence between `/admin/{*path:nonfile}` and `{*path:nonfile}`
- [x] Define behaviour when `wwwroot/web/` is absent at startup
- [x] Define behaviour when `wwwroot/web/` exists but has no `index.html`
- [x] Define trailing-slash handling for `/admin`
- [x] Confirm directory browsing stays disabled
## Step 3: Design the health endpoint
- [x] Define the response contract for healthy and unhealthy states
- [x] Confirm no dependency probing is performed
- [x] Define behaviour while the instance is availability-disabled
- [x] Define what the endpoint must never expose
## Step 4: Design the availability-gate changes
- [x] Define the `/health` bypass placement within the existing prefix list
- [x] Resolve the FR-24 implementation approach — pipeline ordering versus in-middleware validation
- [x] Define admin-bypass behaviour for valid, forged, expired and absent tokens
- [x] Confirm the preserved behaviour: a valid Owner or Administrator token still bypasses the gate
## Step 5: Define business rules
- [x] Enumerate path-resolution rules with precedence
- [x] Enumerate health-reporting rules
- [x] Enumerate admin-bypass rules
- [x] Identify error and edge-case scenarios
## Step 6: Generate artifacts
- [x] Generate `business-logic-model.md`
- [x] Generate `business-rules.md`
- [x] Generate `domain-entities.md`
- [x] Validate all diagrams against the Mermaid standards
- [x] Verify Security Baseline compliance for this unit's design
@@ -0,0 +1,86 @@
# Code Generation Summary — U1 Hosting & Serving
**Date**: 2026-07-27
**Requirements**: FR-07, FR-10, FR-24
---
## Files Created
| Path | Purpose |
|---|---|
| `src/SlpModularCms.Core/Hosting/JwtTokenValidation.cs` | Single source of the JWT validation parameters |
| `src/SlpModularCms.Core/Hosting/Health/HealthReport.cs` | Liveness response model |
| `src/SlpModularCms.Core/Hosting/Health/HealthCheckExtensions.cs` | `AddCmsHealthChecks()` / `MapCmsHealthChecks()` |
| `src/SlpModularCms.Core/Hosting/Security/IAdminTokenValidator.cs` | Contract for the validated admin bypass |
| `src/SlpModularCms.Core/Hosting/Security/AdminTokenValidator.cs` | Implementation |
| `src/SlpModularCms.Api/Extensions/StaticContentExtensions.cs` | Two-mount composition and SPA fallbacks |
| `src/SlpModularCms.Api/Extensions/WebsitePlaceholder.html` | Embedded placeholder page |
| `src/SlpModularCms.Core.Tests/Hosting/AdminTokenValidatorTests.cs` | 13 tests |
| `src/SlpModularCms.Core.Tests/Hosting/HealthReportTests.cs` | 3 tests |
## Files Modified
| Path | Change |
|---|---|
| `src/SlpModularCms.Core/Hosting/ServiceCollectionExtensions.cs` | Validation parameters built once via the factory, registered as a singleton and shared with the bearer scheme; `IAdminTokenValidator` registered |
| `src/SlpModularCms.Api/Program.cs` | `AddCmsHealthChecks()`, `UseCmsStaticContent()`, `MapCmsHealthChecks()`, `MapCmsSpaFallbacks()` |
| `src/SlpModularCms.Api/SlpModularCms.Api.csproj` | Placeholder embedded as a resource |
| `src/SlpModularCms.Api.Slave/Program.cs` | Health checks; no static mounts |
| `src/SlpModularCms.Modules.Availability/Middleware/AvailabilityMiddleware.cs` | `/health` bypass; `IsAdminBypass` delegates to the validator; unvalidated `ReadJwtToken` removed |
| `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs` | Constructor change; new bypass and forged-token cases |
| `src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareMasterGateTests.cs` | Constructor change; `/health` bypass case |
No duplicate or parallel files were created — every existing file was modified in place.
---
## Implementation Decisions
### The forged-token fix is proven against the real validator, not only a substitute
The middleware's own tests substitute `IAdminTokenValidator`, which is correct unit-testing practice — the middleware's job is to *ask*, not to validate. But a substitute keeps passing even if the middleware were later rewired back to unvalidated token parsing.
A nested `WithRealValidator` class therefore wires the middleware to the actual `AdminTokenValidator` and asserts both halves of the fix: a **forged unsigned Owner token is rejected**, and a **genuine Owner token still bypasses**. The second matters as much as the first — an administrator must always be able to reach a disabled instance to switch it back on.
### `AddCmsHealthChecks()` deliberately takes no options
Adding a database probe therefore requires editing this method, which is visible in review, rather than flipping a setting. Liveness-only is enforced by the shape of the API instead of by discipline.
### The placeholder is an embedded resource, and that was verified
`wwwroot/web/` is owned and overwritten by a separate website workspace, so a placeholder file there would be deleted by the first real deployment or mistaken for part of the customer's site. Embedding keeps it outside that boundary.
Because a wrong resource name would fail *silently* — falling back to a minimal inline HTML string — the compiled assembly's manifest was inspected to confirm the name resolves: `SlpModularCms.Api.Extensions.WebsitePlaceholder.html`.
### Static mounts are resolved at startup
`RegisterMount` only registers a mount when its directory exists, so a directory created *after* the process started is not served until the next restart. This is correct for the intended deployment model — the atomic release switch links `wwwroot/web/` into place before the process starts — but it is behaviour worth knowing: dropping a website into a running instance requires a restart.
---
## Deviation From the Plan
**Step 11 (`StaticContentTests`) was not implemented as written.** The plan placed it in `SlpModularCms.Core.Tests`, but `StaticContentExtensions` lives in the `SlpModularCms.Api` project, which `Core.Tests` does not reference and must not.
`SlpModularCms.Api` has no test project, by the same deliberate convention that gives `SlpModularCms.Api.Slave` none — the Clients solution folder holds deployables, not tested libraries. Creating one would have been a structural change outside this unit's scope.
What the step was meant to cover is mostly ASP.NET Core's own static-file behaviour rather than this project's logic. The genuinely project-specific behaviours — mount ordering, fallback precedence, the `nonfile` constraint, the placeholder path and the `/admin` redirect — require a composed host and are therefore **carried to the phase-level Build and Test stage**, where both hosts are started.
Carried to Build and Test:
- `/admin` redirects to `/admin/`
- A missing asset under either mount returns `404`, never HTML
- A client-side route under `/admin` serves the admin `index.html`
- A client-side route at the root serves the website `index.html`, or the placeholder when absent
- `/health` answers while the instance is availability-disabled
---
## Verification
| Check | Result |
|---|---|
| `dotnet build SlpModularCms.sln -c Release` | ✅ 0 errors |
| `SlpModularCms.Core.Tests` | ✅ 83 passed (was 54) |
| `SlpModularCms.Modules.Availability.Tests` | ✅ 82 passed (was 78) |
| `SlpModularCms.Modules.Identity.Tests` | ✅ 37 passed (unchanged) |
| `SlpModularCms.Modules.Master.Tests` | ✅ 51 passed (was 50) |
| Embedded resource name resolves | ✅ Verified against the compiled assembly manifest |
No failures occurred during generation; nothing needed fixing and retrying.
@@ -0,0 +1,213 @@
# Business Logic Model — U1 Hosting & Serving
**Unit**: U1 Hosting & Serving
**Requirements**: FR-07, FR-10, FR-24
---
## 1. Scope of the Logic
U1 contains no domain business logic in the usual sense — no customer, order or invoice. What it does contain is **request-resolution logic**: given an incoming path, which of three co-hosted surfaces should answer, and under what conditions may the answer be suppressed.
Three decisions are made per request:
1. Which static mount, if any, owns this path
2. Whether the availability gate applies
3. Which fallback resolves a client-side route
---
## 2. Request Resolution Flow
```mermaid
graph TD
req["Incoming request"]
hdr["Security headers register<br/>response-start callback"]
adminmount{"Path starts with /admin ?"}
adminslash{"Path is exactly /admin<br/>without trailing slash ?"}
redirect["308 redirect to /admin/"]
adminfile{"File exists in<br/>wwwroot/admin ?"}
serveadmin["Serve admin asset"]
webfile{"File exists in<br/>wwwroot/web ?"}
serveweb["Serve website asset"]
gate["Availability gate"]
bypass{"Bypass prefix<br/>or valid admin token ?"}
blocked["503 ProblemDetails"]
route["Routing"]
health{"Path is /health ?"}
healthresp["Health report"]
api{"Path starts with /api/v1 ?"}
ctrl["Controller"]
fallback{"Path has a file extension ?"}
notfound["404"]
whichspa{"Path starts with /admin ?"}
adminindex["Serve wwwroot/admin/index.html"]
webindex{"wwwroot/web/index.html<br/>exists ?"}
serveindex["Serve website index.html"]
placeholder["Serve built-in placeholder page"]
req --> hdr
hdr --> adminmount
adminmount -->|yes| adminslash
adminslash -->|yes| redirect
adminslash -->|no| adminfile
adminfile -->|yes| serveadmin
adminfile -->|no| gate
adminmount -->|no| webfile
webfile -->|yes| serveweb
webfile -->|no| gate
gate --> bypass
bypass -->|no, and disabled| blocked
bypass -->|yes or available| route
route --> health
health -->|yes| healthresp
health -->|no| api
api -->|yes| ctrl
api -->|no| fallback
fallback -->|yes| notfound
fallback -->|no| whichspa
whichspa -->|yes| adminindex
whichspa -->|no| webindex
webindex -->|yes| serveindex
webindex -->|no| placeholder
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef serve fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef bad fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
class req,hdr entry;
class adminmount,adminslash,adminfile,webfile,bypass,health,api,fallback,whichspa,webindex decision;
class serveadmin,serveweb,route,healthresp,ctrl,adminindex,serveindex,placeholder,redirect serve;
class blocked,notfound bad;
```
Text alternative: static mounts are checked first and short-circuit when a file exists; everything else passes the availability gate, then routes to the health endpoint, a controller, a 404 for missing assets, or one of the two SPA fallbacks — with a built-in placeholder when the website's index is absent.
**Key property**: static files short-circuit before the availability gate. A deliberately disabled instance therefore still serves the customer's website while blocking `/api/v1` and the admin SPA's routes. This is pre-existing behaviour, retained deliberately (see `architecture.md`).
---
## 3. Admin Bypass Evaluation (FR-24)
The gate's admin bypass currently parses the bearer token **without verifying its signature**, so a forged token grants bypass. Per Q1 = A the middleware will validate the token itself using the same `TokenValidationParameters` as the JWT bearer scheme, obtained from a **single shared source** rather than copied.
This approach was chosen over moving `UseAuthentication()` earlier, which would have changed the pipeline for every module including future ones.
```mermaid
sequenceDiagram
box rgba(246,224,94,0.4) Caller
participant C as Client
end
box rgba(144,205,244,0.4) Gate
participant M as AvailabilityMiddleware
participant V as Token validator
end
box rgba(154,230,180,0.4) Downstream
participant N as Rest of pipeline
end
C->>M: Request with Authorization header
M->>M: check bypass prefixes
M->>V: validate token with shared parameters
alt token valid and role is Owner or Administrator
V-->>M: validated principal
M->>N: continue, gate bypassed
else token invalid, forged, or expired
V-->>M: validation failed
M->>M: evaluate master gate and local status
M-->>C: 503 if disabled, otherwise continue
end
```
Text alternative: the middleware validates the bearer token with the same parameters as the bearer scheme; only a genuinely valid Owner or Administrator token bypasses the gate, while a forged or expired token falls through to normal availability evaluation.
**Behaviour preserved**: an administrator with a valid token can always reach a disabled instance to switch it back on. **Behaviour removed**: an unauthenticated caller can no longer bypass the gate with a self-made token.
---
## 4. Health Reporting
`/health` reports **infrastructure liveness only**. It performs no database call and probes no dependency (D-21).
Per Q4 = C the response is JSON containing status, timestamp, application version and loaded module names.
```mermaid
graph TD
call["GET /health"]
bypasslist["On the availability bypass list<br/>so a disabled instance still answers"]
inproc["Read in-process state only<br/>no database, no dependency probe"]
compose["Compose report:<br/>status, timestamp, version, modules"]
ok["200 Healthy"]
dead["Process not running:<br/>no response at all"]
call --> bypasslist
bypasslist --> inproc
inproc --> compose
compose --> ok
call -.->|"if startup failed"| dead
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef step fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef bad fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
class call entry;
class bypasslist,inproc,compose step;
class ok good;
class dead bad;
```
Text alternative: the health endpoint answers from in-process state only and stays reachable while the instance is disabled; the failure signal is the absence of a response when the process did not start.
**Why the module list is included**: `ModuleOrchestrator` logs rather than throws when a module fails to load, so an instance can start successfully with reduced capability. Without this field there is no way to detect that after a deploy without host access — which NFR-06 explicitly requires.
**Where the "unhealthy" signal comes from**: not from this endpoint reporting failure, but from the process not answering at all. U2's fail-fast startup is what produces that signal. A liveness check whose process always starts would be worthless; combined with fail-fast migration it is meaningful.
---
## 5. Missing Website Directory
A fresh deployment has no `wwwroot/web/` — the customer's website is deployed separately. The CMS must still start and serve `/admin` and `/api/v1` (Q2 = B, Q3 = A).
```mermaid
graph TD
boot["Startup"]
check{"wwwroot/web exists ?"}
warn["Log a warning with the expected path"]
normal["Register mount normally"]
reg["Register mount tolerating absence"]
run["Application starts either way"]
visit["Visitor requests /"]
hasindex{"index.html present ?"}
site["Serve the website"]
ph["Serve built-in placeholder<br/>explaining no site is deployed<br/>and linking to /admin"]
boot --> check
check -->|no| warn
warn --> reg
check -->|yes| normal
reg --> run
normal --> run
run --> visit
visit --> hasindex
hasindex -->|yes| site
hasindex -->|no| ph
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef step fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef warnnode fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
class boot,visit entry;
class check,hasindex decision;
class normal,reg,run,site,ph step;
class warn warnnode;
```
Text alternative: a missing website directory logs a warning but never blocks startup; visitors then receive a built-in placeholder page instead of an error, and the admin SPA and API remain fully available.
**Why a warning rather than an informational line** (Q3 = A): on a fresh install the absence is normal, but on a running production instance it means the customer's website has vanished — the exact scenario the release design exists to prevent. A warning is visible in Sentry without blocking startup, so the normal case costs nothing while the dangerous case is not silent.
---
## 6. Trailing Slash for `/admin`
The admin SPA is built with `base: '/admin/'`. A request to `/admin` without the trailing slash resolves relative references one level too high, breaking asset loading in a way that looks like a deployment fault. Per Q5 = A, `/admin` redirects to `/admin/`.
The redirect applies **only** to the exact path `/admin`. Deeper paths such as `/admin/dashboard` are handled by the SPA fallback unchanged.
@@ -0,0 +1,134 @@
# Business Rules — U1 Hosting & Serving
---
## Rule Categories
```mermaid
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 |
@@ -0,0 +1,128 @@
# Domain Entities — U1 Hosting & Serving
**Note**: U1 introduces **no persisted entity**. It adds no table, no migration and no database column. Its "domain" consists of in-memory configuration descriptors and a response model. They are documented here because they are the data structures the unit's logic operates on, and because reviewers should be able to confirm that nothing is being persisted.
---
## Concept Relationships
```mermaid
graph TD
host["Host application"]
mountweb["StaticMount: website<br/>request path /"]
mountadmin["StaticMount: admin SPA<br/>request path /admin"]
provider["File provider<br/>per mount"]
fallbackweb["SPA fallback: website"]
fallbackadmin["SPA fallback: admin"]
placeholder["Placeholder page<br/>embedded resource"]
report["HealthReport<br/>response model"]
orchestrator["ModuleOrchestrator<br/>existing"]
bypass["Bypass prefix list<br/>existing, extended"]
tokenparams["Token validation parameters<br/>existing, now shared"]
host -->|"registers"| mountweb
host -->|"registers"| mountadmin
mountweb -->|"resolves files via"| provider
mountadmin -->|"resolves files via"| provider
mountweb -->|"falls back to"| fallbackweb
mountadmin -->|"falls back to"| fallbackadmin
fallbackweb -->|"substitutes when index absent"| placeholder
host -->|"exposes"| report
report -->|"reads module names from"| orchestrator
host -->|"configures"| bypass
host -->|"shares"| tokenparams
bypass -->|"used by availability gate"| tokenparams
classDef hostnode fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef mount fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef model fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
classDef existing fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
class host hostnode;
class mountweb,mountadmin,provider,fallbackweb,fallbackadmin,placeholder mount;
class report model;
class orchestrator,bypass,tokenparams existing;
```
Text alternative: the host registers two independent static mounts each with its own file provider and SPA fallback, plus a health response model that reads module names from the existing orchestrator; the bypass list and token validation parameters are existing structures this unit extends and shares.
---
## Static Mount Descriptor (configuration, in-memory)
Not a class to be persisted — this describes what each `UseStaticFiles` registration is configured with.
| Field | Website mount | Admin mount |
|---|---|---|
| Physical root | `{contentRoot}/wwwroot/web` | `{contentRoot}/wwwroot/admin` |
| Request path | `""` (root) | `/admin` |
| Default file | `index.html` | `index.html` |
| Directory browsing | Disabled | Disabled |
| Tolerates missing root | **Yes** — logs a warning | Yes — logs a warning |
| Registration order | Second | **First** |
**Registration order matters**: the admin mount must be registered first, or `/admin/...` would be resolved against the website root.
---
## HealthReport (response model, not persisted)
| Field | Type | Purpose |
|---|---|---|
| `status` | string | `"Healthy"`. A running process always reports healthy; absence of a response is the unhealthy signal |
| `timestamp` | timestamp with offset | When the report was produced, so a cached response is recognisable |
| `version` | string | The application's informational version, so a deploy can be confirmed without host access |
| `modules` | string array | Names of modules loaded by `ModuleOrchestrator` |
**Validation and constraints**:
- Every field is derived from in-process state. No field may require a database query, file read or network call (BR-U1-15).
- No field may contain configuration values, paths, connection details or environment variable contents (BR-U1-18).
- `modules` is read from the existing `ModuleOrchestrator.ModuleNames`, which is already exposed anonymously by `/api/v1/System/capabilities` — so this field introduces no new disclosure.
---
## Placeholder Page (embedded static content)
| Property | Value |
|---|---|
| Storage | Embedded resource in the assembly, not a file in `wwwroot` |
| Served when | The website fallback is needed and `wwwroot/web/index.html` is absent |
| Status code | `200` |
| Content | A statement that no website has been deployed yet, the expected target path, and a link to `/admin` |
**Why embedded rather than a file**: a file in `wwwroot/web/` would be inside the directory a website workspace owns and overwrites — it would be deleted by the first real website deployment, or worse, mistaken for part of the customer's site. Embedding keeps it outside that boundary entirely.
**What it must not contain**: no version, no environment name, no module list, no configuration. It is served anonymously to any visitor of the site root, which is a wider audience than `/health`.
---
## Extended Existing Structures
### Bypass prefix list (`AvailabilityMiddleware`)
| Aspect | Detail |
|---|---|
| Current contents | `/api/v1/Availability/status`, `/api/v1/Auth/`, `/api/v1/Setup/status`, `/api/v1/master/`, `/api/v1/SlaveStatus` |
| Added by U1 | `/health` |
| Matching | Case-insensitive prefix match, unchanged |
### Shared token validation parameters
| Aspect | Detail |
|---|---|
| Currently | Configured once inside `AddJwtBearer` in `ServiceCollectionExtensions.AddCoreInfrastructure` |
| Change | Extracted so the same instance is used by both the bearer scheme and the availability gate |
| Constraint | Exactly one definition. Two copies could drift, and a gate more permissive than the scheme would silently re-open the hole FR-24 closes |
---
## Persistence Summary
| Question | Answer |
|---|---|
| New tables? | None |
| New migrations? | None |
| New columns? | None |
| New configuration sections? | None — U1 adds no `appsettings` section |
| Anything written to disk at runtime? | No |
All persistence work in Round 1 belongs to **U2**, which adds the Data Protection keys table and the automatic Core migration.