Adds 2 units and docs for unit 3. nfr-requirements plan
This commit is contained in:
+152
@@ -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.
|
||||
+103
@@ -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.
|
||||
+72
@@ -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`.
|
||||
+302
@@ -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 |
|
||||
+232
@@ -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");
|
||||
```
|
||||
+99
@@ -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` |
|
||||
+88
@@ -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.
|
||||
Reference in New Issue
Block a user