Skip to content
Open
Changes from 4 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
1c4ea14
fix: enforce first-party popup cap for ads
AlejandroAkbal Mar 20, 2026
a83dc82
refactor: add popup guard debug instrumentation
AlejandroAkbal Mar 20, 2026
6734b52
refactor: expose popup guard match reasons
AlejandroAkbal Mar 20, 2026
6c9ec6e
fix: harden popup cap state handling
AlejandroAkbal Mar 20, 2026
8c1dfa8
fix: prefer latest popup timestamp across tabs
AlejandroAkbal Mar 20, 2026
2e02ceb
fix: spend popup cap on first vendor attempt
AlejandroAkbal Mar 20, 2026
91eba7f
fix: exempt in-page push opens from popup cap
AlejandroAkbal Mar 20, 2026
85d891e
fix: defer ad arming until after first click
AlejandroAkbal Mar 20, 2026
e37699d
fix: ignore capped vendor click handlers
AlejandroAkbal Mar 20, 2026
e2b542b
chore: enable popup guard debug in development
AlejandroAkbal Mar 20, 2026
b5a0607
refactor: simplify popup cap guard
AlejandroAkbal Mar 20, 2026
beb5218
Revert "fix: defer ad arming until after first click"
AlejandroAkbal Mar 20, 2026
6edda36
fix: bypass popup guard for trusted app opens
AlejandroAkbal Mar 20, 2026
90820bf
chore: restore dev popup guard logs
AlejandroAkbal Mar 20, 2026
0cace6b
fix: exempt push provider opens from popup cap
AlejandroAkbal Mar 20, 2026
fe6d0a0
fix: treat hotsoz push redirects as in-page opens
AlejandroAkbal Mar 20, 2026
06177ee
chore: log skipped ad scripts while capped
AlejandroAkbal Mar 20, 2026
19cca55
refactor: centralize ad provider metadata
AlejandroAkbal Mar 20, 2026
db4bfe8
refactor: restore ad provider notes
AlejandroAkbal Mar 20, 2026
94bf27c
test: cover popup guard decisions
AlejandroAkbal Mar 21, 2026
92c9fda
fix: correct popunder provider URL
AlejandroAkbal Mar 21, 2026
f42c131
test: assert required popup cap duration
AlejandroAkbal Mar 21, 2026
3e88a92
fix: abort blocked ad popup fallbacks
AlejandroAkbal Mar 21, 2026
6644249
Merge remote-tracking branch 'origin/main' into fix/ad-popup-cap-30m
AlejandroAkbal Mar 21, 2026
6b4ffbb
test: cover popup guard browser flow
AlejandroAkbal Mar 22, 2026
38697f0
test: tighten capped popup browser assertions
AlejandroAkbal Mar 22, 2026
031a706
refactor: simplify ad provider flattening
AlejandroAkbal Mar 23, 2026
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
324 changes: 324 additions & 0 deletions composables/useAdvertisements.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,332 @@
import { default as randomWeightedChoice } from 'random-weighted-choice'

const AD_POPUP_CAP_DURATION_MS = 30 * 60 * 1000
const AD_LAST_POPUP_AT_STORAGE_KEY = 'ads-last-popup-at'
// Temporary debug toggles for popup-cap behavior:
// - window.__ADS_POPUP_GUARD_DEBUG__ = true
// - localStorage.setItem('ads-popup-guard-debug', '1')
const AD_POPUP_DEBUG_STORAGE_KEY = 'ads-popup-guard-debug'
const AD_POPUP_DEBUG_WINDOW_FLAG = '__ADS_POPUP_GUARD_DEBUG__'
const STACK_URL_REGEX = /https?:\/\/[^\s)]+/g
const DEBUG_TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on'])
const INTEGER_TIMESTAMP_REGEX = /^\d+$/

type WindowOpenArgs = Parameters<Window['open']>
type WindowOpenResult = ReturnType<Window['open']>
type PopupCapState = {
isActive: boolean
lastPopupAt: number | null
elapsedSinceLastPopupMs: number | null
}
type VendorPopupMatchReason = 'cross-origin-script' | 'same-origin-js-script' | 'no-user-activation'

