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:
+213
@@ -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.
|
||||
+134
@@ -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 |
|
||||
+128
@@ -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.
|
||||
Reference in New Issue
Block a user