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
7.1 KiB
Domain Entities — U4 Observability Integration
No persisted entity. U4 adds no table, no migration and no database column. It adds two configuration sections, one anonymous endpoint, and build-time frontend values.
Concept Relationships
graph TD
obsconfig["Observability configuration<br/>backend"]
dsn["Sentry DSN"]
env["Environment name"]
release["Release identifier"]
logging["Structured logger"]
corr["Correlation identifier"]
scrubber["Credential scrubber"]
sentrybe["Sentry client<br/>backend"]
tunnel["Tunnel endpoint"]
vite["Vite build-time values<br/>frontend"]
sentryfe["Sentry client<br/>frontend"]
umami["Umami script component"]
apicfg["API base URL resolution"]
obsconfig --> dsn
obsconfig --> env
obsconfig --> release
dsn --> sentrybe
dsn --> tunnel
env --> sentrybe
release --> sentrybe
logging --> corr
logging --> sentrybe
scrubber -->|"filters before send"| sentrybe
vite --> sentryfe
vite --> umami
vite --> apicfg
sentryfe -->|"posts envelopes to"| tunnel
tunnel -->|"forwards to DSN host only"| sentrybe
classDef cfg fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
classDef backend fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef frontend fill:#d6bcfa,stroke:#6b46c1,stroke-width:1px,color:#000;
classDef guard fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
class obsconfig,dsn,env,release,vite cfg;
class logging,corr,sentrybe,tunnel backend;
class sentryfe,umami,apicfg frontend;
class scrubber guard;
Text alternative: backend configuration supplies the Sentry DSN, environment and release; the structured logger attaches a correlation identifier and feeds Sentry through a credential scrubber; the frontend reads build-time values and routes its error envelopes through the same-origin tunnel, which forwards only to the configured DSN host.
Backend configuration — Observability section
| Field | Type | Default | Purpose |
|---|---|---|---|
SentryDsn |
string | empty | Absent means Sentry is skipped entirely (BR-U4-08) |
Environment |
string | falls back to ASPNETCORE_ENVIRONMENT |
Tag distinguishing test from production |
TunnelMaxPayloadBytes |
int | fixed default | Upper bound enforced by the tunnel (BR-U4-17) |
TracesSampleRate |
double | conservative default | Performance sampling; kept low so the free plan is not exhausted |
Not configurable, deliberately: the scrub list (BR-U4-13) and the tunnel's destination host (BR-U4-16). Both are security-critical, and making either configurable would create a way to switch the protection off — the scrub list by omission, the destination by turning the endpoint into a request-forgery primitive.
Secret handling: a Sentry DSN is not a secret in the usual sense — it identifies a project and permits event submission, and the frontend's copy is visible in the page source. It still comes from environment variables in production, consistent with D-16, rather than being committed.
Correlation identifier
| Aspect | Detail |
|---|---|
| Purpose | Ties every log entry and Sentry event from one request together (SECURITY-03) |
| Scope | One request |
| Presence | On every log entry, not only on errors |
| Mechanism | Not fixed here — HttpContext.TraceIdentifier versus W3C traceparent is OPEN-01, decided in this unit's NFR Design |
Recorded as an entity because the choice affects the shape of every log entry, and NFR Design will settle it rather than leaving it to code generation.
Credential scrubber
Not persisted; a filter applied to every outbound Sentry event.
| Removed | Reason |
|---|---|
Cookie header, entire |
Carries the refreshToken. Removing one cookie by rewriting the header is error-prone |
Authorization header |
Bearer token |
X-Master-Api-Key header |
Master/slave shared secret |
| Request body | Login and password-change bodies carry passwords |
| Retained | Reason |
|---|---|
| Method, path, query string | Diagnostic value. Note the invitation-token caveat below |
| User agent | Browser-specific failures |
| IP address | Attack-pattern recognition |
| Authenticated username | Whose session hit the problem |
| Correlation identifier | Ties the event to its log entries |
Invitation-token caveat: /api/v1/Invitation/validate?token=… puts a token in the query string, which is retained. It is single-use and time-limited rather than a standing credential, and the value of knowing which endpoint was called outweighs it. Documented so the trade-off is visible.
Security event types
Six event shapes, not persisted — emitted as structured log entries that also become Sentry events.
| Event | Level | Context |
|---|---|---|
| Failed login | Warning | Endpoint, whether the account exists, correlation ID |
| Authorization denied | Warning | Endpoint, required policy, role held, correlation ID |
| Master API key rejected | Warning | Endpoint, calling host, correlation ID |
| Admin bypass rejected | Warning | Path, rejection reason class, correlation ID |
| Rate limit triggered | Warning | Limiter name, endpoint, correlation ID |
| Migration failure | Critical | Exception, attempt count, correlation ID |
All six sit at Warning or above, so they cross the Sentry event threshold (BR-U4-06) by construction rather than by coincidence.
Rejection reason classes for the admin bypass — InvalidSignature, Expired, WrongIssuer, NotAdmin, Malformed — never the token itself. The distinction matters: InvalidSignature suggests forgery, while Expired is usually an administrator with a stale tab.
Frontend build-time values
| Variable | Purpose | Absent behaviour |
|---|---|---|
VITE_API_BASE_URL |
API origin | Same-origin (BR-U4-22) |
VITE_SENTRY_DSN |
Frontend Sentry project | Sentry skipped (BR-U4-26) |
VITE_APP_ENV |
Environment tag | Untagged |
VITE_UMAMI_SCRIPT_URL |
Umami script origin | Umami not loaded |
VITE_UMAMI_WEBSITE_ID |
Per-environment website ID | Nothing rendered (BR-U4-28) |
VITE_APP_TITLE |
Existing; unchanged | Defaults to SlpModularCms |
Why these force two build artifacts (D-15): Vite bakes them into the bundle at build time, so one dist/ cannot carry both the test and production website IDs or environment tags. The CI workflow therefore produces one artifact per environment.
Frontend config model change
AppConfig.apiBaseUrl gains one new legal value: the empty string, meaning same-origin. Its Zod schema becomes "empty string or valid absolute URL" — relaxed by exactly one case, not loosened to accept anything (BR-U4-24).
Persistence Summary
| Question | Answer |
|---|---|
| New tables? | None |
| New migrations? | None |
| New configuration sections? | One backend section (Observability); frontend variables are build-time |
| New endpoints? | One — the anonymous Sentry tunnel |
| Secrets stored? | None. The DSN is not a standing credential and comes from environment variables |
| Data sent to third parties? | Error events to Sentry, page views to self-hosted Umami. Credentials scrubbed per BR-U4-13 |