Files
slp-modular-cms/aidlc-docs/features/gitea-deployment-workflow/construction/u4-observability/functional-design/business-logic-model.md
T
SluijsensandClaude Opus 5 357d395629 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
2026-07-28 00:01:04 +02:00

12 KiB
Raw Blame History

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 AF, 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:

  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.

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.