Files
slp-modular-cms/aidlc-docs/features/gitea-deployment-workflow/inception/application-design/services.md
T
SluijsensandClaude Opus 5 8568ca43c6 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
2026-07-27 23:59:30 +02:00

10 KiB

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:

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.