Files
slp-modular-cms/aidlc-docs/features/gitea-deployment-workflow/construction/u1-hosting-serving/functional-design/business-logic-model.md
T
SluijsensandClaude Opus 5 29a93ef873 Separates website and admin roots, adds /health, hardens the availability gate
Prepares the single-host layout for deployment. The customer's public
website moves from wwwroot/ to wwwroot/web/, so a CMS deploy can no
longer overwrite content it does not own: with the website in its own
directory, the release directory can be swapped without touching it.

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

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

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

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

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

9.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:

  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

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.