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.3 KiB
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:
- Which static mount, if any, owns this path
- Whether the availability gate applies
- Which fallback resolves a client-side route
2. Request Resolution Flow
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.
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.
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).
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.