Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions apps/sim/app/api/attribution/freebuff/handoff/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { type NextRequest, NextResponse } from 'next/server'
import { sealFreebuffAttribution, storeFreebuffHandoff } from '@/lib/analytics/freebuff-agentic'
import {
type FreebuffHandoffBody,
freebuffHandoffContract,
} from '@/lib/api/contracts/freebuff-attribution'
import { parseRequest } from '@/lib/api/server'
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

/** Public device-flow initialization; holds no account authority and never issues credentials. */
export const POST = withRouteHandler(async (request: NextRequest) => {
const limited = await enforceIpRateLimit('freebuff-handoff', request)
if (limited) return limited
const parsed = await parseRequest(freebuffHandoffContract, request, {})
if (!parsed.success) return parsed.response
try {
const body: FreebuffHandoffBody = parsed.data.body
const sealed = await sealFreebuffAttribution(body.conversionToken)
await storeFreebuffHandoff(body.request, body.challenge, sealed)
return new NextResponse(null, { status: 204, headers: { 'Cache-Control': 'no-store' } })
} catch {
return NextResponse.json({ error: 'Attribution handoff unavailable' }, { status: 503 })
}
})
97 changes: 97 additions & 0 deletions apps/sim/app/api/attribution/freebuff/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { createLogger } from '@sim/logger'
import { type NextRequest, NextResponse } from 'next/server'
import {
FREEBUFF_AGENTIC_COOKIE,
FREEBUFF_ATTRIBUTION_TTL_SECONDS,
readFreebuffAttribution,
scopeFreebuffAttribution,
sealFreebuffAttribution,
} from '@/lib/analytics/freebuff-agentic'
import {
type FreebuffLandingQuery,
freebuffLandingContract,
} from '@/lib/api/contracts/freebuff-attribution'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { enforceIpRateLimit } from '@/lib/core/rate-limiter'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { bindAccountAttribution } from '@/lib/users/application/attribution'

const logger = createLogger('FreebuffLanding')

/** Capture before rendering any page or loading analytics; the redirect URL never carries bfcid. */
export const GET = withRouteHandler(async (request: NextRequest) => {
const destination = new URL('/signup', getBaseUrl())
let sealed = request.cookies.get(FREEBUFF_AGENTIC_COOKIE)?.value
let clearCookie = false
let captureFailed = false
const limited = await enforceIpRateLimit('freebuff-landing', request)
if (limited) {
limited.headers.set('Cache-Control', 'no-store')
limited.headers.set('Referrer-Policy', 'no-referrer')
return limited
}
const parsed = await parseRequest(freebuffLandingContract, request, {})
if (parsed.success) {
const query: FreebuffLandingQuery = parsed.data.query
try {
const session = await getSession()
if (query.request && query.challenge && query.pairing) {
destination.pathname = '/cli/auth'
destination.search = new URLSearchParams({
request: query.request,
challenge: query.challenge,
pairing: query.pairing,
scope: 'platform',
...(query.workspace ? { workspace: query.workspace } : {}),
}).toString()
} else if (query.bfcid) {
sealed = await sealFreebuffAttribution(query.bfcid)
}
const captured = await readFreebuffAttribution(sealed)
if (captured) {
if (session?.user.id && sealed) {
if (
!('impersonatedBy' in session.session && session.session.impersonatedBy) &&
(!captured.boundUserId || captured.boundUserId === session.user.id)
) {
sealed = await scopeFreebuffAttribution(sealed, session.user.id)
await bindAccountAttribution.execute({
principal: {
kind: 'session',
userId: session.user.id,
sessionId: session.session.id,
},
input: { sealed },
})
}
sealed = undefined
clearCookie = true
}
} else {
sealed = undefined
}
} catch {
Comment thread
waleedlatif1 marked this conversation as resolved.
logger.warn('Attribution capture unavailable')
captureFailed = true
}
}
const response = captureFailed
? NextResponse.json(
{ error: 'Attribution capture unavailable; retry this request' },
{ status: 503, headers: { 'Retry-After': '5' } }
)
: NextResponse.redirect(destination, 303)
response.headers.set('Cache-Control', 'no-store')
response.headers.set('Referrer-Policy', 'no-referrer')
if (sealed || clearCookie)
response.cookies.set(FREEBUFF_AGENTIC_COOKIE, sealed ?? '', {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: clearCookie ? 0 : FREEBUFF_ATTRIBUTION_TTL_SECONDS,
})
return response
})
2 changes: 1 addition & 1 deletion apps/sim/app/api/cli/auth/approve/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ const CHALLENGE = createHash('sha256').update('b'.repeat(43)).digest('base64url'

describe('POST /api/cli/auth/approve', () => {
beforeEach(() => {
mockGetSession.mockResolvedValue({ user: { id: 'user-1' } })
mockGetSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } })
mockEnforceUserRateLimit.mockResolvedValue(null)
mockCreateApproval.mockResolvedValue(undefined)
mockGetPermissions.mockResolvedValue('admin')
Expand Down
7 changes: 7 additions & 0 deletions apps/sim/app/api/cli/auth/approve/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { enforceUserRateLimit } from '@/lib/core/rate-limiter'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { capabilityRefusal } from '@/lib/permission-groups/capability-assertions'
import { isCapabilityWithheldForUser } from '@/lib/permission-groups/user-scope.server'
import { bindApprovedCliAttribution } from '@/lib/users/application/attribution'
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'

const logger = createLogger('CliAuthApproveAPI')
Expand Down Expand Up @@ -131,6 +132,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
}
}

