Files
crewli/apps/app/src/observability/scrubber.ts
bert.hausmans bc477837eb 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>
2026-05-07 17:56:21 +02:00

132 lines
3.9 KiB
TypeScript

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()
}