# 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.