Skip to content

Commit 97eb0dc

Browse files
committed
feat(attribution): track agentic signup and workflow outcomes
1 parent 2cef19f commit 97eb0dc

27 files changed

Lines changed: 30433 additions & 19 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { type NextRequest, NextResponse } from 'next/server'
2+
import { sealFreebuffAttribution, storeFreebuffHandoff } from '@/lib/analytics/freebuff-agentic'
3+
import {
4+
type FreebuffHandoffBody,
5+
freebuffHandoffContract,
6+
} from '@/lib/api/contracts/freebuff-attribution'
7+
import { parseRequest } from '@/lib/api/server'
8+
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
9+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10+
11+
/** Public device-flow initialization; holds no account authority and never issues credentials. */
12+
export const POST = withRouteHandler(async (request: NextRequest) => {
13+
const limited = await enforceIpRateLimit('freebuff-handoff', request)
14+
if (limited) return limited
15+
const parsed = await parseRequest(freebuffHandoffContract, request, {})
16+
if (!parsed.success) return parsed.response
17+
try {
18+
const body: FreebuffHandoffBody = parsed.data.body
19+
const sealed = await sealFreebuffAttribution(body.conversionToken)
20+
await storeFreebuffHandoff(body.request, body.challenge, sealed)
21+
return new NextResponse(null, { status: 204, headers: { 'Cache-Control': 'no-store' } })
22+
} catch {
23+
return NextResponse.json({ error: 'Attribution handoff unavailable' }, { status: 503 })
24+
}
25+
})
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import { createLogger } from '@sim/logger'
2+
import { type NextRequest, NextResponse } from 'next/server'
3+
import {
4+
FREEBUFF_AGENTIC_COOKIE,
5+
FREEBUFF_ATTRIBUTION_TTL_SECONDS,
6+
readFreebuffAttribution,
7+
readFreebuffHandoff,
8+
sealFreebuffAttribution,
9+
} from '@/lib/analytics/freebuff-agentic'
10+
import {
11+
type FreebuffLandingQuery,
12+
freebuffLandingContract,
13+
} from '@/lib/api/contracts/freebuff-attribution'
14+
import { parseRequest } from '@/lib/api/server'
15+
import { getSession } from '@/lib/auth'
16+
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
17+
import { getBaseUrl } from '@/lib/core/utils/urls'
18+
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
19+
import { bindAccountAttribution } from '@/lib/users/application/attribution'
20+
21+
const logger = createLogger('FreebuffLanding')
22+
23+
/** Capture before rendering any page or loading analytics; the redirect URL never carries bfcid. */
24+
export const GET = withRouteHandler(async (request: NextRequest) => {
25+
const destination = new URL('/signup', getBaseUrl())
26+
let sealed: string | undefined
27+
const limited = await enforceIpRateLimit('freebuff-landing', request)
28+
if (!limited) {
29+
const parsed = await parseRequest(freebuffLandingContract, request, {})
30+
if (parsed.success) {
31+
const query: FreebuffLandingQuery = parsed.data.query
32+
try {
33+
if (query.request && query.challenge && query.pairing) {
34+
destination.pathname = '/cli/auth'
35+
destination.search = new URLSearchParams({
36+
request: query.request,
37+
challenge: query.challenge,
38+
pairing: query.pairing,
39+
scope: 'platform',
40+
...(query.workspace ? { workspace: query.workspace } : {}),
41+
}).toString()
42+
sealed = await readFreebuffHandoff(query.request, query.challenge)
43+
} else if (query.bfcid) {
44+
sealed = await sealFreebuffAttribution(query.bfcid)
45+
}
46+
if (await readFreebuffAttribution(sealed)) {
47+
const session = await getSession()
48+
if (session?.user.id && sealed)
49+
await bindAccountAttribution.execute({
50+
principal: {
51+
kind: 'session',
52+
userId: session.user.id,
53+
sessionId: session.session.id,
54+
},
55+
input: { sealed },
56+
})
57+
} else {
58+
sealed = undefined
59+
}
60+
} catch {
61+
logger.warn('Attribution capture unavailable')
62+
sealed = undefined
63+
}
64+
}
65+
}
66+
const response = NextResponse.redirect(destination, 303)
67+
response.headers.set('Cache-Control', 'no-store')
68+
response.headers.set('Referrer-Policy', 'no-referrer')
69+
if (sealed)
70+
response.cookies.set(FREEBUFF_AGENTIC_COOKIE, sealed, {
71+
httpOnly: true,
72+
secure: true,
73+
sameSite: 'lax',
74+
path: '/',
75+
maxAge: FREEBUFF_ATTRIBUTION_TTL_SECONDS,
76+
})
77+
return response
78+
})

