The integrity check mapped four outcomes onto a single null: no response,
a 404, a rejected key, and a genuine answer. Only "rejected key" is
recoverable, and it was being reported as "unreachable" and never repaired —
so an instance registered against the wrong URL stayed broken until someone
edited the database by hand. That is exactly what happened locally.
GetRegisteredMasterUrlAsync now returns an outcome alongside the URL.
Unauthorized triggers registration; 404 is reported as "this host does not
serve the master/slave protocol", which names the actual mistake instead of
hiding it behind a generic contact failure; unreachable and server errors
behave as before.
Registering on a rejected key cannot hijack a slave that belongs to another
master: the slave accepts a registration only when it has none, and refuses
any key that does not match an existing one. So it succeeds exactly in the
case worth recovering and fails harmlessly otherwise. That guarantee lives on
the slave, so the test asserting the refusal now says out loud that the
master depends on it.
Found while diagnosing a status push that failed against a frontend URL.
Small and contained, so fixed here rather than filed as tech debt.
372 tests pass, up from 366.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
Starting both hosts after U2 fails to decrypt a stored slave API key: the
row was encrypted under the old file-based key ring, and the database key
ring holds a different, freshly generated key. The key ring working
correctly is exactly why the old key is not found.
U2 moved the ring without a path for ciphertext that predates it. Confirmed
with the user that nothing is deployed yet, so no migration ships and the
first deploy meets an empty database. ASM-08 records the condition and makes
it blocking for U6 if it ever stops holding.
Also notes that the master lets the CryptographicException escape while the
slave catches it and reports "master API key rejected" instead — the
ambiguity U4's event was designed around, showing up on day one.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
U4. Console logging plus Sentry, a same-origin tunnel so ad blockers cannot
silence browser errors, Umami on the admin SPA, and six security events that
alert rules can actually be built on.
The correlation id is the W3C trace id from the ambient Activity, enabled by
one line of ActivityTrackingOptions so every entry from every category carries
it without touching a call site. It propagates across the master/slave
boundary via traceparent, which TraceIdentifier cannot do at all, and it is
the same value ProblemDetails already returns to the browser.
The security events use source-generated LoggerMessage with constant
templates. Sentry groups log events by message, so interpolating an email
address would give every address its own issue and "more than 20 failed
logins in five minutes" could never fire — the events would arrive, be
visible, be tagged, and the alerting would silently be impossible. A test
asserts the rendered message is identical across argument values.
Scrubbing happens in-process, before transmission, and covers Set-Cookie as
well as Cookie: the login response issues the refreshToken there, so
scrubbing only the request side would protect nothing. Transactions are
scrubbed too, because they carry request data and are the channel nobody
thinks of.
The tunnel derives its destination from the DSN once at startup and reads
nothing from the request, which is what separates a tunnel from a
server-side request forgery primitive. Size is capped by a bounded read
rather than by trusting Content-Length, and the endpoint is rate limited.
Two things found along the way. Zod 4's url() hands the value to the URL
constructor, which accepts any scheme — so the existing frontend validation
would have accepted the exact "htp://" typo BR-U4-24 names, and the SPA
would have called a nonexistent origin. Now constrained to http(s). And the
new appsettings comments are verified against the real configuration
provider, because the failure mode if it rejected them is both hosts
refusing to start after a release switch.
One deviation. IAdminTokenValidator was meant to gain a reason-reporting
overload; implemented that way, a substitute returning false by default
silently inverted the access decision while both methods compiled. Two
methods whose difference is invisible at the call site is the defect, so it
is now a single Validate returning AdminTokenResult.
Touches two files from already-committed units: DatabaseMigrationExtensions
(U2) gains a flush before the rethrow, or the one Critical event in the
system dies with the process; AdminTokenValidator (U1) classifies why a
bypass was refused.
Build 0 errors; 366 backend tests pass, up from 315, and 237 frontend tests,
up from 213. tsc clean, eslint clean on every changed file.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
U3. These headers normally come from nginx, but the deployment target does
not allow server configuration, so the application emits them itself. That
changes the failure mode: a bad nginx config fails loudly at reload, while a
middleware that never runs sends nothing and says nothing.
Two policies, defined in code. Strict for /admin, /api/v1 and /health;
relaxed for the public website, which is authored elsewhere by someone who
has never seen this policy. Configuration decides where a policy applies and
which external origins are permitted; it cannot invent a policy that is
subtly permissive.
script-src 'self' under Strict has no 'unsafe-inline' and no 'unsafe-eval',
asserted by a test so that loosening it means deleting a test that says why.
style-src does carry 'unsafe-inline' and cannot not: Radix positions its
overlays with inline style attributes, which nonces cannot reach at all.
Two traps handled explicitly. StartsWithSegments rather than string
StartsWith, because "/administrator".StartsWith("/admin") is true and a
public page would silently lose its inline scripts with no server-side trace.
And all decision logic sits in a static writer rather than in the middleware,
because DefaultHttpContext.Response.OnStarting is a no-op — the obvious
middleware test observes nothing and an assertion that nothing was written
passes for entirely the wrong reason.
An unknown policy name fails ValidateOnStart, so the process exits rather
than quietly serving /admin under the relaxed policy. Origin format is
validated too, beyond what the design asked: a CSP source list silently
ignores a malformed source, so a URL with a path would look configured and
block the script anyway.
BR-U3-22's Umami startup warning is withdrawn (REF-U3-01) — the backend
never sees VITE_UMAMI_WEBSITE_ID. It becomes a blocking CI gate in U5.
Build 0 errors; 315 tests pass, up from 253.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
NFR Design for U3 and U4. Two decisions the earlier stages had deliberately
left open, plus four risks the functional design did not name.
OPEN-01 closed: the correlation ID is the W3C trace ID from the ambient
Activity, with TraceIdentifier as the fallback. It propagates across the
master/slave boundary via traceparent, which TraceIdentifier cannot do at
all, and it is the same value ProblemDetails already returns to the client.
REF-U3-01 raised: BR-U3-22's Umami-origin startup warning cannot work. The
backend never sees VITE_UMAMI_WEBSITE_ID, so the check would either always
warn or never warn. Withdrawn from U3 and replaced by a blocking U5 CI gate
that compares the frontend build variable against that environment's CSP
origins, where both values are visible.
Four additions beyond the functional design:
- Set-Cookie added to the scrub list; the login response issues the
refreshToken there, so scrubbing only the request cookie protects nothing
- SetBeforeSendTransaction alongside SetBeforeSend; transactions carry
request data too
- OnRejected on the rate limiter; today a 429 leaves no trace anywhere
- FlushAsync before the migration-failure rethrow, or the one Critical
event in the system dies with the process
Two traps recorded with tests attached: Sentry groups log events by message
template, so interpolated messages make FR-19's rate-based alert rules
unimplementable while appearing to work; and DefaultHttpContext.Response
.OnStarting is a no-op, so the obvious middleware test asserts nothing.
Three values chosen rather than escalated, each one line to change and all
three listed for review at the end of U4's pattern document: JSON console
outside Development, TracesSampleRate 0.1, tunnel cap 200 KB.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
Replaces the literal SA password in the container command and the
example connection string with a placeholder.
This change was already in the working tree at the start of the session
and is unrelated to the deployment feature; it is committed separately
so it stays easy to identify.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
Records the functional design for the two remaining application units,
before any of their code exists.
Security headers have to come from the application, because relying on
nginx or IIS configuration is exactly what this deployment model rules
out. Strict applies to /admin, /api/v1 and /health; a relaxed policy
applies to the public website, which this repository does not author.
The strict policy needs style-src 'unsafe-inline'. That is not a
shortcut: Radix positions dropdowns and dialogs with inline style
attributes recalculated per click and scroll position, and CSP nonces
apply only to style elements, never to style attributes. No nonce- or
hash-based variant leaves the admin UI working. The exception is bounded
to styles — script-src stays closed, which is where XSS actually lives.
The website's policy is enforcing rather than absent, so every
HTML-serving path carries a CSP and no exception has to be recorded. It
still blocks external script origins, so it remains a real boundary.
HSTS is skipped in development: browsers remember it per host and
localhost is shared with unrelated projects. Every other header applies
locally, so a CSP violation surfaces while developing.
For observability, browser error reports tunnel through the API rather
than going to Sentry directly. Ad blockers block Sentry domains, which
loses errors precisely for the users most likely to have browser
oddities. The tunnel forwards only to the host derived from the
configured DSN — a caller-supplied destination would turn an anonymous
endpoint into a request-forgery primitive.
Two consequences of the chosen options are recorded rather than left
implicit:
Enabling SendDefaultPii attaches request headers, and this application
carries two standing credentials in them. Besides the refreshToken
cookie, X-Master-Api-Key would have been sent to a third party on every
error raised during a master/slave call. The scrub list removes the
whole Cookie header, Authorization, X-Master-Api-Key and the request
body.
Console logging at Information plus structured logging to Sentry would,
taken literally, mean one Sentry event per request — exhausting the free
plan within hours and burying real errors in request noise. The
thresholds are split: console keeps Information, Sentry takes warnings
and above as events with Information as breadcrumbs, so every event
arrives carrying the trail that led to it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
Nothing here is visible in normal operation. Its whole purpose is that
swapping the release directory on deploy cannot silently destroy state.
Data Protection secures the API keys that authenticate master/slave
communication. Two separate defaults would each have destroyed them:
keys are held on the filesystem, which a release swap discards, and the
application discriminator is derived from the content root path, which
changes with every release directory — so even keys stored in a database
would have stopped being derivable. Keys now live in
ApplicationDbContext and the discriminator is a fixed constant.
Losing them produces no error. It produces stored keys that no longer
decrypt, which presents as an apparent network fault between a Master
and its slaves and is easily misdiagnosed. That is also why the tests
assert the resulting configuration rather than the registration: the
XmlRepository must be the EF one and the discriminator must be the
constant, plus a round-trip proving a value encrypted before a deploy is
readable after one. A test that only checked "Data Protection is
registered" would have passed in the broken case too.
Both modules previously called AddDataProtection() themselves. Module
registration runs after the host's, so those calls re-registered the
configuration chain and would have overridden the persistent store while
IDataProtector still resolved. They are removed, with a comment at each
site — the deletion otherwise looks like a regression. Each module's own
test project now guards against it being reintroduced.
ApplicationDbContext also migrates itself at startup. Deploy targets
offer no CLI, so migrations cannot be a manual step on the server.
Failures are classified rather than treated alike: a connection failure
means the database is not up yet, normal when the app and the database
start together after a reboot, and is retried with backoff; a migration
failure means something is broken and fails at once. Either way the
process does not start, which is what makes the liveness health check
trustworthy — an application that cannot reach its schema never answers
/health, so monitoring goes red instead of reporting a healthy instance
that cannot serve a request.
The cost of migrating without a human gate is that migrations must stay
forward-compatible and non-destructive, since rollback is "redeploy the
previous release". The new migration is purely additive.
Also wires this and the preceding hosting commit into both hosts, as
they touch the same lines of Program.cs.
Two constraints are enforced by documentation rather than code, and
belong in the deployment instructions: the key table must never be
pruned, and only one instance may migrate a given database at a time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
Prepares the single-host layout for deployment. The customer's public
website moves from wwwroot/ to wwwroot/web/, so a CMS deploy can no
longer overwrite content it does not own: with the website in its own
directory, the release directory can be swapped without touching it.
Each front-end gets its own file provider, and both tolerate a missing
directory at startup — a fresh deployment has no website until a
separate workspace deploys one, and the CMS must still serve /admin and
the API. When the website's index.html is absent, an embedded
placeholder is served instead of a 404, which also doubles as proof the
CMS itself is running. The placeholder is embedded in the assembly
rather than shipped into wwwroot/web/, because that directory is owned
and overwritten by the website workspace.
Adds GET /health for uptime monitoring. It reports infrastructure
liveness only and is deliberately NOT the same thing as
/api/v1/Availability/status or /api/v1/System/capabilities: those are
CMS domain state that also serve the master/slave protocol. A healthy
instance can be switched off by design, and a switched-on instance can
be unhealthy, so conflating them would alert on business state and stay
silent on real outages. /health is on the availability gate's bypass
list for the same reason.
Fixes a real defect in the gate's admin bypass. It parsed the bearer
token with ReadJwtToken, which reads claims without verifying the
signature, so an unauthenticated caller could forge an unsigned token
carrying an Owner role claim and bypass the gate that suspends a
customer's site. Protected endpoints still rejected them, so nothing
leaked — but the gate itself was bypassable. The token is now fully
validated against the same parameters as the bearer scheme, resolved
from one shared source so the two cannot drift apart.
Host wiring for these changes lands with the data-durability commit,
since both units touch the same lines of Program.cs.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
Adds the AI-DLC inception record for deploying the CMS as a single .NET
application on hosting where no server configuration is possible.
The reverse-engineering artifacts were regenerated: the previous set
predated the Master module, the Slave host, the solution reorganisation
and single-host serving, all of which matter for deployment. Findings
were verified by running the build, both test suites and the linter
rather than inferred, which surfaced two facts the plan depends on:
the frontend lint gate currently fails (5 errors), and two transitive
packages carry high-severity advisories.
Records 24 functional requirements, 32 traced decisions and a
seven-unit decomposition whose ordering is load-bearing: durability
work must land before the first automated deploy, or the very first
deploy is the one that silently breaks master/slave trust.
Two conflicts found while designing and carried into the units:
- Both modules call AddDataProtection(), which runs after the host and
would override a persistent key store while still passing any
registration test.
- The availability gate runs before authentication, so its admin
bypass cannot read HttpContext.User.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HHoJpxYXzHACSQguHrC5fw
Shared hosting (e.g. mijnhostingpartner.nl) typically allows only one
site/app-pool, so SlpModularCms.Api now serves everything itself:
'/' for the customer's public website (deployed separately, not part
of this repo), '/admin' for the CMS admin SPA, and '/api/v1' for the
API as before.
- Program.cs: static files from wwwroot + SPA fallbacks per path so
client-side routing works for both frontends.
- frontend/: builds with base '/admin/' in production (dev unchanged),
router basepath follows suit.
- SlpModularCms.Api.csproj: publish now builds the admin frontend and
copies its output into wwwroot/admin automatically.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wi5qHAuq8UbzN4NLUFeKkJ
Adds AGENTS.md as the canonical description of the Application/Tests/Clients
solution layout, mirrored into CLAUDE.md, .junie/guidelines.md, and
.github/copilot-instructions.md so Claude, Junie, and Copilot all pick it up.
Also un-ignores .junie/ so guidelines.md is tracked.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Wi5qHAuq8UbzN4NLUFeKkJ
Marks aidlc-state.md as closed and logs the commit/push handoff in
audit.md; these notes were written after the prior commit had already
been created.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
Relocates ModuleOrchestrator, ServiceCollectionExtensions, and
ApiPrefixConvention from SlpModularCms.Api into SlpModularCms.Core.Hosting
so a new Master-less SlpModularCms.Api.Slave host project (ports 5285/7222)
can share the same bootstrap code without duplicating it. This lets a
developer run a master instance and a slave instance side by side locally
to test the master/slave connection, without touching the existing
master/slave protocol itself.
Relocates the two orchestrator/convention test files from
Modules.Identity.Tests to Core.Tests, dropping an incidental
ProjectReference to SlpModularCms.Api that existed only for those tests.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Finishes the master-cms-module feature (Units 1-4): runs Build and
Test across master-backend, slave-availability-extension and
frontend-cms-page, fixes a missing Availability EF migration for
MasterRegistration and a TanStack Query v5 mutation-callback type
break, adds the missing MasterModule appsettings section, and
documents the module in README.md. Also seeds a tech-debt-backlog
feature to track dead config and pre-existing/introduced frontend
lint findings for later cleanup.
Chrome 89+ schemeful same-site treats http://localhost and https://localhost
as different sites, blocking SameSite=Strict cookies on cross-origin fetch
(e.g. Vite on port 5173, API on port 7221).
Fix: make CookieSameSite configurable per environment in JwtSettings.
- Default: Strict (production)
- appsettings.Development.json: None (allows cross-origin cookie in dev)
- When SameSite=None, Secure is always forced (browser requirement)
Revert the earlier Vite proxy approach in favour of this backend config.
VITE_API_BASE_URL remains a freely configurable URL in .env.local.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Chrome 89+ treats http://localhost and https://localhost as different sites
(schemeful same-site). The refreshToken cookie (SameSite=Strict) was not
sent when the Vite dev server (HTTP) made cross-origin fetch calls to the
.NET backend (HTTPS), causing a 401 on every page reload.
Fix: route /api/* through the Vite dev server proxy so all requests stay
same-origin. The cookie is now always sent and auth sessions survive reloads.
- vite.config.ts: proxy /api → https://localhost:7221 (secure:false for dev cert)
- .env.example: document proxy vs direct mode, set default VITE_API_BASE_URL to empty
- config.ts: accept empty string as valid apiBaseUrl alongside full URLs
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Add AvailabilityStatusDetails record and GetStatusDetailsAsync() to
IAvailabilityService; implement in PersistentAvailabilityService so the
admin-set reason stored in the database is returned alongside the status
- AvailabilityController.GetStatus() now returns the stored message instead
of hardcoded English strings
- Add messageAvailable / messageMaintenance / messageUnavailable translation
keys in en + nl so default messages are fully translatable
- AvailabilityStatusBadge: Available always shows the translated default;
Maintenance and Unavailable show the custom admin reason when set,
otherwise fall back to the translated default
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Eager-load both en and nl translation bundles at i18n init to eliminate
the async gap that caused English flash when Dutch was the detected language
- LanguageSwitcher: use i18n.language (synchronous) instead of resolvedLanguage
(asynchronous) so the visual selection is always correct after switching
- AvailabilityController: remove hardcoded English messages ("System is running
normally.") from GET /availability/status; return empty string so the frontend
translations control the display text
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Add @tanstack/react-query 5.101.0; wrap app with QueryClientProvider
- Add AvailabilityStatus type and AvailabilityResponse to api/types.ts
- Implement useAvailabilityStatus (staleTime 30s, stale-on-error preserved)
- Add AvailabilityStatusBadge with green/amber/red states and stale indicator
- Replace DashboardPage placeholder card with live availability widget
- Add MSW availability handler; update test/utils with QueryClientProvider
- 55/55 tests pass (FR-05)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Add /settings (Owner only) and /profile routes under authenticatedRoute
so sidebar stays visible and layout is preserved
- Create SettingsPage and ProfilePage placeholder components (coming soon)
- Remove Profile nav item from Sidebar — profile now accessible via UserMenu
- Add Profile link to UserMenu dropdown above logout
- Update Sidebar tests to reflect profile-free nav
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
Functional design documents were written based on self-answered questions
instead of asking the user. Removed pre-written docs and restored the plan
with open questions for the user to answer.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
ApiPrefixConvention already prepends api/v1 to all controllers.
AuthController had it hardcoded too, resulting in the doubled route
api/v1/api/v1/auth/login which caused a 404 on login attempts.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Call changeLanguage(values.language) after successful owner creation so
the UI immediately switches to the language the owner chose
- Call _markSystemInitialized() to update the router cache before navigating
to /login; without this the guard saw initialized=false and redirected
back to /setup, blocking the redirect
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Destructure confirmPassword out of form values in SetupPage and
InviteCompletePage so it is never sent to the backend
- Add global cursor:pointer rule for buttons, selects, labels and links
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Router catch block now falls back to { initialized: false } instead of
silently continuing, preventing unwanted redirect to /login when the
API call fails at startup
- Add Name field to CreateOwnerRequest and propagate to ApplicationUser.DisplayName
so the owner's display name is stored during initial setup
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Add /api/v1/ prefix to all Unit 2 API calls (Setup/status, Setup/owner,
Invitation/validate, Invitation/complete) to match backend ApiPrefixConvention
- Fix setup status endpoint to POST /api/v1/Setup/owner (not /Setup)
- Handle PascalCase 'Initialized' response from .NET backend without camelCase policy
- Update all MSW mock handlers to match corrected /api/v1/ URL patterns
- Remove unused imports from RouteGuard.test.tsx
Root cause: backend uses ApiPrefixConvention('api/v1') but frontend calls
were missing the prefix, and .NET defaults to PascalCase JSON serialization.
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
- Fix invitation MSW handler: use 'valid-token' to match test fixtures
- Add data-testid='setup-success' to SetupPage success state
- Change invite email input from readOnly to disabled (matches test assertion)
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>