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
12 KiB
Component Methods
Method signatures, purpose and input/output types. Detailed business rules are defined per unit in Functional Design (CONSTRUCTION phase) — this document establishes the interface contracts only.
Signatures are indicative C# and may be refined during Code Generation, but the shape of each contract is a design decision recorded here.
C-01 SecurityHeadersMiddleware
public sealed class SecurityHeadersMiddleware
{
public SecurityHeadersMiddleware(RequestDelegate next, IOptions<SecurityHeadersOptions> options, CspPolicyBuilder policyBuilder);
public Task InvokeAsync(HttpContext context);
}
| Member | Purpose | Input | Output |
|---|---|---|---|
InvokeAsync |
Register a response-start callback that applies the appropriate headers, then continue the pipeline | HttpContext |
Task |
Interface notes:
- Headers are applied through
HttpResponse.OnStarting, not before callingnext. The response content type is unknown until the response begins, and HTML-only headers (FU1 = A) cannot be decided without it. - The CSP policy for the request path is resolved once per request, before the callback, so path matching does not run at response-start time.
- Existing headers are never overwritten — a downstream component that deliberately set one wins.
C-02 SecurityHeadersOptions
public sealed class SecurityHeadersOptions
{
public List<PathPolicyRule> PathPolicies { get; set; } = new();
public string DefaultPolicy { get; set; } = "Relaxed";
public List<string> AllowedScriptOrigins { get; set; } = new();
public List<string> AllowedConnectOrigins { get; set; } = new();
public bool Enabled { get; set; } = true;
}
public sealed class PathPolicyRule
{
public string PathPrefix { get; set; } = string.Empty;
public string Policy { get; set; } = string.Empty;
}
| Member | Purpose |
|---|---|
PathPolicies |
Ordered path-prefix to policy-name assignment. Configuration, so paths can be added without code changes (Q6 = B) |
DefaultPolicy |
Policy applied when no prefix matches — Relaxed, covering the public website |
AllowedScriptOrigins |
Origins added to the CSP script-src directive — the Umami script host |
AllowedConnectOrigins |
Origins added to connect-src — the Sentry ingest host |
Enabled |
Escape hatch for local development or diagnosis |
Design rule (FU2 = A): policy definitions are in code; only assignment and environment-specific origins are configuration. A misconfiguration can therefore misroute a path but cannot invent a broken policy.
C-03 CspPolicyBuilder
public sealed class CspPolicyBuilder
{
public CspPolicyBuilder(IOptions<SecurityHeadersOptions> options);
public string Build(string policyName);
public string ResolvePolicyName(PathString path);
}
| Method | Purpose | Input | Output |
|---|---|---|---|
Build |
Compose the full CSP header value for a named policy, injecting configured origins | string policyName |
string — the header value |
ResolvePolicyName |
Determine which policy applies to a request path by prefix match, falling back to DefaultPolicy |
PathString |
string — policy name |
Interface notes:
- Policy strings are built once at startup and cached by name;
Buildreturns the cached value. Composing a CSP per request would be wasteful on a static-file-heavy workload. - Two policies are defined in code:
Strict(baselinedefault-src 'self') andRelaxed(permissive enough that a website author who never saw this repository is not broken by it — D-31). - An unknown policy name is a configuration error and must fail at startup, not silently fall back.
C-04 Health-check registration
public static IServiceCollection AddCmsHealthChecks(this IServiceCollection services);
public static IEndpointRouteBuilder MapCmsHealthChecks(this IEndpointRouteBuilder endpoints);
| Method | Purpose | Input | Output |
|---|---|---|---|
AddCmsHealthChecks |
Register framework health-check services | IServiceCollection |
same, for chaining |
MapCmsHealthChecks |
Map GET /health |
IEndpointRouteBuilder |
same, for chaining |
Interface notes:
- No database check and no dependency probes (D-21). The registration takes no options precisely so that "just add one more check" is a visible code change rather than a configuration drift.
- Response is the framework default:
200withHealthy, or503withUnhealthy. - The endpoint is anonymous and exposes no information beyond liveness.
C-05 CmsDataProtection registration
public static IServiceCollection AddCmsDataProtection(this IServiceCollection services, IConfiguration configuration);
| Method | Purpose | Input | Output |
|---|---|---|---|
AddCmsDataProtection |
Configure Data Protection to persist keys in ApplicationDbContext with a stable application discriminator |
IServiceCollection, IConfiguration |
same, for chaining |
Interface notes:
- Replaces the bare
services.AddDataProtection()calls currently made independently byAvailabilityModuleandMasterModule. Those must be removed, or a later registration could silently override the persistent store. - The application discriminator must be stable and explicit. By default it derives from the content root path, which changes with every atomic release-directory switch (FR-06) — which would defeat the entire purpose of FR-12.
C-06 ApplicationDbContext extension
public class ApplicationDbContext : IdentityDbContext<...>, IDataProtectionKeyContext
{
public DbSet<DataProtectionKey> DataProtectionKeys { get; set; }
}
| Member | Purpose |
|---|---|
DataProtectionKeys |
Backing store for the Data Protection key ring, required by IDataProtectionKeyContext |
Requires one new Core migration, applied automatically by C-07.
C-07 Startup migration runner
public static WebApplication MigrateCoreDatabase(this WebApplication app);
| Method | Purpose | Input | Output |
|---|---|---|---|
MigrateCoreDatabase |
Apply pending ApplicationDbContext migrations before the app serves traffic |
WebApplication |
same, for chaining |
Interface notes:
- Exceptions propagate (Q8 = A). No try/catch, no logged-and-continue. A host that cannot reach or migrate its database must not start.
- Called before
app.Run()and before any request is accepted, so no request ever sees a partially migrated schema. - Deliberately covers only
ApplicationDbContext; the two module contexts already migrate themselves in theirUseModuleimplementations, and moving that would change existing behaviour outside this feature's scope.
C-08 CmsLogging registration
public static IHostApplicationBuilder AddCmsLogging(this IHostApplicationBuilder builder);
| Method | Purpose | Input | Output |
|---|---|---|---|
AddCmsLogging |
Configure structured console logging with a correlation identifier on every entry | IHostApplicationBuilder |
same, for chaining |
Interface notes:
- Independent of Sentry (Q10 = B) — structured logging must work with no DSN configured.
- The correlation-ID mechanism is not fixed here; OPEN-01 is decided in NFR Design for Unit 4.
- Must not log secrets, tokens or PII (SECURITY-03).
C-09 CmsSentry registration
public static IHostApplicationBuilder AddCmsSentry(this IHostApplicationBuilder builder);
| Method | Purpose | Input | Output |
|---|---|---|---|
AddCmsSentry |
Initialise Sentry when a DSN is configured; do nothing when it is not | IHostApplicationBuilder |
same, for chaining |
Interface notes:
- Absent DSN is a normal, supported state, not an error — local development and any deployment without Sentry must run unchanged with console logging only (FR-14).
- Tags events with environment and release.
- Security-relevant events for alerting (FR-19) are emitted by application code; what qualifies as alertable is defined in NFR Design for Unit 4.
C-10 Static-file mount composition (SlpModularCms.Api only)
Composed inline in Program.cs rather than behind an abstraction, since it is host-specific and there is exactly one host that needs it.
| Registration | Purpose |
|---|---|
UseDefaultFiles + UseStaticFiles with PhysicalFileProvider(wwwroot/web) at / |
Serve the customer's public website |
UseDefaultFiles + UseStaticFiles with PhysicalFileProvider(wwwroot/admin), RequestPath = "/admin" |
Serve the admin SPA |
MapFallbackToFile("/admin/{*path:nonfile}", …) |
Admin SPA client-side routes |
MapFallbackToFile("{*path:nonfile}", …) |
Public website client-side routes |
Interface notes:
- Order matters: the
/adminmount must be registered before the root mount, so/admin/...is not captured by the root provider. - The
nonfileconstraint is retained on both fallbacks — a missing asset must still404rather than receive HTML (existing behaviour worth preserving deliberately). - Directory browsing stays disabled (SECURITY-09).
- Both providers must tolerate a missing directory at startup: a fresh deployment has no
wwwroot/web/until a website workspace deploys into it, and the CMS must still start.
C-13 AvailabilityMiddleware (modified)
private static readonly string[] _bypassPrefixes = [ /* existing */, "/health" ];
private bool IsAdminBypass(HttpContext context);
| Member | Change | Purpose |
|---|---|---|
_bypassPrefixes |
Add /health |
The availability gate must never mask infrastructure liveness (FR-10, D-22) |
IsAdminBypass |
Stop using ReadJwtToken; rely on a validated principal |
Close the forged-token bypass (FR-24, SECURITY-08) |
Interface notes:
- Signature is unchanged; only the implementation and the constant change.
- Preserved behaviour: an Owner or Administrator with a valid token still bypasses the gate, so administrators can always reach a disabled instance.
- If the implementation moves to reading
HttpContext.User, note thatAvailabilityMiddlewarecurrently runs beforeUseAuthentication(). Either authentication must run earlier, or the middleware must validate the token itself with the same parameters as the bearer scheme. This ordering constraint is the substance of the fix and is resolved in Functional Design for Unit 2.
C-14 Frontend configuration (modified)
export function getAppConfig(): AppConfig;
export interface AppConfig {
apiBaseUrl: string; // '' means same-origin
appTitle: string;
}
| Change | Purpose |
|---|---|
apiBaseUrl accepts empty string |
Same-origin default when VITE_API_BASE_URL is unset (FR-13) |
| Zod schema relaxed | Accept either an empty string or a valid absolute URL — not any string, so a malformed value is still caught |
Interface notes:
ApiClientcomposes request URLs as${baseUrl}${path}, so an empty base yields a root-relative URL — same-origin without further change.- Local development against
https://localhost:7221(master) or:7222(slave) must keep working exactly as today.
C-15 Frontend observability (new)
| Element | Purpose |
|---|---|
Sentry initialisation in main.tsx |
Error and performance reporting; skipped when VITE_SENTRY_DSN is absent |
| Umami script component | Analytics; renders nothing when the website ID is absent or in local development |
Interface notes: both read build-time Vite variables, which is why two separate builds are produced (D-15).
C-11 / C-12 Workflow interfaces
deploy-scp.yaml (and later deploy-ftps.yaml) — workflow_call inputs
| Input | Type | Purpose |
|---|---|---|
artifact_name |
string | Build artifact to download |
environment |
string | test or production — used for naming and tagging |
deploy_path |
string | Target base path on the host |
release_retention |
number | How many previous releases to retain for fast rollback (FR-06, D-26) |
Secrets are inherited. The input interface is identical across transports (Q11 = B), so a caller can switch workflow file without changing arguments.
continuous_integration.yaml — workflow_dispatch inputs
| Input | Type | Default | Purpose |
|---|---|---|---|
deploy_production |
boolean | false |
The only route to production (FR-04) |