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>
107 lines
4.1 KiB
TypeScript
107 lines
4.1 KiB
TypeScript
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'
|
|
}
|