fix(checkout): entitlement watchdog unblocks Dodo wallet-return deadlock (#3357)

* fix(checkout): entitlement watchdog unblocks Dodo wallet-return deadlock

Buyers completing a Dodo checkout on the subscription-trial flow get
stranded on Dodo's "Payment successful" page indefinitely. HAR evidence
(session cks_0NdL9xlzrFFNivgTeGFU9 / pay_0NdLA3yIfX3BVDoXrFltx, live):
after 3DS succeeds, Dodo's iframe navigates to
/status/{id}/wallet-return?status=succeeded and then emits nothing --
no checkout.status, no checkout.redirect_requested postMessage. Our
onEvent handler never runs, so onSuccess / banner / redirect never
fire. Prior PRs #3298, #3346, #3354 all depended on Dodo emitting a
terminal event; this path emits none.

Fix: merchant-side entitlement watchdog in both the /pro bundle
(pro-test/src/services/checkout.ts) and the dashboard bundle
(src/services/checkout.ts). When the overlay is open, poll
/api/me/entitlement every 3s with a 10min cap. When the webhook flips
the user to pro, close the stuck overlay and run the post-checkout
side effects -- independent of whatever Dodo's iframe does. Existing
event-driven paths are preserved unchanged (they remain the fast path
for non-wallet-return checkouts); the watchdog is the floor.

Idempotency via a successFired closure flag; both the event handler
and the watchdog route through the same runTerminalSuccessSideEffects
function, making double-fires impossible. checkout.closed stops the
watchdog cleanly on cancel.

Observability: Sentry breadcrumb with reason tag on every terminal
success, plus captureMessage at info level when the watchdog resolves
it -- countable signal for prevalence tracking while Dodo investigates.

Rebuilt public/pro/ bundle (index-CiMZEtgt.js to index-QpSvSkuY.js).

Plan: docs/plans/2026-04-23-002-fix-dodo-checkout-entitlement-watchdog-plan.md
Skill: .claude/skills/dodo-wallet-return-skips-postmessage/SKILL.md

* fix(checkout): stop watchdog on destroyCheckoutOverlay to prevent orphan side effects

Greptile P1 on #3357. destroyCheckoutOverlay cleared initialized and
onSuccessCallback but never called _resetOverlaySession, so if the
dashboard layout unmounted mid-checkout the watchdog setInterval kept
running inside the closed-over scope. On entitlement flip, the orphaned
watchdog would fire clearCheckoutAttempt / clearPendingCheckoutIntent /
markPostCheckout / safeCloseOverlay against whatever session was active
by then -- stepping on a new checkout's state or silently closing a
fresh overlay.

Fix: call _resetOverlaySession before dropping references, and null it
out after. _resetOverlaySession is the only accessor for the closure's
stopWatchdog so it must run before the module-scoped slot is cleared.

* test(checkout): extract testable entitlement watchdog + state-machine tests

Greptile residual risk on #3357: the watchdog state machine had no
targeted automated coverage, especially the wallet-return path where
no terminal Dodo event arrives and success is detected only via
entitlement polling.

Extract the watchdog into src/services/entitlement-watchdog.ts as a
pure DI module (fetch / setInterval / clock / token source / onPro
all injected). Mirror the file at pro-test/src/services/entitlement-
watchdog.ts since the two bundles have no cross-root imports (pro-test
alias '@' resolves to pro-test root only). Both src/services/checkout.ts
and pro-test/src/services/checkout.ts now consume createEntitlement-
Watchdog instead of inlining setInterval.

Tests cover the wallet-return scenario explicitly plus the full state
matrix:
- wallet-return path: isPro flips to true -> onPro fires exactly once
- timeout cap: isPro stays false past timeoutMs -> self-terminate
  WITHOUT firing onPro
- missing token: tick no-ops, poller keeps trying
- non-2xx response (401/5xx): tick swallows, poller continues
- fetch rejection: tick swallows, poller continues
- idempotence: onPro never fires twice across consecutive pro ticks
- stop(): clears interval immediately, onPro never called
- double-start while active: second start is a no-op
- start after prior onPro: no-op (post-success reuse guard)

Parity test (tests/entitlement-watchdog-parity.test.mts) asserts the
two mirror files are byte-identical so drift alarms at CI time.

Rebuilt public/pro/ bundle (index-QpSvSkuY.js -> index-C-qy2Yt9.js).
This commit is contained in:
Elie Habib
2026-04-24 07:53:51 +04:00
committed by GitHub
parent 5cec1b8c4c
commit 38f7002f19
8 changed files with 846 additions and 67 deletions

View File

@@ -20,6 +20,7 @@ import {
stripCheckoutIntentFromSearch,
buildCheckoutReturnUrl,
} from './checkout-intent-url';
import { createEntitlementWatchdog, type EntitlementWatchdog } from './entitlement-watchdog';
let clerk: InstanceType<typeof Clerk> | null = null;
let checkoutInFlight = false;
@@ -119,9 +120,132 @@ async function _loadClerk(): Promise<InstanceType<typeof Clerk>> {
return clerk;
}
/**
* Entitlement watchdog tuning.
*
* Why this exists at all: Dodo's overlay can navigate to
* `/status/{id}/wallet-return` after a successful payment (observed on
* subscription-trial `amount=0` flows) and never emit `checkout.status`
* or `checkout.redirect_requested` back to the parent. Prior PRs (#3298
* flip to manualRedirect:false, #3346 add redirect_requested handler,
* #3354 Escape-key close hatch) all depended on Dodo emitting SOMETHING;
* the wallet-return path emits nothing. The watchdog polls our own
* entitlement endpoint so the post-checkout journey completes from the
* webhook regardless of what Dodo's iframe does.
*
* INTERVAL: 3000ms floor. Below 2s our own pipeline is eventually
* consistent (Convex + Upstash webhook latency) so faster polling just
* burns Clerk token refreshes. 3s is imperceptible to humans.
*
* TIMEOUT: 10 minutes. A real user who paid and left the tab open 10min
* without the webhook landing has a different problem (Dodo outage,
* webhook pipeline broken) — the fix isn't a longer poll.
*/
const WATCHDOG_INTERVAL_MS = 3_000;
const WATCHDOG_TIMEOUT_MS = 10 * 60 * 1000;
export function initOverlay(onSuccess?: () => void): void {
import('dodopayments-checkout').then(({ DodoPayments }) => {
const env = import.meta.env.VITE_DODO_ENVIRONMENT;
// Closure-scoped watchdog + idempotency state. Reset implicitly
// on each new overlay open because `checkout.opened` is what starts
// the watchdog and `_terminalFired` only gates within one session:
// `checkout.closed` clears both. The SDK Initialize is idempotent
// per the main-app comment in src/services/checkout.ts, so this
// closure wraps the one-and-only live onEvent handler.
let _terminalFired = false;
let watchdog: EntitlementWatchdog | null = null;
const stopWatchdog = (): void => {
watchdog?.stop();
watchdog = null;
};
const safeCloseOverlay = (): void => {
try {
if (DodoPayments.Checkout.isOpen?.()) {
DodoPayments.Checkout.close();
}
} catch {
// Overlay already gone / SDK mid-teardown.
}
};
// Single terminal-success entry point. Both the event handler and
// the watchdog route through here so double-fires are impossible.
// `redirectTo` optional: the event path supplies Dodo's
// redirect_to (which may embed payment_id etc.); the watchdog
// path falls back to our canonical success URL.
const fireTerminalSuccess = (
reason: 'event-status' | 'event-redirect' | 'watchdog',
redirectTo?: string,
): void => {
if (_terminalFired) return;
_terminalFired = true;
stopWatchdog();
Sentry.addBreadcrumb({
category: 'checkout',
message: `terminal success (${reason})`,
level: 'info',
data: { reason },
});
// Counter-signal so Dodo's wallet-return deadlock prevalence is
// measurable in Sentry. We intentionally log `info`, not `error`
// — this is expected handling, not a failure. See
// `feedback_sentry_level_expected_user_states`.
if (reason === 'watchdog') {
Sentry.captureMessage('Dodo wallet-return deadlock — watchdog resolved', {
level: 'info',
tags: { surface: 'pro-marketing', code: 'watchdog_resolved' },
});
}
try {
onSuccess?.();
} catch (err) {
console.error('[checkout] onSuccess threw:', err);
Sentry.captureException(err, {
tags: { surface: 'pro-marketing', action: 'on-success' },
});
}
// The event-redirect path does its OWN navigation using the
// URL Dodo supplied (preserves payment_id / subscription_id
// query params downstream consumers may read). Watchdog and
// event-status paths use the canonical fallback — Dodo's
// status endpoint is authoritative for the entitlement; the
// URL params are informational at this point.
if (reason === 'event-redirect') {
window.location.href = redirectTo || 'https://worldmonitor.app/?wm_checkout=success';
} else {
safeCloseOverlay();
window.location.href = 'https://worldmonitor.app/?wm_checkout=success';
}
};
const startWatchdog = (): void => {
if (watchdog !== null || _terminalFired) return;
watchdog = createEntitlementWatchdog(
{
endpoint: `${API_BASE}/me/entitlement`,
intervalMs: WATCHDOG_INTERVAL_MS,
timeoutMs: WATCHDOG_TIMEOUT_MS,
},
{
getToken: getAuthToken,
fetch: (input, init) => fetch(input, init),
setInterval: (cb, ms) => window.setInterval(cb, ms),
clearInterval: (id) => window.clearInterval(id),
now: () => Date.now(),
onPro: () => fireTerminalSuccess('watchdog'),
},
);
watchdog.start();
};
DodoPayments.Initialize({
mode: env === 'live_mode' ? 'live' : 'test',
displayType: 'overlay',
@@ -141,6 +265,15 @@ export function initOverlay(onSuccess?: () => void): void {
console.info('[checkout] dodo event', event.event_type,
status !== undefined ? { status } : undefined);
// `checkout.opened` is the only terminal-adjacent event Dodo
// emits reliably on BOTH the happy path and the wallet-return
// deadlock path (confirmed via HAR 2026-04-23). It's our
// earliest safe moment to arm the watchdog.
if (event.event_type === 'checkout.opened') {
_terminalFired = false;
startWatchdog();
}
// Dodo's documented `manualRedirect: true` flow emits TWO events
// on terminal success: `checkout.status` for UI updates, and
// `checkout.redirect_requested` carrying the URL WE must navigate
@@ -152,7 +285,7 @@ export function initOverlay(onSuccess?: () => void): void {
// legacy top-level `event.data.status` read was a guess against
// an older SDK version and most likely never matched.
if (event.event_type === 'checkout.status' && status === 'succeeded') {
onSuccess?.();
fireTerminalSuccess('event-status');
}
if (event.event_type === 'checkout.redirect_requested') {
const redirectTo = msg?.redirect_to as string | undefined;
@@ -161,7 +294,12 @@ export function initOverlay(onSuccess?: () => void): void {
// changelog v1.84.0. Our return_url carries `?wm_checkout=success`
// so the dashboard bridge (src/services/checkout-return.ts) fires
// regardless of Dodo's appended params.
window.location.href = redirectTo || 'https://worldmonitor.app/?wm_checkout=success';
fireTerminalSuccess('event-redirect', redirectTo);
}
if (event.event_type === 'checkout.closed') {
// Cancel path. Do not fire success — user didn't pay, or
// the watchdog timed out gracefully.
stopWatchdog();
}
if (event.event_type === 'checkout.link_expired') {
// Not user-blocking — log-only for now; follow-up if Sentry

View File

@@ -0,0 +1,120 @@
/**
* Entitlement-polling watchdog for the Dodo checkout overlay.
*
* Context: Dodo's overlay can navigate to /status/{id}/wallet-return
* after a successful payment (observed on subscription-trial amount=0
* flows) and never emit a terminal postMessage (checkout.status /
* checkout.redirect_requested) back to the parent. When that happens,
* the merchant's onEvent handler can't tell the purchase succeeded and
* the user is stranded on Dodo's success page forever.
*
* This module polls /api/me/entitlement at a fixed interval. When the
* webhook has flipped the user to pro, `onPro` fires once. The poller
* stops on: (1) a single onPro fire, (2) stop() call, (3) hard timeout.
* The caller owns the onPro side effects (post-checkout cleanup,
* overlay close, navigation).
*
* Shape: pure DI module. All environmental dependencies (fetch, timers,
* clock, token source) are injected so the state machine is testable
* without any DOM or network. See tests/entitlement-watchdog.test.mts.
*
* This file MUST be kept byte-identical with
* pro-test/src/services/entitlement-watchdog.ts. The parity check in
* tests/entitlement-watchdog-parity.test.mts enforces that. If you
* change one, change both.
*/
export interface EntitlementWatchdogDeps {
/** Returns a Bearer token or null if the user isn't signed in yet. */
getToken: () => Promise<string | null>;
/** Injected fetch — use globalThis.fetch in production, a stub in tests. */
fetch: typeof fetch;
/** Injected timer. Use window.setInterval in production. */
setInterval: (handler: () => void, timeout: number) => number;
/** Injected clearer. */
clearInterval: (id: number) => void;
/** Monotonic-ish clock. Use Date.now in production, a controllable stub in tests. */
now: () => number;
/** Fired exactly once when entitlement flips to pro. */
onPro: () => void;
}
export interface EntitlementWatchdog {
start: () => void;
stop: () => void;
isActive: () => boolean;
}
export interface EntitlementWatchdogConfig {
/** Endpoint to poll. Must return `{ isPro: boolean }` on 200. */
endpoint: string;
/** Poll interval in ms. 3000ms is our production value. */
intervalMs: number;
/** Hard cap after which the poller self-terminates WITHOUT firing onPro. */
timeoutMs: number;
/** AbortSignal timeout for each individual fetch. Optional; defaults to 8000. */
fetchTimeoutMs?: number;
}
export function createEntitlementWatchdog(
config: EntitlementWatchdogConfig,
deps: EntitlementWatchdogDeps,
): EntitlementWatchdog {
let intervalId: number | null = null;
let startedAt = 0;
let fired = false;
const fetchTimeoutMs = config.fetchTimeoutMs ?? 8_000;
const stop = (): void => {
if (intervalId !== null) {
deps.clearInterval(intervalId);
intervalId = null;
}
};
const tick = async (): Promise<void> => {
// Re-check inside the tick in case start/stop raced with this
// scheduled callback. setInterval can fire one more time after
// clearInterval in some runtimes.
if (intervalId === null || fired) return;
if (deps.now() - startedAt > config.timeoutMs) {
// Hard cap. Do NOT fire onPro on timeout — if the webhook hasn't
// landed after the cap, something else is broken and promoting
// the user via a stale entitlement read would mask it.
stop();
return;
}
try {
const token = await deps.getToken();
if (!token) return;
const resp = await deps.fetch(config.endpoint, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(fetchTimeoutMs),
});
if (!resp.ok) return;
const body = (await resp.json()) as { isPro?: boolean };
if (body.isPro && !fired) {
fired = true;
stop();
deps.onPro();
}
} catch {
// Swallow — poll retries on next tick. Unexpected exceptions
// would otherwise spam Sentry once every interval for up to
// timeoutMs.
}
};
return {
start: (): void => {
if (intervalId !== null || fired) return;
startedAt = deps.now();
// Cast: the DOM lib types setInterval's return as number, the
// Node lib types it as NodeJS.Timeout. Injected deps use number
// because that matches window.setInterval and our fake timer.
intervalId = deps.setInterval(() => { void tick(); }, config.intervalMs);
},
stop,
isActive: (): boolean => intervalId !== null,
};
}

File diff suppressed because one or more lines are too long

View File

@@ -144,7 +144,7 @@
}
</script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
<script type="module" crossorigin src="/pro/assets/index-CiMZEtgt.js"></script>
<script type="module" crossorigin src="/pro/assets/index-C-qy2Yt9.js"></script>
<link rel="stylesheet" crossorigin href="/pro/assets/index-xSEP0-ib.css">
</head>
<body>

View File

@@ -39,6 +39,7 @@ import {
import { loadActiveReferral } from './referral-capture';
import { showDuplicateSubscriptionDialog } from './checkout-duplicate-dialog';
import { resolvePlanDisplayName } from './checkout-plan-names';
import { createEntitlementWatchdog, type EntitlementWatchdog } from './entitlement-watchdog';
export {
EXTENDED_UNLOCK_TIMEOUT_MS,
@@ -133,6 +134,20 @@ let _resetOverlaySession: (() => void) | null = null;
let _watchersInitialized = false;
let _escapeHandler: ((e: KeyboardEvent) => void) | null = null;
/**
* Entitlement watchdog tuning (mirrors pro-test/src/services/checkout.ts).
*
* Dodo's overlay can navigate to `/status/{id}/wallet-return` after a
* successful payment (observed on subscription-trial `amount=0` flows)
* and never emit `checkout.status` or `checkout.redirect_requested`.
* Prior PRs assumed Dodo would emit SOMETHING; the wallet-return path
* emits nothing. Watchdog polls our own entitlement endpoint so the
* post-checkout cleanup runs from the webhook regardless of what
* Dodo's iframe does. See docs/plans/2026-04-23-002-*-plan.md.
*/
const WATCHDOG_INTERVAL_MS = 3_000;
const WATCHDOG_TIMEOUT_MS = 10 * 60 * 1000;
/**
* Dodo's hosted overlay has been observed to deadlock: the in-iframe X
* button hits `GET /api/checkout/sessions/{id}/payment-link` → 404 →
@@ -178,48 +193,130 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
// must reset when a new overlay opens. `openCheckout` resets this
// flag via the exported `resetOverlaySessionState()` helper below.
let successFired = false;
_resetOverlaySession = () => { successFired = false; };
let navigationFired = false;
let watchdog: EntitlementWatchdog | null = null;
const stopWatchdog = (): void => {
watchdog?.stop();
watchdog = null;
};
_resetOverlaySession = () => {
successFired = false;
navigationFired = false;
stopWatchdog();
};
// Shared terminal-success side effects (run ONCE per overlay session).
// Called from: `checkout.status=succeeded` (event path), the
// watchdog when entitlement flips to pro (fallback path), and the
// watchdog-free `checkout.redirect_requested` handler when it arrives
// before status (rare but possible per docs). The `successFired` flag
// makes subsequent callers no-op, preserving prior single-fire semantics.
//
// The entitlement watcher in panel-layout.ts owns the free→pro reload
// (REQUIRES_SKIP_INITIAL_SNAPSHOT_BEHAVIOR; see mirror marker in
// panel-layout.ts) — this block does NOT reload or navigate on its own.
const runTerminalSuccessSideEffects = (reason: 'event-status' | 'event-redirect' | 'watchdog'): void => {
if (successFired) return;
successFired = true;
stopWatchdog();
Sentry.addBreadcrumb({
category: 'checkout',
message: `terminal success (${reason})`,
level: 'info',
data: { reason },
});
if (reason === 'watchdog') {
// Counter-signal so Dodo's wallet-return deadlock prevalence is
// measurable in Sentry. `info` level, not `error`, per
// feedback_sentry_level_expected_user_states.
Sentry.captureMessage('Dodo wallet-return deadlock — watchdog resolved', {
level: 'info',
tags: { component: 'dodo-checkout', code: 'watchdog_resolved' },
});
}
try {
onSuccessCallback?.();
} catch (err) {
console.error('[checkout] onSuccessCallback threw:', err);
Sentry.captureException(err, {
tags: { component: 'dodo-checkout', action: 'on-success' },
});
}
// Terminal success: clear both keys. LAST_CHECKOUT_ATTEMPT_KEY
// is no longer needed (no retry context required); PENDING is
// cleared to avoid auto-opening the overlay on the reload.
clearCheckoutAttempt('success');
clearPendingCheckoutIntent();
// Session flag so the reloaded page seeds the entitlement transition
// detector as post-checkout — see comment block preserved from the
// original inlined handler below for the full rationale.
markPostCheckout();
};
const startWatchdog = (): void => {
if (watchdog !== null || successFired) return;
watchdog = createEntitlementWatchdog(
{
endpoint: '/api/me/entitlement',
intervalMs: WATCHDOG_INTERVAL_MS,
timeoutMs: WATCHDOG_TIMEOUT_MS,
},
{
getToken: getClerkToken,
fetch: (input, init) => fetch(input, init),
setInterval: (cb, ms) => window.setInterval(cb, ms),
clearInterval: (id) => window.clearInterval(id),
now: () => Date.now(),
onPro: () => {
runTerminalSuccessSideEffects('watchdog');
// Close the stuck overlay so the entitlement watcher's reload
// is not hidden behind Dodo's "payment successful" page.
safeCloseOverlay();
},
},
);
watchdog.start();
};
DodoPayments.Initialize({
mode: env === 'live_mode' ? 'live' : 'test',
displayType: 'overlay',
onEvent: (event: CheckoutEvent) => {
switch (event.event_type) {
case 'checkout.opened':
// Arm the watchdog at the earliest safe moment. HAR 2026-04-23
// confirms `checkout.opened` fires on both the happy path AND
// the wallet-return deadlock path; terminal events do not.
startWatchdog();
break;
case 'checkout.status': {
// Docs-documented shape is ONLY `event.data.message.status` —
// the prior top-level `event.data.status` read was a guess
// against an older SDK version and most likely never matched.
// (overlay-checkout.mdx / inline-checkout.mdx, SDK >= 0.109.2).
//
// Reload ownership: the entitlement watcher in panel-layout.ts
// is the SINGLE reload source (fires on free→pro transition).
// We no longer schedule a belt-and-braces setTimeout reload
// here — that competed with the watcher and made "still
// unlocking" UX impossible because the banner was guaranteed
// to be wiped at 3s regardless of webhook latency.
//
// REQUIRES_SKIP_INITIAL_SNAPSHOT_BEHAVIOR — the watcher's
// first-snapshot seeding depends on PR #3163 (merged
// 2026-04-18) having fixed the swallow-first-snapshot bug.
// If that PR is ever reverted or its behavior regresses,
// tests in tests/entitlement-transition.test.mts will fail
// (specifically "simulates the incident sequence" case); see
// the mirror marker in panel-layout.ts.
const rawData = event.data as Record<string, unknown> | undefined;
const status = (rawData?.message as Record<string, unknown> | undefined)?.status;
if (status === 'succeeded') {
successFired = true;
onSuccessCallback?.();
// Terminal success: clear both keys. LAST_CHECKOUT_ATTEMPT_KEY
// is no longer needed (no retry context required); PENDING is
// cleared to avoid auto-opening the overlay on the reload.
clearCheckoutAttempt('success');
clearPendingCheckoutIntent();
// Mark a session flag so the reloaded page seeds the entitlement
// transition detector as post-checkout — without this, the
// detector would treat the first pro snapshot as "legacy-pro
// baseline" and swallow the activation.
//
// Reload ownership: the entitlement watcher in panel-layout.ts
// is the SINGLE reload source (fires on free→pro transition).
// We no longer schedule a belt-and-braces setTimeout reload
// here — that competed with the watcher and made "still
// unlocking" UX impossible because the banner was guaranteed
// to be wiped at 3s regardless of webhook latency.
//
// REQUIRES_SKIP_INITIAL_SNAPSHOT_BEHAVIOR — the watcher's
// first-snapshot seeding depends on PR #3163 (merged
// 2026-04-18) having fixed the swallow-first-snapshot bug.
// If that PR is ever reverted or its behavior regresses,
// tests in tests/entitlement-transition.test.mts will fail
// (specifically "simulates the incident sequence" case); see
// the mirror marker in panel-layout.ts.
markPostCheckout();
runTerminalSuccessSideEffects('event-status');
}
break;
}
@@ -231,6 +328,7 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
// the retry CTA. The attempt record will be cleared later by
// the terminal path that actually resolves (success, dismissed,
// duplicate, or the mount-time abandonment sweep).
stopWatchdog();
if (!successFired) {
clearPendingCheckoutIntent();
}
@@ -242,8 +340,17 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
// fail on Safari with an orphaned about:blank tab; we follow
// the docs-prescribed handler instead.
// (overlay-checkout.mdx: "Redirect the customer manually".)
//
// On the happy path both `checkout.status=succeeded` and
// `checkout.redirect_requested` fire — status runs the
// markPostCheckout + cleanup side effects, redirect navigates
// away. When only redirect_requested fires (no prior status),
// we run the side effects here so the post-checkout flag is
// set before we navigate.
const redirectTo = (event.data?.message as Record<string, unknown> | undefined)?.redirect_to as string | undefined;
if (redirectTo) {
if (!successFired) runTerminalSuccessSideEffects('event-redirect');
if (redirectTo && !navigationFired) {
navigationFired = true;
window.location.href = redirectTo;
}
break;
@@ -255,6 +362,7 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
// deadlock bug (payment-link 404 + render loop) never reaches
// this branch — it traps inside their iframe — but any error
// that DOES escape should not leave a broken overlay mounted.
stopWatchdog();
safeCloseOverlay();
break;
}
@@ -276,6 +384,15 @@ export function initCheckoutOverlay(onSuccess?: () => void): void {
* stored success callback so a new layout can register its own callback.
*/
export function destroyCheckoutOverlay(): void {
// Stop any in-flight watchdog BEFORE we drop references. If the layout
// unmounts mid-checkout, the watchdog's setInterval would otherwise
// keep running inside the closed-over scope and, on entitlement flip,
// fire side effects (clearCheckoutAttempt, clearPendingCheckoutIntent,
// markPostCheckout, safeCloseOverlay) against a subsequent session's
// state. _resetOverlaySession is the only accessor for that closure's
// stopWatchdog.
_resetOverlaySession?.();
_resetOverlaySession = null;
initialized = false;
onSuccessCallback = null;
if (_escapeHandler) {

View File

@@ -0,0 +1,120 @@
/**
* Entitlement-polling watchdog for the Dodo checkout overlay.
*
* Context: Dodo's overlay can navigate to /status/{id}/wallet-return
* after a successful payment (observed on subscription-trial amount=0
* flows) and never emit a terminal postMessage (checkout.status /
* checkout.redirect_requested) back to the parent. When that happens,
* the merchant's onEvent handler can't tell the purchase succeeded and
* the user is stranded on Dodo's success page forever.
*
* This module polls /api/me/entitlement at a fixed interval. When the
* webhook has flipped the user to pro, `onPro` fires once. The poller
* stops on: (1) a single onPro fire, (2) stop() call, (3) hard timeout.
* The caller owns the onPro side effects (post-checkout cleanup,
* overlay close, navigation).
*
* Shape: pure DI module. All environmental dependencies (fetch, timers,
* clock, token source) are injected so the state machine is testable
* without any DOM or network. See tests/entitlement-watchdog.test.mts.
*
* This file MUST be kept byte-identical with
* pro-test/src/services/entitlement-watchdog.ts. The parity check in
* tests/entitlement-watchdog-parity.test.mts enforces that. If you
* change one, change both.
*/
export interface EntitlementWatchdogDeps {
/** Returns a Bearer token or null if the user isn't signed in yet. */
getToken: () => Promise<string | null>;
/** Injected fetch — use globalThis.fetch in production, a stub in tests. */
fetch: typeof fetch;
/** Injected timer. Use window.setInterval in production. */
setInterval: (handler: () => void, timeout: number) => number;
/** Injected clearer. */
clearInterval: (id: number) => void;
/** Monotonic-ish clock. Use Date.now in production, a controllable stub in tests. */
now: () => number;
/** Fired exactly once when entitlement flips to pro. */
onPro: () => void;
}
export interface EntitlementWatchdog {
start: () => void;
stop: () => void;
isActive: () => boolean;
}
export interface EntitlementWatchdogConfig {
/** Endpoint to poll. Must return `{ isPro: boolean }` on 200. */
endpoint: string;
/** Poll interval in ms. 3000ms is our production value. */
intervalMs: number;
/** Hard cap after which the poller self-terminates WITHOUT firing onPro. */
timeoutMs: number;
/** AbortSignal timeout for each individual fetch. Optional; defaults to 8000. */
fetchTimeoutMs?: number;
}
export function createEntitlementWatchdog(
config: EntitlementWatchdogConfig,
deps: EntitlementWatchdogDeps,
): EntitlementWatchdog {
let intervalId: number | null = null;
let startedAt = 0;
let fired = false;
const fetchTimeoutMs = config.fetchTimeoutMs ?? 8_000;
const stop = (): void => {
if (intervalId !== null) {
deps.clearInterval(intervalId);
intervalId = null;
}
};
const tick = async (): Promise<void> => {
// Re-check inside the tick in case start/stop raced with this
// scheduled callback. setInterval can fire one more time after
// clearInterval in some runtimes.
if (intervalId === null || fired) return;
if (deps.now() - startedAt > config.timeoutMs) {
// Hard cap. Do NOT fire onPro on timeout — if the webhook hasn't
// landed after the cap, something else is broken and promoting
// the user via a stale entitlement read would mask it.
stop();
return;
}
try {
const token = await deps.getToken();
if (!token) return;
const resp = await deps.fetch(config.endpoint, {
headers: { Authorization: `Bearer ${token}` },
signal: AbortSignal.timeout(fetchTimeoutMs),
});
if (!resp.ok) return;
const body = (await resp.json()) as { isPro?: boolean };
if (body.isPro && !fired) {
fired = true;
stop();
deps.onPro();
}
} catch {
// Swallow — poll retries on next tick. Unexpected exceptions
// would otherwise spam Sentry once every interval for up to
// timeoutMs.
}
};
return {
start: (): void => {
if (intervalId !== null || fired) return;
startedAt = deps.now();
// Cast: the DOM lib types setInterval's return as number, the
// Node lib types it as NodeJS.Timeout. Injected deps use number
// because that matches window.setInterval and our fake timer.
intervalId = deps.setInterval(() => { void tick(); }, config.intervalMs);
},
stop,
isActive: (): boolean => intervalId !== null,
};
}

View File

@@ -0,0 +1,40 @@
/**
* Parity check for the entitlement-watchdog mirror files.
*
* `src/services/entitlement-watchdog.ts` (dashboard bundle) and
* `pro-test/src/services/entitlement-watchdog.ts` (marketing bundle)
* MUST be byte-identical. The dashboard version is what the unit tests
* in entitlement-watchdog.test.mts cover; pro-test imports its own copy
* because the bundles have no cross-root imports (Vite alias `@`
* resolves to the pro-test root only). A silent drift between the two
* copies would leave /pro's watchdog uncovered and possibly broken.
*
* Prior-art: the scripts/shared/ mirror convention
* (feedback_shared_dir_mirror_requirement).
*/
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { readFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, resolve } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
describe('entitlement-watchdog.ts mirror parity', () => {
it('src/services/entitlement-watchdog.ts and pro-test/src/services/entitlement-watchdog.ts are byte-identical', async () => {
const dashboard = await readFile(
resolve(__dirname, '..', 'src/services/entitlement-watchdog.ts'),
'utf-8',
);
const marketing = await readFile(
resolve(__dirname, '..', 'pro-test/src/services/entitlement-watchdog.ts'),
'utf-8',
);
assert.equal(
dashboard,
marketing,
'If this fails, cp src/services/entitlement-watchdog.ts pro-test/src/services/entitlement-watchdog.ts (or the reverse) and re-run the gates. The two files MUST stay in lockstep.',
);
});
});

View File

@@ -0,0 +1,244 @@
/**
* State-machine tests for createEntitlementWatchdog.
*
* This module is the fallback that unblocks the Dodo wallet-return
* deadlock: when Dodo's overlay completes a payment but never emits
* checkout.status / checkout.redirect_requested, the watchdog polls
* /api/me/entitlement until the webhook flips isPro, then fires onPro.
* See PR #3357 / skill dodo-wallet-return-skips-postmessage.
*
* Scenarios covered:
* - Wallet-return path: N isPro:false polls then isPro:true -> onPro
* fires exactly once.
* - Timeout cap: isPro never flips -> poller self-terminates without
* firing onPro (we'd rather strand than falsely promote).
* - Missing token: tick is a no-op; poller keeps trying.
* - Non-200 responses (401 / 404 / 5xx): tick swallows, poller keeps
* trying.
* - Fetch rejects (offline / abort): tick swallows, poller keeps
* trying.
* - Idempotence: once onPro has fired, later ticks do not re-fire.
* - stop(): clears the interval immediately, onPro never called.
* - Double-start: second start() while active is a no-op.
*/
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import {
createEntitlementWatchdog,
type EntitlementWatchdogDeps,
} from '@/services/entitlement-watchdog';
interface Harness {
deps: EntitlementWatchdogDeps;
/** Drive N ticks synchronously, resolving between each. */
tickTimes: (n: number) => Promise<void>;
/** Advance the fake clock by ms without firing ticks. */
advanceTime: (ms: number) => void;
fetchCalls: number;
tokenCalls: number;
onProCalls: number;
/** Active interval callback, or null if stopped. */
getActiveCb: () => (() => void) | null;
}
/**
* Build a harness whose fetch response is driven by `respond`, called
* for each tick. Returning null from `respond` means "reject" (simulates
* network error / AbortError). Returning a Response-shaped object drives
* the ok / json branches.
*/
function buildHarness(
respond: (tickIndex: number) => { ok: boolean; body?: unknown } | null,
tokenProvider: (tickIndex: number) => string | null = () => 'tok_test',
): Harness {
let activeId: number | null = null;
let activeCb: (() => void) | null = null;
let nextId = 1;
let fakeNow = 1_000;
let tickIndex = 0;
const harness: Harness = {
deps: {} as EntitlementWatchdogDeps,
tickTimes: async (n: number): Promise<void> => {
for (let i = 0; i < n; i++) {
if (!activeCb) return;
activeCb();
// Drain microtasks + the tick's async chain (await getToken,
// await fetch, await resp.json). 3 macro yields is enough at
// this depth; node:test runs synchronously otherwise.
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
}
},
advanceTime: (ms: number): void => { fakeNow += ms; },
fetchCalls: 0,
tokenCalls: 0,
onProCalls: 0,
getActiveCb: () => activeCb,
};
harness.deps = {
getToken: async () => {
const t = tokenProvider(harness.tokenCalls);
harness.tokenCalls++;
return t;
},
fetch: async (_input: unknown, _init?: unknown) => {
const idx = harness.fetchCalls;
harness.fetchCalls++;
const r = respond(idx);
if (r === null) {
throw new Error('simulated fetch error');
}
return {
ok: r.ok,
status: r.ok ? 200 : 500,
json: async () => r.body ?? {},
} as unknown as Response;
},
setInterval: ((cb: () => void, _ms: number) => {
activeCb = cb;
activeId = nextId++;
return activeId;
}) as typeof setInterval,
clearInterval: ((id: number) => {
if (id === activeId) {
activeCb = null;
activeId = null;
}
}) as typeof clearInterval,
now: () => fakeNow,
onPro: () => {
harness.onProCalls++;
},
};
// Also register tickIndex for future extension
void tickIndex;
return harness;
}
describe('createEntitlementWatchdog', () => {
it('wallet-return path: fires onPro exactly once after isPro flips to true', async () => {
const h = buildHarness((i) => ({ ok: true, body: { isPro: i >= 3 } }));
const wd = createEntitlementWatchdog(
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 600_000 },
h.deps,
);
wd.start();
// Three non-pro ticks, then pro tick -> onPro.
await h.tickTimes(5);
assert.equal(h.onProCalls, 1, 'onPro should fire exactly once');
assert.equal(wd.isActive(), false, 'watchdog should stop after success');
assert.equal(h.getActiveCb(), null, 'interval should be cleared');
});
it('timeout cap: never fires onPro if isPro stays false past timeoutMs', async () => {
const h = buildHarness(() => ({ ok: true, body: { isPro: false } }));
const wd = createEntitlementWatchdog(
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 10_000 },
h.deps,
);
wd.start();
await h.tickTimes(2);
assert.equal(h.onProCalls, 0);
assert.equal(wd.isActive(), true);
// Push the clock past the timeoutMs cap and tick once more.
h.advanceTime(11_000);
await h.tickTimes(1);
assert.equal(h.onProCalls, 0, 'onPro must NOT fire on timeout');
assert.equal(wd.isActive(), false, 'watchdog self-terminates on timeout');
});
it('missing token: tick is a no-op, poller keeps running', async () => {
const h = buildHarness(
() => ({ ok: true, body: { isPro: true } }),
(i) => (i < 2 ? null : 'tok_test'),
);
const wd = createEntitlementWatchdog(
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 600_000 },
h.deps,
);
wd.start();
await h.tickTimes(3);
assert.equal(h.fetchCalls, 1, 'fetch only runs when token is present');
assert.equal(h.onProCalls, 1, 'onPro fires on the tick that got a token');
});
it('non-2xx response: tick swallows, poller continues', async () => {
// First two ticks return 401, third returns isPro:true.
const h = buildHarness((i) => (i < 2 ? { ok: false } : { ok: true, body: { isPro: true } }));
const wd = createEntitlementWatchdog(
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 600_000 },
h.deps,
);
wd.start();
await h.tickTimes(3);
assert.equal(h.onProCalls, 1);
assert.equal(wd.isActive(), false);
});
it('fetch rejection: tick swallows, poller continues', async () => {
// First tick rejects, second succeeds with isPro:true.
const h = buildHarness((i) => (i === 0 ? null : { ok: true, body: { isPro: true } }));
const wd = createEntitlementWatchdog(
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 600_000 },
h.deps,
);
wd.start();
await h.tickTimes(2);
assert.equal(h.onProCalls, 1, 'poller survives a rejection and fires on next success');
});
it('idempotence: onPro does not fire twice if isPro stays true across ticks', async () => {
const h = buildHarness(() => ({ ok: true, body: { isPro: true } }));
const wd = createEntitlementWatchdog(
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 600_000 },
h.deps,
);
wd.start();
await h.tickTimes(5);
assert.equal(h.onProCalls, 1, 'onPro only fires once even if stop races with later ticks');
});
it('stop(): clears interval immediately; onPro never fires', async () => {
const h = buildHarness(() => ({ ok: true, body: { isPro: true } }));
const wd = createEntitlementWatchdog(
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 600_000 },
h.deps,
);
wd.start();
wd.stop();
await h.tickTimes(3);
assert.equal(h.onProCalls, 0);
assert.equal(wd.isActive(), false);
assert.equal(h.fetchCalls, 0, 'no fetches should happen after stop()');
});
it('double-start is a no-op while active', async () => {
const h = buildHarness(() => ({ ok: true, body: { isPro: false } }));
const wd = createEntitlementWatchdog(
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 600_000 },
h.deps,
);
wd.start();
const cbBefore = h.getActiveCb();
wd.start();
const cbAfter = h.getActiveCb();
assert.strictEqual(cbBefore, cbAfter, 'second start must not register a new interval');
});
it('start after onPro fired is a no-op (post-success reuse guard)', async () => {
const h = buildHarness(() => ({ ok: true, body: { isPro: true } }));
const wd = createEntitlementWatchdog(
{ endpoint: '/api/me/entitlement', intervalMs: 3_000, timeoutMs: 600_000 },
h.deps,
);
wd.start();
await h.tickTimes(1);
assert.equal(h.onProCalls, 1);
// Pretend caller mistakenly re-starts the same instance.
wd.start();
await h.tickTimes(2);
assert.equal(h.onProCalls, 1, 'onPro must not re-fire after a prior success on the same instance');
});
});