Skip to content

Commit c055b07

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@37b5fbd48e0b3f90e6b4e1088a02041d9f169a4a
1 parent 77d0244 commit c055b07

8 files changed

Lines changed: 284 additions & 61 deletions

File tree

‎bun.lock‎

Lines changed: 43 additions & 49 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎cli/src/__tests__/integration/login-flow-instrumentation.test.ts‎

Lines changed: 112 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,102 @@ const createTrackEventMock = () =>
2424
mock((_event: AnalyticsEvent, _properties?: Record<string, any>) => {})
2525

2626
describe('login-flow analytics instrumentation', () => {
27+
test('plain and TUI callers carry one explicit CLI UUID without changing authentication', async () => {
28+
const cliFlowId = 'a0000000-0000-4000-8000-000000000001'
29+
for (const via of ['modal', 'plain_command'] as const) {
30+
const data = {
31+
loginUrl: 'https://freebuff.test/login?auth_code=opaque-auth-code',
32+
fingerprintHash: 'private-hash',
33+
expiresAt: '2030-01-01T00:00:00Z',
34+
}
35+
const loginCode = mock(async () => ({
36+
ok: true as const,
37+
status: 200,
38+
data,
39+
}))
40+
const result = await generateLoginUrl(
41+
{
42+
logger: createMockLogger(),
43+
apiClient: createMockApiClient({ loginCode }),
44+
},
45+
{
46+
baseUrl: 'https://freebuff.test',
47+
fingerprintId: 'private-fingerprint',
48+
via,
49+
cliFlowId,
50+
},
51+
)
52+
const browserUrl = new URL(result.loginUrl)
53+
expect(browserUrl.searchParams.get('cli_flow')).toBe(cliFlowId)
54+
expect(browserUrl.searchParams.getAll('cli_flow')).toEqual([cliFlowId])
55+
expect(browserUrl.searchParams.get('auth_code')).toBe('opaque-auth-code')
56+
expect(result.fingerprintHash).toBe(data.fingerprintHash)
57+
expect(result.expiresAt).toBe(data.expiresAt)
58+
expect(loginCode).toHaveBeenCalledWith({
59+
fingerprintId: 'private-fingerprint',
60+
})
61+
expect(data.loginUrl).not.toContain('cli_flow')
62+
}
63+
})
64+
65+
test('auth codes and invalid correlation values never imply CLI origin', async () => {
66+
const data = {
67+
loginUrl:
68+
'https://freebuff.test/login?auth_code=opaque-auth-code&native_apple=1',
69+
fingerprintHash: 'private-hash',
70+
expiresAt: '2030-01-01T00:00:00Z',
71+
}
72+
const loginCode = mock(async () => ({
73+
ok: true as const,
74+
status: 200,
75+
data,
76+
}))
77+
const apiClient = createMockApiClient({ loginCode })
78+
for (const cliFlowId of [
79+
undefined,
80+
'private-fingerprint',
81+
'opaque-auth-code',
82+
]) {
83+
const result = await generateLoginUrl(
84+
{ logger: createMockLogger(), apiClient },
85+
{
86+
baseUrl: 'https://freebuff.test',
87+
fingerprintId: 'private-fingerprint',
88+
cliFlowId,
89+
},
90+
)
91+
expect(result).toEqual(data)
92+
}
93+
})
94+
95+
test('optional correlation never rejects an unusable or non-web URL', async () => {
96+
for (const loginUrl of ['not a URL', 'freebuff://login?auth_code=opaque']) {
97+
const data = {
98+
loginUrl,
99+
fingerprintHash: 'hash',
100+
expiresAt: '2030-01-01T00:00:00Z',
101+
}
102+
const loginCode = mock(async () => ({
103+
ok: true as const,
104+
status: 200,
105+
data,
106+
}))
107+
expect(
108+
await generateLoginUrl(
109+
{
110+
logger: createMockLogger(),
111+
apiClient: createMockApiClient({ loginCode }),
112+
},
113+
{
114+
baseUrl: 'https://freebuff.test',
115+
fingerprintId: 'fingerprint',
116+
cliFlowId: 'a0000000-0000-4000-8000-000000000001',
117+
},
118+
),
119+
).toEqual(data)
120+
}
121+
})
122+
27123
test('generateLoginUrl emits LOGIN_STARTED on entry and nothing else on success', async () => {
28124
const trackEvent = createTrackEventMock()
29125
const loginCode = mock(async () => ({
@@ -84,13 +180,20 @@ describe('login-flow analytics instrumentation', () => {
84180
test('pollLoginStatus emits LOGIN_TIMEOUT with attempts + via', async () => {
85181
const trackEvent = createTrackEventMock()
86182
const loginStatus = mock(
87-
async () => ({ ok: false, status: 401 }) as ApiResponse<{ user?: unknown }>,
183+
async () =>
184+
({ ok: false, status: 401 }) as ApiResponse<{ user?: unknown }>,
88185
)
89186
const apiClient = createMockApiClient({ loginStatus })
90187
const clock = createClock()
91188

92189
const result = await pollLoginStatus(
93-
{ sleep: clock.sleep, logger: createMockLogger(), now: clock.now, apiClient, trackEvent },
190+
{
191+
sleep: clock.sleep,
192+
logger: createMockLogger(),
193+
now: clock.now,
194+
apiClient,
195+
trackEvent,
196+
},
94197
{
95198
baseUrl: 'https://cli.test',
96199
fingerprintId: 'finger-1',
@@ -115,7 +218,13 @@ describe('login-flow analytics instrumentation', () => {
115218
const clock = createClock()
116219

117220
const result = await pollLoginStatus(
118-
{ sleep: clock.sleep, logger: createMockLogger(), now: clock.now, apiClient, trackEvent },
221+
{
222+
sleep: clock.sleep,
223+
logger: createMockLogger(),
224+
now: clock.now,
225+
apiClient,
226+
trackEvent,
227+
},
119228
{
120229
baseUrl: 'https://cli.test',
121230
fingerprintId: 'finger-1',

‎cli/src/hooks/use-fetch-login-url.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { useMutation } from '@tanstack/react-query'
2+
import { randomUUID } from 'node:crypto'
23
import { safeOpen } from '../utils/open-url'
34

45
import { LOGIN_WEBSITE_URL } from '../login/constants'
56
import { generateLoginUrl } from '../login/login-flow'
67
import { trackEvent } from '../utils/analytics'
78
import { logger } from '../utils/logger'
9+
import { IS_FREEBUFF } from '../utils/constants'
810

911
interface UseFetchLoginUrlParams {
1012
setLoginUrl: (url: string | null) => void
@@ -37,6 +39,7 @@ export function useFetchLoginUrl({
3739
baseUrl: LOGIN_WEBSITE_URL,
3840
fingerprintId,
3941
via: 'modal',
42+
cliFlowId: IS_FREEBUFF ? randomUUID() : undefined,
4043
},
4144
)
4245
},

‎cli/src/login/login-flow.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,24 @@ export interface GenerateLoginUrlOptions {
3232
baseUrl: string
3333
fingerprintId: string
3434
via?: LoginVia
35+
/** Explicit Freebuff CLI origin. Never use a fingerprint or auth token here. */
36+
cliFlowId?: string
37+
}
38+
39+
const CLI_FLOW_ID =
40+
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
41+
42+
/** Attribution is optional and cannot make a usable sign-in URL fail. */
43+
function withCliLoginFlow(loginUrl: string, flowId?: string): string {
44+
if (!flowId || !CLI_FLOW_ID.test(flowId)) return loginUrl
45+
try {
46+
const url = new URL(loginUrl)
47+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return loginUrl
48+
url.searchParams.set('cli_flow', flowId)
49+
return url.toString()
50+
} catch {
51+
return loginUrl
52+
}
3553
}
3654

3755
export async function generateLoginUrl(
@@ -82,7 +100,10 @@ export async function generateLoginUrl(
82100
throw new Error('Failed to get login URL')
83101
}
84102

85-
return response.data
103+
const loginUrl = withCliLoginFlow(response.data.loginUrl, options.cliFlowId)
104+
return loginUrl === response.data.loginUrl
105+
? response.data
106+
: { ...response.data, loginUrl }
86107
}
87108

88109
interface PollLoginStatusDeps {

‎cli/src/login/plain-login.ts‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
2+
import { randomUUID } from 'node:crypto'
23
import { cyan, green, red, yellow, bold } from 'picocolors'
34

45
import { LOGIN_WEBSITE_URL } from './constants'
@@ -7,11 +8,7 @@ import {
78
FREEBUFF_DISCORD_INVITE_URL,
89
} from './discord-invite'
910
import { generateLoginUrl, pollLoginStatus } from './login-flow'
10-
import {
11-
flushAnalytics,
12-
identifyUser,
13-
trackEvent,
14-
} from '../utils/analytics'
11+
import { flushAnalytics, identifyUser, trackEvent } from '../utils/analytics'
1512
import { saveUserCredentials } from '../utils/auth'
1613
import { IS_FREEBUFF } from '../utils/constants'
1714
import { getFingerprintId } from '../utils/fingerprint'
@@ -39,7 +36,12 @@ export async function runPlainLogin(): Promise<void> {
3936
try {
4037
loginData = await generateLoginUrl(
4138
{ logger, trackEvent },
42-
{ baseUrl: LOGIN_WEBSITE_URL, fingerprintId, via: 'plain_command' },
39+
{
40+
baseUrl: LOGIN_WEBSITE_URL,
41+
fingerprintId,
42+
via: 'plain_command',
43+
cliFlowId: IS_FREEBUFF ? randomUUID() : undefined,
44+
},
4345
)
4446
} catch (error) {
4547
console.error(
@@ -97,11 +99,13 @@ export async function runPlainLogin(): Promise<void> {
9799
}
98100

99101
console.log()
100-
console.log(green(
102+
console.log(
103+
green(
101104
user.name
102105
? `✓ Logged in as ${user.name} (${user.email})`
103106
: `✓ Logged in as ${user.email}`,
104-
))
107+
),
108+
)
105109
console.log()
106110
const cliName = IS_FREEBUFF ? 'freebuff' : 'codebuff'
107111
console.log('You can now run ' + cyan(cliName) + ' to start.')

‎common/src/constants/analytics-events.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ export enum AnalyticsEvent {
324324
FREEBUFF_GET_STARTED_SIGN_IN_CLICKED = 'freebuff.get_started_sign_in_clicked',
325325
FREEBUFF_GET_STARTED_SIGNED_IN = 'freebuff.get_started_signed_in',
326326
FREEBUFF_EMAIL_AUTH = 'freebuff.email_auth',
327+
FREEBUFF_AUTH_STEP = 'freebuff.auth_step',
327328
FREEBUFF_GET_STARTED_ELIGIBILITY_RESOLVED = 'freebuff.get_started_eligibility_resolved',
328329
FREEBUFF_GET_STARTED_CONNECT_GITHUB_CLICKED = 'freebuff.get_started_connect_github_clicked',
329330
FREEBUFF_GET_STARTED_INSTALL_COMMAND_COPIED = 'freebuff.get_started_install_command_copied',
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import { z } from 'zod'
2+
3+
export const FREEBUFF_AUTH_EVENT = 'freebuff.auth_step'
4+
export const authOriginSchema = z.enum(['web', 'desktop', 'cli', 'unknown'])
5+
export const authProviderSchema = z.enum([
6+
'email',
7+
'google',
8+
'github',
9+
'apple',
10+
'existing',
11+
])
12+
export const authContextSchema = z
13+
.object({
14+
flow_id: z.string().uuid(),
15+
attempt_id: z.string().uuid(),
16+
origin: authOriginSchema,
17+
provider: authProviderSchema.optional(),
18+
started_at: z.number().int().positive(),
19+
})
20+
.strict()
21+
export const authEventSchema = authContextSchema
22+
.omit({ started_at: true })
23+
.extend({
24+
event_version: z.literal(1),
25+
step: z.enum(['login', 'provider', 'auth', 'connect']),
26+
outcome: z.enum([
27+
'shown',
28+
'selected',
29+
'submitted',
30+
'succeeded',
31+
'failed',
32+
'cancelled',
33+
'reused',
34+
]),
35+
reason: z
36+
.enum([
37+
'oauth',
38+
'blocked',
39+
'configuration',
40+
'invalid',
41+
'expired',
42+
'error',
43+
'phone_required',
44+
'already_connected',
45+
'invalid_credentials',
46+
'invalid_link',
47+
'rate_limited',
48+
'unavailable',
49+
'challenge_incomplete',
50+
'network',
51+
'request_failed',
52+
'invalid_password',
53+
'password_mismatch',
54+
])
55+
.optional(),
56+
is_new_user: z.boolean().optional(),
57+
})
58+
.strict()
59+
export type AuthContext = z.infer<typeof authContextSchema>
60+
export type AuthEvent = z.infer<typeof authEventSchema>
61+
export type AuthProvider = z.infer<typeof authProviderSchema>
62+
63+
/** All external payloads are closed: never spread a callback, user or error. */
64+
export function authEventProperties(
65+
context: AuthContext | null | undefined,
66+
step: AuthEvent['step'],
67+
outcome: AuthEvent['outcome'],
68+
details: Pick<AuthEvent, 'provider' | 'reason' | 'is_new_user'> = {},
69+
): AuthEvent | null {
70+
const parsed = authContextSchema.safeParse(context)
71+
if (!parsed.success) return null
72+
const { started_at: _, ...fields } = parsed.data
73+
const result = authEventSchema.safeParse({
74+
...fields,
75+
...details,
76+
step,
77+
outcome,
78+
event_version: 1,
79+
})
80+
return result.success ? result.data : null
81+
}
82+
83+
export function authInsertId(event: AuthEvent): string {
84+
return `auth:${event.flow_id}:${event.attempt_id}:${event.step}:${event.outcome}:${event.reason ?? ''}`
85+
}
86+
87+
/** Session-local analytics identity, deliberately unrelated to an account. */
88+
export function authDistinctId(flow: string): string {
89+
return `freebuff-auth:${flow}`
90+
}

‎common/src/util/analytics-sampling.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ const ALWAYS_TRACK_EVENTS = new Set<AnalyticsEvent>([
4141
AnalyticsEvent.DESKTOP_PROJECT_CREATED,
4242
AnalyticsEvent.DESKTOP_TURN_COMPLETED,
4343
AnalyticsEvent.DESKTOP_FUNNEL_STEP,
44+
AnalyticsEvent.FREEBUFF_AUTH_STEP,
4445
// Low-volume recovery funnel. Sampling any stage independently would make
4546
// creation-to-success and repeated-failure rates impossible to measure.
4647
AnalyticsEvent.DESKTOP_FAILED_TURN_RECOVERY,

0 commit comments

Comments
 (0)