Frontend (Unit 2 completion): dual dev-server tooling (pnpm dev:slave, pnpm dev:all), per-instance browser tab titles, and a backend capability check (SystemController + useSystemCapabilities + ModuleGuard) so a Master-only page is hidden on a slave instance instead of assuming every backend has every module. Master/slave protocol fixes surfaced by actually running master and slave side by side locally: - Deactivating a CMS instance (Inactive) now releases the slave's master gate instead of leaving it stuck on its last pushed status. - The periodic integrity check now also re-pushes status to every reachable slave (previously URL-verification only) and runs once immediately on startup. - Added the originally-specified (but never implemented) slave-pull path: a slave now periodically polls its own status from the master (GET /api/v1/SlaveStatus) and fails open to Available if the master is unreachable for too long, complementing the existing push. - The slave's own Settings page can no longer "successfully" change local availability while the master controls it; it's now locked with an explanatory banner and the backend rejects the write with 409 instead of silently no-op'ing it. - CMS instance status badges now match the dashboard's color/icon styling instead of a plain grey badge. Also corrected the master-cms-module design docs to match this as-built behavior, and flagged (without a full rewrite) a larger, pre-existing divergence between its inception-stage application design and what construction actually built. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
10 KiB
Services — Master CMS Module
⚠️ Superseded, found stale 2026-07-04: this document is the inception-stage design and describes an earlier pull-based
IMasterAvailabilityService(static cache +MasterModuleOptions.CacheMinutes+/api/internal/master/*routes) that was not what got built. Construction pivoted to a push-based protocol instead (/api/v1/master/*routes,_masterIsAvailable/_masterDisableMessagefields, noCacheMinutes). A slave-pull mechanism was eventually added too, but on 2026-07-04 and with a different shape (MasterStatusPollingBackgroundServicepollingGET /api/v1/SlaveStatuson an interval, with a time-based fail-open) than what's described below. Treat this file as historical intent, not current truth — the accurate, as-built design lives inconstruction/master-backend/functional-design/*.mdandconstruction/slave-availability-extension/functional-design/*.md. This divergence predates 2026-07-04 and was found (not caused) during today's documentation audit;CacheMinutes/ApiKeydead-config fallout is already tracked as TD-001 intech-debt-backlog.
Service Definitions
Master-Side Services
CmsInstanceService
- Interface:
ICmsInstanceService - Lifetime: Scoped
- Injected Dependencies:
ICmsInstanceRepository,ISlaveApiClient,IOptions<MasterModuleOptions>,ILogger<CmsInstanceService> - Responsibilities: Business orchestration — coordinates repository (data) and SlaveApiClient (HTTP side-effects); enforces business rules (mandatory DisableMessage, Inactive cannot be pushed)
- Key orchestration: On
AddAsync→ persist entity then callRegisterMasterAsync; onUpdateStatusAsync→ validate, persist, then callPushStatusAsync; onVerifyIntegrityAsync→ query all active instances, callGetRegisteredMasterUrlAsyncper instance, re-register on mismatch
SlaveApiClient
- Interface:
ISlaveApiClient - Lifetime: Transient (managed by
IHttpClientFactory) - Registration:
services.AddHttpClient<ISlaveApiClient, SlaveApiClient>() - Injected Dependencies:
HttpClient(injected by framework) - Responsibilities: Typed HTTP client for all Master → Slave API calls; sets
X-Master-Api-Keyheader per call; deserializes responses; returns success flags rather than throwing (callers decide error handling) - Endpoints called:
POST {slaveUrl}/api/internal/master/registerPUT {slaveUrl}/api/v1/Availability/admin/status(reuses existing slave endpoint)GET {slaveUrl}/api/internal/master/registration(integrity check)
IntegrityCheckBackgroundService
- Lifetime: Singleton (registered via
services.AddHostedService<IntegrityCheckBackgroundService>()) - Injected Dependencies:
IServiceScopeFactory,IOptions<MasterModuleOptions>,ILogger<IntegrityCheckBackgroundService> - Responsibilities: Periodic background loop; resolves
ICmsInstanceServiceviaIServiceScopeFactoryper tick (required becauseICmsInstanceServiceis Scoped); runsVerifyIntegrityAsync(); interval fromMasterModuleOptions.IntegrityCheckIntervalMinutes
Slave-Side Services
MasterAvailabilityService
- Interface:
IMasterAvailabilityService - Lifetime: Scoped
- Injected Dependencies:
AvailabilityDbContext,IHttpClientFactory,IOptions<MasterModuleOptions>,ILogger<MasterAvailabilityService> - Responsibilities: Checks DB for
MasterRegistration; if none exists returnsHasMaster = false; if exists checks cache freshness againstMasterModuleOptions.CacheMinutes; pulls from Master via HTTP on cache miss; returns cached value on HTTP failure (fail-open, NFR-MASTER-01) - Cache fields (static):
_cachedStatus(defaultAvailable),_cachedDisableMessage,_lastFetchedAt,_cachedMasterUrl - Exemption: No special logic needed — a Master CMS instance never calls
RegisterMasteron itself, soMasterRegistrationstable is empty →HasMaster = falsealways on a Master instance (FR-MASTER-09)
Orchestration Flows
Flow 1 — Add Slave CMS
sequenceDiagram
box rgba(99,179,237,0.3) Frontend
participant UI as Browser
end
box rgba(154,230,180,0.3) Master CMS
participant Ctrl as CmsInstanceController
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Slave CMS
participant SlaveEndpoint as AvailabilityController
end
UI->>Ctrl: POST /api/v1/CmsInstances
Ctrl->>Svc: AddAsync(request)
Svc->>Repo: AddAsync(entity)
Repo-->>Svc: entity tracked
Svc->>Repo: SaveChangesAsync()
Svc->>Client: RegisterMasterAsync(slaveUrl, apiKey, masterUrl)
Client->>SlaveEndpoint: POST /api/internal/master/register
SlaveEndpoint-->>Client: 200 OK
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastContactedAt)
Repo-->>Svc: updated
Svc->>Repo: SaveChangesAsync()
Svc-->>Ctrl: CmsInstanceDto
Ctrl-->>UI: 201 Created
Text alternative: Browser posts new slave to master controller → service persists → calls slave registration endpoint → updates LastContactedAt → returns DTO.
Flow 2 — Set Slave Status
sequenceDiagram
box rgba(99,179,237,0.3) Frontend
participant UI as Browser
end
box rgba(154,230,180,0.3) Master CMS
participant Ctrl as CmsInstanceController
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Slave CMS
participant SlaveCtrl as AvailabilityController
end
UI->>Ctrl: PUT /api/v1/CmsInstances/{id}/status
Ctrl->>Svc: UpdateStatusAsync(id, status, disableMessage)
Svc->>Repo: GetByIdAsync(id)
Repo-->>Svc: CmsInstance
Note over Svc: Validate DisableMessage required if NotAvailable
Svc->>Repo: UpdateAsync (Status, DisableMessage)
Svc->>Repo: SaveChangesAsync()
Svc->>Client: PushStatusAsync(url, apiKey, status, disableMessage)
Client->>SlaveCtrl: PUT /api/v1/Availability/admin/status
SlaveCtrl-->>Client: 200 OK
Client-->>Svc: true
Svc->>Repo: UpdateAsync (LastStatusPushedAt)
Svc->>Repo: SaveChangesAsync()
Svc-->>Ctrl: void
Ctrl-->>UI: 200 OK
Text alternative: Browser sends status update → master validates, persists, pushes to slave endpoint → updates LastStatusPushedAt → returns 200.
Flow 3 — Integrity Check (Background)
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) Master CMS
participant Svc as CmsInstanceService
participant Repo as CmsInstanceRepository
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Slave CMS
participant SlaveEndpoint as Slave API
end
Timer->>BgSvc: Tick (every IntegrityCheckIntervalMinutes)
BgSvc->>Svc: VerifyIntegrityAsync()
Svc->>Repo: GetActiveAsync()
Repo-->>Svc: list of active CmsInstances
loop for each active instance
Svc->>Client: GetRegisteredMasterUrlAsync(slaveUrl, apiKey)
Client->>SlaveEndpoint: GET /api/internal/master/registration
SlaveEndpoint-->>Client: registeredMasterUrl
Client-->>Svc: registeredMasterUrl
alt URL mismatch
Svc->>Client: RegisterMasterAsync(slaveUrl, apiKey, masterUrl)
Client->>SlaveEndpoint: POST /api/internal/master/register
SlaveEndpoint-->>Client: 200 OK
Client-->>Svc: true
end
end
Svc-->>BgSvc: done
Text alternative: Background timer triggers integrity service → per active slave checks registered URL → re-registers if mismatch.
Flow 4 — Two-Phase Availability Gate (Slave Middleware)
flowchart TD
Start(["Incoming HTTP Request"])
Bypass{"Bypass prefix?"}
HasMaster{"MasterRegistration\nexists in DB?"}
CacheFresh{"Cache fresh?"}
PullMaster["Pull status from Master\n(HTTP GET)"]
PullOk{"Pull successful?"}
MasterStatus{"Master status?"}
LocalCheck["Existing local gate\nIAvailabilityService.IsAvailableAsync()"]
AdminBypass{"Admin bypass\n(Owner/Admin JWT)?"}
Block503Master["503 Service Unavailable\n+ DisableMessage"]
Block503Local["503 / Maintenance"]
Pass(["Pass request to next middleware"])
Start --> Bypass
Bypass -->|yes| Pass
Bypass -->|no| HasMaster
HasMaster -->|no| LocalCheck
HasMaster -->|yes| CacheFresh
CacheFresh -->|yes| MasterStatus
CacheFresh -->|no| PullMaster
PullMaster --> PullOk
PullOk -->|yes| MasterStatus
PullOk -->|no - use cached| MasterStatus
MasterStatus -->|NotAvailable| Block503Master
MasterStatus -->|Available| LocalCheck
LocalCheck -->|Available| Pass
LocalCheck -->|NotAvailable or Maintenance| AdminBypass
AdminBypass -->|yes| Pass
AdminBypass -->|no| Block503Local
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 block fill:#FC8181,stroke:#C53030,stroke-width:2px,color:#000
class Bypass,HasMaster,CacheFresh,PullOk,MasterStatus,AdminBypass decision
class PullMaster,LocalCheck action
class Start terminal
class Pass terminal
class Block503Master,Block503Local block
Text alternative: Request enters middleware → check bypass → if master registered check cached/pulled status → if NotAvailable return 503 with DisableMessage → else proceed to existing local availability gate.
Flow 5 — Slave Registration (Master Registers Itself)
sequenceDiagram
box rgba(154,230,180,0.3) Master CMS
participant Client as SlaveApiClient
end
box rgba(246,224,94,0.3) Slave CMS
participant Ctrl as AvailabilityController
participant DB as AvailabilityDbContext
end
Client->>Ctrl: POST /api/internal/master/register\nHeader: X-Master-Api-Key\nBody: {masterUrl}
Ctrl->>Ctrl: Validate API key vs MasterModuleOptions.ApiKey
alt Invalid key
Ctrl-->>Client: 401 Unauthorized
else Valid key
Ctrl->>DB: Upsert MasterRegistration (masterUrl)
DB-->>Ctrl: saved
Ctrl-->>Client: 200 OK
end
Text alternative: Master posts registration with API key header → slave validates key → upserts MasterRegistration record → returns 200.