if (!('impersonatedBy' in session.session && session.session.impersonatedBy)) {
await bindApprovedCliAttribution.execute({
Comment thread
waleedlatif1 marked this conversation as resolved.
principal: { kind: 'session', userId: session.user.id, sessionId: session.session.id },
input: { requestId, challenge },
})
}
await createApproval(session.user.id, requestId, challenge, {
Comment thread
waleedlatif1 marked this conversation as resolved.
scope,
workspaceId,
Expand Down
6 changes: 4 additions & 2 deletions apps/sim/app/cli/auth/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ const CHALLENGE = 'c'.repeat(43)
const PAIRING = 'ABCD-2345'

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

function pageProps() {
Expand All @@ -24,6 +24,8 @@ function pageProps() {
request: REQUEST,
challenge: CHALLENGE,
pairing: PAIRING,
scope: 'platform',
workspace: 'workspace-fixture',
}),
}
}
Expand All @@ -37,7 +39,7 @@ describe('CliAuthPage signed-out bounce', () => {
envFlagsMock.isRegistrationDisabled = false
})

it('sends a signed-out visitor to signup, carrying the handoff as callbackUrl', async () => {
it('preserves the requested key scope and workspace through signup', async () => {
await expect(CliAuthPage(pageProps())).rejects.toThrow(
`NEXT_REDIRECT:/signup?callbackUrl=${EXPECTED_CALLBACK}`
)
Expand Down
14 changes: 9 additions & 5 deletions apps/sim/app/cli/auth/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@ export const metadata: Metadata = {

export const dynamic = 'force-dynamic'

interface CliAuthPageProps {
searchParams: Promise<SearchParams>
}

/**
* Browser half of the CLI key handoff.
*
Expand All @@ -37,11 +41,7 @@ export const dynamic = 'force-dynamic'
* account, so the pairing visitor is necessarily an existing user and signup is
* guaranteed to be the wrong hop. Go straight to login there.
*/
export default async function CliAuthPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
export default async function CliAuthPage({ searchParams }: CliAuthPageProps) {
const [session, params] = await Promise.all([
getSession(),
cliAuthSearchParamsCache.parse(searchParams),
Expand All @@ -54,6 +54,10 @@ export default async function CliAuthPage({
request: resolution.request.request,
challenge: resolution.request.challenge,
pairing: resolution.request.pairing,
scope: resolution.request.scope,
...(resolution.request.suggestedWorkspaceId
? { workspace: resolution.request.suggestedWorkspaceId }
: {}),
})
redirect(
buildAuthCrossLink(isRegistrationDisabled ? '/login' : '/signup', {
Expand Down
29 changes: 29 additions & 0 deletions apps/sim/lib/analytics/freebuff-agentic/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Freebuff agentic attribution

This flow implements https://freebuff.com/docs/advertisers/agentic-attribution and is separate from the existing display conversion API.

## Rollout

1. Deploy the additive attribution migration and application together through the normal pipeline. The old application ignores the new table.
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. The browser URL only opens the existing approval page; attribution is claimed by the authenticated approval action, pinned to that account, and deleted from Redis only after persistence succeeds.
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.
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.
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.
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.

No advertiser API key is used by the agentic endpoint. Existing display signup reporting stays separate and still requires its configured server key.

## Outcomes and delivery

- `account_created:<user-id>` records a new human account created after token capture. Returning users retain attribution for product use without becoming new signups.
- `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.
- Attribution is scoped to the human actor, not every member of their workspace. Latest captured attribution wins; replaying an older cookie cannot replace it.
- 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.
- 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.
- Authentication attribution failures emit a token-free warning without blocking sign-in. Failed signed-in browser captures retain an account-bound encrypted cookie and return a retryable response. Workflow completion isolates attribution writes in a savepoint; on failure it commits a durable marker with the completed log. The outbox worker drains that indexed marker queue in bounded batches, atomically enqueuing events and clearing their markers.

## Verification

`bun run test:integration freebuff-agentic completion-ledger-order` uses disposable PostgreSQL and Redis and writes `apps/sim/test-results/integration.json`. It verifies encryption, expiry, token-free redirects, authenticated CLI approval, auth hooks, billed completion during attribution failure, recovery, 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.

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.
Loading
Loading