Files
slp-modular-cms/aidlc-docs/features/gitea-deployment-workflow/construction/u4-observability/functional-design/frontend-components.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

8.5 KiB

Frontend Components — U4 Observability Integration

Changes to the admin SPA in frontend/. Two new components, two modified files.


Component Hierarchy

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

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.