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

9.9 KiB

Business Logic Model — Unit 1: master-backend

Flow 1 — AddAsync (Add Slave CMS)

Trigger: POST /api/v1/CmsInstances (Owner only)

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)

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->>DP: Unprotect(entity.ApiKey)
        DP-->>Svc: plainApiKey
        Svc->>Client: PushStatusAsync(slaveUrl, plainApiKey, isAvailable=true, disableMessage=null)
        Note over Svc: Releases the master gate — the master no longer manages this slave, so it must not stay stuck on its last pushed status
        alt Release success
            Client-->>Svc: true
            Svc->>Repo: UpdateAsync (LastStatusPushedAt = UtcNow)
            Svc->>Repo: SaveChangesAsync()
            Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=true)
        else Release failed
            Client-->>Svc: false
            Svc-->>Ctrl: UpdateStatusResult(Success=true, SlaveContactSuccess=false)
        end
    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 decrypts ApiKey and pushes status to slave — for Inactive this push is always isAvailable=true, disableMessage=null (releasing the gate); for Available/NotAvailable it pushes the new status as-is. Returns SlaveContactSuccess=false if the push fails, but the DB write is always the authority.

Updated 2026-07-04: the Inactive branch previously did not push anything to the slave at all (see history below) — this left the slave stuck on whatever status it had last received, indefinitely. Fixed by always releasing the gate on deactivation.


Flow 3 — VerifyIntegrityAsync (Background Integrity Check)

Trigger: IntegrityCheckBackgroundService — one tick immediately on host startup, then every IntegrityCheckIntervalMinutes (startup tick added 2026-07-04)

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
            Note over Svc: Unreachable for URL check — skip the status re-push for this instance this cycle
        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
            Note over Svc: Status re-push (added 2026-07-04) — runs whenever the slave was reachable, independent of the URL-match outcome
            Svc->>Client: PushStatusAsync(slaveUrl, plainApiKey, isAvailable=(Status==Available), disableMessage)
            alt Push success
                Client-->>Svc: true
                Svc->>Repo: UpdateAsync (LastStatusPushedAt = UtcNow)
                Svc->>Repo: SaveChangesAsync()
            else Push failed
                Client-->>Svc: false
                Note over Svc: Logged; no flag change — next cycle (or the immediate startup tick) will retry
            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. (Added 2026-07-04) For every slave that was reachable, the service additionally re-pushes the master's currently persisted Status/DisableMessage to that slave — this is what lets a slave that reset its in-memory gate (e.g. after a restart) catch up without waiting for the next explicit admin status change. Combined with the new immediate startup tick on IntegrityCheckBackgroundService, this reconciliation now also runs right after the master process (re)starts.

Updated 2026-07-04: previously this flow only verified/re-registered the master URL and never re-pushed status (see history below) — a restarted slave (whose in-memory master-gate defaults to Available) would show the wrong status until the master's next explicit UI-driven change.