Adds 2 units and docs for unit 3. nfr-requirements plan

This commit is contained in:
2026-06-29 22:18:37 +02:00
parent 0e01ca1e1c
commit c156107cb1
126 changed files with 15204 additions and 80199 deletions
@@ -0,0 +1,302 @@
# Logical Components — Unit 2: slave-availability-extension
## Component Overview
```mermaid
graph TD
subgraph Controllers
MC["MasterController\n(new)"]
end
subgraph Services
IMAS["IMasterAvailabilityService"]
MAS["MasterAvailabilityService\n(volatile static cache)"]
IMAKP["IMasterApiKeyProtector"]
MAKP["MasterApiKeyProtector"]
MASD["MasterAvailabilityServiceDependencies\n(record)"]
end
subgraph Repositories
IMRR["IMasterRegistrationRepository"]
MRR["MasterRegistrationRepository"]
end
subgraph Data
AVDBCTX["AvailabilityDbContext\n(new)"]
MR["MasterRegistration\n(entity)"]
end
subgraph Middleware
AVMW["AvailabilityMiddleware\n(extended)"]
end
subgraph External
DP["IDataProtectionProvider\n(ASP.NET Core)"]
end
MC -->|delegates| IMAS
MAS -.->|implements| IMAS
MAS -->|uses| MASD
MASD -->|contains| IMRR
MASD -->|contains| IMAKP
MRR -.->|implements| IMRR
MAKP -.->|implements| IMAKP
MRR -->|reads/writes| AVDBCTX
AVDBCTX -->|owns| MR
MAKP -->|wraps| DP
AVMW -->|InvokeAsync param| IMAS
classDef interface fill:#fff,stroke:#63b3ed,stroke-width:2px,color:#2b6cb0
classDef impl fill:#63b3ed,stroke:#2b6cb0,color:#fff
classDef entity fill:#FFC107,stroke:#F57F17,color:#000
classDef infra fill:#9ae6b4,stroke:#2f855a,color:#000
classDef middleware fill:#CE93D8,stroke:#6A1B9A,color:#000
classDef external fill:#eee,stroke:#999,color:#333
class IMAS,IMRR,IMAKP interface
class MAS,MRR,MAKP,MASD impl
class MR,AVDBCTX entity
class MC infra
class AVMW middleware
class DP external
```
Text alternative: `MasterController` delegates to `IMasterAvailabilityService`. `MasterAvailabilityService` uses `MasterAvailabilityServiceDependencies` which holds `IMasterRegistrationRepository` and `IMasterApiKeyProtector`. Repository uses `AvailabilityDbContext`. `MasterApiKeyProtector` wraps `IDataProtectionProvider`. `AvailabilityMiddleware` receives `IMasterAvailabilityService` as third `InvokeAsync` parameter.
---
## Component Specifications
### 1. `MasterRegistration` (Entity)
**Namespace**: `SlpModularCms.Modules.Availability.Data.Entities`
| Property | Type | Notes |
|----------|------|-------|
| `Id` | `Guid` | PK; always `new Guid("00000000-0000-0000-0000-000000000001")` |
| `MasterUrl` | `string` | Required; max 500 |
| `ApiKey` | `string` | Required; max 2000 (encrypted via Data Protection) |
| `RegisteredAt` | `DateTimeOffset` | Set once on creation |
| `LastContactedAt` | `DateTimeOffset?` | Updated on every valid master call |
---
### 2. `AvailabilityDbContext`
**Namespace**: `SlpModularCms.Modules.Availability.Data`
```csharp
public class AvailabilityDbContext : DbContext
{
public DbSet<MasterRegistration> MasterRegistrations => Set<MasterRegistration>();
}
```
| Aspect | Decision |
|--------|----------|
| Migration assembly | `SlpModularCms.Modules.Availability` |
| Table | `AvailabilityMasterRegistrations` |
| Applied at | `AvailabilityModule.UseModule``MigrateAsync()` |
| Connection string | `ConnectionStrings:DefaultConnection` (same as other DbContexts) |
---
### 3. `IMasterRegistrationRepository` / `MasterRegistrationRepository`
**Namespace**: `SlpModularCms.Modules.Availability.Repositories`
```csharp
public interface IMasterRegistrationRepository
{
Task<MasterRegistration?> GetAsync();
Task UpsertAsync(MasterRegistration registration);
Task SaveChangesAsync();
}
```
| Method | Notes |
|--------|-------|
| `GetAsync()` | Loads singleton by fixed Id; returns `null` if row does not exist |
| `UpsertAsync(registration)` | `Add` if not tracked; `Update` if tracked or found by Id |
| `SaveChangesAsync()` | Explicit save; service controls transaction boundary |
**Registration**: `services.AddScoped<IMasterRegistrationRepository, MasterRegistrationRepository>()`
---
### 4. `IMasterApiKeyProtector` / `MasterApiKeyProtector`
**Namespace**: `SlpModularCms.Modules.Availability.Services`
```csharp
public interface IMasterApiKeyProtector
{
string Protect(string plainApiKey);
string? Unprotect(string encryptedApiKey); // null on CryptographicException
}
```
**Registration**: `services.AddSingleton<IMasterApiKeyProtector, MasterApiKeyProtector>()`
---
### 5. `MasterAvailabilityServiceDependencies` (Record)
**Namespace**: `SlpModularCms.Modules.Availability.Services`
```csharp
[ExcludeFromCodeCoverage]
public record MasterAvailabilityServiceDependencies(
IMasterRegistrationRepository Repository,
IMasterApiKeyProtector KeyProtector,
ILogger<MasterAvailabilityService> Logger
);
```
**Registration**: `services.AddScoped<MasterAvailabilityServiceDependencies>()`
---
### 6. `IMasterAvailabilityService` / `MasterAvailabilityService`
**Namespace**: `SlpModularCms.Modules.Availability.Services`
```csharp
public interface IMasterAvailabilityService
{
Task RegisterAsync(string masterUrl, string apiKey);
Task PushStatusAsync(string apiKey, bool isAvailable, string? disableMessage);
Task<string?> GetRegisteredUrlAsync(string apiKey);
MasterGateStatus GetMasterStatus();
}
```
**Static fields** (in implementation):
```csharp
private static volatile bool _masterIsAvailable = true;
private static volatile string? _masterDisableMessage = null;
```
**Key validation helper** (private, reused across all 3 write-path methods):
```csharp
private async Task<MasterRegistration?> ValidateApiKeyAsync(string apiKey)
{
var registration = await _deps.Repository.GetAsync();
if (registration is null) return null;
var stored = _deps.KeyProtector.Unprotect(registration.ApiKey);
return stored == apiKey ? registration : null;
}
```
**Registration**: `services.AddScoped<IMasterAvailabilityService, MasterAvailabilityService>()`
---
### 7. `MasterGateStatus` (Record)
**Namespace**: `SlpModularCms.Modules.Availability.Services`
```csharp
[ExcludeFromCodeCoverage]
public record MasterGateStatus(bool IsAvailable, string? DisableMessage);
```
---
### 8. `MasterController`
**Namespace**: `SlpModularCms.Modules.Availability.Controllers`
```csharp
[ApiController]
[Route("[controller]")] // → /api/v1/master via ApiPrefixConvention
public class MasterController : ControllerBase
{
[HttpPost("register")] // POST /api/v1/master/register
[HttpPost("status")] // POST /api/v1/master/status
[HttpGet("registered-url")] // GET /api/v1/master/registered-url
}
```
**Constructor**: `MasterController(IMasterAvailabilityService svc)` — single dependency, no record wrapper needed.
**Auth**: No `[Authorize]` attribute — API key validated in `MasterAvailabilityService`.
**Response on 401**: `Unauthorized()` — no body to avoid leaking registration state.
---
### 9. `AvailabilityMiddleware` (Extended)
**Extended fields** (added to existing class):
```csharp
// Updated bypass prefix list
private static readonly string[] _bypassPrefixes =
[
"/api/v1/Availability/status",
"/api/v1/Auth/",
"/api/v1/Setup/status",
"/api/v1/master/" // NEW
];
```
**Updated `InvokeAsync` signature**:
```csharp
public async Task InvokeAsync(
HttpContext context,
IAvailabilityService localSvc,
IMasterAvailabilityService masterSvc)
```
---
## Dependency Registration Summary
All new registrations added to `AvailabilityModule.RegisterServices`:
```csharp
// Data
services.AddDbContext<AvailabilityDbContext>((sp, options) =>
options.UseSqlServer(sp.GetRequiredService<IConfiguration>()
.GetConnectionString("DefaultConnection")));
// Security
services.AddDataProtection();
services.AddSingleton<IMasterApiKeyProtector, MasterApiKeyProtector>();
// Repositories
services.AddScoped<IMasterRegistrationRepository, MasterRegistrationRepository>();
// Services
services.AddScoped<MasterAvailabilityServiceDependencies>();
services.AddScoped<IMasterAvailabilityService, MasterAvailabilityService>();
```
And in `AvailabilityModule.UseModule`:
```csharp
using var scope = app.ApplicationServices.CreateScope();
await scope.ServiceProvider
.GetRequiredService<AvailabilityDbContext>()
.Database.MigrateAsync();
```
---
## New Files Summary
| File | Project | Type |
|------|---------|------|
| `Data/Entities/MasterRegistration.cs` | Availability | Entity |
| `Data/AvailabilityDbContext.cs` | Availability | DbContext |
| `Repositories/IMasterRegistrationRepository.cs` | Availability | Interface |
| `Repositories/MasterRegistrationRepository.cs` | Availability | Implementation |
| `Services/IMasterApiKeyProtector.cs` | Availability | Interface |
| `Services/MasterApiKeyProtector.cs` | Availability | Implementation |
| `Services/MasterGateStatus.cs` | Availability | Record |
| `Services/MasterAvailabilityServiceDependencies.cs` | Availability | Record |
| `Services/IMasterAvailabilityService.cs` | Availability | Interface |
| `Services/MasterAvailabilityService.cs` | Availability | Implementation |
| `Controllers/MasterController.cs` | Availability | Controller |
| `Middleware/AvailabilityMiddleware.cs` | Availability | Modified (extended) |
| `AvailabilityModule.cs` | Availability | Modified (registration + migration) |
| `Data/Migrations/*` | Availability | EF Core auto-generated |
@@ -0,0 +1,232 @@
# NFR Design Patterns — Unit 2: slave-availability-extension
## Pattern 1 — Security: `IMasterApiKeyProtector` Wrapper
**NFR**: ApiKey encrypted at rest (Q1=C NFR Requirements); decrypted only for comparison; never logged
**Pattern**: Thin wrapper interface over ASP.NET Core Data Protection. Mirrors Unit 1's `IApiKeyProtector` pattern but with a distinct purpose string to prevent cross-module decryption.
**Interface**:
```csharp
public interface IMasterApiKeyProtector
{
string Protect(string plainApiKey);
string? Unprotect(string encryptedApiKey); // null on CryptographicException
}
```
**Implementation**:
```csharp
public class MasterApiKeyProtector : IMasterApiKeyProtector
{
private readonly IDataProtector _protector;
public MasterApiKeyProtector(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("SlpModularCms.Availability.MasterApiKey");
}
public string Protect(string plainApiKey) => _protector.Protect(plainApiKey);
public string? Unprotect(string encryptedApiKey)
{
try { return _protector.Unprotect(encryptedApiKey); }
catch (CryptographicException) { return null; }
}
}
```
**Registration** (in `AvailabilityModule.RegisterServices`):
```csharp
services.AddDataProtection();
services.AddSingleton<IMasterApiKeyProtector, MasterApiKeyProtector>();
```
**Usage in tests**:
```csharp
var protector = Substitute.For<IMasterApiKeyProtector>();
protector.Protect(Arg.Any<string>()).Returns(s => $"enc:{s.ArgAt<string>(0)}");
protector.Unprotect(Arg.Any<string>()).Returns(s => s.ArgAt<string>(0).Replace("enc:", ""));
```
---
## Pattern 2 — Performance: `volatile` Static Cache + Sync `GetMasterStatus()`
**NFR**: Master gate check is synchronous; zero DB call per request (PERF-01/02)
**Pattern**: Volatile static fields in `MasterAvailabilityService` updated only on status push. Exposed via a synchronous interface method so `AvailabilityMiddleware` can mock it in tests without accessing static state directly.
**Static fields**:
```csharp
private static volatile bool _masterIsAvailable = true;
private static volatile string? _masterDisableMessage = null;
```
**Return type**:
```csharp
public record MasterGateStatus(bool IsAvailable, string? DisableMessage);
```
**Interface method** (sync — no `Task`):
```csharp
public interface IMasterAvailabilityService
{
Task RegisterAsync(string masterUrl, string apiKey);
Task PushStatusAsync(string apiKey, bool isAvailable, string? disableMessage);
Task<string?> GetRegisteredUrlAsync(string apiKey);
MasterGateStatus GetMasterStatus(); // sync; reads volatile fields
}
```
**Implementation**:
```csharp
public MasterGateStatus GetMasterStatus()
=> new(_masterIsAvailable, _masterDisableMessage);
```
**Write point** (only in `PushStatusAsync`):
```csharp
_masterIsAvailable = isAvailable;
_masterDisableMessage = disableMessage;
```
**Why `volatile`**: `bool` and `string` reference assignments are already atomic in .NET. `volatile` adds the memory barrier to ensure other threads see the updated value without a `lock`. Consistent with `PersistentAvailabilityService`'s `_lastErrorTime` pattern.
---
## Pattern 3 — Constructor Aggregation: `MasterAvailabilityServiceDependencies`
**NFR**: Consistent with Unit 1's `MasterServiceDependencies` pattern (Q2=B)
**Record definition**:
```csharp
[ExcludeFromCodeCoverage]
public record MasterAvailabilityServiceDependencies(
IMasterRegistrationRepository Repository,
IMasterApiKeyProtector KeyProtector,
ILogger<MasterAvailabilityService> Logger
);
```
**Registration**:
```csharp
services.AddScoped<MasterAvailabilityServiceDependencies>();
services.AddScoped<IMasterAvailabilityService, MasterAvailabilityService>();
```
**`MasterAvailabilityService` constructor**:
```csharp
public MasterAvailabilityService(MasterAvailabilityServiceDependencies deps)
{
_deps = deps;
}
```
**Test construction** (no DI container):
```csharp
var deps = new MasterAvailabilityServiceDependencies(
Substitute.For<IMasterRegistrationRepository>(),
Substitute.For<IMasterApiKeyProtector>(),
NullLogger<MasterAvailabilityService>.Instance
);
var svc = new MasterAvailabilityService(deps);
```
---
## Pattern 4 — Middleware Extension: `InvokeAsync` Third Parameter (Q1=A)
**NFR**: Master gate is outer gate; evaluated before local gate; admin bypass applies to both
**Pattern**: `AvailabilityMiddleware.InvokeAsync` receives `IMasterAvailabilityService` as a third DI-resolved parameter. ASP.NET Core middleware supports per-request parameter injection on `InvokeAsync`.
**Extended `InvokeAsync` signature**:
```csharp
public async Task InvokeAsync(
HttpContext context,
IAvailabilityService localSvc,
IMasterAvailabilityService masterSvc)
```
**Evaluation order**:
```mermaid
graph TD
A[Request] --> B{Bypass path?}
B -->|Yes| Z[Pass through]
B -->|No| C{Admin JWT?}
C -->|Yes| Z
C -->|No| D{masterSvc.GetMasterStatus\n.IsAvailable?}
D -->|true| E{localSvc\n.IsAvailableAsync?}
D -->|false| F[503 with master\nDisableMessage]
E -->|Available| Z
E -->|Unavailable| G[503 with local\ndisable message]
classDef decision fill:#FFC107,stroke:#F57F17,color:#000
classDef pass fill:#9ae6b4,stroke:#2f855a,color:#000
classDef fail fill:#FC8181,stroke:#C53030,color:#000
class B,C,D,E decision
class Z pass
class F,G fail
```
Text alternative: Request hits bypass path check, then admin JWT check. If both fail, master gate evaluates (sync). If master blocks: 503 with master message. If master passes: local gate evaluates (async). If local blocks: 503 with local message. Otherwise pass through.
**Extended bypass prefix** (added to `_bypassPrefixes` array):
```csharp
private static readonly string[] _bypassPrefixes =
[
"/api/v1/Availability/status",
"/api/v1/Auth/",
"/api/v1/Setup/status",
"/api/v1/master/" // NEW — master can always reach slave
];
```
**503 response for master gate** (uses `ProblemDetails`):
```csharp
var masterStatus = masterSvc.GetMasterStatus();
if (!masterStatus.IsAvailable)
{
context.Response.StatusCode = StatusCodes.Status503ServiceUnavailable;
await context.Response.WriteAsJsonAsync(new ProblemDetails
{
Status = 503,
Title = "Service Unavailable",
Detail = masterStatus.DisableMessage
});
return;
}
```
**Test pattern** (mock `IMasterAvailabilityService`):
```csharp
var masterSvc = Substitute.For<IMasterAvailabilityService>();
masterSvc.GetMasterStatus().Returns(new MasterGateStatus(false, "Maintenance"));
// invoke middleware — assert 503
```
---
## Pattern 5 — Structured Logging
**Pattern**: Structured fields per event; never log key material.
| Scenario | Level | Structured Fields |
|----------|-------|------------------|
| First master registration | `Information` | `{MasterUrl}` |
| Re-registration (key match) | `Information` | `{MasterUrl}` |
| API key mismatch | `Warning` | `{Endpoint}` — do NOT log key |
| Missing header | `Warning` | `{Endpoint}` |
| Status update received | `Information` | `{IsAvailable}`, `{DisableMessage}` |
| Master gate blocked request | `Warning` | `{Path}`, `{DisableMessage}` |
| Get-registered-url called | `Debug` | — |
| Key decryption failure | `Error` | — (no key material) |
**Example**:
```csharp
_logger.LogWarning(
"Master API key mismatch on {Endpoint} — returning 401",
"POST /api/v1/master/status");
```