‎apps/sim/app/cli/auth/page.test.tsx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ const CHALLENGE = 'c'.repeat(43)
1515
const PAIRING = 'ABCD-2345'
1616

1717
const EXPECTED_CALLBACK = encodeURIComponent(
18-
`/cli/auth?request=${REQUEST}&challenge=${CHALLENGE}&pairing=${PAIRING}`
18+
`/cli/auth?request=${REQUEST}&challenge=${CHALLENGE}&pairing=${PAIRING}&scope=platform&workspace=workspace-fixture`
1919
)
2020

2121
function pageProps() {
@@ -24,6 +24,8 @@ function pageProps() {
2424
request: REQUEST,
2525
challenge: CHALLENGE,
2626
pairing: PAIRING,
27+
scope: 'platform',
28+
workspace: 'workspace-fixture',
2729
}),
2830
}
2931
}
@@ -37,7 +39,7 @@ describe('CliAuthPage signed-out bounce', () => {
3739
envFlagsMock.isRegistrationDisabled = false
3840
})
3941

40-
it('sends a signed-out visitor to signup, carrying the handoff as callbackUrl', async () => {
42+
it('preserves the requested key scope and workspace through signup', async () => {
4143
await expect(CliAuthPage(pageProps())).rejects.toThrow(
4244
`NEXT_REDIRECT:/signup?callbackUrl=${EXPECTED_CALLBACK}`
4345
)

‎apps/sim/app/cli/auth/page.tsx‎

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ export const metadata: Metadata = {
1818

1919
export const dynamic = 'force-dynamic'
2020

21+
interface CliAuthPageProps {
22+
searchParams: Promise<SearchParams>
23+
}
24+
2125
/**
2226
* Browser half of the CLI key handoff.
2327
*
@@ -37,11 +41,7 @@ export const dynamic = 'force-dynamic'
3741
* account, so the pairing visitor is necessarily an existing user and signup is
3842
* guaranteed to be the wrong hop. Go straight to login there.
3943
*/
40-
export default async function CliAuthPage({
41-
searchParams,
42-
}: {
43-
searchParams: Promise<SearchParams>
44-
}) {
44+
export default async function CliAuthPage({ searchParams }: CliAuthPageProps) {
4545
const [session, params] = await Promise.all([
4646
getSession(),
4747
cliAuthSearchParamsCache.parse(searchParams),
@@ -54,6 +54,10 @@ export default async function CliAuthPage({
5454
request: resolution.request.request,
5555
challenge: resolution.request.challenge,
5656
pairing: resolution.request.pairing,
57+
scope: resolution.request.scope,
58+
...(resolution.request.suggestedWorkspaceId
59+
? { workspace: resolution.request.suggestedWorkspaceId }
60+
: {}),
5761
})
5862
redirect(
5963
buildAuthCrossLink(isRegistrationDisabled ? '/login' : '/signup', {
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# Freebuff agentic attribution
2+
3+
This flow implements https://freebuff.com/docs/advertisers/agentic-attribution and is separate from the existing display conversion API.
4+
5+
## Rollout
6+
7+
1. Deploy the additive attribution migration and application together through the normal pipeline. The old application ignores the new table.
8+
2. Publish the CLI containing this change. The proposed command-scoped carrier is `SIM_FREEBUFF_CONVERSION_TOKEN`; it is supported only for `sim login --method api-key`. It is removed from the CLI environment before browser launch and is never written into CLI configuration.
9+
3. Have Freebuff review the exact runtime carrier and login procedure before placing it into a sponsored procedure. This repository change does not approve or alter an existing reviewed procedure or consent hash.
10+
4. For browser-only handoffs, use `/api/attribution/freebuff` as the landing destination and let Freebuff append its signed `bfcid`. This dedicated endpoint captures the opaque token and redirects to signup before client scripts run. Do not send agentic tokens through the display tag's `bfcid` cookie.
11+
5. Verify the canonical HTTPS app origin before activation. The CLI sends tokens only to `https://www.sim.ai`, with redirects disabled. The browser cookie is Secure, HttpOnly, SameSite=Lax, host-only, and encrypted.
12+
6. Obtain a Freebuff-issued signed test token. Exercise the real new-account and returning-account flows, complete a deployed workflow run, verify accepted and deduplicated postbacks, and confirm test classification and zero charge with Freebuff. Then verify a real accepted-proposal handoff; a generic test token cannot prove the proposal/run join.
13+
14+
No advertiser API key is used by the agentic endpoint. Existing display signup reporting stays separate and still requires its configured server key.
15+
16+
## Outcomes and delivery
17+
18+
- `account_created:<user-id>` records a new human account created after token capture. Returning users retain attribution for product use without becoming new signups.
19+
- `tool_used:<execution-id>` records completed deployed workflow executions by their execution actor. Failed, pending, cancelled, and undeployed preview executions are excluded. Each execution can be reported once; recurring executions have distinct IDs.
20+
- Attribution is scoped to the human actor, not every member of their workspace. Latest captured attribution wins; replaying an older cookie cannot replace it.
21+
- The outbox stores encrypted token snapshots with original event IDs and timestamps. One request is sent per lease, with at most three attempts. Network, 429, and 5xx failures retry; other HTTP failures are terminal. Delivery status and HTTP status remain available in the outbox payload. No raw response or token is logged.
22+
- The association expires 30 days after capture; Freebuff enforces its authoritative 30-day window after Accept. Expiry cleanup removes the stored association. Terminal deliveries erase the encrypted token from their outbox payload.
23+
- Attribution failures never prevent authentication or a workflow completion. Such failures emit a token-free warning and must be reconciled during rollout.
24+
25+
## Verification
26+
27+
`bun run test:integration freebuff-agentic` uses disposable PostgreSQL and Redis and writes `apps/sim/test-results/integration.json`. It verifies encryption, expiry, token-free redirects, CLI rendezvous isolation, atomic rollback, idempotency, retry identity, and terminal rejection. The auth session in the landing fixture is simulated; this is not evidence that deployed OAuth callbacks or the Freebuff partner handoff have been verified.
28+
29+
Do not enable the broad v2 site pixel as part of this rollout. It is a separate telemetry decision and is unnecessary for these server events.
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
import { db } from '@sim/db'
2+
import { freebuffAttribution, outboxEvent, user } from '@sim/db/schema'
3+
import { generateId } from '@sim/utils/id'
4+
import { eq, inArray, sql } from 'drizzle-orm'
5+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
6+
import { freebuffAgenticOutboxHandlers } from '@/lib/analytics/freebuff-agentic/outbox'
7+
import {
8+
bindFreebuffAttribution,
9+
enqueueFreebuffUse,
10+
} from '@/lib/analytics/freebuff-agentic/service'
11+
import {
12+
readFreebuffAttribution,
13+
sealFreebuffAttribution,
14+
} from '@/lib/analytics/freebuff-agentic/token'
15+
import { processOutboxEventById } from '@/lib/core/outbox/service'
16+
17+
/** Protects token secrecy, expiry, transactional rollback, replay identity and retry policy. */
18+
const userId = generateId()
19+
const token = 'fixture-opaque-token-not-issued-by-freebuff'
20+
const executionId = generateId()
21+
const ids = [`freebuff:account_created:${userId}`, `freebuff:tool_used:${executionId}`]
22+
23+
beforeAll(async () => {
24+
await db.insert(user).values({
25+
id: userId,
26+
name: 'Fixture',
27+
email: `${userId}@example.test`,
28+
emailVerified: true,
29+
createdAt: new Date(),
30+
updatedAt: new Date(),
31+
})
32+
})
33+
afterAll(async () => {
34+
await db
35+
.delete(outboxEvent)
36+
.where(
37+
sql`${outboxEvent.id} like ${`freebuff:expire:${userId}:%`} or ${inArray(outboxEvent.id, ids)}`
38+
)
39+
await db.delete(user).where(eq(user.id, userId))
40+
})
41+
42+
beforeEach(async () => {
43+
await db
44+
.delete(outboxEvent)
45+
.where(
46+
sql`${outboxEvent.id} like ${`freebuff:expire:${userId}:%`} or ${inArray(outboxEvent.id, ids)}`
47+
)
48+
await db.delete(freebuffAttribution).where(eq(freebuffAttribution.userId, userId))
49+
})
50+
afterEach(() => {
51+
vi.useRealTimers()
52+
vi.unstubAllGlobals()
53+
})
54+
async function seedUse() {
55+
await bindFreebuffAttribution(userId, await sealFreebuffAttribution(token), new Date())
56+
await db.transaction((tx) => enqueueFreebuffUse(tx, userId, executionId, new Date()))
57+
}
58+
59+
describe('agentic attribution across committed outcomes', () => {
60+
it('rejects tampered and expired capture without exposing the bearer token', async () => {
61+
const sealed = await sealFreebuffAttribution(token)
62+
expect(sealed).not.toContain(token)
63+
expect((await readFreebuffAttribution(sealed))?.token).toBe(token)
64+
expect(await readFreebuffAttribution(`${sealed}00`)).toBeNull()
65+
vi.useFakeTimers()
66+
vi.setSystemTime(Date.now() + 31 * 24 * 60 * 60 * 1000)
67+
expect(await readFreebuffAttribution(sealed)).toBeNull()
68+
vi.useRealTimers()
69+
})
70+
71+
it('retains signup identity on repeated login and rolls back uncommitted use', async () => {
72+
const sealed = await sealFreebuffAttribution(token)
73+
const createdAt = new Date()
74+
await bindFreebuffAttribution(userId, sealed, createdAt)
75+
await bindFreebuffAttribution(userId, sealed)
76+
const [association] = await db
77+
.select()
78+
.from(freebuffAttribution)
79+
.where(eq(freebuffAttribution.userId, userId))
80+
expect(association.encryptedToken).not.toContain(token)
81+
const [signup] = await db.select().from(outboxEvent).where(eq(outboxEvent.id, ids[0]))
82+
expect(signup.payload).toMatchObject({
83+
eventType: 'account_created',
84+
occurredAt: createdAt.toISOString(),
85+
})
86+
await expect(
87+
db.transaction(async (tx) => {
88+
await enqueueFreebuffUse(tx, userId, executionId, new Date())
89+
throw new Error('rollback fixture')
90+
})
91+
).rejects.toThrow('rollback fixture')
92+
expect(await db.select().from(outboxEvent).where(eq(outboxEvent.id, ids[1]))).toHaveLength(0)
93+
const occurredAt = new Date()
94+
await db.transaction(async (tx) => {
95+
await enqueueFreebuffUse(tx, userId, executionId, occurredAt)
96+
await enqueueFreebuffUse(tx, userId, executionId, new Date(occurredAt.getTime() + 1))
97+
})
98+
const uses = await db.select().from(outboxEvent).where(eq(outboxEvent.id, ids[1]))
99+
expect(uses).toHaveLength(1)
100+
expect(uses[0].payload).toMatchObject({
101+
eventType: 'tool_used',
102+
occurredAt: occurredAt.toISOString(),
103+
})
104+
})
105+
106+
it('retries with original identity and treats deduplicated delivery as success', async () => {
107+
await seedUse()
108+
const requests: Record<string, unknown>[] = []
109+
let count = 0
110+
vi.stubGlobal('fetch', async (_url: string, init: RequestInit) => {
111+
requests.push(JSON.parse(String(init.body)))
112+
expect(init.headers).not.toHaveProperty('Authorization')
113+
return new Response(
114+
JSON.stringify(++count === 1 ? { error: 'busy' } : { accepted: true, deduped: true }),
115+
{ status: count === 1 ? 503 : 200, headers: { 'Content-Type': 'application/json' } }
116+
)
117+
})
118+
expect(await processOutboxEventById(ids[1], freebuffAgenticOutboxHandlers)).toBe('pending')
119+
await db
120+
.update(outboxEvent)
121+
.set({ availableAt: new Date(0) })
122+
.where(eq(outboxEvent.id, ids[1]))
123+
expect(await processOutboxEventById(ids[1], freebuffAgenticOutboxHandlers)).toBe('completed')
124+
expect(requests).toHaveLength(2)
125+
expect(requests[1]).toEqual(requests[0])
126+
expect(requests[0]).toMatchObject({
127+
conversionToken: token,
128+
eventType: 'tool_used',
129+
eventId: `tool_used:${executionId}`,
130+
})
131+
vi.unstubAllGlobals()
132+
})
133+
134+
it.each(['network', 'server'])(
135+
'erases credentials after three exhausted %s attempts',
136+
async (failure) => {
137+
await seedUse()
138+
let attempts = 0
139+
vi.stubGlobal('fetch', async () => {
140+
attempts++
141+
if (failure === 'network') throw new Error('fixture network failure')
142+
return new Response('{}', { status: 503 })
143+
})
144+
for (let attempt = 0; attempt < 3; attempt++) {
145+
await db
146+
.update(outboxEvent)
147+
.set({ availableAt: new Date(0) })
148+
.where(eq(outboxEvent.id, ids[1]))
149+
expect(await processOutboxEventById(ids[1], freebuffAgenticOutboxHandlers)).toBe(
150+
attempt === 2 ? 'dead_letter' : 'pending'
151+
)
152+
}
153+
const [event] = await db.select().from(outboxEvent).where(eq(outboxEvent.id, ids[1]))
154+
expect(attempts).toBe(3)
155+
expect(event.payload).toMatchObject({
156+
deliveryStatus: 'retry_exhausted',
157+
encryptedToken: null,
158+
})
159+
}
160+
)
161+
162+
it('records a terminal rejection without retrying invalid credentials', async () => {
163+
await seedUse()
164+
vi.stubGlobal(
165+
'fetch',
166+
async () => new Response('{"error":"invalid_conversion_token"}', { status: 401 })
167+
)
168+
expect(await processOutboxEventById(ids[0], freebuffAgenticOutboxHandlers)).toBe('completed')
169+
const [event] = await db.select().from(outboxEvent).where(eq(outboxEvent.id, ids[0]))
170+
expect(event.payload).toMatchObject({ deliveryStatus: 'rejected', httpStatus: 401 })
171+
expect(event.payload).toHaveProperty('encryptedToken', null)
172+
vi.unstubAllGlobals()
173+
})
174+
})
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { sha256Hex } from '@sim/security/hash'
2+
import { getRedisClient } from '@/lib/core/config/redis'
3+
4+
/** The browser receives only the existing pairing handle, never the conversion token. */
5+
function key(requestId: string, challenge: string): string {
6+
return `freebuff:handoff:${sha256Hex(`${requestId}:${challenge}`)}`
7+
}
8+
9+
export async function storeFreebuffHandoff(
10+
requestId: string,
11+
challenge: string,
12+
sealed: string
13+
): Promise<void> {
14+
const redis = getRedisClient()
15+
if (!redis) throw new Error('Attribution handoff unavailable')
16+
const result = await redis.set(key(requestId, challenge), sealed, 'EX', 900, 'NX')
17+
if (result !== 'OK') throw new Error('Attribution handoff already registered')
18+
}
19+
20+
export async function readFreebuffHandoff(
21+
requestId: string,
22+
challenge: string
23+
): Promise<string | undefined> {
24+
return (await getRedisClient()?.get(key(requestId, challenge))) ?? undefined
25+
}

0 commit comments

Comments
 (0)