Designs the security headers and observability units

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
This commit is contained in:
2026-07-28 00:01:04 +02:00
co-authored by Claude Opus 5
parent 5f3eda2680
commit 357d395629
13 changed files with 2471 additions and 0 deletions
@@ -0,0 +1,237 @@
# Business Logic Model — U4 Observability Integration
**Unit**: U4 Observability Integration
**Requirements**: FR-13, FR-14, FR-15, FR-16, FR-19
---
## 1. Scope of the Logic
U4 answers three questions that cannot otherwise be answered without host access: is the application erroring, is it being used, and which build is running. Its logic is mostly about **graceful absence** — every observability service must be optional, because local development and any deployment without them must work unchanged.
---
## 2. Degradation Model
Three fully functional configurations rather than one required setup:
```mermaid
graph TD
boot["Startup"]
logging["Structured console logging<br/>always active"]
dsn{"Sentry DSN configured ?"}
sentryon["Sentry initialised<br/>environment and release tagged"]
sentryoff["Sentry skipped<br/>console only"]
reachable{"Sentry reachable ?"}
delivered["Events delivered"]
buffered["Sentry buffers and drops<br/>application never blocked"]
boot --> logging
logging --> dsn
dsn -->|yes| sentryon
dsn -->|no| sentryoff
sentryon --> reachable
reachable -->|yes| delivered
reachable -->|no| buffered
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef always fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef degraded fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
class boot entry;
class logging,sentryon,delivered always;
class dsn,reachable decision;
class sentryoff,buffered degraded;
```
Text alternative: structured console logging is always active; Sentry initialises only when a DSN is present, and an unreachable Sentry never blocks the application.
**An absent DSN is a supported state, not an error.** Logging is registered before Sentry so that a problem initialising Sentry is itself logged.
---
## 3. Log Levels — Console Versus Sentry
Q3 = C asked for `Information` across the board, including the framework. That is right for the **console**, and would be wrong for **Sentry**.
Sending every framework `Information` entry to Sentry means an event per request. Sentry's free plan would be exhausted within hours, and the errors that matter would be lost among request noise — the opposite of what monitoring is for.
The two destinations therefore get different thresholds:
| Destination | Threshold | Rationale |
|---|---|---|
| **Console** | `Information` for everything, framework included (Q3 = C) | On the Pi the console is captured by the process manager, so volume is cheap and detail is useful |
| **Sentry — events** | `Warning` and above | Still "more than exceptions" as Q16 = C requires, since warnings are included, without one event per request |
| **Sentry — breadcrumbs** | `Information` | Informational entries travel *attached to* an event as context, so the detail is there when something goes wrong without being an event itself |
This satisfies both answers rather than choosing between them: the console gets everything, Sentry gets warnings and errors, and every Sentry event arrives carrying the informational trail that led to it.
---
## 4. Security Event Emission
Per Q4 = all of AF, six event types are emitted for alerting.
```mermaid
graph TD
subgraph auth["Authentication and authorization"]
e1["Failed login"]
e2["Authorization denied<br/>on a protected endpoint"]
e5["Rate limit triggered<br/>on login endpoints"]
end
subgraph proto["Master and slave protocol"]
e3["Master API key rejected"]
e4["Admin bypass rejected<br/>at the availability gate"]
end
subgraph infra["Infrastructure"]
e6["Migration failure at startup"]
end
sink["Structured log entry<br/>plus Sentry event"]
alert["Sentry alert rule<br/>configured in Operations"]
e1 --> sink
e2 --> sink
e3 --> sink
e4 --> sink
e5 --> sink
e6 --> sink
sink --> alert
classDef authgrp fill:#fbb6ce,stroke:#b83280,stroke-width:1px,color:#000;
classDef protogrp fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef infragrp fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
classDef out fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
class e1,e2,e5 authgrp;
class e3,e4 protogrp;
class e6 infragrp;
class sink,alert out;
```
Text alternative: six event types across authentication, the master/slave protocol and infrastructure all flow into structured log entries that also become Sentry events, on which alert rules are configured during the Operations phase.
### What each event means, and what it carries
| Event | What it indicates | Context carried | Never carried |
|---|---|---|---|
| Failed login | Repeated occurrences suggest an attack or a forgotten password | Timestamp, endpoint, correlation ID, whether the account exists | Password, the attempted password, full email |
| Authorization denied | Someone reached an endpoint they lack rights for | Endpoint, required policy, role held, correlation ID | Token contents |
| Master API key rejected | An attacker, **or** a genuine key-ring problem | Endpoint, calling host, correlation ID | The key, or any part of it |
| Admin bypass rejected | Since FR-24, someone attempted the availability gate with an invalid token | Path, reason class (invalid signature, expired, wrong role), correlation ID | The token |
| Rate limit triggered | Brute-force pressure on login | Limiter name, endpoint, correlation ID | Client identity beyond what the limiter partitions on |
| Migration failure | Not a security event, but you want to know immediately | Exception, attempt count, correlation ID | Connection string, credentials |
**The two most diagnostically valuable are also the least obvious.** A rejected master API key is ambiguous by nature — it means either an intruder or that the key ring has become unreadable. Distinguishing them is exactly what the U2 durability work exists to make unnecessary, but if it ever happens, this event is the first sign. And a rejected admin bypass only became a meaningful signal *because* FR-24 started validating properly; before that, a forged token succeeded silently.
---
## 5. Sentry Transport — Tunnel Through the API
Per Q1 = A, browser error reports do not go directly to Sentry.
```mermaid
sequenceDiagram
box rgba(246,224,94,0.4) Browser
participant SPA as Admin SPA
end
box rgba(144,205,244,0.4) Application
participant T as Tunnel endpoint
end
box rgba(251,182,206,0.4) External
participant S as Sentry ingest
end
SPA->>T: POST envelope to same-origin tunnel path
T->>T: validate size and content type
T->>S: forward envelope to the configured DSN host
S-->>T: accepted
T-->>SPA: 200
```
Text alternative: the admin SPA posts its Sentry envelope to a same-origin tunnel endpoint, which forwards it to Sentry's ingest host and returns success to the browser.
**Why a tunnel** — two reasons, one of them the actual motivation:
1. **Ad blockers block requests to Sentry domains** with `ERR_BLOCKED_BY_CLIENT`. Without a tunnel, errors are lost precisely for the users who have an ad blocker — a silently biased sample of exactly the group most likely to have browser oddities.
2. It keeps browser traffic same-origin, so U3's CSP needs `connect-src 'self'` and no external Sentry origin. Simpler policy, and one fewer thing to forget.
**The reference project tunnels through nginx.** NFR-01 forbids relying on server configuration, so here the application forwards it.
**Constraints on the tunnel**, because it is an anonymous endpoint that makes outbound requests on request:
- Only forwards to the host derived from the configured DSN — never to a caller-supplied destination
- Rejects payloads above a fixed size
- Does nothing at all when no DSN is configured
- Not on the availability bypass list: if the instance is switched off, error reporting from the admin SPA stopping is acceptable
**The backend's own Sentry reporting does not use the tunnel** — server-side code has no ad blocker and no CSP, and reports directly.
---
## 6. Sentry Request Context and Scrubbing
Per Q2 = B, `SendDefaultPii` is enabled with a filter. This needs care, because "PII" understates what is actually attached.
With `SendDefaultPii` on, Sentry includes request headers — and this application carries a `refreshToken` in a cookie and a master API key in a header. Both are **credentials**, not merely personal data. Sending them to a third party would be worse than the problem the setting solves.
The filter therefore removes, before any event leaves the process:
| Removed | Why |
|---|---|
| The entire `Cookie` header | Contains the `refreshToken`. Removing one cookie by rewriting the header is error-prone; removing the header is not |
| `Authorization` header | Bearer token |
| `X-Master-Api-Key` header | The master/slave shared secret. **Not mentioned when this was chosen, but the same class of secret** |
| Request body | Login and password-change bodies contain passwords |
What remains and is genuinely useful: method, path, query string, user agent, IP address, authenticated username, and the correlation ID.
**Query strings are retained** — but note that invitation tokens travel as `?token=…` on `/api/v1/Invitation/validate`. That is a single-use, time-limited token rather than a standing credential, and the diagnostic value of seeing which endpoint was called outweighs it. Recorded so the decision is visible rather than accidental.
---
## 7. Frontend Configuration Resolution
Per FR-13, the API base URL becomes same-origin by default.
```mermaid
graph TD
read["Read VITE_API_BASE_URL"]
empty{"Absent or empty ?"}
same["Same-origin: use relative paths"]
valid{"Valid absolute URL ?"}
explicit["Use the explicit origin"]
invalid["Development: warn loudly<br/>Production: use as given"]
read --> empty
empty -->|yes| same
empty -->|no| valid
valid -->|yes| explicit
valid -->|no| invalid
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef warn fill:#fbd38d,stroke:#c05621,stroke-width:1px,color:#000;
class read entry;
class empty,valid decision;
class same,explicit good;
class invalid warn;
```
Text alternative: an absent or empty API base URL means same-origin relative requests; an explicit absolute URL is used as given; a malformed value warns loudly in development rather than being silently accepted.
**Why same-origin is the right default here**: in the single-host model the API is served by the same process as `/admin`, so a relative path always works and no CORS configuration is needed. The explicit form remains fully supported because local development runs the SPA on port 5173 against the API on 7221 (or 7222 for the slave) — that setup must keep working exactly as before.
**Malformed values are not silently accepted.** Validation is relaxed to permit an empty string, not to permit anything.
---
## 8. Umami Analytics
Per Q5 = A: measured in test and production, never locally.
| Condition | Behaviour |
|---|---|
| Local development | Script never loaded, regardless of configuration |
| No website ID configured | Nothing rendered |
| Website ID configured, test or production | Script loaded with the environment's own website ID |
Per-environment website IDs are why two separate frontend builds exist (D-15): the ID is a build-time value, so one bundle cannot carry both.
`Do Not Track` is deliberately **not** consulted (Q5 = A rather than B). Umami sets no cookies and collects no personal data, and the admin SPA's audience is a known set of operators — so honouring DNT would reduce data without protecting anyone. Recorded as a conscious choice.
@@ -0,0 +1,151 @@
# Business Rules — U4 Observability Integration
---
## Reporting Decision Logic
```mermaid
graph TD
entry["Log entry or exception"]
console["Write to console<br/>Information and above"]
dsn{"Sentry DSN configured ?"}
stop["Done: console only"]
level{"Level is Warning or above ?"}
crumb["Attach as breadcrumb<br/>context for a future event"]
scrub["Scrub credentials from request context"]
event["Send as Sentry event<br/>environment and release tagged"]
entry --> console
console --> dsn
dsn -->|no| stop
dsn -->|yes| level
level -->|no| crumb
level -->|yes| scrub
scrub --> event
classDef entrynode fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef step fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
classDef neutral fill:#e2e8f0,stroke:#4a5568,stroke-width:1px,color:#000;
class entry entrynode;
class dsn,level decision;
class console,crumb,scrub,event step;
class stop neutral;
```
Text alternative: everything goes to the console; with a DSN configured, informational entries become breadcrumbs while warnings and above become Sentry events, always after credentials are scrubbed from the request context.
---
## Logging Rules
| ID | Rule |
|---|---|
| **BR-U4-01** | Structured console logging is always active, independent of Sentry. |
| **BR-U4-02** | Console threshold is `Information`, including framework categories. |
| **BR-U4-03** | Every log entry carries a correlation identifier. |
| **BR-U4-04** | Logging is registered **before** Sentry, so a Sentry initialisation problem is itself logged. |
| **BR-U4-05** | No log entry may contain a password, token, API key, connection string or cookie value. |
| **BR-U4-06** | Sentry **event** threshold is `Warning` and above. |
| **BR-U4-07** | Sentry **breadcrumb** threshold is `Information`, so events arrive with the trail that led to them. |
**Rationale for BR-U4-06 and BR-U4-07 — reconciling two answers rather than choosing between them**: Q3 = C asked for `Information` everywhere, and Q16 = C asked for structured logging to Sentry beyond exceptions. Taken literally together, every framework `Information` entry would become a Sentry event — an event per request, exhausting the free plan within hours and burying real errors in request noise.
Splitting the thresholds honours both: the console gets everything (Q3 = C), and Sentry gets warnings and errors — still more than exceptions, as Q16 = C requires — each carrying its informational breadcrumbs.
---
## Sentry Rules
| ID | Rule |
|---|---|
| **BR-U4-08** | An absent DSN is a normal, supported state. Sentry initialisation is skipped and console logging continues. |
| **BR-U4-09** | An unreachable Sentry never blocks or fails a request. |
| **BR-U4-10** | Every event is tagged with the environment and the release. |
| **BR-U4-11** | One Sentry project serves both backend and frontend, and both environments, distinguished by tags. |
| **BR-U4-12** | Request context is included, with credentials scrubbed per BR-U4-13. |
| **BR-U4-13** | Before any event leaves the process, these are removed: the entire `Cookie` header, the `Authorization` header, the `X-Master-Api-Key` header, and the request body. |
| **BR-U4-14** | Scrubbing happens in-process, before transmission — never relying on a server-side setting in Sentry. |
**Rationale for BR-U4-13 — this list is longer than the question implied.** Enabling `SendDefaultPii` attaches request headers, and this application carries two standing credentials in headers: the `refreshToken` cookie and the `X-Master-Api-Key` used by the master/slave protocol. The API key was not mentioned when the setting was chosen, but it is the same class of secret and would otherwise be sent to a third party on every error raised during a master/slave call.
The request body is removed because the login and password-change endpoints carry passwords in it.
**Rationale for BR-U4-14**: server-side scrubbing means the secret already left the building. Doing it in-process is the only version that actually protects anything.
**What is deliberately retained**: method, path, query string, user agent, IP address, authenticated username and correlation ID. Note that invitation tokens travel as `?token=…` on one endpoint — a single-use, time-limited token rather than a standing credential, and the diagnostic value of the path and query outweighs it. Recorded so the trade-off is visible rather than accidental.
---
## Sentry Tunnel Rules
| ID | Rule |
|---|---|
| **BR-U4-15** | The browser sends Sentry envelopes to a same-origin tunnel endpoint, not directly to Sentry. |
| **BR-U4-16** | The tunnel forwards **only** to the host derived from the configured DSN. A caller-supplied destination is never honoured. |
| **BR-U4-17** | The tunnel rejects payloads above a fixed maximum size. |
| **BR-U4-18** | With no DSN configured, the tunnel accepts nothing and does nothing. |
| **BR-U4-19** | The tunnel is anonymous — error reports must work for a user whose session just expired. |
| **BR-U4-20** | The tunnel is **not** on the availability bypass list. |
| **BR-U4-21** | The backend's own Sentry reporting bypasses the tunnel and reports directly. |
**Rationale for BR-U4-16 — the rule that keeps this endpoint from being a liability**: an anonymous endpoint that makes an outbound request on demand is a server-side request forgery primitive if the destination comes from the caller. Deriving the destination solely from configuration removes that entirely.
**Rationale for BR-U4-19 and BR-U4-20 together**: the tunnel must be anonymous, because the errors most worth capturing include authentication failures. But it need not survive the instance being switched off — if the CMS is deliberately disabled, losing admin-SPA error reports is acceptable, and keeping it off the bypass list means one less anonymous, outbound-capable endpoint reachable on a disabled instance.
---
## Frontend Configuration Rules
| ID | Rule |
|---|---|
| **BR-U4-22** | An absent or empty `VITE_API_BASE_URL` resolves to same-origin: requests use relative paths. |
| **BR-U4-23** | An explicit absolute URL is used as supplied. |
| **BR-U4-24** | Validation accepts an empty string **or** a valid absolute URL — nothing else. A malformed value is not silently accepted. |
| **BR-U4-25** | Local development against `https://localhost:7221` (master) and `:7222` (slave) must keep working unchanged. |
| **BR-U4-26** | Frontend Sentry initialisation is skipped when no DSN is configured. |
| **BR-U4-27** | The Umami script is never loaded in local development, regardless of configuration. |
| **BR-U4-28** | With no Umami website ID configured, nothing is rendered. |
| **BR-U4-29** | `Do Not Track` is not consulted. |
**Rationale for BR-U4-24**: relaxing validation to allow an empty value is not the same as removing validation. A typo such as `htp://localhost:7221` must still be caught, or the SPA silently issues requests to a nonexistent origin.
**Rationale for BR-U4-29** (Q5 = A): Umami sets no cookies and collects no personal data, and the admin SPA's audience is a known set of operators. Honouring DNT would reduce data without protecting anyone. A conscious choice rather than an omission.
---
## Error and Edge-Case Scenarios
| Scenario | Expected behaviour |
|---|---|
| No DSN, application runs normally | Console logging only. No error, no warning about the absence |
| DSN configured, Sentry unreachable | Sentry buffers and eventually drops. No request fails |
| Exception during a master/slave call | Event sent; `X-Master-Api-Key` scrubbed |
| Failed login | Event at warning level; no password, no attempted password |
| Startup migration fails | Event sent, then the process does not start. The event must be delivered before exit |
| Sentry initialisation itself throws | Logged by the already-registered console logger; the application continues without Sentry |
| Browser posts to the tunnel with no DSN configured | Rejected; nothing forwarded |
| Browser posts an oversized payload to the tunnel | Rejected |
| Instance availability-disabled, browser posts to the tunnel | `503` from the gate. Accepted loss |
| Ad blocker active | The tunnel is same-origin, so reports arrive — the reason it exists |
| `VITE_API_BASE_URL` unset in a production build | Same-origin. The intended production configuration |
| `VITE_API_BASE_URL` set to `https://localhost:7221` locally | Used as given; local development unchanged |
| `VITE_API_BASE_URL` set to `htp://typo` | Development: loud warning. Production: used as given, and the requests visibly fail |
| Umami configured but its origin missing from the CSP | Script blocked by the browser; U3's startup warning (BR-U3-22) flags the misconfiguration |
| Local development with a Umami ID configured | Script not loaded |
---
## Security Compliance for U4
| Rule | Status | Notes |
|---|---|---|
| SECURITY-03 | **Compliant** | Structured logging with a correlation ID on every entry (BR-U4-03); credentials and PII excluded by BR-U4-05 and BR-U4-13; centralised destination via Sentry |
| SECURITY-11 | Compliant | The tunnel's fixed destination (BR-U4-16) prevents it becoming a request-forgery primitive |
| SECURITY-13 | Compliant | The Umami script is external and constrained by U3's CSP; SRI applied where the provider supports it |
| SECURITY-14 | **Addressed, with DEV-01** | Six alertable event types emitted (BR-U4-01 group); alert rules configured in Operations. Retention remains the accepted deviation — Sentry's plan retains roughly 30 days against the 90 the rule asks for |
| SECURITY-15 | Compliant | Observability failures never propagate into request handling (BR-U4-09) |
**No new deviation.** DEV-01 already covers the retention shortfall; nothing here introduces another.
**One risk closed that was not in the original scope**: BR-U4-13 adds `X-Master-Api-Key` to the scrub list. Without it, enabling `SendDefaultPii` would have sent the master/slave shared secret to a third-party service on every error raised during a master/slave call.
@@ -0,0 +1,153 @@
# 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
```mermaid
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 |
@@ -0,0 +1,212 @@
# Frontend Components — U4 Observability Integration
Changes to the admin SPA in `frontend/`. Two new components, two modified files.
---
## Component Hierarchy
```mermaid
graph TD
main["main.tsx<br/>entry point"]
sentryinit["initSentry<br/>called before render"]
config["lib/config.ts<br/>getAppConfig"]
apiclient["lib/api-client.ts<br/>ApiClient"]
query["QueryClientProvider"]
authprov["AuthProvider"]
errbound["SentryErrorBoundary<br/>NEW"]
inner["InnerApp"]
umami["UmamiAnalytics<br/>NEW"]
router["RouterProvider"]
toaster["Toaster"]
main --> sentryinit
main --> config
sentryinit --> config
config --> apiclient
main --> query
query --> authprov
authprov --> errbound
errbound --> inner
errbound --> umami
inner --> router
authprov --> toaster
classDef root fill:#4CAF50,stroke:#2e7d32,color:#000;
classDef guard fill:#FF9800,stroke:#e65100,color:#000;
classDef page fill:#2196F3,stroke:#0d47a1,color:#000;
classDef hook fill:#9C27B0,stroke:#4a148c,color:#000;
classDef newcomp fill:#9ae6b4,stroke:#2f855a,color:#000;
class main root;
class errbound,authprov guard;
class inner,router,toaster page;
class config,apiclient,sentryinit hook;
class umami newcomp;
```
Text alternative: Sentry initialises before rendering, a new error boundary wraps the application inside the auth provider, and a new Umami component sits alongside the app tree; the existing config module now also feeds Sentry initialisation.
---
## New Component — `SentryErrorBoundary`
**Location**: `frontend/src/components/SentryErrorBoundary.tsx`
| Aspect | Detail |
|---|---|
| Purpose | Catch render-time React errors that would otherwise blank the screen, report them, and show a recoverable fallback |
| Props | `children: ReactNode` |
| State | Held by Sentry's own boundary implementation |
| Placement | **Inside** `AuthProvider`, **outside** `InnerApp` |
| Behaviour without a DSN | Still catches and still shows the fallback; simply reports nothing |
| `data-testid` | `error-boundary-fallback`, `error-boundary-retry-button` |
**Why inside `AuthProvider` rather than outermost**: the fallback needs to be reachable for a logged-in user, and an error inside a page should not tear down the session context — otherwise recovering from a render error would also log the user out.
### Fallback content rules
| Must | Must not |
|---|---|
| State that something went wrong | Show the exception message |
| Offer a retry that remounts the subtree | Show a stack trace |
| Offer a link to the dashboard | Show a Sentry event ID as the primary content |
Exception text frequently contains internal detail; showing it to an operator is both unhelpful and a small information leak (SECURITY-09).
---
## New Component — `UmamiAnalytics`
**Location**: `frontend/src/components/UmamiAnalytics.tsx`
| Aspect | Detail |
|---|---|
| Purpose | Inject the Umami tracking script when configured |
| Props | None — reads configuration directly |
| Renders | Nothing visible |
| `data-testid` | Not applicable — no interactive element |
### Behaviour
| Condition | Result |
|---|---|
| `import.meta.env.DEV` | Script **never** injected (BR-U4-27) |
| Script URL or website ID absent | Nothing injected (BR-U4-28) |
| Both present, not local | Script injected once with the website ID |
| Component re-renders | Script injected **once** — guarded against duplicates |
| `Do Not Track` set | Ignored; script still injected (BR-U4-29) |
**Why a component rather than a tag in `index.html`**: the website ID is a build-time variable, and `index.html` cannot read `import.meta.env`. A component also makes the "never in development" and "once only" rules testable.
---
## Modified — `frontend/src/lib/config.ts`
| Change | Detail |
|---|---|
| `apiBaseUrl` | An absent or empty `VITE_API_BASE_URL` now resolves to `''`, meaning same-origin |
| Zod schema | Accepts an empty string **or** a valid absolute URL — nothing else (BR-U4-24) |
| New fields | `sentryDsn`, `appEnv`, `umamiScriptUrl`, `umamiWebsiteId` |
| Existing behaviour | An explicit absolute URL still works unchanged, so local development against `:7221` and `:7222` is unaffected |
**The validation is relaxed by exactly one case, not removed.** A value like `htp://localhost:7221` must still be caught, or the SPA silently issues requests to a nonexistent origin — a failure that looks like the API being down.
---
## Modified — `frontend/src/main.tsx`
| Change | Order |
|---|---|
| Call `initSentry()` | **First**, before the query client and before render — so an error during startup is still captured |
| Wrap the tree in `SentryErrorBoundary` | Inside `AuthProvider` |
| Render `UmamiAnalytics` | Alongside `InnerApp` |
| Existing `document.title` and MSW logic | Unchanged |
---
## Sentry Initialisation
**Location**: `frontend/src/lib/sentry.ts` (new)
| Aspect | Detail |
|---|---|
| Skipped when | No DSN configured (BR-U4-26) |
| `environment` | From `VITE_APP_ENV` |
| `release` | From the package version, matching the existing `__APP_VERSION__` pattern in the reference project |
| `tunnel` | Same-origin tunnel path — **not** Sentry's ingest URL |
| `sendDefaultPii` | `false` on the frontend |
**Why `sendDefaultPii` is false here even though the backend enables it with scrubbing**: the backend can scrub in-process before transmission because it controls the send. In the browser there is no equivalent guarantee, and the frontend has nothing to add that the backend cannot already report. There is no reason to accept the risk.
**Why the tunnel matters most on the frontend**: ad blockers block requests to Sentry domains, so without the tunnel the admin SPA loses errors precisely for users who have one.
---
## User Interaction Flows
### Render error recovery
```mermaid
graph TD
render["Page renders"]
err["Component throws"]
catch["SentryErrorBoundary catches"]
report{"DSN configured ?"}
send["Report via the tunnel"]
skip["No report"]
fallback["Show fallback:<br/>message, retry, dashboard link"]
retry["User clicks retry"]
remount["Subtree remounts;<br/>session preserved"]
render --> err
err --> catch
catch --> report
report -->|yes| send
report -->|no| skip
send --> fallback
skip --> fallback
fallback --> retry
retry --> remount
classDef entry fill:#f6e05e,stroke:#c05621,stroke-width:1px,color:#000;
classDef decision fill:#90cdf4,stroke:#2b6cb0,stroke-width:1px,color:#000;
classDef step fill:#2196F3,stroke:#0d47a1,color:#000;
classDef good fill:#9ae6b4,stroke:#2f855a,stroke-width:1px,color:#000;
class render,err entry;
class report decision;
class catch,send,skip,fallback step;
class retry,remount good;
```
Text alternative: a component error is caught by the boundary, reported through the tunnel when a DSN is configured, and shown as a recoverable fallback whose retry remounts the subtree while preserving the session.
---
## Form Validation Rules
U4 adds no form. Existing validation via `react-hook-form` and Zod is unchanged.
One indirect effect worth noting: existing form submission errors surface as `ProblemDetailsError` from `ApiClient`. Those are **handled** errors, already shown to the user, and must **not** become Sentry events — otherwise every validation failure a user makes becomes an alert. Only unhandled errors and `NetworkError` are reported.
---
## API Integration Points
| Component | Endpoint | Notes |
|---|---|---|
| `ApiClient` | `/api/v1/**` | Now same-origin by default (BR-U4-22) |
| Sentry client | Same-origin tunnel path | New. Not an `/api/v1` route, so no version prefix |
| `UmamiAnalytics` | Umami script origin | External; must be permitted by U3's CSP `script-src` |
---
## Testing Approach
| Component | Assertions |
|---|---|
| `config.ts` | Empty value resolves to same-origin; explicit URL preserved; malformed value rejected |
| `UmamiAnalytics` | Nothing injected in development; nothing without a website ID; injected once when configured; not injected twice on re-render |
| `SentryErrorBoundary` | Fallback shown on a child throw; fallback contains no exception text; retry remounts; works without a DSN |
| Sentry initialisation | Skipped without a DSN; tunnel option set rather than a direct ingest URL |
All use the existing Vitest, Testing Library and MSW setup. Note that `pnpm run lint` is still failing for pre-existing reasons until U5 — so lint should be run on the **changed files** during this unit, to avoid new violations hiding among the five existing ones.