Plans the Gitea deployment feature and refreshes the codebase analysis

Adds the AI-DLC inception record for deploying the CMS as a single .NET
application on hosting where no server configuration is possible.

The reverse-engineering artifacts were regenerated: the previous set
predated the Master module, the Slave host, the solution reorganisation
and single-host serving, all of which matter for deployment. Findings
were verified by running the build, both test suites and the linter
rather than inferred, which surfaced two facts the plan depends on:
the frontend lint gate currently fails (5 errors), and two transitive
packages carry high-severity advisories.

Records 24 functional requirements, 32 traced decisions and a
seven-unit decomposition whose ordering is load-bearing: durability
work must land before the first automated deploy, or the very first
deploy is the one that silently breaks master/slave trust.

Two conflicts found while designing and carried into the units:
- Both modules call AddDataProtection(), which runs after the host and
  would override a persistent key store while still passing any
  registration test.
- The availability gate runs before authentication, so its admin
  bypass cannot read HttpContext.User.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
This commit is contained in:
2026-07-27 23:59:30 +02:00
co-authored by Claude Opus 5
parent 38857038a0
commit 8568ca43c6
25 changed files with 4233 additions and 503 deletions
@@ -0,0 +1,170 @@
# Services and Orchestration
Service definitions, responsibilities and orchestration patterns.
This feature introduces few *stateful* services. Its service layer is mostly **composition orchestration**: the order in which registrations and middleware are applied, which for a single process serving three surfaces is where the real design lives. A correct set of components in the wrong order produces security headers that never reach the public website, or a health endpoint that a disabled instance hides.
---
## S-01 Host composition service (`Program.cs`, both hosts)
**Responsibility**: compose configuration, services and the request pipeline in an order that satisfies all 24 functional requirements simultaneously.
**Orchestration pattern**: explicit sequential composition. Per Q9 = A the two hosts keep their own `Program.cs`; with implementations in `Core` (Q1 = A), what is duplicated is a readable list of calls, not logic.
### Service registration order
| # | Registration | Notes |
|---|---|---|
| 1 | `AddJsonFile("appsettings.local.json", optional: true)` | Existing |
| 2 | `AddCmsLogging()` | **Early** — so subsequent startup work is already logged structurally |
| 3 | `AddCmsSentry()` | Separate from logging (Q10 = B); no-op without a DSN |
| 4 | `ModuleOrchestrator.DiscoverModules()` | Existing |
| 5 | `AddCoreInfrastructure(configuration)` | Existing — DbContext, Identity, JWT, policies, exception handling |
| 6 | `AddCmsDataProtection(configuration)` | **Before module registration** — see the conflict note below |
| 7 | `AddCmsCors` / `AddCmsRateLimiting` | Existing |
| 8 | `AddCmsHealthChecks()` | New |
| 9 | `AddCmsSecurityHeaders(configuration)` | New — binds options, registers `CspPolicyBuilder` |
| 10 | `orchestrator.RegisterModuleServices(services)` | Existing |
| 11 | `AddControllers(...)` with `ApiPrefixConvention` | Existing |
> **Registration conflict that must be resolved (step 6 versus step 10)**
> `AvailabilityModule.RegisterServices` and `MasterModule.RegisterServices` each call `services.AddDataProtection()` today. Whichever runs last wins the configuration. If those calls remain, module registration at step 10 would silently discard the persistent key store configured at step 6 — and FR-12 would appear implemented while doing nothing.
> **Resolution**: remove `AddDataProtection()` from both modules; the host configures Data Protection once. Assigned to Unit 2 and verified in that unit's tests.
### Middleware pipeline order
| # | Middleware | Why here |
|---|---|---|
| 1 | `UseExceptionHandler()` | Existing — must be outermost to catch everything |
| 2 | `UseCmsSecurityHeaders()` | **New.** Before static files, because static files short-circuit the pipeline — anything registered after them never reaches the public website. Placed after the exception handler so error responses also carry headers. Applies per-header scoping at response start (FU1 = A) |
| 3 | `UseRateLimiter()` | Existing |
| 4 | Dev-only: `MapOpenApi()`, `MapScalarApiReference()` | Existing — Development only (SECURITY-09) |
| 5 | `UseHttpsRedirection()` | Existing |
| 6 | Static files — `/admin` mount, then `/` mount | New arrangement (C-10). `Api` host only. `/admin` first so it is not captured by the root provider |
| 7 | `UseCors()` | Existing |
| 8 | `orchestrator.UseModules(app)` → installs `AvailabilityMiddleware` | Existing position. Consequence, deliberately unchanged: the public website is served *before* the availability gate, so a disabled instance still serves the website while blocking `/api/v1` and `/admin` |
| 9 | `UseAuthentication()` / `UseAuthorization()` | Existing — but see the FR-24 ordering constraint below |
| 10 | `MapControllers()` | Existing |
| 11 | `MapCmsHealthChecks()` | New. An endpoint, therefore after the gate — which is exactly why `/health` must be on the bypass list (D-22) |
| 12 | Two `MapFallbackToFile` registrations | Existing pattern, retargeted to the two mounts |
> **Ordering constraint for FR-24**
> `AvailabilityMiddleware` (step 8) runs **before** `UseAuthentication()` (step 9), so `HttpContext.User` is not yet populated when the admin bypass is evaluated. Two options, decided in Functional Design for Unit 2:
> **(a)** validate the token inside the middleware using the same `TokenValidationParameters` as the bearer scheme, or
> **(b)** move `UseAuthentication()` before the module middleware.
> Option (b) is a smaller change but alters the pipeline for every module, including any future one — a wider blast radius than this feature should take on. Option (a) is contained but duplicates validation parameters, which must then be shared rather than copied.
---
## S-02 Security-header application service
**Responsibility**: decide and apply the correct headers for each response.
**Orchestration**:
1. On request: resolve the policy name for the path via `CspPolicyBuilder.ResolvePolicyName` (prefix match, `DefaultPolicy` fallback)
2. Register an `OnStarting` callback carrying that policy name
3. At response start, inspect `Content-Type` and apply:
- **Always**: `X-Content-Type-Options`, `Strict-Transport-Security`
- **HTML responses only**: `Content-Security-Policy`, `X-Frame-Options`, `Referrer-Policy`
4. Skip any header already present
**Why response-start rather than pre-`next`**: the content type is unknown until the response begins, and per-header scoping (FU1 = A) depends on it. Setting headers before calling `next` would force an all-or-nothing choice.
**Failure behaviour**: header application never throws into the response path. A misconfigured policy is a **startup** failure (unknown policy name), not a per-request one.
---
## S-03 Startup migration orchestration
**Responsibility**: bring the database schema to the required version before serving traffic.
**Orchestration**:
1. After `builder.Build()`, before `app.Run()`
2. `MigrateCoreDatabase()` applies `ApplicationDbContext` migrations — **fail fast** (Q8 = A)
3. `orchestrator.UseModules(app)` triggers the two module contexts' existing `Database.Migrate()` calls
**Sequencing note**: Core migrates before the modules. All three contexts share one connection string and one database, and the Data Protection keys table lives in `ApplicationDbContext` (Q7 = A) — so the keys table must exist before any module resolves an `IDataProtector`.
**Failure behaviour**: propagate. The process does not start, `/health` does not answer, UptimeRobot goes red. This is the intended chain and the reason a liveness-only check is sufficient.
---
## S-04 Observability orchestration
**Responsibility**: make errors and usage visible without host access.
**Orchestration**:
- `AddCmsLogging()` first, so Sentry initialisation problems are themselves logged
- `AddCmsSentry()` second, reading the DSN from configuration; **absent DSN is a supported state**, not an error
- Both registered before any other service, so startup failures — including a fail-fast migration — are captured
**Degradation model**: three levels, each fully functional.
| Configuration | Behaviour |
|---|---|
| No DSN | Structured console logging only |
| DSN present | Console plus Sentry, environment-tagged |
| DSN present, Sentry unreachable | Sentry's own buffering and drop behaviour; the application is never blocked |
---
## S-05 Deployment orchestration (`.gitea/workflows/`)
**Responsibility**: turn a commit into a running release without endangering data the deployment does not own.
**Orchestration**:
```mermaid
graph TD
trigger["Trigger:<br/>PR, push to master,<br/>or workflow_dispatch"]
gates["Quality gates<br/>build, test, vulnerability scan,<br/>frontend build, test, lint"]
buildtest["Build artifact: test<br/>env-specific Vite vars"]
buildprod["Build artifact: production<br/>env-specific Vite vars"]
deploytest["deploy-scp: test<br/>auto on master"]
deployprod["deploy-scp: production<br/>only with deploy_production"]
done(["Running release"])
trigger --> gates
gates --> buildtest
gates --> buildprod
buildtest --> deploytest
buildprod --> deployprod
deploytest --> done
deployprod --> done
classDef trig fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef gate fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef build fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef deploy fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
classDef fin fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
class trigger trig;
class gates gate;
class buildtest,buildprod build;
class deploytest,deployprod deploy;
class done fin;
```
Text alternative: every trigger runs the quality gates; a test build always follows and deploys automatically on master, while a production build and deploy run only when the `deploy_production` input is set.
**Per-deployment sequence** (inside `deploy-scp.yaml`):
1. Download the artifact
2. **Production only**: take a database backup (FR-20) — before anything is changed
3. Upload into a **new** release directory
4. Link the persistent `wwwroot/web/` into the new release (ASM-01) — the step that keeps the customer's website alive across the switch
5. Switch the active release atomically
6. Restart the process
7. Verify `/health` responds
8. Prune old releases beyond the retention count, keeping at least the previous one (D-26)
**Rollback**: switch back to the retained previous release directory and restart — no rebuild needed. Forward-compatible, non-destructive migrations are what make this safe (FR-11, D-26).
---
## S-06 Transport selection
**Responsibility**: move files to a host over whatever protocol that host offers.
**Orchestration (Q11 = B)**: one reusable workflow per transport, all sharing an identical `workflow_call` input interface. `deploy-scp.yaml` exists now; `deploy-ftps.yaml` is added when production moves to shared hosting (D-02, OPEN-04). The caller changes only the `uses:` line — satisfying NFR-09 without building an abstraction for a transport that does not yet exist.
**Why not one workflow with a `transport` input**: the two transports differ in more than a command — atomic directory switching and process restart are natural over SSH but not available over FTPS, where `app_offline.htm` becomes the mechanism instead. Separate files keep each honest about what it can actually guarantee, rather than hiding a materially different deployment model behind a shared conditional.