function getScriptUrlsFromStack(stack?: string): URL[] {
if (!stack) {
return []
}

const matches = stack.match(STACK_URL_REGEX)

if (!matches) {
return []
}

const urls: URL[] = []

for (const rawMatch of matches) {
const normalizedUrl = rawMatch
.replace(/[),]$/, '')
.replace(/:\d+:\d+$/, '')

try {
urls.push(new URL(normalizedUrl))
} catch {
// Ignore malformed URLs from stack traces
}
}

return urls
}

function getVendorPopupMatchReason(
callerScriptUrls: URL[],
hasUserActivation: boolean
): VendorPopupMatchReason | null {
if (callerScriptUrls.length === 0) {
return hasUserActivation ? null : 'no-user-activation'
}

const currentOrigin = window.location.origin

for (const scriptUrl of callerScriptUrls) {
if (scriptUrl.origin !== currentOrigin) {
return 'cross-origin-script'
}

// Keep the heuristic broad: treat same-origin static /js scripts as likely ad/vendor callers.
if (scriptUrl.pathname.startsWith('/js/')) {
return 'same-origin-js-script'
}
}

return hasUserActivation ? null : 'no-user-activation'
}

export default function () {
const popunderScript = useState<string>('popunder-script', () => '')
const pushScript = useState<string>('push-notification-script', () => '')
const isPopupGuardInstalled = useState<boolean>('ads-popup-guard-installed', () => false)
const isPopupGuardArmed = useState<boolean>('ads-popup-guard-armed', () => false)
const lastAdPopupAtInMemory = useState<number | null>('ads-last-popup-at-in-memory', () => null)

if (!import.meta.client) {
return
}

function isPopupGuardDebugEnabled(): boolean {
const debugFlagOnWindow = (window as Window & Record<string, unknown>)[AD_POPUP_DEBUG_WINDOW_FLAG]

if (typeof debugFlagOnWindow === 'boolean') {
return debugFlagOnWindow
}

try {
const rawDebugFlag = window.localStorage.getItem(AD_POPUP_DEBUG_STORAGE_KEY)

if (!rawDebugFlag) {
return false
}

return DEBUG_TRUTHY_VALUES.has(rawDebugFlag.trim().toLowerCase())
} catch {
return false
}
}

function debugPopupGuardDecision(details: {
decision: 'allowed' | 'blocked'
reason: 'vendor-cap-active' | 'vendor-cap-inactive'
vendorPopupMatchReason: VendorPopupMatchReason
args: WindowOpenArgs
hasUserActivation: boolean
callerScriptUrls: URL[]
capState: PopupCapState
recordedPopupAt?: number
openAttemptOutcome?: 'opened' | 'blocked-or-null' | 'threw-error'
openError?: string
}) {
if (!isPopupGuardDebugEnabled()) {
return
}

const [requestedUrl, target, windowFeatures] = details.args

console.debug('[ads-popup-guard]', {
decision: details.decision,
reason: details.reason,
vendorPopupMatchReason: details.vendorPopupMatchReason,
requestedUrl: typeof requestedUrl === 'string' ? requestedUrl : null,
target: typeof target === 'string' ? target : null,
windowFeatures: typeof windowFeatures === 'string' ? windowFeatures : null,
hasUserActivation: details.hasUserActivation,
callerScriptUrlCount: details.callerScriptUrls.length,
callerScriptUrls: details.callerScriptUrls.slice(0, 5).map(scriptUrl => scriptUrl.href),
capDurationMs: AD_POPUP_CAP_DURATION_MS,
capActive: details.capState.isActive,
lastPopupAt: details.capState.lastPopupAt,
elapsedSinceLastPopupMs: details.capState.elapsedSinceLastPopupMs,
recordedPopupAt: details.recordedPopupAt ?? null,
openAttemptOutcome: details.openAttemptOutcome ?? null,
openError: details.openError ?? null
})
}

function parseStoredLastPopupAt(rawLastPopupAt: string, now: number): number | null {
const normalizedRawLastPopupAt = rawLastPopupAt.trim()

if (!INTEGER_TIMESTAMP_REGEX.test(normalizedRawLastPopupAt)) {
return null
}

const parsedLastPopupAt = Number(normalizedRawLastPopupAt)

if (
!Number.isSafeInteger(parsedLastPopupAt)
|| parsedLastPopupAt <= 0
|| parsedLastPopupAt > now
) {
return null
}

return parsedLastPopupAt
}

function getLastAdPopupAt(now = Date.now()): number | null {
const inMemoryLastPopupAt = lastAdPopupAtInMemory.value

if (inMemoryLastPopupAt !== null) {
if (
Number.isSafeInteger(inMemoryLastPopupAt)
&& inMemoryLastPopupAt > 0
&& inMemoryLastPopupAt <= now
) {
return inMemoryLastPopupAt
}

// Reset invalid or future in-memory values so they do not over-block.
lastAdPopupAtInMemory.value = null
}

try {
const rawLastPopupAt = window.localStorage.getItem(AD_LAST_POPUP_AT_STORAGE_KEY)

if (rawLastPopupAt) {
const parsedLastPopupAt = parseStoredLastPopupAt(rawLastPopupAt, now)

if (parsedLastPopupAt !== null) {
lastAdPopupAtInMemory.value = parsedLastPopupAt
return parsedLastPopupAt
}
}
} catch {
// Ignore storage failures and use in-memory fallback
}

return null
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

function recordAdPopupOpened(at = Date.now()) {
lastAdPopupAtInMemory.value = at

try {
window.localStorage.setItem(AD_LAST_POPUP_AT_STORAGE_KEY, String(at))
} catch {
// Ignore storage failures and keep the in-memory fallback
}
}

function getAdPopupCapState(now = Date.now()): PopupCapState {
const lastAdPopupAt = getLastAdPopupAt(now)

if (!lastAdPopupAt) {
return {
isActive: false,
lastPopupAt: null,
elapsedSinceLastPopupMs: null
}
}

const elapsedSinceLastPopupMs = now - lastAdPopupAt

return {
isActive: elapsedSinceLastPopupMs < AD_POPUP_CAP_DURATION_MS,
lastPopupAt: lastAdPopupAt,
elapsedSinceLastPopupMs
}
}

function isAdPopupCapActive(): boolean {
return getAdPopupCapState().isActive
}

if (!isPopupGuardInstalled.value) {
const originalWindowOpen = window.open.bind(window)

window.open = (...args: WindowOpenArgs): WindowOpenResult => {
if (!isPopupGuardArmed.value) {
return originalWindowOpen(...args)
}

const userActivation = (window.navigator as Navigator & {
// Legacy browsers may not expose navigator.userActivation.
userActivation?: { isActive: boolean }
}).userActivation

// Default to true when userActivation is unavailable so older browsers keep allowing
// popups instead of breaking ad flows entirely; this trades stricter detection for compatibility.
const hasUserActivation = userActivation?.isActive ?? true
const callerScriptUrls = getScriptUrlsFromStack(new Error().stack)
const vendorPopupMatchReason = getVendorPopupMatchReason(callerScriptUrls, hasUserActivation)

if (!vendorPopupMatchReason) {
return originalWindowOpen(...args)
}

const capState = getAdPopupCapState()

if (capState.isActive) {
debugPopupGuardDecision({
decision: 'blocked',
reason: 'vendor-cap-active',
vendorPopupMatchReason,
args,
hasUserActivation,
callerScriptUrls,
capState
})

return null
}

try {
const popupHandle = originalWindowOpen(...args)

if (popupHandle) {
const openedAt = Date.now()
recordAdPopupOpened(openedAt)

debugPopupGuardDecision({
decision: 'allowed',
reason: 'vendor-cap-inactive',
vendorPopupMatchReason,
args,
hasUserActivation,
callerScriptUrls,
capState,
recordedPopupAt: openedAt,
openAttemptOutcome: 'opened'
})
} else {
debugPopupGuardDecision({
decision: 'allowed',
reason: 'vendor-cap-inactive',
vendorPopupMatchReason,
args,
hasUserActivation,
callerScriptUrls,
capState,
openAttemptOutcome: 'blocked-or-null'
})
}

return popupHandle
} catch (error) {
debugPopupGuardDecision({
decision: 'allowed',
reason: 'vendor-cap-inactive',
vendorPopupMatchReason,
args,
hasUserActivation,
callerScriptUrls,
capState,
openAttemptOutcome: 'threw-error',
openError: error instanceof Error ? error.message : String(error)
})

throw error
}
}

isPopupGuardInstalled.value = true
}

// Phase 1: stop injecting ad scripts while the 30-minute popup cap is active.
if (isAdPopupCapActive()) {
return
}

// Phase 2 (arming): once scripts load, guard vendor-like popups with the first-party cap.
isPopupGuardArmed.value = true
Comment thread
AlejandroAkbal marked this conversation as resolved.

const popunderAds = [
/**
Expand Down