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
Business Logic Model — Unit 2: slave-availability-extension
Flow 1: Register Master (POST /api/v1/master/register)
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)
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)
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)
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.
Flow 5: Slave Poll (Slave → Master) — Added 2026-07-04
Trigger: MasterStatusPollingBackgroundService — one tick immediately on slave startup, then every MasterPolling:PollIntervalSeconds (default 30s).
sequenceDiagram
box rgba(76,175,80,0.15) Slave CMS
participant Timer as PeriodicTimer
participant BgSvc as MasterStatusPollingBackgroundService
participant Svc as MasterAvailabilityService
participant Client as MasterStatusPollClient
participant DB as AvailabilityDbContext
participant Cache as StaticCache
end
box rgba(33,150,243,0.15) Master CMS
participant MC as SlaveStatusController
end
Timer->>BgSvc: Tick
BgSvc->>Svc: GetPollTargetAsync()
Svc->>DB: GetRegistrationAsync()
alt No registration
DB-->>Svc: null
Svc-->>BgSvc: null
BgSvc-->>Timer: no-op, wait for next tick
else Registration exists
DB-->>Svc: MasterUrl, encrypted ApiKey
Svc-->>BgSvc: MasterUrl, plain ApiKey
BgSvc->>Client: GetStatusAsync(masterUrl, plainApiKey)
Client->>MC: GET /api/v1/SlaveStatus with X-Master-Api-Key header
alt Poll succeeds
MC-->>Client: 200 OK { IsAvailable, DisableMessage }
Client-->>BgSvc: PolledMasterStatus
BgSvc->>Svc: ApplyPolledStatusAsync(isAvailable, disableMessage)
Svc->>Cache: set _masterIsAvailable / _masterDisableMessage
Svc->>DB: Update LastPolledAt=now, LastContactedAt=now
else Poll fails (network error, timeout, 401, etc.)
Client-->>BgSvc: null
BgSvc->>Svc: RecordPollFailureAsync(failOpenAfter)
Svc->>DB: read LastPolledAt (or RegisteredAt if never polled)
alt Unreachable longer than failOpenAfter
Svc->>Cache: force _masterIsAvailable=true, _masterDisableMessage=null
Note over Svc: Fail-open — a dead/unreachable master must never permanently block this slave
else Still within grace period
Note over Svc: No change — leave the existing cached gate as-is
end
end
end
Text alternative: Background timer triggers the poller; if no master is registered, it's a no-op. Otherwise it calls GET /api/v1/SlaveStatus on the registered master. On success, the response overwrites the in-memory gate and updates LastPolledAt/LastContactedAt. On failure, the gate is left alone unless the master has been unreachable (via poll) for longer than MasterPolling:FailOpenAfterMinutes, in which case the gate is forced open (Available, no message).
Flow 6: Local Availability Status Read/Write with Master-Gate Override — Added 2026-07-04
Applies to the slave's own /settings admin UI/API — GET /api/v1/Availability/status and POST /api/v1/Availability/admin/status — layered on top of PersistentAvailabilityService, which previously only ever reflected the locally-persisted GlobalAvailabilityState regardless of the master gate.
sequenceDiagram
box rgba(33,150,243,0.15) Frontend (SettingsPage)
participant FE as Owner Browser
end
box rgba(76,175,80,0.15) Slave CMS
participant Ctrl as AvailabilityController
participant Svc as PersistentAvailabilityService
participant MasterSvc as MasterAvailabilityService
participant DB as ApplicationDbContext
end
FE->>Ctrl: GET /api/v1/Availability/status
Ctrl->>Svc: GetStatusDetailsAsync()
Svc->>MasterSvc: GetMasterStatus()
alt Master gate closed (IsAvailable = false)
MasterSvc-->>Svc: NotAvailable, masterMessage
Svc-->>Ctrl: Status=NotAvailable, Message=masterMessage, IsMasterControlled=true
else Master gate open
MasterSvc-->>Svc: Available
Svc->>DB: read GlobalAvailabilityState
DB-->>Svc: local Status/Message
Svc-->>Ctrl: Status, Message, IsMasterControlled=false
end
Ctrl-->>FE: 200 OK
FE->>Ctrl: POST /api/v1/Availability/admin/status (attempted local change)
Ctrl->>Svc: UpdateStatusAsync(newStatus, reason, updatedBy)
Svc->>MasterSvc: GetMasterStatus()
alt Master gate closed
MasterSvc-->>Svc: NotAvailable
Svc-->>Ctrl: throw MasterControlledAvailabilityException
Ctrl-->>FE: 409 Conflict (ProblemDetails)
else Master gate open
MasterSvc-->>Svc: Available
Svc->>DB: persist newStatus/reason
Svc-->>Ctrl: success
Ctrl-->>FE: 200 OK
end
Text alternative: Reading status now checks the master gate first — if closed, the response reflects the master's forced status and message, with IsMasterControlled=true, regardless of what's persisted locally. Writing a status change now performs the same master-gate check first: if closed, the write is rejected outright with 409 Conflict instead of silently succeeding with no visible effect, since the master gate would have overridden it on the very next read anyway.
Previously (before 2026-07-04):
GetStatusDetailsAsyncignored the master gate entirely and always returned the locally-persisted status;UpdateStatusAsyncalways wrote the requested change regardless of the master gate, giving a misleading "success" for a change that was immediately invisible.