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,152 @@
# Business Logic Model — Unit 2: slave-availability-extension
## Flow 1: Register Master (POST /api/v1/master/register)
```mermaid
sequenceDiagram
box rgba(33,150,243,0.15) Master CMS
participant MC as MasterCms
end
box rgba(76,175,80,0.15) Slave CMS
participant Ctrl as MasterController
participant Svc as MasterAvailabilityService
participant DB as AvailabilityDbContext
end
MC->>Ctrl: POST register with X-Master-Api-Key header and MasterUrl body
Ctrl->>Svc: RegisterAsync(masterUrl, apiKey)
Svc->>DB: GetRegistrationAsync()
alt No registration exists
DB-->>Svc: null
Svc->>DB: Insert MasterRegistration with masterUrl apiKey RegisteredAt LastContactedAt=now
DB-->>Svc: saved
Svc-->>Ctrl: success
Ctrl-->>MC: 200 OK
else Registration exists and ApiKey matches
DB-->>Svc: existing record
Svc->>DB: Update MasterUrl and LastContactedAt=now
DB-->>Svc: saved
Svc-->>Ctrl: success
Ctrl-->>MC: 200 OK
else Registration exists and ApiKey does not match
DB-->>Svc: existing record
Svc-->>Ctrl: unauthorized
Ctrl-->>MC: 401 Unauthorized
end
```
Text alternative: Master posts to slave register endpoint. Service checks DB for existing registration. If none: insert new row. If exists and key matches: update MasterUrl. If exists but key mismatch: return 401.
---
## Flow 2: Status Push (POST /api/v1/master/status)
```mermaid
sequenceDiagram
box rgba(33,150,243,0.15) Master CMS
participant MC as MasterCms
end
box rgba(76,175,80,0.15) Slave CMS
participant Ctrl as MasterController
participant Svc as MasterAvailabilityService
participant DB as AvailabilityDbContext
participant Cache as StaticCache
end
MC->>Ctrl: POST status with X-Master-Api-Key header isAvailable and disableMessage
Ctrl->>Svc: PushStatusAsync(apiKey, isAvailable, disableMessage)
Svc->>DB: GetRegistrationAsync()
alt No registration
DB-->>Svc: null
Svc-->>Ctrl: unauthorized
Ctrl-->>MC: 401 Unauthorized
else ApiKey mismatch
DB-->>Svc: record with different key
Svc-->>Ctrl: unauthorized
Ctrl-->>MC: 401 Unauthorized
else ApiKey valid
DB-->>Svc: valid registration
Svc->>Cache: set _masterIsAvailable and _masterDisableMessage
Svc->>DB: Update LastContactedAt=now
DB-->>Svc: saved
Svc-->>Ctrl: success
Ctrl-->>MC: 200 OK
end
```
Text alternative: Master posts status. Service validates API key. If valid: update static cache and LastContactedAt. On any validation failure: 401.
---
## Flow 3: Get Registered URL (GET /api/v1/master/registered-url)
```mermaid
sequenceDiagram
box rgba(33,150,243,0.15) Master CMS
participant MC as MasterCms
end
box rgba(76,175,80,0.15) Slave CMS
participant Ctrl as MasterController
participant Svc as MasterAvailabilityService
participant DB as AvailabilityDbContext
end
MC->>Ctrl: GET registered-url with X-Master-Api-Key header
Ctrl->>Svc: GetRegisteredUrlAsync(apiKey)
Svc->>DB: GetRegistrationAsync()
alt No registration
DB-->>Svc: null
Svc-->>Ctrl: unauthorized
Ctrl-->>MC: 401 Unauthorized
else ApiKey mismatch
DB-->>Svc: record
Svc-->>Ctrl: unauthorized
Ctrl-->>MC: 401 Unauthorized
else ApiKey valid
DB-->>Svc: valid registration
Svc->>DB: Update LastContactedAt=now
DB-->>Svc: saved
Svc-->>Ctrl: return MasterUrl
Ctrl-->>MC: 200 OK with MasterUrl payload
end
```
Text alternative: Master requests the URL it registered on this slave. Service validates key, updates LastContactedAt, returns stored MasterUrl. 401 on any validation failure.
---
## Flow 4: Middleware Gate Evaluation (every request)
```mermaid
sequenceDiagram
box rgba(33,150,243,0.15) Incoming Request
participant Req as HttpRequest
end
box rgba(76,175,80,0.15) Slave CMS Pipeline
participant MW as AvailabilityMiddleware
participant Cache as StaticCache
participant Local as IAvailabilityService
participant Next as NextMiddleware
end
Req->>MW: any request
alt Path in bypass list
MW-->>Next: pass through unconditionally
else Admin JWT bearer token present
MW-->>Next: pass through unconditionally
else Check master gate
MW->>Cache: read _masterIsAvailable
alt Master is available or no status received yet
MW->>Local: IsAvailableAsync()
alt Locally available
MW-->>Next: pass through
else Locally unavailable
MW-->>Req: 503 with local disable message
end
else Master says unavailable
MW-->>Req: 503 with master disable message
end
end
```
Text alternative: Middleware first checks bypass paths, then admin JWT. If neither applies: checks static master cache; if master unavailable return 503. If master available: checks local availability service; if locally unavailable return 503. Otherwise pass through.
@@ -0,0 +1,103 @@
# Business Rules — Unit 2: slave-availability-extension
## Rule Set 1: API Key Validation
```mermaid
graph TD
A{Registration\nexists in DB?} -->|No| B{Is this a\nregister call?}
B -->|Yes| C[Accept and create\nnew registration]
B -->|No| D[Return 401\nUnauthorized]
A -->|Yes| E{X-Master-Api-Key\nmatches stored key?}
E -->|Yes| F[Proceed with\nbusiness logic]
E -->|No| G[Return 401\nUnauthorized]
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 A,B,E decision
class C,F pass
class D,G fail
```
Text alternative: If no registration exists and this is a register call, create it. If no registration and not a register call, 401. If registration exists, validate key; match = proceed, mismatch = 401.
**Validation rules**:
| # | Rule | Applies to |
|---|------|-----------|
| BR-SLAVE-01 | First `POST /register` with no existing registration: accept unconditionally, store `ApiKey` from `X-Master-Api-Key` header | Register endpoint |
| BR-SLAVE-02 | Subsequent `POST /register`: validate header against stored `ApiKey`. Match → update `MasterUrl` + `LastContactedAt`. Mismatch → 401. | Register endpoint |
| BR-SLAVE-03 | `POST /status` without existing registration → 401 | Status push endpoint |
| BR-SLAVE-04 | `POST /status` with key mismatch → 401 | Status push endpoint |
| BR-SLAVE-05 | `GET /registered-url` without existing registration → 401 | Get-URL endpoint |
| BR-SLAVE-06 | `GET /registered-url` with key mismatch → 401 | Get-URL endpoint |
| BR-SLAVE-07 | Missing or empty `X-Master-Api-Key` header → 401 on all endpoints | All master endpoints |
---
## Rule Set 2: Master Gate Bypass
```mermaid
graph TD
A{Path starts with\nbypass prefix?} -->|Yes| B[Pass through\nunconditionally]
A -->|No| C{Valid admin\nJWT bearer?}
C -->|Yes| B
C -->|No| D{Master gate\nenabled?}
D -->|_masterIsAvailable = true\nor default| E[Proceed to\nlocal gate]
D -->|_masterIsAvailable = false| F[Return 503\nwith master message]
E --> G{Local availability\ncheck}
G -->|Available| H[Pass to next\nmiddleware]
G -->|Unavailable| I[Return 503\nwith local 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 A,C,D,G decision
class B,E,H pass
class F,I fail
```
Text alternative: Bypass path check first. Admin JWT next (bypasses both gates). Then master gate (static field). If master blocks: 503. If master passes: local gate. If local blocks: 503. Otherwise pass through.
**Bypass prefix list** (extended from existing):
| Path prefix | Reason |
|-------------|--------|
| `/api/v1/Availability/status` | Already bypassed — public status endpoint |
| `/api/v1/Auth/` | Already bypassed — login must always work |
| `/api/v1/Setup/status` | Already bypassed — frontend init check |
| `/api/v1/master/` | **NEW** — master management endpoints must bypass gate so master can always push status or re-register |
**Cache behavior rules**:
| # | Rule |
|---|------|
| BR-SLAVE-08 | `_masterIsAvailable` defaults to `true` (fail-open) on process startup |
| BR-SLAVE-09 | `_masterDisableMessage` defaults to `null` on process startup |
| BR-SLAVE-10 | Cache has no expiry (Q4=A); only updated on `POST /status` with valid API key |
| BR-SLAVE-11 | 503 response from master gate includes `_masterDisableMessage` in `ProblemDetails.Detail` |
---
## Rule Set 3: MasterRegistration Singleton
| # | Rule |
|---|------|
| BR-SLAVE-12 | `MasterRegistration` is a singleton: `Id` is always `Guid.Parse("00000000-0000-0000-0000-000000000001")` |
| BR-SLAVE-13 | On `RegisterAsync`: if row with that Id exists → update. If not → insert. Never delete. |
| BR-SLAVE-14 | `RegisteredAt` is set once at creation and never updated |
| BR-SLAVE-15 | `LastContactedAt` is updated on every successful master call (register, status push, get-url) |
---
## Rule Set 4: Controller Routing
| Endpoint | Method | Route | Auth |
|----------|--------|-------|------|
| Register master | `POST` | `/api/v1/master/register` | None (API key in header) |
| Receive status push | `POST` | `/api/v1/master/status` | None (API key in header) |
| Get registered URL | `GET` | `/api/v1/master/registered-url` | None (API key in header) |
All three endpoints are unauthenticated from ASP.NET Core's perspective — they use the custom `X-Master-Api-Key` header validation implemented in `MasterAvailabilityService`. They are also in the middleware bypass list so the gate cannot block master management calls.
@@ -0,0 +1,72 @@
# Domain Entities — Unit 2: slave-availability-extension
## Entity Relationship Diagram
```mermaid
graph TD
subgraph AvailabilityModule["Availability Module (slave side)"]
DbCtx["AvailabilityDbContext"]
MR["MasterRegistration\n(singleton row)"]
Cache["MasterStatusCache\n(static fields)"]
MAS["IMasterAvailabilityService\n/MasterAvailabilityService"]
end
DbCtx -->|owns| MR
MAS -->|reads/writes| DbCtx
MAS -->|updates| Cache
MW["AvailabilityMiddleware\n(extended)"] -->|reads synchronously| Cache
MC["MasterController\n(new)"] -->|delegates to| MAS
classDef entity fill:#FFC107,stroke:#F57F17,color:#000
classDef service fill:#9ae6b4,stroke:#2f855a,color:#000
classDef infra fill:#63b3ed,stroke:#2b6cb0,color:#000
classDef cache fill:#CE93D8,stroke:#6A1B9A,color:#000
class MR entity
class MAS service
class DbCtx,MC infra
class Cache,MW cache
```
Text alternative: `AvailabilityDbContext` owns the singleton `MasterRegistration` row. `MasterAvailabilityService` reads/writes the DbContext and updates the in-process `MasterStatusCache` (static fields). `AvailabilityMiddleware` reads the cache synchronously. `MasterController` delegates all logic to `MasterAvailabilityService`.
---
## Entity: MasterRegistration
Singleton row — at most one record exists per slave instance. Upserted on each successful registration.
| Field | Type | Constraints | Notes |
|-------|------|-------------|-------|
| `Id` | `Guid` | PK | Fixed value (e.g. `Guid.Empty`) enforces singleton |
| `MasterUrl` | `string` | Required, max 500 | URL of the master CMS that registered this slave |
| `ApiKey` | `string` | Required, max 1000 | Plain-text API key sent in first registration; used for subsequent validation |
| `RegisteredAt` | `DateTimeOffset` | Required | Timestamp of first registration |
| `LastContactedAt` | `DateTimeOffset?` | Optional | Updated on every successful master call (register, status push, get-url) |
> **Singleton enforcement**: The `Id` is a fixed known value (`Guid.Parse("00000000-0000-0000-0000-000000000001")`). On first `POST /api/v1/master/register` the row is created; on re-registration the same row is updated in-place. This avoids a composite unique constraint and makes EF upsert trivial.
---
## In-Process Cache: MasterStatusCache (static fields)
Not a DB entity — lives in memory on the slave process.
| Field | Type | Default | Notes |
|-------|------|---------|-------|
| `_masterIsAvailable` | `bool` | `true` | Set by status push; `true` = pass gate |
| `_masterDisableMessage` | `string?` | `null` | Message forwarded from master to 503 response |
**No expiry** (Q4=A): cache is valid indefinitely until the master pushes again. If the master goes offline permanently, the last known status is used. Default = `true` (Available) = fail-open.
---
## DbContext: AvailabilityDbContext
New per-module DbContext in `SlpModularCms.Modules.Availability`. Separate from `ApplicationDbContext` (Core).
| DbSet | Entity | Table Name |
|-------|--------|-----------|
| `MasterRegistrations` | `MasterRegistration` | `AvailabilityMasterRegistrations` |
Migration assembly: `SlpModularCms.Modules.Availability`. Applied at startup in `AvailabilityModule.UseModule`.
@@ -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");
```
@@ -0,0 +1,99 @@
# NFR Requirements — Unit 2: slave-availability-extension
## Security
### SEC-01: ApiKey encrypted at rest (Q1=C)
The `MasterRegistration.ApiKey` field is **not** stored in plain text. The slave encrypts the key using ASP.NET Core Data Protection before writing to the DB, and decrypts it before comparison on each incoming request.
| Aspect | Decision |
|--------|----------|
| Mechanism | ASP.NET Core Data Protection (`IDataProtectionProvider`) |
| Wrapper type | `IMasterApiKeyProtector` / `MasterApiKeyProtector` |
| Purpose string | `"SlpModularCms.Availability.MasterApiKey"` |
| Comparison | Decrypt stored key → compare with incoming header value (plain-text equality) |
| On decryption failure | Return `null` (key is unreadable) → treat as mismatch → 401 |
**Rationale**: Consistent with Unit 1's `IApiKeyProtector` pattern. Protects the key if the database is accessed directly (e.g., backup file, DB dump). The purpose string isolates it from Unit 1's protector scope.
### SEC-02: Missing or empty header → 401
An absent or empty `X-Master-Api-Key` header on any master endpoint (register, status, registered-url) immediately returns 401 without touching the database. No information leaked about whether a registration exists.
### SEC-03: Unauthenticated controller, API key validated in service
The three master endpoints carry no `[Authorize]` attribute — authentication is done via the custom header in `MasterAvailabilityService`. The endpoints are in the `/api/v1/master/` bypass prefix so the availability gate cannot block them.
---
## Performance
### PERF-01: Master gate check is synchronous
The `AvailabilityMiddleware` reads `_masterIsAvailable` from a static volatile field — zero async overhead, zero DB call per request. Only status pushes touch the DB.
### PERF-02: No DB call on gate evaluation
The master gate evaluates solely from `volatile` static fields. The local gate still calls `IAvailabilityService.IsAvailableAsync()` (one DB read with 1s cache per `AvailabilityOptions.StatusCacheSeconds` — unchanged from existing behaviour).
---
## Reliability
### REL-01: Fail-open on process startup (Q2=A, Q4=A from FD)
`_masterIsAvailable` defaults to `true` at process startup. A slave that restarts before the master pushes status again is immediately accessible. The master's `IntegrityCheckBackgroundService` will push status again within `IntegrityCheckIntervalMinutes`.
### REL-02: No expiry on cached status (Q4=A from FD)
The static cache has no TTL. If the master goes offline permanently, the last pushed status is used indefinitely. For a slave last told "unavailable", it remains unavailable until either:
- The master recovers and pushes "available" again, or
- An operator manually calls `POST /api/v1/Availability/status` locally (existing `AvailabilityController` endpoint, Owner-only).
### REL-03: Static field thread safety via `volatile` (Q2=A)
`private static volatile bool _masterIsAvailable` and `private static volatile string? _masterDisableMessage`. Writes to reference types and bools are atomic in .NET; `volatile` ensures cross-thread visibility without a lock on every request. Consistent with `PersistentAvailabilityService`'s `_lastErrorTime` pattern.
---
## Maintainability
### MAINT-01: Test coverage ≥ 80% (Q3=B)
**Excluded** from coverage:
- `AvailabilityModule` (service registration changes + `Database.MigrateAsync()` call)
- EF Core migration files (auto-generated)
- DTO / request / response record classes (`[ExcludeFromCodeCoverage]`)
**Included** (must reach ≥ 80%):
- `MasterController`
- `MasterAvailabilityService` (all 3 public methods)
- `MasterApiKeyProtector`
- `AvailabilityMiddleware` (extended paths — master gate logic)
- `IMasterRegistrationRepository` / `MasterRegistrationRepository`
### MAINT-02: Structured logging (Q4=B)
| Scenario | Level | Structured Fields |
|----------|-------|------------------|
| First master registration | `Information` | `masterUrl` |
| Re-registration (existing key match) | `Information` | `masterUrl` |
| API key mismatch on any endpoint | `Warning` | `endpoint` (do NOT log the key itself) |
| Missing X-Master-Api-Key header | `Warning` | `endpoint` |
| Status update received | `Information` | `isAvailable`, `disableMessage` |
| Master gate blocked a request | `Warning` | `path`, `disableMessage` |
| Get-registered-url called | `Debug` | — |
| Decryption failure on stored key | `Error` | (no key value) |
---
## Test Framework (unchanged from Unit 1)
| Aspect | Decision |
|--------|----------|
| Framework | xUnit |
| Mocking | NSubstitute 5.x |
| Assertions | FluentAssertions 8.x |
| EF Core testing | EF Core InMemory provider |
| Data Protection testing | `EphemeralDataProtectionProvider` |
| Project name | `SlpModularCms.Modules.Availability.Master.Tests` |
@@ -0,0 +1,88 @@
# Tech Stack Decisions — Unit 2: slave-availability-extension
## Data Protection
### Decision: `IMasterApiKeyProtector` wrapping ASP.NET Core Data Protection
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| Interface | `IMasterApiKeyProtector` with `Protect(string)` / `Unprotect(string)` | Testable; mirrors Unit 1's `IApiKeyProtector` pattern |
| Implementation | `MasterApiKeyProtector : IMasterApiKeyProtector` | Wraps `IDataProtectionProvider`; purpose-scoped |
| Purpose string | `"SlpModularCms.Availability.MasterApiKey"` | Prevents cross-module decryption with Unit 1's scope |
| On `Unprotect` failure | Catch `CryptographicException`, return `null` | Service treats null as key mismatch → 401; logs Error |
| Key ring | Default file system (inherits app-level `AddDataProtection()` setup) | No extra configuration needed; same caveat as Unit 1 regarding containerized deployments |
**Production note**: Same as Unit 1 — for multi-instance or containerized deployments, configure a shared key ring (`PersistKeysToDbContext`, `PersistKeysToAzureBlobStorage`, etc.). Without it, a restarted container cannot decrypt keys stored by the previous instance.
---
## Static Cache
### Decision: `volatile` static fields in `MasterAvailabilityService`
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| `_masterIsAvailable` | `private static volatile bool` | Atomic read/write for bool; `volatile` ensures CPU cache flush visibility |
| `_masterDisableMessage` | `private static volatile string?` | Reference assignment is atomic in .NET; `volatile` ensures visibility |
| Default | `_masterIsAvailable = true`, `_masterDisableMessage = null` | Fail-open: process startup = Available |
| Write location | `MasterAvailabilityService.PushStatusAsync` only | Single write point; no other code modifies cache |
| Read location | `AvailabilityMiddleware.InvokeAsync` only | Single read point; no async overhead |
**Why not `lock`**: No multi-field invariant to protect (fields are read/written independently). `volatile` matches the existing pattern in `PersistentAvailabilityService` (`_lastErrorTime`).
---
## EF Core / Database
### Decision: New `AvailabilityDbContext` with own migrations
| Aspect | Decision |
|--------|----------|
| DbContext class | `AvailabilityDbContext : DbContext` in `SlpModularCms.Modules.Availability` |
| Migration assembly | `SlpModularCms.Modules.Availability` (same project) |
| Migration application | `app.ApplicationServices.CreateScope()``AvailabilityDbContext.Database.MigrateAsync()` in `AvailabilityModule.UseModule(IApplicationBuilder)` |
| DbSet | `DbSet<MasterRegistration> MasterRegistrations` |
| Table name | `AvailabilityMasterRegistrations` |
| Connection string | Reuses `ConnectionStrings:DefaultConnection` (same as `ApplicationDbContext` and `MasterDbContext`) |
| Registration | `services.AddDbContext<AvailabilityDbContext>((sp, options) => ...)` using `IConfiguration` from service provider |
**Singleton enforcement**: `MasterRegistration.Id` is always `new Guid("00000000-0000-0000-0000-000000000001")`. EF `AddOrUpdate` via `ExecuteUpdateAsync` / find-by-id pattern.
---
## Repository
### Decision: `IMasterRegistrationRepository` / `MasterRegistrationRepository`
| Method | Signature | Notes |
|--------|-----------|-------|
| `GetAsync` | `Task<MasterRegistration?>` | Loads singleton by fixed Id; returns null if not exists |
| `UpsertAsync` | `Task UpsertAsync(MasterRegistration registration)` | Add or Update based on whether row exists |
| `SaveChangesAsync` | `Task SaveChangesAsync()` | Explicit save; keeps service in control of transaction boundary |
---
## Controller
### Decision: New `MasterController` in Availability module
| Aspect | Decision |
|--------|----------|
| Class | `MasterController : ControllerBase` in `SlpModularCms.Modules.Availability.Controllers` |
| Route | `[Route("[controller]")]``/api/v1/master` via `ApiPrefixConvention("api/v1")` |
| Auth | No `[Authorize]` — API key validated in service layer |
| Response on 401 | `Unauthorized()` (HTTP 401) — no `ProblemDetails` body to avoid leaking info |
| Response on success | `Ok()` for register/status; `Ok(new { MasterUrl })` for registered-url |
---
## New Dependencies
| Package | Already present? | Notes |
|---------|-----------------|-------|
| `Microsoft.AspNetCore.DataProtection` | Yes (shared framework) | No NuGet addition needed |
| EF Core SqlServer | Yes (via Core project) | No addition needed |
| xUnit / NSubstitute / FluentAssertions | Yes (existing test projects) | Reference same versions as `Availability.Tests` |
| EF Core InMemory | Likely yes | Confirm in `Availability.Tests.csproj` |
**Net new NuGet packages required**: None.