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,76 @@
# Business Logic Model — Unit 3: frontend-cms-page
## Component Orchestration
`CmsPage` is the root orchestrator. It owns all modal state and delegates data operations to hooks.
```
CmsPage
├── useCmsInstances() → fetches instance list
├── CmsInstanceList → renders table, emits onSetStatus
├── AddCmsInstanceDialog → useAddCmsInstance() internally
└── SetStatusDialog → useUpdateCmsInstanceStatus() internally
```
---
## Data Flows
### Flow 1 — Load instances
```
CmsPage mounts
→ useCmsInstances fires GET /api/v1/CmsInstances
→ isPending: show loading indicator
→ isError: show error message
→ data=[]: render EmptyState (centered placeholder + Add button)
→ data=[...]: render CmsInstanceList
```
### Flow 2 — Add CmsInstance
```
User clicks "Add CMS" button
→ addDialogOpen = true → AddCmsInstanceDialog opens
User fills Name, URL, ApiKey and submits
→ useAddCmsInstance fires POST /api/v1/CmsInstances
→ HTTP 201: close dialog, invalidate ['cmsInstances'], toast success
→ HTTP 400: show FormBannerError inside dialog (do not close)
→ Network error: show FormBannerError inside dialog
```
### Flow 3 — Update instance status
```
User clicks "Set Status" in row actions dropdown
→ statusTarget = instance → SetStatusDialog opens
User selects status, optionally fills DisableMessage, submits
→ useUpdateCmsInstanceStatus fires PUT /api/v1/CmsInstances/{id}/status
→ HTTP 200 + slaveContactSuccess=true:
close dialog, invalidate ['cmsInstances'],
toast "Status updated — cliënt confirmed"
→ HTTP 200 + slaveContactSuccess=false:
close dialog, invalidate ['cmsInstances'],
toast "Status saved — cliënt unreachable"
→ HTTP 404: toast error "Instance not found"
→ HTTP 400: toast error (generic)
→ Network error: toast error (generic)
```
### Flow 4 — DisableMessage conditional visibility
```
SetStatusDialog: status field changes
→ status === 'NotAvailable': show DisableMessage field (required)
→ status !== 'NotAvailable': hide DisableMessage field, clear value
```
---
## Cache Strategy
| Hook | Query key | Stale time | Invalidated by |
|------|-----------|------------|----------------|
| `useCmsInstances` | `['cmsInstances']` | 30 000 ms | `useAddCmsInstance.onSuccess`, `useUpdateCmsInstanceStatus.onSuccess` |
| `useAddCmsInstance` | — | — | — |
| `useUpdateCmsInstanceStatus` | — | — | — |
@@ -0,0 +1,49 @@
# Business Rules — Unit 3: frontend-cms-page
## Table Display (Q1: B+C)
- **BR-FE-01**: The `CmsInstanceList` table displays six columns: Name, URL, Status, Last Contact, DisableMessage, Actions.
- **BR-FE-02**: `lastContactedAt` is formatted as a localised date/time string; displays "—" when `null`.
- **BR-FE-03**: `disableMessage` displays "—" when `null` or empty.
## Inactive Row Styling (Q2: A)
- **BR-FE-04**: A `<TableRow>` whose `instance.status === 'Inactive'` receives `className="opacity-50"`. All cells within that row are visually dimmed as a result.
## Status Badge Colors
- **BR-FE-05**: `Available` → green badge (`secondary` or custom green variant).
- **BR-FE-06**: `NotAvailable` → red badge (`destructive` variant).
- **BR-FE-07**: `Inactive` → muted badge (`outline` variant).
## SetStatusDialog — DisableMessage (Q3 note, existing unit-of-work)
- **BR-FE-08**: The DisableMessage field is only rendered when the selected status is `NotAvailable`.
- **BR-FE-09**: DisableMessage is required (non-empty) when status is `NotAvailable`. Form submit is blocked if it is empty.
- **BR-FE-10**: When status changes away from `NotAvailable`, the DisableMessage field is hidden and its value is reset to `""`.
- **BR-FE-11**: The value sent to the API is `null` for all statuses except `NotAvailable`; for `NotAvailable` it is the trimmed string value.
## UpdateStatusResult Toast (Q3: B, "cliënt" terminology)
- **BR-FE-12**: On HTTP 200 with `slaveContactSuccess === true`: show toast "Status updated — cliënt confirmed".
- **BR-FE-13**: On HTTP 200 with `slaveContactSuccess === false`: show toast "Status saved — cliënt unreachable".
- **BR-FE-14**: The word "slave" must not appear in any user-visible text. Use "cliënt" in all UI strings.
## AddCmsInstanceDialog Error Handling (Q4: A)
- **BR-FE-15**: On HTTP 400 from `POST /api/v1/CmsInstances`: display a `FormBannerError` at the top of the dialog. The dialog remains open.
- **BR-FE-16**: On network error: display a `FormBannerError` at the top of the dialog. The dialog remains open.
- **BR-FE-17**: On success (HTTP 201): close the dialog and show a success toast.
## Empty State (Q5: A)
- **BR-FE-18**: When `useCmsInstances` returns an empty array, render a centered placeholder instead of the table. The placeholder contains:
- A `LayoutGrid` icon (consistent with the existing `CmsPage` placeholder).
- A heading: "No CMS instances yet".
- A description: "Add your first CMS instance to get started."
- An "Add CMS" button that opens `AddCmsInstanceDialog`.
- **BR-FE-19**: The "Add CMS" button in the page header is always visible regardless of empty state.
## Access Control
- **BR-FE-20**: The `/cms` route is already guarded by `RoleGuard allowedRoles={['Owner']}`. No additional guard logic is needed inside `CmsPage`.
@@ -0,0 +1,74 @@
# Domain Entities — Unit 3: frontend-cms-page
## TypeScript Types
### CmsInstanceStatus
```typescript
export type CmsInstanceStatus = 'Available' | 'NotAvailable' | 'Inactive';
```
String union matching `CmsInstanceStatus` enum values serialized by the backend (`Available`, `NotAvailable`, `Inactive`).
---
### CmsInstance
```typescript
export interface CmsInstance {
id: string; // UUID
name: string;
url: string;
status: CmsInstanceStatus;
disableMessage: string | null;
lastContactedAt: string | null; // ISO 8601, null if never contacted
lastStatusPushedAt: string | null; // ISO 8601
lastIntegrityCheckFailedAt: string | null; // ISO 8601
}
```
---
### CreateCmsInstanceRequest
```typescript
export interface CreateCmsInstanceRequest {
name: string;
url: string;
apiKey: string;
}
```
Sent as body to `POST /api/v1/CmsInstances`.
---
### UpdateCmsInstanceStatusRequest
```typescript
export interface UpdateCmsInstanceStatusRequest {
status: CmsInstanceStatus;
disableMessage: string | null;
}
```
Sent as body to `PUT /api/v1/CmsInstances/{id}/status`. `disableMessage` is `null` unless `status === 'NotAvailable'`.
---
### UpdateStatusResult
```typescript
export interface UpdateStatusResult {
success: boolean;
slaveContactSuccess: boolean; // true = cliënt was reachable and confirmed
}
```
Returned by `PUT /api/v1/CmsInstances/{id}/status`. `slaveContactSuccess` drives the differentiated toast message.
---
## File Location
All types added to `src/api/types.ts`.
@@ -0,0 +1,214 @@
# Frontend Components — Unit 3: frontend-cms-page
## Component Hierarchy
```
CmsPage (src/pages/CmsPage.tsx)
├── AddCmsInstanceDialog (src/components/cms/AddCmsInstanceDialog.tsx)
├── SetStatusDialog (src/components/cms/SetStatusDialog.tsx)
└── CmsInstanceList (src/components/cms/CmsInstanceList.tsx)
```
---
## CmsPage
**File**: `src/pages/CmsPage.tsx`
**State**:
| State | Type | Initial | Description |
|-------|------|---------|-------------|
| `addDialogOpen` | `boolean` | `false` | Controls AddCmsInstanceDialog visibility |
| `statusTarget` | `CmsInstance \| null` | `null` | Instance passed to SetStatusDialog; null = closed |
**Hooks**: `useCmsInstances()`
**Render logic**:
```
Header row: title + "Add CMS" button (always visible)
isPending → loading text
isError → error text
data === [] → EmptyState (icon + heading + description + Add button)
data.length > 0 → CmsInstanceList
AddCmsInstanceDialog (controlled by addDialogOpen)
SetStatusDialog (controlled by statusTarget !== null)
```
---
## CmsInstanceList
**File**: `src/components/cms/CmsInstanceList.tsx`
**Props**:
```typescript
interface CmsInstanceListProps {
instances: CmsInstance[];
onSetStatus: (instance: CmsInstance) => void;
}
```
**Columns** (BR-FE-01):
| Column | Source field | Notes |
|--------|-------------|-------|
| Name | `instance.name` | — |
| URL | `instance.url` | — |
| Status | `instance.status` | Rendered as badge (BR-FE-05..07) |
| Last Contact | `instance.lastContactedAt` | Formatted date; "—" when null (BR-FE-02) |
| Disable Message | `instance.disableMessage` | "—" when null (BR-FE-03) |
| Actions | — | Dropdown with "Set Status" item |
**Row styling**: `<TableRow className={instance.status === 'Inactive' ? 'opacity-50' : ''}>` (BR-FE-04)
**Interactions**: Clicking "Set Status" in the actions dropdown calls `onSetStatus(instance)`.
---
## AddCmsInstanceDialog
**File**: `src/components/cms/AddCmsInstanceDialog.tsx`
**Props**:
```typescript
interface AddCmsInstanceDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
```
**Form fields**:
| Field | Type | Validation |
|-------|------|-----------|
| Name | text | Required, non-empty |
| URL | text | Required, valid URL format |
| ApiKey | password | Required, non-empty |
**Hooks**: `useAddCmsInstance()`
**Submit behaviour**:
- Success (HTTP 201): call `onOpenChange(false)`, show success toast
- HTTP 400: show `FormBannerError` inside dialog, keep open (BR-FE-15)
- Network error: show `FormBannerError` inside dialog, keep open (BR-FE-16)
---
## SetStatusDialog
**File**: `src/components/cms/SetStatusDialog.tsx`
**Props**:
```typescript
interface SetStatusDialogProps {
instance: CmsInstance | null;
open: boolean;
onOpenChange: (open: boolean) => void;
}
```
**Form fields**:
| Field | Type | Condition | Validation |
|-------|------|-----------|-----------|
| Status | select (`Available` \| `NotAvailable` \| `Inactive`) | always | Required |
| DisableMessage | text | only when `status === 'NotAvailable'` | Required when visible |
**Hooks**: `useUpdateCmsInstanceStatus()`
**Submit behaviour**:
- HTTP 200 + `slaveContactSuccess=true`: close dialog, toast "Status updated — cliënt confirmed" (BR-FE-12)
- HTTP 200 + `slaveContactSuccess=false`: close dialog, toast "Status saved — cliënt unreachable" (BR-FE-13)
- HTTP 404: toast error, keep open
- HTTP 400 / network error: toast error, keep open
**DisableMessage field logic** (BR-FE-08..11):
- Rendered only when selected status is `'NotAvailable'`
- On status change away from `'NotAvailable'`: clear field value
- Value sent to API: `null` unless status is `'NotAvailable'`
---
## Hooks
### useCmsInstances
**File**: `src/api/useCmsInstances.ts`
```typescript
useQuery<CmsInstance[], Error>({
queryKey: ['cmsInstances'],
queryFn: () => api.get<CmsInstance[]>('/api/v1/CmsInstances'),
staleTime: 30_000,
})
```
### useAddCmsInstance
**File**: `src/api/useAddCmsInstance.ts`
```typescript
useMutation<CmsInstance, Error, CreateCmsInstanceRequest>({
mutationFn: (data) => api.post<CmsInstance>('/api/v1/CmsInstances', data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cmsInstances'] }),
})
```
### useUpdateCmsInstanceStatus
**File**: `src/api/useUpdateCmsInstanceStatus.ts`
```typescript
useMutation<UpdateStatusResult, Error, { id: string } & UpdateCmsInstanceStatusRequest>({
mutationFn: ({ id, ...body }) =>
api.put<UpdateStatusResult>(`/api/v1/CmsInstances/${id}/status`, body),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['cmsInstances'] }),
})
```
---
## i18n Keys (additions to `cms` namespace)
```json
"cms": {
"title": "CMS Instances",
"addButton": "Add CMS",
"emptyState": {
"heading": "No CMS instances yet",
"description": "Add your first CMS instance to get started."
},
"table": {
"name": "Name",
"url": "URL",
"status": "Status",
"lastContact": "Last Contact",
"disableMessage": "Disable Message",
"actions": "Actions",
"never": "—"
},
"status": {
"Available": "Available",
"NotAvailable": "Unavailable",
"Inactive": "Inactive"
},
"actions": {
"setStatus": "Set Status"
},
"add": {
"title": "Add CMS Instance",
"nameLabel": "Name",
"urlLabel": "URL",
"apiKeyLabel": "API Key",
"submitButton": "Add",
"successToast": "CMS instance added successfully"
},
"setStatus": {
"title": "Set Status",
"statusLabel": "Status",
"disableMessageLabel": "Disable Message",
"disableMessagePlaceholder": "Reason for disabling…",
"submitButton": "Save",
"successContactedToast": "Status updated — cliënt confirmed",
"successUnreachableToast": "Status saved — cliënt unreachable"
}
}
```
@@ -0,0 +1,63 @@
# Code Summary — Unit 1: master-backend
## New Project: SlpModularCms.Modules.Master
| File | Description |
|------|-------------|
| `SlpModularCms.Modules.Master.csproj` | Project file; references Core; adds Microsoft.Extensions.Http.Resilience 9.6.0 |
| `Options/MasterModuleOptions.cs` | Configuration POCO: IntegrityCheckIntervalMinutes, HttpTimeoutSeconds, MasterUrl, CacheMinutes, ApiKey |
| `Data/Entities/CmsInstanceStatus.cs` | Enum: Available=0, NotAvailable=1, Inactive=2 |
| `Data/Entities/CmsInstance.cs` | EF Core entity with all domain fields including LastIntegrityCheckFailedAt |
| `Data/MasterDbContext.cs` | Per-module DbContext; table MasterCmsInstances; configured via OnModelCreating |
| `Models/CmsInstanceDto.cs` | Record DTO (excludes ApiKey); ExcludeFromCodeCoverage |
| `Models/CreateCmsInstanceRequest.cs` | Record request for POST; ExcludeFromCodeCoverage |
| `Models/UpdateStatusRequest.cs` | Record request for status update; ExcludeFromCodeCoverage |
| `Models/UpdateStatusResult.cs` | Record result with Success + SlaveContactSuccess; ExcludeFromCodeCoverage |
| `Repositories/ICmsInstanceRepository.cs` | Interface: GetAllAsync, GetActiveAsync, GetByIdAsync, AddAsync, Update, SaveChangesAsync |
| `Repositories/CmsInstanceRepository.cs` | EF Core implementation; GetActiveAsync excludes Inactive |
| `Services/IApiKeyProtector.cs` | Interface: Protect/Unprotect |
| `Services/ApiKeyProtector.cs` | Data Protection wrapper; purpose string "SlpModularCms.Master.ApiKey" |
| `Services/ISlaveApiClient.cs` | Interface: RegisterMasterAsync, PushStatusAsync, GetRegisteredMasterUrlAsync |
| `Services/SlaveApiClient.cs` | Typed HTTP client; X-Master-Api-Key header on each call; fail-open (returns false on exception) |
| `Services/MasterServiceDependencies.cs` | Record aggregating 6 CmsInstanceService dependencies; ExcludeFromCodeCoverage |
| `Services/ICmsInstanceService.cs` | Interface: GetAllAsync, AddAsync, UpdateStatusAsync, VerifyIntegrityAsync |
| `Services/CmsInstanceService.cs` | Business logic; HttpContext → config fallback for MasterUrl; never returns ApiKey in DTO |
| `BackgroundServices/IntegrityCheckBackgroundService.cs` | PeriodicTimer; per-tick IServiceScope; catches all exceptions per tick |
| `Controllers/CmsInstanceController.cs` | [Authorize(Policy="OwnerOnly")]; GET / POST / PUT /{id}/status |
| `MasterModule.cs` | IModule implementation; DI registration; db.Database.Migrate() in UseModule; ExcludeFromCodeCoverage |
| `Migrations/.gitkeep` | Placeholder; run CLI to generate migration (see below) |
## New Project: SlpModularCms.Modules.Master.Tests
| File | Description |
|------|-------------|
| `SlpModularCms.Modules.Master.Tests.csproj` | xUnit + NSubstitute + FluentAssertions + EF InMemory |
| `Repositories/CmsInstanceRepositoryTests.cs` | EF InMemory; covers all repository methods |
| `Services/ApiKeyProtectorTests.cs` | Uses EphemeralDataProtectionProvider; round-trip + invalid ciphertext tests |
| `Services/SlaveApiClientTests.cs` | FakeHttpMessageHandler; tests success/failure/exception paths + header assertion |
| `Services/CmsInstanceServiceTests.cs` | NSubstitute; covers all business logic branches including HttpContext fallback |
| `BackgroundServices/IntegrityCheckBackgroundServiceTests.cs` | PeriodicTimer integration; verifies exception isolation |
| `Controllers/CmsInstanceControllerTests.cs` | NSubstitute ICmsInstanceService; verifies all HTTP response codes |
## Modified Files
| File | Change |
|------|--------|
| `src/SlpModularCms.Api/SlpModularCms.Api.csproj` | Added ProjectReference to SlpModularCms.Modules.Master |
| `SlpModularCms.sln` | Added both new projects with GUIDs and src folder nesting |
## EF Core Migration
After building the solution, run:
```bash
dotnet ef migrations add InitialCreate --project src/SlpModularCms.Modules.Master --startup-project src/SlpModularCms.Api
```
This generates the `Migrations/` folder contents. The migration is applied automatically on startup via `db.Database.Migrate()` in `MasterModule.UseModule`.
## Notes
- `Microsoft.Extensions.Http.Resilience` version `9.6.0` — verify/update during `dotnet restore` if a newer version is available for .NET 10
- `IntegrityCheckIntervalMinutes = 0` in tests forces immediate PeriodicTimer ticks (valid for test scenarios only)
- Slave-side endpoints (`/api/v1/master/register`, `/api/v1/master/status`, `/api/v1/master/registered-url`) are implemented in Unit 2 (slave-availability-extension)
@@ -0,0 +1,191 @@
# Business Logic Model — Unit 1: master-backend
## Flow 1 — AddAsync (Add Slave CMS)
**Trigger**: `POST /api/v1/CmsInstances` (Owner only)
```mermaid
sequenceDiagram
box rgba(99,179,237,0.3) API Layer
participant Ctrl as CmsInstanceController
end
box rgba(154,230,180,0.3) Service Layer
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Infrastructure
participant DP as IDataProtector
participant Ctx as IHttpContextAccessor
participant Opts as MasterModuleOptions
end
box rgba(200,200,200,0.3) Persistence
participant DB as MasterDbContext
end
Ctrl->>Svc: AddAsync(request)
Note over Svc: Validate Name, Url, ApiKey not empty
Note over Svc: Validate Url starts with http or https
Svc->>DP: Protect(request.ApiKey)
DP-->>Svc: encryptedApiKey
Svc->>Repo: AddAsync(new CmsInstance)
Note over Svc,Repo: Status=Available, LastContactedAt=null
Svc->>Repo: SaveChangesAsync()
Repo->>DB: INSERT CmsInstances
Note over Svc: Determine masterUrl
Svc->>Ctx: try get base URL from HttpContext
alt HttpContext available
Ctx-->>Svc: masterUrl from request
else HttpContext unavailable
Svc->>Opts: read MasterUrl
Opts-->>Svc: configured masterUrl
end
Svc->>DP: Unprotect(encryptedApiKey)
DP-->>Svc: plainApiKey
Svc->>Client: RegisterMasterAsync(slaveUrl, plainApiKey, masterUrl)
alt Registration success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else Registration failed
Client-->>Svc: false
Note over Svc: LastContactedAt stays null (owner can see)
end
Svc-->>Ctrl: CmsInstanceDto
Ctrl-->>Ctrl: return 201 Created
```
Text alternative: Controller calls service; service validates, encrypts ApiKey, persists entity, determines master URL from HttpContext or config, attempts slave registration, updates LastContactedAt on success; always returns DTO regardless of registration outcome.
---
## Flow 2 — UpdateStatusAsync (Set Slave Status)
**Trigger**: `PUT /api/v1/CmsInstances/{id}/status` (Owner only)
```mermaid
sequenceDiagram
box rgba(99,179,237,0.3) API Layer
participant Ctrl as CmsInstanceController
end
box rgba(154,230,180,0.3) Service Layer
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Infrastructure
participant DP as IDataProtector
end
box rgba(200,200,200,0.3) Persistence
participant DB as MasterDbContext
end
Ctrl->>Svc: UpdateStatusAsync(id, status, disableMessage)
Svc->>Repo: GetByIdAsync(id)
Repo->>DB: SELECT CmsInstances WHERE Id
DB-->>Repo: CmsInstance or null
Repo-->>Svc: entity or null
alt Entity not found
Svc-->>Ctrl: throw NotFoundException
end
Note over Svc: Validate DisableMessage required if NotAvailable
alt Validation fails
Svc-->>Ctrl: throw ValidationException
end
alt newStatus = Inactive
Svc->>Repo: UpdateAsync (Status=Inactive, DisableMessage=null)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=true)
else newStatus = Available or NotAvailable
Svc->>Repo: UpdateAsync (Status, DisableMessage)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Svc->>DP: Unprotect(entity.ApiKey)
DP-->>Svc: plainApiKey
Svc->>Client: PushStatusAsync(slaveUrl, plainApiKey, status, disableMessage)
alt Push success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastStatusPushedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=true)
else Push failed
Client-->>Svc: false
Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=false)
end
end
Ctrl-->>Ctrl: return 200 OK with UpdateStatusResult
```
Text alternative: Controller calls service with id and new status; service loads entity, validates, updates DB, then for non-Inactive transitions decrypts ApiKey and pushes status to slave; returns SlaveContactSuccess=false if push fails but DB is always the authority.
---
## Flow 3 — VerifyIntegrityAsync (Background Integrity Check)
**Trigger**: `IntegrityCheckBackgroundService` periodic timer (every `IntegrityCheckIntervalMinutes`)
```mermaid
sequenceDiagram
box rgba(200,200,200,0.3) Background
participant Timer as PeriodicTimer
participant BgSvc as IntegrityCheckBackgroundService
end
box rgba(154,230,180,0.3) Service Layer
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Infrastructure
participant DP as IDataProtector
participant Opts as MasterModuleOptions
end
box rgba(200,200,200,0.3) Persistence
participant DB as MasterDbContext
end
Timer->>BgSvc: Tick
BgSvc->>Svc: VerifyIntegrityAsync()
Svc->>Repo: GetActiveAsync()
Repo->>DB: SELECT WHERE Status != Inactive
DB-->>Repo: list of CmsInstance
Repo-->>Svc: instances
loop for each instance
Svc->>DP: Unprotect(instance.ApiKey)
DP-->>Svc: plainApiKey
Svc->>Opts: read MasterUrl
Opts-->>Svc: masterUrl
Svc->>Client: GetRegisteredMasterUrlAsync(slaveUrl, plainApiKey)
alt Slave unreachable
Client-->>Svc: throws or returns null
Svc->>Repo: UpdateAsync (LastIntegrityCheckFailedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else Slave reachable
Client-->>Svc: registeredMasterUrl
alt URLs match
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow, LastIntegrityCheckFailedAt = null)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else URL mismatch
Svc->>Client: RegisterMasterAsync(slaveUrl, plainApiKey, masterUrl)
alt Re-registration success
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastContactedAt = UtcNow, LastIntegrityCheckFailedAt = null)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
else Re-registration failed
Client-->>Svc: false
Svc->>Repo: UpdateAsync (LastIntegrityCheckFailedAt = UtcNow)
Svc->>Repo: SaveChangesAsync()
Repo->>DB: UPDATE CmsInstances
end
end
end
end
Svc-->>BgSvc: done
```
Text alternative: Background timer triggers integrity service; for each non-Inactive slave: decrypts key, retrieves registered master URL, clears failure flag on match, re-registers on mismatch, sets LastIntegrityCheckFailedAt when slave is unreachable or re-registration fails.
@@ -0,0 +1,148 @@
# Business Rules — Unit 1: master-backend
## BR-01 — Status Update Decision Logic
```mermaid
graph TD
Start(["UpdateStatusAsync called"])
CheckExists{"Entity exists\nfor given id?"}
NotFound["Throw NotFoundException\n404 to caller"]
CheckMsg{"newStatus = NotAvailable\nAND disableMessage\nis null or empty?"}
ValidationErr["Throw ValidationException\nDisableMessage required"]
CheckInactive{"newStatus\n= Inactive?"}
SetInactive["Status = Inactive\nDisableMessage = null\nNo HTTP push\nSlaveContactSuccess = true"]
PersistStatus["Persist Status + DisableMessage\nto MasterDbContext"]
DecryptKey["Decrypt ApiKey\nvia IDataProtector"]
PushSlave["PushStatusAsync\nto slave endpoint"]
PushOk{"HTTP push\nsucceeded?"}
UpdatePushed["LastStatusPushedAt = UtcNow\nSave"]
ReturnOk["Return UpdateStatusResult\nSuccess=true\nSlaveContactSuccess=true"]
ReturnWarn["Return UpdateStatusResult\nSuccess=true\nSlaveContactSuccess=false"]
Done(["Return result to controller"])
Start --> CheckExists
CheckExists -->|"no"| NotFound
CheckExists -->|"yes"| CheckMsg
CheckMsg -->|"yes — invalid"| ValidationErr
CheckMsg -->|"no — valid"| CheckInactive
CheckInactive -->|"yes"| SetInactive --> Done
CheckInactive -->|"no"| PersistStatus --> DecryptKey --> PushSlave --> PushOk
PushOk -->|"yes"| UpdatePushed --> ReturnOk --> Done
PushOk -->|"no"| ReturnWarn --> Done
classDef decision fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
classDef action fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef terminal fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef error fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class CheckExists,CheckMsg,CheckInactive,PushOk decision
class PersistStatus,DecryptKey,PushSlave,UpdatePushed,SetInactive action
class Start,Done terminal
class NotFound,ValidationErr error
```
Text alternative: Load entity (404 if missing) → validate DisableMessage required for NotAvailable → for Inactive skip push → for others persist, decrypt key, push to slave, set SlaveContactSuccess based on push result.
---
## BR-02 — Integrity Check Decision Logic
```mermaid
graph TD
Start(["VerifyIntegrityAsync\nper instance"])
GetUrl["GetRegisteredMasterUrlAsync\n(slaveUrl, plainApiKey)"]
Reachable{"Slave\nreachable?"}
SetFailed["LastIntegrityCheckFailedAt = UtcNow\nSave — continue to next"]
UrlMatch{"registeredMasterUrl\n= expected masterUrl?"}
ClearOk["LastIntegrityCheckFailedAt = null\nLastContactedAt = UtcNow\nSave"]
ReRegister["RegisterMasterAsync\n(slaveUrl, plainApiKey, masterUrl)"]
RegOk{"Re-registration\nsucceeded?"}
ClearAfterReg["LastIntegrityCheckFailedAt = null\nLastContactedAt = UtcNow\nSave"]
SetFailedReg["LastIntegrityCheckFailedAt = UtcNow\nSave"]
Next(["Next instance"])
Start --> GetUrl --> Reachable
Reachable -->|"no"| SetFailed --> Next
Reachable -->|"yes"| UrlMatch
UrlMatch -->|"match"| ClearOk --> Next
UrlMatch -->|"mismatch"| ReRegister --> RegOk
RegOk -->|"yes"| ClearAfterReg --> Next
RegOk -->|"no"| SetFailedReg --> Next
classDef decision fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
classDef action fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef terminal fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef error fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class Reachable,UrlMatch,RegOk decision
class GetUrl,SetFailed,ClearOk,ReRegister,ClearAfterReg,SetFailedReg action
class Start,Next terminal
```
Text alternative: For each active slave — attempt to get its registered master URL; if unreachable set failure flag; if reachable and URL matches clear flag; if mismatch re-register; clear flag on success, set flag on failure.
---
## Validation Rules
| Rule | Field | Condition | Error |
|------|-------|-----------|-------|
| BR-VAL-01 | `Name` | Must not be null or whitespace | `Name is required` |
| BR-VAL-02 | `Url` | Must not be null or whitespace | `Url is required` |
| BR-VAL-03 | `Url` | Must start with `http://` or `https://` | `Url must be a valid absolute HTTP(S) URL` |
| BR-VAL-04 | `ApiKey` | Must not be null or whitespace (on create) | `ApiKey is required` |
| BR-VAL-05 | `DisableMessage` | Required (non-empty) when `Status = NotAvailable` | `DisableMessage is required when status is NotAvailable` |
| BR-VAL-06 | `Status` | Must be a valid `CmsInstanceStatus` enum value | `Invalid status value` |
| BR-VAL-07 | `id` (update) | CmsInstance with given id must exist | `CmsInstance not found` (404) |
---
## Status Transition Rules
| From | To | DisableMessage | HTTP Push | Notes |
|------|----|---------------|-----------|-------|
| Any | `Available` | Clear to null | Yes | Slave re-enabled |
| Any | `NotAvailable` | Required, non-empty | Yes | Slave disabled with message |
| Any | `Inactive` | Clear to null | **No** | Master stops all contact |
| `Inactive` | `Available` | Clear to null | Yes | Reactivation |
| `Inactive` | `NotAvailable` | Required, non-empty | Yes | Reactivation with disable |
---
## ApiKey Encryption Rules
| Rule | Description |
|------|-------------|
| BR-ENC-01 | `ApiKey` is encrypted via `IDataProtector` before writing to `MasterDbContext` |
| BR-ENC-02 | `ApiKey` is decrypted via `IDataProtector` immediately before each HTTP call requiring it |
| BR-ENC-03 | `ApiKey` is **never** included in `CmsInstanceDto` or any other API response |
| BR-ENC-04 | `ApiKey` is accepted in `CreateCmsInstanceRequest` on creation only; no update endpoint for ApiKey |
---
## Master URL Resolution Rules
| Context | Resolution Strategy |
|---------|-------------------|
| Controller-originated calls (Add) | Derive from `HttpContext.Request` scheme + host + (optional port) via `IHttpContextAccessor` |
| Background service calls (Integrity Check) | Read `MasterModuleOptions.MasterUrl` from configuration |
| `MasterModuleOptions.MasterUrl` is null in background context | Log a warning; skip registration/integrity for that cycle |
---
## HTTP Contact Exclusion Rules
| Rule | Description |
|------|-------------|
| BR-CONTACT-01 | Instances with `Status = Inactive` are excluded from `GetActiveAsync` and never contacted via HTTP |
| BR-CONTACT-02 | Status push is skipped when transitioning any status → `Inactive` |
| BR-CONTACT-03 | Integrity check runs only against instances where `Status != Inactive` |
---
## Background Service Rules
| Rule | Description |
|------|-------------|
| BR-BG-01 | `IntegrityCheckBackgroundService` resolves `ICmsInstanceService` via `IServiceScopeFactory` per tick (not injected directly, as service is Scoped) |
| BR-BG-02 | Each tick creates and disposes its own `IServiceScope` |
| BR-BG-03 | Exceptions within a single slave's integrity check are caught, logged, and do not abort processing for remaining slaves |
| BR-BG-04 | If `MasterModuleOptions.MasterUrl` is null or empty, the background service logs a warning and skips the entire integrity check for that cycle |
@@ -0,0 +1,132 @@
# Domain Entities — Unit 1: master-backend
## Entity Overview
```mermaid
graph TD
MasterDbCtx["MasterDbContext\n(per-module EF Core DbContext)"]
CmsInst["CmsInstance\n(aggregate root)"]
Status["CmsInstanceStatus\n(enum)"]
Opts["MasterModuleOptions\n(config POCO)"]
DP["IDataProtector\n(ApiKey encryption)"]
DTO["CmsInstanceDto\n(API response shape)"]
CreateReq["CreateCmsInstanceRequest\n(API input)"]
UpdateReq["UpdateStatusRequest\n(API input)"]
UpdateRes["UpdateStatusResult\n(API response for status update)"]
MasterDbCtx -->|"owns"| CmsInst
CmsInst -->|"has"| Status
CmsInst -->|"ApiKey encrypted via"| DP
CmsInst -->|"projected to"| DTO
CreateReq -->|"creates"| CmsInst
UpdateReq -->|"mutates status of"| CmsInst
UpdateRes -->|"returned from UpdateStatusAsync"| CmsInst
Opts -->|"IntegrityCheckIntervalMinutes"| MasterDbCtx
Opts -->|"MasterUrl fallback"| MasterDbCtx
classDef entity fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef infra fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef dto fill:#FFC107,stroke:#F57F17,stroke-width:1px,color:#000
classDef config fill:#CE93D8,stroke:#6A1B9A,stroke-width:1px,color:#000
class CmsInst entity
class MasterDbCtx,DP infra
class Status,DTO,CreateReq,UpdateReq,UpdateRes dto
class Opts config
```
Text alternative: MasterDbContext owns CmsInstance; CmsInstance has a Status enum and its ApiKey is encrypted via IDataProtector; request/response shapes map to and from CmsInstance.
---
## CmsInstance
**Table**: `CmsInstances` (owned by `MasterDbContext`, migrations in `SlpModularCms.Modules.Master`)
| Field | Type | Nullable | Notes |
|-------|------|----------|-------|
| `Id` | `Guid` | No | Primary key |
| `Name` | `string` | No | Friendly display name; required |
| `Url` | `string` | No | Base URL of slave CMS API; must start with `http://` or `https://` |
| `ApiKey` | `string` | No | Encrypted via ASP.NET Core Data Protection before storage; decrypted before HTTP calls |
| `Status` | `CmsInstanceStatus` | No | Default: `Available` on creation |
| `DisableMessage` | `string?` | Yes | Required when `Status = NotAvailable`; null otherwise |
| `LastContactedAt` | `DateTimeOffset?` | Yes | Null = never successfully contacted; set on successful registration or integrity check |
| `LastStatusPushedAt` | `DateTimeOffset?` | Yes | Null = status never successfully pushed; set after successful `PushStatusAsync` |
| `LastIntegrityCheckFailedAt` | `DateTimeOffset?` | Yes | Null = no pending failure; set when integrity check cannot reach slave or re-registration fails; cleared on next successful contact |
---
## CmsInstanceStatus
```csharp
public enum CmsInstanceStatus
{
Available = 0,
NotAvailable = 1,
Inactive = 2,
}
```
| Value | Meaning | Master Contacts Slave? |
|-------|---------|----------------------|
| `Available` | Slave is enabled; normal operation | Yes (status push + integrity checks) |
| `NotAvailable` | Slave is disabled; `DisableMessage` served to end-users | Yes (status push + integrity checks) |
| `Inactive` | Soft-removed; greyed out in UI | **No** — all HTTP contact is halted |
---
## MasterModuleOptions
**Config section**: `"MasterModule"` in `appsettings.json`
| Property | Type | Default | Side | Notes |
|----------|------|---------|------|-------|
| `IntegrityCheckIntervalMinutes` | `int` | `60` | Master | Interval for `IntegrityCheckBackgroundService` |
| `MasterUrl` | `string?` | `null` | Master | Fallback public URL of this master CMS; used by background service when `HttpContext` is unavailable |
| `CacheMinutes` | `int` | `60` | Slave | Slave pull cache interval (used by Unit 2) |
| `ApiKey` | `string?` | `null` | Slave | Slave API key for validating incoming master requests (used by Unit 2) |
---
## CmsInstanceDto (API Response)
**Rule**: `ApiKey` is **never** included (NFR-MASTER-03).
| Property | Type | Notes |
|----------|------|-------|
| `Id` | `Guid` | |
| `Name` | `string` | |
| `Url` | `string` | |
| `Status` | `string` | Serialized as string (`"Available"` / `"NotAvailable"` / `"Inactive"`) |
| `DisableMessage` | `string?` | |
| `LastContactedAt` | `DateTimeOffset?` | |
| `LastStatusPushedAt` | `DateTimeOffset?` | |
| `LastIntegrityCheckFailedAt` | `DateTimeOffset?` | Visible in UI so owner knows which slaves have pending check failures |
---
## CreateCmsInstanceRequest (API Input)
| Property | Type | Validation |
|----------|------|-----------|
| `Name` | `string` | Required, non-empty |
| `Url` | `string` | Required; must start with `http://` or `https://` |
| `ApiKey` | `string` | Required, non-empty |
---
## UpdateStatusRequest (API Input)
| Property | Type | Validation |
|----------|------|-----------|
| `Status` | `CmsInstanceStatus` | Required; must be valid enum value |
| `DisableMessage` | `string?` | Required and non-empty when `Status = NotAvailable`; ignored otherwise |
---
## UpdateStatusResult (Service Return / API Response)
| Property | Type | Notes |
|----------|------|-------|
| `Success` | `bool` | Always `true` when status persisted to DB (DB is the authority) |
| `SlaveContactSuccess` | `bool` | `true` if HTTP push to slave succeeded; `false` if push failed (slave unreachable); not applicable for `Inactive` transitions (returns `true`) |
@@ -0,0 +1,128 @@
# Logical Components — Unit 1: master-backend
## Full Component Wiring Diagram
```mermaid
graph TD
subgraph ServiceLayer["Service Layer"]
CmsService["CmsInstanceService"]
Deps["MasterServiceDependencies\n(constructor record)"]
Repo["ICmsInstanceRepository"]
SlaveClientIface["ISlaveApiClient"]
ApiKeyProt["IApiKeyProtector"]
HttpCtxAcc["IHttpContextAccessor"]
Logger["ILogger"]
Opts["MasterModuleOptions\n(via IOptions)"]
end
subgraph SecurityLayer["Security Layer"]
ApiKeyProtImpl["ApiKeyProtector"]
DataProt["IDataProtectionProvider\n(ASP.NET Core)"]
Purpose["Purpose string\nSlpModularCms.Master.ApiKey"]
end
subgraph HttpLayer["HTTP + Resilience Layer"]
SlaveClientImpl["SlaveApiClient"]
PollyPipeline["Polly ResiliencePipeline\nRetry x2 + Timeout"]
HttpClientInst["HttpClient\n(IHttpClientFactory)"]
end
subgraph BackgroundLayer["Background Service"]
BgSvc["IntegrityCheckBackgroundService"]
ScopeFactory["IServiceScopeFactory"]
Scope["IServiceScope\n(per tick)"]
PeriodicT["PeriodicTimer\n(IntegrityCheckIntervalMinutes)"]
end
subgraph DataLayer["Data Layer"]
RepoImpl["CmsInstanceRepository"]
DbCtx["MasterDbContext"]
Table["CmsInstances table"]
end
CmsService --> Deps
Deps --> Repo
Deps --> SlaveClientIface
Deps --> ApiKeyProt
Deps --> HttpCtxAcc
Deps --> Logger
Deps --> Opts
ApiKeyProt --> ApiKeyProtImpl
ApiKeyProtImpl --> DataProt
DataProt --> Purpose
SlaveClientIface --> SlaveClientImpl
SlaveClientImpl --> PollyPipeline
PollyPipeline --> HttpClientInst
Repo --> RepoImpl
RepoImpl --> DbCtx
DbCtx --> Table
BgSvc --> PeriodicT
BgSvc --> ScopeFactory
ScopeFactory --> Scope
Scope --> CmsService
classDef service fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef security fill:#FC8181,stroke:#C53030,stroke-width:1px,color:#000
classDef http fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef background fill:#CE93D8,stroke:#6A1B9A,stroke-width:1px,color:#000
classDef data fill:#FFC107,stroke:#F57F17,stroke-width:1px,color:#000
classDef record fill:#B0BEC5,stroke:#546E7A,stroke-width:1px,color:#000
class CmsService,Repo,SlaveClientIface,ApiKeyProt service
class Deps record
class ApiKeyProtImpl,DataProt,Purpose security
class SlaveClientImpl,PollyPipeline,HttpClientInst,HttpCtxAcc http
class BgSvc,ScopeFactory,Scope,PeriodicT background
class RepoImpl,DbCtx,Table,Logger,Opts data
```
Text alternative: CmsInstanceService receives all dependencies via MasterServiceDependencies record; IApiKeyProtector wraps Data Protection; ISlaveApiClient wraps SlaveApiClient backed by Polly pipeline; ICmsInstanceRepository wraps MasterDbContext; IntegrityCheckBackgroundService creates a new IServiceScope per PeriodicTimer tick to resolve CmsInstanceService.
---
## Component Responsibility Summary
| Component | Type | NFR Pattern |
|-----------|------|------------|
| `MasterServiceDependencies` | Record | Constructor aggregation (reduces constructor arity) |
| `IApiKeyProtector` / `ApiKeyProtector` | Interface + Singleton | Security — Data Protection wrapper; mock-friendly |
| `SlaveApiClient` | Typed HTTP client | Resilience — Polly retry + timeout applied via `AddResilienceHandler` |
| `IntegrityCheckBackgroundService` | Singleton `BackgroundService` | Reliability — per-tick `IServiceScope`; exception isolation per slave |
| `MasterDbContext` | EF Core DbContext | Maintainability — per-module migrations; own connection |
| `CmsInstanceService` | Scoped service | Orchestration — resolved via `IServiceScope` by background service |
---
## DI Registration Order (in `MasterModule.RegisterServices`)
```
1. services.AddDataProtection()
2. services.AddSingleton<IApiKeyProtector, ApiKeyProtector>()
3. services.Configure<MasterModuleOptions>(config.GetSection("MasterModule"))
4. services.AddDbContext<MasterDbContext>(...)
5. services.AddScoped<ICmsInstanceRepository, CmsInstanceRepository>()
6. services.AddScoped<MasterServiceDependencies>()
7. services.AddScoped<ICmsInstanceService, CmsInstanceService>()
8. services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()
.AddResilienceHandler("slave-resilience", ...)
9. services.AddHostedService<IntegrityCheckBackgroundService>()
10. services.AddHttpContextAccessor() (if not already registered by host)
```
---
## NFR Coverage Traceability
| NFR | Pattern Applied | Component |
|-----|----------------|-----------|
| Fail-open (NFR-MASTER-01) | `SlaveApiClient` catches failures, returns `false`; service continues | `SlaveApiClient`, `CmsInstanceService` |
| API key security (NFR-MASTER-03) | `IApiKeyProtector` wraps Data Protection; never returns key in DTO | `ApiKeyProtector`, `CmsInstanceDto` mapping |
| Configurable interval (NFR-MASTER-04) | `PeriodicTimer` reads `MasterModuleOptions.IntegrityCheckIntervalMinutes` | `IntegrityCheckBackgroundService` |
| ≥80% test coverage (NFR-MASTER-05) | All service/repository/client classes have interfaces; `MasterServiceDependencies` simplifies test setup | All interfaces |
| Per-module migrations (NFR-MASTER-06) | `MasterDbContext` with own migration assembly; applied in `UseModule` | `MasterDbContext`, `MasterModule` |
| Retry resilience (Q2) | Polly exponential backoff on `IHttpClientBuilder` | `SlaveApiClient` registration |
| Timeout (Q1) | Polly `AddTimeout` per attempt, driven by `HttpTimeoutSeconds` | `SlaveApiClient` registration |
| Logging levels (Q5) | `Error` for status push failures; `Warning` for integrity check failures | `CmsInstanceService`, `IntegrityCheckBackgroundService` |
@@ -0,0 +1,208 @@
# NFR Design Patterns — Unit 1: master-backend
## Pattern 1 — Resilience: Polly Pipeline via `AddResilienceHandler`
**NFR**: Exponential backoff (3 attempts), per-attempt timeout (`HttpTimeoutSeconds`)
**Pattern**: Single shared resilience pipeline registered on the `IHttpClientBuilder` for `SlaveApiClient`. All HTTP calls from `SlaveApiClient` pass through the pipeline automatically — no per-method boilerplate.
**Pipeline composition** (outer → inner execution order):
1. **Retry**`AddRetry` with exponential backoff; max 2 retries (3 total attempts); base delay 1s → 2s with jitter; retries on `HttpRequestException` and non-2xx responses
2. **Timeout**`AddTimeout` with `TimeSpan.FromSeconds(MasterModuleOptions.HttpTimeoutSeconds)`; applied per attempt (not total)
**Registration** (in `MasterModule.RegisterServices`):
```csharp
services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()
.AddResilienceHandler("slave-resilience", (builder, ctx) =>
{
var opts = ctx.ServiceProvider
.GetRequiredService<IOptions<MasterModuleOptions>>().Value;
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
ShouldHandle = args => ValueTask.FromResult(
args.Outcome.Exception is HttpRequestException ||
(args.Outcome.Result?.IsSuccessStatusCode == false))
});
builder.AddTimeout(TimeSpan.FromSeconds(opts.HttpTimeoutSeconds));
});
```
**Retry flow**:
```mermaid
graph TD
Call["SlaveApiClient HTTP call"]
Attempt["Execute HTTP request\n(with per-attempt timeout)"]
Success{"Response\nsuccessful?"}
ReturnOk["Return result"]
MaxReached{"Max attempts\n(3) reached?"}
Backoff["Wait exponential delay\n1s or 2s plus jitter"]
ReturnFail["Return false\nor throw on final attempt"]
Call --> Attempt --> Success
Success -->|"yes"| ReturnOk
Success -->|"no"| MaxReached
MaxReached -->|"yes"| ReturnFail
MaxReached -->|"no"| Backoff --> Attempt
classDef decision fill:#FFC107,stroke:#F57F17,stroke-width:2px,color:#000
classDef action fill:#63b3ed,stroke:#2b6cb0,stroke-width:1px,color:#000
classDef terminal fill:#9ae6b4,stroke:#2f855a,stroke-width:2px,color:#000
classDef fail fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class Success,MaxReached decision
class Call,Attempt,Backoff action
class ReturnOk terminal
class ReturnFail fail
```
Text alternative: HTTP call enters pipeline; per-attempt timeout applies; on failure checks if max attempts reached; if not waits exponential delay and retries; after 3 total failures returns false.
---
## Pattern 2 — Security: `IApiKeyProtector` Wrapper
**NFR**: ApiKey encrypted at rest; decrypted only for HTTP calls; never exposed in responses
**Pattern**: Thin wrapper interface over ASP.NET Core Data Protection. Keeps `CmsInstanceService` independent of Data Protection internals and makes unit tests trivially simple (mock returns plain strings).
**Interface**:
```csharp
public interface IApiKeyProtector
{
string Protect(string plainApiKey);
string Unprotect(string encryptedApiKey);
}
```
**Implementation**:
```csharp
public class ApiKeyProtector : IApiKeyProtector
{
private readonly IDataProtector _protector;
public ApiKeyProtector(IDataProtectionProvider provider)
{
_protector = provider.CreateProtector("SlpModularCms.Master.ApiKey");
}
public string Protect(string plainApiKey) => _protector.Protect(plainApiKey);
public string Unprotect(string encrypted) => _protector.Unprotect(encrypted);
}
```
**Registration** (in `MasterModule.RegisterServices`):
```csharp
services.AddDataProtection();
services.AddSingleton<IApiKeyProtector, ApiKeyProtector>();
```
**Usage in tests**:
```csharp
var protector = new Mock<IApiKeyProtector>();
protector.Setup(p => p.Protect(It.IsAny<string>())).Returns((string s) => $"enc:{s}");
protector.Setup(p => p.Unprotect(It.IsAny<string>())).Returns((string s) => s.Replace("enc:", ""));
```
---
## Pattern 3 — Constructor Aggregation: `MasterServiceDependencies`
**Rationale**: `CmsInstanceService` requires 6 dependencies. Wrapping them in a record removes constructor noise and groups related parameters semantically.
**Record definition**:
```csharp
public record MasterServiceDependencies(
ICmsInstanceRepository Repository,
ISlaveApiClient SlaveClient,
IApiKeyProtector ApiKeyProtector,
IOptions<MasterModuleOptions> Options,
IHttpContextAccessor HttpContextAccessor,
ILogger<CmsInstanceService> Logger
);
```
**Registration** (framework resolves all fields automatically):
```csharp
services.AddScoped<MasterServiceDependencies>();
services.AddScoped<ICmsInstanceService, CmsInstanceService>();
```
**`CmsInstanceService` constructor**:
```csharp
public CmsInstanceService(MasterServiceDependencies deps)
{
_deps = deps;
}
```
**Test construction** (explicit, no DI container needed):
```csharp
var deps = new MasterServiceDependencies(
mockRepo.Object,
mockSlaveClient.Object,
mockProtector.Object,
Options.Create(new MasterModuleOptions()),
mockHttpContextAccessor.Object,
NullLogger<CmsInstanceService>.Instance
);
var svc = new CmsInstanceService(deps);
```
---
## Pattern 4 — Background Service Scope Isolation
**NFR**: `ICmsInstanceService` is Scoped; `IntegrityCheckBackgroundService` is Singleton
**Pattern**: Create and dispose a dedicated `IServiceScope` per tick. No singleton scope leakage.
```csharp
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using var timer = new PeriodicTimer(
TimeSpan.FromMinutes(_options.Value.IntegrityCheckIntervalMinutes));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
await using var scope = _scopeFactory.CreateAsyncScope();
try
{
var svc = scope.ServiceProvider
.GetRequiredService<ICmsInstanceService>();
await svc.VerifyIntegrityAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Unexpected error during integrity check tick");
}
}
}
```
---
## Pattern 5 — Structured Logging
**Pattern**: Log with structured fields; never log the raw `ApiKey` value.
| Scenario | Level | Fields |
|----------|-------|--------|
| Status push failed | `Error` | `{InstanceId}`, `{SlaveUrl}`, `{Exception}` |
| Integrity check: slave unreachable | `Warning` | `{InstanceId}`, `{SlaveUrl}` |
| Integrity check: URL mismatch | `Warning` | `{InstanceId}`, `{SlaveUrl}`, `{ExpectedUrl}`, `{RegisteredUrl}` |
| Integrity check: re-registration ok | `Information` | `{InstanceId}`, `{SlaveUrl}` |
| Integrity check: re-registration failed | `Warning` | `{InstanceId}`, `{SlaveUrl}` |
| ApiKey decryption failure | `Error` | `{InstanceId}` — do NOT log key material |
| Tick started | `Debug` | `{ActiveInstanceCount}` |
**Example**:
```csharp
_logger.LogWarning(
"Slave {InstanceId} at {SlaveUrl} is unreachable during integrity check",
instance.Id, instance.Url);
```
@@ -0,0 +1,77 @@
# NFR Requirements — Unit 1: master-backend
## Performance
| Requirement | Specification | Source |
|-------------|--------------|--------|
| HTTP timeout for slave calls | Configurable via `MasterModuleOptions.HttpTimeoutSeconds`; default **10 seconds** | Q1 |
| HTTP retry budget | Maximum 3 attempts (initial + 2 retries) with exponential backoff (1s, 2s); per-attempt timeout applies | Q2 |
| Background service interval | Configurable via `MasterModuleOptions.IntegrityCheckIntervalMinutes`; default 60 minutes | NFR-MASTER-04 |
| Controller endpoint latency | No explicit SLA; bounded by HTTP timeout × retry attempts (worst case ~33s for a single unresponsive slave during Add/UpdateStatus) | derived |
---
## Security
| Requirement | Specification | Source |
|-------------|--------------|--------|
| ApiKey at-rest encryption | Encrypted with ASP.NET Core Data Protection before writing to DB; decrypted immediately before HTTP calls | Q3 (+ functional design) |
| Data Protection key storage | **Default file system** (platform default); machine-bound; acceptable for single-instance deployment | Q3 |
| ApiKey exposure | Never included in `CmsInstanceDto` or any API response; `[JsonIgnore]` or explicit DTO mapping | NFR-MASTER-03 |
| Endpoint authorization | All `CmsInstanceController` actions require `[Authorize(Policy = "OwnerOnly")]` | FR-MASTER-10 |
| Internal slave endpoint auth | `POST /api/internal/master/register` validated via `X-Master-Api-Key` header (Unit 2 concern) | FR-MASTER-03 |
---
## Reliability
| Requirement | Specification | Source |
|-------------|--------------|--------|
| Fail-open on slave unreachability | Status push failure returns `SlaveContactSuccess = false` but does not roll back DB change; integrity check sets `LastIntegrityCheckFailedAt` and continues | NFR-MASTER-01 |
| Retry policy | Exponential backoff: attempt 1 (immediate), attempt 2 (+1s delay), attempt 3 (+2s delay); implemented via Polly `ResiliencePipeline` | Q2 |
| Background service isolation | Exceptions per slave instance are caught, logged, and do not abort the full integrity check batch | BR-BG-03 |
| Background service scope | `IServiceScopeFactory` used per tick to resolve scoped `ICmsInstanceService`; scope disposed after each tick | BR-BG-01/02 |
---
## Testability
| Requirement | Specification | Source |
|-------------|--------------|--------|
| Minimum test coverage | ≥ 80% line/branch coverage for `SlpModularCms.Modules.Master` (excluding items below) | NFR-MASTER-05 |
| Coverage exclusions | Apply `[ExcludeFromCodeCoverage]` to: `MasterModule.cs` (IModule boilerplate), EF Core migration files, plain DTO/record classes with no logic | Q4 |
| Test project | `SlpModularCms.Modules.Master.Tests` — separate project; mirrors production project structure | Unit decomposition decision |
| Key test targets | `CmsInstanceService`, `SlaveApiClient`, `IntegrityCheckBackgroundService`, `CmsInstanceController` | NFR-MASTER-05 |
| Interface-driven design | `ICmsInstanceRepository`, `ICmsInstanceService`, `ISlaveApiClient` interfaces required to enable unit test mocking | derived |
---
## Maintainability
| Requirement | Specification | Source |
|-------------|--------------|--------|
| Log level — integrity check failures | **Warning** — slave unreachability during background checks is expected; does not require immediate attention | Q5 |
| Log level — status push failures | **Error** — owner-triggered action failed to reach slave; requires visibility | Q5 |
| Log level — re-registration on mismatch | **Information** — expected recovery action | derived |
| Log level — background service tick | **Debug** — high frequency; only visible when debugging | derived |
| Structured logging | Use `ILogger<T>` with structured message templates; include `slaveUrl` and `instanceId` in log scope | derived |
---
## Updated `MasterModuleOptions` Fields
The following field is added as a result of Q1:
| Property | Type | Default | Notes |
|----------|------|---------|-------|
| `HttpTimeoutSeconds` | `int` | `10` | Timeout applied to each individual HTTP attempt in `SlaveApiClient` |
Full updated options shape:
| Property | Type | Default | Side |
|----------|------|---------|------|
| `IntegrityCheckIntervalMinutes` | `int` | `60` | Master |
| `HttpTimeoutSeconds` | `int` | `10` | Master |
| `MasterUrl` | `string?` | `null` | Master |
| `CacheMinutes` | `int` | `60` | Slave |
| `ApiKey` | `string?` | `null` | Slave |
@@ -0,0 +1,112 @@
# Tech Stack Decisions — Unit 1: master-backend
## HTTP Client & Resilience
### Decision: Typed HTTP Client via `IHttpClientFactory` + Polly
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| HTTP client abstraction | `ISlaveApiClient` / `SlaveApiClient` typed client | Testable via mock injection; clean contract boundary |
| Client registration | `services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()` | Framework manages `HttpClient` lifetime and connection pooling |
| Retry policy | **Polly** `ResiliencePipelineBuilder` with `AddRetry` | Industry standard .NET resilience library; integrates natively with `IHttpClientFactory` via `AddResilienceHandler` |
| Retry configuration | 3 total attempts; delays: 1s → 2s (exponential); jitter optional | Bounded worst-case latency; exponential reduces thundering herd on widespread slave outages |
| Per-attempt timeout | `MasterModuleOptions.HttpTimeoutSeconds` (default 10s) | Configurable per-environment; keeps controller responses bounded |
**NuGet package required**: `Microsoft.Extensions.Http.Resilience` (includes Polly integration)
**Registration pattern**:
```csharp
services.AddHttpClient<ISlaveApiClient, SlaveApiClient>()
.AddResilienceHandler("slave-retry", builder =>
{
builder.AddRetry(new HttpRetryStrategyOptions
{
MaxRetryAttempts = 2,
Delay = TimeSpan.FromSeconds(1),
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
});
builder.AddTimeout(TimeSpan.FromSeconds(options.HttpTimeoutSeconds));
});
```
---
## Data Protection
### Decision: Default ASP.NET Core Data Protection (file system)
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| Key storage | Default file system (no explicit `PersistKeysTo*` call) | Zero configuration; acceptable for single-instance; owner manages production key persistence |
| Purpose string | `"SlpModularCms.Master.ApiKey"` | Scoped protection; prevents cross-purpose decryption |
| Registration | `services.AddDataProtection()` (already called by framework if not explicitly called) | No extra setup needed beyond injecting `IDataProtectionProvider` |
**Production note** (to be included in Unit 4 README update): For containerized or multi-instance deployments, configure a persistent key ring (e.g., `PersistKeysToDbContext`, `PersistKeysToAzureBlobStorage`). Without it, restarting the container causes all encrypted `ApiKey` values to become unreadable.
---
## Background Service
### Decision: .NET `BackgroundService` + `PeriodicTimer`
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| Base class | `BackgroundService` | Built-in .NET hosted service; lifecycle managed by `IHostApplicationLifetime` |
| Timer mechanism | `PeriodicTimer` | Allocates less than `Timer`; await-friendly; cancellation-aware |
| Scope management | `IServiceScopeFactory.CreateScope()` per tick | Required because `ICmsInstanceService` is Scoped; prevents captive dependency |
| Exception handling | `try/catch` around entire tick body; log `Error` and continue | Prevents background service crash on unexpected errors |
---
## Logging
### Decision: `ILogger<T>` structured logging
| Scenario | Log Level | Structured Fields |
|----------|-----------|------------------|
| Status push failed (slave unreachable) | `Error` | `instanceId`, `slaveUrl`, `exception` |
| Integrity check: slave unreachable | `Warning` | `instanceId`, `slaveUrl` |
| Integrity check: URL mismatch detected | `Warning` | `instanceId`, `slaveUrl`, `expectedUrl`, `registeredUrl` |
| Integrity check: re-registration succeeded | `Information` | `instanceId`, `slaveUrl` |
| Integrity check: re-registration failed | `Warning` | `instanceId`, `slaveUrl` |
| Background service tick started | `Debug` | `instanceCount` |
| ApiKey decryption failed | `Error` | `instanceId` (do NOT log the key itself) |
---
## EF Core / Database
### Decision: Per-module `MasterDbContext` with own migrations
| Aspect | Decision |
|--------|----------|
| DbContext class | `MasterDbContext : DbContext` in `SlpModularCms.Modules.Master` |
| Migration assembly | `SlpModularCms.Modules.Master` (same project) |
| Migration application | `app.ApplicationServices.CreateScope()``MasterDbContext.Database.MigrateAsync()` in `MasterModule.UseModule(IApplicationBuilder)` |
| Tables owned | `CmsInstances`, `DataProtectionKeys` (if needed in future) |
| Connection string | Reuses the same connection string as `ApplicationDbContext` (from `ConnectionStrings:DefaultConnection`) |
---
## Test Framework
### Decision: xUnit + Moq (matching existing test projects)
| Aspect | Decision | Rationale |
|--------|----------|-----------|
| Test framework | xUnit | Matches existing `Availability.Tests` project |
| Mocking | Moq | Matches existing test projects |
| Coverage tool | coverlet (via `.runsettings` or `dotnet test --collect`) | Already in use in existing test projects |
| `[ExcludeFromCodeCoverage]` targets | `MasterModule`, EF Core migration files, DTO records | Q4 decision |
| HTTP testing | Mock `ISlaveApiClient` via Moq | Typed client interface enables clean mocking without `HttpMessageHandler` fakes |
---
## Summary of New Dependencies
| Package | Version | Purpose |
|---------|---------|---------|
| `Microsoft.Extensions.Http.Resilience` | Latest stable | Polly integration for `IHttpClientFactory` retry policies |
All other dependencies (EF Core, ASP.NET Core, xUnit, Moq) are already present in the solution.
@@ -0,0 +1,60 @@
# Functional Design Questions — Unit 3: frontend-cms-page
Please answer each question by filling in the letter after the `[Answer]:` tag.
If none of the options match, choose the last option (Other) and describe your preference.
---
## Question 1
Which columns should the `CmsInstanceList` table display?
A) Name, URL, Status
B) Name, URL, Status, Last Contact (`lastContactedAt`)
C) Name, URL, Status, DisableMessage
D) Other (please describe after [Answer]: tag below)
[Answer]: B + C
---
## Question 2
"Inactive rows greyed out" — what exactly?
A) The entire `<TableRow>` gets `opacity-50` (everything fades)
B) All `<TableCell>` text gets `text-muted-foreground` class (subtler dimming)
C) Other (please describe after [Answer]: tag below)
[Answer]: A
---
## Question 3
When the status is updated, the backend pushes to the slave and reports whether it was reachable (`UpdateStatusResult`). What does the frontend show?
A) Always a generic success toast on HTTP 200, regardless of slave details
B) Toast differs: "Status updated — slave confirmed" vs "Status saved, slave unreachable"
C) Other (please describe after [Answer]: tag below)
[Answer]: B, but don't call it slave. I'd rather see the term "cliënt"
---
## Question 4
The backend returns HTTP 400 for invalid data in `AddCmsInstanceDialog`. What does the UI show?
A) Banner error at the top of the dialog (`FormBannerError` pattern, as in LoginPage)
B) Toast on failed submit
C) Other (please describe after [Answer]: tag below)
[Answer]: A
---
## Question 5
When there are no CMS instances yet (empty state):
A) Centered placeholder with icon + "Add your first CMS" message and an Add button
B) Empty table (the Add button in the page header is sufficient)
C) Other (please describe after [Answer]: tag below)
[Answer]: A
@@ -0,0 +1,52 @@
# Functional Design Plan — Unit 3: frontend-cms-page
## Scope
**Modify**: `frontend/src/` (existing React SPA, extended)
**Questions file**: `frontend-cms-page-fd-questions.md`
---
## Steps
### Part A — TypeScript Types
- [ ] **Step 1**`src/api/types.ts` — add `CmsInstance` interface and `CmsInstanceStatus` type
### Part B — TanStack Query Hooks
- [ ] **Step 2**`src/api/useCmsInstances.ts``GET /api/v1/CmsInstances`
- [ ] **Step 3**`src/api/useAddCmsInstance.ts``POST /api/v1/CmsInstances`
- [ ] **Step 4**`src/api/useUpdateCmsInstanceStatus.ts``PUT /api/v1/CmsInstances/{id}/status`
### Part C — Components
- [ ] **Step 5**`src/components/cms/CmsInstanceList.tsx` — table with status badges; Inactive rows styled per Q2
- [ ] **Step 6**`src/components/cms/AddCmsInstanceDialog.tsx` — modal form: Name, URL, ApiKey; error handling per Q4
- [ ] **Step 7**`src/components/cms/SetStatusDialog.tsx` — status dropdown + conditional DisableMessage (mandatory when NotAvailable)
### Part D — Page
- [ ] **Step 8**`src/pages/CmsPage.tsx` — replace placeholder; header + Add button; empty state per Q5; columns per Q1; UpdateStatusResult per Q3
### Part E — i18n
- [ ] **Step 9**`src/i18n/locales/nl/translation.json` — add `cms.*` keys
- [ ] **Step 10**`src/i18n/locales/en/translation.json` — add `cms.*` keys
### Part F — MSW Mocks
- [ ] **Step 11**`src/mocks/cms/handlers.ts` — GET, POST, PUT handlers with in-memory state
- [ ] **Step 12**`src/mocks/browser.ts` + `src/mocks/server.ts` — register cms handlers
### Part G — Tests
- [ ] **Step 13**`src/api/useCmsInstances.test.ts` — GET hook
- [ ] **Step 14**`src/api/useAddCmsInstance.test.ts` — POST mutation + cache invalidation
- [ ] **Step 15**`src/api/useUpdateCmsInstanceStatus.test.ts` — PUT mutation + cache invalidation
- [ ] **Step 16**`src/pages/CmsPage.test.tsx` — renders list, Add button, empty state, Owner-only guard
---
*Answers to design questions: see `frontend-cms-page-fd-questions.md`*
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-functional-design-plan.md`
@@ -0,0 +1,51 @@
# NFR Requirements Questions — Unit 3: frontend-cms-page
Please answer each question by filling in the letter after the `[Answer]:` tag.
If none of the options match, choose the last option (Other) and describe your preference.
---
## Question 1
How strict should the URL validation be for the `url` field in `AddCmsInstanceDialog`?
Context: `z.string().url()` (Zod strict) rejects bare IPs without protocol (`192.168.1.5:8080`) and rejects `localhost:5000`. CMS slave instances may live on a local network or development machine.
A) **Strict**`z.string().url()`. Enforces a valid HTTP/HTTPS URL. User must enter `http://192.168.1.5:8080`. Backend already validates anyway; this prevents obvious typos.
B) **Lenient**`z.string().min(1)`. Any non-empty string is accepted. Backend is the authoritative validator; client only ensures the field is not empty.
C) **Custom** — Must start with `http://` or `https://`, but the rest is not validated (`z.string().regex(/^https?:\/\//)`). Prevents protocol-less entries without being as strict as full URL parsing.
D) Other (please describe after [Answer]: tag below)
[Answer]:
---
## Question 2
What test scope applies to the new components and hooks?
A) **Page integration only**`CmsPage.test.tsx` tests the main flows end-to-end (load list, open Add dialog, set status). Individual components are not tested separately. Keeps the test suite lean.
B) **Hooks + page integration** — Dedicated tests for `useCmsInstances`, `useAddCmsInstance`, `useUpdateCmsInstanceStatus` using `renderHook` + MSW. Page integration test covers the UI flows. Consistent with existing `useUsers` pattern.
C) **Hooks + page + component tests** — In addition to B, dedicated tests for `AddCmsInstanceDialog` and `SetStatusDialog` (error state, DisableMessage conditional, etc.). Matches the `InviteUserDialog.test.tsx` precedent in this project.
D) Other (please describe after [Answer]: tag below)
[Answer]:
---
## Question 3
How should the `ApiKey` input field behave in `AddCmsInstanceDialog`?
The user copies the API key from the slave CMS admin panel and pastes it here. It is an infrastructure credential, not a user password.
A) **`type="password"`** — Hidden by default. Safe against shoulder surfing. The existing `PasswordField` component with show/hide toggle can be reused.
B) **`type="text"`** — Visible. Easier to verify the pasted value is correct. Acceptable since this is a one-time setup action performed by an Owner in a secure context.
C) Other (please describe after [Answer]: tag below)
[Answer]:
@@ -0,0 +1,22 @@
# NFR Requirements Plan — Unit 3: frontend-cms-page
## Unit Context
**Unit**: `frontend-cms-page`
**Inputs**: domain-entities.md, business-logic-model.md, business-rules.md, frontend-components.md
**Key NFR concerns**: form validation strategy, test scope, URL validation
---
## Execution Steps
- [ ] **Step 1** — Analyze answers; flag ambiguities
- [ ] **Step 2** — Generate `nfr-requirements.md`
- [ ] **Step 3** — Generate `tech-stack-decisions.md`
- [ ] **Step 4** — Update `aidlc-state.md`
- [ ] **Step 5** — Present completion message for approval
---
*Questions file*: `frontend-cms-page-nfr-questions.md`
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/frontend-cms-page-nfr-requirements-plan.md`
@@ -0,0 +1,207 @@
# Code Generation Plan — Unit 1: master-backend
## Unit Context
**Unit**: `master-backend`
**Workspace root**: `K:\Development\Projects\SlpModularCms`
**Project type**: Brownfield — new project added to existing solution
**New projects**:
- `src/SlpModularCms.Modules.Master/` (application code)
- `src/SlpModularCms.Modules.Master.Tests/` (test code)
**Modified files**:
- `src/SlpModularCms.Api/SlpModularCms.Api.csproj` — add project reference to Master module
- `SlpModularCms.sln` — add both new projects
**Test stack** (matching existing projects): xUnit, NSubstitute, FluentAssertions, EF Core InMemory
**New NuGet dependency**: `Microsoft.Extensions.Http.Resilience` (Polly integration)
**Requirements covered**: FR-MASTER-01/02/03/04/05/10/12/13/14; NFR-MASTER-03/04/05/06
---
## Generation Steps
### STEP 1 — Project files & solution wiring
- [x] 1a. Create `src/SlpModularCms.Modules.Master/SlpModularCms.Modules.Master.csproj`
- Target: `net10.0`; Nullable + ImplicitUsings enabled
- ProjectReference: `SlpModularCms.Core`
- PackageReference: `Microsoft.Extensions.Http.Resilience` (latest stable)
- [x] 1b. Create `src/SlpModularCms.Modules.Master.Tests/SlpModularCms.Modules.Master.Tests.csproj`
- Target: `net10.0`; `IsPackable = false`
- PackageReference: `xunit`, `xunit.runner.visualstudio`, `Microsoft.NET.Test.Sdk`, `coverlet.collector`, `NSubstitute`, `FluentAssertions`, `Microsoft.EntityFrameworkCore.InMemory`, `Microsoft.Extensions.Logging.Abstractions`
- ProjectReference: `SlpModularCms.Modules.Master`
- Global using: `Xunit`
- [x] 1c. Modify `src/SlpModularCms.Api/SlpModularCms.Api.csproj` — add `<ProjectReference Include="..\SlpModularCms.Modules.Master\SlpModularCms.Modules.Master.csproj" />`
- [x] 1d. Modify `SlpModularCms.sln` — add both new projects with correct GUIDs and folder placement
### STEP 2 — Configuration options
- [x] 2a. Create `src/SlpModularCms.Modules.Master/Options/MasterModuleOptions.cs`
- Properties: `IntegrityCheckIntervalMinutes` (int, 60), `HttpTimeoutSeconds` (int, 10), `MasterUrl` (string?), `CacheMinutes` (int, 60), `ApiKey` (string?)
### STEP 3 — Domain entities
- [x] 3a. Create `src/SlpModularCms.Modules.Master/Data/Entities/CmsInstanceStatus.cs`
- Enum: `Available = 0`, `NotAvailable = 1`, `Inactive = 2`
- [x] 3b. Create `src/SlpModularCms.Modules.Master/Data/Entities/CmsInstance.cs`
- All fields per domain-entities.md (including `LastIntegrityCheckFailedAt`)
- `[ExcludeFromCodeCoverage]` NOT applied — entity has no logic; covered by service tests
### STEP 4 — DTOs and request/response models
- [x] 4a. Create `src/SlpModularCms.Modules.Master/Models/CmsInstanceDto.cs``[ExcludeFromCodeCoverage]`
- [x] 4b. Create `src/SlpModularCms.Modules.Master/Models/CreateCmsInstanceRequest.cs``[ExcludeFromCodeCoverage]`
- [x] 4c. Create `src/SlpModularCms.Modules.Master/Models/UpdateStatusRequest.cs``[ExcludeFromCodeCoverage]`
- [x] 4d. Create `src/SlpModularCms.Modules.Master/Models/UpdateStatusResult.cs``[ExcludeFromCodeCoverage]`
### STEP 5 — EF Core DbContext
- [x] 5a. Create `src/SlpModularCms.Modules.Master/Data/MasterDbContext.cs`
- `DbSet<CmsInstance> CmsInstances`
- `OnModelCreating`: configure entity (table name, required fields, max lengths)
- Constructor: accepts `DbContextOptions<MasterDbContext>`
### STEP 6 — Repository
- [x] 6a. Create `src/SlpModularCms.Modules.Master/Repositories/ICmsInstanceRepository.cs`
- `GetAllAsync`, `GetActiveAsync`, `GetByIdAsync`, `AddAsync`, `UpdateAsync`, `SaveChangesAsync`
- [x] 6b. Create `src/SlpModularCms.Modules.Master/Repositories/CmsInstanceRepository.cs`
- Inject `MasterDbContext`; implement all methods
- `GetActiveAsync` filters `Status != CmsInstanceStatus.Inactive`
### STEP 7 — Security: ApiKeyProtector
- [x] 7a. Create `src/SlpModularCms.Modules.Master/Services/IApiKeyProtector.cs`
- `string Protect(string plainApiKey)`
- `string Unprotect(string encryptedApiKey)`
- [x] 7b. Create `src/SlpModularCms.Modules.Master/Services/ApiKeyProtector.cs`
- Inject `IDataProtectionProvider`; purpose string `"SlpModularCms.Master.ApiKey"`
- `[ExcludeFromCodeCoverage]` NOT applied — testable via unit test with real `EphemeralDataProtectionProvider`
### STEP 8 — HTTP client: SlaveApiClient
- [x] 8a. Create `src/SlpModularCms.Modules.Master/Services/ISlaveApiClient.cs`
- `RegisterMasterAsync`, `PushStatusAsync`, `GetRegisteredMasterUrlAsync`
- [x] 8b. Create `src/SlpModularCms.Modules.Master/Services/SlaveApiClient.cs`
- Inject `HttpClient` (typed client)
- Each method: build request with `X-Master-Api-Key` header; handle non-success responses; return `false` / `null` on failure
- JSON serialization: `System.Text.Json` (consistent with project)
### STEP 9 — Service dependencies record
- [x] 9a. Create `src/SlpModularCms.Modules.Master/Services/MasterServiceDependencies.cs`
- Record with 6 properties: `ICmsInstanceRepository`, `ISlaveApiClient`, `IApiKeyProtector`, `IOptions<MasterModuleOptions>`, `IHttpContextAccessor`, `ILogger<CmsInstanceService>`
- `[ExcludeFromCodeCoverage]`
### STEP 10 — CmsInstanceService
- [x] 10a. Create `src/SlpModularCms.Modules.Master/Services/ICmsInstanceService.cs`
- `GetAllAsync`, `AddAsync`, `UpdateStatusAsync`, `VerifyIntegrityAsync`
- [x] 10b. Create `src/SlpModularCms.Modules.Master/Services/CmsInstanceService.cs`
- Inject `MasterServiceDependencies`
- `GetAllAsync`: return all as `CmsInstanceDto` (no ApiKey)
- `AddAsync`: validate → encrypt → persist → determine masterUrl (HttpContext → config fallback) → register → update `LastContactedAt` if success
- `UpdateStatusAsync`: validate → load → validate DisableMessage → persist → for non-Inactive: decrypt → push → update `LastStatusPushedAt` if success → return `UpdateStatusResult`
- `VerifyIntegrityAsync`: get active → per slave: decrypt → get registered URL → compare → re-register if mismatch → update `LastIntegrityCheckFailedAt` / `LastContactedAt`
- Logging per business-rules.md (Error/Warning/Information)
### STEP 11 — Background service
- [x] 11a. Create `src/SlpModularCms.Modules.Master/BackgroundServices/IntegrityCheckBackgroundService.cs`
- Inject `IServiceScopeFactory`, `IOptions<MasterModuleOptions>`, `ILogger<IntegrityCheckBackgroundService>`
- `PeriodicTimer` with `IntegrityCheckIntervalMinutes`
- `CreateAsyncScope()` per tick; resolve `ICmsInstanceService`; call `VerifyIntegrityAsync()`
- Outer `try/catch` logs `Error` and continues
- `[ExcludeFromCodeCoverage]` NOT applied — test with mocked `IServiceScopeFactory`
### STEP 12 — Controller
- [x] 12a. Create `src/SlpModularCms.Modules.Master/Controllers/CmsInstanceController.cs`
- `[ApiController]`, `[Route("[controller]")]`, `[Authorize(Policy = "OwnerOnly")]`
- `GetAll()`: GET → `ICmsInstanceService.GetAllAsync()` → 200 OK
- `Add([FromBody] CreateCmsInstanceRequest)`: POST → `ICmsInstanceService.AddAsync()` → 201 Created
- `UpdateStatus(Guid id, [FromBody] UpdateStatusRequest)`: PUT `/{id}/status``ICmsInstanceService.UpdateStatusAsync()` → 200 OK with `UpdateStatusResult`
- Error handling: catch `KeyNotFoundException` → 404; catch `ArgumentException` (validation) → 400
### STEP 13 — Module registration
- [x] 13a. Create `src/SlpModularCms.Modules.Master/MasterModule.cs``[ExcludeFromCodeCoverage]`
- `Name = "Master"`, `Version = "1.0.0"`
- `RegisterServices`: AddDataProtection, AddSingleton IApiKeyProtector, Configure MasterModuleOptions, AddDbContext MasterDbContext, AddScoped repo + deps + service, AddHttpClient ISlaveApiClient + AddResilienceHandler, AddHostedService IntegrityCheckBackgroundService, AddHttpContextAccessor
- `UseModule`: migrate `MasterDbContext` at startup
### STEP 14 — EF Core migration
- [x] 14a. Create `src/SlpModularCms.Modules.Master/Migrations/` directory placeholder
- Document CLI command to generate initial migration:
```
dotnet ef migrations add InitialCreate --project src/SlpModularCms.Modules.Master --startup-project src/SlpModularCms.Api
```
- Note: Migration is created via CLI after Step 13 compile-succeeds; not hand-generated
### STEP 15 — Unit tests: Repository
- [x] 15a. Create `src/SlpModularCms.Modules.Master.Tests/Repositories/CmsInstanceRepositoryTests.cs`
- Use EF Core InMemory for `MasterDbContext`
- Test: `GetAllAsync`, `GetActiveAsync` (excludes Inactive), `GetByIdAsync` (found/not found), `AddAsync` + `SaveChangesAsync`, `UpdateAsync`
### STEP 16 — Unit tests: ApiKeyProtector
- [x] 16a. Create `src/SlpModularCms.Modules.Master.Tests/Services/ApiKeyProtectorTests.cs`
- Use `EphemeralDataProtectionProvider` (real provider, no mocks)
- Test: Protect returns non-plaintext; Unprotect(Protect(x)) == x; Unprotect with wrong key throws
### STEP 17 — Unit tests: SlaveApiClient
- [x] 17a. Create `src/SlpModularCms.Modules.Master.Tests/Services/SlaveApiClientTests.cs`
- Use `NSubstitute` `HttpMessageHandler` substitute or `MockHttpMessageHandler`
- Test: `RegisterMasterAsync` returns true on 200, false on 4xx/5xx/exception
- Test: `PushStatusAsync` returns true on 200, false on failure
- Test: `GetRegisteredMasterUrlAsync` returns URL from response body, null on failure
- Test: `X-Master-Api-Key` header is set on each request
### STEP 18 — Unit tests: CmsInstanceService
- [x] 18a. Create `src/SlpModularCms.Modules.Master.Tests/Services/CmsInstanceServiceTests.cs`
- Substitute all 6 dependencies via NSubstitute
- `AddAsync` tests: success path, registration failure (record still persisted), URL from HttpContext, URL from config fallback
- `UpdateStatusAsync` tests: not found → throws, missing DisableMessage → throws, Inactive → no push, Available → persists + pushes, push fails → SlaveContactSuccess=false
- `VerifyIntegrityAsync` tests: skip Inactive, URL match → clear flag, mismatch → re-register, unreachable → set LastIntegrityCheckFailedAt
- `GetAllAsync`: ApiKey not in DTO
### STEP 19 — Unit tests: IntegrityCheckBackgroundService
- [x] 19a. Create `src/SlpModularCms.Modules.Master.Tests/BackgroundServices/IntegrityCheckBackgroundServiceTests.cs`
- Test: service calls `VerifyIntegrityAsync` on tick
- Test: exceptions in `VerifyIntegrityAsync` are caught and logged (service does not crash)
- Use NSubstitute `IServiceScopeFactory` + `IServiceScope`
### STEP 20 — Unit tests: Controller
- [x] 20a. Create `src/SlpModularCms.Modules.Master.Tests/Controllers/CmsInstanceControllerTests.cs`
- Substitute `ICmsInstanceService` via NSubstitute
- `GetAll`: 200 with list
- `Add`: 201 Created with dto; 400 on validation exception; 400 on argument exception
- `UpdateStatus`: 200 with result; 404 on KeyNotFoundException; 400 on ArgumentException
### STEP 21 — Code documentation summary
- [x] 21a. Create `aidlc-docs/features/master-cms-module/construction/master-backend/code/code-summary.md`
- List all created/modified files with paths
- Note the EF Core migration CLI command
- Note NuGet package added
---
## File Summary
| File | Action | ExcludeFromCoverage |
|------|--------|-------------------|
| `SlpModularCms.Modules.Master.csproj` | Create | N/A |
| `SlpModularCms.Modules.Master.Tests.csproj` | Create | N/A |
| `SlpModularCms.Api.csproj` | Modify | N/A |
| `SlpModularCms.sln` | Modify | N/A |
| `Options/MasterModuleOptions.cs` | Create | No |
| `Data/Entities/CmsInstanceStatus.cs` | Create | No |
| `Data/Entities/CmsInstance.cs` | Create | No |
| `Models/CmsInstanceDto.cs` | Create | Yes |
| `Models/CreateCmsInstanceRequest.cs` | Create | Yes |
| `Models/UpdateStatusRequest.cs` | Create | Yes |
| `Models/UpdateStatusResult.cs` | Create | Yes |
| `Data/MasterDbContext.cs` | Create | No |
| `Repositories/ICmsInstanceRepository.cs` | Create | No |
| `Repositories/CmsInstanceRepository.cs` | Create | No |
| `Services/IApiKeyProtector.cs` | Create | No |
| `Services/ApiKeyProtector.cs` | Create | No |
| `Services/ISlaveApiClient.cs` | Create | No |
| `Services/SlaveApiClient.cs` | Create | No |
| `Services/MasterServiceDependencies.cs` | Create | Yes |
| `Services/ICmsInstanceService.cs` | Create | No |
| `Services/CmsInstanceService.cs` | Create | No |
| `BackgroundServices/IntegrityCheckBackgroundService.cs` | Create | No |
| `Controllers/CmsInstanceController.cs` | Create | No |
| `MasterModule.cs` | Create | Yes |
| Tests (6 files) | Create | N/A |
**Total**: 25 application files (24 new, 2 modified) + 6 test files + 1 code summary
@@ -0,0 +1,122 @@
# Functional Design Plan — Unit 1: master-backend
## Unit Context
**Unit**: `master-backend`
**Project**: `SlpModularCms.Modules.Master` (new)
**Test Project**: `SlpModularCms.Modules.Master.Tests` (new)
**Requirements**: FR-MASTER-01 t/m FR-MASTER-05, FR-MASTER-10, FR-MASTER-12/13/14; NFR-MASTER-03/04/05/06
---
## Functional Design Questions
Answer each question by filling in your choice after the `[Answer]:` tag.
---
### Q1 — Auto-Registration Failure Behavior
When the Master calls `RegisterMasterAsync` on a new slave during `AddAsync`, and the slave is unreachable or returns an error, what should happen?
A) **Persist anyway, LastContactedAt = null** — save the `CmsInstance` record regardless; `LastContactedAt` stays null to signal no successful contact; the owner can see the slave is uncontacted and resolve manually.
B) **Rollback — do not persist** — if registration fails, the entire `AddAsync` operation fails; return an error to the owner; no partial record is created.
C) **Persist with Status = Inactive** — save the record but set status to `Inactive` automatically; the owner must manually reactivate once the slave is reachable.
D) Other
[Answer]: A
---
### Q2 — Status Push When Setting to Inactive
When the owner sets a slave's status to `Inactive`, should the Master attempt to push this status change to the slave via HTTP?
A) **No push for Inactive**`Inactive` means the Master stops contacting the slave entirely; no status push is sent; the slave keeps its last received state until the owner reactivates it.
B) **Push Inactive status** — send a final status update to the slave before going silent, so the slave is aware it has been deactivated.
C) Other
[Answer]: A
---
### Q3 — Integrity Check: Unreachable Slave
During `VerifyIntegrityAsync`, if a slave is unreachable (HTTP timeout or error), what should happen?
A) **Log and skip** — log the failure at Warning level, skip this slave, continue with the remaining slaves in the batch; retry on the next scheduled cycle.
B) **Mark as needs-check** — update a `LastIntegrityCheckFailedAt` timestamp on the entity; retry more aggressively on next cycle for flagged slaves.
C) Other
[Answer]: B
---
### Q4 — Master URL Discovery
When the Master calls `RegisterMasterAsync`, it needs to send its own public base URL to the slave. How should the Master know its own URL?
A) **Configured in `appsettings.json`** — owner sets `MasterModule:MasterUrl` (e.g., `https://master.myapp.com`). Explicit, reliable in all environments; simple to implement; follows existing `appsettings` pattern.
B) **Derived from `HttpContext`** — inject `IHttpContextAccessor` and derive the base URL from the current request in the controller; pass it down to the service. No config needed; but only works when called from an HTTP request (not from background service integrity checks).
C) **Two-value approach** — use `IHttpContextAccessor` when available (in controller context), fall back to `MasterModule:MasterUrl` config (for background service context).
D) Other
[Answer]: C
---
### Q5 — ApiKey Storage
The `ApiKey` in `CmsInstance` is the secret the Master sends to the slave in `X-Master-Api-Key`. How should it be stored in the `MasterDbContext`?
A) **Plaintext** — stored as-is in the database. The master must read the actual value to send in HTTP calls, so it must be stored in recoverable form. Access to the database is already protected by the deployment environment.
B) **Encrypted via ASP.NET Core Data Protection** — encrypt at write, decrypt at read using `IDataProtector`. More secure at rest; adds complexity; requires Data Protection key management.
C) Other
[Answer]: B
---
### Q6 — CmsInstanceController: Return 503 or 200 on Failed Slave Push
When `UpdateStatusAsync` successfully persists the new status but the subsequent HTTP push to the slave fails, what should the controller return to the frontend?
A) **200 OK with a warning field** — the status change is persisted (source of truth is the master DB); return 200 with an additional `slaveContactSuccess: false` field so the UI can display a warning.
B) **200 OK, no warning** — the master DB is the authority; whether the slave received the push is an implementation detail; the background integrity check ensures eventual consistency.
C) **207 Multi-Status** — partial success response indicating the DB write succeeded but the slave push failed.
D) Other
[Answer]: A
---
## Execution Steps
After all questions above are answered, the following artifacts will be generated:
- [x] **Step 1** — Analyze all answers; flag any ambiguities
- [x] **Step 2** — Generate `domain-entities.md``CmsInstance` entity, `MasterModuleOptions`, enums, DTO shapes
- [x] **Step 3** — Generate `business-logic-model.md` — process flows: AddAsync, UpdateStatusAsync, VerifyIntegrityAsync
- [x] **Step 4** — Generate `business-rules.md` — validation rules, constraints, decision logic
- [x] **Step 5** — Validate all Mermaid diagrams (no hyphens in IDs, classDef colors, text alternatives)
- [x] **Step 6** — Update `aidlc-state.md`
- [x] **Step 7** — Present completion message for user approval
---
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/master-backend-functional-design-plan.md`
@@ -0,0 +1,72 @@
# NFR Design Plan — Unit 1: master-backend
## Unit Context
**Unit**: `master-backend`
**Input**: nfr-requirements.md, tech-stack-decisions.md, functional-design artifacts
**Patterns to design**: Polly resilience pipeline, Data Protection wiring, background service isolation
---
## NFR Design Questions
Answer each question by filling in your choice after the `[Answer]:` tag.
---
### Q1 — Data Protection: Injection Pattern
`CmsInstanceService` needs to encrypt/decrypt `ApiKey` via `IDataProtector`. How should it be injected?
A) **Direct `IDataProtectionProvider` injection** — inject `IDataProtectionProvider` in `CmsInstanceService`; call `.CreateProtector("SlpModularCms.Master.ApiKey")` in the constructor. Simple; no extra type; easy to test by mocking `IDataProtectionProvider`.
B) **Thin `IApiKeyProtector` wrapper** — define a small `IApiKeyProtector` interface with `Protect(string)` and `Unprotect(string)` methods; inject the interface into `CmsInstanceService`. Purpose is explicit; makes mocking in tests trivially simple (no need to mock ASP.NET Core Data Protection internals).
C) Other
[Answer]: B
---
### Q2 — Polly Pipeline: Registration Scope
The Polly resilience pipeline (exponential backoff + timeout) applies to all `SlaveApiClient` calls. How should it be registered?
A) **Single pipeline on `AddResilienceHandler`** — configure one pipeline on the `IHttpClientBuilder` for `SlaveApiClient`; applies automatically to all HTTP calls made by this client. Zero per-call setup; consistent behavior across all slave endpoints.
B) **Inline `ResiliencePipeline` per method** — build and execute a `ResiliencePipeline` explicitly inside each `SlaveApiClient` method. More control per call (e.g., different timeout for integrity check vs. status push); more boilerplate.
C) Other
[Answer]: A
---
### Q3 — `CmsInstanceService` Constructor Complexity
`CmsInstanceService` will inject: `ICmsInstanceRepository`, `ISlaveApiClient`, `IDataProtectionProvider` (or `IApiKeyProtector`), `IOptions<MasterModuleOptions>`, `IHttpContextAccessor`, `ILogger<CmsInstanceService>` — 6 dependencies. Is this acceptable?
A) **Accept 6 dependencies** — follows existing project pattern (`PersistentAvailabilityService` has similar dependencies); no need to introduce aggregation objects.
B) **Introduce `MasterServiceDependencies` context record** — wrap the 6 dependencies in a single record type to simplify the constructor signature. Cleaner constructor; slightly more indirection.
C) Other
[Answer]: B
---
## Execution Steps
After all questions above are answered, the following artifacts will be generated:
- [x] **Step 1** — Analyze all answers; flag any ambiguities
- [x] **Step 2** — Generate `nfr-design-patterns.md` — Polly pipeline design, Data Protection pattern, logging pattern, test isolation pattern
- [x] **Step 3** — Generate `logical-components.md` — infrastructure wiring diagram, component interaction for NFR patterns
- [x] **Step 4** — Validate all Mermaid diagrams
- [x] **Step 5** — Update `aidlc-state.md`
- [x] **Step 6** — Present completion message for user approval
---
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/master-backend-nfr-design-plan.md`
@@ -0,0 +1,110 @@
# NFR Requirements Plan — Unit 1: master-backend
## Unit Context
**Unit**: `master-backend`
**Applicable NFRs**: NFR-MASTER-01 (fail-open), NFR-MASTER-03 (API key security), NFR-MASTER-04 (background service interval), NFR-MASTER-05 (≥80% test coverage), NFR-MASTER-06 (per-module migrations)
---
## NFR Questions
Answer each question by filling in your choice after the `[Answer]:` tag.
---
### Q1 — HTTP Timeout for Slave API Calls
The `SlaveApiClient` calls slave endpoints for registration, status push, and integrity checks. What HTTP timeout should be configured?
A) **5 seconds** — short timeout; registration and status push are synchronous user-triggered actions; a hanging slave should not block the owner long.
B) **15 seconds** — moderate timeout; balances responsiveness with tolerance for temporarily slow slaves.
C) **30 seconds** — generous timeout; maximizes chance of successful contact before giving up.
D) **Configurable via `MasterModuleOptions.HttpTimeoutSeconds` (default 10)** — owner can tune per-environment; sensible default.
E) Other
[Answer]: D
---
### Q2 — HTTP Retry Policy for Slave API Calls
Should `SlaveApiClient` retry failed HTTP calls automatically, or fail immediately and rely on the next integrity check cycle for recovery?
A) **No retry — fail immediately** — if a slave is unreachable, return `false` at once; the background integrity check handles eventual re-registration; keeps user-visible latency predictable.
B) **1 retry with short delay (e.g., 2s)** — single retry on transient failures (network blip); still bounded latency; reduces false negatives on flaky connections.
C) **Exponential backoff (3 attempts)** — standard resilience pattern; handles transient errors well; may add up to ~7s latency in worst case.
D) Other
[Answer]: C
---
### Q3 — ASP.NET Core Data Protection Key Storage
The `ApiKey` values are encrypted with `IDataProtector`. Where should Data Protection keys be stored?
A) **Default file system** (`%APPDATA%\Microsoft\UserSecrets` / platform default) — zero configuration; keys are machine-bound; acceptable for single-instance deployments.
B) **SQL Server via EF Core** (`services.AddDataProtection().PersistKeysToDbContext<MasterDbContext>()`) — keys survive container restarts and work across deployable instances; requires a `DataProtectionKeys` table in `MasterDbContext`.
C) **Default for now, documented as a production concern** — use the default in code; add a note in documentation that production deployments should configure persistent key storage.
D) Other
[Answer]: A
---
### Q4 — Test Coverage: Exclusions
NFR-MASTER-05 requires ≥80% coverage on new backend code. Which classes should be excluded from coverage measurement for `SlpModularCms.Modules.Master`?
A) **Module registration + migrations only** — exclude `MasterModule.cs` (module registration boilerplate) and EF Core migration files; cover everything else including controllers, services, and repository.
B) **Module registration, migrations, and DTOs/records** — additionally exclude plain record/DTO classes (no logic to test); cover all classes with business logic.
C) **No exclusions** — aim for ≥80% including all files; let natural coverage determine what's tested.
D) Other
[Answer]: B
---
### Q5 — Logging for Slave Contact Failures
When a slave is unreachable during a status push or integrity check, at what log level should the failure be recorded?
A) **Warning** — slave unreachability is expected during network issues; `Warning` signals the issue without triggering on-call alerts; appropriate for a fail-open system.
B) **Error** — slave contact failures represent a degraded state; `Error` ensures visibility in monitoring dashboards and may trigger alerts.
C) **Warning for integrity checks, Error for status push failures** — integrity checks are background maintenance; status push failures have direct owner impact.
D) Other
[Answer]: C
---
## Execution Steps
After all questions above are answered, the following artifacts will be generated:
- [x] **Step 1** — Analyze all answers; flag any ambiguities
- [x] **Step 2** — Generate `nfr-requirements.md` — performance, security, reliability, testability, maintainability requirements
- [x] **Step 3** — Generate `tech-stack-decisions.md` — HTTP client config, Data Protection, retry policy, logging
- [x] **Step 4** — Update `aidlc-state.md`
- [x] **Step 5** — Present completion message for user approval
---
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/master-backend-nfr-requirements-plan.md`
@@ -0,0 +1,62 @@
# Code Generation Plan — Unit 2: slave-availability-extension
## Scope
**Modify**: `src/SlpModularCms.Modules.Availability/` (existing project)
**Create**: `src/SlpModularCms.Modules.Availability.Master.Tests/` (new test project)
**Update**: `SlpModularCms.sln` (add new test project)
**Update**: existing `AvailabilityMiddlewareTests.cs` in `Availability.Tests` (new 3-param signature)
---
## Steps
### Part A — Production Code (Availability module)
- [x] **Step 1**`SlpModularCms.Modules.Availability.csproj` — add `InternalsVisibleTo` for new test project
- [x] **Step 2**`Data/Entities/MasterRegistration.cs` — entity with singleton Id constant
- [x] **Step 3**`Data/AvailabilityDbContext.cs` — DbContext with MasterRegistrations DbSet + OnModelCreating
- [x] **Step 4**`Repositories/IMasterRegistrationRepository.cs``GetAsync`, `AddAsync`, `Update`, `SaveChangesAsync`
- [x] **Step 5**`Repositories/MasterRegistrationRepository.cs` — EF Core implementation
- [x] **Step 6**`Services/IMasterApiKeyProtector.cs``Protect` + `Unprotect` (null on crypto failure)
- [x] **Step 7**`Services/MasterApiKeyProtector.cs` — wraps `IDataProtectionProvider`; purpose `"SlpModularCms.Availability.MasterApiKey"`
- [x] **Step 8**`Services/MasterGateStatus.cs``record MasterGateStatus(bool IsAvailable, string? DisableMessage)`
- [x] **Step 9**`Services/MasterAvailabilityServiceDependencies.cs` — record with repo, protector, logger
- [x] **Step 10**`Services/IMasterAvailabilityService.cs``RegisterAsync(bool)`, `PushStatusAsync(bool)`, `GetRegisteredUrlAsync(string?)`, `GetMasterStatus()`
- [x] **Step 11**`Services/MasterAvailabilityService.cs` — volatile static fields + implementation of all 4 methods
- [x] **Step 12**`Models/RegisterMasterRequest.cs` + `Models/PushStatusRequest.cs``[ExcludeFromCodeCoverage]` records
- [x] **Step 13**`Controllers/MasterController.cs` — 3 endpoints; header extraction; delegate to service; Unauthorized() on false/null
- [x] **Step 14**`Middleware/AvailabilityMiddleware.cs` — extend: add `/api/v1/master/` bypass; move admin check before local gate; add master gate between admin check and local gate
- [x] **Step 15**`AvailabilityModule.cs` — add DbContext, protector, repo, deps, service registrations; add `MigrateAsync` in UseModule
### Part B — Test Project (new)
- [x] **Step 16**`SlpModularCms.Modules.Availability.Master.Tests.csproj` — new project; copy packages from Master.Tests; add `Microsoft.IdentityModel.Tokens` for JWT tests
- [x] **Step 17**`Repositories/MasterRegistrationRepositoryTests.cs` — EF InMemory; GetAsync (null + found); AddAsync; Update; SaveChanges
- [x] **Step 18**`Services/MasterApiKeyProtectorTests.cs``EphemeralDataProtectionProvider`; round-trip; invalid ciphertext returns null
- [x] **Step 19**`Services/MasterAvailabilityServiceTests.cs` — NSubstitute; all 4 public methods; key mismatch; first registration; re-registration; static cache state
- [x] **Step 20**`Controllers/MasterControllerTests.cs` — NSubstitute; missing header → 401; success → 200; service returns false/null → 401
- [x] **Step 21**`Middleware/AvailabilityMiddlewareMasterGateTests.cs` — NSubstitute; master gate blocks → 503; master gate passes → local gate evaluated; `/api/v1/master/` bypasses; master gate + admin JWT → pass through
### Part C — Cross-cutting
- [x] **Step 22**`SlpModularCms.sln` — add `Availability.Master.Tests` project with new GUID
- [x] **Step 23**`src/SlpModularCms.Modules.Availability.Tests/AvailabilityMiddlewareTests.cs` — update all `InvokeAsync(context, _service)` calls to `InvokeAsync(context, _service, masterSvc)` (masterSvc stub returning `IsAvailable=true`)
---
## Key Design Notes
| Concern | Decision |
|---------|----------|
| Singleton Id | `public static readonly Guid SingletonId = new("00000000-0000-0000-0000-000000000001")` |
| IMasterAvailabilityService.RegisterAsync | Returns `bool` (true=ok, false=key mismatch) |
| IMasterAvailabilityService.PushStatusAsync | Returns `bool` (true=ok, false=key mismatch) |
| IMasterAvailabilityService.GetRegisteredUrlAsync | Returns `string?` (null=unauthorized) |
| Unprotect null guard | `storedKey is null \|\| storedKey != incoming` → treat as mismatch |
| Middleware InvokeAsync order | bypass paths → admin JWT → master gate → local gate |
| Existing AvailabilityMiddlewareTests | Add stub `IMasterAvailabilityService` returning `IsAvailable=true` as 3rd param |
---
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/slave-availability-extension-code-generation-plan.md`
@@ -0,0 +1,119 @@
# Functional Design Plan — Unit 2: slave-availability-extension
## Unit Context
**Unit**: `slave-availability-extension`
**Project**: `src/SlpModularCms.Modules.Availability/` (EXTENDED — existing project)
**Test Project**: `src/SlpModularCms.Modules.Availability.Master.Tests/` (NEW)
**Construction Cycle**: Functional Design → NFR Requirements → NFR Design → Code Generation
**What this unit does**: Extends the slave CMS so it can:
1. Accept registrations from a master CMS (store master URL)
2. Receive status pushes from master (cache available/unavailable)
3. Return the registered master URL (for integrity checks from master)
4. Block requests via a two-phase middleware gate (master gate outer + local gate inner)
**Endpoint contract (fixed by Unit 1 SlaveApiClient)**:
- `POST /api/v1/master/register` — receive master registration
- `POST /api/v1/master/status` — receive status push from master
- `GET /api/v1/master/registered-url` — return currently registered master URL
---
## Questions
Answer each question by filling in your choice after the `[Answer]:` tag.
---
### Q1 — API Key Validation: How does the slave authenticate incoming master requests?
The master sends an `X-Master-Api-Key` header on every call. The slave must validate this. Where does the slave get the expected key to compare against?
A) **Configuration-based** — slave admin sets the expected key in `appsettings.json` under `Availability:MasterApiKey`. The key is shared out-of-band when setting up the master/slave relationship. Simple and explicit.
B) **Stored at first registration** — slave accepts the first registration request unconditionally and stores the API key from the header. Subsequent calls (status push, registered-url) validate against this stored key. No pre-configuration needed.
C) **No validation** — slave trusts all requests to master endpoints (relies on network security). Simpler but less secure.
D) Other
[Answer]: B
---
### Q2 — No Master Registered: What happens when a slave has never been registered with a master?
If no registration exists in the `MasterRegistrations` table, what should the master gate do?
A) **Fail-open** — no registration means master gate passes (slave is Available from master's perspective). This is safe: a fresh slave not yet connected to a master is fully accessible. Consistent with the fail-open philosophy from the requirements.
B) **Fail-closed** — no registration means master gate blocks (slave is unavailable until a master registers it). More secure but breaks fresh deployments.
C) Other
[Answer]: A
---
### Q3 — Master Gate: Which paths bypass the master gate?
The master gate blocks incoming requests when master says slave is unavailable. Which paths should always bypass it?
A) **Same as local gate + master endpoints** — bypass the same paths already in `_bypassPrefixes` (Auth, Setup, Availability/status) AND add `/api/v1/master/` so master can always push status or re-register even when gate is closed.
B) **All internal API paths** — bypass everything under `/api/v1/master/` and `/api/v1/Availability/` (broader bypass for any "system" paths).
C) **Master endpoints only** — only `/api/v1/master/` bypasses the master gate; keep Auth/Setup/Availability bypass only in the local gate where it already lives.
D) Other
[Answer]: A
---
### Q4 — Master Status Cache: When does the cached master status expire?
When the master pushes a status to the slave, the slave stores it in a static field. How long is it valid?
A) **No expiry** — cache never expires; only updated when master pushes again. If master goes offline permanently, last known status is used forever. Simplest implementation; consistent with fail-open (default = Available).
B) **Configurable expiry** — add `MasterCacheMinutes` to `AvailabilityOptions`; after expiry, status reverts to Available (fail-open). Allows fresh slaves to auto-recover if master disappears.
C) **Timestamp-based expiry (same as existing circuit breaker)** — static field with `_lastMasterUpdateTime`; if older than `CacheMinutes`, revert to Available.
D) Other
[Answer]: A
---
### Q5 — MasterRegistration Entity: What data does it store?
The `MasterRegistrations` table on the slave stores data about the registered master. What fields are needed?
A) **Minimal**`Id` (Guid PK), `MasterUrl` (string). Just the URL needed for integrity check response. The API key (Q1) is stored in config, not DB.
B) **Extended**`Id` (Guid PK), `MasterUrl` (string), `RegisteredAt` (DateTimeOffset), `LastContactedAt` (DateTimeOffset?). More diagnostic info; useful for monitoring.
C) Other
[Answer]: B
---
## Execution Steps
After all questions above are answered, the following artifacts will be generated:
- [x] **Step 1** — Analyze answers; flag ambiguities
- [x] **Step 2** — Generate `domain-entities.md``MasterRegistration` entity + relationship to `AvailabilityDbContext`
- [x] **Step 3** — Generate `business-logic-model.md` — sequence diagrams for: register, status push, get-registered-url, middleware gate evaluation
- [x] **Step 4** — Generate `business-rules.md` — validation rules, gate bypass logic, cache behavior, API key validation
- [x] **Step 5** — Validate all Mermaid diagrams
- [ ] **Step 6** — Update `aidlc-state.md`
- [ ] **Step 7** — Present completion message for approval
---
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/slave-availability-extension-functional-design-plan.md`
@@ -0,0 +1,72 @@
# NFR Design Plan — Unit 2: slave-availability-extension
## Unit Context
**Unit**: `slave-availability-extension`
**Inputs**: nfr-requirements.md, tech-stack-decisions.md (Unit 2)
**Already decided**: `IMasterApiKeyProtector` wrapper (mirrors Unit 1 `IApiKeyProtector`); `volatile` static cache fields; structured logging table; `AvailabilityDbContext` + `IMasterRegistrationRepository`
**Key design decisions remaining**: middleware extension pattern, `IMasterAvailabilityService` interface shape, service constructor
---
## Questions
Answer each question by filling in your choice after the `[Answer]:` tag.
---
### Q1 — Middleware: How does `AvailabilityMiddleware` read the master gate status?
The master gate needs to read `_masterIsAvailable` (volatile bool) on every request. Two approaches:
A) **InvokeAsync injection** — add `IMasterAvailabilityService` as a third parameter to `InvokeAsync`. The service exposes a synchronous `GetMasterStatus()` method that returns the volatile fields. Pattern:
```csharp
public async Task InvokeAsync(HttpContext context,
IAvailabilityService localSvc,
IMasterAvailabilityService masterSvc)
{
if (!masterSvc.GetMasterStatus().IsAvailable) { /* 503 */ }
...
}
```
Fully testable via NSubstitute substitute on `IMasterAvailabilityService`. Consistent with how `IAvailabilityService` is already injected.
B) **Direct static read**`AvailabilityMiddleware` reads `MasterAvailabilityService._masterIsAvailable` directly via `internal static volatile` fields (with `InternalsVisibleTo` for the test project). No extra injection; no interface method for status read; slightly faster on hot path.
C) Other
[Answer]: A
---
### Q2 — `MasterAvailabilityService` constructor: Direct injection or deps record?
`MasterAvailabilityService` needs 3 dependencies: `IMasterRegistrationRepository`, `IMasterApiKeyProtector`, `ILogger<MasterAvailabilityService>`.
A) **Direct injection** — pass all 3 as constructor parameters. Simple; idiomatic for a small number of deps. No wrapper record needed.
```csharp
public MasterAvailabilityService(
IMasterRegistrationRepository repository,
IMasterApiKeyProtector keyProtector,
ILogger<MasterAvailabilityService> logger)
```
B) **Dependencies record** — wrap in `MasterAvailabilityServiceDependencies` record (consistent with Unit 1's `MasterServiceDependencies`). Useful if deps may grow or for visual consistency.
C) Other
[Answer]: B
---
## Execution Steps
- [x] **Step 1** — Analyze answers; flag ambiguities
- [x] **Step 2** — Generate `nfr-design-patterns.md`
- [x] **Step 3** — Generate `logical-components.md`
- [ ] **Step 4** — Update `aidlc-state.md`
- [ ] **Step 5** — Present completion message for approval
---
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/slave-availability-extension-nfr-design-plan.md`
@@ -0,0 +1,88 @@
# NFR Requirements Plan — Unit 2: slave-availability-extension
## Unit Context
**Unit**: `slave-availability-extension`
**Inputs**: domain-entities.md, business-logic-model.md, business-rules.md (Unit 2 FD)
**Key NFR concerns**: ApiKey storage security, static cache thread safety, test coverage, logging
---
## Questions
Answer each question by filling in your choice after the `[Answer]:` tag.
---
### Q1 — ApiKey Storage: How should the slave store the master's ApiKey?
The `MasterRegistration.ApiKey` is used to validate subsequent master calls (BR-SLAVE-02/04/06). How should it be stored in the DB?
A) **Plain text** — store as-is from the `X-Master-Api-Key` header. Simple; compare directly on each request. Acceptable given the key is an infrastructure credential (not user password) and the DB should be secured.
B) **SHA-256 hash** — store `SHA256(apiKey)` and compare `SHA256(incoming)` on each request. No plain-text at rest; constant-time comparison prevents timing attacks.
C) **ASP.NET Core Data Protection** — encrypt using `IDataProtectionProvider` (same pattern as `ApiKeyProtector` in Unit 1). Reversible; consistent with master-side pattern.
D) Other
[Answer]: C
---
### Q2 — Static Cache Thread Safety: How should `_masterIsAvailable` and `_masterDisableMessage` be protected?
These static fields are read on every request (high frequency) and written only when the master pushes status (rare). What thread safety approach is appropriate?
A) **`volatile` fields** — `private static volatile bool _masterIsAvailable = true` and `private static volatile string? _masterDisableMessage`. Sufficient for atomic single-field reads/writes in .NET; no lock overhead per request. Consistent with `PersistentAvailabilityService`'s existing circuit breaker pattern.
B) **`lock` statement** — lock a `static readonly object _lock` around both reads and writes. Explicit and safe; slight overhead on every request read.
C) Other
[Answer]: A
---
### Q3 — Test Coverage: What should be excluded from coverage in this unit?
Which components in Unit 2 should receive `[ExcludeFromCodeCoverage]`?
A) **Module/wiring only** — only the `AvailabilityModule` changes (service registrations, migration call) and EF migration files. All service, controller, and middleware logic is covered.
B) **Module + DTOs**`AvailabilityModule` changes + DTO/request/response records + EF migration files. Consistent with Unit 1 pattern.
C) Other
[Answer]: B
---
### Q4 — Logging: What log levels apply to the new slave-side logic?
A) **Minimal** — only Error for unexpected exceptions. Keep logs quiet since master calls are frequent background operations.
B) **Structured per scenario** — follow the same table approach as Unit 1:
- `Warning` — API key mismatch on any endpoint
- `Warning` — master gate blocked a request (log path + disable message)
- `Information` — master registered successfully (first registration)
- `Information` — status update received (isAvailable value)
- `Debug` — get-registered-url called
C) Other
[Answer]: B
---
## Execution Steps
- [x] **Step 1** — Analyze answers; flag ambiguities
- [x] **Step 2** — Generate `nfr-requirements.md`
- [x] **Step 3** — Generate `tech-stack-decisions.md`
- [ ] **Step 4** — Update `aidlc-state.md`
- [ ] **Step 5** — Present completion message for approval
---
*Artifact path*: `aidlc-docs/features/master-cms-module/construction/plans/slave-availability-extension-nfr-requirements-plan.md`
@@ -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.