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' }