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
12 KiB
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:
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 A–F, six event types are emitted for alerting.
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.
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:
- 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. - 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.
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.