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>
8.9 KiB
Business Rules — Unit 2: slave-availability-extension
Rule Set 1: API Key Validation
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
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 |
/api/v1/SlaveStatus |
NEW, added 2026-07-04 — this is actually the master's incoming endpoint for slave pulls, but it's added to this same bypass list on any instance that also loads Modules.Availability (i.e. the master itself), so the master's own local-gate status never blocks a slave from reading it |
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 | (Updated 2026-07-04) Cache is updated on POST /status (push, valid API key) and periodically overwritten by MasterStatusPollingBackgroundService pulling GET /api/v1/SlaveStatus from the master (see Rule Set 5). It is no longer purely push-driven or expiry-free: a poll failure that persists past MasterPolling:FailOpenAfterMinutes (default 5 min, measured from MasterRegistration.LastPolledAt) forcibly resets the cache to Available/null regardless of the last pushed value. |
| 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) |
| BR-SLAVE-16 | (Added 2026-07-04) LastPolledAt is updated whenever this slave successfully polls the master via MasterStatusPollingBackgroundService (distinct from LastContactedAt, which tracks master-initiated contact) |
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.
Rule Set 5: Slave Pull + Fail-Open — Added 2026-07-04
Closes a gap versus the original inception requirements (inception/requirements/requirements.md, FR-MASTER-06 "Slave Pull Model", FR-MASTER-07 "Slave Fallback Behavior", NFR-MASTER-01 "Fail-Open Safety"): construction had implemented push-only, with fail-open surviving only as an in-memory startup default (BR-SLAVE-08/09) rather than an actively-reconciling pull. Added after a slave was observed remaining on a stale status through a restart and again after being deactivated on the master.
| # | Rule |
|---|---|
| BR-PULL-01 | MasterStatusPollingBackgroundService runs one tick immediately on slave startup, then every MasterPolling:PollIntervalSeconds (default 30) |
| BR-PULL-02 | If no MasterRegistration exists yet, the poll tick is a no-op (nothing to poll) |
| BR-PULL-03 | On a successful poll (GET /api/v1/SlaveStatus on the registered master, header X-Master-Api-Key), the response overwrites the in-memory gate (_masterIsAvailable/_masterDisableMessage) and updates MasterRegistration.LastPolledAt + LastContactedAt |
| BR-PULL-04 | On a failed poll (network error, timeout, or non-success HTTP status), the gate is not changed immediately — instead RecordPollFailureAsync checks how long it's been since LastPolledAt (or RegisteredAt if never polled) |
| BR-PULL-05 | Fail-open: if that elapsed time exceeds MasterPolling:FailOpenAfterMinutes (default 5), the gate is forced to Available/null — a master that is dead or unreachable must never permanently block a slave |
| BR-PULL-06 | Push (BR-SLAVE-01 through 11) and pull (this rule set) are independent and complementary: push gives instant reactivity to an explicit admin status change; pull is the self-healing safety net for everything push can miss (slave restarts, dropped pushes, local tampering with the in-memory gate) |
Rule Set 6: Master-Controlled Availability Lock — Added 2026-07-04
Applies to the slave's own local availability admin UI/API (PersistentAvailabilityService / AvailabilityController — the endpoints an Owner uses on /settings to set this instance's own Maintenance/NotAvailable status), not the master↔slave protocol endpoints above. Added because an Owner on a master-disabled slave could previously "successfully" set local status to Available with no visible effect, since the master gate silently overrode the display.
| # | Rule |
|---|---|
| BR-LOCK-01 | GET /api/v1/Availability/status returns the master-gate status (not the locally-persisted one) whenever the master gate is closed (IsAvailable = false), and includes a new IsMasterControlled: true flag in that case |
| BR-LOCK-02 | POST /api/v1/Availability/admin/status (PersistentAvailabilityService.UpdateStatusAsync) throws MasterControlledAvailabilityException and makes no DB write when the master gate is closed, instead of silently persisting a change that would have no visible effect |
| BR-LOCK-03 | The controller translates that exception into 409 Conflict (ProblemDetails) |
| BR-LOCK-04 | The frontend Settings page reads isMasterControlled and disables the mode selector, the reason field, and the save button, showing a banner explaining that the Master CMS controls this status |