diff --git a/apps/app/.env.example b/apps/app/.env.example new file mode 100644 index 00000000..5a85b635 --- /dev/null +++ b/apps/app/.env.example @@ -0,0 +1,16 @@ +# Crewli SPA — local development defaults. Copy to .env.local and edit +# per-developer overrides. + +VITE_API_URL=http://localhost:8000/api/v1 +VITE_APP_NAME=Crewli +VITE_PORTAL_URL=http://localhost:5175 + +# WS-7 Observability (RFC-WS-7-OBSERVABILITY.md §3.3, §3.4). +# Empty DSN = SDK no-op (no events captured locally). Production gets +# the crewli-app DSN from the 1Password vault under +# `Crewli / GlitchTip / DSNs`. +VITE_SENTRY_DSN_FRONTEND= + +# Release identifier in the form `crewli-app@`. Injected at +# build-time by deploy.sh; leave blank locally. +VITE_SENTRY_RELEASE= diff --git a/apps/app/env.d.ts b/apps/app/env.d.ts index 15d88625..534cff15 100644 --- a/apps/app/env.d.ts +++ b/apps/app/env.d.ts @@ -4,6 +4,12 @@ interface ImportMetaEnv { readonly VITE_API_URL: string readonly VITE_APP_NAME: string readonly VITE_PORTAL_URL: string + // RFC-WS-7 §3.3 — empty DSN = SDK no-op. Production gets the crewli-app + // DSN from 1Password vault. + readonly VITE_SENTRY_DSN_FRONTEND?: string + // RFC-WS-7 §3.4 — `crewli-app@`, injected at build-time by + // deploy.sh. Optional during local dev. + readonly VITE_SENTRY_RELEASE?: string } interface ImportMeta { diff --git a/apps/app/package.json b/apps/app/package.json index f7e67855..2786095c 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -20,6 +20,7 @@ "@casl/ability": "6.7.3", "@casl/vue": "2.2.2", "@floating-ui/dom": "1.6.8", + "@sentry/vue": "10.52.0", "@sindresorhus/is": "7.1.0", "@tanstack/vue-query": "^5.95.2", "@tiptap/extension-highlight": "^2.27.1", diff --git a/apps/app/pnpm-lock.yaml b/apps/app/pnpm-lock.yaml index 64077762..89377565 100644 --- a/apps/app/pnpm-lock.yaml +++ b/apps/app/pnpm-lock.yaml @@ -21,6 +21,9 @@ importers: '@floating-ui/dom': specifier: 1.6.8 version: 1.6.8 + '@sentry/vue': + specifier: 10.52.0 + version: 10.52.0(pinia@3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3)) '@sindresorhus/is': specifier: 7.1.0 version: 7.1.0 @@ -1233,6 +1236,43 @@ packages: '@sec-ant/readable-stream@0.4.1': resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==} + '@sentry-internal/browser-utils@10.52.0': + resolution: {integrity: sha512-x/yEPZdpH6NGQeoeQnV9tj8reAH8twNttiltGZl2o8Rk7sQeUfe7E8yuYP2XbJ2RqyZK5qRS3COrNyMPzf6KFA==} + engines: {node: '>=18'} + + '@sentry-internal/feedback@10.52.0': + resolution: {integrity: sha512-5kAn1W8ZvCuHtEHXpq6iRkUMdNCilwww+YxaN2yofVrCivAbB3Ha5JJUMqmWOPW0pC27zGYmoJMIDvG+PczUxA==} + engines: {node: '>=18'} + + '@sentry-internal/replay-canvas@10.52.0': + resolution: {integrity: sha512-BI5ie4dxPuUJ344CXVSnAxY1xZCbghglPSCIlTOYODpR9so9yo5IZh+Mwspt0oWsUMaxWJiQSNYlbPWi7WDavg==} + engines: {node: '>=18'} + + '@sentry-internal/replay@10.52.0': + resolution: {integrity: sha512-diywyuc/H7VTUR+W5ryVmLF+0X4UP1OskMqb6V8RSAvJHcj2JmIm7uP+Fc6ACTno+b6AUShwT/L4xVXzO6X9Cw==} + engines: {node: '>=18'} + + '@sentry/browser@10.52.0': + resolution: {integrity: sha512-ijL9jN86oXwXQWbwhPlEb70ODJSEmjxQEQdnZkC4gDWbjswcwvRsVJPYk+1xl2ir2iZixRIHipVxDcLwian35g==} + engines: {node: '>=18'} + + '@sentry/core@10.52.0': + resolution: {integrity: sha512-VA/kAqLhkMnRWY2RXdBLyTemR9D4m7MVRy/gyapoq9yvllVPx9WXbvKgnMP2LQp7mFgT/oLFvw58aQKaYTGn3A==} + engines: {node: '>=18'} + + '@sentry/vue@10.52.0': + resolution: {integrity: sha512-6MHYKXGQz39yFJ27HzNYGWJtmwDhEwp7EvCm6cJPBlXQNbYOoNTDrzq4TuI0cLJzyAW7mIQ+k4n4iMpa6EbfaA==} + engines: {node: '>=18'} + peerDependencies: + '@tanstack/vue-router': ^1.64.0 + pinia: 2.x || 3.x + vue: 2.x || 3.x + peerDependenciesMeta: + '@tanstack/vue-router': + optional: true + pinia: + optional: true + '@shikijs/core@1.29.2': resolution: {integrity: sha512-vju0lY9r27jJfOY4Z7+Rt/nIOjzJpZ3y+nYpqtUZInVoXQ/TJZcfGnNOGnKjFdVZb8qexiCuSlZRKcGfhhTTZQ==} @@ -6203,6 +6243,42 @@ snapshots: '@sec-ant/readable-stream@0.4.1': {} + '@sentry-internal/browser-utils@10.52.0': + dependencies: + '@sentry/core': 10.52.0 + + '@sentry-internal/feedback@10.52.0': + dependencies: + '@sentry/core': 10.52.0 + + '@sentry-internal/replay-canvas@10.52.0': + dependencies: + '@sentry-internal/replay': 10.52.0 + '@sentry/core': 10.52.0 + + '@sentry-internal/replay@10.52.0': + dependencies: + '@sentry-internal/browser-utils': 10.52.0 + '@sentry/core': 10.52.0 + + '@sentry/browser@10.52.0': + dependencies: + '@sentry-internal/browser-utils': 10.52.0 + '@sentry-internal/feedback': 10.52.0 + '@sentry-internal/replay': 10.52.0 + '@sentry-internal/replay-canvas': 10.52.0 + '@sentry/core': 10.52.0 + + '@sentry/core@10.52.0': {} + + '@sentry/vue@10.52.0(pinia@3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)))(vue@3.5.22(typescript@5.9.3))': + dependencies: + '@sentry/browser': 10.52.0 + '@sentry/core': 10.52.0 + vue: 3.5.22(typescript@5.9.3) + optionalDependencies: + pinia: 3.0.3(typescript@5.9.3)(vue@3.5.22(typescript@5.9.3)) + '@shikijs/core@1.29.2': dependencies: '@shikijs/engine-javascript': 1.29.2 diff --git a/apps/app/src/main.ts b/apps/app/src/main.ts index 902a514a..ff544415 100644 --- a/apps/app/src/main.ts +++ b/apps/app/src/main.ts @@ -1,6 +1,8 @@ import { createApp } from 'vue' import { VueQueryPlugin } from '@tanstack/vue-query' import { queryClientConfig } from '@/lib/query-client' +import { initSentry, installContextBinding } from '@/observability' +import { router } from '@/plugins/1.router' import App from '@/App.vue' import { registerPlugins } from '@core/utils/plugins' @@ -12,9 +14,24 @@ import '@styles/styles.scss' // Create vue app const app = createApp(App) -// Register plugins +// RFC-WS-7 — Sentry init runs before plugin registration so the SDK can +// hook Vue's errorHandler before any plugin or component initialises. +// Empty DSN = SDK no-op (mirrors backend behaviour, RFC §3.3). +initSentry({ + app, + router, + dsn: import.meta.env.VITE_SENTRY_DSN_FRONTEND ?? '', + release: import.meta.env.VITE_SENTRY_RELEASE ?? '', + environment: import.meta.env.MODE, +}) + +// Register plugins (router, pinia, vuetify, …). registerPlugins(app) +// Bind auth-scope tags per route navigation. Must run after pinia is set +// up by registerPlugins (the guard reads useAuthStore / useOrganisationStore). +installContextBinding(router) + app.use(VueQueryPlugin, queryClientConfig) // Mount vue app diff --git a/apps/app/src/observability/contextBinding.ts b/apps/app/src/observability/contextBinding.ts new file mode 100644 index 00000000..ef72cec0 --- /dev/null +++ b/apps/app/src/observability/contextBinding.ts @@ -0,0 +1,106 @@ +import * as Sentry from '@sentry/vue' +import type { RouteLocationNormalized, Router } from 'vue-router' +import { useAuthStore } from '@/stores/useAuthStore' +import { useOrganisationStore } from '@/stores/useOrganisationStore' + +/** + * Installs a Vue Router beforeEach guard that binds Sentry scope tags per + * RFC-WS-7 §3.6. Frontend-equivalent of the backend AuthScopeContextListener. + * + * Three zones: + * - Token-based portal (route.meta.public === true && context === 'portal'): + * actor_scope=portal, no user_id/username — RFC §3.6 explicit. Stricter + * scrubbing applies via the {@link scrubEvent} hook regardless of zone. + * - Platform admin (path /platform/* with super_admin role): actor_scope= + * platform, full user context. No organisation_id (forced fallback would + * misattribute platform-scoped events). + * - Organizer (everything else, authenticated): actor_scope=organisation + * when an active organisation is selected; otherwise actor_scope=user. + * - Unauthenticated public pages (login, password reset): actor_scope= + * anonymous. + * + * Crewli's auth-store API differs from the RFC's speculative shape: + * - User identity: useAuthStore().user (User | null) + * - Role list: useAuthStore().appRoles (string[]) + * - Active organisation: useOrganisationStore().activeOrganisationId + * The guard reads these stores directly; pinia must be initialised before + * the first navigation, which is satisfied by registerPlugins() running + * before app.mount() in main.ts. + */ +export function installContextBinding(router: Router): void { + router.beforeEach(to => { + bindScope(to) + }) +} + +function bindScope(route: RouteLocationNormalized): void { + Sentry.getCurrentScope().clear() + + const scope = Sentry.getCurrentScope() + + // Always-present route-scope tags. Frontend never has http.method on a + // route navigation; that tag belongs to per-request fetch instrumentation + // which sentry-vue auto-attaches to fetch breadcrumbs. + scope.setTag('app', 'app') + scope.setTag('route_name', String(route.name ?? 'unnamed')) + + // Token-based portal flow (artist advance, public form fill). RFC §3.6: + // strict mode, no user_id, no username. The backend portal token already + // resolves the organisation via the matching artist/event row, so a + // captured frontend event correlates via request_id. + if (route.meta.public === true && route.meta.context === 'portal') { + scope.setTag('actor_scope', 'portal') + scope.setTag('actor_type', 'portal_token') + + return + } + + // Other public routes (login, forgot-password, register) — anonymous. + const auth = useAuthStore() + if (!auth.isAuthenticated || auth.user === null) { + scope.setTag('actor_scope', 'anonymous') + scope.setTag('actor_type', 'unauthenticated') + + return + } + + // Authenticated zones — bind user identity (RFC §3.8: ULID, never email). + scope.setUser({ + id: String(auth.user.id), + username: String(auth.user.id), + }) + scope.setTag('user_id', String(auth.user.id)) + + // Platform admin scope: super_admin on /platform/* routes. + if (route.path.startsWith('/platform') && auth.isSuperAdmin) { + scope.setTag('actor_scope', 'platform') + scope.setTag('actor_type', 'super_admin') + + return + } + + // Organizer scope. Tag actor_scope=organisation when an active org is + // selected (organisation_id from useOrganisationStore mirrors backend + // route-param resolution). Otherwise actor_scope=user — Crewli's + // many-to-many user-org model has no reliable single-org hint without + // the active selection. + const org = useOrganisationStore() + if (org.activeOrganisationId !== null && org.activeOrganisationId !== '') { + scope.setTag('actor_scope', 'organisation') + scope.setTag('organisation_id', String(org.activeOrganisationId)) + } + else { + scope.setTag('actor_scope', 'user') + } + + scope.setTag('actor_type', resolveActorType(auth.appRoles)) +} + +function resolveActorType(roles: readonly string[]): string { + if (roles.includes('super_admin')) + return 'super_admin' + if (roles.includes('org_admin')) + return 'organizer_admin' + + return 'org_member' +} diff --git a/apps/app/src/observability/index.ts b/apps/app/src/observability/index.ts new file mode 100644 index 00000000..d80a3a9f --- /dev/null +++ b/apps/app/src/observability/index.ts @@ -0,0 +1,4 @@ +export { installContextBinding } from './contextBinding' +export { scrubEvent } from './scrubber' +export { initSentry } from './sentry' +export type { SentryInitOptions } from './sentry' diff --git a/apps/app/src/observability/scrubber.ts b/apps/app/src/observability/scrubber.ts new file mode 100644 index 00000000..c9fdf38e --- /dev/null +++ b/apps/app/src/observability/scrubber.ts @@ -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) + + 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; 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).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).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 = {} + for (const [key, value] of Object.entries(data as Record)) { + 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): Record { + const result: Record = {} + 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() +} diff --git a/apps/app/src/observability/sentry.ts b/apps/app/src/observability/sentry.ts new file mode 100644 index 00000000..1d0bce8f --- /dev/null +++ b/apps/app/src/observability/sentry.ts @@ -0,0 +1,62 @@ +import * as Sentry from '@sentry/vue' +import type { App } from 'vue' +import type { Router } from 'vue-router' +import { scrubEvent } from './scrubber' + +export interface SentryInitOptions { + app: App + router: Router + dsn: string + release: string + environment: string +} + +/** + * Initialises @sentry/vue for the SPA per RFC-WS-7 §3.2-§3.9. + * + * - Empty DSN → no-op (RFC §3.3, mirrors backend). + * - Errors-only — tracesSampleRate / profilesSampleRate hard-pinned to 0 + * (RFC §2 amendment B). + * - sendDefaultPii=false (RFC §3.7 / §3.8); user identity is bound + * explicitly by {@link installContextBinding} as a ULID-only object. + * - app=app initial scope tag (RFC §3.6) so GlitchTip can filter + * frontend vs backend events. + * - PII scrubbing via {@link scrubEvent} as the beforeSend hook (RFC §3.7 + * frontend block). + */ +export function initSentry(options: SentryInitOptions): void { + if (options.dsn === '') + return + + Sentry.init({ + app: options.app, + dsn: options.dsn, + release: options.release === '' ? undefined : options.release, + environment: options.environment, + + // RFC §2 amendment B — errors-only. + tracesSampleRate: 0, + profilesSampleRate: 0, + + // RFC §3.7 / §3.8: never let Sentry's auto-context capture IP, locals + // from stack frames, or the User session-cookie payload. + sendDefaultPii: false, + + // RFC §3.7 frontend point 5: console-logging integration off in prod + // (info / debug breadcrumbs may include user data through formatted + // arguments). Keep the BrowserApiErrors/Vue/global integrations. + integrations: defaults => defaults.filter(i => i.name !== 'Console'), + + // RFC §3.7 frontend block — scrubber applied on every event. + beforeSend: scrubEvent, + + // RFC §3.6 — route-scope baseline tag. AuthScopeContextListener-style + // binding of actor_scope / user_id / actor_type happens per route + // navigation in {@link installContextBinding}. + initialScope: { + tags: { + app: 'app', + }, + }, + }) +} diff --git a/apps/app/vite.config.ts b/apps/app/vite.config.ts index a34431b1..41dd4f6f 100644 --- a/apps/app/vite.config.ts +++ b/apps/app/vite.config.ts @@ -127,6 +127,11 @@ export default defineConfig({ }, build: { chunkSizeWarningLimit: 5000, + + // RFC-WS-7 §3.5 — sourcemaps generated at build, uploaded to GlitchTip + // by deploy.sh, then `find dist -name '*.map' -delete` strips them + // before nginx serves dist/. No public-mapped sources on production. + sourcemap: true, }, optimizeDeps: { exclude: ['vuetify'],