From 6649d8a2d394d9c4eec8244a182848ea8145149e Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 12 Nov 2026 18:06:55 -0800 Subject: [PATCH 01/20] fix(api): match pause points with jsonb_path_exists so a status-filtered run list stays fast (#9971) --- .../workflows/executor/execution-queries.ts | 11 ++++---- .../lib/workflows/executor/pause-point-sql.ts | 28 +++++++++---------- .../sim/lib/workflows/executor/paused-runs.ts | 15 ++++++---- 3 files changed, 28 insertions(+), 26 deletions(-) diff --git a/apps/sim/lib/workflows/executor/execution-queries.ts b/apps/sim/lib/workflows/executor/execution-queries.ts index 03bef36954a..69ed459a5ac 100644 --- a/apps/sim/lib/workflows/executor/execution-queries.ts +++ b/apps/sim/lib/workflows/executor/execution-queries.ts @@ -1,7 +1,7 @@ import { db } from '@sim/db' import { pausedExecutions, workflowExecutionLogs } from '@sim/db/schema' import { and, asc, desc, eq, gt, gte, lt, lte, or, sql } from 'drizzle-orm' -import { PAUSE_POINT_ROWS } from '@/lib/workflows/executor/pause-point-sql' +import { somePausePointMatches } from '@/lib/workflows/executor/pause-point-sql' export type WorkflowExecutionStatus = | 'pending' @@ -27,6 +27,10 @@ export interface ListWorkflowExecutionsInput { order: 'asc' | 'desc' } +/** A point a resume can still act on: any `resumeStatus` but `resumed` or `failed`, absent included. */ +const POINT_UNSETTLED = + '!(@.resumeStatus.type() == "string" && (@.resumeStatus == "resumed" || @.resumeStatus == "failed"))' + /** * An open pause record reads `paused` unless the log has finished and no point is left to resume, * the same rule `readWorkflowExecutionStatus` applies to a single run. @@ -35,10 +39,7 @@ const executionStatus = sql`CASE WHEN ${pausedExecutions.status} IN ('paused', 'partially_resumed') AND NOT ( ${workflowExecutionLogs.status} IN ('completed', 'failed', 'cancelled') - AND NOT EXISTS ( - select 1 from ${PAUSE_POINT_ROWS} - where coalesce(point.value ->> 'resumeStatus', 'paused') NOT IN ('resumed', 'failed') - ) + AND NOT ${somePausePointMatches(POINT_UNSETTLED)} ) THEN 'paused' ELSE ${workflowExecutionLogs.status} diff --git a/apps/sim/lib/workflows/executor/pause-point-sql.ts b/apps/sim/lib/workflows/executor/pause-point-sql.ts index 372b84b9b01..eb1e2cf87d8 100644 --- a/apps/sim/lib/workflows/executor/pause-point-sql.ts +++ b/apps/sim/lib/workflows/executor/pause-point-sql.ts @@ -1,21 +1,19 @@ import { pausedExecutions } from '@sim/db/schema' -import { sql } from 'drizzle-orm' +import { type SQL, sql } from 'drizzle-orm' /** - * Every pause point of a `paused_executions` row, as the derived table `point(value)`. + * Whether any pause point of a `paused_executions` row matches the jsonpath `filter`, which + * reads the point as `@`. * * `pause_points` is an object keyed by context id, or an array on legacy rows; both shapes are - * read, as `normalizePausePoints` reads them, and each set function only ever receives the shape - * it accepts. + * read, as `normalizePausePoints` reads them. A `jsonb_path_exists` call per row keeps this cheap + * enough to evaluate in a `WHERE` across every run of a workflow, which a correlated + * set-returning subquery is not. */ -export const PAUSE_POINT_ROWS = sql`( - select value from jsonb_each( - case when jsonb_typeof(${pausedExecutions.pausePoints}) = 'object' - then ${pausedExecutions.pausePoints} else '{}'::jsonb end - ) - union all - select value from jsonb_array_elements( - case when jsonb_typeof(${pausedExecutions.pausePoints}) = 'array' - then ${pausedExecutions.pausePoints} else '[]'::jsonb end - ) -) as point(value)` +export function somePausePointMatches(filter: string): SQL { + return sql`(case jsonb_typeof(${pausedExecutions.pausePoints}) + when 'object' then jsonb_path_exists(${pausedExecutions.pausePoints}, ${`$.* ? (${filter})`}::jsonpath) + when 'array' then jsonb_path_exists(${pausedExecutions.pausePoints}, ${`$[*] ? (${filter})`}::jsonpath) + else false + end)` +} diff --git a/apps/sim/lib/workflows/executor/paused-runs.ts b/apps/sim/lib/workflows/executor/paused-runs.ts index 07219f5c584..5ecd14f08fe 100644 --- a/apps/sim/lib/workflows/executor/paused-runs.ts +++ b/apps/sim/lib/workflows/executor/paused-runs.ts @@ -1,6 +1,6 @@ import { db } from '@sim/db' import { pausedExecutions, workflow } from '@sim/db/schema' -import { and, eq, inArray, isNull, sql } from 'drizzle-orm' +import { and, eq, inArray, isNull } from 'drizzle-orm' import { type CursorKey, encodeKeyset, @@ -14,7 +14,7 @@ import { resumeKeyset, textKey, } from '@/lib/api/list-query' -import { PAUSE_POINT_ROWS } from '@/lib/workflows/executor/pause-point-sql' +import { somePausePointMatches } from '@/lib/workflows/executor/pause-point-sql' /** A paused run of an active workflow, with the raw `pause_points` its detail is built from. */ interface WorkspacePausedRunRow { @@ -32,12 +32,15 @@ const PAUSED_RUN_KEYS: readonly KeysetKey[] = [ textKey(pausedExecutions.id, (row) => row.id), ] -/** Whether `point.value` is a pause point still waiting on a human, with `mapPausePoints`' defaults. */ -const POINT_WAITS_ON_HUMAN = sql`coalesce(point.value ->> 'pauseKind', 'human') <> 'time' - and coalesce(point.value ->> 'resumeStatus', 'paused') = 'paused'` +/** + * A pause point still waiting on a human, with `mapPausePoints`' defaults: a missing or null + * `pauseKind` reads `human` and a missing or null `resumeStatus` reads `paused`. + */ +const POINT_WAITS_ON_HUMAN = + '!(@.pauseKind.type() == "string" && @.pauseKind == "time") && (!exists(@.resumeStatus) || @.resumeStatus == null || (@.resumeStatus.type() == "string" && @.resumeStatus == "paused"))' /** A paused run with a pause point still waiting on a human. */ -const WAITS_ON_HUMAN = sql`exists (select 1 from ${PAUSE_POINT_ROWS} where ${POINT_WAITS_ON_HUMAN})` +const WAITS_ON_HUMAN = somePausePointMatches(POINT_WAITS_ON_HUMAN) /** The cursor that resumes the list after `row`. */ export function pausedRunCursorKeys(row: WorkspacePausedRunRow): CursorKey[] { From 755dee56fedfd1d07467f8be8bb153a41a8e357b Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 12 Nov 2026 18:57:05 -0800 Subject: [PATCH 02/20] fix(docs): copy section permalinks and polish outline navigation (#9972) * fix(docs): copy section permalinks and polish outline navigation * test(docs): verify focus returns from outline links --- .../components/docs/table-of-contents.tsx | 22 +++- apps/docs/components/ui/heading-link.tsx | 53 ++++++++ apps/docs/components/ui/heading.tsx | 12 +- apps/docs/scripts/test-navigation-e2e.ts | 113 ++++++++++++++++++ 4 files changed, 188 insertions(+), 12 deletions(-) create mode 100644 apps/docs/components/ui/heading-link.tsx diff --git a/apps/docs/components/docs/table-of-contents.tsx b/apps/docs/components/docs/table-of-contents.tsx index 4f4b5289e3c..fb6da145b37 100644 --- a/apps/docs/components/docs/table-of-contents.tsx +++ b/apps/docs/components/docs/table-of-contents.tsx @@ -19,9 +19,18 @@ export function TableOfContents({ headings, active, compact = false }: TableOfCo {headings.map(({ id, title, depth }) => (
  • { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return const details = event.currentTarget.closest('details') if (details) details.open = false }} @@ -40,7 +49,16 @@ export function TableOfContents({ headings, active, compact = false }: TableOfCo ) return compact ? ( -
    +
    { + if (event.key !== 'Escape' || !event.currentTarget.open) return + event.preventDefault() + event.stopPropagation() + event.currentTarget.open = false + event.currentTarget.querySelector('summary')?.focus() + }} + > On this page diff --git a/apps/docs/components/ui/heading-link.tsx b/apps/docs/components/ui/heading-link.tsx new file mode 100644 index 00000000000..052a5de1a1e --- /dev/null +++ b/apps/docs/components/ui/heading-link.tsx @@ -0,0 +1,53 @@ +'use client' + +import { useState } from 'react' +import { useCopyToClipboard } from '@sim/emcn' +import { Check, Link, TriangleAlert } from '@sim/emcn/icons' +import { cn } from '@/components/docs/classnames' + +interface HeadingLinkProps { + id: string +} + +/** Copies a section permalink while retaining native anchor navigation. */ +export function HeadingLink({ id }: HeadingLinkProps) { + const { copied, copy } = useCopyToClipboard() + const [failed, setFailed] = useState(false) + const announcement = failed + ? 'Could not copy. Copy the link from the address bar.' + : copied + ? 'Link copied' + : '' + const Icon = failed ? TriangleAlert : copied ? Check : Link + + return ( + <> + { + if ( + event.defaultPrevented || + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey + ) + return + setFailed(!(await copy(event.currentTarget.href))) + }} + className={cn( + 'pointer-coarse:before:-inset-3.5 relative ml-2 inline-flex rounded align-middle text-[var(--docs-muted)] opacity-0 pointer-coarse:opacity-100 transition-opacity pointer-coarse:before:absolute hover:text-[var(--docs-link)] focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-[var(--docs-link)] focus-visible:outline-offset-2 group-hover/heading:opacity-100 motion-reduce:transition-none', + (copied || failed) && 'opacity-100' + )} + > + + + + {announcement} + + + ) +} diff --git a/apps/docs/components/ui/heading.tsx b/apps/docs/components/ui/heading.tsx index 2aa4d508e6f..d91a8af80d3 100644 --- a/apps/docs/components/ui/heading.tsx +++ b/apps/docs/components/ui/heading.tsx @@ -1,6 +1,6 @@ import type { HTMLAttributes } from 'react' -import { Link } from '@sim/emcn/icons' import { cn } from '@/components/docs/classnames' +import { HeadingLink } from '@/components/ui/heading-link' type HeadingTag = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6' interface HeadingProps extends HTMLAttributes { @@ -29,15 +29,7 @@ export function Heading({ as: Tag = 'h2', className, children, id, ...props }: H )} > {children} - {id && ( - - - - )} + {id && } ) } diff --git a/apps/docs/scripts/test-navigation-e2e.ts b/apps/docs/scripts/test-navigation-e2e.ts index e6842fb0036..911d3ec98f5 100644 --- a/apps/docs/scripts/test-navigation-e2e.ts +++ b/apps/docs/scripts/test-navigation-e2e.ts @@ -178,6 +178,119 @@ try { ? await (engine === 'webkit' ? webkit : chromium).launch() : await webkit.connect(webkitEndpoint, { timeout: 180_000 }) const feedbackBrowser = browser + await check( + 'Section permalinks copy the full URL and preserve native link navigation', + async () => { + const context = await feedbackBrowser.newContext({ + permissions: ['clipboard-read', 'clipboard-write'], + viewport: { width: 1440, height: 900 }, + }) + try { + const page = await context.newPage() + await open(page, '/files/editor?view=reference') + const formatting = page.locator('#formatting-text').getByRole('link') + await formatting.click() + const first = new URL('/files/editor?view=reference#formatting-text', target).href + await expect(page).toHaveURL(first) + await expect(formatting).toHaveAccessibleName('Link copied') + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(first) + + const tables = page.locator('#tables').getByRole('link') + await tables.focus() + await page.keyboard.press('Enter') + const second = new URL('/files/editor?view=reference#tables', target).href + await expect(page).toHaveURL(second) + await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(second) + const [newTab] = await Promise.all([ + context.waitForEvent('page', { timeout: 10_000 }), + page + .locator('#code-blocks') + .getByRole('link') + .click({ modifiers: ['ControlOrMeta'] }), + ]) + await newTab.waitForLoadState('domcontentloaded') + await expect(newTab).toHaveURL( + new URL('/files/editor?view=reference#code-blocks', target).href + ) + await newTab.close() + await page.bringToFront() + await expect(page).toHaveURL(second) + expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(second) + await page.screenshot({ path: join(directory, 'section-permalink.png') }) + } finally { + await context.close() + } + }, + engine === 'webkit' + ? 'Playwright WebKit cannot grant clipboard read/write permissions; Chromium verifies native clipboard contents.' + : undefined + ) + await check('Section permalink navigation survives an unavailable clipboard', async () => { + const context = await feedbackBrowser.newContext() + try { + await context.addInitScript(() => { + Object.defineProperty(navigator, 'clipboard', { value: undefined }) + }) + const page = await context.newPage() + await open(page, '/files/editor') + const link = page.locator('#formatting-text').getByRole('link') + await link.click() + await expect(page).toHaveURL(new URL('/files/editor#formatting-text', target).href) + await expect(link).toHaveAccessibleName('Could not copy. Copy the link from the address bar.') + await expect(page.getByRole('status').filter({ hasText: 'Could not copy.' })).toBeVisible() + await page.screenshot({ path: join(directory, 'section-permalink-copy-unavailable.png') }) + await page + .getByRole('navigation', { name: 'Page navigation', exact: true }) + .getByRole('link', { name: 'Previous Files', exact: true }) + .click() + await expect(page).toHaveURL(new URL('/files', target).href) + await expect( + page.getByRole('link', { name: 'Could not copy. Copy the link from the address bar.' }) + ).toHaveCount(0) + } finally { + await context.close() + } + }) + await check( + 'Compact page outline dismisses with Escape and preserves new-tab clicks', + async () => { + for (const width of [390, 1100]) { + const context = await feedbackBrowser.newContext({ viewport: { width, height: 900 } }) + try { + const page = await context.newPage() + await open(page, '/files/editor') + const summary = page + .locator('summary') + .filter({ hasText: 'On this page' }) + .filter({ visible: true }) + const details = summary.locator('..') + await summary.click() + await expect(details).toHaveAttribute('open', '') + await details.getByRole('link', { name: 'Formatting text', exact: true }).focus() + await page.keyboard.press('Escape') + await expect(details).not.toHaveAttribute('open') + await expect(summary).toBeFocused() + + await summary.click() + const link = details.getByRole('link', { name: 'Formatting text', exact: true }) + const [newTab] = await Promise.all([ + context.waitForEvent('page', { timeout: 10_000 }), + link.click({ modifiers: ['ControlOrMeta'] }), + ]) + await newTab.waitForLoadState('domcontentloaded') + await expect(newTab).toHaveURL(new URL('/files/editor#formatting-text', target).href) + await expect(details).toHaveAttribute('open', '') + await newTab.close() + await link.click() + await expect(page).toHaveURL(new URL('/files/editor#formatting-text', target).href) + await expect(details).not.toHaveAttribute('open') + await page.screenshot({ path: join(directory, `section-outline-${width}.png`) }) + } finally { + await context.close() + } + } + } + ) await check('Feedback forwards votes and preserves unchanged retry identity', async () => { const cert = process.env.DOCS_FEEDBACK_TEST_CERT const key = process.env.DOCS_FEEDBACK_TEST_KEY From c02e06d8486f6bac7b29f7807d0896b6dfbae5fa Mon Sep 17 00:00:00 2001 From: Waleed Date: Thu, 12 Nov 2026 19:38:17 -0800 Subject: [PATCH 03/20] chore(analytics): remove Freebuff ad tracking and the Beluga short-link rewrite (#9974) * chore(analytics): remove Freebuff ad tracking and the Beluga short-link rewrite * fix(consent): delete the retired Freebuff click-id cookie for returning visitors --- apps/sim/app/(auth)/signup/signup-form.tsx | 4 +- .../cookie-policy/cookie-policy-content.tsx | 12 +--- .../app/(landing)/privacy/privacy-content.tsx | 17 ++--- .../app/_shell/consent/consent-provider.tsx | 4 +- .../consent/freebuff-click-id-guard.tsx | 21 ------ .../_shell/consent/retired-cookie-cleanup.tsx | 24 +++++++ apps/sim/lib/analytics/attribution.ts | 1 - apps/sim/lib/analytics/freebuff.server.ts | 67 ------------------- apps/sim/lib/analytics/freebuff.ts | 49 -------------- apps/sim/lib/auth/auth.ts | 19 ------ apps/sim/lib/consent/scripts.ts | 14 ---- apps/sim/lib/core/config/env.ts | 1 - apps/sim/lib/core/security/csp.ts | 4 -- apps/sim/next.config.ts | 4 -- 14 files changed, 33 insertions(+), 208 deletions(-) delete mode 100644 apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx create mode 100644 apps/sim/app/_shell/consent/retired-cookie-cleanup.tsx delete mode 100644 apps/sim/lib/analytics/freebuff.server.ts delete mode 100644 apps/sim/lib/analytics/freebuff.ts diff --git a/apps/sim/app/(auth)/signup/signup-form.tsx b/apps/sim/app/(auth)/signup/signup-form.tsx index af6506485e3..d74c61ddb88 100644 --- a/apps/sim/app/(auth)/signup/signup-form.tsx +++ b/apps/sim/app/(auth)/signup/signup-form.tsx @@ -5,7 +5,6 @@ import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' import { createLogger } from '@sim/logger' import { useRouter, useSearchParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' -import { trackFreebuffConversion } from '@/lib/analytics/freebuff' import { trackGoogleEvent } from '@/lib/analytics/google' import { client, useSession } from '@/lib/auth/client' import { DISPOSABLE_EMAIL_ERROR_CODE, DISPOSABLE_EMAIL_MESSAGE } from '@/lib/auth/constants' @@ -112,7 +111,7 @@ function SignupFormContent({ const searchParams = useSearchParams() const { refetch: refetchSession } = useSession() const posthog = usePostHog() - const { measurement, marketing } = useTrackingConsent() + const { measurement } = useTrackingConsent() useCaptureWhenReady('signup_page_viewed', {}) const [isLoading, setIsLoading] = useState(false) @@ -352,7 +351,6 @@ function SignupFormContent({ } if (measurement) trackGoogleEvent('sign_up', { method: 'email' }) - if (marketing) trackFreebuffConversion('signup_completed', response.data.user.id) try { await refetchSession() diff --git a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx index b887ca7b617..b5004de6944 100644 --- a/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx +++ b/apps/sim/app/(landing)/cookie-policy/cookie-policy-content.tsx @@ -44,7 +44,7 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { title: 'Cookie Policy', description: 'What cookies Sim sets, why, how long they last, and how to change your choice at any time.', - lastUpdated: 'October 13, 2026', + lastUpdated: 'November 12, 2026', intro: [ { kind: 'paragraph', @@ -238,12 +238,6 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { ], ['personalization_id', 'X (Twitter)', 'Personalizes the ads shown on X.', '13 months'], ['muc_ads', 'X (Twitter)', 'Measures ad conversions across X domains.', '13 months'], - [ - 'bfcid', - 'Freebuff', - 'Stores the click identifier from a Freebuff ad that brought you to sim.ai, so a sign-up can be credited to that ad. Deleted if you withdraw Marketing consent.', - '30 days', - ], ]), ], }, @@ -280,8 +274,7 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { , Google Ads,{' '} X (Twitter), - and PostHog. For Freebuff,{' '} - {CHANGE_CHOICES} to withdraw Marketing consent. + and PostHog. ), }, @@ -303,7 +296,6 @@ export const COOKIE_POLICY_CONFIG: LegalPageConfig = { Google{' '} (Analytics and Ads),{' '} X (Twitter),{' '} - Freebuff,{' '} Ahrefs,{' '} PostHog, and{' '} Cloudflare. diff --git a/apps/sim/app/(landing)/privacy/privacy-content.tsx b/apps/sim/app/(landing)/privacy/privacy-content.tsx index c90897a0c81..8ac239179f5 100644 --- a/apps/sim/app/(landing)/privacy/privacy-content.tsx +++ b/apps/sim/app/(landing)/privacy/privacy-content.tsx @@ -44,7 +44,7 @@ function richText(content: string): ReactNode { export const PRIVACY_CONFIG: LegalPageConfig = { title: 'Privacy Policy', description: 'Sim Studio, Inc. · Operating the Sim platform (sim.ai)', - lastUpdated: 'November 4, 2026', + lastUpdated: 'November 12, 2026', intro: [ { kind: 'paragraph', @@ -263,7 +263,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = { ], [ 'Behavioral remarketing', - 'Cookie and pixel identifiers, ad click identifiers, browser and Device data, campaign attribution, website interaction data, and, for Freebuff sign-up conversions, a pseudonymous Sim Account identifier and the sign-up time', + 'Cookie and pixel identifiers, ad click identifiers, browser and Device data, campaign attribution, and website interaction data', 'Consent — Article 6(1)(a)', 'Marketing cookies and ad personalization require Marketing consent. Google Ads may load earlier with ad storage denied and send limited cookieless consent signals. Consent may be changed or withdrawn through the cookie preferences link.', ], @@ -577,7 +577,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = { { kind: 'paragraph', content: richText( - 'Sim advertises through Google Ads, X, and Freebuff, and measures which ads lead to sign-ups and demo bookings. These services use non-essential Cookies, pixels, and similar technologies. Marketing Cookies and ad personalization are enabled only after You consent to the Marketing category in the cookie banner.' + 'Sim advertises through Google Ads and X, and measures which ads lead to sign-ups and demo bookings. These services use non-essential Cookies, pixels, and similar technologies. Marketing Cookies and ad personalization are enabled only after You consent to the Marketing category in the cookie banner.' ), }, { @@ -592,12 +592,6 @@ export const PRIVACY_CONFIG: LegalPageConfig = { "**X.** The X pixel loads on Sim's marketing pages only after Marketing consent. It reports page views and demo bookings." ), }, - { - kind: 'paragraph', - content: richText( - '**Freebuff.** After Marketing consent, the Freebuff tag stores the click identifier from a Freebuff ad. When You sign up, Sim sends Freebuff that click identifier, the event type, a pseudonymous Sim Account identifier, and the sign-up time so the sign-up can be credited to the ad. Sim does not send Your name or email address.' - ), - }, { kind: 'paragraph', content: richText( @@ -621,9 +615,6 @@ export const PRIVACY_CONFIG: LegalPageConfig = { richText( 'X: [Personalization and data settings](https://x.com/settings/privacy_and_safety)' ), - richText( - 'Freebuff: withdraw Marketing consent through the cookie preferences link; Sim then deletes the Freebuff click identifier cookie' - ), ], }, ], @@ -987,7 +978,7 @@ export const PRIVACY_CONFIG: LegalPageConfig = { { kind: 'paragraph', content: richText( - 'We do not sell Personal Information for money. When Marketing Cookies are enabled, Sim makes identifiers and internet activity on sim.ai available to Google, X, and Freebuff for advertising and ad measurement. This includes cookie and click identifiers, pages visited, and conversion events. California law may treat this as a "sale" or "sharing" of Personal Information. You may opt out at any time through the cookie preferences link, by sending a Global Privacy Control signal, or by emailing privacy@sim.ai. Sim does not knowingly sell or share the Personal Information of consumers under 16.' + 'We do not sell Personal Information for money. When Marketing Cookies are enabled, Sim makes identifiers and internet activity on sim.ai available to Google and X for advertising and ad measurement. This includes cookie and click identifiers, pages visited, and conversion events. California law may treat this as a "sale" or "sharing" of Personal Information. You may opt out at any time through the cookie preferences link, by sending a Global Privacy Control signal, or by emailing privacy@sim.ai. Sim does not knowingly sell or share the Personal Information of consumers under 16.' ), }, { kind: 'subheading', text: 'Global Privacy Control' }, diff --git a/apps/sim/app/_shell/consent/consent-provider.tsx b/apps/sim/app/_shell/consent/consent-provider.tsx index d274ff4e101..e6c00148435 100644 --- a/apps/sim/app/_shell/consent/consent-provider.tsx +++ b/apps/sim/app/_shell/consent/consent-provider.tsx @@ -5,8 +5,8 @@ import { TrackingConsentProvider } from '@/lib/consent/tracking-consent' import { AttributionCookieGuard } from '@/app/_shell/consent/attribution-cookie-guard' import { ConsentBanner } from '@/app/_shell/consent/consent-banner' import { ConsentStoreProvider } from '@/app/_shell/consent/consent-store-provider' -import { FreebuffClickIdGuard } from '@/app/_shell/consent/freebuff-click-id-guard' import { GoogleAnalyticsPageViewTracker } from '@/app/_shell/consent/google-analytics-page-view-tracker' +import { RetiredCookieCleanup } from '@/app/_shell/consent/retired-cookie-cleanup' interface ConsentProviderProps { children: ReactNode @@ -24,8 +24,8 @@ export function ConsentProvider({ children }: ConsentProviderProps) { {children} - + diff --git a/apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx b/apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx deleted file mode 100644 index bc221be8fb0..00000000000 --- a/apps/sim/app/_shell/consent/freebuff-click-id-guard.tsx +++ /dev/null @@ -1,21 +0,0 @@ -'use client' - -import { useEffect } from 'react' -import { clearFreebuffClickId } from '@/lib/analytics/freebuff' -import { useTrackingConsent } from '@/lib/consent/tracking-consent' - -/** - * Drops a stored Freebuff click id once consent resolves without marketing, so - * a withdrawn or expired grant can never be attributed by the server postback, - * which only sees the cookie. Withdrawal reloads the page, so this runs before - * any later signup. - */ -export function FreebuffClickIdGuard() { - const { isResolved, marketing } = useTrackingConsent() - - useEffect(() => { - if (isResolved && !marketing) clearFreebuffClickId() - }, [isResolved, marketing]) - - return null -} diff --git a/apps/sim/app/_shell/consent/retired-cookie-cleanup.tsx b/apps/sim/app/_shell/consent/retired-cookie-cleanup.tsx new file mode 100644 index 00000000000..d583f938864 --- /dev/null +++ b/apps/sim/app/_shell/consent/retired-cookie-cleanup.tsx @@ -0,0 +1,24 @@ +'use client' + +import { useEffect } from 'react' + +/** + * Cookies a removed integration may have left in returning browsers. Nothing + * reads them any more, so they are deleted whatever the visitor's consent: + * keeping an unused advertising identifier serves no one. `bfcid` lives at most + * 30 days, so this list can be emptied once that window has passed. + */ +const RETIRED_COOKIES = ['bfcid'] as const + +/** Deletes every retired cookie present in this browser, once per page load. */ +export function RetiredCookieCleanup() { + useEffect(() => { + for (const name of RETIRED_COOKIES) { + if (document.cookie.includes(`${name}=`)) { + document.cookie = `${name}=; Max-Age=0; Path=/; SameSite=Lax` + } + } + }, []) + + return null +} diff --git a/apps/sim/lib/analytics/attribution.ts b/apps/sim/lib/analytics/attribution.ts index 82fa37b720e..0b6c62412cc 100644 --- a/apps/sim/lib/analytics/attribution.ts +++ b/apps/sim/lib/analytics/attribution.ts @@ -49,7 +49,6 @@ const CLICK_ID_PARAMETERS = [ 'ttclid', 'twclid', 'rdt_cid', - 'bfcid', ] as const /** diff --git a/apps/sim/lib/analytics/freebuff.server.ts b/apps/sim/lib/analytics/freebuff.server.ts deleted file mode 100644 index 3db98deb40a..00000000000 --- a/apps/sim/lib/analytics/freebuff.server.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { createLogger } from '@sim/logger' -import { sleep } from '@sim/utils/helpers' -import { backoffWithJitter } from '@sim/utils/retry' -import type { FreebuffConversionEvent } from '@/lib/analytics/freebuff' -import { env } from '@/lib/core/config/env' - -const logger = createLogger('FreebuffConversions') - -const FREEBUFF_CONVERSIONS_URL = 'https://freebuff.com/api/advertisers/conversions' -const MAX_ATTEMPTS = 4 -const REQUEST_TIMEOUT_MS = 10_000 - -/** Mirrors the tag's own click-id check, so a tampered cookie is never forwarded. */ -const CLICK_ID_SHAPE = /^bfc_[A-Za-z0-9._-]{1,508}$/ - -interface FreebuffConversion { - clickId: string - eventType: FreebuffConversionEvent - /** Idempotency key, shared with the tag call for the same conversion. */ - eventId: string - occurredAt: Date -} - -/** - * Server-to-server conversion postback. Network failures and 5xx responses are - * retried with the same `eventId` and `occurredAt`; every 4xx is terminal. A - * `deduped` answer means the tag already reported it and is a success. Never - * throws, so a caller can fire and forget. - */ -export async function reportFreebuffConversion(conversion: FreebuffConversion): Promise { - const apiKey = env.FREEBUFF_API_KEY - if (!apiKey || !CLICK_ID_SHAPE.test(conversion.clickId)) return - - const body = JSON.stringify({ - clickId: conversion.clickId, - eventType: conversion.eventType, - eventId: conversion.eventId, - occurredAt: conversion.occurredAt.toISOString(), - }) - const context = { eventType: conversion.eventType, eventId: conversion.eventId } - - for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { - let status: number | undefined - try { - const response = await fetch(FREEBUFF_CONVERSIONS_URL, { - method: 'POST', - headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, - body, - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), - }) - status = response.status - const result = await response.text().catch(() => '') - if (response.ok) { - logger.info('Freebuff conversion recorded', { ...context, result }) - return - } - if (status < 500) { - logger.warn('Freebuff conversion rejected', { ...context, status, result }) - return - } - } catch (error) { - logger.warn('Freebuff conversion request failed', { ...context, attempt, error }) - } - if (attempt < MAX_ATTEMPTS) await sleep(backoffWithJitter(attempt, null)) - else logger.error('Freebuff conversion postback gave up', { ...context, status }) - } -} diff --git a/apps/sim/lib/analytics/freebuff.ts b/apps/sim/lib/analytics/freebuff.ts deleted file mode 100644 index 6f994523d14..00000000000 --- a/apps/sim/lib/analytics/freebuff.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Freebuff Ads conversion tracking. A Freebuff ad click lands with a signed - * `?bfcid=` click id; the hosted tag stores it in a first-party `bfcid` cookie, - * and each conversion is reported twice with the same `eventId` — once by the - * tag and once by the server postback — so Freebuff dedupes them into one. - * - * @see https://freebuff.com/docs/advertisers/conversions - */ - -export const FREEBUFF_TAG_SRC = 'https://freebuff.com/freebuff-tag.js' as const - -/** First-party cookie the tag writes the captured click id to. */ -export const FREEBUFF_CLICK_ID_COOKIE = 'bfcid' as const - -export type FreebuffConversionEvent = 'signup_completed' - -type FreebuffCommand = ( - command: 'conversion', - eventType: FreebuffConversionEvent, - options?: { eventId?: string } -) => void - -declare global { - interface Window { - freebuff?: FreebuffCommand & { q?: unknown[][] } - } -} - -/** Queues commands until the async tag loads and replays them. */ -export function installFreebuffStub(): void { - if (window.freebuff) return - const queue: unknown[][] = [] - window.freebuff = Object.assign((...args: unknown[]) => void queue.push(args), { q: queue }) -} - -/** Deletes the tag's click-id cookie, which it writes host-only on `Path=/`. */ -export function clearFreebuffClickId(): void { - if (!document.cookie.includes(`${FREEBUFF_CLICK_ID_COOKIE}=`)) return - document.cookie = `${FREEBUFF_CLICK_ID_COOKIE}=; Max-Age=0; Path=/; SameSite=Lax` -} - -/** - * Reports a conversion from the page. Call only after the caller has verified - * marketing consent; the tag is a no-op for visitors who did not arrive from an - * ad. `eventId` must match the one the server postback sends. - */ -export function trackFreebuffConversion(event: FreebuffConversionEvent, eventId: string): void { - window.freebuff?.('conversion', event, { eventId }) -} diff --git a/apps/sim/lib/auth/auth.ts b/apps/sim/lib/auth/auth.ts index 0b492da2045..6fb31d22b92 100644 --- a/apps/sim/lib/auth/auth.ts +++ b/apps/sim/lib/auth/auth.ts @@ -36,8 +36,6 @@ import { renderWelcomeEmail, } from '@/components/emails' import { readAttributionProperties } from '@/lib/analytics/attribution' -import { FREEBUFF_CLICK_ID_COOKIE } from '@/lib/analytics/freebuff' -import { reportFreebuffConversion } from '@/lib/analytics/freebuff.server' import { getAccessControlConfig, isEmailBlockedByAccessControl } from '@/lib/auth/access-control' import { enqueueAdminRemovedChatCleanup } from '@/lib/auth/admin-removal-chat-cleanup' import { holdAdminRemovalStops, stopAdminRemovedRuns } from '@/lib/auth/admin-removal-stops' @@ -373,23 +371,6 @@ export const auth = betterAuth({ userId: user.id, }) - /** - * Only the marketing-consent-gated Freebuff tag writes the `bfcid` - * cookie, and `FreebuffClickIdGuard` deletes it once marketing consent - * is withdrawn or expires. Not awaited: the postback - * retries on its own and must never delay signup. The browser tag - * reports the same `eventId` on email signup and Freebuff dedupes. - */ - const freebuffClickId = context?.getCookie(FREEBUFF_CLICK_ID_COOKIE) - if (freebuffClickId) { - void reportFreebuffConversion({ - clickId: freebuffClickId, - eventType: 'signup_completed', - eventId: user.id, - occurredAt: user.createdAt, - }) - } - try { PlatformEvents.userSignedUp({ userId: user.id, diff --git a/apps/sim/lib/consent/scripts.ts b/apps/sim/lib/consent/scripts.ts index a75a4a7110a..6bdc867894d 100644 --- a/apps/sim/lib/consent/scripts.ts +++ b/apps/sim/lib/consent/scripts.ts @@ -1,7 +1,6 @@ import { ahrefsAnalytics } from '@c15t/scripts/ahrefs-analytics' import { gtag } from '@c15t/scripts/google-tag' import { xPixel } from '@c15t/scripts/x-pixel' -import { FREEBUFF_TAG_SRC, installFreebuffStub } from '@/lib/analytics/freebuff' import { getGooglePageLocation, updateGooglePageContext } from '@/lib/consent/google-context' import { isPathOrDescendant } from '@/lib/navigation/paths' @@ -53,19 +52,6 @@ const GLOBAL_CONSENT_SCRIPTS = [ }, }, ahrefsAnalytics({ key: AHREFS_ANALYTICS_KEY }), - /** - * Global rather than landing-only: the ad lands on a marketing page but the - * conversion fires from `/signup`. The tag recovers `?bfcid=` from the - * original navigation entry, so a client-side route change before consent - * resolves does not lose the click id. - */ - { - id: 'freebuff-tag', - src: FREEBUFF_TAG_SRC, - category: 'marketing', - async: true, - onBeforeLoad: installFreebuffStub, - }, ] as const /** Excludes embedded extension pages and restricts Google's loader to production origins. */ diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index b87c640130d..d2039aaefa1 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -322,7 +322,6 @@ export const env = createEnv({ // Monitoring & Analytics TELEMETRY_ENDPOINT: z.string().url().optional(), // Custom telemetry/analytics endpoint - FREEBUFF_API_KEY: z.string().min(1).optional(), // Freebuff Ads key for server-side conversion postbacks (unset disables them) COST_MULTIPLIER: z.number().optional(), // Multiplier for cost calculations LOG_LEVEL: z.enum(['DEBUG', 'INFO', 'WARN', 'ERROR']).optional(), // Minimum log level to display (defaults to ERROR in production, DEBUG in development) GRAFANA_OTLP_ENDPOINT: z.string().url().optional(), // Grafana Cloud OTLP HTTP gateway base URL (e.g., https://otlp-gateway-prod-us-east-0.grafana.net/otlp). Trigger.dev exporters append /v1/traces, /v1/logs, /v1/metrics. diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts index bb880026487..77c90decc36 100644 --- a/apps/sim/lib/core/security/csp.ts +++ b/apps/sim/lib/core/security/csp.ts @@ -119,8 +119,6 @@ const STATIC_SCRIPT_SRC = [ // X (Twitter) conversion pixel (landing pages) — the base code injects // uwt.js as a