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:
+275
@@ -0,0 +1,275 @@
|
||||
# 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`
|
||||
|
||||
```csharp
|
||||
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 calling `next`. 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`
|
||||
|
||||
```csharp
|
||||
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`
|
||||
|
||||
```csharp
|
||||
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; `Build` returns the cached value. Composing a CSP per request would be wasteful on a static-file-heavy workload.
|
||||
- Two policies are defined in code: `Strict` (baseline `default-src 'self'`) and `Relaxed` (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
|
||||
|
||||
```csharp
|
||||
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: `200` with `Healthy`, or `503` with `Unhealthy`.
|
||||
- The endpoint is anonymous and exposes no information beyond liveness.
|
||||
|
||||
---
|
||||
|
||||
## C-05 `CmsDataProtection` registration
|
||||
|
||||
```csharp
|
||||
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 by `AvailabilityModule` and `MasterModule`. 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
|
||||
|
||||
```csharp
|
||||
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
|
||||
|
||||
```csharp
|
||||
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 their `UseModule` implementations, and moving that would change existing behaviour outside this feature's scope.
|
||||
|
||||
---
|
||||
|
||||
## C-08 `CmsLogging` registration
|
||||
|
||||
```csharp
|
||||
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
|
||||
|
||||
```csharp
|
||||
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 `/admin` mount must be registered before the root mount, so `/admin/...` is not captured by the root provider.
|
||||
- The `nonfile` constraint is retained on both fallbacks — a missing asset must still `404` rather 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)
|
||||
|
||||
```csharp
|
||||
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 that `AvailabilityMiddleware` currently runs **before** `UseAuthentication()`. 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)
|
||||
|
||||
```typescript
|
||||
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**:
|
||||
- `ApiClient` composes 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) |
|
||||
Reference in New Issue
Block a user