# 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:
PR, push to master,
or workflow_dispatch"]
gates["Quality gates
build, test, vulnerability scan,
frontend build, test, lint"]
buildtest["Build artifact: test
env-specific Vite vars"]
buildprod["Build artifact: production
env-specific Vite vars"]
deploytest["deploy-scp: test
auto on master"]
deployprod["deploy-scp: production
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.