koala73/worldmonitor · error · ConvexError

Invalid returnUrl: must use a trusted worldmonitor.app…

Error message

Invalid returnUrl: must use a trusted worldmonitor.app origin

What it means

Second returnUrl guard: the parsed origin must exactly match an entry of TRUSTED_RETURN_URL_ORIGINS in convex/payments/returnUrlOrigin.ts (worldmonitor.app plus the www/app/api/tech/finance/commodity/happy/energy subdomains) or the deployment's SITE_URL origin. It is deliberately NOT a *.worldmonitor.app suffix match, because vendor-owned CNAME subdomains (clerk., abacus.) must never become post-payment redirect targets; the file header documents real 500s (WORLDMONITOR-K7/-Q4) caused by allowlist drift when api.worldmonitor.app was missing.

Solutions

  1. Use an exact trusted origin, e.g. https://app.worldmonitor.app/dashboard
  2. For preview/self-hosted deployments set SITE_URL to the deployment's own origin — isTrustedReturnUrlOrigin accepts it as extraOrigin
  3. If a new first-party host genuinely serves the app, add it to TRUSTED_RETURN_URL_ORIGINS in convex/payments/returnUrlOrigin.ts and extend tests/checkout-return-url-origin.test.mts in both directions
  4. Never relax this to a suffix match to 'fix' the error

Example fix

// before
createCheckout({ productId, returnUrl: "http://worldmonitor.app/pro" });
// after
createCheckout({ productId, returnUrl: "https://www.worldmonitor.app/pro" });
Defensive patterns

Strategy: validation

Validate before calling

import { TRUSTED_RETURN_URL_ORIGINS } from "../convex/payments/returnUrlOrigin";
function trustedReturn(u: string): string | undefined {
  try {
    const { origin } = new URL(u);
    return TRUSTED_RETURN_URL_ORIGINS.includes(origin) ? u : undefined;
  } catch { return undefined; }
}

Type guard

function isTrustedReturn(u: string): boolean {
  try { return TRUSTED_RETURN_URL_ORIGINS.includes(new URL(u).origin); } catch { return false; }
}

Try / catch

try {
  await createCheckout({ productId, returnUrl });
} catch (e) {
  if (e instanceof ConvexError && String(e.message).includes("trusted worldmonitor.app origin")) {
    return createCheckout({ productId, returnUrl: "https://worldmonitor.app" });
  }
  throw e;
}

Prevention

When it happens

Trigger: returnUrl with http:// scheme, a non-standard port (https://worldmonitor.app:8443), a look-alike host (https://worldmonitor.app.evil.com), a vendor subdomain like https://clerk.worldmonitor.app, or any third-party origin. Also self-hosted/preview deployments where SITE_URL doesn't match the host the user is actually on.

Common situations: Local dev on http://localhost:5173 with SITE_URL unset; a new first-party subdomain attached in Vercel but never added to the allowlist (the exact K7/Q4 drift); security tests probing open redirects.

Related errors


AI-assisted analysis of koala73/worldmonitor@e586b8b4b8 (2026-08-21). Data as JSON: /api/errors/71a5f2298a25b780. Report an issue: GitHub.

Appendix: source

Thrown at convex/payments/checkout.ts:290

  user: UserInfo,
): Promise<
  | (Awaited<ReturnType<typeof createDodoCheckoutSession>> & { anonymous_claim_token?: string })
  | CheckoutRateLimitedOutcome
  | CheckoutTimedOutOutcome
> {
  // Validate returnUrl to prevent open-redirect attacks.
  const siteUrl = process.env.SITE_URL ?? "https://worldmonitor.app";
  let returnUrl = siteUrl;
  if (args.returnUrl) {
    let parsedReturnUrl: URL;
    try {
      parsedReturnUrl = new URL(args.returnUrl);
    } catch {
      throw new ConvexError("Invalid returnUrl: must be a valid absolute URL");
    }

    if (!isTrustedReturnUrlOrigin(parsedReturnUrl.origin, new URL(siteUrl).origin)) {
      throw new ConvexError(
        "Invalid returnUrl: must use a trusted worldmonitor.app origin",
      );
    }
    returnUrl = parsedReturnUrl.toString();
  }

  // Completed edge idempotency replays return before reaching this boundary.
  // Consume once per creation, outside the provider retry ladder. A failed
  // admission mutation must propagate: unknown capacity cannot authorize work.
  const denied: CheckoutRateLimitedOutcome | null = await ctx.runMutation(
    internal.payments.checkout.admitCheckout, { userId: user.userId },
  );
  if (denied) return denied;

  // Record Terms assent (#6976). Both checkout paths — the /pro pricing page
  // and every dashboard CTA — funnel through here, so one call covers them all
  // and no client can skip it: the buyer clicked a button that sits directly
  // under "By subscribing you agree to the Terms of Service and Privacy Policy".

View on GitHub (pinned to e586b8b4b8)