Files
SluijsensandClaude Sonnet 5 0447993181 Completes local-dev-master-slave-setup: dual-instance frontend tooling, module-capability gating, and master/slave protocol self-healing fixes
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>
2026-07-04 19:53:52 +02:00

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/_masterDisableMessage fields, no CacheMinutes). A slave-pull mechanism was eventually added too, but on 2026-07-04 and with a different shape (MasterStatusPollingBackgroundService polling GET /api/v1/SlaveStatus on 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 in construction/master-backend/functional-design/*.md and construction/slave-availability-extension/functional-design/*.md. This divergence predates 2026-07-04 and was found (not caused) during today's documentation audit; CacheMinutes/ApiKey dead-config fallout is already tracked as TD-001 in tech-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 call RegisterMasterAsync; on UpdateStatusAsync → validate, persist, then call PushStatusAsync; on VerifyIntegrityAsync → query all active instances, call GetRegisteredMasterUrlAsync per 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-Key header per call; deserializes responses; returns success flags rather than throwing (callers decide error handling)
  • Endpoints called:
    • POST {slaveUrl}/api/internal/master/register
    • PUT {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 ICmsInstanceService via IServiceScopeFactory per tick (required because ICmsInstanceService is Scoped); runs VerifyIntegrityAsync(); interval from MasterModuleOptions.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 returns HasMaster = false; if exists checks cache freshness against MasterModuleOptions.CacheMinutes; pulls from Master via HTTP on cache miss; returns cached value on HTTP failure (fail-open, NFR-MASTER-01)
  • Cache fields (static): _cachedStatus (default Available), _cachedDisableMessage, _lastFetchedAt, _cachedMasterUrl
  • Exemption: No special logic needed — a Master CMS instance never calls RegisterMaster on itself, so MasterRegistrations table is empty → HasMaster = false always 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.