diff --git a/cli/src/ai/telemetry.ts b/cli/src/ai/telemetry.ts index 4113d79d6a..1610be7509 100644 --- a/cli/src/ai/telemetry.ts +++ b/cli/src/ai/telemetry.ts @@ -37,7 +37,8 @@ export async function trackAiAnalysisChoice(input: TrackAiAnalysisChoiceInput): channel: 'build-lifecycle', icon: '๐Ÿค–', notify: false, - user_id: input.orgId, + org_id: input.orgId, + tracking_version: 2, tags: { app_id: input.appId, platform: input.platform, @@ -76,7 +77,8 @@ export async function trackAiAnalysisResult(input: TrackAiAnalysisResultInput): channel: 'build-lifecycle', icon: '๐Ÿค–', notify: false, - user_id: input.orgId, + org_id: input.orgId, + tracking_version: 2, tags, }) } diff --git a/cli/src/build/credentials-command.ts b/cli/src/build/credentials-command.ts index 3f40316df4..5fe6fae5c1 100644 --- a/cli/src/build/credentials-command.ts +++ b/cli/src/build/credentials-command.ts @@ -488,7 +488,8 @@ export async function saveCredentialsCommand(options: SaveCredentialsOptions): P channel: 'credentials', event: 'Credentials saved', icon: '๐Ÿ”', - user_id: orgId, + org_id: orgId, + tracking_version: 2, tags: { 'app-id': appId, 'platform': platform, diff --git a/cli/src/build/onboarding/telemetry.ts b/cli/src/build/onboarding/telemetry.ts index fe86a52d6e..00a4cdc09c 100644 --- a/cli/src/build/onboarding/telemetry.ts +++ b/cli/src/build/onboarding/telemetry.ts @@ -56,7 +56,8 @@ export async function trackBuilderOnboardingStep(input: TrackBuilderOnboardingSt channel: 'builder-onboarding', icon: '๐Ÿงญ', notify: false, - user_id: input.orgId, + org_id: input.orgId, + tracking_version: 2, tags, }) } @@ -83,7 +84,8 @@ export async function trackBuilderOnboardingAction(input: TrackBuilderOnboarding channel: 'builder-onboarding', icon: '๐Ÿงญ', notify: false, - user_id: input.orgId, + org_id: input.orgId, + tracking_version: 2, tags, }) } diff --git a/cli/src/build/request.ts b/cli/src/build/request.ts index f975c72502..2b8e3d524e 100644 --- a/cli/src/build/request.ts +++ b/cli/src/build/request.ts @@ -1658,7 +1658,8 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO channel: 'native-builder', event: 'Build requested', icon: '๐Ÿ—๏ธ', - user_id: orgId, + org_id: orgId, + tracking_version: 2, tags: { 'app-id': appId, 'platform': platform, @@ -2165,7 +2166,8 @@ export async function requestBuildInternal(appId: string, options: BuildRequestO channel: 'native-builder', event: finalStatus === 'succeeded' ? 'Build succeeded' : 'Build failed', icon: finalStatus === 'succeeded' ? 'โœ…' : 'โŒ', - user_id: orgId, + org_id: orgId, + tracking_version: 2, tags: { 'app-id': appId, 'platform': platform, diff --git a/cli/src/build/telemetry.ts b/cli/src/build/telemetry.ts index f14d1f0a82..02dc4e25aa 100644 --- a/cli/src/build/telemetry.ts +++ b/cli/src/build/telemetry.ts @@ -79,7 +79,8 @@ export async function trackBuilderUpload(input: TrackBuilderUploadInput): Promis channel: 'build-lifecycle', icon: ICON_BY_PHASE[input.phase], notify: false, - user_id: input.orgId, + org_id: input.orgId, + tracking_version: 2, tags, }) } diff --git a/cli/src/utils.ts b/cli/src/utils.ts index 90609ce692..01e9d21bef 100644 --- a/cli/src/utils.ts +++ b/cli/src/utils.ts @@ -158,6 +158,14 @@ interface TrackOptions { * example: "user-123" */ user_id?: string + /** + * Organization ID for actor-scoped tracking. + */ + org_id?: string + /** + * Tracking payload contract version. + */ + tracking_version?: number /** * Event icon (emoji) * must be a single emoji diff --git a/cli/test/test-onboarding-telemetry.mjs b/cli/test/test-onboarding-telemetry.mjs index 9078ce7cb0..1ea01ad3a2 100644 --- a/cli/test/test-onboarding-telemetry.mjs +++ b/cli/test/test-onboarding-telemetry.mjs @@ -47,7 +47,8 @@ try { assert.equal(body.event, 'Builder Onboarding Action') assert.equal(body.channel, 'builder-onboarding') assert.equal(body.notify, false) - assert.equal(body.user_id, 'org-id') + assert.equal(body.org_id, 'org-id') + assert.equal(body.tracking_version, 2) assert.deepEqual(body.tags, { accepted: 'true', action: 'android_sa_method_selected', diff --git a/src/components/dashboard/DemoOnboardingModal.vue b/src/components/dashboard/DemoOnboardingModal.vue index c05e95e54c..87a905a175 100644 --- a/src/components/dashboard/DemoOnboardingModal.vue +++ b/src/components/dashboard/DemoOnboardingModal.vue @@ -184,15 +184,19 @@ const confettiTimer = ref(null) const timers = ref([]) function trackNoAppDemoEvent(event: string, tags: Record = {}) { - sendEvent({ - channel: 'demo-onboarding', - event, - icon: '๐Ÿงช', - user_id: organizationStore.currentOrganization?.gid, - notify: false, - tags, - }).catch() - pushEvent(`user:${event}`, config.supaHost) + const orgId = organizationStore.currentOrganization?.gid + if (orgId) { + sendEvent({ + channel: 'demo-onboarding', + event, + icon: '๐Ÿงช', + org_id: orgId, + tracking_version: 2, + notify: false, + tags, + }).catch() + pushEvent(`user:${event}`, config.supaHost, { org_id: orgId }) + } } function trackNoAppDemoStepEvent(stepId: DemoStep | 'global', action: string, tags: Record = {}) { diff --git a/src/components/dashboard/InviteTeammateModal.vue b/src/components/dashboard/InviteTeammateModal.vue index ff09f4e328..c7c6b5e099 100644 --- a/src/components/dashboard/InviteTeammateModal.vue +++ b/src/components/dashboard/InviteTeammateModal.vue @@ -174,13 +174,17 @@ function completeInviteSuccess(payload: InviteSuccessPayload) { isEmailDialogOpen.value = false isFullDetailsDialogOpen.value = false emit('success', payload) - sendEvent({ - channel: 'onboarding-v2', - event: `onboarding-step-invite-teammate`, - icon: '๐Ÿ‘ฅ', - user_id: organizationStore.currentOrganization?.gid, - notify: false, - }).catch() + const orgId = organizationStore.currentOrganization?.gid + if (orgId) { + sendEvent({ + channel: 'onboarding-v2', + event: `onboarding-step-invite-teammate`, + icon: '๐Ÿ‘ฅ', + org_id: orgId, + tracking_version: 2, + notify: false, + }).catch() + } } async function handleEmailSubmit() { diff --git a/src/components/dashboard/StepsApp.vue b/src/components/dashboard/StepsApp.vue index b6a9316905..895660e36e 100644 --- a/src/components/dashboard/StepsApp.vue +++ b/src/components/dashboard/StepsApp.vue @@ -84,14 +84,18 @@ function stepToName(stepNumber: number): string { function setLog() { if (props.onboarding && main.user?.id) { - sendEvent({ - channel: 'onboarding-v2', - event: `onboarding-step-${stepToName(step.value)}`, - icon: '๐Ÿ‘ถ', - user_id: organizationStore.currentOrganization?.gid, - notify: false, - }).catch() - pushEvent(`user:onboarding-step-${stepToName(step.value)}`, config.supaHost) + const orgId = organizationStore.currentOrganization?.gid + if (orgId) { + sendEvent({ + channel: 'onboarding-v2', + event: `onboarding-step-${stepToName(step.value)}`, + icon: '๐Ÿ‘ถ', + org_id: orgId, + tracking_version: 2, + notify: false, + }).catch() + pushEvent(`user:onboarding-step-${stepToName(step.value)}`, config.supaHost, { org_id: orgId }) + } } if (step.value === 2) { console.log('Finished onboarding for app ID:', appId.value) @@ -121,14 +125,18 @@ function goToNextStep(scrollTargetId?: string) { function openInviteDialog() { inviteModalRef.value?.openDialog() - sendEvent({ - channel: 'onboarding-v2', - event: `onboarding-alternative-send-invite`, - icon: '๐Ÿ‘ถ', - user_id: organizationStore.currentOrganization?.gid, - notify: false, - }).catch() - pushEvent(`user:onboarding-alternative-send-invite`, config.supaHost) + const orgId = organizationStore.currentOrganization?.gid + if (orgId) { + sendEvent({ + channel: 'onboarding-v2', + event: `onboarding-alternative-send-invite`, + icon: '๐Ÿ‘ถ', + org_id: orgId, + tracking_version: 2, + notify: false, + }).catch() + pushEvent(`user:onboarding-alternative-send-invite`, config.supaHost, { org_id: orgId }) + } } function onInviteSuccess() { @@ -153,10 +161,11 @@ async function createDemoApp() { channel: 'onboarding-v2', event: 'onboarding-create-demo-app', icon: '๐Ÿ‘ถ', - user_id: orgId, + org_id: orgId, + tracking_version: 2, notify: false, }).catch() - pushEvent('user:onboarding-create-demo-app', config.supaHost) + pushEvent('user:onboarding-create-demo-app', config.supaHost, { org_id: orgId }) const { data, error } = await supabase.functions.invoke('app/demo', { method: 'POST', diff --git a/src/components/dashboard/StepsBuild.vue b/src/components/dashboard/StepsBuild.vue index 422e2865ac..18e8df5d1a 100644 --- a/src/components/dashboard/StepsBuild.vue +++ b/src/components/dashboard/StepsBuild.vue @@ -141,14 +141,18 @@ function stepToName(stepNumber: number): string { function setLog() { if (initialOnboarding && main.user?.id) { - sendEvent({ - channel: 'onboarding-build', - event: `onboarding-build-step-${stepToName(step.value)}`, - icon: 'build', - user_id: organizationStore.currentOrganization?.gid, - notify: false, - }).catch() - pushEvent(`user:onboarding-build-${stepToName(step.value)}`, config.supaHost) + const orgId = organizationStore.currentOrganization?.gid + if (orgId) { + sendEvent({ + channel: 'onboarding-build', + event: `onboarding-build-step-${stepToName(step.value)}`, + icon: 'build', + org_id: orgId, + tracking_version: 2, + notify: false, + }).catch() + pushEvent(`user:onboarding-build-${stepToName(step.value)}`, config.supaHost, { org_id: orgId }) + } } if (step.value === completedStepIndex.value) { emit('done') diff --git a/src/components/dashboard/StepsBundle.vue b/src/components/dashboard/StepsBundle.vue index cf78d28588..00c42b2233 100644 --- a/src/components/dashboard/StepsBundle.vue +++ b/src/components/dashboard/StepsBundle.vue @@ -71,14 +71,18 @@ function stepToName(stepNumber: number): string { function setLog() { console.log('setLog', props.onboarding, main.user?.id, step.value) if (props.onboarding && main.user?.id) { - sendEvent({ - channel: 'onboarding-bundle', - event: `onboarding-bundle-step-${stepToName(step.value)}`, - icon: '๐Ÿ‘ถ', - user_id: organizationStore.currentOrganization?.gid, - notify: false, - }).catch() - pushEvent(`user:onboarding-bundle-${stepToName(step.value)}`, config.supaHost) + const orgId = organizationStore.currentOrganization?.gid + if (orgId) { + sendEvent({ + channel: 'onboarding-bundle', + event: `onboarding-bundle-step-${stepToName(step.value)}`, + icon: '๐Ÿ‘ถ', + org_id: orgId, + tracking_version: 2, + notify: false, + }).catch() + pushEvent(`user:onboarding-bundle-${stepToName(step.value)}`, config.supaHost, { org_id: orgId }) + } } if (step.value === 2) { emit('done') diff --git a/src/components/dashboard/TrialBanner.vue b/src/components/dashboard/TrialBanner.vue index 7c603b2fb8..956d2cefd3 100644 --- a/src/components/dashboard/TrialBanner.vue +++ b/src/components/dashboard/TrialBanner.vue @@ -36,6 +36,7 @@ const config = getLocalConfig() function trackBannerEvent(eventName: string) { const org = currentOrg.value pushEvent(eventName, config.supaHost, { + ...(org?.gid ? { org_id: org.gid } : {}), trial_days_left: org?.trial_left ?? 0, org_gid: org?.gid ?? '', }) diff --git a/src/pages/settings/organization/Plans.vue b/src/pages/settings/organization/Plans.vue index bf51bc5e2a..a9f520f266 100644 --- a/src/pages/settings/organization/Plans.vue +++ b/src/pages/settings/organization/Plans.vue @@ -369,13 +369,17 @@ watchEffect(async () => { } loadData(true) - sendEvent({ - channel: 'usage', - event: 'User visit', - icon: '๐Ÿ’ณ', - user_id: currentOrganization.value?.gid, - notify: false, - }).catch() + const orgId = currentOrganization.value?.gid + if (orgId) { + sendEvent({ + channel: 'usage', + event: 'User visit', + icon: '๐Ÿ’ณ', + org_id: orgId, + tracking_version: 2, + notify: false, + }).catch() + } } } }) diff --git a/src/pages/settings/organization/Usage.vue b/src/pages/settings/organization/Usage.vue index 45ced12204..0a2fb4d785 100644 --- a/src/pages/settings/organization/Usage.vue +++ b/src/pages/settings/organization/Usage.vue @@ -39,13 +39,17 @@ watchEffect(async () => { toast.success(t('usage-success')) } else if (main.user?.id) { - sendEvent({ - channel: 'usage', - event: 'User visit', - icon: '๐Ÿ’ณ', - user_id: currentOrganization.value?.gid, - notify: false, - }).catch() + const orgId = currentOrganization.value?.gid + if (orgId) { + sendEvent({ + channel: 'usage', + event: 'User visit', + icon: '๐Ÿ’ณ', + org_id: orgId, + tracking_version: 2, + notify: false, + }).catch() + } } } }) diff --git a/src/services/tracking.ts b/src/services/tracking.ts index 2cb3fc24b3..5262922611 100644 --- a/src/services/tracking.ts +++ b/src/services/tracking.ts @@ -28,6 +28,14 @@ interface TrackOptions { * example: "user-123" */ user_id?: string + /** + * Organization ID for actor-scoped tracking. + */ + org_id?: string + /** + * Tracking payload contract version. + */ + tracking_version?: number /** * Event icon (emoji) * must be a single emoji diff --git a/supabase/functions/_backend/private/events.ts b/supabase/functions/_backend/private/events.ts index 557ebae47f..e20b9f2ce4 100644 --- a/supabase/functions/_backend/private/events.ts +++ b/supabase/functions/_backend/private/events.ts @@ -5,6 +5,7 @@ import type { BentoTrackingPayload } from '../utils/tracking.ts' import { Hono } from 'hono/tiny' import { BRES, parseBody, quickError, simpleError, useCors } from '../utils/hono.ts' import { middlewareV2 } from '../utils/hono_middleware.ts' +import { cloudlog } from '../utils/logging.ts' import { checkPermission } from '../utils/rbac.ts' import { broadcastCLIEvent } from '../utils/realtime_broadcast.ts' import { hasOrgRight, hasOrgRightApikey, supabaseWithAuth } from '../utils/supabase.ts' @@ -24,15 +25,62 @@ interface ResolvedTrackingId { orgId?: string } +interface TrackEventBody extends TrackOptions { + notifyConsole?: boolean + org_id?: string + tracking_version?: number | string +} + +function isTrackingV2(version: unknown) { + return version === 2 || version === '2' +} + async function resolveTrackingUserId( c: Context, requestedUserId: string | undefined, + requestedOrgId: string | undefined, appId: string | undefined, + trackingV2 = false, notifyConsole = false, ): Promise { const forbiddenError = notifyConsole ? 'Forbidden' : 'no_permission' const authUserId = c.get('auth')?.userId ?? '' + if (trackingV2) { + if (!requestedOrgId) { + cloudlog({ + requestId: c.get('requestId'), + message: 'tracking v2 event missing org_id; sending actor-scoped event without organization group', + }) + return { trackingUserId: authUserId } + } + + if (appId) { + if (!(await checkPermission(c, 'app.read', { appId }))) { + throw quickError(403, forbiddenError, 'You cannot send events for this organization') + } + + const supabase = supabaseWithAuth(c, c.get('auth')!) + const { data: app, error } = await supabase + .from('apps') + .select('owner_org') + .eq('app_id', appId) + .single() + + if (error || !app || app.owner_org !== requestedOrgId) { + throw quickError(403, forbiddenError, 'You cannot send events for this organization') + } + + return { trackingUserId: authUserId, orgId: requestedOrgId } + } + + if (await checkPermission(c, 'org.read', { orgId: requestedOrgId })) { + return { trackingUserId: authUserId, orgId: requestedOrgId } + } + + throw quickError(403, forbiddenError, 'You cannot send events for this organization') + } + if (!requestedUserId || requestedUserId === authUserId) { return { trackingUserId: authUserId } } @@ -77,13 +125,19 @@ function canAccessRequestedOrg(c: Context, orgId: string } app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => { - const body = await parseBody(c) - const { notifyConsole = false, ...trackOptions } = body - const requestedOrgId = body.notifyConsole && typeof body.user_id === 'string' && body.user_id.length > 0 - ? body.user_id - : undefined + const body = await parseBody(c) + const { notifyConsole = false, org_id: _orgId, tracking_version: _trackingVersion, ...trackOptions } = body + const trackingV2 = isTrackingV2(body.tracking_version) + const requestedOrgId = trackingV2 && typeof body.org_id === 'string' && body.org_id.length > 0 + ? body.org_id + : body.notifyConsole && typeof body.user_id === 'string' && body.user_id.length > 0 + ? body.user_id + : undefined - if (requestedOrgId && !(await canAccessRequestedOrg(c, requestedOrgId))) + // Legacy notifyConsole still sends the target org in `user_id`, so keep this + // preflight scoped to notifyConsole. Non-notify v2 events validate `org_id` + // inside resolveTrackingUserId(), where app ownership and org access diverge. + if (body.notifyConsole && requestedOrgId && !(await canAccessRequestedOrg(c, requestedOrgId))) throw quickError(403, 'Forbidden', 'You cannot send events for this organization') const requestedUserId = typeof body.user_id === 'string' ? body.user_id : undefined @@ -92,8 +146,15 @@ app.post('/', middlewareV2(['read', 'write', 'all', 'upload']), async (c) => { : typeof body.tags?.app_id === 'string' ? body.tags.app_id : undefined - const { trackingUserId, orgId: verifiedOrgId } = await resolveTrackingUserId(c, requestedUserId, appId, Boolean(body.notifyConsole)) - const trackedBody = requestedUserId ? { ...trackOptions, user_id: trackingUserId } : trackOptions + const { trackingUserId, orgId: verifiedOrgId } = await resolveTrackingUserId(c, requestedUserId, requestedOrgId, appId, trackingV2, Boolean(body.notifyConsole)) + const trackedTags = trackingV2 && verifiedOrgId + ? { ...(trackOptions.tags || {}), org_id: verifiedOrgId } + : trackOptions.tags + const trackedBody = trackingV2 + ? { ...trackOptions, user_id: trackingUserId, tags: trackedTags } + : requestedUserId + ? { ...trackOptions, user_id: trackingUserId } + : trackOptions // notifyConsole: broadcast to Supabase Realtime only, skip all tracking if (notifyConsole) { diff --git a/supabase/functions/_backend/public/build/ai_analyze.ts b/supabase/functions/_backend/public/build/ai_analyze.ts index 7b329d89f2..f8c188c02b 100644 --- a/supabase/functions/_backend/public/build/ai_analyze.ts +++ b/supabase/functions/_backend/public/build/ai_analyze.ts @@ -25,6 +25,7 @@ interface EmitAiAnalysisResultInput { jobId: string result: AiAnalysisResult ownerOrg?: string + userId: string logsBytes: number durationMs?: number } @@ -44,6 +45,8 @@ async function emitAiAnalysisResult(c: Context, input: EmitAiAnalysisResultInput result: input.result, logs_bytes: String(input.logsBytes), } + if (input.ownerOrg) + tags.org_id = input.ownerOrg if (input.durationMs !== undefined && Number.isFinite(input.durationMs)) tags.duration_ms = String(Math.round(input.durationMs)) @@ -56,7 +59,7 @@ async function emitAiAnalysisResult(c: Context, input: EmitAiAnalysisResultInput channel: 'build-lifecycle', icon: '๐Ÿค–', notify: false, - user_id: input.ownerOrg, + user_id: input.userId, groups: input.ownerOrg ? { organization: input.ownerOrg } : undefined, tags, }) @@ -90,7 +93,7 @@ export async function aiAnalyzeBuild( user_id: apikey.user_id, }) // No row yet โ€” `ownerOrg` is unknown for this branch. - await emitAiAnalysisResult(c, { appId, jobId, result: 'unauthorized', logsBytes }) + await emitAiAnalysisResult(c, { appId, jobId, result: 'unauthorized', userId: apikey.user_id, logsBytes }) throw simpleError('unauthorized', 'You do not have permission to analyze this build') } @@ -110,7 +113,7 @@ export async function aiAnalyzeBuild( job_id: jobId, error: selectErr.message, }) - await emitAiAnalysisResult(c, { appId, jobId, result: 'builder_error', logsBytes }) + await emitAiAnalysisResult(c, { appId, jobId, result: 'builder_error', userId: apikey.user_id, logsBytes }) throw simpleError('internal_error', 'Failed to fetch build request') } @@ -123,19 +126,19 @@ export async function aiAnalyzeBuild( user_id: apikey.user_id, }) // Row is null โ€” `ownerOrg` cannot be resolved for this branch. - await emitAiAnalysisResult(c, { appId, jobId, result: 'unauthorized', logsBytes }) + await emitAiAnalysisResult(c, { appId, jobId, result: 'unauthorized', userId: apikey.user_id, logsBytes }) throw simpleError('unauthorized', 'You do not have permission to analyze this build') } const ownerOrg = row.owner_org if (row.status !== 'failed') { - await emitAiAnalysisResult(c, { appId, jobId, result: 'invalid_state', ownerOrg, logsBytes }) + await emitAiAnalysisResult(c, { appId, jobId, result: 'invalid_state', ownerOrg, userId: apikey.user_id, logsBytes }) throw simpleError('invalid_state', 'AI analysis only available for failed builds') } if (row.ai_analyzed === true) { - await emitAiAnalysisResult(c, { appId, jobId, result: 'already_analyzed', ownerOrg, logsBytes }) + await emitAiAnalysisResult(c, { appId, jobId, result: 'already_analyzed', ownerOrg, userId: apikey.user_id, logsBytes }) // 409 (not the simpleError default of 400) โ€” CLI branches on res.status === 409 for this case throw quickError(409, 'already_analyzed', 'AI analysis already requested for this job') } @@ -150,10 +153,11 @@ export async function aiAnalyzeBuild( channel: 'build-lifecycle', icon: '๐Ÿค–', notify: false, - user_id: ownerOrg, + user_id: apikey.user_id, groups: { organization: ownerOrg }, tags: { app_id: appId, + org_id: ownerOrg, job_id: jobId, logs_bytes: String(logsBytes), }, @@ -171,7 +175,7 @@ export async function aiAnalyzeBuild( const builderUrl = getEnv(c, 'BUILDER_URL') const builderApiKey = getEnv(c, 'BUILDER_API_KEY') if (!builderUrl || !builderApiKey) { - await emitAiAnalysisResult(c, { appId, jobId, result: 'config_error', ownerOrg, logsBytes }) + await emitAiAnalysisResult(c, { appId, jobId, result: 'config_error', ownerOrg, userId: apikey.user_id, logsBytes }) throw simpleError('config_error', 'Builder service not configured') } @@ -200,7 +204,7 @@ export async function aiAnalyzeBuild( job_id: jobId, error: err instanceof Error ? err.message : String(err), }) - await emitAiAnalysisResult(c, { appId, jobId, result: 'builder_error', ownerOrg, logsBytes, durationMs }) + await emitAiAnalysisResult(c, { appId, jobId, result: 'builder_error', ownerOrg, userId: apikey.user_id, logsBytes, durationMs }) throw simpleError('builder_error', isTimeout ? 'AI analysis timed out' : 'AI analysis request failed') } @@ -214,7 +218,7 @@ export async function aiAnalyzeBuild( status: builderResp.status, error: errText, }) - await emitAiAnalysisResult(c, { appId, jobId, result: 'builder_error', ownerOrg, logsBytes, durationMs }) + await emitAiAnalysisResult(c, { appId, jobId, result: 'builder_error', ownerOrg, userId: apikey.user_id, logsBytes, durationMs }) throw simpleError('builder_error', `AI analysis failed: ${errText}`) } @@ -226,7 +230,7 @@ export async function aiAnalyzeBuild( message: 'Builder AI analyze returned malformed body', job_id: jobId, }) - await emitAiAnalysisResult(c, { appId, jobId, result: 'builder_error', ownerOrg, logsBytes, durationMs }) + await emitAiAnalysisResult(c, { appId, jobId, result: 'builder_error', ownerOrg, userId: apikey.user_id, logsBytes, durationMs }) throw simpleError('builder_error', 'AI analysis returned malformed response') } @@ -258,7 +262,7 @@ export async function aiAnalyzeBuild( user_id: apikey.user_id, }) - await emitAiAnalysisResult(c, { appId, jobId, result: 'success', ownerOrg, logsBytes, durationMs }) + await emitAiAnalysisResult(c, { appId, jobId, result: 'success', ownerOrg, userId: apikey.user_id, logsBytes, durationMs }) return c.json({ analysis: result.analysis }, 200) } diff --git a/supabase/functions/_backend/public/build/request.ts b/supabase/functions/_backend/public/build/request.ts index ee18171819..296a1a2047 100644 --- a/supabase/functions/_backend/public/build/request.ts +++ b/supabase/functions/_backend/public/build/request.ts @@ -323,10 +323,11 @@ export async function requestBuild( channel: 'build-lifecycle', icon: '๐Ÿ› ๏ธ', notify: false, - user_id: org_id, + user_id: apikey.user_id, groups: { organization: org_id }, tags: { app_id, + org_id, platform, build_mode, }, diff --git a/supabase/functions/_backend/public/build/start.ts b/supabase/functions/_backend/public/build/start.ts index ffc94c7357..bf4da6dd8a 100644 --- a/supabase/functions/_backend/public/build/start.ts +++ b/supabase/functions/_backend/public/build/start.ts @@ -91,14 +91,14 @@ async function markBuildAsFailed( // status write uses service role because API-key RLS must stay read-only here. // // Fetch the row first to capture the fields we need for the lifecycle event - // (previousStatus for the CAS guard + platform/build_mode/owner_org for the + // (previousStatus for the CAS guard + platform/build_mode/owner_org/requested_by for the // payload). Without this, marking a build failed here would silently miss // the `Build Failed` transition event, leaving the lifecycle funnel // incomplete for the builder-rejection and outer-catch paths. const adminClient = supabaseAdmin(c) const { data: row, error: selectError } = await adminClient .from('build_requests') - .select('status, platform, build_mode, owner_org') + .select('status, platform, build_mode, owner_org, requested_by') .eq('builder_job_id', jobId) .eq('app_id', appId) .maybeSingle() @@ -168,6 +168,7 @@ async function markBuildAsFailed( platform: row.platform, build_mode: row.build_mode, owner_org: row.owner_org, + requested_by: row.requested_by, }, }) } @@ -214,7 +215,7 @@ export async function startBuild( const { data: buildRequest, error: buildRequestError } = await supabase .from('build_requests') - .select('id, app_id, owner_org, status, platform, build_mode') + .select('id, app_id, owner_org, requested_by, status, platform, build_mode') .eq('builder_job_id', jobId) .eq('app_id', appId) .maybeSingle() @@ -333,6 +334,7 @@ export async function startBuild( platform: buildRequest.platform, build_mode: buildRequest.build_mode, owner_org: buildRequest.owner_org, + requested_by: buildRequest.requested_by, }, }) } diff --git a/supabase/functions/_backend/public/build/status.ts b/supabase/functions/_backend/public/build/status.ts index 0454ff129b..1452b3088a 100644 --- a/supabase/functions/_backend/public/build/status.ts +++ b/supabase/functions/_backend/public/build/status.ts @@ -97,7 +97,7 @@ export async function getBuildStatus( // This prevents cross-app access by mixing an allowed app_id with another app's job_id. const { data: buildRequest, error: buildRequestError } = await supabase .from('build_requests') - .select('app_id, owner_org, platform, status, build_mode') + .select('app_id, owner_org, requested_by, platform, status, build_mode') .eq('builder_job_id', job_id) .maybeSingle() @@ -250,6 +250,7 @@ export async function getBuildStatus( platform: buildRequest.platform, build_mode: buildRequest.build_mode, owner_org: buildRequest.owner_org, + requested_by: buildRequest.requested_by, }, }) } diff --git a/supabase/functions/_backend/triggers/cron_reconcile_build_status.ts b/supabase/functions/_backend/triggers/cron_reconcile_build_status.ts index f308511195..462abd9561 100644 --- a/supabase/functions/_backend/triggers/cron_reconcile_build_status.ts +++ b/supabase/functions/_backend/triggers/cron_reconcile_build_status.ts @@ -274,6 +274,7 @@ app.post('/', middlewareAPISecret, async (c) => { platform: build.platform, build_mode: build.build_mode, owner_org: build.owner_org, + requested_by: build.requested_by, }, }) } diff --git a/supabase/functions/_backend/utils/build_tracking.ts b/supabase/functions/_backend/utils/build_tracking.ts index 928310e051..dcc0ecc1f0 100644 --- a/supabase/functions/_backend/utils/build_tracking.ts +++ b/supabase/functions/_backend/utils/build_tracking.ts @@ -65,6 +65,7 @@ interface BuildRowForTracking { platform: string build_mode: string owner_org: string + requested_by: string } export interface EmitBuildTransitionInput { @@ -112,6 +113,7 @@ export async function emitBuildTransitionEvent(c: Context, input: EmitBuildTrans const tags: Record = { app_id: input.build.app_id, + org_id: input.build.owner_org, platform: input.build.platform, build_mode: input.build.build_mode, } @@ -138,7 +140,7 @@ export async function emitBuildTransitionEvent(c: Context, input: EmitBuildTrans channel: 'build-lifecycle', icon: ICON_BY_TRANSITION[transition], notify: false, - user_id: input.build.owner_org, + user_id: input.build.requested_by, groups: { organization: input.build.owner_org }, tags, }) diff --git a/tests/ai-analysis-telemetry.unit.test.ts b/tests/ai-analysis-telemetry.unit.test.ts index d0b3863cc1..3881dbb420 100644 --- a/tests/ai-analysis-telemetry.unit.test.ts +++ b/tests/ai-analysis-telemetry.unit.test.ts @@ -37,7 +37,8 @@ describe('trackAiAnalysisChoice', () => { channel: 'build-lifecycle', icon: '๐Ÿค–', notify: false, - user_id: 'org-uuid-1', + org_id: 'org-uuid-1', + tracking_version: 2, tags: { app_id: 'com.example.app', platform: 'ios', @@ -88,7 +89,8 @@ describe('trackAiAnalysisResult', () => { channel: 'build-lifecycle', icon: '๐Ÿค–', notify: false, - user_id: 'org-uuid-1', + org_id: 'org-uuid-1', + tracking_version: 2, tags: { app_id: 'com.example.app', platform: 'ios', diff --git a/tests/build-ai-analyze.test.ts b/tests/build-ai-analyze.test.ts index 95b354dddc..5b19aa6674 100644 --- a/tests/build-ai-analyze.test.ts +++ b/tests/build-ai-analyze.test.ts @@ -95,7 +95,7 @@ describe('aiAnalyzeBuild', () => { expect(results).toHaveLength(1) const [, payload] = results[0] expect(payload.tags.result).toBe('unauthorized') - expect(payload.user_id).toBeUndefined() + expect(payload.user_id).toBe(apikey.user_id) expect(payload.groups).toBeUndefined() }) @@ -111,7 +111,7 @@ describe('aiAnalyzeBuild', () => { expect(results).toHaveLength(1) const [, payload] = results[0] expect(payload.tags.result).toBe('unauthorized') - expect(payload.user_id).toBeUndefined() + expect(payload.user_id).toBe(apikey.user_id) expect(payload.groups).toBeUndefined() }) @@ -127,8 +127,9 @@ describe('aiAnalyzeBuild', () => { const results = trackingCallsByEvent('AI Build Analysis Result') expect(results).toHaveLength(1) expect(results[0][1].tags.result).toBe('invalid_state') - expect(results[0][1].user_id).toBe(orgId) + expect(results[0][1].user_id).toBe(apikey.user_id) expect(results[0][1].groups).toEqual({ organization: orgId }) + expect(results[0][1].tags.org_id).toBe(orgId) }) it('throws already_analyzed when ai_analyzed is true; fires Result(already_analyzed) only (no Requested)', async () => { @@ -207,6 +208,7 @@ describe('aiAnalyzeBuild', () => { const results = trackingCallsByEvent('AI Build Analysis Result') expect(results).toHaveLength(1) expect(results[0][1].tags.result).toBe('config_error') - expect(results[0][1].user_id).toBe(orgId) + expect(results[0][1].user_id).toBe(apikey.user_id) + expect(results[0][1].tags.org_id).toBe(orgId) }) }) diff --git a/tests/build-lifecycle-emit.unit.test.ts b/tests/build-lifecycle-emit.unit.test.ts index d54850c22b..9d0dac9890 100644 --- a/tests/build-lifecycle-emit.unit.test.ts +++ b/tests/build-lifecycle-emit.unit.test.ts @@ -13,6 +13,7 @@ const baseBuild = { platform: 'ios', build_mode: 'release', owner_org: 'org-uuid-1', + requested_by: 'user-uuid-1', } function fakeContext() { @@ -40,10 +41,11 @@ describe('emitBuildTransitionEvent', () => { channel: 'build-lifecycle', icon: 'โณ', notify: false, - user_id: 'org-uuid-1', + user_id: 'user-uuid-1', groups: { organization: 'org-uuid-1' }, tags: { app_id: 'com.example.app', + org_id: 'org-uuid-1', platform: 'ios', build_mode: 'release', }, diff --git a/tests/build-start-log-token.test.ts b/tests/build-start-log-token.test.ts index 15916c3468..7a8b2d3db9 100644 --- a/tests/build-start-log-token.test.ts +++ b/tests/build-start-log-token.test.ts @@ -80,6 +80,7 @@ describe('build start direct log token', () => { id: '3eb4f870-720d-46b9-843f-2e6d57d54000', app_id: appId, owner_org: '3eb4f870-720d-46b9-843f-2e6d57d54001', + requested_by: userId, status: 'pending', platform: 'ios', build_mode: 'release', @@ -238,6 +239,7 @@ describe('build start direct log token', () => { platform: 'ios', build_mode: 'release', owner_org: '3eb4f870-720d-46b9-843f-2e6d57d54001', + requested_by: userId, }, error: null, }), diff --git a/tests/builder-onboarding-telemetry.unit.test.ts b/tests/builder-onboarding-telemetry.unit.test.ts index 60a5dc9626..caf5126d0b 100644 --- a/tests/builder-onboarding-telemetry.unit.test.ts +++ b/tests/builder-onboarding-telemetry.unit.test.ts @@ -31,7 +31,8 @@ describe('trackBuilderOnboardingStep', () => { channel: 'builder-onboarding', icon: '๐Ÿงญ', notify: false, - user_id: 'org-uuid-1', + org_id: 'org-uuid-1', + tracking_version: 2, tags: { step: 'api-key-instructions', platform: 'ios', diff --git a/tests/builder-upload-telemetry.unit.test.ts b/tests/builder-upload-telemetry.unit.test.ts index 48a7707b0c..801cb17fa3 100644 --- a/tests/builder-upload-telemetry.unit.test.ts +++ b/tests/builder-upload-telemetry.unit.test.ts @@ -59,7 +59,8 @@ describe('trackBuilderUpload', () => { channel: 'build-lifecycle', icon: 'โฌ†๏ธ', notify: false, - user_id: 'org-uuid-1', + org_id: 'org-uuid-1', + tracking_version: 2, tags: { app_id: 'com.example.app', platform: 'ios', diff --git a/tests/events.test.ts b/tests/events.test.ts index 30c9985229..4cdac8cf66 100644 --- a/tests/events.test.ts +++ b/tests/events.test.ts @@ -51,6 +51,83 @@ describe('[POST] /private/events operations', () => { expect(data.status).toBe('ok') }) + it('tracks v2 events with actor user and organization context', async () => { + const response = await fetch(`${BASE_URL}/private/events`, { + method: 'POST', + headers: { + capgkey: headers.Authorization, + }, + body: JSON.stringify({ + channel: 'test', + event: 'test_event_v2', + description: 'Testing v2 event tracking', + icon: '๐Ÿงช', + notify: false, + org_id: ORG_ID, + tracking_version: 2, + tags: { + app_id: APPNAME_EVENT, + test: true, + }, + }), + }) + + const data = await response.json() as { status: string } + expect(response.status).toBe(200) + expect(data.status).toBe('ok') + }) + + it('tracks v2 org-scoped events without an app id', async () => { + const response = await fetch(`${BASE_URL}/private/events`, { + method: 'POST', + headers: { + capgkey: headers.Authorization, + }, + body: JSON.stringify({ + channel: 'test', + event: 'test_event_v2_org_scoped', + description: 'Testing v2 org-scoped event tracking', + icon: '๐Ÿงช', + notify: false, + org_id: ORG_ID, + tracking_version: 2, + tags: { + test: true, + }, + }), + }) + + const data = await response.json() as { status: string } + expect(response.status).toBe(200) + expect(data.status).toBe('ok') + }) + + it('rejects v2 app-scoped events when the requested org does not own the app', async () => { + const response = await fetch(`${BASE_URL}/private/events`, { + method: 'POST', + headers: { + capgkey: headers.Authorization, + }, + body: JSON.stringify({ + channel: 'test', + event: 'test_event_v2', + description: 'Cross-org v2 spoof attempt', + icon: '๐Ÿงช', + notify: false, + org_id: NON_OWNER_ORG_ID, + tracking_version: 2, + tags: { + app_id: APPNAME_EVENT, + test: true, + }, + }), + }) + + const data = await response.json() as { error: string } + expect(response.status).toBe(403) + expect(data.error).toBe('no_permission') + }) + it('rejects notifyConsole broadcasts for foreign organizations', async () => { const response = await fetch(`${BASE_URL}/private/events`, { method: 'POST',