feat: install @sentry/vue + observability module skeleton
WS-7 PR-3 commit 1. Frontend mirror of the backend SDK install (commits bdb89a2..adab3be), wired against the existing apps/app SPA. - pnpm add @sentry/vue@10.52.0 (pinned). - src/observability/sentry.ts: initSentry() — empty DSN no-op (RFC §3.3), errors-only (tracesSampleRate=0, profilesSampleRate=0; RFC §2 amend.B), sendDefaultPii=false, Console integration off, beforeSend wired to the scrubber, initial scope tag app=app for GlitchTip filtering. - src/observability/scrubber.ts: TypeScript port of backend SentryEventScrubber. RFC §3.7 frontend block — body / header / query scrubbing, form_values wholesale replacement, cookies wholesale, defensive strip of contexts.storage and user.cookies, max-depth guard. - src/observability/contextBinding.ts: Vue Router beforeEach guard that binds RFC §3.6 auth-scope tags per navigation. Three zones via route.meta.public + route.path matching: - portal token zone (meta.public + meta.context=portal) → actor_scope= portal, no user_id (RFC §3.6 explicit) - /platform/* with super_admin → actor_scope=platform, no org tag - default authenticated → actor_scope=organisation when an active organisation is selected (useOrganisationStore.activeOrganisationId), otherwise actor_scope=user - unauthenticated public pages → actor_scope=anonymous Reads useAuthStore (user, appRoles, isSuperAdmin) and useOrganisationStore (activeOrganisationId) — corrected vs. RFC's speculative auth-store API. - src/observability/index.ts: barrel. - src/main.ts: initSentry runs before registerPlugins so Sentry's Vue errorHandler hooks before any plugin or component initialises; installContextBinding runs after registerPlugins so pinia is up. - env.d.ts: VITE_SENTRY_DSN_FRONTEND + VITE_SENTRY_RELEASE typed. - .env.example: new file (didn't exist before) documenting all SPA env vars including the new Sentry pair. - vite.config.ts: build.sourcemap=true (RFC §3.5 — generated, uploaded to GlitchTip by deploy.sh, then stripped before nginx serves dist/). Typecheck: green. Build: green, *.map files emitted alongside *.js chunks as expected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
131
apps/app/src/observability/scrubber.ts
Normal file
131
apps/app/src/observability/scrubber.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { EventHint, ErrorEvent as SentryErrorEvent } from '@sentry/vue'
|
||||
|
||||
const SENSITIVE_BODY_KEYS = [
|
||||
'password',
|
||||
'password_confirmation',
|
||||
'current_password',
|
||||
'token',
|
||||
'api_key',
|
||||
'secret',
|
||||
'webhook_secret',
|
||||
'dsn',
|
||||
'signature',
|
||||
'authorization',
|
||||
'cookie',
|
||||
'bearer',
|
||||
'iban',
|
||||
'bic',
|
||||
'passport_number',
|
||||
'bsn',
|
||||
] as const
|
||||
|
||||
const SENSITIVE_HEADERS = [
|
||||
'authorization',
|
||||
'cookie',
|
||||
'set-cookie',
|
||||
'x-api-key',
|
||||
'x-impersonation-token',
|
||||
] as const
|
||||
|
||||
const SENSITIVE_QUERY_KEYS = ['token', 'api_key'] as const
|
||||
|
||||
const SCRUBBED = '[scrubbed]'
|
||||
const FORM_VALUES_REPLACEMENT = '[scrubbed_form_values]'
|
||||
const MAX_DEPTH = 10
|
||||
|
||||
/**
|
||||
* RFC-WS-7 §3.7 frontend block: scrubs PII from outgoing Sentry events.
|
||||
*
|
||||
* Mirrors the backend SentryEventScrubber semantics so a captured request
|
||||
* looks the same regardless of which side originated the event. Adds
|
||||
* frontend-specific scrubs (cookies via document.cookie exposure,
|
||||
* localStorage/sessionStorage never in event context).
|
||||
*/
|
||||
export function scrubEvent(event: SentryErrorEvent, _hint?: EventHint): SentryErrorEvent | null {
|
||||
if (event.request) {
|
||||
if (event.request.data !== undefined && event.request.data !== null && typeof event.request.data === 'object')
|
||||
event.request.data = scrubBody(event.request.data, 0)
|
||||
|
||||
if (event.request.headers && typeof event.request.headers === 'object')
|
||||
event.request.headers = scrubHeaders(event.request.headers as Record<string, string>)
|
||||
|
||||
if (typeof event.request.query_string === 'string' && event.request.query_string !== '')
|
||||
event.request.query_string = scrubQueryString(event.request.query_string)
|
||||
|
||||
// RFC §3.7 frontend point 1: cookies wholesale. Sentry types the field
|
||||
// as Record<string, string>; replacing with a single sentinel key keeps
|
||||
// the structure shape while removing any real values.
|
||||
if (event.request.cookies !== undefined)
|
||||
event.request.cookies = { scrubbed: SCRUBBED }
|
||||
}
|
||||
|
||||
// RFC §3.7 frontend point 2: localStorage / sessionStorage never in event
|
||||
// context. Sentry doesn't add these by default but defensively strip.
|
||||
if (event.contexts && 'storage' in event.contexts)
|
||||
delete (event.contexts as Record<string, unknown>).storage
|
||||
|
||||
// RFC §3.7 frontend point 1: scrub document.cookie if Sentry's
|
||||
// BrowserSession integration injected it under user.
|
||||
if (event.user && 'cookies' in event.user)
|
||||
delete (event.user as Record<string, unknown>).cookies
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
function scrubBody(data: unknown, depth: number): unknown {
|
||||
if (depth > MAX_DEPTH)
|
||||
return ['[max_depth]']
|
||||
|
||||
if (Array.isArray(data))
|
||||
return data.map(item => scrubBody(item, depth + 1))
|
||||
|
||||
if (data === null || typeof data !== 'object')
|
||||
return data
|
||||
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
|
||||
const lowerKey = key.toLowerCase()
|
||||
|
||||
if (lowerKey === 'form_values') {
|
||||
result[key] = FORM_VALUES_REPLACEMENT
|
||||
continue
|
||||
}
|
||||
|
||||
if ((SENSITIVE_BODY_KEYS as readonly string[]).includes(lowerKey)) {
|
||||
result[key] = SCRUBBED
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
result[key] = scrubBody(value, depth + 1)
|
||||
continue
|
||||
}
|
||||
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function scrubHeaders(headers: Record<string, string>): Record<string, string> {
|
||||
const result: Record<string, string> = {}
|
||||
for (const [name, value] of Object.entries(headers)) {
|
||||
if ((SENSITIVE_HEADERS as readonly string[]).includes(name.toLowerCase()))
|
||||
result[name] = SCRUBBED
|
||||
|
||||
else
|
||||
result[name] = value
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
function scrubQueryString(queryString: string): string {
|
||||
const params = new URLSearchParams(queryString)
|
||||
for (const key of Array.from(params.keys())) {
|
||||
if ((SENSITIVE_QUERY_KEYS as readonly string[]).includes(key.toLowerCase()))
|
||||
params.set(key, SCRUBBED)
|
||||
}
|
||||
|
||||
return params.toString()
|
||||
}
|
||||
Reference in New Issue
Block a user