diff --git a/web/app/[locale]/pricing/page.tsx b/web/app/[locale]/pricing/page.tsx
index 335578d30b38..8335fa518cbc 100644
--- a/web/app/[locale]/pricing/page.tsx
+++ b/web/app/[locale]/pricing/page.tsx
@@ -38,14 +38,15 @@ import {
type FaqItem,
type SizeRow,
} from "../../components/pricing-shared";
-import { CheckoutButton } from "../../components/checkout-navigation";
import {
+ PricingCheckoutButton,
PricingIntervalProvider,
PricingIntervalSelector,
PricingIntervalValue,
} from "../../components/pricing-interval-selector";
import {
PRO_PRICING_USD,
+ TEAM_PRICING_USD,
proBillingInterval,
} from "../../../services/billing/plans";
@@ -104,10 +105,21 @@ export default async function PricingPage({
month: withCheckoutInterval(PRO_CHECKOUT_URL, "month"),
year: withCheckoutInterval(PRO_CHECKOUT_URL, "year"),
};
+ const teamCheckoutHrefs = {
+ month: withCheckoutInterval(TEAM_CHECKOUT_URL, "month"),
+ year: withCheckoutInterval(TEAM_CHECKOUT_URL, "year"),
+ };
const annualComparePrice = t("annualComparePrice", {
monthly: PRO_PRICING_USD.year.monthlyEquivalent,
annual: PRO_PRICING_USD.year.billedAmount,
});
+ const teamMonthlyComparePrice = t("teamMonthlyComparePrice", {
+ monthly: TEAM_PRICING_USD.month.monthlyEquivalent,
+ });
+ const teamAnnualComparePrice = t("teamAnnualComparePrice", {
+ monthly: TEAM_PRICING_USD.year.monthlyEquivalent,
+ annual: TEAM_PRICING_USD.year.billedAmount,
+ });
const freeFeatures = t.raw("free.features") as string[];
const proBaseFeatures = t.raw("pro.features") as string[];
@@ -159,7 +171,7 @@ export default async function PricingPage({
period={t("perMonth")}
>
@@ -205,10 +217,26 @@ export default async function PricingPage({
{/* Team */}
@@ -248,7 +276,12 @@ export default async function PricingPage({
annual={annualComparePrice}
/>
),
- team: `${t("team.price")}${t("perUserMonth")}`,
+ team: (
+
+ ),
enterprise: t("enterprise.price"),
}}
actions={{
@@ -271,9 +304,14 @@ export default async function PricingPage({
)
),
team: (
-
+
),
enterprise: (
diff --git a/web/app/api/billing/checkout/route.ts b/web/app/api/billing/checkout/route.ts
index 5c8dbcb03b7d..6273037ee1b7 100644
--- a/web/app/api/billing/checkout/route.ts
+++ b/web/app/api/billing/checkout/route.ts
@@ -20,7 +20,7 @@ import {
resolveTeamPrice,
stripe,
} from "../../../../services/billing/stripe";
-import { proBillingInterval } from "../../../../services/billing/plans";
+import { billingInterval } from "../../../../services/billing/plans";
export const dynamic = "force-dynamic";
@@ -100,7 +100,7 @@ async function stripeProCheckout(
request.nextUrl.searchParams.get("cmux_scheme"),
request,
);
- const interval = proBillingInterval(request.nextUrl.searchParams.get("interval"));
+ const interval = billingInterval(request.nextUrl.searchParams.get("interval"));
const successUrl =
`${request.nextUrl.origin}/api/billing/complete` +
`?session_id={CHECKOUT_SESSION_ID}&cmux_scheme=${encodeURIComponent(scheme)}`;
@@ -162,14 +162,17 @@ async function stripeTeamCheckout(
request.nextUrl.searchParams.get("cmux_scheme"),
request,
);
+ const interval = billingInterval(request.nextUrl.searchParams.get("interval"));
const successUrl =
`${request.nextUrl.origin}/api/billing/complete` +
`?session_id={CHECKOUT_SESSION_ID}&cmux_scheme=${encodeURIComponent(scheme)}`;
const cancelUrl = new URL("/pricing?billing=cancelled", request.nextUrl.origin);
+ cancelUrl.searchParams.set("interval", interval);
const metadata = {
stackTeamId: teamId,
plan: "team",
app: "cmux",
+ billingInterval: interval,
};
try {
@@ -178,7 +181,7 @@ async function stripeTeamCheckout(
mode: "subscription",
line_items: [
{
- price: await resolveTeamPrice(),
+ price: await resolveTeamPrice(interval),
quantity: await checkoutTeamSeatCount(team),
adjustable_quantity: {
enabled: true,
@@ -200,6 +203,7 @@ async function stripeTeamCheckout(
captureBillingError(error, {
route: "/api/billing/checkout",
plan: "team",
+ interval,
stackTeamId: teamId,
});
return NextResponse.redirect(new URL("/pricing?billing=error", request.url));
diff --git a/web/app/app-pricing/page.tsx b/web/app/app-pricing/page.tsx
index e0e559346262..3a48e77903c3 100644
--- a/web/app/app-pricing/page.tsx
+++ b/web/app/app-pricing/page.tsx
@@ -31,7 +31,6 @@ import {
type FaqItem,
type SizeRow,
} from "../components/pricing-shared";
-import { CheckoutButton } from "../components/checkout-navigation";
import {
PricingCheckoutButton,
PricingIntervalProvider,
@@ -40,6 +39,7 @@ import {
} from "../components/pricing-interval-selector";
import {
PRO_PRICING_USD,
+ TEAM_PRICING_USD,
proBillingInterval,
} from "../../services/billing/plans";
@@ -70,7 +70,10 @@ export default async function AppPricingPage({
month: appPricingCheckoutURL("pro", requestOrigin, cmuxScheme, "month"),
year: appPricingCheckoutURL("pro", requestOrigin, cmuxScheme, "year"),
};
- const teamCheckoutURL = appPricingCheckoutURL("team", requestOrigin, cmuxScheme);
+ const teamCheckoutHrefs = {
+ month: appPricingCheckoutURL("team", requestOrigin, cmuxScheme, "month"),
+ year: appPricingCheckoutURL("team", requestOrigin, cmuxScheme, "year"),
+ };
const banner = appPricingBanner(params);
const theme = appPricingTheme(params);
const proFeatures = visibleProFeatures({
@@ -85,6 +88,17 @@ export default async function AppPricingPage({
monthly: PRO_PRICING_USD.year.monthlyEquivalent,
annual: PRO_PRICING_USD.year.billedAmount,
});
+ const teamMonthlyComparePrice = pricingMessage(
+ pricing.teamMonthlyComparePrice,
+ { monthly: TEAM_PRICING_USD.month.monthlyEquivalent },
+ );
+ const teamAnnualComparePrice = pricingMessage(
+ pricing.teamAnnualComparePrice,
+ {
+ monthly: TEAM_PRICING_USD.year.monthlyEquivalent,
+ annual: TEAM_PRICING_USD.year.billedAmount,
+ },
+ );
return (
<>
@@ -133,7 +147,7 @@ export default async function AppPricingPage({
{pricing.free.cta}
)}
-
+
{pricing.free.featuresLead}
@@ -186,13 +200,29 @@ export default async function AppPricingPage({
+ }
+ period={
+
+ }
>
{appStorePaymentGated ? (
{pricing.billingUnavailable}
) : (
- {pricing.team.cta}
+
+ {pricing.team.cta}
+
)}
{pricing.team.featuresLead}
@@ -236,7 +266,12 @@ export default async function AppPricingPage({
annual={annualComparePrice}
/>
),
- team: `${pricing.team.price}${pricing.perUserMonth}`,
+ team: (
+
+ ),
enterprise: pricing.enterprise.price,
}}
actions={{
@@ -264,9 +299,14 @@ export default async function AppPricingPage({
team: appStorePaymentGated ? (
{pricing.billingUnavailable}
) : (
-
+
{pricing.team.cta}
-
+
),
enterprise: appStorePaymentGated ? (
{pricing.billingUnavailable}
diff --git a/web/app/components/pricing-interval-selector.tsx b/web/app/components/pricing-interval-selector.tsx
index 5b9ee173d570..7e76847cff29 100644
--- a/web/app/components/pricing-interval-selector.tsx
+++ b/web/app/components/pricing-interval-selector.tsx
@@ -12,17 +12,20 @@ import {
import { posthog } from "../lib/posthog-client";
import {
PRO_PRICING_USD,
- type ProBillingInterval,
+ TEAM_PRICING_USD,
+ type BillingInterval,
} from "../../services/billing/plans";
import { CheckoutButton } from "./checkout-navigation";
import type { PricingActionSize } from "./pricing-shared";
type PricingSurface = "public_pricing" | "app_pricing" | "dashboard_billing";
-export type ProCheckoutHrefs = Record;
+type PricingPlan = "pro" | "team";
+export type PricingCheckoutHrefs = Record;
+export type ProCheckoutHrefs = PricingCheckoutHrefs;
type PricingIntervalContextValue = {
- interval: ProBillingInterval;
- setInterval: (interval: ProBillingInterval) => void;
+ interval: BillingInterval;
+ setInterval: (interval: BillingInterval) => void;
};
const PricingIntervalContext =
@@ -32,7 +35,7 @@ export function PricingIntervalProvider({
initialInterval,
children,
}: {
- initialInterval: ProBillingInterval;
+ initialInterval: BillingInterval;
children: ReactNode;
}) {
const [interval, setInterval] = useState(initialInterval);
@@ -131,22 +134,29 @@ export function PricingCheckoutButton({
hrefs,
children,
location,
+ plan = "pro",
size = "default",
}: {
- hrefs: ProCheckoutHrefs;
+ hrefs: PricingCheckoutHrefs;
children: ReactNode;
location: string;
+ plan?: PricingPlan;
size?: PricingActionSize;
}) {
const { interval } = usePricingInterval();
- const pricing = PRO_PRICING_USD[interval];
+ const pricing = plan === "pro"
+ ? PRO_PRICING_USD[interval]
+ : TEAM_PRICING_USD[interval];
return (
" >&2
+ exit 2
+ ;;
+esac
+
+WEBHOOK_SECRET_FILE="/tmp/.cmux-live-whsec"
+STRIPE_API_BASE="https://api.stripe.com/v1"
+WEBHOOK_URL="https://cmux.com/api/stripe/webhook"
+WEBHOOK_DESCRIPTION="cmux billing (webhook-driven entitlements)"
+EVENTS=(
+ "checkout.session.completed"
+ "customer.subscription.created"
+ "customer.subscription.updated"
+ "customer.subscription.deleted"
+ "invoice.paid"
+ "invoice.payment_failed"
+)
+
+if [[ ! -f "$SECRET_FILE" ]]; then
+ cat >&2 <&2
+ exit 1
+fi
+
+if [[
+ "$STRIPE_PROVISION_KEY" != "sk_${KEY_PREFIX}_"* &&
+ "$STRIPE_PROVISION_KEY" != "rk_${KEY_PREFIX}_"*
+]]; then
+ echo "$KEY_NAME must be a ${MODE}-mode Stripe secret or restricted key" >&2
+ exit 1
+fi
+
+if ! command -v curl >/dev/null 2>&1; then
+ echo "curl is required" >&2
+ exit 1
+fi
+
+if ! command -v jq >/dev/null 2>&1; then
+ echo "jq is required" >&2
+ exit 1
+fi
+
+stripe_get() {
+ local path="$1"
+ shift
+ curl -fsS -u "${STRIPE_PROVISION_KEY}:" --get "$@" "${STRIPE_API_BASE}${path}"
+}
+
+stripe_post() {
+ local path="$1"
+ shift
+ curl -fsS -u "${STRIPE_PROVISION_KEY}:" -X POST "$@" "${STRIPE_API_BASE}${path}"
+}
+
+ensure_product() {
+ local name="$1"
+ local plan="$2"
+ local response product_id
+
+ response="$(
+ stripe_get "/products/search" \
+ --data-urlencode "query=name:'${name}' AND active:'true'" \
+ --data-urlencode "limit=1"
+ )"
+ product_id="$(jq -r '.data[0].id // empty' <<<"$response")"
+
+ if [[ -n "$product_id" ]]; then
+ echo "Found product ${name}: ${product_id}" >&2
+ else
+ response="$(
+ stripe_post "/products" \
+ -d "name=${name}" \
+ -d "metadata[app]=cmux" \
+ -d "metadata[plan]=${plan}"
+ )"
+ product_id="$(jq -er '.id' <<<"$response")"
+ echo "Created product ${name}: ${product_id}" >&2
+ fi
+ printf '%s' "$product_id"
+}
+
+canonical_product() {
+ local monthly_lookup_key="$1"
+ local name="$2"
+ local plan="$3"
+ local response product_id product_name product_active
+
+ response="$(
+ stripe_get "/prices" \
+ --data-urlencode "lookup_keys[]=${monthly_lookup_key}" \
+ --data-urlencode "expand[]=data.product" \
+ --data-urlencode "limit=1"
+ )"
+ product_id="$(jq -r '.data[0].product.id // empty' <<<"$response")"
+ if [[ -z "$product_id" ]]; then
+ ensure_product "$name" "$plan"
+ return
+ fi
+
+ product_name="$(jq -r '.data[0].product.name // empty' <<<"$response")"
+ product_active="$(jq -r '.data[0].product.active // false' <<<"$response")"
+ if [[ "$product_name" != "$name" || "$product_active" != "true" ]]; then
+ echo "Price ${monthly_lookup_key} belongs to an unexpected product: ${product_id}" >&2
+ exit 1
+ fi
+
+ echo "Using canonical product ${name}: ${product_id}" >&2
+ printf '%s' "$product_id"
+}
+
+ensure_price() {
+ local product_id="$1"
+ local lookup_key="$2"
+ local unit_amount="$3"
+ local interval="$4"
+ local nickname="$5"
+ local response price_id existing_amount existing_currency existing_interval
+ local existing_product existing_active
+
+ response="$(
+ stripe_get "/prices" \
+ --data-urlencode "lookup_keys[]=${lookup_key}" \
+ --data-urlencode "limit=1"
+ )"
+ price_id="$(jq -r '.data[0].id // empty' <<<"$response")"
+
+ if [[ -n "$price_id" ]]; then
+ existing_amount="$(jq -r '.data[0].unit_amount // empty' <<<"$response")"
+ existing_currency="$(jq -r '.data[0].currency // empty' <<<"$response")"
+ existing_interval="$(jq -r '.data[0].recurring.interval // empty' <<<"$response")"
+ existing_product="$(jq -r '.data[0].product // empty' <<<"$response")"
+ existing_active="$(jq -r '.data[0].active // false' <<<"$response")"
+ if [[
+ "$existing_amount" != "$unit_amount" ||
+ "$existing_currency" != "usd" ||
+ "$existing_interval" != "$interval" ||
+ "$existing_product" != "$product_id" ||
+ "$existing_active" != "true"
+ ]]; then
+ echo "Price ${lookup_key} exists with unexpected configuration: ${price_id}" >&2
+ exit 1
+ fi
+ echo "Found price ${lookup_key}: ${price_id}"
+ return 0
+ fi
+
+ response="$(
+ stripe_post "/prices" \
+ -d "product=${product_id}" \
+ -d "currency=usd" \
+ -d "unit_amount=${unit_amount}" \
+ -d "recurring[interval]=${interval}" \
+ -d "lookup_key=${lookup_key}" \
+ -d "nickname=${nickname}"
+ )"
+ price_id="$(jq -er '.id' <<<"$response")"
+ echo "Created price ${lookup_key}: ${price_id}"
+}
+
+pro_product_id="$(canonical_product "cmux-pro-monthly" "cmux Pro" "pro")"
+team_product_id="$(canonical_product "cmux-team-monthly" "cmux Team" "team")"
+
+ensure_price "$pro_product_id" "cmux-pro-monthly" "3000" "month" "cmux Pro Monthly"
+# Keep the original $240 annual Price active for existing subscribers. Stripe
+# Price amounts are immutable, so new annual checkouts use a new lookup key.
+ensure_price "$pro_product_id" "cmux-pro-yearly" "24000" "year" "cmux Pro Yearly (Legacy)"
+ensure_price "$pro_product_id" "cmux-pro-yearly-288" "28800" "year" "cmux Pro Yearly"
+ensure_price "$team_product_id" "cmux-team-monthly" "3500" "month" "cmux Team Monthly"
+ensure_price "$team_product_id" "cmux-team-yearly-336" "33600" "year" "cmux Team Yearly"
+
+if [[ "$MODE" == "live" ]]; then
+ webhooks_response="$(stripe_get "/webhook_endpoints" --data-urlencode "limit=100")"
+ webhook_id="$(
+ jq -r --arg url "$WEBHOOK_URL" '
+ .data[]
+ | select(.url == $url and .status == "enabled")
+ | .id
+ ' <<<"$webhooks_response" | head -n 1
+ )"
+
+ if [[ -n "$webhook_id" ]]; then
+ echo "Found webhook endpoint: $webhook_id"
+ echo "Webhook signing secrets are only returned at creation time; use the existing production STRIPE_WEBHOOK_SECRET."
+ else
+ event_args=()
+ for event in "${EVENTS[@]}"; do
+ event_args+=(-d "enabled_events[]=${event}")
+ done
+ webhook_response="$(
+ stripe_post "/webhook_endpoints" \
+ -d "url=${WEBHOOK_URL}" \
+ -d "description=${WEBHOOK_DESCRIPTION}" \
+ "${event_args[@]}"
+ )"
+ webhook_id="$(jq -er '.id' <<<"$webhook_response")"
+ webhook_secret="$(jq -er '.secret' <<<"$webhook_response")"
+ umask 077
+ printf '%s\n' "$webhook_secret" >"$WEBHOOK_SECRET_FILE"
+ chmod 600 "$WEBHOOK_SECRET_FILE"
+ echo "Created webhook endpoint: $webhook_id"
+ echo "Captured new webhook signing secret in $WEBHOOK_SECRET_FILE (chmod 600)."
+ fi
+
+ cat <<'EOF'
+
+Vercel production env commands
+Run these from a checkout linked to the cmux Vercel project:
+
+ vercel env add STRIPE_SECRET_KEY production --scope manaflow
+ vercel env add STRIPE_WEBHOOK_SECRET production --scope manaflow
+
+Do not paste the provisioning key as STRIPE_SECRET_KEY. Use a least-privilege server key with:
+ Checkout Sessions write, Customers write, Subscriptions read, Prices read, Products read.
+EOF
+fi
diff --git a/web/scripts/stripe/provision-live.sh b/web/scripts/stripe/provision-live.sh
index ebd2d0ab2ee7..f2c85c814bcd 100755
--- a/web/scripts/stripe/provision-live.sh
+++ b/web/scripts/stripe/provision-live.sh
@@ -1,180 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
-SECRET_FILE="${HOME}/.secrets/cmux-stripe-live.env"
-WEBHOOK_SECRET_FILE="/tmp/.cmux-live-whsec"
-STRIPE_API_BASE="https://api.stripe.com/v1"
-PRODUCT_NAME="cmux Pro"
-WEBHOOK_URL="https://cmux.com/api/stripe/webhook"
-WEBHOOK_DESCRIPTION="cmux Pro billing (webhook-driven entitlements)"
-EVENTS=(
- "checkout.session.completed"
- "customer.subscription.created"
- "customer.subscription.updated"
- "customer.subscription.deleted"
- "invoice.paid"
- "invoice.payment_failed"
-)
-
-if [[ ! -f "$SECRET_FILE" ]]; then
- cat >&2 <&2
- exit 1
-fi
-
-if [[ "$STRIPE_LIVE_PROVISION_KEY" != sk_live_* && "$STRIPE_LIVE_PROVISION_KEY" != rk_live_* ]]; then
- echo "STRIPE_LIVE_PROVISION_KEY must be a live-mode Stripe secret or restricted key" >&2
- exit 1
-fi
-
-if ! command -v curl >/dev/null 2>&1; then
- echo "curl is required" >&2
- exit 1
-fi
-
-if ! command -v jq >/dev/null 2>&1; then
- echo "jq is required" >&2
- exit 1
-fi
-
-stripe_get() {
- local path="$1"
- shift
- curl -fsS -u "${STRIPE_LIVE_PROVISION_KEY}:" --get "$@" "${STRIPE_API_BASE}${path}"
-}
-
-stripe_post() {
- local path="$1"
- shift
- curl -fsS -u "${STRIPE_LIVE_PROVISION_KEY}:" -X POST "$@" "${STRIPE_API_BASE}${path}"
-}
-
-product_response="$(
- stripe_get "/products/search" \
- --data-urlencode "query=name:'${PRODUCT_NAME}' AND active:'true'" \
- --data-urlencode "limit=1"
-)"
-product_id="$(jq -r '.data[0].id // empty' <<<"$product_response")"
-
-if [[ -n "$product_id" ]]; then
- echo "Found product: $product_id"
-else
- create_product_response="$(
- stripe_post "/products" \
- -d "name=${PRODUCT_NAME}" \
- -d "metadata[app]=cmux" \
- -d "metadata[plan]=pro"
- )"
- product_id="$(jq -er '.id' <<<"$create_product_response")"
- echo "Created product: $product_id"
-fi
-
-ensure_price() {
- local lookup_key="$1"
- local unit_amount="$2"
- local interval="$3"
- local nickname="$4"
- local response price_id existing_amount existing_currency existing_interval
- local existing_product existing_active
-
- response="$(
- stripe_get "/prices" \
- --data-urlencode "lookup_keys[]=${lookup_key}" \
- --data-urlencode "limit=1"
- )"
- price_id="$(jq -r '.data[0].id // empty' <<<"$response")"
-
- if [[ -n "$price_id" ]]; then
- existing_amount="$(jq -r '.data[0].unit_amount // empty' <<<"$response")"
- existing_currency="$(jq -r '.data[0].currency // empty' <<<"$response")"
- existing_interval="$(jq -r '.data[0].recurring.interval // empty' <<<"$response")"
- existing_product="$(jq -r '.data[0].product // empty' <<<"$response")"
- existing_active="$(jq -r '.data[0].active // false' <<<"$response")"
- if [[
- "$existing_amount" != "$unit_amount" ||
- "$existing_currency" != "usd" ||
- "$existing_interval" != "$interval" ||
- "$existing_product" != "$product_id" ||
- "$existing_active" != "true"
- ]]; then
- echo "Price ${lookup_key} exists with unexpected configuration: ${price_id}" >&2
- exit 1
- fi
- echo "Found price ${lookup_key}: ${price_id}"
- return 0
- fi
-
- response="$(
- stripe_post "/prices" \
- -d "product=${product_id}" \
- -d "currency=usd" \
- -d "unit_amount=${unit_amount}" \
- -d "recurring[interval]=${interval}" \
- -d "lookup_key=${lookup_key}" \
- -d "nickname=${nickname}"
- )"
- price_id="$(jq -er '.id' <<<"$response")"
- echo "Created price ${lookup_key}: ${price_id}"
-}
-
-ensure_price "cmux-pro-monthly" "3000" "month" "cmux Pro Monthly"
-# Keep the original $240 annual Price active for existing subscribers. Stripe
-# Price amounts are immutable, so new annual checkouts use a new lookup key.
-ensure_price "cmux-pro-yearly" "24000" "year" "cmux Pro Yearly (Legacy)"
-ensure_price "cmux-pro-yearly-288" "28800" "year" "cmux Pro Yearly"
-
-webhooks_response="$(stripe_get "/webhook_endpoints" --data-urlencode "limit=100")"
-webhook_id="$(
- jq -r --arg url "$WEBHOOK_URL" '
- .data[]
- | select(.url == $url and .status == "enabled")
- | .id
- ' <<<"$webhooks_response" | head -n 1
-)"
-
-if [[ -n "$webhook_id" ]]; then
- echo "Found webhook endpoint: $webhook_id"
- echo "Webhook signing secrets are only returned at creation time; use the existing production STRIPE_WEBHOOK_SECRET."
-else
- event_args=()
- for event in "${EVENTS[@]}"; do
- event_args+=(-d "enabled_events[]=${event}")
- done
- webhook_response="$(
- stripe_post "/webhook_endpoints" \
- -d "url=${WEBHOOK_URL}" \
- -d "description=${WEBHOOK_DESCRIPTION}" \
- "${event_args[@]}"
- )"
- webhook_id="$(jq -er '.id' <<<"$webhook_response")"
- webhook_secret="$(jq -er '.secret' <<<"$webhook_response")"
- umask 077
- printf '%s\n' "$webhook_secret" >"$WEBHOOK_SECRET_FILE"
- chmod 600 "$WEBHOOK_SECRET_FILE"
- echo "Created webhook endpoint: $webhook_id"
- echo "Captured new webhook signing secret in $WEBHOOK_SECRET_FILE (chmod 600)."
-fi
-
-cat <<'EOF'
-
-Vercel production env commands
-Run these from a checkout linked to the cmux Vercel project:
-
- vercel env add STRIPE_SECRET_KEY production --scope manaflow
- vercel env add STRIPE_WEBHOOK_SECRET production --scope manaflow
-
-Do not paste the provisioning key as STRIPE_SECRET_KEY. Use a least-privilege server key with:
- Checkout Sessions write, Customers write, Subscriptions read, Prices read, Products read.
-EOF
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+exec "$SCRIPT_DIR/provision-catalog.sh" live
diff --git a/web/scripts/stripe/provision-test.sh b/web/scripts/stripe/provision-test.sh
new file mode 100755
index 000000000000..7de013c210fb
--- /dev/null
+++ b/web/scripts/stripe/provision-test.sh
@@ -0,0 +1,5 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+exec "$SCRIPT_DIR/provision-catalog.sh" test
diff --git a/web/services/billing/plans.ts b/web/services/billing/plans.ts
index e952f6eee103..6b991f079bde 100644
--- a/web/services/billing/plans.ts
+++ b/web/services/billing/plans.ts
@@ -1,4 +1,15 @@
-export type ProBillingInterval = "month" | "year";
+export type BillingInterval = "month" | "year";
+export type ProBillingInterval = BillingInterval;
+
+type PlanPricing = Record<
+ BillingInterval,
+ {
+ billedAmount: number;
+ monthlyEquivalent: number;
+ discountPercent: number;
+ lookupKey: string;
+ }
+>;
export const PRO_PRICING_USD = {
month: {
@@ -13,18 +24,27 @@ export const PRO_PRICING_USD = {
discountPercent: 20,
lookupKey: "cmux-pro-yearly-288",
},
-} as const satisfies Record<
- ProBillingInterval,
- {
- billedAmount: number;
- monthlyEquivalent: number;
- discountPercent: number;
- lookupKey: string;
- }
->;
+} as const satisfies PlanPricing;
+
+export const TEAM_PRICING_USD = {
+ month: {
+ billedAmount: 35,
+ monthlyEquivalent: 35,
+ discountPercent: 0,
+ lookupKey: "cmux-team-monthly",
+ },
+ year: {
+ billedAmount: 336,
+ monthlyEquivalent: 28,
+ discountPercent: 20,
+ lookupKey: "cmux-team-yearly-336",
+ },
+} as const satisfies PlanPricing;
export const LEGACY_PRO_YEARLY_LOOKUP_KEY = "cmux-pro-yearly";
-export function proBillingInterval(value: string | null | undefined): ProBillingInterval {
+export function billingInterval(value: string | null | undefined): BillingInterval {
return value === "year" ? "year" : "month";
}
+
+export const proBillingInterval = billingInterval;
diff --git a/web/services/billing/stripe.ts b/web/services/billing/stripe.ts
index b7b0521bffa8..613a6ed38973 100644
--- a/web/services/billing/stripe.ts
+++ b/web/services/billing/stripe.ts
@@ -3,15 +3,15 @@ import Stripe from "stripe";
import { env } from "../../app/env";
import {
PRO_PRICING_USD,
- type ProBillingInterval,
+ TEAM_PRICING_USD,
+ type BillingInterval,
} from "./plans";
-export type { ProBillingInterval } from "./plans";
-const TEAM_PRICE_LOOKUP_KEY = "cmux-team-monthly";
+export type { BillingInterval, ProBillingInterval } from "./plans";
let stripeClient: Stripe | null = null;
-const resolvedPriceIds = new Map();
-let resolvedTeamPriceId: string | null = null;
+const resolvedProPriceIds = new Map();
+const resolvedTeamPriceIds = new Map();
export function isStripeBillingConfigured(): boolean {
return Boolean(env.STRIPE_SECRET_KEY);
@@ -27,13 +27,13 @@ export function stripe(): Stripe {
return stripeClient;
}
-export async function resolveProPrice(interval: ProBillingInterval): Promise {
+export async function resolveProPrice(interval: BillingInterval): Promise {
const overridden = interval === "month"
? env.STRIPE_PRO_MONTHLY_PRICE_ID
: env.STRIPE_PRO_YEARLY_PRICE_ID;
if (overridden) return overridden;
- const cached = resolvedPriceIds.get(interval);
+ const cached = resolvedProPriceIds.get(interval);
if (cached) return cached;
const lookupKey = PRO_PRICING_USD[interval].lookupKey;
@@ -46,23 +46,29 @@ export async function resolveProPrice(interval: ProBillingInterval): Promise {
- if (env.STRIPE_TEAM_MONTHLY_PRICE_ID) return env.STRIPE_TEAM_MONTHLY_PRICE_ID;
- if (resolvedTeamPriceId) return resolvedTeamPriceId;
+export async function resolveTeamPrice(interval: BillingInterval): Promise {
+ const overridden = interval === "month"
+ ? env.STRIPE_TEAM_MONTHLY_PRICE_ID
+ : env.STRIPE_TEAM_YEARLY_PRICE_ID;
+ if (overridden) return overridden;
+
+ const cached = resolvedTeamPriceIds.get(interval);
+ if (cached) return cached;
+ const lookupKey = TEAM_PRICING_USD[interval].lookupKey;
const prices = await stripe().prices.list({
active: true,
- lookup_keys: [TEAM_PRICE_LOOKUP_KEY],
+ lookup_keys: [lookupKey],
limit: 1,
});
const priceId = prices.data[0]?.id;
if (!priceId) {
- throw new Error(`Stripe price lookup key not found: ${TEAM_PRICE_LOOKUP_KEY}`);
+ throw new Error(`Stripe price lookup key not found: ${lookupKey}`);
}
- resolvedTeamPriceId = priceId;
+ resolvedTeamPriceIds.set(interval, priceId);
return priceId;
}
From 8f09503ce1b26a4882748c7546e7847cc7461a8b Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 13:53:59 -0700
Subject: [PATCH 13/50] test: reject external browser intent from untrusted
sites
---
cmuxTests/AuthEnvironmentTests.swift | 3 +++
1 file changed, 3 insertions(+)
diff --git a/cmuxTests/AuthEnvironmentTests.swift b/cmuxTests/AuthEnvironmentTests.swift
index 75ca309a5b19..39ea7d64a88a 100644
--- a/cmuxTests/AuthEnvironmentTests.swift
+++ b/cmuxTests/AuthEnvironmentTests.swift
@@ -397,6 +397,9 @@ struct AuthEnvironmentTests {
#expect(!BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
try #require(URL(string: "cmux://enterprise?cmux_external_browser=1"))
))
+ #expect(!BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
+ try #require(URL(string: "https://attacker.example/?cmux_external_browser=1"))
+ ))
}
@Test("Pro upgrade workspace reuse keeps a live tracked workspace")
From 4ca37813536ef63f6d8f8f65986cc5d360bae9c8 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 13:54:45 -0700
Subject: [PATCH 14/50] fix: trust-gate external browser handoffs
---
.../Panels/BrowserNavigationDelegate.swift | 33 +++++++++++++++++--
cmuxTests/AuthEnvironmentTests.swift | 28 +++++++++++++---
web/app/[locale]/dashboard/billing/page.tsx | 8 +++--
3 files changed, 60 insertions(+), 9 deletions(-)
diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift
index ab0bb605c0cc..a0d402be3cd1 100644
--- a/Sources/Panels/BrowserNavigationDelegate.swift
+++ b/Sources/Panels/BrowserNavigationDelegate.swift
@@ -3,15 +3,44 @@ import Foundation
import WebKit
enum BrowserExternalNavigationIntent {
- static func shouldOpenInSystemBrowser(_ url: URL) -> Bool {
+ static func shouldOpenInSystemBrowser(
+ _ url: URL,
+ trustedOrigin: URL = AuthEnvironment.appWebOrigin
+ ) -> Bool {
guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https",
- let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
+ let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
+ sameOrigin(url, trustedOrigin) else {
return false
}
return components.queryItems?.contains(where: {
$0.name == "cmux_external_browser" && $0.value != "0"
}) == true
}
+
+ private static func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool {
+ guard let lhsComponents = URLComponents(url: lhs, resolvingAgainstBaseURL: false),
+ let rhsComponents = URLComponents(url: rhs, resolvingAgainstBaseURL: false),
+ let lhsScheme = lhsComponents.scheme?.lowercased(),
+ let rhsScheme = rhsComponents.scheme?.lowercased(),
+ let lhsHost = lhsComponents.host?.lowercased(),
+ let rhsHost = rhsComponents.host?.lowercased() else {
+ return false
+ }
+ return lhsScheme == rhsScheme
+ && lhsHost == rhsHost
+ && effectivePort(lhsComponents) == effectivePort(rhsComponents)
+ }
+
+ private static func effectivePort(_ components: URLComponents) -> Int? {
+ if let port = components.port {
+ return port
+ }
+ switch components.scheme?.lowercased() {
+ case "http": return 80
+ case "https": return 443
+ default: return nil
+ }
+ }
}
@MainActor final class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
diff --git a/cmuxTests/AuthEnvironmentTests.swift b/cmuxTests/AuthEnvironmentTests.swift
index 39ea7d64a88a..85a74e9c9621 100644
--- a/cmuxTests/AuthEnvironmentTests.swift
+++ b/cmuxTests/AuthEnvironmentTests.swift
@@ -385,20 +385,38 @@ struct AuthEnvironmentTests {
@Test("external browser intent applies to checkout and Contact sales links")
func externalBrowserIntentAppliesToCheckoutAndContactSalesLinks() throws {
+ let trustedOrigin = try #require(URL(string: "https://cmux.com"))
#expect(BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
- try #require(URL(string: "https://cmux.com/api/billing/checkout?cmux_external_browser=1"))
+ try #require(URL(string: "https://cmux.com/api/billing/checkout?cmux_external_browser=1")),
+ trustedOrigin: trustedOrigin
))
#expect(BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
- try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=1"))
+ try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=1")),
+ trustedOrigin: trustedOrigin
))
#expect(!BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
- try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=0"))
+ try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=0")),
+ trustedOrigin: trustedOrigin
))
#expect(!BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
- try #require(URL(string: "cmux://enterprise?cmux_external_browser=1"))
+ try #require(URL(string: "cmux://enterprise?cmux_external_browser=1")),
+ trustedOrigin: trustedOrigin
))
#expect(!BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
- try #require(URL(string: "https://attacker.example/?cmux_external_browser=1"))
+ try #require(URL(string: "https://attacker.example/?cmux_external_browser=1")),
+ trustedOrigin: trustedOrigin
+ ))
+ #expect(!BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
+ try #require(URL(string: "http://cmux.com/?cmux_external_browser=1")),
+ trustedOrigin: trustedOrigin
+ ))
+ #expect(!BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
+ try #require(URL(string: "https://cmux.com:8443/?cmux_external_browser=1")),
+ trustedOrigin: trustedOrigin
+ ))
+ #expect(BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
+ try #require(URL(string: "http://localhost:4100/enterprise?cmux_external_browser=1")),
+ trustedOrigin: try #require(URL(string: "http://localhost:4100"))
))
}
diff --git a/web/app/[locale]/dashboard/billing/page.tsx b/web/app/[locale]/dashboard/billing/page.tsx
index 5e3629da82bf..6baa264dd371 100644
--- a/web/app/[locale]/dashboard/billing/page.tsx
+++ b/web/app/[locale]/dashboard/billing/page.tsx
@@ -544,9 +544,13 @@ function billingBanner(value: string | undefined) {
function priceCopy(subscription: StripeSubscriptionRow): string | null {
const lookupKey = priceLookupKey(subscription) ?? subscription.priceId;
- if (lookupKey === PRO_PRICING_USD.month.lookupKey) return "$30/month";
+ if (lookupKey === PRO_PRICING_USD.month.lookupKey) {
+ return `$${PRO_PRICING_USD.month.billedAmount}/month`;
+ }
if (lookupKey === LEGACY_PRO_YEARLY_LOOKUP_KEY) return "$240/year";
- if (lookupKey === PRO_PRICING_USD.year.lookupKey) return "$288/year";
+ if (lookupKey === PRO_PRICING_USD.year.lookupKey) {
+ return `$${PRO_PRICING_USD.year.billedAmount}/year`;
+ }
return null;
}
From d8cf6050365b808878248d5412fe0f2cd9249f09 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 13:55:47 -0700
Subject: [PATCH 15/50] test(web): reject invalid billing intervals
---
web/tests/billing-checkout-route.test.ts | 15 +++++++++++++++
1 file changed, 15 insertions(+)
diff --git a/web/tests/billing-checkout-route.test.ts b/web/tests/billing-checkout-route.test.ts
index 23c560a24e42..7707907965a6 100644
--- a/web/tests/billing-checkout-route.test.ts
+++ b/web/tests/billing-checkout-route.test.ts
@@ -411,4 +411,19 @@ describe("billing checkout route", () => {
);
expect(getUser).not.toHaveBeenCalled();
});
+
+ test("rejects unknown checkout intervals", async () => {
+ const response = await GET(
+ new NextRequest(
+ "https://cmux.test/api/billing/checkout?plan=team&interval=yearly",
+ ),
+ );
+
+ expect(response.status).toBe(307);
+ expect(response.headers.get("location")).toBe(
+ "https://cmux.test/pricing?billing=invalid_plan",
+ );
+ expect(getUser).not.toHaveBeenCalled();
+ expect(createStripeSession).not.toHaveBeenCalled();
+ });
});
From 83c96b481cdbe7fa353206c9f1e1c1a370994d74 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 13:56:02 -0700
Subject: [PATCH 16/50] fix(web): reject invalid billing intervals
---
web/app/api/billing/checkout/route.ts | 24 +++++++++++++++++++-----
1 file changed, 19 insertions(+), 5 deletions(-)
diff --git a/web/app/api/billing/checkout/route.ts b/web/app/api/billing/checkout/route.ts
index 6273037ee1b7..075e7f12f935 100644
--- a/web/app/api/billing/checkout/route.ts
+++ b/web/app/api/billing/checkout/route.ts
@@ -20,7 +20,10 @@ import {
resolveTeamPrice,
stripe,
} from "../../../../services/billing/stripe";
-import { billingInterval } from "../../../../services/billing/plans";
+import {
+ billingInterval,
+ type BillingInterval,
+} from "../../../../services/billing/plans";
export const dynamic = "force-dynamic";
@@ -63,16 +66,22 @@ async function resolveCheckout(request: NextRequest): Promise {
if (!plan) {
return NextResponse.redirect(new URL("/pricing?billing=invalid_plan", request.url));
}
+ const interval = checkoutBillingInterval(
+ request.nextUrl.searchParams.get("interval"),
+ );
+ if (!interval) {
+ return NextResponse.redirect(new URL("/pricing?billing=invalid_plan", request.url));
+ }
if (!isStripeBillingConfigured()) {
return NextResponse.redirect(new URL("/pricing?billing=unavailable", request.url));
}
if (plan === "pro") {
- return stripeProCheckout(request, stackServerApp);
+ return stripeProCheckout(request, stackServerApp, interval);
}
if (plan === "team") {
- return stripeTeamCheckout(request, stackServerApp);
+ return stripeTeamCheckout(request, stackServerApp, interval);
}
// checkoutPlan only yields "pro" | "team" | null (null handled above); this is
// unreachable but keeps GET returning a NextResponse instead of possibly-undefined.
@@ -82,6 +91,7 @@ async function resolveCheckout(request: NextRequest): Promise {
async function stripeProCheckout(
request: NextRequest,
stackServerApp: CheckoutStackServerApp,
+ interval: BillingInterval,
) {
const user =
(await stackServerApp.getUser({ or: "return-null" })) ??
@@ -100,7 +110,6 @@ async function stripeProCheckout(
request.nextUrl.searchParams.get("cmux_scheme"),
request,
);
- const interval = billingInterval(request.nextUrl.searchParams.get("interval"));
const successUrl =
`${request.nextUrl.origin}/api/billing/complete` +
`?session_id={CHECKOUT_SESSION_ID}&cmux_scheme=${encodeURIComponent(scheme)}`;
@@ -145,6 +154,7 @@ async function stripeProCheckout(
async function stripeTeamCheckout(
request: NextRequest,
stackServerApp: CheckoutStackServerApp,
+ interval: BillingInterval,
) {
const user =
(await stackServerApp.getUser({ or: "return-null" })) ??
@@ -162,7 +172,6 @@ async function stripeTeamCheckout(
request.nextUrl.searchParams.get("cmux_scheme"),
request,
);
- const interval = billingInterval(request.nextUrl.searchParams.get("interval"));
const successUrl =
`${request.nextUrl.origin}/api/billing/complete` +
`?session_id={CHECKOUT_SESSION_ID}&cmux_scheme=${encodeURIComponent(scheme)}`;
@@ -309,6 +318,11 @@ function checkoutPlan(raw: string | null): "pro" | "team" | null {
return null;
}
+function checkoutBillingInterval(raw: string | null): BillingInterval | null {
+ if (raw === null) return billingInterval(raw);
+ return raw === "month" || raw === "year" ? raw : null;
+}
+
async function checkoutStackServerApp(): Promise {
const { getStackServerApp, isStackConfigured } = await import("../../../lib/stack");
if (!isStackConfigured()) return null;
From f0f88f7a4f4ce0d335442c83ab5c6be4c210eb6c Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 16:34:20 -0700
Subject: [PATCH 17/50] Pin GhosttyKit for pricing theme picker
---
docs/ghostty-fork.md | 28 +++++++++++++++++++---------
scripts/ghosttykit-checksums.txt | 1 +
2 files changed, 20 insertions(+), 9 deletions(-)
diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md
index eb7e74a0baa3..e6d0436224be 100644
--- a/docs/ghostty-fork.md
+++ b/docs/ghostty-fork.md
@@ -12,15 +12,11 @@ When we change the fork, update this document and the parent submodule SHA.
## Current fork changes
-The submodule pinned by this branch is `36a46414a`, the fork-main merge of
-https://github.com/manaflow-ai/ghostty/pull/172. It combines the hidden-renderer
-reclamation and retry-deadline line through `4d6f0014f` with the resolved
-font-binding action callbacks originally ending at `80d7fb35a`.
-https://github.com/manaflow-ai/ghostty/pull/171 reapplied the font callback
-commits on current fork main and clarified the callback's non-reentrant
-contract. PR 172 then recorded the original font branch as ancestry without
-changing the integrated tree, so the final pin descends from both former
-gitlinks (`cd1f8e012` and `80d7fb35a`).
+The submodule pinned by this branch is `59f2b5d2e`, merged into fork main by
+https://github.com/manaflow-ai/ghostty/pull/175. It combines the initial cmux
+theme-picker render fix at `5068b3a37` with the fork-main Sentry environment
+race fix through `abcf5697d`. The previously documented renderer and font-action
+integration at `36a46414a` remains in its ancestry.
The renderer line was reviewed in
https://github.com/manaflow-ai/ghostty/pull/168, following the merged
@@ -39,6 +35,20 @@ The seven PRs landed in merge commits `1e86b46e2`, `4dab6fd6c`,
The final font integration landed in merge commits `23003282d` and
`36a46414a`.
+### Initial cmux theme-picker render
+
+- Commit: `5068b3a37` (fix: render cmux theme picker before input)
+- File: `src/cli/list_themes.zig`
+- Summary:
+ - Initializes the terminal dimensions, renders the theme picker, and flushes
+ the first frame before waiting for input, so the picker does not open blank.
+ - Merges cleanly with the `abcf5697d` Sentry initialization fix; no conflict
+ resolution was required.
+- Artifact:
+ - https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-59f2b5d2ec67a5f9dfe9138f6e5a4353b75d238e-crashsubdir-cmux-crash-v1
+ - SHA-256 `3767b7bba0931f9cab359d0c8147885e14a2b6ce420044e5946b4b823fc093da`
+ is pinned in `scripts/ghosttykit-checksums.txt`.
+
### Hidden macOS renderer reclamation
- Pull request:
diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt
index 08286822d9d7..78cdf93f59b1 100644
--- a/scripts/ghosttykit-checksums.txt
+++ b/scripts/ghosttykit-checksums.txt
@@ -108,3 +108,4 @@ cd1f8e0120f534cabc7d89257baccc42c166d369 578864e66ed7483d8c28fd2418d6b9961467d4a
36a46414a7c5dc122ffbf2992fec6d4a73cf7c65 8784a1bd29d3d13250b9557b8982d362054fd326d48b8fc8c0deac56f4f71c0d
6143bac777724854e5a3bab8c7a4e47735f17113 e4c976b234dac24f6261f27af64fbf1b013d618d5b9c903ac876d9046678ff17
abcf5697d4fcd05e29a83ccfc090d6e234952849 61f0034da86c4907d6b1c607fcfd6810425cd02406dcc7a0652d4289c9b530ea
+59f2b5d2ec67a5f9dfe9138f6e5a4353b75d238e 3767b7bba0931f9cab359d0c8147885e14a2b6ce420044e5946b4b823fc093da
From 681e5deb3b36d7f8580a0bea5eee84bb52221311 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 16:57:04 -0700
Subject: [PATCH 18/50] Harden annual pricing integration
---
.../AppSession/BrowserAppTheme.swift | 102 ++++++++++++++++++
.../BrowserExternalNavigationPolicy.swift | 49 +++++++++
.../AppSession/BrowserAppWebPolicyTests.swift | 71 ++++++++++++
.../Panels/BrowserNavigationDelegate.swift | 46 +-------
Sources/Panels/BrowserPanel.swift | 5 +-
Sources/ProWelcomeChecklist.swift | 72 ++-----------
cmuxTests/AuthEnvironmentTests.swift | 49 ++-------
web/app/env.ts | 8 +-
web/services/billing/stripe.ts | 2 +-
9 files changed, 252 insertions(+), 152 deletions(-)
create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
create mode 100644 Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
create mode 100644 Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
new file mode 100644
index 000000000000..0c137a99e3d8
--- /dev/null
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
@@ -0,0 +1,102 @@
+public import Foundation
+
+/// Serializable app-web theme values shared by pricing and Pro welcome browser surfaces.
+public struct BrowserAppTheme: Equatable, Sendable {
+ public let appearance: String
+ public let background: String
+ public let foreground: String
+ public let accent: String
+ public let accentOnBackground: String
+ public let accentOnForeground: String
+
+ /// Creates a complete theme after the app has resolved readable accent variants.
+ public init(
+ appearance: String,
+ background: String,
+ foreground: String,
+ accent: String,
+ accentOnBackground: String,
+ accentOnForeground: String
+ ) {
+ self.appearance = appearance
+ self.background = background
+ self.foreground = foreground
+ self.accent = accent
+ self.accentOnBackground = accentOnBackground
+ self.accentOnForeground = accentOnForeground
+ }
+
+ /// Query items used to seed a server-rendered app-web page with the native theme.
+ public var queryItems: [URLQueryItem] {
+ [
+ URLQueryItem(name: "appearance", value: appearance),
+ URLQueryItem(name: "background", value: background),
+ URLQueryItem(name: "foreground", value: foreground),
+ URLQueryItem(name: "accent", value: accent),
+ URLQueryItem(name: "accent_on_background", value: accentOnBackground),
+ URLQueryItem(name: "accent_on_foreground", value: accentOnForeground),
+ ]
+ }
+
+ /// JavaScript that refreshes an already-rendered supported page without navigation.
+ public func applyingJavaScript() -> String? {
+ let payload = JavaScriptPayload(
+ appearance: appearance,
+ background: background,
+ foreground: foreground,
+ accent: accent,
+ accentOnBackground: accentOnBackground,
+ accentOnForeground: accentOnForeground
+ )
+ guard let data = try? JSONEncoder().encode(payload),
+ let json = String(data: data, encoding: .utf8) else {
+ return nil
+ }
+ return """
+ (() => {
+ const root = document.querySelector('[data-cmux-app-theme]');
+ if (!root) return false;
+ const theme = \(json);
+ root.style.setProperty('--ghostty-background', theme.background);
+ root.style.setProperty('--ghostty-foreground', theme.foreground);
+ root.style.setProperty('--cmux-product-blue', theme.accent);
+ root.style.setProperty('--cmux-product-blue-on-background', theme.accentOnBackground);
+ root.style.setProperty('--cmux-product-blue-on-foreground', theme.accentOnForeground);
+ root.style.backgroundColor = theme.background;
+ root.style.colorScheme = theme.appearance;
+ root.dataset.cmuxAppThemeAppearance = theme.appearance;
+ if (root.hasAttribute('data-app-pricing-appearance')) {
+ root.setAttribute('data-app-pricing-appearance', theme.appearance);
+ }
+ if (root.hasAttribute('data-app-pro-welcome-appearance')) {
+ root.setAttribute('data-app-pro-welcome-appearance', theme.appearance);
+ }
+ for (const element of [document.documentElement, document.body]) {
+ element?.style.setProperty('background', theme.background, 'important');
+ }
+ document.querySelector('meta[name="theme-color"]')
+ ?.setAttribute('content', theme.background);
+ return true;
+ })()
+ """
+ }
+
+ /// Returns whether the URL hosts an app-web surface that consumes this theme contract.
+ public func supports(url: URL?) -> Bool {
+ guard let url,
+ let scheme = url.scheme?.lowercased(),
+ scheme == "http" || scheme == "https" else {
+ return false
+ }
+ return url.path == "/app-pricing" || url.path == "/app-pro-welcome"
+ }
+
+ private struct JavaScriptPayload: Encodable {
+ let appearance: String
+ let background: String
+ let foreground: String
+ let accent: String
+ let accentOnBackground: String
+ let accentOnForeground: String
+ }
+}
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
new file mode 100644
index 000000000000..7ee06ebed4c7
--- /dev/null
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
@@ -0,0 +1,49 @@
+public import Foundation
+
+/// Decides whether a trusted app-web URL explicitly requests the system browser.
+public struct BrowserExternalNavigationPolicy: Equatable, Sendable {
+ public let trustedOrigin: URL
+
+ /// Creates a policy pinned to the exact origin allowed to request external navigation.
+ public init(trustedOrigin: URL) {
+ self.trustedOrigin = trustedOrigin
+ }
+
+ /// Returns true only for an HTTP(S) URL on the trusted origin with a nonzero intent marker.
+ public func shouldOpenInSystemBrowser(_ url: URL) -> Bool {
+ guard let scheme = url.scheme?.lowercased(),
+ scheme == "http" || scheme == "https",
+ let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
+ sameOrigin(url, trustedOrigin) else {
+ return false
+ }
+ return components.queryItems?.contains(where: {
+ $0.name == "cmux_external_browser" && $0.value != "0"
+ }) == true
+ }
+
+ private func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool {
+ guard let lhsComponents = URLComponents(url: lhs, resolvingAgainstBaseURL: false),
+ let rhsComponents = URLComponents(url: rhs, resolvingAgainstBaseURL: false),
+ let lhsScheme = lhsComponents.scheme?.lowercased(),
+ let rhsScheme = rhsComponents.scheme?.lowercased(),
+ let lhsHost = lhsComponents.host?.lowercased(),
+ let rhsHost = rhsComponents.host?.lowercased() else {
+ return false
+ }
+ return lhsScheme == rhsScheme
+ && lhsHost == rhsHost
+ && effectivePort(lhsComponents) == effectivePort(rhsComponents)
+ }
+
+ private func effectivePort(_ components: URLComponents) -> Int? {
+ if let port = components.port {
+ return port
+ }
+ return switch components.scheme?.lowercased() {
+ case "http": 80
+ case "https": 443
+ default: nil
+ }
+ }
+}
diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
new file mode 100644
index 000000000000..c203ed475eb3
--- /dev/null
+++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
@@ -0,0 +1,71 @@
+import Foundation
+import Testing
+
+@testable import CmuxBrowser
+
+@Suite("Browser app-web policies")
+struct BrowserAppWebPolicyTests {
+ @Test("external intent requires the trusted origin and an enabled marker")
+ func externalIntentRequiresTrustedOriginAndEnabledMarker() throws {
+ let policy = BrowserExternalNavigationPolicy(
+ trustedOrigin: try #require(URL(string: "https://cmux.com"))
+ )
+
+ #expect(policy.shouldOpenInSystemBrowser(
+ try #require(URL(string: "https://cmux.com/api/billing/checkout?cmux_external_browser=1"))
+ ))
+ #expect(policy.shouldOpenInSystemBrowser(
+ try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=1"))
+ ))
+ #expect(!policy.shouldOpenInSystemBrowser(
+ try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=0"))
+ ))
+ #expect(!policy.shouldOpenInSystemBrowser(
+ try #require(URL(string: "cmux://enterprise?cmux_external_browser=1"))
+ ))
+ #expect(!policy.shouldOpenInSystemBrowser(
+ try #require(URL(string: "https://attacker.example/?cmux_external_browser=1"))
+ ))
+ #expect(!policy.shouldOpenInSystemBrowser(
+ try #require(URL(string: "http://cmux.com/?cmux_external_browser=1"))
+ ))
+ #expect(!policy.shouldOpenInSystemBrowser(
+ try #require(URL(string: "https://cmux.com:8443/?cmux_external_browser=1"))
+ ))
+ }
+
+ @Test("external intent supports a loopback development origin")
+ func externalIntentSupportsLoopbackDevelopmentOrigin() throws {
+ let policy = BrowserExternalNavigationPolicy(
+ trustedOrigin: try #require(URL(string: "http://localhost:4100"))
+ )
+ #expect(policy.shouldOpenInSystemBrowser(
+ try #require(URL(string: "http://localhost:4100/enterprise?cmux_external_browser=1"))
+ ))
+ }
+
+ @Test("theme serializes shared variables and supports only app-web routes")
+ func themeSerializesSharedVariablesAndSupportsOnlyAppWebRoutes() throws {
+ let theme = BrowserAppTheme(
+ appearance: "dark",
+ background: "#112233",
+ foreground: "#DDEEFF",
+ accent: "#0091FF",
+ accentOnBackground: "#0091FF",
+ accentOnForeground: "#00517F"
+ )
+
+ let query = Dictionary(uniqueKeysWithValues: theme.queryItems.compactMap { item in
+ item.value.map { (item.name, $0) }
+ })
+ #expect(query["appearance"] == "dark")
+ #expect(query["accent_on_foreground"] == "#00517F")
+
+ let script = try #require(theme.applyingJavaScript())
+ #expect(script.contains("[data-cmux-app-theme]"))
+ #expect(script.contains("--cmux-product-blue-on-background"))
+ #expect(theme.supports(url: URL(string: "https://cmux.com/app-pricing")))
+ #expect(theme.supports(url: URL(string: "https://cmux.com/app-pro-welcome")))
+ #expect(!theme.supports(url: URL(string: "https://cmux.com/pricing")))
+ }
+}
diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift
index ad50b9e60e4f..c1562aef0168 100644
--- a/Sources/Panels/BrowserNavigationDelegate.swift
+++ b/Sources/Panels/BrowserNavigationDelegate.swift
@@ -3,50 +3,12 @@ import CmuxBrowser
import Foundation
import WebKit
-enum BrowserExternalNavigationIntent {
- static func shouldOpenInSystemBrowser(
- _ url: URL,
- trustedOrigin: URL = AuthEnvironment.appWebOrigin
- ) -> Bool {
- guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https",
- let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
- sameOrigin(url, trustedOrigin) else {
- return false
- }
- return components.queryItems?.contains(where: {
- $0.name == "cmux_external_browser" && $0.value != "0"
- }) == true
- }
-
- private static func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool {
- guard let lhsComponents = URLComponents(url: lhs, resolvingAgainstBaseURL: false),
- let rhsComponents = URLComponents(url: rhs, resolvingAgainstBaseURL: false),
- let lhsScheme = lhsComponents.scheme?.lowercased(),
- let rhsScheme = rhsComponents.scheme?.lowercased(),
- let lhsHost = lhsComponents.host?.lowercased(),
- let rhsHost = rhsComponents.host?.lowercased() else {
- return false
- }
- return lhsScheme == rhsScheme
- && lhsHost == rhsHost
- && effectivePort(lhsComponents) == effectivePort(rhsComponents)
- }
-
- private static func effectivePort(_ components: URLComponents) -> Int? {
- if let port = components.port {
- return port
- }
- switch components.scheme?.lowercased() {
- case "http": return 80
- case "https": return 443
- default: return nil
- }
- }
-}
-
@MainActor final class BrowserNavigationDelegate: NSObject, WKNavigationDelegate {
enum PolicyCancellationKind { case terminal(restoreAttemptID: UUID?) }
private let subframeDownloadIntents = BrowserSubframeDownloadIntentTracker()
+ private let externalNavigationPolicy = BrowserExternalNavigationPolicy(
+ trustedOrigin: AuthEnvironment.appWebOrigin
+ )
private var shouldPrintAfterCurrentNavigationFinishes = false
var didStartProvisionalNavigation: ((WKWebView, WKNavigation?) -> Void)?
var didCommit: ((WKWebView, WKNavigation?) -> Void)?
@@ -579,7 +541,7 @@ enum BrowserExternalNavigationIntent {
private func shouldOpenInSystemBrowser(_ navigationAction: WKNavigationAction, url: URL) -> Bool {
guard navigationAction.targetFrame?.isMainFrame != false else { return false }
guard navigationAction.navigationType == .linkActivated else { return false }
- return BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(url)
+ return externalNavigationPolicy.shouldOpenInSystemBrowser(url)
}
func canHandleSSLTrustBypassToken(_ token: String) -> Bool {
diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift
index c2d4f58ced33..81ec8809c1e8 100644
--- a/Sources/Panels/BrowserPanel.swift
+++ b/Sources/Panels/BrowserPanel.swift
@@ -5265,8 +5265,9 @@ final class BrowserPanel: Panel, ObservableObject {
}
private func applyAppWebTheme(_ theme: AppWebThemeSnapshot, to webView: WKWebView) {
- guard AppWebThemeSnapshot.supports(url: webView.url),
- let script = theme.applyingJavaScript() else {
+ let browserTheme = theme.browserTheme
+ guard browserTheme.supports(url: webView.url),
+ let script = browserTheme.applyingJavaScript() else {
return
}
webView.evaluateJavaScript(script, completionHandler: nil)
diff --git a/Sources/ProWelcomeChecklist.swift b/Sources/ProWelcomeChecklist.swift
index 020db7995f4b..470c4b435c27 100644
--- a/Sources/ProWelcomeChecklist.swift
+++ b/Sources/ProWelcomeChecklist.swift
@@ -1,22 +1,15 @@
import AppKit
+import CmuxBrowser
import CmuxTerminalCore
import Foundation
+@MainActor
struct AppWebThemeSnapshot: Equatable {
let appearance: String
let background: String
let foreground: String
let accent: String
- private struct JavaScriptPayload: Encodable {
- let appearance: String
- let background: String
- let foreground: String
- let accent: String
- let accentOnBackground: String
- let accentOnForeground: String
- }
-
static func current(notification: Notification? = nil) -> AppWebThemeSnapshot {
let userInfo = notification?.userInfo
let backgroundColor = GhosttyBackgroundTheme.color(from: notification)
@@ -51,19 +44,8 @@ struct AppWebThemeSnapshot: Equatable {
contrastAdjustedAccent(on: foreground)
}
- var queryItems: [URLQueryItem] {
- [
- URLQueryItem(name: "appearance", value: appearance),
- URLQueryItem(name: "background", value: background),
- URLQueryItem(name: "foreground", value: foreground),
- URLQueryItem(name: "accent", value: accent),
- URLQueryItem(name: "accent_on_background", value: accentOnBackground),
- URLQueryItem(name: "accent_on_foreground", value: accentOnForeground),
- ]
- }
-
- func applyingJavaScript() -> String? {
- let payload = JavaScriptPayload(
+ var browserTheme: BrowserAppTheme {
+ BrowserAppTheme(
appearance: appearance,
background: background,
foreground: foreground,
@@ -71,37 +53,6 @@ struct AppWebThemeSnapshot: Equatable {
accentOnBackground: accentOnBackground,
accentOnForeground: accentOnForeground
)
- guard let data = try? JSONEncoder().encode(payload),
- let json = String(data: data, encoding: .utf8) else {
- return nil
- }
- return """
- (() => {
- const root = document.querySelector('[data-cmux-app-theme]');
- if (!root) return false;
- const theme = \(json);
- root.style.setProperty('--ghostty-background', theme.background);
- root.style.setProperty('--ghostty-foreground', theme.foreground);
- root.style.setProperty('--cmux-product-blue', theme.accent);
- root.style.setProperty('--cmux-product-blue-on-background', theme.accentOnBackground);
- root.style.setProperty('--cmux-product-blue-on-foreground', theme.accentOnForeground);
- root.style.backgroundColor = theme.background;
- root.style.colorScheme = theme.appearance;
- root.dataset.cmuxAppThemeAppearance = theme.appearance;
- if (root.hasAttribute('data-app-pricing-appearance')) {
- root.setAttribute('data-app-pricing-appearance', theme.appearance);
- }
- if (root.hasAttribute('data-app-pro-welcome-appearance')) {
- root.setAttribute('data-app-pro-welcome-appearance', theme.appearance);
- }
- for (const element of [document.documentElement, document.body]) {
- element?.style.setProperty('background', theme.background, 'important');
- }
- document.querySelector('meta[name="theme-color"]')
- ?.setAttribute('content', theme.background);
- return true;
- })()
- """
}
private func contrastAdjustedAccent(on backgroundHex: String) -> String {
@@ -115,15 +66,6 @@ struct AppWebThemeSnapshot: Equatable {
).hexString()
}
- static func supports(url: URL?) -> Bool {
- guard let url,
- let scheme = url.scheme?.lowercased(),
- scheme == "http" || scheme == "https" else {
- return false
- }
- return url.path == "/app-pricing" || url.path == "/app-pro-welcome"
- }
-
}
/// Presents the one-time "Welcome to cmux Pro" checklist after a user becomes
@@ -233,14 +175,16 @@ extension ProUpgradePresenter {
decoratedAppWebURL(base, theme: AppWebThemeSnapshot.current())
}
+ @MainActor
static func decoratedAppWebURL(_ base: URL, theme: AppWebThemeSnapshot) -> URL {
var components = URLComponents(url: base, resolvingAgainstBaseURL: false)
var queryItems = components?.queryItems ?? []
- let themedQueryNames = Set(theme.queryItems.map(\.name))
+ let browserTheme = theme.browserTheme
+ let themedQueryNames = Set(browserTheme.queryItems.map(\.name))
queryItems.removeAll { themedQueryNames.contains($0.name) }
queryItems.removeAll { $0.name == "cmux_app" }
queryItems.removeAll { $0.name == "cmux_scheme" }
- queryItems.append(contentsOf: theme.queryItems)
+ queryItems.append(contentsOf: browserTheme.queryItems)
queryItems.append(URLQueryItem(name: "cmux_app", value: "1"))
queryItems.append(URLQueryItem(name: "cmux_scheme", value: AuthEnvironment.callbackScheme))
components?.queryItems = queryItems
diff --git a/cmuxTests/AuthEnvironmentTests.swift b/cmuxTests/AuthEnvironmentTests.swift
index 584475679bb6..9f1916f04002 100644
--- a/cmuxTests/AuthEnvironmentTests.swift
+++ b/cmuxTests/AuthEnvironmentTests.swift
@@ -311,6 +311,7 @@ struct AuthEnvironmentTests {
#expect(appProWelcomeURL.path == "/app-pro-welcome")
}
+ @MainActor
@Test("app web URLs carry the Ghostty colors and cmux product accent")
func appWebURLsCarryGhosttyColorsAndCmuxProductAccent() throws {
let base = try #require(URL(string: "https://cmux.com/app-pricing?interval=year&accent=%23000000"))
@@ -337,6 +338,7 @@ struct AuthEnvironmentTests {
#expect(values["cmux_app"] == "1")
}
+ @MainActor
@Test("app web appearance and product accent follow the Ghostty background")
func appWebAppearanceAndProductAccentFollowGhosttyBackground() throws {
let darkSnapshot = AppWebThemeSnapshot.resolved(
@@ -362,6 +364,7 @@ struct AuthEnvironmentTests {
#expect(lightSnapshot.accentOnForeground == "#0088FF")
}
+ @MainActor
@Test("app web theme JavaScript updates every shared theme variable")
func appWebThemeJavaScriptUpdatesEverySharedThemeVariable() throws {
let theme = AppWebThemeSnapshot(
@@ -370,7 +373,8 @@ struct AuthEnvironmentTests {
foreground: "#171717",
accent: "#0088FF"
)
- let script = try #require(theme.applyingJavaScript())
+ let browserTheme = theme.browserTheme
+ let script = try #require(browserTheme.applyingJavaScript())
#expect(script.contains("[data-cmux-app-theme]"))
#expect(script.contains("--ghostty-background"))
@@ -378,46 +382,9 @@ struct AuthEnvironmentTests {
#expect(script.contains("--cmux-product-blue"))
#expect(script.contains("--cmux-product-blue-on-background"))
#expect(script.contains("--cmux-product-blue-on-foreground"))
- #expect(AppWebThemeSnapshot.supports(url: URL(string: "https://cmux.com/app-pricing")))
- #expect(AppWebThemeSnapshot.supports(url: URL(string: "https://cmux.com/app-pro-welcome")))
- #expect(!AppWebThemeSnapshot.supports(url: URL(string: "https://cmux.com/pricing")))
- }
-
- @Test("external browser intent applies to checkout and Contact sales links")
- func externalBrowserIntentAppliesToCheckoutAndContactSalesLinks() throws {
- let trustedOrigin = try #require(URL(string: "https://cmux.com"))
- #expect(BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
- try #require(URL(string: "https://cmux.com/api/billing/checkout?cmux_external_browser=1")),
- trustedOrigin: trustedOrigin
- ))
- #expect(BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
- try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=1")),
- trustedOrigin: trustedOrigin
- ))
- #expect(!BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
- try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=0")),
- trustedOrigin: trustedOrigin
- ))
- #expect(!BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
- try #require(URL(string: "cmux://enterprise?cmux_external_browser=1")),
- trustedOrigin: trustedOrigin
- ))
- #expect(!BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
- try #require(URL(string: "https://attacker.example/?cmux_external_browser=1")),
- trustedOrigin: trustedOrigin
- ))
- #expect(!BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
- try #require(URL(string: "http://cmux.com/?cmux_external_browser=1")),
- trustedOrigin: trustedOrigin
- ))
- #expect(!BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
- try #require(URL(string: "https://cmux.com:8443/?cmux_external_browser=1")),
- trustedOrigin: trustedOrigin
- ))
- #expect(BrowserExternalNavigationIntent.shouldOpenInSystemBrowser(
- try #require(URL(string: "http://localhost:4100/enterprise?cmux_external_browser=1")),
- trustedOrigin: try #require(URL(string: "http://localhost:4100"))
- ))
+ #expect(browserTheme.supports(url: URL(string: "https://cmux.com/app-pricing")))
+ #expect(browserTheme.supports(url: URL(string: "https://cmux.com/app-pro-welcome")))
+ #expect(!browserTheme.supports(url: URL(string: "https://cmux.com/pricing")))
}
@Test("app session handoff pins credentials to production or debug loopback")
diff --git a/web/app/env.ts b/web/app/env.ts
index 981b9122d93b..74806ce76850 100644
--- a/web/app/env.ts
+++ b/web/app/env.ts
@@ -173,7 +173,9 @@ export const env = createEnv({
STRIPE_SECRET_KEY: z.string().min(1).optional(),
STRIPE_WEBHOOK_SECRET: z.string().min(1).optional(),
STRIPE_PRO_MONTHLY_PRICE_ID: z.string().min(1).optional(),
- STRIPE_PRO_YEARLY_PRICE_ID: z.string().min(1).optional(),
+ // Deliberately distinct from the legacy STRIPE_PRO_YEARLY_PRICE_ID,
+ // which can refer to the grandfathered $240/year price.
+ STRIPE_PRO_YEARLY_288_PRICE_ID: z.string().min(1).optional(),
STRIPE_TEAM_MONTHLY_PRICE_ID: z.string().min(1).optional(),
STRIPE_TEAM_YEARLY_PRICE_ID: z.string().min(1).optional(),
// App Store Connect API for server-side TestFlight enrollment. Optional:
@@ -299,7 +301,9 @@ export const env = createEnv({
STRIPE_SECRET_KEY: trimEnv(process.env.STRIPE_SECRET_KEY),
STRIPE_WEBHOOK_SECRET: trimEnv(process.env.STRIPE_WEBHOOK_SECRET),
STRIPE_PRO_MONTHLY_PRICE_ID: trimEnv(process.env.STRIPE_PRO_MONTHLY_PRICE_ID),
- STRIPE_PRO_YEARLY_PRICE_ID: trimEnv(process.env.STRIPE_PRO_YEARLY_PRICE_ID),
+ STRIPE_PRO_YEARLY_288_PRICE_ID: trimEnv(
+ process.env.STRIPE_PRO_YEARLY_288_PRICE_ID,
+ ),
STRIPE_TEAM_MONTHLY_PRICE_ID: trimEnv(process.env.STRIPE_TEAM_MONTHLY_PRICE_ID),
STRIPE_TEAM_YEARLY_PRICE_ID: trimEnv(process.env.STRIPE_TEAM_YEARLY_PRICE_ID),
ASC_KEY_ID: trimEnv(process.env.ASC_KEY_ID),
diff --git a/web/services/billing/stripe.ts b/web/services/billing/stripe.ts
index 613a6ed38973..c6c4c4d0e8e4 100644
--- a/web/services/billing/stripe.ts
+++ b/web/services/billing/stripe.ts
@@ -30,7 +30,7 @@ export function stripe(): Stripe {
export async function resolveProPrice(interval: BillingInterval): Promise {
const overridden = interval === "month"
? env.STRIPE_PRO_MONTHLY_PRICE_ID
- : env.STRIPE_PRO_YEARLY_PRICE_ID;
+ : env.STRIPE_PRO_YEARLY_288_PRICE_ID;
if (overridden) return overridden;
const cached = resolvedProPriceIds.get(interval);
From 37b02bbba2d2998d675d58da642c75827d3ac8be Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 17:03:10 -0700
Subject: [PATCH 19/50] Pin app theme injection to trusted origin
---
.../AppSession/BrowserAppTheme.swift | 7 ++++---
.../AppSession/BrowserAppWebPolicyTests.swift | 20 ++++++++++++++++---
Sources/Panels/BrowserPanel.swift | 5 ++++-
cmuxTests/AuthEnvironmentTests.swift | 19 +++++++++++++++---
4 files changed, 41 insertions(+), 10 deletions(-)
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
index 0c137a99e3d8..4e264cf6bb56 100644
--- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
@@ -81,11 +81,12 @@ public struct BrowserAppTheme: Equatable, Sendable {
"""
}
- /// Returns whether the URL hosts an app-web surface that consumes this theme contract.
- public func supports(url: URL?) -> Bool {
+ /// Returns whether the URL is a trusted app-web surface that consumes this theme contract.
+ public func supports(url: URL?, trustedOrigin: URL) -> Bool {
guard let url,
let scheme = url.scheme?.lowercased(),
- scheme == "http" || scheme == "https" else {
+ scheme == "http" || scheme == "https",
+ BrowserAppWebOrigin(trustedOrigin).contains(url) else {
return false
}
return url.path == "/app-pricing" || url.path == "/app-pro-welcome"
diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
index c203ed475eb3..92df12d93cc0 100644
--- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
+++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
@@ -46,6 +46,7 @@ struct BrowserAppWebPolicyTests {
@Test("theme serializes shared variables and supports only app-web routes")
func themeSerializesSharedVariablesAndSupportsOnlyAppWebRoutes() throws {
+ let trustedOrigin = try #require(URL(string: "https://cmux.com"))
let theme = BrowserAppTheme(
appearance: "dark",
background: "#112233",
@@ -64,8 +65,21 @@ struct BrowserAppWebPolicyTests {
let script = try #require(theme.applyingJavaScript())
#expect(script.contains("[data-cmux-app-theme]"))
#expect(script.contains("--cmux-product-blue-on-background"))
- #expect(theme.supports(url: URL(string: "https://cmux.com/app-pricing")))
- #expect(theme.supports(url: URL(string: "https://cmux.com/app-pro-welcome")))
- #expect(!theme.supports(url: URL(string: "https://cmux.com/pricing")))
+ #expect(theme.supports(
+ url: URL(string: "https://cmux.com/app-pricing"),
+ trustedOrigin: trustedOrigin
+ ))
+ #expect(theme.supports(
+ url: URL(string: "https://cmux.com/app-pro-welcome"),
+ trustedOrigin: trustedOrigin
+ ))
+ #expect(!theme.supports(
+ url: URL(string: "https://cmux.com/pricing"),
+ trustedOrigin: trustedOrigin
+ ))
+ #expect(!theme.supports(
+ url: URL(string: "https://attacker.example/app-pricing"),
+ trustedOrigin: trustedOrigin
+ ))
}
}
diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift
index 81ec8809c1e8..49c0ab0e4b58 100644
--- a/Sources/Panels/BrowserPanel.swift
+++ b/Sources/Panels/BrowserPanel.swift
@@ -5266,7 +5266,10 @@ final class BrowserPanel: Panel, ObservableObject {
private func applyAppWebTheme(_ theme: AppWebThemeSnapshot, to webView: WKWebView) {
let browserTheme = theme.browserTheme
- guard browserTheme.supports(url: webView.url),
+ guard browserTheme.supports(
+ url: webView.url,
+ trustedOrigin: AuthEnvironment.appWebOrigin
+ ),
let script = browserTheme.applyingJavaScript() else {
return
}
diff --git a/cmuxTests/AuthEnvironmentTests.swift b/cmuxTests/AuthEnvironmentTests.swift
index 9f1916f04002..71c8634d1abb 100644
--- a/cmuxTests/AuthEnvironmentTests.swift
+++ b/cmuxTests/AuthEnvironmentTests.swift
@@ -382,9 +382,22 @@ struct AuthEnvironmentTests {
#expect(script.contains("--cmux-product-blue"))
#expect(script.contains("--cmux-product-blue-on-background"))
#expect(script.contains("--cmux-product-blue-on-foreground"))
- #expect(browserTheme.supports(url: URL(string: "https://cmux.com/app-pricing")))
- #expect(browserTheme.supports(url: URL(string: "https://cmux.com/app-pro-welcome")))
- #expect(!browserTheme.supports(url: URL(string: "https://cmux.com/pricing")))
+ #expect(browserTheme.supports(
+ url: URL(string: "https://cmux.com/app-pricing"),
+ trustedOrigin: AuthEnvironment.appWebOrigin
+ ))
+ #expect(browserTheme.supports(
+ url: URL(string: "https://cmux.com/app-pro-welcome"),
+ trustedOrigin: AuthEnvironment.appWebOrigin
+ ))
+ #expect(!browserTheme.supports(
+ url: URL(string: "https://cmux.com/pricing"),
+ trustedOrigin: AuthEnvironment.appWebOrigin
+ ))
+ #expect(!browserTheme.supports(
+ url: URL(string: "https://attacker.example/app-pricing"),
+ trustedOrigin: AuthEnvironment.appWebOrigin
+ ))
}
@Test("app session handoff pins credentials to production or debug loopback")
From 7badff42714fe6e08b08e61d972c2fca11e237b9 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 17:25:18 -0700
Subject: [PATCH 20/50] test: cover final annual pricing regressions
---
.../AppSession/BrowserAppWebPolicyTests.swift | 14 +++++++++++
web/tests/app-pricing-appearance.test.ts | 11 +++++++++
web/tests/app-pricing-page.test.tsx | 3 ++-
web/tests/billing-checkout-route.test.ts | 4 ++--
web/tests/billing-portal-route.test.ts | 4 ++--
web/tests/dashboard-billing-page.test.tsx | 3 ++-
web/tests/pricing-page.test.tsx | 3 ++-
web/tests/stripe-provision-catalog.test.ts | 24 +++++++++++++++++++
8 files changed, 59 insertions(+), 7 deletions(-)
create mode 100644 web/tests/stripe-provision-catalog.test.ts
diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
index 92df12d93cc0..8483b48cdbc3 100644
--- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
+++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
@@ -17,6 +17,12 @@ struct BrowserAppWebPolicyTests {
#expect(policy.shouldOpenInSystemBrowser(
try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=1"))
))
+ #expect(!policy.shouldOpenInSystemBrowser(
+ try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser"))
+ ))
+ #expect(!policy.shouldOpenInSystemBrowser(
+ try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=yes"))
+ ))
#expect(!policy.shouldOpenInSystemBrowser(
try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=0"))
))
@@ -73,6 +79,14 @@ struct BrowserAppWebPolicyTests {
url: URL(string: "https://cmux.com/app-pro-welcome"),
trustedOrigin: trustedOrigin
))
+ #expect(theme.supports(
+ url: URL(string: "https://cmux.com/app-pricing/"),
+ trustedOrigin: trustedOrigin
+ ))
+ #expect(theme.supports(
+ url: URL(string: "https://cmux.com/app-pro-welcome/"),
+ trustedOrigin: trustedOrigin
+ ))
#expect(!theme.supports(
url: URL(string: "https://cmux.com/pricing"),
trustedOrigin: trustedOrigin
diff --git a/web/tests/app-pricing-appearance.test.ts b/web/tests/app-pricing-appearance.test.ts
index 87d3ae7277d4..a0409f72dc57 100644
--- a/web/tests/app-pricing-appearance.test.ts
+++ b/web/tests/app-pricing-appearance.test.ts
@@ -50,4 +50,15 @@ describe("app pricing contrast-adjusted accent", () => {
expect(theme.accentOnBackground).toBe("#123456");
expect(theme.accentOnForeground).toBe("#abcdef");
});
+
+ test("rejects translucent backgrounds that cannot be contrast-checked", () => {
+ const theme = appPricingTheme({
+ appearance: "dark",
+ background: "#FFFFFF00",
+ accent: "#0088ff",
+ });
+
+ expect(theme.background).toBe("#272822");
+ expect(theme.accentOnBackground).toBe("#0088ff");
+ });
});
diff --git a/web/tests/app-pricing-page.test.tsx b/web/tests/app-pricing-page.test.tsx
index 63be1c98aa17..0b2c71997495 100644
--- a/web/tests/app-pricing-page.test.tsx
+++ b/web/tests/app-pricing-page.test.tsx
@@ -119,7 +119,8 @@ describe("app pricing page", () => {
expect(html).toContain("$28");
expect(html).toContain("per month billed yearly");
expect(html).toContain("per user per month billed yearly");
- expect(html).not.toContain("Billed $288 annually · save 20%");
+ expect(html).toContain("Billed $288 annually · save 20%");
+ expect(html).toContain("Billed $336 annually · save 20%");
expect(html).toContain("$28/user/month · $336/user/year");
expect(html).toContain(
"http://localhost:9210/api/billing/checkout?plan=pro&cmux_external_browser=1&cmux_scheme=cmux-dev-test&interval=year",
diff --git a/web/tests/billing-checkout-route.test.ts b/web/tests/billing-checkout-route.test.ts
index 7707907965a6..2e00f6c8a2ca 100644
--- a/web/tests/billing-checkout-route.test.ts
+++ b/web/tests/billing-checkout-route.test.ts
@@ -175,13 +175,13 @@ describe("billing checkout route", () => {
const response = await GET(
new NextRequest(
- "https://cmux.test/api/billing/checkout?plan=pro&cmux_distribution=appstore&cmux_scheme=cmux",
+ "https://cmux.test/api/billing/checkout?plan=pro&interval=year&cmux_distribution=appstore&cmux_scheme=cmux",
),
);
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
- "https://cmux.test/app-pricing?cmux_app=1&cmux_distribution=appstore&billing=unavailable",
+ "https://cmux.test/app-pricing?cmux_app=1&cmux_distribution=appstore&billing=unavailable&interval=year",
);
expect(getUser).not.toHaveBeenCalled();
expect(createStripeSession).not.toHaveBeenCalled();
diff --git a/web/tests/billing-portal-route.test.ts b/web/tests/billing-portal-route.test.ts
index 535525732139..81703be8cc09 100644
--- a/web/tests/billing-portal-route.test.ts
+++ b/web/tests/billing-portal-route.test.ts
@@ -132,13 +132,13 @@ describe("billing portal route", () => {
test("blocks direct portal requests from the iOS App Store distribution", async () => {
const response = await GET(
new NextRequest(
- "https://cmux.test/api/billing/portal?cmux_distribution=appstore&cmux_scheme=cmux",
+ "https://cmux.test/api/billing/portal?interval=year&cmux_distribution=appstore&cmux_scheme=cmux",
),
);
expect(response.status).toBe(302);
expect(response.headers.get("location")).toBe(
- "https://cmux.test/app-pricing?cmux_app=1&cmux_distribution=appstore&billing=unavailable",
+ "https://cmux.test/app-pricing?cmux_app=1&cmux_distribution=appstore&billing=unavailable&interval=year",
);
expect(getUser).not.toHaveBeenCalled();
expect(createPortalSession).not.toHaveBeenCalled();
diff --git a/web/tests/dashboard-billing-page.test.tsx b/web/tests/dashboard-billing-page.test.tsx
index 19534c8ea47e..aeb65ec32abc 100644
--- a/web/tests/dashboard-billing-page.test.tsx
+++ b/web/tests/dashboard-billing-page.test.tsx
@@ -119,7 +119,8 @@ describe("dashboard billing page", () => {
expect(html).toContain("$28");
expect(html).toContain("per month billed yearly");
expect(html).toContain("per user per month billed yearly");
- expect(html).not.toContain("Billed $288 annually · save 20%");
+ expect(html).toContain("Billed $288 annually · save 20%");
+ expect(html).toContain("Billed $336 annually · save 20%");
expect(html).toContain(
'href="/api/billing/checkout?plan=pro&cmux_external_browser=1&interval=year"',
);
diff --git a/web/tests/pricing-page.test.tsx b/web/tests/pricing-page.test.tsx
index 7668fbde8397..3bfed33770cd 100644
--- a/web/tests/pricing-page.test.tsx
+++ b/web/tests/pricing-page.test.tsx
@@ -119,7 +119,8 @@ describe("localized pricing page", () => {
expect(html).toContain("$24");
expect(html).toContain("per month billed yearly");
- expect(html).not.toContain("Billed $288 annually · save 20%");
+ expect(html).toContain("Billed $288 annually · save 20%");
+ expect(html).toContain("Billed $336 annually · save 20%");
expect(html).toContain("$24/month · $288/year");
expect(html).toContain("$28");
expect(html).toContain("per user per month billed yearly");
diff --git a/web/tests/stripe-provision-catalog.test.ts b/web/tests/stripe-provision-catalog.test.ts
new file mode 100644
index 000000000000..68c14f6a9174
--- /dev/null
+++ b/web/tests/stripe-provision-catalog.test.ts
@@ -0,0 +1,24 @@
+import { expect, test } from "bun:test";
+import { readFileSync } from "node:fs";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
+const source = readFileSync(
+ resolve(webRoot, "scripts/stripe/provision-catalog.sh"),
+ "utf8",
+);
+
+test("keeps Stripe credentials and webhook secrets out of predictable process and file paths", () => {
+ expect(source).not.toContain('-u "${STRIPE_PROVISION_KEY}:"');
+ expect(source).toContain("--header @-");
+ expect(source).toContain("mktemp");
+ expect(source).not.toContain('WEBHOOK_SECRET_FILE="/tmp/.cmux-live-whsec"');
+});
+
+test("validates catalog identity, cadence, and unique webhook ownership", () => {
+ expect(source).toContain("product_matches_catalog_identity");
+ expect(source).toContain("recurring.interval_count");
+ expect(source).toContain("webhook_ids");
+ expect(source).toContain("Multiple enabled webhook endpoints");
+});
From 95a977f830039de44864363d07f14c10ac63e392 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 17:28:12 -0700
Subject: [PATCH 21/50] fix: close annual pricing review gaps
---
.../AppSession/BrowserAppTheme.swift | 5 +-
.../BrowserExternalNavigationPolicy.swift | 31 +----
Sources/ProWelcomeChecklist.swift | 112 +++++++++++++++++-
.../Sidebar/SidebarAppearanceSupport.swift | 105 ----------------
cmuxTests/SidebarWidthPolicyTests.swift | 87 ++++++++------
web/app/[locale]/dashboard/billing/page.tsx | 26 ++++
web/app/[locale]/pricing/page.tsx | 26 ++++
web/app/app-pricing/appearance.ts | 2 +-
web/app/app-pricing/page.tsx | 26 ++++
.../components/pricing-interval-selector.tsx | 14 +++
web/app/lib/billing.ts | 4 +
web/scripts/stripe/provision-catalog.sh | 66 ++++++++---
web/tests/app-pricing-appearance.test.ts | 2 +-
13 files changed, 318 insertions(+), 188 deletions(-)
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
index 4e264cf6bb56..0991dd2d2bea 100644
--- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
@@ -89,7 +89,10 @@ public struct BrowserAppTheme: Equatable, Sendable {
BrowserAppWebOrigin(trustedOrigin).contains(url) else {
return false
}
- return url.path == "/app-pricing" || url.path == "/app-pro-welcome"
+ let normalizedPath = url.path.count > 1 && url.path.hasSuffix("/")
+ ? String(url.path.dropLast())
+ : url.path
+ return normalizedPath == "/app-pricing" || normalizedPath == "/app-pro-welcome"
}
private struct JavaScriptPayload: Encodable {
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
index 7ee06ebed4c7..9eec53096f20 100644
--- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
@@ -9,41 +9,16 @@ public struct BrowserExternalNavigationPolicy: Equatable, Sendable {
self.trustedOrigin = trustedOrigin
}
- /// Returns true only for an HTTP(S) URL on the trusted origin with a nonzero intent marker.
+ /// Returns true only for an HTTP(S) URL on the trusted origin with an explicit intent marker.
public func shouldOpenInSystemBrowser(_ url: URL) -> Bool {
guard let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https",
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
- sameOrigin(url, trustedOrigin) else {
+ BrowserAppWebOrigin(trustedOrigin).contains(url) else {
return false
}
return components.queryItems?.contains(where: {
- $0.name == "cmux_external_browser" && $0.value != "0"
+ $0.name == "cmux_external_browser" && $0.value == "1"
}) == true
}
-
- private func sameOrigin(_ lhs: URL, _ rhs: URL) -> Bool {
- guard let lhsComponents = URLComponents(url: lhs, resolvingAgainstBaseURL: false),
- let rhsComponents = URLComponents(url: rhs, resolvingAgainstBaseURL: false),
- let lhsScheme = lhsComponents.scheme?.lowercased(),
- let rhsScheme = rhsComponents.scheme?.lowercased(),
- let lhsHost = lhsComponents.host?.lowercased(),
- let rhsHost = rhsComponents.host?.lowercased() else {
- return false
- }
- return lhsScheme == rhsScheme
- && lhsHost == rhsHost
- && effectivePort(lhsComponents) == effectivePort(rhsComponents)
- }
-
- private func effectivePort(_ components: URLComponents) -> Int? {
- if let port = components.port {
- return port
- }
- return switch components.scheme?.lowercased() {
- case "http": 80
- case "https": 443
- default: nil
- }
- }
}
diff --git a/Sources/ProWelcomeChecklist.swift b/Sources/ProWelcomeChecklist.swift
index 470c4b435c27..2046d3d1586c 100644
--- a/Sources/ProWelcomeChecklist.swift
+++ b/Sources/ProWelcomeChecklist.swift
@@ -60,12 +60,122 @@ struct AppWebThemeSnapshot: Equatable {
let backgroundColor = NSColor(hex: backgroundHex) else {
return accent
}
- return cmuxContrastAdjustedAccentNSColor(
+ return Self.contrastAdjustedAccentNSColor(
accentColor,
on: backgroundColor
).hexString()
}
+ /// Preserves the preferred app-web accent whenever it is readable,
+ /// otherwise makes the smallest 8-bit sRGB move toward white or black that
+ /// reaches the requested contrast.
+ static func contrastAdjustedAccentNSColor(
+ _ preferredColor: NSColor,
+ on backgroundColor: NSColor,
+ minimumContrast: CGFloat = 4.5
+ ) -> NSColor {
+ let preferred = RGBBytes(color: preferredColor).color
+ let background = RGBBytes(color: backgroundColor).color
+ guard cmuxContrastRatio(
+ foreground: preferred,
+ background: background
+ ) < minimumContrast else {
+ return preferred
+ }
+
+ let candidates = [0, 255].compactMap { targetComponent in
+ contrastAdjustmentCandidate(
+ preferred: RGBBytes(color: preferred),
+ background: background,
+ targetComponent: targetComponent,
+ minimumContrast: minimumContrast
+ )
+ }
+ if let candidate = candidates.min(by: {
+ if $0.step == $1.step {
+ return $0.contrast > $1.contrast
+ }
+ return $0.step < $1.step
+ }) {
+ return candidate.color
+ }
+
+ return cmuxContrastRatio(foreground: .white, background: background)
+ >= cmuxContrastRatio(foreground: .black, background: background)
+ ? .white
+ : .black
+ }
+
+ private struct RGBBytes {
+ let red: Int
+ let green: Int
+ let blue: Int
+
+ init(color: NSColor) {
+ let value = UInt64(color.hexString().dropFirst(), radix: 16) ?? 0
+ red = Int((value >> 16) & 0xFF)
+ green = Int((value >> 8) & 0xFF)
+ blue = Int(value & 0xFF)
+ }
+
+ init(red: Int, green: Int, blue: Int) {
+ self.red = red
+ self.green = green
+ self.blue = blue
+ }
+
+ var color: NSColor {
+ NSColor(
+ srgbRed: CGFloat(red) / 255,
+ green: CGFloat(green) / 255,
+ blue: CGFloat(blue) / 255,
+ alpha: 1
+ )
+ }
+
+ func mixed(toward targetComponent: Int, step: Int) -> RGBBytes {
+ func mix(_ component: Int) -> Int {
+ (component * (255 - step) + targetComponent * step) / 255
+ }
+ return RGBBytes(
+ red: mix(red),
+ green: mix(green),
+ blue: mix(blue)
+ )
+ }
+ }
+
+ private struct ContrastAdjustmentCandidate {
+ let color: NSColor
+ let step: Int
+ let contrast: CGFloat
+ }
+
+ private static func contrastAdjustmentCandidate(
+ preferred: RGBBytes,
+ background: NSColor,
+ targetComponent: Int,
+ minimumContrast: CGFloat
+ ) -> ContrastAdjustmentCandidate? {
+ for step in 1...255 {
+ let color = preferred.mixed(
+ toward: targetComponent,
+ step: step
+ ).color
+ let contrast = cmuxContrastRatio(
+ foreground: color,
+ background: background
+ )
+ if contrast >= minimumContrast {
+ return ContrastAdjustmentCandidate(
+ color: color,
+ step: step,
+ contrast: contrast
+ )
+ }
+ }
+ return nil
+ }
}
/// Presents the one-time "Welcome to cmux Pro" checklist after a user becomes
diff --git a/Sources/Sidebar/SidebarAppearanceSupport.swift b/Sources/Sidebar/SidebarAppearanceSupport.swift
index 1ccbda6a9a03..ac43a665eba1 100644
--- a/Sources/Sidebar/SidebarAppearanceSupport.swift
+++ b/Sources/Sidebar/SidebarAppearanceSupport.swift
@@ -168,111 +168,6 @@ func cmuxContrastRatio(foreground: NSColor, background: NSColor) -> CGFloat {
)
}
-/// Preserves the preferred accent whenever it is readable, otherwise makes the
-/// smallest 8-bit sRGB move toward white or black that reaches the requested
-/// contrast. This keeps cmux blue recognizable across arbitrary terminal
-/// themes instead of replacing it with a generic foreground color.
-func cmuxContrastAdjustedAccentNSColor(
- _ preferredColor: NSColor,
- on backgroundColor: NSColor,
- minimumContrast: CGFloat = 4.5
-) -> NSColor {
- let preferred = CmuxRGBBytes(color: preferredColor).color
- let background = CmuxRGBBytes(color: backgroundColor).color
- guard cmuxContrastRatio(foreground: preferred, background: background) < minimumContrast else {
- return preferred
- }
-
- let candidates = [0, 255].compactMap { targetComponent in
- cmuxContrastAdjustmentCandidate(
- preferred: CmuxRGBBytes(color: preferred),
- background: background,
- targetComponent: targetComponent,
- minimumContrast: minimumContrast
- )
- }
- if let candidate = candidates.min(by: {
- if $0.step == $1.step {
- return $0.contrast > $1.contrast
- }
- return $0.step < $1.step
- }) {
- return candidate.color
- }
-
- let black = NSColor.black
- let white = NSColor.white
- return cmuxContrastRatio(foreground: white, background: background)
- >= cmuxContrastRatio(foreground: black, background: background)
- ? white
- : black
-}
-
-private struct CmuxRGBBytes {
- let red: Int
- let green: Int
- let blue: Int
-
- init(color: NSColor) {
- let value = UInt64(color.hexString().dropFirst(), radix: 16) ?? 0
- red = Int((value >> 16) & 0xFF)
- green = Int((value >> 8) & 0xFF)
- blue = Int(value & 0xFF)
- }
-
- init(red: Int, green: Int, blue: Int) {
- self.red = red
- self.green = green
- self.blue = blue
- }
-
- var color: NSColor {
- NSColor(
- srgbRed: CGFloat(red) / 255,
- green: CGFloat(green) / 255,
- blue: CGFloat(blue) / 255,
- alpha: 1
- )
- }
-
- func mixed(toward targetComponent: Int, step: Int) -> CmuxRGBBytes {
- func mix(_ component: Int) -> Int {
- (component * (255 - step) + targetComponent * step) / 255
- }
- return CmuxRGBBytes(
- red: mix(red),
- green: mix(green),
- blue: mix(blue)
- )
- }
-}
-
-private struct CmuxContrastAdjustmentCandidate {
- let color: NSColor
- let step: Int
- let contrast: CGFloat
-}
-
-private func cmuxContrastAdjustmentCandidate(
- preferred: CmuxRGBBytes,
- background: NSColor,
- targetComponent: Int,
- minimumContrast: CGFloat
-) -> CmuxContrastAdjustmentCandidate? {
- for step in 1...255 {
- let color = preferred.mixed(toward: targetComponent, step: step).color
- let contrast = cmuxContrastRatio(foreground: color, background: background)
- if contrast >= minimumContrast {
- return CmuxContrastAdjustmentCandidate(
- color: color,
- step: step,
- contrast: contrast
- )
- }
- }
- return nil
-}
-
private func cmuxReadableForegroundBaseColor(on backgroundColor: NSColor) -> NSColor {
cmuxReadableColorScheme(for: backgroundColor) == .dark ? .white : .black
}
diff --git a/cmuxTests/SidebarWidthPolicyTests.swift b/cmuxTests/SidebarWidthPolicyTests.swift
index d6887ea997ec..0e2cb7834881 100644
--- a/cmuxTests/SidebarWidthPolicyTests.swift
+++ b/cmuxTests/SidebarWidthPolicyTests.swift
@@ -2,6 +2,7 @@ import AppKit
import CmuxAppKitSupportUI
import CmuxFoundation
import SwiftUI
+import Testing
import XCTest
#if canImport(cmux_DEV)
@@ -277,6 +278,56 @@ final class SidebarWidthPolicyTests: XCTestCase {
}
}
+@MainActor
+@Suite("App web theme contrast")
+struct AppWebThemeContrastTests {
+ @Test
+ func keepsReadableCmuxBlue() throws {
+ let accent = try #require(NSColor(hex: "#0088FF"))
+ let background = try #require(NSColor(hex: "#171717"))
+ let adjusted = AppWebThemeSnapshot.contrastAdjustedAccentNSColor(
+ accent,
+ on: background
+ )
+
+ #expect(adjusted.hexString() == accent.hexString())
+ }
+
+ @Test
+ func darkensAgainstLightTheme() throws {
+ let background = try #require(NSColor(hex: "#FDF6E3"))
+ let adjusted = AppWebThemeSnapshot.contrastAdjustedAccentNSColor(
+ try #require(NSColor(hex: "#0088FF")),
+ on: background
+ )
+
+ #expect(adjusted.hexString() == "#0071D5")
+ #expect(
+ cmuxContrastRatio(
+ foreground: adjusted,
+ background: background
+ ) >= 4.5
+ )
+ }
+
+ @Test
+ func lightensAgainstDarkSelectedButton() throws {
+ let background = try #require(NSColor(hex: "#4A4543"))
+ let adjusted = AppWebThemeSnapshot.contrastAdjustedAccentNSColor(
+ try #require(NSColor(hex: "#0088FF")),
+ on: background
+ )
+
+ #expect(adjusted.hexString() == "#6BB9FF")
+ #expect(
+ cmuxContrastRatio(
+ foreground: adjusted,
+ background: background
+ ) >= 4.5
+ )
+ }
+}
+
final class SidebarWorkspaceSelectionColorTests: XCTestCase {
func testSelectedColoredWorkspaceUsesStandardSelectionBackgroundInLightAndDark() {
for colorScheme in [ColorScheme.light, .dark] {
@@ -379,42 +430,6 @@ final class SidebarWorkspaceSelectionColorTests: XCTestCase {
assertColor(foreground, equals: NSColor.white.withAlphaComponent(0.75))
}
- func testContrastAdjustedAccentKeepsReadableCmuxBlue() throws {
- let accent = try XCTUnwrap(NSColor(hex: "#0088FF"))
- let background = try XCTUnwrap(NSColor(hex: "#171717"))
- let adjusted = cmuxContrastAdjustedAccentNSColor(accent, on: background)
-
- assertColor(adjusted, equals: accent)
- }
-
- func testContrastAdjustedAccentDarkensAgainstLightTheme() throws {
- let background = try XCTUnwrap(NSColor(hex: "#FDF6E3"))
- let adjusted = cmuxContrastAdjustedAccentNSColor(
- try XCTUnwrap(NSColor(hex: "#0088FF")),
- on: background
- )
-
- XCTAssertEqual(adjusted.hexString(), "#0071D5")
- XCTAssertGreaterThanOrEqual(
- cmuxContrastRatio(foreground: adjusted, background: background),
- 4.5
- )
- }
-
- func testContrastAdjustedAccentLightensAgainstDarkSelectedButton() throws {
- let background = try XCTUnwrap(NSColor(hex: "#4A4543"))
- let adjusted = cmuxContrastAdjustedAccentNSColor(
- try XCTUnwrap(NSColor(hex: "#0088FF")),
- on: background
- )
-
- XCTAssertEqual(adjusted.hexString(), "#6BB9FF")
- XCTAssertGreaterThanOrEqual(
- cmuxContrastRatio(foreground: adjusted, background: background),
- 4.5
- )
- }
-
func testTitlebarControlForegroundContrastsWithLightTerminalBackground() throws {
let background = try XCTUnwrap(NSColor(hex: "#F7F7F7"))
let snapshot = makeWindowAppearanceSnapshot(background: background)
diff --git a/web/app/[locale]/dashboard/billing/page.tsx b/web/app/[locale]/dashboard/billing/page.tsx
index 6baa264dd371..ac0b39a0abde 100644
--- a/web/app/[locale]/dashboard/billing/page.tsx
+++ b/web/app/[locale]/dashboard/billing/page.tsx
@@ -291,6 +291,19 @@ function FreePlanUpsell({
annual={pricingT("perMonthBilledYearly")}
/>
}
+ priceDetail={
+
+ {pricingT("annualPriceDetail", {
+ amount: PRO_PRICING_USD.year.billedAmount,
+ discount: PRO_PRICING_USD.year.discountPercent,
+ })}
+
+ }
+ />
+ }
>
}
+ priceDetail={
+
+ {pricingT("annualPriceDetail", {
+ amount: TEAM_PRICING_USD.year.billedAmount,
+ discount: TEAM_PRICING_USD.year.discountPercent,
+ })}
+
+ }
+ />
+ }
>
}
+ priceDetail={
+
+ {t("annualPriceDetail", {
+ amount: PRO_PRICING_USD.year.billedAmount,
+ discount: PRO_PRICING_USD.year.discountPercent,
+ })}
+
+ }
+ />
+ }
badge={
snapshot.isPro ? (
{t("currentPlan")}
@@ -229,6 +242,19 @@ export default async function PricingPage({
annual={t("perUserMonthBilledYearly")}
/>
}
+ priceDetail={
+
+ {t("annualPriceDetail", {
+ amount: TEAM_PRICING_USD.year.billedAmount,
+ discount: TEAM_PRICING_USD.year.discountPercent,
+ })}
+
+ }
+ />
+ }
>
}
+ priceDetail={
+
+ {pricingMessage(pricing.annualPriceDetail, {
+ amount: PRO_PRICING_USD.year.billedAmount,
+ discount: PRO_PRICING_USD.year.discountPercent,
+ })}
+
+ }
+ />
+ }
badge={
snapshot.isPro ? (
{pricing.currentPlan}
@@ -212,6 +225,19 @@ export default async function AppPricingPage({
annual={pricing.perUserMonthBilledYearly}
/>
}
+ priceDetail={
+
+ {pricingMessage(pricing.annualPriceDetail, {
+ amount: TEAM_PRICING_USD.year.billedAmount,
+ discount: TEAM_PRICING_USD.year.discountPercent,
+ })}
+
+ }
+ />
+ }
>
{appStorePaymentGated ? (
{pricing.billingUnavailable}
diff --git a/web/app/components/pricing-interval-selector.tsx b/web/app/components/pricing-interval-selector.tsx
index 7e76847cff29..eeccd3cc05c9 100644
--- a/web/app/components/pricing-interval-selector.tsx
+++ b/web/app/components/pricing-interval-selector.tsx
@@ -37,6 +37,20 @@ export function PricingIntervalProvider({
}: {
initialInterval: BillingInterval;
children: ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+function PricingIntervalState({
+ initialInterval,
+ children,
+}: {
+ initialInterval: BillingInterval;
+ children: ReactNode;
}) {
const [interval, setInterval] = useState(initialInterval);
diff --git a/web/app/lib/billing.ts b/web/app/lib/billing.ts
index 40f64e84c988..2c48c977aed2 100644
--- a/web/app/lib/billing.ts
+++ b/web/app/lib/billing.ts
@@ -65,6 +65,10 @@ export function appStorePricingUnavailableURL(requestUrl: URL): URL {
const value = requestUrl.searchParams.get(key);
if (value) redirectURL.searchParams.set(key, value);
}
+ const interval = requestUrl.searchParams.get(CHECKOUT_INTERVAL_PARAM);
+ if (interval === "month" || interval === "year") {
+ redirectURL.searchParams.set(CHECKOUT_INTERVAL_PARAM, interval);
+ }
return redirectURL;
}
diff --git a/web/scripts/stripe/provision-catalog.sh b/web/scripts/stripe/provision-catalog.sh
index 55837a81d4a0..e181c79e7318 100755
--- a/web/scripts/stripe/provision-catalog.sh
+++ b/web/scripts/stripe/provision-catalog.sh
@@ -19,7 +19,6 @@ case "$MODE" in
;;
esac
-WEBHOOK_SECRET_FILE="/tmp/.cmux-live-whsec"
STRIPE_API_BASE="https://api.stripe.com/v1"
WEBHOOK_URL="https://cmux.com/api/stripe/webhook"
WEBHOOK_DESCRIPTION="cmux billing (webhook-driven entitlements)"
@@ -73,26 +72,54 @@ fi
stripe_get() {
local path="$1"
shift
- curl -fsS -u "${STRIPE_PROVISION_KEY}:" --get "$@" "${STRIPE_API_BASE}${path}"
+ printf 'Authorization: Bearer %s\n' "$STRIPE_PROVISION_KEY" |
+ curl -fsS --header @- --get "$@" "${STRIPE_API_BASE}${path}"
}
stripe_post() {
local path="$1"
shift
- curl -fsS -u "${STRIPE_PROVISION_KEY}:" -X POST "$@" "${STRIPE_API_BASE}${path}"
+ printf 'Authorization: Bearer %s\n' "$STRIPE_PROVISION_KEY" |
+ curl -fsS --header @- -X POST "$@" "${STRIPE_API_BASE}${path}"
+}
+
+product_matches_catalog_identity() {
+ local product_json="$1"
+ local name="$2"
+ local plan="$3"
+ jq -e \
+ --arg name "$name" \
+ --arg plan "$plan" \
+ '
+ .name == $name
+ and .active == true
+ and .metadata.app == "cmux"
+ and .metadata.plan == $plan
+ ' <<<"$product_json" >/dev/null
}
ensure_product() {
local name="$1"
local plan="$2"
- local response product_id
+ local response product_json product_id
+ local -a matching_product_ids=()
response="$(
stripe_get "/products/search" \
--data-urlencode "query=name:'${name}' AND active:'true'" \
- --data-urlencode "limit=1"
+ --data-urlencode "limit=100"
)"
- product_id="$(jq -r '.data[0].id // empty' <<<"$response")"
+ while IFS= read -r product_json; do
+ if product_matches_catalog_identity "$product_json" "$name" "$plan"; then
+ matching_product_ids+=("$(jq -er '.id' <<<"$product_json")")
+ fi
+ done < <(jq -c '.data[]' <<<"$response")
+
+ if (( ${#matching_product_ids[@]} > 1 )); then
+ echo "Multiple canonical products found for ${name}" >&2
+ exit 1
+ fi
+ product_id="${matching_product_ids[0]:-}"
if [[ -n "$product_id" ]]; then
echo "Found product ${name}: ${product_id}" >&2
@@ -113,7 +140,7 @@ canonical_product() {
local monthly_lookup_key="$1"
local name="$2"
local plan="$3"
- local response product_id product_name product_active
+ local response product_json product_id
response="$(
stripe_get "/prices" \
@@ -127,9 +154,8 @@ canonical_product() {
return
fi
- product_name="$(jq -r '.data[0].product.name // empty' <<<"$response")"
- product_active="$(jq -r '.data[0].product.active // false' <<<"$response")"
- if [[ "$product_name" != "$name" || "$product_active" != "true" ]]; then
+ product_json="$(jq -c '.data[0].product' <<<"$response")"
+ if ! product_matches_catalog_identity "$product_json" "$name" "$plan"; then
echo "Price ${monthly_lookup_key} belongs to an unexpected product: ${product_id}" >&2
exit 1
fi
@@ -145,6 +171,7 @@ ensure_price() {
local interval="$4"
local nickname="$5"
local response price_id existing_amount existing_currency existing_interval
+ local existing_interval_count
local existing_product existing_active
response="$(
@@ -158,12 +185,14 @@ ensure_price() {
existing_amount="$(jq -r '.data[0].unit_amount // empty' <<<"$response")"
existing_currency="$(jq -r '.data[0].currency // empty' <<<"$response")"
existing_interval="$(jq -r '.data[0].recurring.interval // empty' <<<"$response")"
+ existing_interval_count="$(jq -r '.data[0].recurring.interval_count // empty' <<<"$response")"
existing_product="$(jq -r '.data[0].product // empty' <<<"$response")"
existing_active="$(jq -r '.data[0].active // false' <<<"$response")"
if [[
"$existing_amount" != "$unit_amount" ||
"$existing_currency" != "usd" ||
"$existing_interval" != "$interval" ||
+ "$existing_interval_count" != "1" ||
"$existing_product" != "$product_id" ||
"$existing_active" != "true"
]]; then
@@ -200,13 +229,19 @@ ensure_price "$team_product_id" "cmux-team-yearly-336" "33600" "year" "cmux Team
if [[ "$MODE" == "live" ]]; then
webhooks_response="$(stripe_get "/webhook_endpoints" --data-urlencode "limit=100")"
- webhook_id="$(
+ webhook_ids="$(
jq -r --arg url "$WEBHOOK_URL" '
.data[]
| select(.url == $url and .status == "enabled")
| .id
- ' <<<"$webhooks_response" | head -n 1
+ ' <<<"$webhooks_response"
)"
+ webhook_count="$(awk 'NF { count += 1 } END { print count + 0 }' <<<"$webhook_ids")"
+ if (( webhook_count > 1 )); then
+ echo "Multiple enabled webhook endpoints found for ${WEBHOOK_URL}" >&2
+ exit 1
+ fi
+ webhook_id="$(sed -n '1p' <<<"$webhook_ids")"
event_args=()
for event in "${EVENTS[@]}"; do
@@ -228,10 +263,11 @@ if [[ "$MODE" == "live" ]]; then
webhook_id="$(jq -er '.id' <<<"$webhook_response")"
webhook_secret="$(jq -er '.secret' <<<"$webhook_response")"
umask 077
- printf '%s\n' "$webhook_secret" >"$WEBHOOK_SECRET_FILE"
- chmod 600 "$WEBHOOK_SECRET_FILE"
+ webhook_secret_file="$(mktemp "${TMPDIR:-/tmp}/cmux-live-whsec.XXXXXX")"
+ printf '%s\n' "$webhook_secret" >"$webhook_secret_file"
+ chmod 600 "$webhook_secret_file"
echo "Created webhook endpoint: $webhook_id"
- echo "Captured new webhook signing secret in $WEBHOOK_SECRET_FILE (chmod 600)."
+ echo "Captured new webhook signing secret in $webhook_secret_file (chmod 600)."
fi
cat <<'EOF'
diff --git a/web/tests/app-pricing-appearance.test.ts b/web/tests/app-pricing-appearance.test.ts
index a0409f72dc57..3e0f668ed34f 100644
--- a/web/tests/app-pricing-appearance.test.ts
+++ b/web/tests/app-pricing-appearance.test.ts
@@ -59,6 +59,6 @@ describe("app pricing contrast-adjusted accent", () => {
});
expect(theme.background).toBe("#272822");
- expect(theme.accentOnBackground).toBe("#0088ff");
+ expect(theme.accentOnBackground).toBe("#0F8FFF");
});
});
From 689162dfb50f54a0d7bc1841315b88f642316894 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 17:35:48 -0700
Subject: [PATCH 22/50] test(web): exercise Stripe catalog behavior
---
web/tests/stripe-provision-catalog.test.ts | 338 ++++++++++++++++++++-
1 file changed, 322 insertions(+), 16 deletions(-)
diff --git a/web/tests/stripe-provision-catalog.test.ts b/web/tests/stripe-provision-catalog.test.ts
index 68c14f6a9174..32e28ce27755 100644
--- a/web/tests/stripe-provision-catalog.test.ts
+++ b/web/tests/stripe-provision-catalog.test.ts
@@ -1,24 +1,330 @@
-import { expect, test } from "bun:test";
-import { readFileSync } from "node:fs";
-import { dirname, resolve } from "node:path";
+import { afterEach, describe, expect, test } from "bun:test";
+import {
+ chmodSync,
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ statSync,
+ writeFileSync,
+} from "node:fs";
+import { spawn } from "node:child_process";
+import { tmpdir } from "node:os";
+import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
+type CurlCall = {
+ args: string[];
+ authorization: string;
+};
+
+type ProvisionResult = {
+ calls: CurlCall[];
+ exitCode: number;
+ root: string;
+ stderr: string;
+ stdout: string;
+};
+
const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
-const source = readFileSync(
- resolve(webRoot, "scripts/stripe/provision-catalog.sh"),
- "utf8",
+const provisionScript = resolve(
+ webRoot,
+ "scripts/stripe/provision-catalog.sh",
);
+const temporaryRoots: string[] = [];
-test("keeps Stripe credentials and webhook secrets out of predictable process and file paths", () => {
- expect(source).not.toContain('-u "${STRIPE_PROVISION_KEY}:"');
- expect(source).toContain("--header @-");
- expect(source).toContain("mktemp");
- expect(source).not.toContain('WEBHOOK_SECRET_FILE="/tmp/.cmux-live-whsec"');
+afterEach(() => {
+ for (const root of temporaryRoots.splice(0)) {
+ rmSync(root, { force: true, recursive: true });
+ }
});
-test("validates catalog identity, cadence, and unique webhook ownership", () => {
- expect(source).toContain("product_matches_catalog_identity");
- expect(source).toContain("recurring.interval_count");
- expect(source).toContain("webhook_ids");
- expect(source).toContain("Multiple enabled webhook endpoints");
+describe("Stripe catalog provisioning", () => {
+ test("sends credentials through stdin instead of process arguments", async () => {
+ const result = await runProvision("test", "valid");
+
+ expect(result.exitCode).toBe(0);
+ expect(result.calls.length).toBeGreaterThan(0);
+ for (const call of result.calls) {
+ expect(call.args.join("\n")).not.toContain("sk_test_mock_secret");
+ expect(call.authorization).toBe(
+ "Authorization: Bearer sk_test_mock_secret",
+ );
+ }
+ });
+
+ test("does not reuse an unrelated product with the expected name", async () => {
+ const result = await runProvision("test", "unrelated-product");
+
+ expect(result.exitCode).toBe(0);
+ const priceCreation = result.calls.find(
+ (call) =>
+ call.args.includes("https://api.stripe.com/v1/prices") &&
+ call.args.includes("product=prod_new_pro"),
+ );
+ expect(priceCreation).toBeDefined();
+ expect(
+ result.calls.some((call) => call.args.includes("product=prod_attacker")),
+ ).toBe(false);
+ });
+
+ test("rejects a canonical product with mismatched catalog metadata", async () => {
+ const result = await runProvision("test", "wrong-product-metadata");
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain(
+ "belongs to an unexpected product: prod_pro",
+ );
+ });
+
+ test("rejects a recurring price with a non-monthly cadence count", async () => {
+ const result = await runProvision("test", "wrong-interval-count");
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain(
+ "cmux-pro-monthly exists with unexpected configuration",
+ );
+ });
+
+ test("rejects duplicate live webhooks before mutating either endpoint", async () => {
+ const result = await runProvision("live", "duplicate-webhooks");
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain(
+ "Multiple enabled webhook endpoints found",
+ );
+ expect(
+ result.calls.some((call) =>
+ call.args.some((argument) =>
+ argument.includes("/webhook_endpoints/we_"),
+ ),
+ ),
+ ).toBe(false);
+ });
+
+ test("stores a new webhook secret in a unique mode-600 file", async () => {
+ const result = await runProvision("live", "create-webhook");
+
+ expect(result.exitCode).toBe(0);
+ const match = result.stdout.match(
+ /Captured new webhook signing secret in (.+) \(chmod 600\)\./,
+ );
+ const secretPath = match?.[1];
+ expect(secretPath).toBeDefined();
+ expect(secretPath?.startsWith(`${join(result.root, "tmp")}/`)).toBe(true);
+ expect(readFileSync(secretPath!, "utf8")).toBe("whsec_mock\n");
+ expect(statSync(secretPath!).mode & 0o777).toBe(0o600);
+ });
});
+
+async function runProvision(
+ mode: "test" | "live",
+ scenario: string,
+): Promise {
+ const root = mkdtempSync(join(tmpdir(), "cmux-stripe-catalog-test-"));
+ temporaryRoots.push(root);
+ const home = join(root, "home");
+ const bin = join(root, "bin");
+ const temp = join(root, "tmp");
+ const log = join(root, "curl.jsonl");
+ mkdirSync(join(home, ".secrets"), { recursive: true });
+ mkdirSync(bin, { recursive: true });
+ mkdirSync(temp, { recursive: true });
+
+ const keyName =
+ mode === "live"
+ ? "STRIPE_LIVE_PROVISION_KEY"
+ : "STRIPE_TEST_PROVISION_KEY";
+ const key = mode === "live"
+ ? "sk_live_mock_secret"
+ : "sk_test_mock_secret";
+ writeFileSync(
+ join(home, ".secrets", `cmux-stripe-${mode}.env`),
+ `${keyName}=${key}\n`,
+ { mode: 0o600 },
+ );
+ const curlPath = join(bin, "curl");
+ writeFileSync(curlPath, mockCurlSource, { mode: 0o700 });
+ chmodSync(curlPath, 0o700);
+
+ const childProcess = spawn("bash", [provisionScript, mode], {
+ env: {
+ ...process.env,
+ HOME: home,
+ MOCK_CURL_LOG: log,
+ MOCK_STRIPE_SCENARIO: scenario,
+ PATH: `${bin}:${process.env.PATH ?? ""}`,
+ TMPDIR: temp,
+ },
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ childProcess.stdout.setEncoding("utf8");
+ childProcess.stderr.setEncoding("utf8");
+ let stdout = "";
+ let stderr = "";
+ childProcess.stdout.on("data", (chunk: string) => {
+ stdout += chunk;
+ });
+ childProcess.stderr.on("data", (chunk: string) => {
+ stderr += chunk;
+ });
+ const exitCode = await new Promise((resolveExit, reject) => {
+ childProcess.once("error", reject);
+ childProcess.once("close", (code) => resolveExit(code ?? 1));
+ });
+ const calls = readFileSync(log, "utf8")
+ .trim()
+ .split("\n")
+ .filter(Boolean)
+ .map((line) => JSON.parse(line) as CurlCall);
+
+ return { calls, exitCode, root, stderr, stdout };
+}
+
+const mockCurlSource = String.raw`#!/usr/bin/env bun
+import { appendFileSync } from "node:fs";
+
+const args = process.argv.slice(2);
+const authorization = (await Bun.stdin.text()).trim();
+appendFileSync(
+ process.env.MOCK_CURL_LOG,
+ JSON.stringify({ args, authorization }) + "\n",
+);
+
+const scenario = process.env.MOCK_STRIPE_SCENARIO;
+const url = args.find((argument) =>
+ argument.startsWith("https://api.stripe.com/v1"),
+);
+const isPost = args.includes("POST");
+const valueFor = (prefix) => {
+ const argument = args.find((candidate) => candidate.startsWith(prefix));
+ return argument ? argument.slice(prefix.length) : null;
+};
+const lookupKey = valueFor("lookup_keys[]=");
+const expandedProduct = args.includes("expand[]=data.product");
+const dataValue = valueFor("name=");
+const respond = (value) => {
+ process.stdout.write(JSON.stringify(value));
+};
+
+const products = {
+ pro: {
+ id: "prod_pro",
+ name: "cmux Pro",
+ active: true,
+ metadata: { app: "cmux", plan: "pro" },
+ },
+ team: {
+ id: "prod_team",
+ name: "cmux Team",
+ active: true,
+ metadata: { app: "cmux", plan: "team" },
+ },
+};
+const prices = {
+ "cmux-pro-monthly": {
+ id: "price_pro_month",
+ unit_amount: 3000,
+ interval: "month",
+ product: "pro",
+ },
+ "cmux-pro-yearly": {
+ id: "price_pro_year_legacy",
+ unit_amount: 24000,
+ interval: "year",
+ product: "pro",
+ },
+ "cmux-pro-yearly-288": {
+ id: "price_pro_year",
+ unit_amount: 28800,
+ interval: "year",
+ product: "pro",
+ },
+ "cmux-team-monthly": {
+ id: "price_team_month",
+ unit_amount: 3500,
+ interval: "month",
+ product: "team",
+ },
+ "cmux-team-yearly-336": {
+ id: "price_team_year",
+ unit_amount: 33600,
+ interval: "year",
+ product: "team",
+ },
+};
+
+if (url.endsWith("/prices") && !isPost) {
+ const price = prices[lookupKey];
+ if (
+ scenario === "unrelated-product" &&
+ lookupKey?.startsWith("cmux-pro")
+ ) {
+ respond({ data: [] });
+ } else if (price) {
+ const product = products[price.product];
+ const returnedProduct =
+ scenario === "wrong-product-metadata" &&
+ lookupKey === "cmux-pro-monthly" &&
+ expandedProduct
+ ? { ...product, metadata: { app: "other", plan: "pro" } }
+ : product;
+ respond({
+ data: [{
+ id: price.id,
+ unit_amount: price.unit_amount,
+ currency: "usd",
+ recurring: {
+ interval: price.interval,
+ interval_count:
+ scenario === "wrong-interval-count" &&
+ lookupKey === "cmux-pro-monthly" &&
+ !expandedProduct
+ ? 2
+ : 1,
+ },
+ product: expandedProduct ? returnedProduct : product.id,
+ active: true,
+ }],
+ });
+ } else {
+ respond({ data: [] });
+ }
+} else if (url.endsWith("/products/search")) {
+ respond({
+ data:
+ scenario === "unrelated-product"
+ ? [{
+ id: "prod_attacker",
+ name: "cmux Pro",
+ active: true,
+ metadata: { app: "other", plan: "pro" },
+ }]
+ : [],
+ });
+} else if (url.endsWith("/products") && isPost) {
+ respond({
+ id: dataValue === "cmux Pro" ? "prod_new_pro" : "prod_new_team",
+ });
+} else if (url.endsWith("/prices") && isPost) {
+ respond({ id: "price_created" });
+} else if (url.endsWith("/webhook_endpoints") && !isPost) {
+ if (scenario === "duplicate-webhooks") {
+ respond({
+ data: [
+ { id: "we_one", url: "https://cmux.com/api/stripe/webhook", status: "enabled" },
+ { id: "we_two", url: "https://cmux.com/api/stripe/webhook", status: "enabled" },
+ ],
+ });
+ } else {
+ respond({ data: [] });
+ }
+} else if (url.endsWith("/webhook_endpoints") && isPost) {
+ respond({ id: "we_created", secret: "whsec_mock" });
+} else if (url.includes("/webhook_endpoints/") && isPost) {
+ respond({ id: url.split("/").at(-1) });
+} else {
+ process.stderr.write("Unexpected mock Stripe request: " + args.join(" ") + "\n");
+ process.exit(2);
+}
+`;
From 58187a76405bb532ab17f92f99a2a60458116fb5 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 17:42:46 -0700
Subject: [PATCH 23/50] test: cover cross-origin annual checkout handoff
---
.../AppSession/BrowserAppWebPolicyTests.swift | 7 +++++++
web/tests/stripe-provision-catalog.test.ts | 5 ++++-
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
index 8483b48cdbc3..655daa363ccf 100644
--- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
+++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
@@ -17,6 +17,13 @@ struct BrowserAppWebPolicyTests {
#expect(policy.shouldOpenInSystemBrowser(
try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=1"))
))
+ #expect(policy.shouldOpenInSystemBrowser(
+ try #require(
+ URL(
+ string: "https://billing.example/checkout?cmux_external_browser=1"
+ )
+ )
+ ))
#expect(!policy.shouldOpenInSystemBrowser(
try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser"))
))
diff --git a/web/tests/stripe-provision-catalog.test.ts b/web/tests/stripe-provision-catalog.test.ts
index 32e28ce27755..22b0df55116a 100644
--- a/web/tests/stripe-provision-catalog.test.ts
+++ b/web/tests/stripe-provision-catalog.test.ts
@@ -95,8 +95,11 @@ describe("Stripe catalog provisioning", () => {
);
expect(
result.calls.some((call) =>
+ call.args.includes("POST") &&
call.args.some((argument) =>
- argument.includes("/webhook_endpoints/we_"),
+ argument.startsWith(
+ "https://api.stripe.com/v1/webhook_endpoints",
+ ),
),
),
).toBe(false);
From 01b3a2e2af971a55b1fe98c260bdba05db3240c3 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 17:43:46 -0700
Subject: [PATCH 24/50] fix: trust cross-origin checkout from app pricing
---
.../AppSession/BrowserAppTheme.swift | 7 +--
.../AppSession/BrowserAppWebOrigin.swift | 10 +++++
.../BrowserExternalNavigationPolicy.swift | 38 ++++++++++++----
.../AppSession/BrowserAppWebPolicyTests.swift | 45 ++++++++++++++-----
.../Panels/BrowserNavigationDelegate.swift | 6 ++-
5 files changed, 80 insertions(+), 26 deletions(-)
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
index 0991dd2d2bea..1065b2fea815 100644
--- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
@@ -86,13 +86,10 @@ public struct BrowserAppTheme: Equatable, Sendable {
guard let url,
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https",
- BrowserAppWebOrigin(trustedOrigin).contains(url) else {
+ BrowserAppWebOrigin(trustedOrigin).containsAppSurface(url) else {
return false
}
- let normalizedPath = url.path.count > 1 && url.path.hasSuffix("/")
- ? String(url.path.dropLast())
- : url.path
- return normalizedPath == "/app-pricing" || normalizedPath == "/app-pro-welcome"
+ return true
}
private struct JavaScriptPayload: Encodable {
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift
index 7a7fe5fe528c..1ba441734556 100644
--- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift
@@ -14,6 +14,16 @@ struct BrowserAppWebOrigin {
}
return effectivePort(candidate) == effectivePort(url)
}
+
+ func containsAppSurface(_ candidate: URL) -> Bool {
+ guard contains(candidate) else { return false }
+ let normalizedPath = candidate.path.count > 1
+ && candidate.path.hasSuffix("/")
+ ? String(candidate.path.dropLast())
+ : candidate.path
+ return normalizedPath == "/app-pricing"
+ || normalizedPath == "/app-pro-welcome"
+ }
}
private func effectivePort(_ url: URL) -> Int? {
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
index 9eec53096f20..003bec4878a8 100644
--- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
@@ -1,6 +1,6 @@
public import Foundation
-/// Decides whether a trusted app-web URL explicitly requests the system browser.
+/// Decides whether a trusted app-web surface explicitly requests the system browser.
public struct BrowserExternalNavigationPolicy: Equatable, Sendable {
public let trustedOrigin: URL
@@ -9,16 +9,36 @@ public struct BrowserExternalNavigationPolicy: Equatable, Sendable {
self.trustedOrigin = trustedOrigin
}
- /// Returns true only for an HTTP(S) URL on the trusted origin with an explicit intent marker.
- public func shouldOpenInSystemBrowser(_ url: URL) -> Bool {
- guard let scheme = url.scheme?.lowercased(),
- scheme == "http" || scheme == "https",
+ /// Returns true only when a trusted app-web source activates a safe web URL
+ /// with an explicit intent marker. The destination may use a separate
+ /// configured billing origin.
+ public func shouldOpenInSystemBrowser(
+ _ url: URL,
+ sourceURL: URL?
+ ) -> Bool {
+ guard let sourceURL,
+ BrowserAppWebOrigin(trustedOrigin).containsAppSurface(sourceURL),
+ isSafeWebDestination(url),
let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
- BrowserAppWebOrigin(trustedOrigin).contains(url) else {
+ components.queryItems?.contains(where: {
+ $0.name == "cmux_external_browser" && $0.value == "1"
+ }) == true else {
+ return false
+ }
+ return true
+ }
+
+ private func isSafeWebDestination(_ url: URL) -> Bool {
+ switch url.scheme?.lowercased() {
+ case "https":
+ return url.host != nil
+ case "http":
+ guard let host = url.host?.lowercased() else { return false }
+ return host == "localhost"
+ || host == "127.0.0.1"
+ || host == "::1"
+ default:
return false
}
- return components.queryItems?.contains(where: {
- $0.name == "cmux_external_browser" && $0.value == "1"
- }) == true
}
}
diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
index 655daa363ccf..c0d4b25842d2 100644
--- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
+++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
@@ -10,40 +10,59 @@ struct BrowserAppWebPolicyTests {
let policy = BrowserExternalNavigationPolicy(
trustedOrigin: try #require(URL(string: "https://cmux.com"))
)
+ let trustedSource = try #require(
+ URL(string: "https://cmux.com/app-pricing")
+ )
+ let untrustedSource = try #require(
+ URL(string: "https://attacker.example/app-pricing")
+ )
+ let wrongPortSource = try #require(
+ URL(string: "https://cmux.com:8443/app-pricing")
+ )
#expect(policy.shouldOpenInSystemBrowser(
- try #require(URL(string: "https://cmux.com/api/billing/checkout?cmux_external_browser=1"))
+ try #require(URL(string: "https://cmux.com/api/billing/checkout?cmux_external_browser=1")),
+ sourceURL: trustedSource
))
#expect(policy.shouldOpenInSystemBrowser(
- try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=1"))
+ try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=1")),
+ sourceURL: trustedSource
))
#expect(policy.shouldOpenInSystemBrowser(
try #require(
URL(
string: "https://billing.example/checkout?cmux_external_browser=1"
)
- )
+ ),
+ sourceURL: trustedSource
))
#expect(!policy.shouldOpenInSystemBrowser(
- try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser"))
+ try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser")),
+ sourceURL: trustedSource
))
#expect(!policy.shouldOpenInSystemBrowser(
- try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=yes"))
+ try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=yes")),
+ sourceURL: trustedSource
))
#expect(!policy.shouldOpenInSystemBrowser(
- try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=0"))
+ try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=0")),
+ sourceURL: trustedSource
))
#expect(!policy.shouldOpenInSystemBrowser(
- try #require(URL(string: "cmux://enterprise?cmux_external_browser=1"))
+ try #require(URL(string: "cmux://enterprise?cmux_external_browser=1")),
+ sourceURL: trustedSource
))
#expect(!policy.shouldOpenInSystemBrowser(
- try #require(URL(string: "https://attacker.example/?cmux_external_browser=1"))
+ try #require(URL(string: "https://billing.example/?cmux_external_browser=1")),
+ sourceURL: untrustedSource
))
#expect(!policy.shouldOpenInSystemBrowser(
- try #require(URL(string: "http://cmux.com/?cmux_external_browser=1"))
+ try #require(URL(string: "http://cmux.com/?cmux_external_browser=1")),
+ sourceURL: trustedSource
))
#expect(!policy.shouldOpenInSystemBrowser(
- try #require(URL(string: "https://cmux.com:8443/?cmux_external_browser=1"))
+ try #require(URL(string: "https://billing.example/?cmux_external_browser=1")),
+ sourceURL: wrongPortSource
))
}
@@ -52,8 +71,12 @@ struct BrowserAppWebPolicyTests {
let policy = BrowserExternalNavigationPolicy(
trustedOrigin: try #require(URL(string: "http://localhost:4100"))
)
+ let trustedSource = try #require(
+ URL(string: "http://localhost:4100/app-pricing")
+ )
#expect(policy.shouldOpenInSystemBrowser(
- try #require(URL(string: "http://localhost:4100/enterprise?cmux_external_browser=1"))
+ try #require(URL(string: "http://localhost:4100/enterprise?cmux_external_browser=1")),
+ sourceURL: trustedSource
))
}
diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift
index c1562aef0168..07916bf21525 100644
--- a/Sources/Panels/BrowserNavigationDelegate.swift
+++ b/Sources/Panels/BrowserNavigationDelegate.swift
@@ -541,7 +541,11 @@ import WebKit
private func shouldOpenInSystemBrowser(_ navigationAction: WKNavigationAction, url: URL) -> Bool {
guard navigationAction.targetFrame?.isMainFrame != false else { return false }
guard navigationAction.navigationType == .linkActivated else { return false }
- return externalNavigationPolicy.shouldOpenInSystemBrowser(url)
+ return externalNavigationPolicy.shouldOpenInSystemBrowser(
+ url,
+ sourceURL: navigationAction.sourceFrame.request.url
+ ?? owner?.webView.url
+ )
}
func canHandleSSLTrustBypassToken(_ token: String) -> Bool {
From 2de43cc2c4749679d12b25a44bef5e6eab767b22 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 17:46:51 -0700
Subject: [PATCH 25/50] test: reject arbitrary external pricing destinations
---
.../AppSession/BrowserAppWebPolicyTests.swift | 8 ++++++++
1 file changed, 8 insertions(+)
diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
index c0d4b25842d2..bffe0a12f978 100644
--- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
+++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
@@ -36,6 +36,14 @@ struct BrowserAppWebPolicyTests {
),
sourceURL: trustedSource
))
+ #expect(!policy.shouldOpenInSystemBrowser(
+ try #require(
+ URL(
+ string: "https://attacker.example/checkout?cmux_external_browser=1"
+ )
+ ),
+ sourceURL: trustedSource
+ ))
#expect(!policy.shouldOpenInSystemBrowser(
try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser")),
sourceURL: trustedSource
From 6fff7b590984effc7522290a6ccd49f021965727 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 17:48:21 -0700
Subject: [PATCH 26/50] fix: pin external pricing handoff destination
---
.../BrowserExternalNavigationPolicy.swift | 45 ++++++++++++++-----
.../AppSession/BrowserAppWebPolicyTests.swift | 15 ++++++-
.../Panels/BrowserNavigationDelegate.swift | 3 +-
3 files changed, 50 insertions(+), 13 deletions(-)
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
index 003bec4878a8..658c6d1a896a 100644
--- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
@@ -3,10 +3,15 @@ public import Foundation
/// Decides whether a trusted app-web surface explicitly requests the system browser.
public struct BrowserExternalNavigationPolicy: Equatable, Sendable {
public let trustedOrigin: URL
+ public let billingCheckoutURL: URL?
- /// Creates a policy pinned to the exact origin allowed to request external navigation.
- public init(trustedOrigin: URL) {
+ /// Creates a policy pinned to the app-web origin and optional billing checkout URL.
+ public init(
+ trustedOrigin: URL,
+ billingCheckoutURL: URL? = nil
+ ) {
self.trustedOrigin = trustedOrigin
+ self.billingCheckoutURL = billingCheckoutURL
}
/// Returns true only when a trusted app-web source activates a safe web URL
@@ -29,16 +34,36 @@ public struct BrowserExternalNavigationPolicy: Equatable, Sendable {
}
private func isSafeWebDestination(_ url: URL) -> Bool {
- switch url.scheme?.lowercased() {
- case "https":
+ if BrowserAppWebOrigin(trustedOrigin).contains(url) {
+ return true
+ }
+ guard let billingCheckoutURL,
+ isSecureBillingURL(billingCheckoutURL),
+ url.user == nil,
+ url.password == nil,
+ BrowserAppWebOrigin(billingCheckoutURL).contains(url) else {
+ return false
+ }
+ return normalizedPath(url) == normalizedPath(billingCheckoutURL)
+ }
+
+ private func isSecureBillingURL(_ url: URL) -> Bool {
+ guard url.user == nil, url.password == nil else { return false }
+ if url.scheme?.lowercased() == "https" {
return url.host != nil
- case "http":
- guard let host = url.host?.lowercased() else { return false }
- return host == "localhost"
- || host == "127.0.0.1"
- || host == "::1"
- default:
+ }
+ guard url.scheme?.lowercased() == "http",
+ let host = url.host?.lowercased() else {
return false
}
+ return host == "localhost"
+ || host == "127.0.0.1"
+ || host == "::1"
+ }
+
+ private func normalizedPath(_ url: URL) -> String {
+ url.path.count > 1 && url.path.hasSuffix("/")
+ ? String(url.path.dropLast())
+ : url.path
}
}
diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
index bffe0a12f978..a8b889344f94 100644
--- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
+++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
@@ -8,7 +8,10 @@ struct BrowserAppWebPolicyTests {
@Test("external intent requires the trusted origin and an enabled marker")
func externalIntentRequiresTrustedOriginAndEnabledMarker() throws {
let policy = BrowserExternalNavigationPolicy(
- trustedOrigin: try #require(URL(string: "https://cmux.com"))
+ trustedOrigin: try #require(URL(string: "https://cmux.com")),
+ billingCheckoutURL: URL(
+ string: "https://billing.example/api/billing/checkout"
+ )
)
let trustedSource = try #require(
URL(string: "https://cmux.com/app-pricing")
@@ -31,7 +34,7 @@ struct BrowserAppWebPolicyTests {
#expect(policy.shouldOpenInSystemBrowser(
try #require(
URL(
- string: "https://billing.example/checkout?cmux_external_browser=1"
+ string: "https://billing.example/api/billing/checkout?cmux_external_browser=1"
)
),
sourceURL: trustedSource
@@ -44,6 +47,14 @@ struct BrowserAppWebPolicyTests {
),
sourceURL: trustedSource
))
+ #expect(!policy.shouldOpenInSystemBrowser(
+ try #require(
+ URL(
+ string: "https://billing.example/other?cmux_external_browser=1"
+ )
+ ),
+ sourceURL: trustedSource
+ ))
#expect(!policy.shouldOpenInSystemBrowser(
try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser")),
sourceURL: trustedSource
diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift
index 07916bf21525..2689fa56ea18 100644
--- a/Sources/Panels/BrowserNavigationDelegate.swift
+++ b/Sources/Panels/BrowserNavigationDelegate.swift
@@ -7,7 +7,8 @@ import WebKit
enum PolicyCancellationKind { case terminal(restoreAttemptID: UUID?) }
private let subframeDownloadIntents = BrowserSubframeDownloadIntentTracker()
private let externalNavigationPolicy = BrowserExternalNavigationPolicy(
- trustedOrigin: AuthEnvironment.appWebOrigin
+ trustedOrigin: AuthEnvironment.appWebOrigin,
+ billingCheckoutURL: AuthEnvironment.billingCheckoutURL
)
private var shouldPrintAfterCurrentNavigationFinishes = false
var didStartProvisionalNavigation: ((WKWebView, WKNavigation?) -> Void)?
From 94fb05d5762b914d48adf122fcb23124c7b3e25b Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 17:56:39 -0700
Subject: [PATCH 27/50] test: require same-origin app checkout relay
---
web/tests/billing-checkout-route.test.ts | 26 ++++++++++++++++++++++++
web/tests/billing-links.test.ts | 4 ++--
2 files changed, 28 insertions(+), 2 deletions(-)
diff --git a/web/tests/billing-checkout-route.test.ts b/web/tests/billing-checkout-route.test.ts
index 2e00f6c8a2ca..d49e6f72f204 100644
--- a/web/tests/billing-checkout-route.test.ts
+++ b/web/tests/billing-checkout-route.test.ts
@@ -187,6 +187,32 @@ describe("billing checkout route", () => {
expect(createStripeSession).not.toHaveBeenCalled();
});
+ test("relays an app-pricing checkout through the server-configured destination", async () => {
+ const previous = process.env.CMUX_APP_PRICING_CHECKOUT_URL;
+ process.env.CMUX_APP_PRICING_CHECKOUT_URL =
+ "https://billing.example/checkout?campaign=annual";
+ try {
+ const response = await GET(
+ new NextRequest(
+ "https://cmux.test/api/billing/checkout?plan=team&interval=year&cmux_scheme=cmux-dev-test&cmux_app_checkout=1",
+ ),
+ );
+
+ expect(response.status).toBe(307);
+ expect(response.headers.get("location")).toBe(
+ "https://billing.example/checkout?campaign=annual&plan=team&interval=year&cmux_scheme=cmux-dev-test",
+ );
+ expect(getUser).not.toHaveBeenCalled();
+ expect(createStripeSession).not.toHaveBeenCalled();
+ } finally {
+ if (previous === undefined) {
+ delete process.env.CMUX_APP_PRICING_CHECKOUT_URL;
+ } else {
+ process.env.CMUX_APP_PRICING_CHECKOUT_URL = previous;
+ }
+ }
+ });
+
test("creates Stripe checkout for anonymous Pro visitors when configured", async () => {
stripeConfigured = true;
userResponses = [null, anonymousUser];
diff --git a/web/tests/billing-links.test.ts b/web/tests/billing-links.test.ts
index 8df944320de3..75b0c087bbfe 100644
--- a/web/tests/billing-links.test.ts
+++ b/web/tests/billing-links.test.ts
@@ -63,12 +63,12 @@ describe("billing links", () => {
);
});
- test("app pricing checkout keeps an explicit origin override", () => {
+ test("app pricing checkout relays an explicit override through its trusted origin", () => {
const previous = process.env.CMUX_APP_PRICING_CHECKOUT_URL;
process.env.CMUX_APP_PRICING_CHECKOUT_URL = "https://billing.example/checkout";
try {
expect(appPricingCheckoutURL("pro", "http://localhost:9210")).toBe(
- "https://billing.example/checkout?plan=pro&cmux_external_browser=1",
+ "http://localhost:9210/api/billing/checkout?plan=pro&cmux_external_browser=1&cmux_app_checkout=1",
);
} finally {
if (previous === undefined) {
From bd3347efef8b33f29bacdf4c7ed854b403742061 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 17:57:58 -0700
Subject: [PATCH 28/50] fix: relay app checkout through trusted origin
---
.../BrowserExternalNavigationPolicy.swift | 41 ++--------------
.../AppSession/BrowserAppWebPolicyTests.swift | 15 +-----
.../Panels/BrowserNavigationDelegate.swift | 3 +-
web/app/api/billing/checkout/route.ts | 6 +++
web/app/lib/billing.ts | 47 +++++++++++++++++--
5 files changed, 55 insertions(+), 57 deletions(-)
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
index 658c6d1a896a..1b4cf48ca025 100644
--- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
@@ -3,15 +3,10 @@ public import Foundation
/// Decides whether a trusted app-web surface explicitly requests the system browser.
public struct BrowserExternalNavigationPolicy: Equatable, Sendable {
public let trustedOrigin: URL
- public let billingCheckoutURL: URL?
- /// Creates a policy pinned to the app-web origin and optional billing checkout URL.
- public init(
- trustedOrigin: URL,
- billingCheckoutURL: URL? = nil
- ) {
+ /// Creates a policy pinned to the exact app-web origin.
+ public init(trustedOrigin: URL) {
self.trustedOrigin = trustedOrigin
- self.billingCheckoutURL = billingCheckoutURL
}
/// Returns true only when a trusted app-web source activates a safe web URL
@@ -34,36 +29,6 @@ public struct BrowserExternalNavigationPolicy: Equatable, Sendable {
}
private func isSafeWebDestination(_ url: URL) -> Bool {
- if BrowserAppWebOrigin(trustedOrigin).contains(url) {
- return true
- }
- guard let billingCheckoutURL,
- isSecureBillingURL(billingCheckoutURL),
- url.user == nil,
- url.password == nil,
- BrowserAppWebOrigin(billingCheckoutURL).contains(url) else {
- return false
- }
- return normalizedPath(url) == normalizedPath(billingCheckoutURL)
- }
-
- private func isSecureBillingURL(_ url: URL) -> Bool {
- guard url.user == nil, url.password == nil else { return false }
- if url.scheme?.lowercased() == "https" {
- return url.host != nil
- }
- guard url.scheme?.lowercased() == "http",
- let host = url.host?.lowercased() else {
- return false
- }
- return host == "localhost"
- || host == "127.0.0.1"
- || host == "::1"
- }
-
- private func normalizedPath(_ url: URL) -> String {
- url.path.count > 1 && url.path.hasSuffix("/")
- ? String(url.path.dropLast())
- : url.path
+ BrowserAppWebOrigin(trustedOrigin).contains(url)
}
}
diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
index a8b889344f94..4c21fd9b88a7 100644
--- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
+++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
@@ -8,10 +8,7 @@ struct BrowserAppWebPolicyTests {
@Test("external intent requires the trusted origin and an enabled marker")
func externalIntentRequiresTrustedOriginAndEnabledMarker() throws {
let policy = BrowserExternalNavigationPolicy(
- trustedOrigin: try #require(URL(string: "https://cmux.com")),
- billingCheckoutURL: URL(
- string: "https://billing.example/api/billing/checkout"
- )
+ trustedOrigin: try #require(URL(string: "https://cmux.com"))
)
let trustedSource = try #require(
URL(string: "https://cmux.com/app-pricing")
@@ -31,7 +28,7 @@ struct BrowserAppWebPolicyTests {
try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=1")),
sourceURL: trustedSource
))
- #expect(policy.shouldOpenInSystemBrowser(
+ #expect(!policy.shouldOpenInSystemBrowser(
try #require(
URL(
string: "https://billing.example/api/billing/checkout?cmux_external_browser=1"
@@ -47,14 +44,6 @@ struct BrowserAppWebPolicyTests {
),
sourceURL: trustedSource
))
- #expect(!policy.shouldOpenInSystemBrowser(
- try #require(
- URL(
- string: "https://billing.example/other?cmux_external_browser=1"
- )
- ),
- sourceURL: trustedSource
- ))
#expect(!policy.shouldOpenInSystemBrowser(
try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser")),
sourceURL: trustedSource
diff --git a/Sources/Panels/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift
index 2689fa56ea18..07916bf21525 100644
--- a/Sources/Panels/BrowserNavigationDelegate.swift
+++ b/Sources/Panels/BrowserNavigationDelegate.swift
@@ -7,8 +7,7 @@ import WebKit
enum PolicyCancellationKind { case terminal(restoreAttemptID: UUID?) }
private let subframeDownloadIntents = BrowserSubframeDownloadIntentTracker()
private let externalNavigationPolicy = BrowserExternalNavigationPolicy(
- trustedOrigin: AuthEnvironment.appWebOrigin,
- billingCheckoutURL: AuthEnvironment.billingCheckoutURL
+ trustedOrigin: AuthEnvironment.appWebOrigin
)
private var shouldPrintAfterCurrentNavigationFinishes = false
var didStartProvisionalNavigation: ((WKWebView, WKNavigation?) -> Void)?
diff --git a/web/app/api/billing/checkout/route.ts b/web/app/api/billing/checkout/route.ts
index 52e021cc2d9a..f1dc56360c11 100644
--- a/web/app/api/billing/checkout/route.ts
+++ b/web/app/api/billing/checkout/route.ts
@@ -4,6 +4,7 @@ import { eq } from "drizzle-orm";
import { validatedNativeCallbackScheme } from "../../../lib/native-callback";
import {
+ appPricingCheckoutRelayURL,
appStorePricingUnavailableURL,
isAppStoreDistributionMode,
} from "../../../lib/billing";
@@ -54,6 +55,11 @@ async function resolveCheckout(request: NextRequest): Promise {
return NextResponse.redirect(appStorePricingUnavailableURL(request.nextUrl));
}
+ const configuredRelayURL = appPricingCheckoutRelayURL(request.nextUrl);
+ if (configuredRelayURL) {
+ return NextResponse.redirect(configuredRelayURL);
+ }
+
const stackServerApp = await checkoutStackServerApp();
if (!stackServerApp) {
return NextResponse.redirect(new URL("/pricing?billing=unavailable", request.url));
diff --git a/web/app/lib/billing.ts b/web/app/lib/billing.ts
index 2c48c977aed2..4c9f523381d1 100644
--- a/web/app/lib/billing.ts
+++ b/web/app/lib/billing.ts
@@ -3,6 +3,7 @@ export const CHECKOUT_EXTERNAL_BROWSER_PARAM = EXTERNAL_BROWSER_PARAM;
export const CHECKOUT_NATIVE_SCHEME_PARAM = "cmux_scheme";
export const CHECKOUT_PLAN_PARAM = "plan";
export const CHECKOUT_INTERVAL_PARAM = "interval";
+export const CHECKOUT_APP_RELAY_PARAM = "cmux_app_checkout";
export const CHECKOUT_PATH = "/api/billing/checkout";
export type CheckoutPlan = "pro" | "team";
export type CheckoutInterval = "month" | "year";
@@ -39,13 +40,34 @@ export function appPricingCheckoutURL(
interval?: CheckoutInterval,
): string {
let href = withExternalBrowserIntent(
- withCheckoutPlan(configuredAppPricingCheckoutURL(requestOrigin), plan),
+ withCheckoutPlan(appPricingCheckoutEntryURL(requestOrigin), plan),
);
+ if (configuredAppPricingCheckoutURL()) {
+ href = withSearchParam(href, CHECKOUT_APP_RELAY_PARAM, "1");
+ }
if (cmuxScheme) href = withSearchParam(href, CHECKOUT_NATIVE_SCHEME_PARAM, cmuxScheme);
if (interval) href = withCheckoutInterval(href, interval);
return href;
}
+export function appPricingCheckoutRelayURL(requestURL: URL): URL | null {
+ if (requestURL.searchParams.get(CHECKOUT_APP_RELAY_PARAM) !== "1") {
+ return null;
+ }
+ const target = configuredAppPricingCheckoutURL();
+ if (!target) return null;
+
+ for (const key of [
+ CHECKOUT_PLAN_PARAM,
+ CHECKOUT_INTERVAL_PARAM,
+ CHECKOUT_NATIVE_SCHEME_PARAM,
+ ]) {
+ const value = requestURL.searchParams.get(key);
+ if (value) target.searchParams.set(key, value);
+ }
+ return target;
+}
+
export function isAppStoreDistributionMode(params: {
cmux_distribution?: SearchParamValue;
cmux_ios_app_store?: SearchParamValue;
@@ -85,9 +107,7 @@ function firstSearchParam(value: SearchParamValue): string | null {
return value ?? null;
}
-function configuredAppPricingCheckoutURL(requestOrigin: string | null): string {
- const configured = process.env.CMUX_APP_PRICING_CHECKOUT_URL?.trim();
- if (configured && configured.length > 0) return configured;
+function appPricingCheckoutEntryURL(requestOrigin: string | null): string {
if (requestOrigin) {
try {
return new URL(CHECKOUT_PATH, requestOrigin).toString();
@@ -97,3 +117,22 @@ function configuredAppPricingCheckoutURL(requestOrigin: string | null): string {
}
return DEFAULT_APP_PRICING_CHECKOUT_URL;
}
+
+function configuredAppPricingCheckoutURL(): URL | null {
+ const configured = process.env.CMUX_APP_PRICING_CHECKOUT_URL?.trim();
+ if (!configured) return null;
+ try {
+ const target = new URL(configured);
+ if (target.username || target.password || !target.hostname) return null;
+ if (target.protocol === "https:") return target;
+ if (
+ target.protocol === "http:" &&
+ ["localhost", "127.0.0.1", "[::1]"].includes(target.hostname.toLowerCase())
+ ) {
+ return target;
+ }
+ } catch {
+ return null;
+ }
+ return null;
+}
From 6313f5d7979c49621a497a922fad2969e38e2a45 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:00:47 -0700
Subject: [PATCH 29/50] test: cover app origin and Stripe pagination guards
---
.../AppSession/BrowserAppWebPolicyTests.swift | 27 ++++
cmuxTests/SidebarWidthPolicyTests.swift | 10 ++
web/tests/stripe-provision-catalog.test.ts | 122 ++++++++++++++++--
3 files changed, 145 insertions(+), 14 deletions(-)
diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
index 4c21fd9b88a7..0d3dd9cfa891 100644
--- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
+++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
@@ -88,6 +88,33 @@ struct BrowserAppWebPolicyTests {
))
}
+ @Test("external intent rejects insecure and non-web origins")
+ func externalIntentRejectsInsecureAndNonWebOrigins() throws {
+ let insecurePolicy = BrowserExternalNavigationPolicy(
+ trustedOrigin: try #require(URL(string: "http://cmux.test"))
+ )
+ #expect(!insecurePolicy.shouldOpenInSystemBrowser(
+ try #require(URL(string: "http://cmux.test/enterprise?cmux_external_browser=1")),
+ sourceURL: URL(string: "http://cmux.test/app-pricing")
+ ))
+
+ let filePolicy = BrowserExternalNavigationPolicy(
+ trustedOrigin: try #require(URL(string: "file:///"))
+ )
+ #expect(!filePolicy.shouldOpenInSystemBrowser(
+ try #require(URL(string: "file:///enterprise?cmux_external_browser=1")),
+ sourceURL: URL(string: "file:///app-pricing")
+ ))
+
+ let securePolicy = BrowserExternalNavigationPolicy(
+ trustedOrigin: try #require(URL(string: "https://cmux.com"))
+ )
+ #expect(!securePolicy.shouldOpenInSystemBrowser(
+ try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=1")),
+ sourceURL: URL(string: "https://user@cmux.com/app-pricing")
+ ))
+ }
+
@Test("theme serializes shared variables and supports only app-web routes")
func themeSerializesSharedVariablesAndSupportsOnlyAppWebRoutes() throws {
let trustedOrigin = try #require(URL(string: "https://cmux.com"))
diff --git a/cmuxTests/SidebarWidthPolicyTests.swift b/cmuxTests/SidebarWidthPolicyTests.swift
index 0e2cb7834881..2bc93107b7e0 100644
--- a/cmuxTests/SidebarWidthPolicyTests.swift
+++ b/cmuxTests/SidebarWidthPolicyTests.swift
@@ -326,6 +326,16 @@ struct AppWebThemeContrastTests {
) >= 4.5
)
}
+
+ @Test
+ func choosesSmallestRGBAdjustmentWhenBothDirectionsAreReadable() throws {
+ let adjusted = AppWebThemeSnapshot.contrastAdjustedAccentNSColor(
+ try #require(NSColor(hex: "#000040")),
+ on: try #require(NSColor(hex: "#8060D0"))
+ )
+
+ #expect(adjusted.hexString() == "#000000")
+ }
}
final class SidebarWorkspaceSelectionColorTests: XCTestCase {
diff --git a/web/tests/stripe-provision-catalog.test.ts b/web/tests/stripe-provision-catalog.test.ts
index 22b0df55116a..2988385a6631 100644
--- a/web/tests/stripe-provision-catalog.test.ts
+++ b/web/tests/stripe-provision-catalog.test.ts
@@ -66,6 +66,44 @@ describe("Stripe catalog provisioning", () => {
expect(
result.calls.some((call) => call.args.includes("product=prod_attacker")),
).toBe(false);
+ expect(
+ result.calls.some(
+ (call) =>
+ call.args.includes("https://api.stripe.com/v1/prices") &&
+ call.args.includes("POST") &&
+ call.args.includes("lookup_key=cmux-pro-yearly-288") &&
+ call.args.includes("unit_amount=28800"),
+ ),
+ ).toBe(true);
+ expect(
+ result.calls.some(
+ (call) =>
+ call.args.includes("https://api.stripe.com/v1/prices") &&
+ call.args.includes("POST") &&
+ call.args.includes("lookup_key=cmux-team-yearly-336") &&
+ call.args.includes("unit_amount=33600"),
+ ),
+ ).toBe(true);
+ });
+
+ test("finds a canonical product on a later search page", async () => {
+ const result = await runProvision("test", "canonical-product-second-page");
+
+ expect(result.exitCode).toBe(0);
+ expect(
+ result.calls.some((call) => call.args.includes("page=products_page_2")),
+ ).toBe(true);
+ expect(
+ result.calls.some(
+ (call) =>
+ call.args.includes("https://api.stripe.com/v1/products") &&
+ call.args.includes("POST") &&
+ call.args.includes("name=cmux Pro"),
+ ),
+ ).toBe(false);
+ expect(
+ result.calls.some((call) => call.args.includes("product=prod_pro")),
+ ).toBe(true);
});
test("rejects a canonical product with mismatched catalog metadata", async () => {
@@ -105,6 +143,29 @@ describe("Stripe catalog provisioning", () => {
).toBe(false);
});
+ test("finds an existing webhook on a later list page", async () => {
+ const result = await runProvision("live", "webhook-second-page");
+
+ expect(result.exitCode).toBe(0);
+ expect(
+ result.calls.some((call) => call.args.includes("starting_after=we_other")),
+ ).toBe(true);
+ expect(
+ result.calls.some((call) =>
+ call.args.includes(
+ "https://api.stripe.com/v1/webhook_endpoints/we_existing",
+ ) && call.args.includes("POST"),
+ ),
+ ).toBe(true);
+ expect(
+ result.calls.some(
+ (call) =>
+ call.args.includes("https://api.stripe.com/v1/webhook_endpoints") &&
+ call.args.includes("POST"),
+ ),
+ ).toBe(false);
+ });
+
test("stores a new webhook secret in a unique mode-600 file", async () => {
const result = await runProvision("live", "create-webhook");
@@ -204,6 +265,8 @@ const valueFor = (prefix) => {
return argument ? argument.slice(prefix.length) : null;
};
const lookupKey = valueFor("lookup_keys[]=");
+const page = valueFor("page=");
+const startingAfter = valueFor("starting_after=");
const expandedProduct = args.includes("expand[]=data.product");
const dataValue = valueFor("name=");
const respond = (value) => {
@@ -260,8 +323,17 @@ const prices = {
if (url.endsWith("/prices") && !isPost) {
const price = prices[lookupKey];
if (
- scenario === "unrelated-product" &&
- lookupKey?.startsWith("cmux-pro")
+ (
+ scenario === "unrelated-product" &&
+ (
+ lookupKey?.startsWith("cmux-pro") ||
+ lookupKey === "cmux-team-yearly-336"
+ )
+ ) ||
+ (
+ scenario === "canonical-product-second-page" &&
+ lookupKey?.startsWith("cmux-pro")
+ )
) {
respond({ data: [] });
} else if (price) {
@@ -294,17 +366,25 @@ if (url.endsWith("/prices") && !isPost) {
respond({ data: [] });
}
} else if (url.endsWith("/products/search")) {
- respond({
- data:
- scenario === "unrelated-product"
- ? [{
- id: "prod_attacker",
- name: "cmux Pro",
- active: true,
- metadata: { app: "other", plan: "pro" },
- }]
- : [],
- });
+ const attacker = {
+ id: "prod_attacker",
+ name: "cmux Pro",
+ active: true,
+ metadata: { app: "other", plan: "pro" },
+ };
+ if (scenario === "canonical-product-second-page" && !page) {
+ respond({ data: [attacker], has_more: true, next_page: "products_page_2" });
+ } else if (
+ scenario === "canonical-product-second-page" &&
+ page === "products_page_2"
+ ) {
+ respond({ data: [products.pro], has_more: false });
+ } else {
+ respond({
+ data: scenario === "unrelated-product" ? [attacker] : [],
+ has_more: false,
+ });
+ }
} else if (url.endsWith("/products") && isPost) {
respond({
id: dataValue === "cmux Pro" ? "prod_new_pro" : "prod_new_team",
@@ -318,9 +398,23 @@ if (url.endsWith("/prices") && !isPost) {
{ id: "we_one", url: "https://cmux.com/api/stripe/webhook", status: "enabled" },
{ id: "we_two", url: "https://cmux.com/api/stripe/webhook", status: "enabled" },
],
+ has_more: false,
+ });
+ } else if (scenario === "webhook-second-page" && !startingAfter) {
+ respond({
+ data: [{ id: "we_other", url: "https://example.com/webhook", status: "enabled" }],
+ has_more: true,
+ });
+ } else if (
+ scenario === "webhook-second-page" &&
+ startingAfter === "we_other"
+ ) {
+ respond({
+ data: [{ id: "we_existing", url: "https://cmux.com/api/stripe/webhook", status: "enabled" }],
+ has_more: false,
});
} else {
- respond({ data: [] });
+ respond({ data: [], has_more: false });
}
} else if (url.endsWith("/webhook_endpoints") && isPost) {
respond({ id: "we_created", secret: "whsec_mock" });
From 86171a861466d9448c99c8bfd9015b2ea38caeeb Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:03:33 -0700
Subject: [PATCH 30/50] fix: close final pricing safety gaps
---
.../AppSession/BrowserAppWebOrigin.swift | 24 +++++-
.../BrowserExternalNavigationPolicy.swift | 5 +-
Sources/ProWelcomeChecklist.swift | 22 ++++--
web/scripts/stripe/provision-catalog.sh | 75 ++++++++++++++-----
4 files changed, 97 insertions(+), 29 deletions(-)
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift
index 1ba441734556..06227d4ed373 100644
--- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift
@@ -8,8 +8,12 @@ struct BrowserAppWebOrigin {
}
func contains(_ candidate: URL) -> Bool {
- guard candidate.scheme?.lowercased() == url.scheme?.lowercased(),
- candidate.host?.lowercased() == url.host?.lowercased() else {
+ guard isSecureOrLoopbackOrigin(url),
+ isSecureOrLoopbackOrigin(candidate),
+ let scheme = url.scheme?.lowercased(),
+ let host = url.host?.lowercased(),
+ candidate.scheme?.lowercased() == scheme,
+ candidate.host?.lowercased() == host else {
return false
}
return effectivePort(candidate) == effectivePort(url)
@@ -26,6 +30,22 @@ struct BrowserAppWebOrigin {
}
}
+private func isSecureOrLoopbackOrigin(_ url: URL) -> Bool {
+ guard url.user == nil,
+ url.password == nil,
+ let host = url.host?.lowercased(),
+ !host.isEmpty else {
+ return false
+ }
+ if url.scheme?.lowercased() == "https" {
+ return true
+ }
+ guard url.scheme?.lowercased() == "http" else { return false }
+ return host == "localhost"
+ || host == "127.0.0.1"
+ || host == "::1"
+}
+
private func effectivePort(_ url: URL) -> Int? {
if let port = url.port { return port }
switch url.scheme?.lowercased() {
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
index 1b4cf48ca025..bb8eb8995cb1 100644
--- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift
@@ -9,9 +9,8 @@ public struct BrowserExternalNavigationPolicy: Equatable, Sendable {
self.trustedOrigin = trustedOrigin
}
- /// Returns true only when a trusted app-web source activates a safe web URL
- /// with an explicit intent marker. The destination may use a separate
- /// configured billing origin.
+ /// Returns true only when a trusted app-web source activates a same-origin
+ /// web URL with an explicit intent marker.
public func shouldOpenInSystemBrowser(
_ url: URL,
sourceURL: URL?
diff --git a/Sources/ProWelcomeChecklist.swift b/Sources/ProWelcomeChecklist.swift
index 2046d3d1586c..12a1c3f33101 100644
--- a/Sources/ProWelcomeChecklist.swift
+++ b/Sources/ProWelcomeChecklist.swift
@@ -92,10 +92,10 @@ struct AppWebThemeSnapshot: Equatable {
)
}
if let candidate = candidates.min(by: {
- if $0.step == $1.step {
+ if $0.distanceSquared == $1.distanceSquared {
return $0.contrast > $1.contrast
}
- return $0.step < $1.step
+ return $0.distanceSquared < $1.distanceSquared
}) {
return candidate.color
}
@@ -143,11 +143,20 @@ struct AppWebThemeSnapshot: Equatable {
blue: mix(blue)
)
}
+
+ func squaredDistance(to other: RGBBytes) -> Int {
+ let redDistance = red - other.red
+ let greenDistance = green - other.green
+ let blueDistance = blue - other.blue
+ return redDistance * redDistance
+ + greenDistance * greenDistance
+ + blueDistance * blueDistance
+ }
}
private struct ContrastAdjustmentCandidate {
let color: NSColor
- let step: Int
+ let distanceSquared: Int
let contrast: CGFloat
}
@@ -158,10 +167,11 @@ struct AppWebThemeSnapshot: Equatable {
minimumContrast: CGFloat
) -> ContrastAdjustmentCandidate? {
for step in 1...255 {
- let color = preferred.mixed(
+ let adjusted = preferred.mixed(
toward: targetComponent,
step: step
- ).color
+ )
+ let color = adjusted.color
let contrast = cmuxContrastRatio(
foreground: color,
background: background
@@ -169,7 +179,7 @@ struct AppWebThemeSnapshot: Equatable {
if contrast >= minimumContrast {
return ContrastAdjustmentCandidate(
color: color,
- step: step,
+ distanceSquared: preferred.squaredDistance(to: adjusted),
contrast: contrast
)
}
diff --git a/web/scripts/stripe/provision-catalog.sh b/web/scripts/stripe/provision-catalog.sh
index e181c79e7318..cb4f22d1e7c3 100755
--- a/web/scripts/stripe/provision-catalog.sh
+++ b/web/scripts/stripe/provision-catalog.sh
@@ -101,19 +101,36 @@ product_matches_catalog_identity() {
ensure_product() {
local name="$1"
local plan="$2"
- local response product_json product_id
+ local response product_json product_id next_page
local -a matching_product_ids=()
+ local -a page_args=()
- response="$(
- stripe_get "/products/search" \
- --data-urlencode "query=name:'${name}' AND active:'true'" \
- --data-urlencode "limit=100"
- )"
- while IFS= read -r product_json; do
- if product_matches_catalog_identity "$product_json" "$name" "$plan"; then
- matching_product_ids+=("$(jq -er '.id' <<<"$product_json")")
+ next_page=""
+ while :; do
+ page_args=()
+ if [[ -n "$next_page" ]]; then
+ page_args+=(--data-urlencode "page=${next_page}")
+ fi
+ response="$(
+ stripe_get "/products/search" \
+ --data-urlencode "query=name:'${name}' AND active:'true'" \
+ --data-urlencode "limit=100" \
+ "${page_args[@]}"
+ )"
+ while IFS= read -r product_json; do
+ if product_matches_catalog_identity "$product_json" "$name" "$plan"; then
+ matching_product_ids+=("$(jq -er '.id' <<<"$product_json")")
+ fi
+ done < <(jq -c '.data[]' <<<"$response")
+
+ if [[ "$(jq -r '.has_more // false' <<<"$response")" != "true" ]]; then
+ break
fi
- done < <(jq -c '.data[]' <<<"$response")
+ next_page="$(
+ jq -er '.next_page | select(type == "string" and length > 0)' \
+ <<<"$response"
+ )"
+ done
if (( ${#matching_product_ids[@]} > 1 )); then
echo "Multiple canonical products found for ${name}" >&2
@@ -228,14 +245,36 @@ ensure_price "$team_product_id" "cmux-team-monthly" "3500" "month" "cmux Team Mo
ensure_price "$team_product_id" "cmux-team-yearly-336" "33600" "year" "cmux Team Yearly"
if [[ "$MODE" == "live" ]]; then
- webhooks_response="$(stripe_get "/webhook_endpoints" --data-urlencode "limit=100")"
- webhook_ids="$(
- jq -r --arg url "$WEBHOOK_URL" '
- .data[]
- | select(.url == $url and .status == "enabled")
- | .id
- ' <<<"$webhooks_response"
- )"
+ webhook_ids=""
+ starting_after=""
+ while :; do
+ webhook_page_args=()
+ if [[ -n "$starting_after" ]]; then
+ webhook_page_args+=(--data-urlencode "starting_after=${starting_after}")
+ fi
+ webhooks_response="$(
+ stripe_get "/webhook_endpoints" \
+ --data-urlencode "limit=100" \
+ "${webhook_page_args[@]}"
+ )"
+ page_webhook_ids="$(
+ jq -r --arg url "$WEBHOOK_URL" '
+ .data[]
+ | select(.url == $url and .status == "enabled")
+ | .id
+ ' <<<"$webhooks_response"
+ )"
+ if [[ -n "$page_webhook_ids" ]]; then
+ webhook_ids+="${webhook_ids:+$'\n'}${page_webhook_ids}"
+ fi
+ if [[ "$(jq -r '.has_more // false' <<<"$webhooks_response")" != "true" ]]; then
+ break
+ fi
+ starting_after="$(
+ jq -er '.data[-1].id | select(type == "string" and length > 0)' \
+ <<<"$webhooks_response"
+ )"
+ done
webhook_count="$(awk 'NF { count += 1 } END { print count + 0 }' <<<"$webhook_ids")"
if (( webhook_count > 1 )); then
echo "Multiple enabled webhook endpoints found for ${WEBHOOK_URL}" >&2
From 16288c4c1dfc52f37407230c6e014e49a713945a Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:15:55 -0700
Subject: [PATCH 31/50] test: reject invalid pricing relay parameters
---
web/tests/billing-checkout-route.test.ts | 28 +++++++++++++++++++++++-
1 file changed, 27 insertions(+), 1 deletion(-)
diff --git a/web/tests/billing-checkout-route.test.ts b/web/tests/billing-checkout-route.test.ts
index d49e6f72f204..c4895d49d2b6 100644
--- a/web/tests/billing-checkout-route.test.ts
+++ b/web/tests/billing-checkout-route.test.ts
@@ -200,7 +200,33 @@ describe("billing checkout route", () => {
expect(response.status).toBe(307);
expect(response.headers.get("location")).toBe(
- "https://billing.example/checkout?campaign=annual&plan=team&interval=year&cmux_scheme=cmux-dev-test",
+ "https://billing.example/checkout?campaign=annual&plan=team&interval=year&cmux_scheme=cmux",
+ );
+ expect(getUser).not.toHaveBeenCalled();
+ expect(createStripeSession).not.toHaveBeenCalled();
+ } finally {
+ if (previous === undefined) {
+ delete process.env.CMUX_APP_PRICING_CHECKOUT_URL;
+ } else {
+ process.env.CMUX_APP_PRICING_CHECKOUT_URL = previous;
+ }
+ }
+ });
+
+ test("rejects invalid app-pricing relay parameters before forwarding", async () => {
+ const previous = process.env.CMUX_APP_PRICING_CHECKOUT_URL;
+ process.env.CMUX_APP_PRICING_CHECKOUT_URL =
+ "https://billing.example/checkout";
+ try {
+ const response = await GET(
+ new NextRequest(
+ "https://cmux.test/api/billing/checkout?plan=enterprise&interval=forever&cmux_scheme=https&cmux_app_checkout=1",
+ ),
+ );
+
+ expect(response.status).toBe(307);
+ expect(response.headers.get("location")).toBe(
+ "https://cmux.test/pricing?billing=invalid_plan",
);
expect(getUser).not.toHaveBeenCalled();
expect(createStripeSession).not.toHaveBeenCalled();
From b80286e7e6d97c4142e4f5776e53b640430ab2f7 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:16:33 -0700
Subject: [PATCH 32/50] fix: validate app pricing relay parameters
---
web/app/api/billing/checkout/route.ts | 11 ++++++++++-
web/app/lib/billing.ts | 22 +++++++++++++---------
web/scripts/stripe/provision-catalog.sh | 6 +++---
3 files changed, 26 insertions(+), 13 deletions(-)
diff --git a/web/app/api/billing/checkout/route.ts b/web/app/api/billing/checkout/route.ts
index f1dc56360c11..674d5383896a 100644
--- a/web/app/api/billing/checkout/route.ts
+++ b/web/app/api/billing/checkout/route.ts
@@ -55,7 +55,16 @@ async function resolveCheckout(request: NextRequest): Promise {
return NextResponse.redirect(appStorePricingUnavailableURL(request.nextUrl));
}
- const configuredRelayURL = appPricingCheckoutRelayURL(request.nextUrl);
+ const configuredRelayURL = appPricingCheckoutRelayURL(request.nextUrl, {
+ plan: checkoutPlan(request.nextUrl.searchParams.get("plan")),
+ interval: checkoutBillingInterval(
+ request.nextUrl.searchParams.get("interval"),
+ ),
+ cmuxScheme: validatedNativeCallbackScheme(
+ request.nextUrl.searchParams.get("cmux_scheme"),
+ request,
+ ),
+ });
if (configuredRelayURL) {
return NextResponse.redirect(configuredRelayURL);
}
diff --git a/web/app/lib/billing.ts b/web/app/lib/billing.ts
index 4c9f523381d1..28ea7c298b50 100644
--- a/web/app/lib/billing.ts
+++ b/web/app/lib/billing.ts
@@ -7,6 +7,11 @@ export const CHECKOUT_APP_RELAY_PARAM = "cmux_app_checkout";
export const CHECKOUT_PATH = "/api/billing/checkout";
export type CheckoutPlan = "pro" | "team";
export type CheckoutInterval = "month" | "year";
+export type AppPricingCheckoutRelayParameters = {
+ plan: CheckoutPlan | null;
+ interval: CheckoutInterval | null;
+ cmuxScheme: string;
+};
export const PRO_CHECKOUT_PATH = withCheckoutPlan(CHECKOUT_PATH, "pro");
export const TEAM_CHECKOUT_PATH = withCheckoutPlan(CHECKOUT_PATH, "team");
export const PRO_CHECKOUT_URL = withExternalBrowserIntent(PRO_CHECKOUT_PATH);
@@ -50,21 +55,20 @@ export function appPricingCheckoutURL(
return href;
}
-export function appPricingCheckoutRelayURL(requestURL: URL): URL | null {
+export function appPricingCheckoutRelayURL(
+ requestURL: URL,
+ parameters: AppPricingCheckoutRelayParameters,
+): URL | null {
if (requestURL.searchParams.get(CHECKOUT_APP_RELAY_PARAM) !== "1") {
return null;
}
+ if (!parameters.plan || !parameters.interval) return null;
const target = configuredAppPricingCheckoutURL();
if (!target) return null;
- for (const key of [
- CHECKOUT_PLAN_PARAM,
- CHECKOUT_INTERVAL_PARAM,
- CHECKOUT_NATIVE_SCHEME_PARAM,
- ]) {
- const value = requestURL.searchParams.get(key);
- if (value) target.searchParams.set(key, value);
- }
+ target.searchParams.set(CHECKOUT_PLAN_PARAM, parameters.plan);
+ target.searchParams.set(CHECKOUT_INTERVAL_PARAM, parameters.interval);
+ target.searchParams.set(CHECKOUT_NATIVE_SCHEME_PARAM, parameters.cmuxScheme);
return target;
}
diff --git a/web/scripts/stripe/provision-catalog.sh b/web/scripts/stripe/provision-catalog.sh
index cb4f22d1e7c3..84e54010bd13 100755
--- a/web/scripts/stripe/provision-catalog.sh
+++ b/web/scripts/stripe/provision-catalog.sh
@@ -143,9 +143,9 @@ ensure_product() {
else
response="$(
stripe_post "/products" \
- -d "name=${name}" \
- -d "metadata[app]=cmux" \
- -d "metadata[plan]=${plan}"
+ --data-urlencode "name=${name}" \
+ --data-urlencode "metadata[app]=cmux" \
+ --data-urlencode "metadata[plan]=${plan}"
)"
product_id="$(jq -er '.id' <<<"$response")"
echo "Created product ${name}: ${product_id}" >&2
From eb336023330a63f2a4c3cf2bf30d18dd40ea3757 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:21:13 -0700
Subject: [PATCH 33/50] test: cover app web loopback aliases
---
.../AppSession/BrowserAppWebPolicyTests.swift | 27 ++++++++++++-------
1 file changed, 17 insertions(+), 10 deletions(-)
diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
index 0d3dd9cfa891..0d4a96680e0b 100644
--- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
+++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
@@ -76,16 +76,23 @@ struct BrowserAppWebPolicyTests {
@Test("external intent supports a loopback development origin")
func externalIntentSupportsLoopbackDevelopmentOrigin() throws {
- let policy = BrowserExternalNavigationPolicy(
- trustedOrigin: try #require(URL(string: "http://localhost:4100"))
- )
- let trustedSource = try #require(
- URL(string: "http://localhost:4100/app-pricing")
- )
- #expect(policy.shouldOpenInSystemBrowser(
- try #require(URL(string: "http://localhost:4100/enterprise?cmux_external_browser=1")),
- sourceURL: trustedSource
- ))
+ for host in ["localhost", "dev.localhost", "127.0.0.10", "::1"] {
+ let authority = host.contains(":") ? "[\(host)]" : host
+ let policy = BrowserExternalNavigationPolicy(
+ trustedOrigin: try #require(URL(string: "http://\(authority):4100"))
+ )
+ let trustedSource = try #require(
+ URL(string: "http://\(authority):4100/app-pricing")
+ )
+ #expect(policy.shouldOpenInSystemBrowser(
+ try #require(
+ URL(
+ string: "http://\(authority):4100/enterprise?cmux_external_browser=1"
+ )
+ ),
+ sourceURL: trustedSource
+ ))
+ }
}
@Test("external intent rejects insecure and non-web origins")
From 65515f894e72b46aeb7c3f2febeac20c03459bf9 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:21:41 -0700
Subject: [PATCH 34/50] fix: share browser loopback host validation
---
.../AppSession/BrowserAppWebOrigin.swift | 4 +--
.../Omnibar/BrowserURLResolver.swift | 27 +++++++++++--------
2 files changed, 17 insertions(+), 14 deletions(-)
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift
index 06227d4ed373..d30c45a53133 100644
--- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift
@@ -41,9 +41,7 @@ private func isSecureOrLoopbackOrigin(_ url: URL) -> Bool {
return true
}
guard url.scheme?.lowercased() == "http" else { return false }
- return host == "localhost"
- || host == "127.0.0.1"
- || host == "::1"
+ return isBrowserLoopbackHost(host)
}
private func effectivePort(_ url: URL) -> Int? {
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Omnibar/BrowserURLResolver.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Omnibar/BrowserURLResolver.swift
index 37554e58a20e..92f8a3f28b6e 100644
--- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Omnibar/BrowserURLResolver.swift
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Omnibar/BrowserURLResolver.swift
@@ -42,10 +42,7 @@ public struct BrowserURLResolver: Sendable {
let lower = trimmed.lowercased()
let bareHost = bareHostCandidate(lower)
- if bareHost == "localhost" ||
- isIPv4Loopback(bareHost) ||
- bareHost == "::1" ||
- (bareHost != ".localhost" && bareHost.hasSuffix(".localhost")) {
+ if isBrowserLoopbackHost(bareHost) {
return URL(string: "http://\(trimmed)")
}
@@ -174,13 +171,6 @@ public struct BrowserURLResolver: Sendable {
return String(lowercasedInput[.. Bool {
- let octets = host.split(separator: ".", omittingEmptySubsequences: false)
- guard octets.count == 4, octets.allSatisfy({ UInt8($0) != nil }) else { return false }
- return octets[0] == "127"
- }
-
private func isDottedHostWithPort(_ input: String, schemeCandidate: String) -> Bool {
guard schemeCandidate.contains(".") else { return false }
guard input.count > schemeCandidate.count else { return false }
@@ -193,3 +183,18 @@ public struct BrowserURLResolver: Sendable {
return rest.isEmpty || rest.first == "/" || rest.first == "?" || rest.first == "#"
}
}
+
+/// Recognizes local development hosts without accepting dotted-host lookalikes.
+func isBrowserLoopbackHost(_ candidate: String) -> Bool {
+ let host = candidate.lowercased()
+ if host == "localhost" || host == "::1" {
+ return true
+ }
+ if host != ".localhost", host.hasSuffix(".localhost") {
+ return true
+ }
+ let octets = host.split(separator: ".", omittingEmptySubsequences: false)
+ return octets.count == 4
+ && octets.allSatisfy { UInt8($0) != nil }
+ && octets[0] == "127"
+}
From 7eb3e3124f2f8ef2335c89a485f0bd077ad9a418 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:26:40 -0700
Subject: [PATCH 35/50] refactor: keep app origin helpers scoped
---
.../AppSession/BrowserAppWebOrigin.swift | 57 ++++++++++++-------
.../Omnibar/BrowserURLResolver.swift | 17 +-----
2 files changed, 37 insertions(+), 37 deletions(-)
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift
index d30c45a53133..599e284f91bf 100644
--- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift
@@ -8,15 +8,15 @@ struct BrowserAppWebOrigin {
}
func contains(_ candidate: URL) -> Bool {
- guard isSecureOrLoopbackOrigin(url),
- isSecureOrLoopbackOrigin(candidate),
+ guard Self.isSecureOrLoopbackOrigin(url),
+ Self.isSecureOrLoopbackOrigin(candidate),
let scheme = url.scheme?.lowercased(),
let host = url.host?.lowercased(),
candidate.scheme?.lowercased() == scheme,
candidate.host?.lowercased() == host else {
return false
}
- return effectivePort(candidate) == effectivePort(url)
+ return Self.effectivePort(candidate) == Self.effectivePort(url)
}
func containsAppSurface(_ candidate: URL) -> Bool {
@@ -28,27 +28,42 @@ struct BrowserAppWebOrigin {
return normalizedPath == "/app-pricing"
|| normalizedPath == "/app-pro-welcome"
}
-}
-private func isSecureOrLoopbackOrigin(_ url: URL) -> Bool {
- guard url.user == nil,
- url.password == nil,
- let host = url.host?.lowercased(),
- !host.isEmpty else {
- return false
+ /// Recognizes local development hosts without accepting dotted-host lookalikes.
+ static func isLoopbackHost(_ candidate: String) -> Bool {
+ let host = candidate.lowercased()
+ if host == "localhost" || host == "::1" {
+ return true
+ }
+ if host != ".localhost", host.hasSuffix(".localhost") {
+ return true
+ }
+ let octets = host.split(separator: ".", omittingEmptySubsequences: false)
+ return octets.count == 4
+ && octets.allSatisfy { UInt8($0) != nil }
+ && octets[0] == "127"
}
- if url.scheme?.lowercased() == "https" {
- return true
+
+ private static func isSecureOrLoopbackOrigin(_ url: URL) -> Bool {
+ guard url.user == nil,
+ url.password == nil,
+ let host = url.host?.lowercased(),
+ !host.isEmpty else {
+ return false
+ }
+ if url.scheme?.lowercased() == "https" {
+ return true
+ }
+ guard url.scheme?.lowercased() == "http" else { return false }
+ return isLoopbackHost(host)
}
- guard url.scheme?.lowercased() == "http" else { return false }
- return isBrowserLoopbackHost(host)
-}
-private func effectivePort(_ url: URL) -> Int? {
- if let port = url.port { return port }
- switch url.scheme?.lowercased() {
- case "http": return 80
- case "https": return 443
- default: return nil
+ private static func effectivePort(_ url: URL) -> Int? {
+ if let port = url.port { return port }
+ switch url.scheme?.lowercased() {
+ case "http": return 80
+ case "https": return 443
+ default: return nil
+ }
}
}
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Omnibar/BrowserURLResolver.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Omnibar/BrowserURLResolver.swift
index 92f8a3f28b6e..6e7f834506fa 100644
--- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Omnibar/BrowserURLResolver.swift
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Omnibar/BrowserURLResolver.swift
@@ -42,7 +42,7 @@ public struct BrowserURLResolver: Sendable {
let lower = trimmed.lowercased()
let bareHost = bareHostCandidate(lower)
- if isBrowserLoopbackHost(bareHost) {
+ if BrowserAppWebOrigin.isLoopbackHost(bareHost) {
return URL(string: "http://\(trimmed)")
}
@@ -183,18 +183,3 @@ public struct BrowserURLResolver: Sendable {
return rest.isEmpty || rest.first == "/" || rest.first == "?" || rest.first == "#"
}
}
-
-/// Recognizes local development hosts without accepting dotted-host lookalikes.
-func isBrowserLoopbackHost(_ candidate: String) -> Bool {
- let host = candidate.lowercased()
- if host == "localhost" || host == "::1" {
- return true
- }
- if host != ".localhost", host.hasSuffix(".localhost") {
- return true
- }
- let octets = host.split(separator: ".", omittingEmptySubsequences: false)
- return octets.count == 4
- && octets.allSatisfy { UInt8($0) != nil }
- && octets[0] == "127"
-}
From ac994209d0466569eea46871b6b50e532da629e6 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:26:40 -0700
Subject: [PATCH 36/50] test: keep unrelated Pagefind coverage unchanged
---
web/tests/vercel-build-inputs.test.ts | 17 +++++++----------
1 file changed, 7 insertions(+), 10 deletions(-)
diff --git a/web/tests/vercel-build-inputs.test.ts b/web/tests/vercel-build-inputs.test.ts
index 7b9e804a2d4b..f75f87963a85 100644
--- a/web/tests/vercel-build-inputs.test.ts
+++ b/web/tests/vercel-build-inputs.test.ts
@@ -19,17 +19,14 @@ test("keeps relay catalog validation inside the Vercel project", () => {
expect(existsSync(resolvedScript)).toBe(true);
});
-test("generates the Pagefind index before Next traces public files", () => {
- for (const scriptName of ["build", "vercel-build"] as const) {
- const buildCommand = packageJSON.scripts[scriptName];
- const pagefindIndex = buildCommand.indexOf(
- "bun tools/build-docs-search.mjs",
- );
- const nextBuildIndex = buildCommand.indexOf("next build");
+test("builds the docs search index before Next captures public assets", () => {
+ const buildCommand = packageJSON.scripts["vercel-build"];
+ const searchIndexPosition = buildCommand.indexOf("build-docs-search.mjs");
+ const nextBuildPosition = buildCommand.indexOf("next build");
- expect(pagefindIndex).toBeGreaterThanOrEqual(0);
- expect(nextBuildIndex).toBeGreaterThan(pagefindIndex);
- }
+ expect(searchIndexPosition).toBeGreaterThanOrEqual(0);
+ expect(nextBuildPosition).toBeGreaterThanOrEqual(0);
+ expect(searchIndexPosition).toBeLessThan(nextBuildPosition);
});
test("includes every dynamically read Open Graph asset in traced route output", () => {
From d060ae5fec82f752ed8d8fc24c7eb5aaf05dfeb3 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:27:23 -0700
Subject: [PATCH 37/50] test: preserve tagged callbacks through pricing relay
---
web/tests/billing-checkout-route.test.ts | 61 ++++++++++++++++++++++++
1 file changed, 61 insertions(+)
diff --git a/web/tests/billing-checkout-route.test.ts b/web/tests/billing-checkout-route.test.ts
index c4895d49d2b6..c3613edee31b 100644
--- a/web/tests/billing-checkout-route.test.ts
+++ b/web/tests/billing-checkout-route.test.ts
@@ -213,6 +213,67 @@ describe("billing checkout route", () => {
}
});
+ test("preserves a validated tagged callback through the configured relay", async () => {
+ const previousURL = process.env.CMUX_APP_PRICING_CHECKOUT_URL;
+ const previousSecret = process.env.CMUX_APP_PRICING_RELAY_SECRET;
+ const previousSchemes = process.env.CMUX_DEV_NATIVE_CALLBACK_SCHEMES;
+ process.env.CMUX_APP_PRICING_CHECKOUT_URL =
+ "https://billing.example/api/billing/checkout";
+ process.env.CMUX_APP_PRICING_RELAY_SECRET =
+ "pricing-relay-test-secret-with-at-least-32-bytes";
+ process.env.CMUX_DEV_NATIVE_CALLBACK_SCHEMES = "cmux-dev-test";
+ try {
+ const relayResponse = await GET(
+ new NextRequest(
+ "http://localhost:4100/api/billing/checkout?plan=pro&interval=year&cmux_scheme=cmux-dev-test&cmux_app_checkout=1",
+ ),
+ );
+ const relayLocation = relayResponse.headers.get("location");
+ expect(relayLocation).toBeString();
+ const relayURL = new URL(relayLocation!);
+ expect(relayURL.origin).toBe("https://billing.example");
+ expect(relayURL.searchParams.get("cmux_scheme")).toBe("cmux-dev-test");
+ expect(relayURL.searchParams.get("cmux_relay_expires")).toMatch(/^\d+$/);
+ expect(relayURL.searchParams.get("cmux_relay_signature")).toMatch(
+ /^[a-f0-9]{64}$/,
+ );
+
+ delete process.env.CMUX_APP_PRICING_CHECKOUT_URL;
+ stripeConfigured = true;
+ userResponses = [null, anonymousUser];
+ const checkoutResponse = await GET(new NextRequest(relayURL));
+
+ expect(checkoutResponse.headers.get("location")).toBe(
+ "https://checkout.stripe.com/c/session",
+ );
+ expect(createdStripeSessions).toHaveLength(1);
+ expect(createdStripeSessions[0]).toMatchObject({
+ metadata: { nativeCallbackScheme: "cmux-dev-test" },
+ subscription_data: {
+ metadata: { nativeCallbackScheme: "cmux-dev-test" },
+ },
+ success_url:
+ "https://billing.example/api/billing/complete?session_id={CHECKOUT_SESSION_ID}&cmux_scheme=cmux-dev-test",
+ });
+ } finally {
+ if (previousURL === undefined) {
+ delete process.env.CMUX_APP_PRICING_CHECKOUT_URL;
+ } else {
+ process.env.CMUX_APP_PRICING_CHECKOUT_URL = previousURL;
+ }
+ if (previousSecret === undefined) {
+ delete process.env.CMUX_APP_PRICING_RELAY_SECRET;
+ } else {
+ process.env.CMUX_APP_PRICING_RELAY_SECRET = previousSecret;
+ }
+ if (previousSchemes === undefined) {
+ delete process.env.CMUX_DEV_NATIVE_CALLBACK_SCHEMES;
+ } else {
+ process.env.CMUX_DEV_NATIVE_CALLBACK_SCHEMES = previousSchemes;
+ }
+ }
+ });
+
test("rejects invalid app-pricing relay parameters before forwarding", async () => {
const previous = process.env.CMUX_APP_PRICING_CHECKOUT_URL;
process.env.CMUX_APP_PRICING_CHECKOUT_URL =
From 27f2fd4b7e08d15eda55bb2a7dab54ec684b0940 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:30:17 -0700
Subject: [PATCH 38/50] fix: preserve trusted tagged pricing callbacks
---
web/.env.example | 6 ++
web/app/api/billing/checkout/route.ts | 54 ++++++----
web/app/api/billing/complete/route.ts | 10 +-
web/app/billing/success/page.tsx | 9 +-
web/app/env.ts | 8 ++
web/app/lib/billing.ts | 129 ++++++++++++++++++++++-
web/app/lib/native-callback.ts | 8 ++
web/tests/billing-checkout-route.test.ts | 14 +++
web/tests/billing-complete-route.test.ts | 24 +++++
web/tests/billing-success-page.test.tsx | 21 ++++
10 files changed, 253 insertions(+), 30 deletions(-)
diff --git a/web/.env.example b/web/.env.example
index 7d91dd32db2c..2f49782f9d26 100644
--- a/web/.env.example
+++ b/web/.env.example
@@ -36,7 +36,13 @@ STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
STRIPE_PRO_MONTHLY_PRICE_ID=
STRIPE_PRO_YEARLY_PRICE_ID=
+STRIPE_PRO_YEARLY_288_PRICE_ID=
STRIPE_TEAM_MONTHLY_PRICE_ID=
+STRIPE_TEAM_YEARLY_PRICE_ID=
+# Optional local app checkout relay. Source and destination must share the
+# 32+ character secret so tagged callback schemes survive the HTTPS handoff.
+CMUX_APP_PRICING_CHECKOUT_URL=
+CMUX_APP_PRICING_RELAY_SECRET=
# App Store Connect API for iOS TestFlight enrollment. Leave empty to show the
# dashboard action as unavailable. ASC_PRIVATE_KEY accepts PEM contents with
diff --git a/web/app/api/billing/checkout/route.ts b/web/app/api/billing/checkout/route.ts
index 674d5383896a..ac386851bbbc 100644
--- a/web/app/api/billing/checkout/route.ts
+++ b/web/app/api/billing/checkout/route.ts
@@ -7,6 +7,7 @@ import {
appPricingCheckoutRelayURL,
appStorePricingUnavailableURL,
isAppStoreDistributionMode,
+ verifiedAppPricingRelayScheme,
} from "../../../lib/billing";
import { cloudDb } from "../../../../db/client";
import { stripeCustomers } from "../../../../db/schema";
@@ -55,15 +56,20 @@ async function resolveCheckout(request: NextRequest): Promise {
return NextResponse.redirect(appStorePricingUnavailableURL(request.nextUrl));
}
- const configuredRelayURL = appPricingCheckoutRelayURL(request.nextUrl, {
- plan: checkoutPlan(request.nextUrl.searchParams.get("plan")),
- interval: checkoutBillingInterval(
- request.nextUrl.searchParams.get("interval"),
- ),
- cmuxScheme: validatedNativeCallbackScheme(
+ const plan = checkoutPlan(request.nextUrl.searchParams.get("plan"));
+ const interval = checkoutBillingInterval(
+ request.nextUrl.searchParams.get("interval"),
+ );
+ const callbackScheme =
+ verifiedAppPricingRelayScheme(request.nextUrl) ??
+ validatedNativeCallbackScheme(
request.nextUrl.searchParams.get("cmux_scheme"),
request,
- ),
+ );
+ const configuredRelayURL = appPricingCheckoutRelayURL(request.nextUrl, {
+ plan,
+ interval,
+ cmuxScheme: callbackScheme,
});
if (configuredRelayURL) {
return NextResponse.redirect(configuredRelayURL);
@@ -74,13 +80,9 @@ async function resolveCheckout(request: NextRequest): Promise {
return NextResponse.redirect(new URL("/pricing?billing=unavailable", request.url));
}
- const plan = checkoutPlan(request.nextUrl.searchParams.get("plan"));
if (!plan) {
return NextResponse.redirect(new URL("/pricing?billing=invalid_plan", request.url));
}
- const interval = checkoutBillingInterval(
- request.nextUrl.searchParams.get("interval"),
- );
if (!interval) {
return NextResponse.redirect(new URL("/pricing?billing=invalid_plan", request.url));
}
@@ -90,10 +92,20 @@ async function resolveCheckout(request: NextRequest): Promise {
}
if (plan === "pro") {
- return stripeProCheckout(request, stackServerApp, interval);
+ return stripeProCheckout(
+ request,
+ stackServerApp,
+ interval,
+ callbackScheme,
+ );
}
if (plan === "team") {
- return stripeTeamCheckout(request, stackServerApp, interval);
+ return stripeTeamCheckout(
+ request,
+ stackServerApp,
+ interval,
+ callbackScheme,
+ );
}
// checkoutPlan only yields "pro" | "team" | null (null handled above); this is
// unreachable but keeps GET returning a NextResponse instead of possibly-undefined.
@@ -104,6 +116,7 @@ async function stripeProCheckout(
request: NextRequest,
stackServerApp: CheckoutStackServerApp,
interval: BillingInterval,
+ callbackScheme: string,
) {
const user =
(await stackServerApp.getUser({ or: "return-null" })) ??
@@ -117,13 +130,9 @@ async function stripeProCheckout(
return NextResponse.redirect(new URL("/pricing?welcome=active", request.url));
}
- const scheme = validatedNativeCallbackScheme(
- request.nextUrl.searchParams.get("cmux_scheme"),
- request,
- );
const successUrl =
`${request.nextUrl.origin}/api/billing/complete` +
- `?session_id={CHECKOUT_SESSION_ID}&cmux_scheme=${encodeURIComponent(scheme)}`;
+ `?session_id={CHECKOUT_SESSION_ID}&cmux_scheme=${encodeURIComponent(callbackScheme)}`;
const cancelUrl = new URL("/pricing?billing=cancelled", request.nextUrl.origin);
cancelUrl.searchParams.set("interval", interval);
const metadata = {
@@ -131,6 +140,7 @@ async function stripeProCheckout(
plan: "pro",
app: "cmux",
billingInterval: interval,
+ nativeCallbackScheme: callbackScheme,
};
try {
@@ -166,6 +176,7 @@ async function stripeTeamCheckout(
request: NextRequest,
stackServerApp: CheckoutStackServerApp,
interval: BillingInterval,
+ callbackScheme: string,
) {
const user =
(await stackServerApp.getUser({ or: "return-null" })) ??
@@ -179,13 +190,9 @@ async function stripeTeamCheckout(
throw new Error("Stack team checkout customer is missing an id");
}
- const scheme = validatedNativeCallbackScheme(
- request.nextUrl.searchParams.get("cmux_scheme"),
- request,
- );
const successUrl =
`${request.nextUrl.origin}/api/billing/complete` +
- `?session_id={CHECKOUT_SESSION_ID}&cmux_scheme=${encodeURIComponent(scheme)}`;
+ `?session_id={CHECKOUT_SESSION_ID}&cmux_scheme=${encodeURIComponent(callbackScheme)}`;
const cancelUrl = new URL("/pricing?billing=cancelled", request.nextUrl.origin);
cancelUrl.searchParams.set("interval", interval);
const metadata = {
@@ -193,6 +200,7 @@ async function stripeTeamCheckout(
plan: "team",
app: "cmux",
billingInterval: interval,
+ nativeCallbackScheme: callbackScheme,
};
try {
diff --git a/web/app/api/billing/complete/route.ts b/web/app/api/billing/complete/route.ts
index 429e07e6d6a9..8fffcae8e682 100644
--- a/web/app/api/billing/complete/route.ts
+++ b/web/app/api/billing/complete/route.ts
@@ -1,7 +1,10 @@
import { NextRequest, NextResponse } from "next/server";
import type Stripe from "stripe";
-import { validatedNativeCallbackScheme } from "../../../lib/native-callback";
+import {
+ trustedNativeCallbackScheme,
+ validatedNativeCallbackScheme,
+} from "../../../lib/native-callback";
import { captureBillingError } from "../../../../services/errors";
import {
isCmuxCheckoutSession,
@@ -47,7 +50,7 @@ export function makeBillingCompleteHandler(
return NextResponse.redirect(new URL("/pricing?billing=error", request.url));
}
- const scheme = validatedNativeCallbackScheme(
+ const requestedScheme = validatedNativeCallbackScheme(
request.nextUrl.searchParams.get("cmux_scheme"),
request,
);
@@ -58,6 +61,9 @@ export function makeBillingCompleteHandler(
if (!isCmuxCheckoutSession(session)) {
return NextResponse.redirect(new URL("/pricing?billing=error", request.url));
}
+ const scheme =
+ trustedNativeCallbackScheme(session.metadata?.nativeCallbackScheme) ??
+ requestedScheme;
if (
session.payment_status === "paid" ||
session.payment_status === "no_payment_required"
diff --git a/web/app/billing/success/page.tsx b/web/app/billing/success/page.tsx
index 96d09c738d08..7925b3412afc 100644
--- a/web/app/billing/success/page.tsx
+++ b/web/app/billing/success/page.tsx
@@ -8,6 +8,7 @@ import type { Locale } from "../../../i18n/routing";
import { locales, routing } from "../../../i18n/routing";
import {
nativeCallbackHrefForScheme,
+ trustedNativeCallbackScheme,
validatedNativeCallbackScheme,
} from "../../lib/native-callback";
import {
@@ -60,7 +61,10 @@ export default async function BillingSuccessPage({
if (!sessionId) redirect("/pricing?billing=error");
const request = requestFromHeaders(requestHeaders, "/billing/success");
- const scheme = validatedNativeCallbackScheme(firstParam(params.cmux_scheme), request);
+ const requestedScheme = validatedNativeCallbackScheme(
+ firstParam(params.cmux_scheme),
+ request,
+ );
let session: Stripe.Checkout.Session;
try {
session = await stripe().checkout.sessions.retrieve(sessionId, {
@@ -73,6 +77,9 @@ export default async function BillingSuccessPage({
});
redirect("/pricing?billing=error");
}
+ const scheme =
+ trustedNativeCallbackScheme(session.metadata?.nativeCallbackScheme) ??
+ requestedScheme;
const subscription = expandedSubscription(session);
let recordedSubscription: Awaited> = null;
try {
diff --git a/web/app/env.ts b/web/app/env.ts
index 834ad6c30851..eede6e582e4f 100644
--- a/web/app/env.ts
+++ b/web/app/env.ts
@@ -178,6 +178,8 @@ export const env = createEnv({
STRIPE_PRO_YEARLY_288_PRICE_ID: z.string().min(1).optional(),
STRIPE_TEAM_MONTHLY_PRICE_ID: z.string().min(1).optional(),
STRIPE_TEAM_YEARLY_PRICE_ID: z.string().min(1).optional(),
+ CMUX_APP_PRICING_CHECKOUT_URL: z.string().url().optional(),
+ CMUX_APP_PRICING_RELAY_SECRET: z.string().min(32).optional(),
// App Store Connect API for server-side TestFlight enrollment. Optional:
// the dashboard shows enrollment unavailable until these credentials are set.
// ASC_PRIVATE_KEY accepts PEM contents with literal "\n" escapes;
@@ -313,6 +315,12 @@ export const env = createEnv({
),
STRIPE_TEAM_MONTHLY_PRICE_ID: trimEnv(process.env.STRIPE_TEAM_MONTHLY_PRICE_ID),
STRIPE_TEAM_YEARLY_PRICE_ID: trimEnv(process.env.STRIPE_TEAM_YEARLY_PRICE_ID),
+ CMUX_APP_PRICING_CHECKOUT_URL: trimEnv(
+ process.env.CMUX_APP_PRICING_CHECKOUT_URL,
+ ),
+ CMUX_APP_PRICING_RELAY_SECRET: trimEnv(
+ process.env.CMUX_APP_PRICING_RELAY_SECRET,
+ ),
ASC_KEY_ID: trimEnv(process.env.ASC_KEY_ID),
ASC_ISSUER_ID: trimEnv(process.env.ASC_ISSUER_ID),
ASC_PRIVATE_KEY: trimEnv(process.env.ASC_PRIVATE_KEY),
diff --git a/web/app/lib/billing.ts b/web/app/lib/billing.ts
index 28ea7c298b50..bbda7662120c 100644
--- a/web/app/lib/billing.ts
+++ b/web/app/lib/billing.ts
@@ -1,9 +1,13 @@
+import { createHmac, timingSafeEqual } from "node:crypto";
+
export const EXTERNAL_BROWSER_PARAM = "cmux_external_browser";
export const CHECKOUT_EXTERNAL_BROWSER_PARAM = EXTERNAL_BROWSER_PARAM;
export const CHECKOUT_NATIVE_SCHEME_PARAM = "cmux_scheme";
export const CHECKOUT_PLAN_PARAM = "plan";
export const CHECKOUT_INTERVAL_PARAM = "interval";
export const CHECKOUT_APP_RELAY_PARAM = "cmux_app_checkout";
+export const CHECKOUT_RELAY_EXPIRES_PARAM = "cmux_relay_expires";
+export const CHECKOUT_RELAY_SIGNATURE_PARAM = "cmux_relay_signature";
export const CHECKOUT_PATH = "/api/billing/checkout";
export type CheckoutPlan = "pro" | "team";
export type CheckoutInterval = "month" | "year";
@@ -18,6 +22,7 @@ export const PRO_CHECKOUT_URL = withExternalBrowserIntent(PRO_CHECKOUT_PATH);
export const TEAM_CHECKOUT_URL = withExternalBrowserIntent(TEAM_CHECKOUT_PATH);
const DEFAULT_APP_PRICING_CHECKOUT_URL = "https://cmux.com/api/billing/checkout";
+const APP_PRICING_RELAY_TTL_SECONDS = 5 * 60;
type SearchParamValue = string | string[] | null | undefined;
@@ -62,16 +67,68 @@ export function appPricingCheckoutRelayURL(
if (requestURL.searchParams.get(CHECKOUT_APP_RELAY_PARAM) !== "1") {
return null;
}
- if (!parameters.plan || !parameters.interval) return null;
+ const { plan, interval, cmuxScheme } = parameters;
+ if (!plan || !interval) return null;
const target = configuredAppPricingCheckoutURL();
if (!target) return null;
- target.searchParams.set(CHECKOUT_PLAN_PARAM, parameters.plan);
- target.searchParams.set(CHECKOUT_INTERVAL_PARAM, parameters.interval);
- target.searchParams.set(CHECKOUT_NATIVE_SCHEME_PARAM, parameters.cmuxScheme);
+ target.searchParams.set(CHECKOUT_PLAN_PARAM, plan);
+ target.searchParams.set(CHECKOUT_INTERVAL_PARAM, interval);
+ const assertion = appPricingRelayAssertion(target, {
+ plan,
+ interval,
+ cmuxScheme,
+ });
+ target.searchParams.set(
+ CHECKOUT_NATIVE_SCHEME_PARAM,
+ assertion?.scheme ?? safeUnassertedRelayScheme(cmuxScheme),
+ );
+ if (assertion) {
+ target.searchParams.set(CHECKOUT_RELAY_EXPIRES_PARAM, assertion.expires);
+ target.searchParams.set(CHECKOUT_RELAY_SIGNATURE_PARAM, assertion.signature);
+ }
return target;
}
+export function verifiedAppPricingRelayScheme(requestURL: URL): string | null {
+ const scheme = requestURL.searchParams
+ .get(CHECKOUT_NATIVE_SCHEME_PARAM)
+ ?.trim()
+ .toLowerCase();
+ if (!scheme || !isProtectedRelayScheme(scheme)) return null;
+ const plan = requestURL.searchParams.get(CHECKOUT_PLAN_PARAM);
+ const interval = requestURL.searchParams.get(CHECKOUT_INTERVAL_PARAM);
+ const expires = requestURL.searchParams.get(CHECKOUT_RELAY_EXPIRES_PARAM);
+ const signature = requestURL.searchParams.get(CHECKOUT_RELAY_SIGNATURE_PARAM);
+ const secret = appPricingRelaySecret();
+ if (
+ (plan !== "pro" && plan !== "team") ||
+ (interval !== "month" && interval !== "year") ||
+ !expires ||
+ !signature ||
+ !secret
+ ) {
+ return null;
+ }
+ const expiresAt = Number(expires);
+ const now = Math.floor(Date.now() / 1000);
+ if (
+ !Number.isSafeInteger(expiresAt) ||
+ expiresAt < now ||
+ expiresAt > now + APP_PRICING_RELAY_TTL_SECONDS
+ ) {
+ return null;
+ }
+ const expected = relaySignature(
+ requestURL,
+ { plan, interval, cmuxScheme: scheme },
+ expires,
+ secret,
+ );
+ if (!constantTimeHexEqual(signature, expected)) return null;
+ return scheme;
+}
+
export function isAppStoreDistributionMode(params: {
cmux_distribution?: SearchParamValue;
cmux_ios_app_store?: SearchParamValue;
@@ -140,3 +197,67 @@ function configuredAppPricingCheckoutURL(): URL | null {
}
return null;
}
+
+function appPricingRelayAssertion(
+ target: URL,
+ parameters: AppPricingCheckoutRelayParameters & {
+ plan: CheckoutPlan;
+ interval: CheckoutInterval;
+ },
+): { scheme: string; expires: string; signature: string } | null {
+ const scheme = parameters.cmuxScheme.trim().toLowerCase();
+ if (!isProtectedRelayScheme(scheme)) return null;
+ const secret = appPricingRelaySecret();
+ if (!secret) return null;
+ const expires = String(
+ Math.floor(Date.now() / 1000) + APP_PRICING_RELAY_TTL_SECONDS,
+ );
+ return {
+ scheme,
+ expires,
+ signature: relaySignature(target, parameters, expires, secret),
+ };
+}
+
+function relaySignature(
+ target: URL,
+ parameters: AppPricingCheckoutRelayParameters & {
+ plan: CheckoutPlan;
+ interval: CheckoutInterval;
+ },
+ expires: string,
+ secret: string,
+): string {
+ const payload = [
+ "cmux-app-pricing-relay-v1",
+ target.origin,
+ target.pathname,
+ parameters.plan,
+ parameters.interval,
+ parameters.cmuxScheme,
+ expires,
+ ].join("\n");
+ return createHmac("sha256", secret).update(payload).digest("hex");
+}
+
+function appPricingRelaySecret(): string | null {
+ const secret = process.env.CMUX_APP_PRICING_RELAY_SECRET?.trim();
+ return secret && secret.length >= 32 ? secret : null;
+}
+
+function safeUnassertedRelayScheme(scheme: string): string {
+ const normalized = scheme.trim().toLowerCase();
+ return isProtectedRelayScheme(normalized) ? "cmux" : normalized;
+}
+
+function isProtectedRelayScheme(scheme: string): boolean {
+ return scheme === "cmux-dev" || /^cmux-dev-[a-z0-9-]+$/.test(scheme);
+}
+
+function constantTimeHexEqual(candidate: string, expected: string): boolean {
+ if (!/^[a-f0-9]{64}$/.test(candidate)) return false;
+ const candidateBytes = Buffer.from(candidate, "hex");
+ const expectedBytes = Buffer.from(expected, "hex");
+ return candidateBytes.length === expectedBytes.length
+ && timingSafeEqual(candidateBytes, expectedBytes);
+}
diff --git a/web/app/lib/native-callback.ts b/web/app/lib/native-callback.ts
index b3196892a021..9394693a7d7a 100644
--- a/web/app/lib/native-callback.ts
+++ b/web/app/lib/native-callback.ts
@@ -18,6 +18,14 @@ export function validatedNativeCallbackScheme(
return DEFAULT_NATIVE_CALLBACK_SCHEME;
}
+export function trustedNativeCallbackScheme(
+ rawScheme: string | null | undefined,
+): string | null {
+ const scheme = rawScheme?.trim().toLowerCase() ?? "";
+ if (NATIVE_SCHEMES.has(scheme) || scheme === "cmux-dev") return scheme;
+ return /^cmux-dev-[a-z0-9-]+$/.test(scheme) ? scheme : null;
+}
+
export function isAllowedNativeReturnTo(
href: string,
request: NextRequest,
diff --git a/web/tests/billing-checkout-route.test.ts b/web/tests/billing-checkout-route.test.ts
index c3613edee31b..2c2bbff949eb 100644
--- a/web/tests/billing-checkout-route.test.ts
+++ b/web/tests/billing-checkout-route.test.ts
@@ -255,6 +255,20 @@ describe("billing checkout route", () => {
success_url:
"https://billing.example/api/billing/complete?session_id={CHECKOUT_SESSION_ID}&cmux_scheme=cmux-dev-test",
});
+
+ createdStripeSessions.length = 0;
+ userResponses = [null, anonymousUser];
+ const signature = relayURL.searchParams.get("cmux_relay_signature")!;
+ relayURL.searchParams.set(
+ "cmux_relay_signature",
+ `${signature[0] === "0" ? "1" : "0"}${signature.slice(1)}`,
+ );
+ await GET(new NextRequest(relayURL));
+ expect(createdStripeSessions[0]).toMatchObject({
+ metadata: { nativeCallbackScheme: "cmux" },
+ success_url:
+ "https://billing.example/api/billing/complete?session_id={CHECKOUT_SESSION_ID}&cmux_scheme=cmux",
+ });
} finally {
if (previousURL === undefined) {
delete process.env.CMUX_APP_PRICING_CHECKOUT_URL;
diff --git a/web/tests/billing-complete-route.test.ts b/web/tests/billing-complete-route.test.ts
index 0355de6b5086..115601a4173f 100644
--- a/web/tests/billing-complete-route.test.ts
+++ b/web/tests/billing-complete-route.test.ts
@@ -60,6 +60,30 @@ describe("billing complete route", () => {
);
});
+ test("uses the callback scheme trusted at checkout on a deployed completion host", async () => {
+ retrievedSession = {
+ id: "cs_123",
+ payment_status: "paid",
+ client_reference_id: "user-1",
+ metadata: {
+ app: "cmux",
+ plan: "pro",
+ nativeCallbackScheme: "cmux-dev-local",
+ },
+ subscription: { id: "sub_1" },
+ customer: { id: "cus_1" },
+ };
+ const response = await GET(
+ new NextRequest(
+ "https://cmux.test/api/billing/complete?session_id=cs_123&cmux_scheme=cmux-dev-local",
+ ),
+ );
+
+ expect(response.headers.get("location")).toBe(
+ "https://cmux.test/billing/success?session_id=cs_123&cmux_scheme=cmux-dev-local",
+ );
+ });
+
test("redirects unpaid sessions to pending pricing state", async () => {
retrievedSession = {
id: "cs_123",
diff --git a/web/tests/billing-success-page.test.tsx b/web/tests/billing-success-page.test.tsx
index cf2f4683c006..1b9237da65e0 100644
--- a/web/tests/billing-success-page.test.tsx
+++ b/web/tests/billing-success-page.test.tsx
@@ -9,9 +9,11 @@ const redirect = mock((href: unknown) => {
throw Object.assign(new Error("redirect"), { href });
});
+let nativeCallbackScheme: string | undefined;
const retrieveSession = mock(async () => ({
customer_details: { email: "buyer@example.com" },
subscription: { status: "active" },
+ metadata: nativeCallbackScheme ? { nativeCallbackScheme } : undefined,
}));
mock.module("next/navigation", () => createNextNavigationMock(redirect));
@@ -98,4 +100,23 @@ describe("billing success page", () => {
expand: ["subscription", "customer"],
});
});
+
+ test("opens the tagged app recorded by the trusted checkout", async () => {
+ nativeCallbackScheme = "cmux-dev-test";
+ try {
+ const element = await BillingSuccessPage({
+ searchParams: Promise.resolve({
+ session_id: "cs_123",
+ cmux_scheme: "cmux-dev-test",
+ }),
+ });
+ const html = renderToStaticMarkup(element);
+
+ expect(html).toContain(
+ "native_app_return_to=cmux-dev-test%3A%2F%2Fauth-callback",
+ );
+ } finally {
+ nativeCallbackScheme = undefined;
+ }
+ });
});
From 7365941aad1e24c1a435322c7e5b3dc5e436dc46 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:36:05 -0700
Subject: [PATCH 39/50] fix: clean up embedded pricing layout
---
web/app/app-pricing/page.tsx | 51 ++++-----------------------
web/app/components/pricing-shared.tsx | 16 ++++++---
web/tests/app-pricing-page.test.tsx | 8 +++--
3 files changed, 23 insertions(+), 52 deletions(-)
diff --git a/web/app/app-pricing/page.tsx b/web/app/app-pricing/page.tsx
index 854e18c867a2..7b30ce90aa6d 100644
--- a/web/app/app-pricing/page.tsx
+++ b/web/app/app-pricing/page.tsx
@@ -129,7 +129,7 @@ export default async function AppPricingPage({
surface="app_pricing"
/>
-
+
}
priceDetail={
@@ -222,7 +222,7 @@ export default async function AppPricingPage({
period={
}
priceDetail={
@@ -275,6 +275,9 @@ export default async function AppPricingPage({
+
+ {pricing.compare.title}
+
{pricing.currentPlan}
- ) : (
-
- {pricing.free.cta}
-
- ),
- pro: snapshot.isPro ? (
- {pricing.currentPlan}
- ) : appStorePaymentGated ? (
- {pricing.billingUnavailable}
- ) : (
-
- {pricing.pro.cta}
-
- ),
- team: appStorePaymentGated ? (
- {pricing.billingUnavailable}
- ) : (
-
- {pricing.team.cta}
-
- ),
- enterprise: appStorePaymentGated ? (
- {pricing.billingUnavailable}
- ) : (
-
- {pricing.enterprise.cta}
-
- ),
- }}
/>
diff --git a/web/app/components/pricing-shared.tsx b/web/app/components/pricing-shared.tsx
index 9bdc969cc9fb..4230609ff6bd 100644
--- a/web/app/components/pricing-shared.tsx
+++ b/web/app/components/pricing-shared.tsx
@@ -81,12 +81,18 @@ export function PlanCard({
{badge ?
{badge}
: null}
{name}
-
-
{price}
- {period ?
{period} : null}
+
+
+ {price}
+ {period ? (
+
+ {period}
+
+ ) : null}
+
+ {priceDetail}
- {priceDetail}
-
{children}
+
{children}
);
}
diff --git a/web/tests/app-pricing-page.test.tsx b/web/tests/app-pricing-page.test.tsx
index 0b2c71997495..76d2cdfc35a9 100644
--- a/web/tests/app-pricing-page.test.tsx
+++ b/web/tests/app-pricing-page.test.tsx
@@ -98,6 +98,10 @@ describe("app pricing page", () => {
);
expect(html).toContain("$35/user/month");
expect(html).toContain('
Includes:
');
+ expect(html).toContain("sm:grid-cols-2 min-[1800px]:grid-cols-4");
+ expect(html.split("api/billing/checkout?plan=pro")).toHaveLength(2);
+ expect(html.split("api/billing/checkout?plan=team")).toHaveLength(2);
+ expect(html).toContain("Compare plans");
expect(html).not.toContain("/api/billing/portal");
});
@@ -117,8 +121,8 @@ describe("app pricing page", () => {
expect(html).toContain("$24");
expect(html).toContain("$28");
- expect(html).toContain("per month billed yearly");
- expect(html).toContain("per user per month billed yearly");
+ expect(html).not.toContain("per month billed yearly");
+ expect(html).not.toContain("per user per month billed yearly");
expect(html).toContain("Billed $288 annually · save 20%");
expect(html).toContain("Billed $336 annually · save 20%");
expect(html).toContain("$28/user/month · $336/user/year");
From 29f60cebf30b401ca1bb028efc8253247fe2dd7b Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:41:18 -0700
Subject: [PATCH 40/50] test: cover annual Team labels without lookup keys
---
web/tests/dashboard-billing-page.test.tsx | 24 +++++++++++++++++++++++
1 file changed, 24 insertions(+)
diff --git a/web/tests/dashboard-billing-page.test.tsx b/web/tests/dashboard-billing-page.test.tsx
index aeb65ec32abc..6e38647ef46b 100644
--- a/web/tests/dashboard-billing-page.test.tsx
+++ b/web/tests/dashboard-billing-page.test.tsx
@@ -221,6 +221,27 @@ describe("dashboard billing page", () => {
expect(await renderBillingPage()).toContain("$336/seat/year");
});
+ test("labels annual Stripe Team subscriptions from checkout metadata", async () => {
+ proUser.selectedTeam = { id: "team-pro", displayName: "Team Pro" };
+ subscriptionResults = [
+ [],
+ [],
+ [
+ stripeSubscriptionRow({
+ cancelAtPeriodEnd: false,
+ plan: "team",
+ scope: "team",
+ seats: 4,
+ lookupKey: "operator-managed-annual-price",
+ billingInterval: "year",
+ }),
+ ],
+ ];
+ customerRows = [{ id: "cus_team" }];
+
+ expect(await renderBillingPage()).toContain("$336/seat/year");
+ });
+
test("renders active Stripe Team for a paid team when no team is selected", async () => {
mockImplementation(proUser.listTeams, async () => [
{ id: "team-free", displayName: "Team Free", clientReadOnlyMetadata: { cmuxPlan: "free" } },
@@ -306,12 +327,14 @@ function stripeSubscriptionRow({
scope = "user",
seats = null,
lookupKey = "cmux-pro-monthly",
+ billingInterval,
}: {
cancelAtPeriodEnd: boolean;
plan?: string;
scope?: string;
seats?: number | null;
lookupKey?: string;
+ billingInterval?: "month" | "year";
}) {
return {
id: "sub_123",
@@ -323,6 +346,7 @@ function stripeSubscriptionRow({
currentPeriodEnd: new Date("2026-12-01T00:00:00Z"),
cancelAtPeriodEnd,
raw: {
+ metadata: billingInterval ? { billingInterval } : {},
items: {
data: [
{
From 880a08d596e3e3758463b8b675c2cef978a03251 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 18:41:36 -0700
Subject: [PATCH 41/50] fix: label annual Team subscriptions from metadata
---
web/app/[locale]/dashboard/billing/page.tsx | 14 +++++++++++++-
1 file changed, 13 insertions(+), 1 deletion(-)
diff --git a/web/app/[locale]/dashboard/billing/page.tsx b/web/app/[locale]/dashboard/billing/page.tsx
index ac0b39a0abde..5babf0c71174 100644
--- a/web/app/[locale]/dashboard/billing/page.tsx
+++ b/web/app/[locale]/dashboard/billing/page.tsx
@@ -474,7 +474,7 @@ function TeamPlan({
? formatBillingDate(subscription.currentPeriodEnd, locale)
: t("dates.unknown");
const seats = String(subscription.seats ?? 1);
- const price = priceLookupKey(subscription) === TEAM_PRICING_USD.year.lookupKey
+ const price = isAnnualTeamSubscription(subscription)
? t("team.annualPrice")
: t("team.price");
@@ -596,6 +596,18 @@ function priceLookupKey(subscription: StripeSubscriptionRow): string | null {
return typeof lookupKey === "string" ? lookupKey : null;
}
+function isAnnualTeamSubscription(subscription: StripeSubscriptionRow): boolean {
+ if (priceLookupKey(subscription) === TEAM_PRICING_USD.year.lookupKey) return true;
+ const raw = subscription.raw;
+ const metadata = raw && typeof raw === "object" ? raw.metadata : null;
+ return Boolean(
+ metadata &&
+ typeof metadata === "object" &&
+ "billingInterval" in metadata &&
+ (metadata as { billingInterval?: unknown }).billingInterval === "year",
+ );
+}
+
function formatBillingDate(date: Date, locale: string): string {
return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(date);
}
From 465ba6cdfc7b7f92b11b71478ea605d4dc463bc7 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 19:29:20 -0700
Subject: [PATCH 42/50] test: cover annual pricing review regressions
---
.../AppSession/BrowserAppWebPolicyTests.swift | 8 +++++
web/tests/after-sign-in-route.test.ts | 34 +++++++++++++++++++
web/tests/billing-checkout-route.test.ts | 14 ++++----
web/tests/billing-success-page.test.tsx | 11 ++++++
web/tests/dashboard-billing-page.test.tsx | 29 ++++++++++++++--
5 files changed, 88 insertions(+), 8 deletions(-)
diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
index 0d4a96680e0b..525a61593737 100644
--- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
+++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
@@ -147,6 +147,10 @@ struct BrowserAppWebPolicyTests {
url: URL(string: "https://cmux.com/app-pricing"),
trustedOrigin: trustedOrigin
))
+ #expect(BrowserAppTheme.supportsAppSurface(
+ url: URL(string: "https://cmux.com/app-pricing"),
+ trustedOrigin: trustedOrigin
+ ))
#expect(theme.supports(
url: URL(string: "https://cmux.com/app-pro-welcome"),
trustedOrigin: trustedOrigin
@@ -163,6 +167,10 @@ struct BrowserAppWebPolicyTests {
url: URL(string: "https://cmux.com/pricing"),
trustedOrigin: trustedOrigin
))
+ #expect(!BrowserAppTheme.supportsAppSurface(
+ url: URL(string: "https://cmux.com/pricing"),
+ trustedOrigin: trustedOrigin
+ ))
#expect(!theme.supports(
url: URL(string: "https://attacker.example/app-pricing"),
trustedOrigin: trustedOrigin
diff --git a/web/tests/after-sign-in-route.test.ts b/web/tests/after-sign-in-route.test.ts
index 1853885ac541..dbb22bba51a6 100644
--- a/web/tests/after-sign-in-route.test.ts
+++ b/web/tests/after-sign-in-route.test.ts
@@ -25,6 +25,7 @@ const signOut = mock((options?: unknown) => {
});
const { makeAfterSignInHandler } = await import("../app/handler/after-sign-in/handler");
+const { appPricingNativeReturnURL } = await import("../app/lib/billing");
const { GET: startNativeSignIn } = await import("../app/handler/native-sign-in/route");
const { makeSignOutAndSignInHandler } = await import("../app/handler/sign-out-and-sign-in/route");
@@ -222,6 +223,39 @@ describe("after sign-in native handoff", () => {
JSON.stringify(["anon-refresh", "anon-access"]),
);
});
+
+ test("accepts only signed tagged purchase callbacks on the deployed host", async () => {
+ const previousSecret = process.env.CMUX_APP_PRICING_RELAY_SECRET;
+ process.env.CMUX_APP_PRICING_RELAY_SECRET =
+ "pricing-relay-test-secret-with-at-least-32-bytes";
+ try {
+ const afterSignIn = appPricingNativeReturnURL(
+ new URL("/handler/after-sign-in", "https://cmux.test"),
+ "cmux-dev-test://auth-callback",
+ "cs_123",
+ );
+ const response = await GET(new NextRequest(afterSignIn));
+
+ expect(response.status).toBe(200);
+ expect(returnHref(await response.text())).toContain(
+ "cmux-dev-test://auth-callback",
+ );
+
+ afterSignIn.searchParams.set(
+ "native_app_return_to",
+ "cmux-dev-other://auth-callback",
+ );
+ const tamperedResponse = await GET(new NextRequest(afterSignIn));
+ expect(tamperedResponse.status).toBe(307);
+ expect(tamperedResponse.headers.get("location")).toBe("https://cmux.test/");
+ } finally {
+ if (previousSecret === undefined) {
+ delete process.env.CMUX_APP_PRICING_RELAY_SECRET;
+ } else {
+ process.env.CMUX_APP_PRICING_RELAY_SECRET = previousSecret;
+ }
+ }
+ });
});
describe("sign out and sign back in", () => {
diff --git a/web/tests/billing-checkout-route.test.ts b/web/tests/billing-checkout-route.test.ts
index 2c2bbff949eb..11fb4d00657b 100644
--- a/web/tests/billing-checkout-route.test.ts
+++ b/web/tests/billing-checkout-route.test.ts
@@ -241,7 +241,10 @@ describe("billing checkout route", () => {
delete process.env.CMUX_APP_PRICING_CHECKOUT_URL;
stripeConfigured = true;
userResponses = [null, anonymousUser];
+ const originalNow = Date.now;
+ Date.now = () => originalNow() - 1_000;
const checkoutResponse = await GET(new NextRequest(relayURL));
+ Date.now = originalNow;
expect(checkoutResponse.headers.get("location")).toBe(
"https://checkout.stripe.com/c/session",
@@ -263,12 +266,11 @@ describe("billing checkout route", () => {
"cmux_relay_signature",
`${signature[0] === "0" ? "1" : "0"}${signature.slice(1)}`,
);
- await GET(new NextRequest(relayURL));
- expect(createdStripeSessions[0]).toMatchObject({
- metadata: { nativeCallbackScheme: "cmux" },
- success_url:
- "https://billing.example/api/billing/complete?session_id={CHECKOUT_SESSION_ID}&cmux_scheme=cmux",
- });
+ const invalidRelayResponse = await GET(new NextRequest(relayURL));
+ expect(invalidRelayResponse.headers.get("location")).toBe(
+ "https://billing.example/pricing?billing=invalid_relay",
+ );
+ expect(createdStripeSessions).toHaveLength(0);
} finally {
if (previousURL === undefined) {
delete process.env.CMUX_APP_PRICING_CHECKOUT_URL;
diff --git a/web/tests/billing-success-page.test.tsx b/web/tests/billing-success-page.test.tsx
index 1b9237da65e0..4fbdd587fa00 100644
--- a/web/tests/billing-success-page.test.tsx
+++ b/web/tests/billing-success-page.test.tsx
@@ -102,6 +102,9 @@ describe("billing success page", () => {
});
test("opens the tagged app recorded by the trusted checkout", async () => {
+ const previousSecret = process.env.CMUX_APP_PRICING_RELAY_SECRET;
+ process.env.CMUX_APP_PRICING_RELAY_SECRET =
+ "pricing-relay-test-secret-with-at-least-32-bytes";
nativeCallbackScheme = "cmux-dev-test";
try {
const element = await BillingSuccessPage({
@@ -115,8 +118,16 @@ describe("billing success page", () => {
expect(html).toContain(
"native_app_return_to=cmux-dev-test%3A%2F%2Fauth-callback",
);
+ expect(html).toContain("cmux_checkout_session=cs_123");
+ expect(html).toContain("cmux_native_return_expires=");
+ expect(html).toMatch(/cmux_native_return_signature=[a-f0-9]{64}/);
} finally {
nativeCallbackScheme = undefined;
+ if (previousSecret === undefined) {
+ delete process.env.CMUX_APP_PRICING_RELAY_SECRET;
+ } else {
+ process.env.CMUX_APP_PRICING_RELAY_SECRET = previousSecret;
+ }
}
});
});
diff --git a/web/tests/dashboard-billing-page.test.tsx b/web/tests/dashboard-billing-page.test.tsx
index 6e38647ef46b..afb6d82d5709 100644
--- a/web/tests/dashboard-billing-page.test.tsx
+++ b/web/tests/dashboard-billing-page.test.tsx
@@ -3,6 +3,7 @@ import { renderToStaticMarkup } from "react-dom/server";
import { stripeCustomers, stripeSubscriptions } from "../db/schema";
import enMessages from "../messages/en.json";
+import jaMessages from "../messages/ja.json";
import { withAccountMutationLeaseSupport } from
"./helpers/account-mutation-db-mock";
@@ -221,7 +222,7 @@ describe("dashboard billing page", () => {
expect(await renderBillingPage()).toContain("$336/seat/year");
});
- test("labels annual Stripe Team subscriptions from checkout metadata", async () => {
+ test("uses the current Stripe price interval over stale checkout metadata", async () => {
proUser.selectedTeam = { id: "team-pro", displayName: "Team Pro" };
subscriptionResults = [
[],
@@ -232,16 +233,37 @@ describe("dashboard billing page", () => {
plan: "team",
scope: "team",
seats: 4,
- lookupKey: "operator-managed-annual-price",
+ lookupKey: "cmux-team-monthly",
billingInterval: "year",
}),
],
];
customerRows = [{ id: "cus_team" }];
+ expect(await renderBillingPage()).toContain("$35/seat/month");
+
+ subscriptionResults = [
+ [],
+ [],
+ [
+ stripeSubscriptionRow({
+ cancelAtPeriodEnd: false,
+ plan: "team",
+ scope: "team",
+ seats: 4,
+ lookupKey: "operator-managed-annual-price",
+ recurringInterval: "year",
+ }),
+ ],
+ ];
expect(await renderBillingPage()).toContain("$336/seat/year");
});
+ test("localizes active annual Pro prices", () => {
+ expect(enMessages.dashboard.billing.pro.annualPrice).toBe("$288/year");
+ expect(jaMessages.dashboard.billing.pro.annualPrice).toBe("$288/年");
+ });
+
test("renders active Stripe Team for a paid team when no team is selected", async () => {
mockImplementation(proUser.listTeams, async () => [
{ id: "team-free", displayName: "Team Free", clientReadOnlyMetadata: { cmuxPlan: "free" } },
@@ -328,6 +350,7 @@ function stripeSubscriptionRow({
seats = null,
lookupKey = "cmux-pro-monthly",
billingInterval,
+ recurringInterval,
}: {
cancelAtPeriodEnd: boolean;
plan?: string;
@@ -335,6 +358,7 @@ function stripeSubscriptionRow({
seats?: number | null;
lookupKey?: string;
billingInterval?: "month" | "year";
+ recurringInterval?: "month" | "year";
}) {
return {
id: "sub_123",
@@ -352,6 +376,7 @@ function stripeSubscriptionRow({
{
price: {
lookup_key: lookupKey,
+ recurring: recurringInterval ? { interval: recurringInterval } : undefined,
},
},
],
From 16e8936bcc47adcb8df0b451d1ffdd36d71b2418 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 19:34:52 -0700
Subject: [PATCH 43/50] fix: close annual pricing review gaps
---
.../AppSession/BrowserAppTheme.swift | 5 +
.../Account/ProUpgradeCard.swift | 2 +-
Resources/Localizable.xcstrings | 4 +-
Sources/Panels/BrowserPanel.swift | 9 ++
.../components/pro-welcome-banner.tsx | 4 +-
web/app/[locale]/dashboard/billing/page.tsx | 50 ++++---
web/app/api/billing/checkout/route.ts | 21 ++-
web/app/app-pricing/page.tsx | 3 +
web/app/billing/success/page.tsx | 8 +-
web/app/handler/after-sign-in/handler.ts | 16 ++-
web/app/lib/billing.ts | 133 ++++++++++++++++--
web/messages/en.json | 6 +-
web/messages/ja.json | 6 +-
web/tests/after-sign-in-route.test.ts | 17 ++-
web/tests/app-pricing-page.test.tsx | 1 +
web/tests/billing-checkout-route.test.ts | 11 +-
16 files changed, 255 insertions(+), 41 deletions(-)
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
index 1065b2fea815..2eb8f56c09a8 100644
--- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
@@ -83,6 +83,11 @@ public struct BrowserAppTheme: Equatable, Sendable {
/// Returns whether the URL is a trusted app-web surface that consumes this theme contract.
public func supports(url: URL?, trustedOrigin: URL) -> Bool {
+ Self.supportsAppSurface(url: url, trustedOrigin: trustedOrigin)
+ }
+
+ /// Checks the app-web URL contract without constructing theme colors.
+ public static func supportsAppSurface(url: URL?, trustedOrigin: URL) -> Bool {
guard let url,
let scheme = url.scheme?.lowercased(),
scheme == "http" || scheme == "https",
diff --git a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift
index e4ebe175d3f2..92096328a1d6 100644
--- a/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift
+++ b/Packages/macOS/CmuxSettingsUI/Sources/CmuxSettingsUI/Account/ProUpgradeCard.swift
@@ -70,7 +70,7 @@ struct ProUpgradeCard: View {
}
return String(
localized: "settings.account.pro.subtitle",
- defaultValue: "Cloud dev boxes, the iOS app, and cmux AI. $30/month, or $240/year."
+ defaultValue: "Cloud dev boxes, the iOS app, and cmux AI. $30/month, or $288/year."
)
}
diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings
index 27cdf80cc6c0..de1806d494b2 100644
--- a/Resources/Localizable.xcstrings
+++ b/Resources/Localizable.xcstrings
@@ -145359,13 +145359,13 @@
"en": {
"stringUnit": {
"state": "translated",
- "value": "Cloud dev boxes, the iOS app, and cmux AI. $30/month, or $240/year."
+ "value": "Cloud dev boxes, the iOS app, and cmux AI. $30/month, or $288/year."
}
},
"ja": {
"stringUnit": {
"state": "translated",
- "value": "クラウド開発マシン、iOSアプリ、cmux AI。月額$30、または年額$240。"
+ "value": "クラウド開発マシン、iOSアプリ、cmux AI。月額$30、または年額$288。"
}
}
}
diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift
index 49c0ab0e4b58..c6e2deb549b4 100644
--- a/Sources/Panels/BrowserPanel.swift
+++ b/Sources/Panels/BrowserPanel.swift
@@ -5244,6 +5244,7 @@ final class BrowserPanel: Panel, ObservableObject {
.sink { [weak self] notification in
guard let self else { return }
self.applyWebViewBackground(color: GhosttyBackgroundTheme.color(from: notification))
+ guard self.supportsAppWebTheme(self.webView) else { return }
self.applyAppWebTheme(AppWebThemeSnapshot.current(notification: notification), to: self.webView)
}
.store(in: &webViewCancellables)
@@ -5261,9 +5262,17 @@ final class BrowserPanel: Panel, ObservableObject {
}
private func applyCurrentAppWebTheme(to webView: WKWebView) {
+ guard supportsAppWebTheme(webView) else { return }
applyAppWebTheme(AppWebThemeSnapshot.current(), to: webView)
}
+ private func supportsAppWebTheme(_ webView: WKWebView) -> Bool {
+ BrowserAppTheme.supportsAppSurface(
+ url: webView.url,
+ trustedOrigin: AuthEnvironment.appWebOrigin
+ )
+ }
+
private func applyAppWebTheme(_ theme: AppWebThemeSnapshot, to webView: WKWebView) {
let browserTheme = theme.browserTheme
guard browserTheme.supports(
diff --git a/web/app/[locale]/components/pro-welcome-banner.tsx b/web/app/[locale]/components/pro-welcome-banner.tsx
index 0a035eb75295..d03d99f45107 100644
--- a/web/app/[locale]/components/pro-welcome-banner.tsx
+++ b/web/app/[locale]/components/pro-welcome-banner.tsx
@@ -32,7 +32,9 @@ export function ProWelcomeBanner() {
? t("billingCancelled")
: billing === "invalid_plan"
? t("billingInvalidPlan")
- : null;
+ : billing === "invalid_relay"
+ ? t("billingInvalidRelay")
+ : null;
if (!message) return null;
return (
diff --git a/web/app/[locale]/dashboard/billing/page.tsx b/web/app/[locale]/dashboard/billing/page.tsx
index 5babf0c71174..a0460b4f0fd8 100644
--- a/web/app/[locale]/dashboard/billing/page.tsx
+++ b/web/app/[locale]/dashboard/billing/page.tsx
@@ -382,7 +382,7 @@ function StripePlan({
subscription: StripeSubscriptionRow;
canManageBilling: boolean;
}) {
- const price = priceCopy(subscription);
+ const price = priceCopy(subscription, t);
const periodDate = subscription.currentPeriodEnd
? formatBillingDate(subscription.currentPeriodEnd, locale)
: t("dates.unknown");
@@ -568,19 +568,26 @@ function billingBanner(value: string | undefined) {
: null;
}
-function priceCopy(subscription: StripeSubscriptionRow): string | null {
+function priceCopy(
+ subscription: StripeSubscriptionRow,
+ t: Awaited
>,
+): string | null {
const lookupKey = priceLookupKey(subscription) ?? subscription.priceId;
if (lookupKey === PRO_PRICING_USD.month.lookupKey) {
- return `$${PRO_PRICING_USD.month.billedAmount}/month`;
+ return t("pro.monthlyPrice");
+ }
+ if (lookupKey === LEGACY_PRO_YEARLY_LOOKUP_KEY) {
+ return t("pro.legacyAnnualPrice");
}
- if (lookupKey === LEGACY_PRO_YEARLY_LOOKUP_KEY) return "$240/year";
if (lookupKey === PRO_PRICING_USD.year.lookupKey) {
- return `$${PRO_PRICING_USD.year.billedAmount}/year`;
+ return t("pro.annualPrice");
}
return null;
}
-function priceLookupKey(subscription: StripeSubscriptionRow): string | null {
+function stripePrice(
+ subscription: StripeSubscriptionRow,
+): Record | null {
const raw = subscription.raw;
const items = raw && typeof raw === "object" ? raw.items : null;
const data = items && typeof items === "object" && "data" in items
@@ -590,22 +597,31 @@ function priceLookupKey(subscription: StripeSubscriptionRow): string | null {
const price = firstItem && typeof firstItem === "object" && "price" in firstItem
? (firstItem as { price?: unknown }).price
: null;
- const lookupKey = price && typeof price === "object" && "lookup_key" in price
- ? (price as { lookup_key?: unknown }).lookup_key
+ return price && typeof price === "object"
+ ? (price as Record)
: null;
+}
+
+function priceLookupKey(subscription: StripeSubscriptionRow): string | null {
+ const lookupKey = stripePrice(subscription)?.lookup_key;
return typeof lookupKey === "string" ? lookupKey : null;
}
+function priceRecurringInterval(
+ subscription: StripeSubscriptionRow,
+): "month" | "year" | null {
+ const recurring = stripePrice(subscription)?.recurring;
+ const interval = recurring && typeof recurring === "object"
+ ? (recurring as { interval?: unknown }).interval
+ : null;
+ return interval === "month" || interval === "year" ? interval : null;
+}
+
function isAnnualTeamSubscription(subscription: StripeSubscriptionRow): boolean {
- if (priceLookupKey(subscription) === TEAM_PRICING_USD.year.lookupKey) return true;
- const raw = subscription.raw;
- const metadata = raw && typeof raw === "object" ? raw.metadata : null;
- return Boolean(
- metadata &&
- typeof metadata === "object" &&
- "billingInterval" in metadata &&
- (metadata as { billingInterval?: unknown }).billingInterval === "year",
- );
+ const lookupKey = priceLookupKey(subscription);
+ if (lookupKey === TEAM_PRICING_USD.year.lookupKey) return true;
+ if (lookupKey === TEAM_PRICING_USD.month.lookupKey) return false;
+ return priceRecurringInterval(subscription) === "year";
}
function formatBillingDate(date: Date, locale: string): string {
diff --git a/web/app/api/billing/checkout/route.ts b/web/app/api/billing/checkout/route.ts
index ac386851bbbc..fdb4b375c62c 100644
--- a/web/app/api/billing/checkout/route.ts
+++ b/web/app/api/billing/checkout/route.ts
@@ -4,8 +4,11 @@ import { eq } from "drizzle-orm";
import { validatedNativeCallbackScheme } from "../../../lib/native-callback";
import {
+ CHECKOUT_RELAY_EXPIRES_PARAM,
+ CHECKOUT_RELAY_SIGNATURE_PARAM,
appPricingCheckoutRelayURL,
appStorePricingUnavailableURL,
+ isProtectedAppPricingRelayScheme,
isAppStoreDistributionMode,
verifiedAppPricingRelayScheme,
} from "../../../lib/billing";
@@ -60,10 +63,24 @@ async function resolveCheckout(request: NextRequest): Promise {
const interval = checkoutBillingInterval(
request.nextUrl.searchParams.get("interval"),
);
+ const rawCallbackScheme = request.nextUrl.searchParams.get("cmux_scheme");
+ const verifiedRelayScheme = verifiedAppPricingRelayScheme(request.nextUrl);
+ const hasRelayAssertion =
+ request.nextUrl.searchParams.has(CHECKOUT_RELAY_EXPIRES_PARAM) ||
+ request.nextUrl.searchParams.has(CHECKOUT_RELAY_SIGNATURE_PARAM);
+ if (
+ isProtectedAppPricingRelayScheme(rawCallbackScheme) &&
+ !verifiedRelayScheme &&
+ hasRelayAssertion
+ ) {
+ return NextResponse.redirect(
+ new URL("/pricing?billing=invalid_relay", request.url),
+ );
+ }
const callbackScheme =
- verifiedAppPricingRelayScheme(request.nextUrl) ??
+ verifiedRelayScheme ??
validatedNativeCallbackScheme(
- request.nextUrl.searchParams.get("cmux_scheme"),
+ rawCallbackScheme,
request,
);
const configuredRelayURL = appPricingCheckoutRelayURL(request.nextUrl, {
diff --git a/web/app/app-pricing/page.tsx b/web/app/app-pricing/page.tsx
index 7b30ce90aa6d..0c652ed7bb5b 100644
--- a/web/app/app-pricing/page.tsx
+++ b/web/app/app-pricing/page.tsx
@@ -407,6 +407,9 @@ function appPricingBanner(
if (billing === "invalid_plan") {
return { message: pricing.billingInvalidPlan };
}
+ if (billing === "invalid_relay") {
+ return { message: pricing.billingInvalidRelay };
+ }
return null;
}
diff --git a/web/app/billing/success/page.tsx b/web/app/billing/success/page.tsx
index 7925b3412afc..011bd303f30e 100644
--- a/web/app/billing/success/page.tsx
+++ b/web/app/billing/success/page.tsx
@@ -11,6 +11,7 @@ import {
trustedNativeCallbackScheme,
validatedNativeCallbackScheme,
} from "../../lib/native-callback";
+import { appPricingNativeReturnURL } from "../../lib/billing";
import {
isActiveStripeSubscriptionStatus,
latestStripeSubscriptionForSession,
@@ -98,8 +99,11 @@ export default async function BillingSuccessPage({
const email = purchaseEmail(session) ?? "";
const { locale, messages } = await billingSuccessMessages(requestHeaders);
- const openCmuxHref = new URL("/handler/after-sign-in", request.nextUrl.origin);
- openCmuxHref.searchParams.set("native_app_return_to", nativeCallbackHrefForScheme(scheme));
+ const openCmuxHref = appPricingNativeReturnURL(
+ new URL("/handler/after-sign-in", request.nextUrl.origin),
+ nativeCallbackHrefForScheme(scheme),
+ sessionId,
+ );
const featureCards: readonly {
key: BillingSuccessFeatureKey;
href: string;
diff --git a/web/app/handler/after-sign-in/handler.ts b/web/app/handler/after-sign-in/handler.ts
index 32b585a92d25..425dc9f4fb9c 100644
--- a/web/app/handler/after-sign-in/handler.ts
+++ b/web/app/handler/after-sign-in/handler.ts
@@ -3,6 +3,10 @@ import {
DEFAULT_NATIVE_CALLBACK_SCHEME,
isAllowedNativeReturnTo,
} from "../../lib/native-callback";
+import {
+ APP_PRICING_NATIVE_RETURN_QUERY_PARAMS,
+ verifiedAppPricingNativeReturnTo,
+} from "../../lib/billing";
import type { Locale } from "../../../i18n/routing";
import { locales, routing } from "../../../i18n/routing";
import {
@@ -280,6 +284,10 @@ function currentAfterSignInPath(request: NextRequest): string {
const afterSignIn = new URL(request.nextUrl.pathname, request.nextUrl.origin);
const nativeReturnTo = request.nextUrl.searchParams.get("native_app_return_to");
if (nativeReturnTo) afterSignIn.searchParams.set("native_app_return_to", nativeReturnTo);
+ for (const name of APP_PRICING_NATIVE_RETURN_QUERY_PARAMS) {
+ const value = request.nextUrl.searchParams.get(name);
+ if (value) afterSignIn.searchParams.set(name, value);
+ }
return `${afterSignIn.pathname}${afterSignIn.search}`;
}
@@ -335,7 +343,13 @@ export function makeAfterSignInHandler(dependencies: AfterSignInHandlerDependenc
accessCookie &&
nativeReturnTo !== null
) {
- if (isAllowedNativeReturnTo(nativeReturnTo, request)) {
+ const trustedPurchaseReturnTo = verifiedAppPricingNativeReturnTo(
+ request.nextUrl,
+ );
+ if (
+ trustedPurchaseReturnTo === nativeReturnTo ||
+ isAllowedNativeReturnTo(nativeReturnTo, request)
+ ) {
const href = buildNativeHref(nativeReturnTo, refreshToken, accessCookie);
if (href) {
if (verifiedNativeHandoff(request, stackCookies, nativeReturnTo)) {
diff --git a/web/app/lib/billing.ts b/web/app/lib/billing.ts
index bbda7662120c..3f2b3b9c4746 100644
--- a/web/app/lib/billing.ts
+++ b/web/app/lib/billing.ts
@@ -8,6 +8,14 @@ export const CHECKOUT_INTERVAL_PARAM = "interval";
export const CHECKOUT_APP_RELAY_PARAM = "cmux_app_checkout";
export const CHECKOUT_RELAY_EXPIRES_PARAM = "cmux_relay_expires";
export const CHECKOUT_RELAY_SIGNATURE_PARAM = "cmux_relay_signature";
+export const CHECKOUT_NATIVE_RETURN_SESSION_PARAM = "cmux_checkout_session";
+export const CHECKOUT_NATIVE_RETURN_EXPIRES_PARAM = "cmux_native_return_expires";
+export const CHECKOUT_NATIVE_RETURN_SIGNATURE_PARAM = "cmux_native_return_signature";
+export const APP_PRICING_NATIVE_RETURN_QUERY_PARAMS = [
+ CHECKOUT_NATIVE_RETURN_SESSION_PARAM,
+ CHECKOUT_NATIVE_RETURN_EXPIRES_PARAM,
+ CHECKOUT_NATIVE_RETURN_SIGNATURE_PARAM,
+] as const;
export const CHECKOUT_PATH = "/api/billing/checkout";
export type CheckoutPlan = "pro" | "team";
export type CheckoutInterval = "month" | "year";
@@ -23,6 +31,7 @@ export const TEAM_CHECKOUT_URL = withExternalBrowserIntent(TEAM_CHECKOUT_PATH);
const DEFAULT_APP_PRICING_CHECKOUT_URL = "https://cmux.com/api/billing/checkout";
const APP_PRICING_RELAY_TTL_SECONDS = 5 * 60;
+const APP_PRICING_RELAY_CLOCK_SKEW_SECONDS = 30;
type SearchParamValue = string | string[] | null | undefined;
@@ -110,15 +119,7 @@ export function verifiedAppPricingRelayScheme(requestURL: URL): string | null {
) {
return null;
}
- const expiresAt = Number(expires);
- const now = Math.floor(Date.now() / 1000);
- if (
- !Number.isSafeInteger(expiresAt) ||
- expiresAt < now ||
- expiresAt > now + APP_PRICING_RELAY_TTL_SECONDS
- ) {
- return null;
- }
+ if (!validRelayExpiry(expires)) return null;
const expected = relaySignature(
requestURL,
{ plan, interval, cmuxScheme: scheme },
@@ -129,6 +130,76 @@ export function verifiedAppPricingRelayScheme(requestURL: URL): string | null {
return scheme;
}
+export function isProtectedAppPricingRelayScheme(
+ rawScheme: string | null | undefined,
+): boolean {
+ return isProtectedRelayScheme(rawScheme?.trim().toLowerCase() ?? "");
+}
+
+export function appPricingNativeReturnURL(
+ target: URL,
+ nativeReturnTo: string,
+ checkoutSessionId: string,
+): URL {
+ const result = new URL(target);
+ result.searchParams.set("native_app_return_to", nativeReturnTo);
+ if (!protectedNativeReturnTo(nativeReturnTo)) return result;
+ const secret = appPricingRelaySecret();
+ if (!secret || !validCheckoutSessionId(checkoutSessionId)) return result;
+ const expires = String(
+ Math.floor(Date.now() / 1000) + APP_PRICING_RELAY_TTL_SECONDS,
+ );
+ result.searchParams.set(CHECKOUT_NATIVE_RETURN_SESSION_PARAM, checkoutSessionId);
+ result.searchParams.set(CHECKOUT_NATIVE_RETURN_EXPIRES_PARAM, expires);
+ result.searchParams.set(
+ CHECKOUT_NATIVE_RETURN_SIGNATURE_PARAM,
+ nativeReturnSignature(
+ result,
+ nativeReturnTo,
+ checkoutSessionId,
+ expires,
+ secret,
+ ),
+ );
+ return result;
+}
+
+export function verifiedAppPricingNativeReturnTo(
+ requestURL: URL,
+): string | null {
+ const nativeReturnTo = requestURL.searchParams.get("native_app_return_to");
+ const checkoutSessionId = requestURL.searchParams.get(
+ CHECKOUT_NATIVE_RETURN_SESSION_PARAM,
+ );
+ const expires = requestURL.searchParams.get(
+ CHECKOUT_NATIVE_RETURN_EXPIRES_PARAM,
+ );
+ const signature = requestURL.searchParams.get(
+ CHECKOUT_NATIVE_RETURN_SIGNATURE_PARAM,
+ );
+ const secret = appPricingRelaySecret();
+ if (
+ !nativeReturnTo ||
+ !protectedNativeReturnTo(nativeReturnTo) ||
+ !checkoutSessionId ||
+ !validCheckoutSessionId(checkoutSessionId) ||
+ !expires ||
+ !signature ||
+ !secret ||
+ !validRelayExpiry(expires)
+ ) {
+ return null;
+ }
+ const expected = nativeReturnSignature(
+ requestURL,
+ nativeReturnTo,
+ checkoutSessionId,
+ expires,
+ secret,
+ );
+ return constantTimeHexEqual(signature, expected) ? nativeReturnTo : null;
+}
+
export function isAppStoreDistributionMode(params: {
cmux_distribution?: SearchParamValue;
cmux_ios_app_store?: SearchParamValue;
@@ -240,6 +311,50 @@ function relaySignature(
return createHmac("sha256", secret).update(payload).digest("hex");
}
+function nativeReturnSignature(
+ target: URL,
+ nativeReturnTo: string,
+ checkoutSessionId: string,
+ expires: string,
+ secret: string,
+): string {
+ const payload = [
+ "cmux-app-pricing-native-return-v1",
+ target.origin,
+ target.pathname,
+ nativeReturnTo,
+ checkoutSessionId,
+ expires,
+ ].join("\n");
+ return createHmac("sha256", secret).update(payload).digest("hex");
+}
+
+function validRelayExpiry(rawExpires: string): boolean {
+ const expiresAt = Number(rawExpires);
+ const now = Math.floor(Date.now() / 1000);
+ return (
+ Number.isSafeInteger(expiresAt)
+ && expiresAt >= now - APP_PRICING_RELAY_CLOCK_SKEW_SECONDS
+ && expiresAt <= now + APP_PRICING_RELAY_TTL_SECONDS
+ + APP_PRICING_RELAY_CLOCK_SKEW_SECONDS
+ );
+}
+
+function validCheckoutSessionId(value: string): boolean {
+ return /^cs_[A-Za-z0-9_]+$/.test(value);
+}
+
+function protectedNativeReturnTo(href: string): boolean {
+ try {
+ const url = new URL(href);
+ return isProtectedRelayScheme(url.protocol.replace(":", "").toLowerCase())
+ && url.hostname === "auth-callback"
+ && (url.pathname === "" || url.pathname === "/");
+ } catch {
+ return false;
+ }
+}
+
function appPricingRelaySecret(): string | null {
const secret = process.env.CMUX_APP_PRICING_RELAY_SECRET?.trim();
return secret && secret.length >= 32 ? secret : null;
diff --git a/web/messages/en.json b/web/messages/en.json
index 55a10aa3dce2..a07e493bb56c 100644
--- a/web/messages/en.json
+++ b/web/messages/en.json
@@ -142,7 +142,10 @@
"pro": {
"name": "cmux Pro",
"activeBody": "Your plan renews on {date}.",
- "pendingBody": "Your plan is scheduled to end on {date}."
+ "pendingBody": "Your plan is scheduled to end on {date}.",
+ "monthlyPrice": "$30/month",
+ "annualPrice": "$288/year",
+ "legacyAnnualPrice": "$240/year"
},
"team": {
"name": "cmux Team",
@@ -689,6 +692,7 @@
"billingUnavailable": "Billing is not available right now. Please try again later.",
"billingCancelled": "Checkout cancelled. You have not been charged.",
"billingInvalidPlan": "That plan is not available. Pick a plan below.",
+ "billingInvalidRelay": "The app checkout link expired or could not be verified. Return to cmux and try again.",
"welcomeTeam": "Your cmux Team purchase is complete."
},
"home": {
diff --git a/web/messages/ja.json b/web/messages/ja.json
index 280c9b5a75e2..f476a009c56c 100644
--- a/web/messages/ja.json
+++ b/web/messages/ja.json
@@ -142,7 +142,10 @@
"pro": {
"name": "cmux Pro",
"activeBody": "プランは{date}に更新されます。",
- "pendingBody": "プランは{date}に終了する予定です。"
+ "pendingBody": "プランは{date}に終了する予定です。",
+ "monthlyPrice": "$30/月",
+ "annualPrice": "$288/年",
+ "legacyAnnualPrice": "$240/年"
},
"team": {
"name": "cmux Team",
@@ -689,6 +692,7 @@
"billingUnavailable": "現在、決済をご利用いただけません。時間をおいてお試しください。",
"billingCancelled": "チェックアウトはキャンセルされました。請求は発生していません。",
"billingInvalidPlan": "指定されたプランはご利用いただけません。以下からプランをお選びください。",
+ "billingInvalidRelay": "アプリのチェックアウトリンクの有効期限が切れているか、検証できませんでした。cmuxに戻ってもう一度お試しください。",
"welcomeTeam": "cmux Team のご購入が完了しました。"
},
"home": {
diff --git a/web/tests/after-sign-in-route.test.ts b/web/tests/after-sign-in-route.test.ts
index dbb22bba51a6..f1805ba30701 100644
--- a/web/tests/after-sign-in-route.test.ts
+++ b/web/tests/after-sign-in-route.test.ts
@@ -237,9 +237,24 @@ describe("after sign-in native handoff", () => {
const response = await GET(new NextRequest(afterSignIn));
expect(response.status).toBe(200);
- expect(returnHref(await response.text())).toContain(
+ const html = await response.text();
+ expect(returnHref(html)).toContain(
"cmux-dev-test://auth-callback",
);
+ const switchURL = new URL(switchAccountHref(html), "https://cmux.test");
+ const nativeSignInTarget = new URL(
+ switchURL.searchParams.get("after_auth_return_to")!,
+ "https://cmux.test",
+ );
+ const preservedAfterSignIn = new URL(
+ nativeSignInTarget.searchParams.get("after_auth_return_to")!,
+ "https://cmux.test",
+ );
+ expect(preservedAfterSignIn.searchParams.get("cmux_checkout_session")).toBe(
+ "cs_123",
+ );
+ expect(preservedAfterSignIn.searchParams.get("cmux_native_return_signature"))
+ .toMatch(/^[a-f0-9]{64}$/);
afterSignIn.searchParams.set(
"native_app_return_to",
diff --git a/web/tests/app-pricing-page.test.tsx b/web/tests/app-pricing-page.test.tsx
index 76d2cdfc35a9..fc9becdc589a 100644
--- a/web/tests/app-pricing-page.test.tsx
+++ b/web/tests/app-pricing-page.test.tsx
@@ -222,6 +222,7 @@ describe("app pricing page", () => {
["welcomeTeam", { welcome: "team" }, "Your cmux Team purchase is complete."],
["billingCancelled", { billing: "cancelled" }, "Checkout cancelled. You have not been charged."],
["billingInvalidPlan", { billing: "invalid_plan" }, "That plan is not available. Pick a plan below."],
+ ["billingInvalidRelay", { billing: "invalid_relay" }, "The app checkout link expired or could not be verified. Return to cmux and try again."],
] as const) {
test(`renders ${name} banner state`, async () => {
const element = await AppPricingPage({
diff --git a/web/tests/billing-checkout-route.test.ts b/web/tests/billing-checkout-route.test.ts
index 11fb4d00657b..b53c2d2f1103 100644
--- a/web/tests/billing-checkout-route.test.ts
+++ b/web/tests/billing-checkout-route.test.ts
@@ -242,9 +242,14 @@ describe("billing checkout route", () => {
stripeConfigured = true;
userResponses = [null, anonymousUser];
const originalNow = Date.now;
- Date.now = () => originalNow() - 1_000;
- const checkoutResponse = await GET(new NextRequest(relayURL));
- Date.now = originalNow;
+ let checkoutResponse: Awaited>;
+ try {
+ const verifierNow = originalNow() - 1_000;
+ Date.now = () => verifierNow;
+ checkoutResponse = await GET(new NextRequest(relayURL));
+ } finally {
+ Date.now = originalNow;
+ }
expect(checkoutResponse.headers.get("location")).toBe(
"https://checkout.stripe.com/c/session",
From 9d263fb1fabee772fb7da6fe78669ecae6966917 Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 19:59:05 -0700
Subject: [PATCH 44/50] fix: return validated restore arguments
---
...faceResumeCommandCanonicalizer+PortableAgentExecutable.swift | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift b/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift
index b839d2bca528..f02983b1a30a 100644
--- a/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift
+++ b/Sources/SurfaceResumeCommandCanonicalizer+PortableAgentExecutable.swift
@@ -75,7 +75,7 @@ extension SurfaceResumeBindingSnapshot {
!value.isEmpty else {
return nil
}
- AgentRestoreCLIArgument(rawValue: value)?.rawValue
+ return AgentRestoreCLIArgument(rawValue: value)?.rawValue
}
private func resolvedStartupCommand(repairPortableAgentExecutable: Bool) -> String {
From cff68a5fb344b9d563c205c5f92de0990ee308eb Mon Sep 17 00:00:00 2001
From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com>
Date: Fri, 31 Jul 2026 20:05:37 -0700
Subject: [PATCH 45/50] fix: align annual pricing interaction contracts
---
.../AppSession/BrowserAppTheme.swift | 5 --
.../AppSession/BrowserAppWebPolicyTests.swift | 16 ++---
Sources/Panels/BrowserPanel.swift | 6 +-
cmuxTests/AuthEnvironmentTests.swift | 8 +--
web/app/app-pricing/appearance.ts | 19 +++++-
web/app/app-pricing/page.tsx | 4 +-
.../components/pricing-interval-selector.tsx | 66 ++++++++++++++-----
web/tests/app-pricing-appearance.test.ts | 6 ++
web/tests/app-pricing-page.test.tsx | 7 +-
web/tests/pricing-page.test.tsx | 3 +-
10 files changed, 87 insertions(+), 53 deletions(-)
diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
index 2eb8f56c09a8..b0fe3da5757b 100644
--- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
+++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift
@@ -81,11 +81,6 @@ public struct BrowserAppTheme: Equatable, Sendable {
"""
}
- /// Returns whether the URL is a trusted app-web surface that consumes this theme contract.
- public func supports(url: URL?, trustedOrigin: URL) -> Bool {
- Self.supportsAppSurface(url: url, trustedOrigin: trustedOrigin)
- }
-
/// Checks the app-web URL contract without constructing theme colors.
public static func supportsAppSurface(url: URL?, trustedOrigin: URL) -> Bool {
guard let url,
diff --git a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
index 525a61593737..9caefed6b238 100644
--- a/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
+++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift
@@ -143,35 +143,27 @@ struct BrowserAppWebPolicyTests {
let script = try #require(theme.applyingJavaScript())
#expect(script.contains("[data-cmux-app-theme]"))
#expect(script.contains("--cmux-product-blue-on-background"))
- #expect(theme.supports(
- url: URL(string: "https://cmux.com/app-pricing"),
- trustedOrigin: trustedOrigin
- ))
#expect(BrowserAppTheme.supportsAppSurface(
url: URL(string: "https://cmux.com/app-pricing"),
trustedOrigin: trustedOrigin
))
- #expect(theme.supports(
+ #expect(BrowserAppTheme.supportsAppSurface(
url: URL(string: "https://cmux.com/app-pro-welcome"),
trustedOrigin: trustedOrigin
))
- #expect(theme.supports(
+ #expect(BrowserAppTheme.supportsAppSurface(
url: URL(string: "https://cmux.com/app-pricing/"),
trustedOrigin: trustedOrigin
))
- #expect(theme.supports(
+ #expect(BrowserAppTheme.supportsAppSurface(
url: URL(string: "https://cmux.com/app-pro-welcome/"),
trustedOrigin: trustedOrigin
))
- #expect(!theme.supports(
- url: URL(string: "https://cmux.com/pricing"),
- trustedOrigin: trustedOrigin
- ))
#expect(!BrowserAppTheme.supportsAppSurface(
url: URL(string: "https://cmux.com/pricing"),
trustedOrigin: trustedOrigin
))
- #expect(!theme.supports(
+ #expect(!BrowserAppTheme.supportsAppSurface(
url: URL(string: "https://attacker.example/app-pricing"),
trustedOrigin: trustedOrigin
))
diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift
index dbfa6479cda0..91a4a45190e9 100644
--- a/Sources/Panels/BrowserPanel.swift
+++ b/Sources/Panels/BrowserPanel.swift
@@ -5275,11 +5275,7 @@ final class BrowserPanel: Panel, ObservableObject {
private func applyAppWebTheme(_ theme: AppWebThemeSnapshot, to webView: WKWebView) {
let browserTheme = theme.browserTheme
- guard browserTheme.supports(
- url: webView.url,
- trustedOrigin: AuthEnvironment.appWebOrigin
- ),
- let script = browserTheme.applyingJavaScript() else {
+ guard let script = browserTheme.applyingJavaScript() else {
return
}
webView.evaluateJavaScript(script, completionHandler: nil)
diff --git a/cmuxTests/AuthEnvironmentTests.swift b/cmuxTests/AuthEnvironmentTests.swift
index 71c8634d1abb..4b458f72265a 100644
--- a/cmuxTests/AuthEnvironmentTests.swift
+++ b/cmuxTests/AuthEnvironmentTests.swift
@@ -382,19 +382,19 @@ struct AuthEnvironmentTests {
#expect(script.contains("--cmux-product-blue"))
#expect(script.contains("--cmux-product-blue-on-background"))
#expect(script.contains("--cmux-product-blue-on-foreground"))
- #expect(browserTheme.supports(
+ #expect(BrowserAppTheme.supportsAppSurface(
url: URL(string: "https://cmux.com/app-pricing"),
trustedOrigin: AuthEnvironment.appWebOrigin
))
- #expect(browserTheme.supports(
+ #expect(BrowserAppTheme.supportsAppSurface(
url: URL(string: "https://cmux.com/app-pro-welcome"),
trustedOrigin: AuthEnvironment.appWebOrigin
))
- #expect(!browserTheme.supports(
+ #expect(!BrowserAppTheme.supportsAppSurface(
url: URL(string: "https://cmux.com/pricing"),
trustedOrigin: AuthEnvironment.appWebOrigin
))
- #expect(!browserTheme.supports(
+ #expect(!BrowserAppTheme.supportsAppSurface(
url: URL(string: "https://attacker.example/app-pricing"),
trustedOrigin: AuthEnvironment.appWebOrigin
))
diff --git a/web/app/app-pricing/appearance.ts b/web/app/app-pricing/appearance.ts
index 0fe8ceed8891..8d551f5a6c91 100644
--- a/web/app/app-pricing/appearance.ts
+++ b/web/app/app-pricing/appearance.ts
@@ -116,7 +116,12 @@ export function appPricingContrastAdjustedAccent(
];
const contrast = appPricingContrastRatio(color, background);
if (contrast >= minimumContrast) {
- return { color, contrast, step };
+ const distanceSquared = color.reduce(
+ (sum, component, index) =>
+ sum + (component - preferred[index]) ** 2,
+ 0,
+ );
+ return { color, contrast, distanceSquared };
}
}
return null;
@@ -124,10 +129,18 @@ export function appPricingContrastAdjustedAccent(
.filter(
(
candidate,
- ): candidate is { color: RGB; contrast: number; step: number } =>
+ ): candidate is {
+ color: RGB;
+ contrast: number;
+ distanceSquared: number;
+ } =>
candidate !== null,
)
- .sort((lhs, rhs) => lhs.step - rhs.step || rhs.contrast - lhs.contrast);
+ .sort(
+ (lhs, rhs) =>
+ lhs.distanceSquared - rhs.distanceSquared
+ || rhs.contrast - lhs.contrast,
+ );
return candidates[0] ? appPricingHex(candidates[0].color) : preferredColor;
}
diff --git a/web/app/app-pricing/page.tsx b/web/app/app-pricing/page.tsx
index 0c652ed7bb5b..76e4c3461aba 100644
--- a/web/app/app-pricing/page.tsx
+++ b/web/app/app-pricing/page.tsx
@@ -164,7 +164,7 @@ export default async function AppPricingPage({
period={
}
priceDetail={
@@ -222,7 +222,7 @@ export default async function AppPricingPage({
period={
}
priceDetail={
diff --git a/web/app/components/pricing-interval-selector.tsx b/web/app/components/pricing-interval-selector.tsx
index eeccd3cc05c9..94b06baf8b24 100644
--- a/web/app/components/pricing-interval-selector.tsx
+++ b/web/app/components/pricing-interval-selector.tsx
@@ -6,7 +6,9 @@ import {
useContext,
useRef,
useState,
+ type KeyboardEvent,
type ReactNode,
+ type Ref,
} from "react";
import { posthog } from "../lib/posthog-client";
@@ -76,6 +78,8 @@ export function PricingIntervalSelector({
}) {
const { interval, setInterval } = usePricingInterval();
const capturedView = useRef(false);
+ const monthlyButton = useRef(null);
+ const annualButton = useRef(null);
const captureView = useCallback(
(node: HTMLDivElement | null) => {
if (!node || capturedView.current) return;
@@ -84,37 +88,58 @@ export function PricingIntervalSelector({
},
[interval, surface],
);
+ const selectInterval = useCallback(
+ (nextInterval: BillingInterval) => {
+ setInterval(nextInterval);
+ capturePricingEvent(
+ "cmuxterm_pricing_interval_selected",
+ nextInterval,
+ surface,
+ );
+ },
+ [setInterval, surface],
+ );
+ const handleKeyDown = (event: KeyboardEvent) => {
+ let nextInterval: BillingInterval | null = null;
+ switch (event.key) {
+ case "ArrowLeft":
+ case "ArrowRight":
+ case "ArrowUp":
+ case "ArrowDown":
+ nextInterval = interval === "month" ? "year" : "month";
+ break;
+ case "Home":
+ nextInterval = "month";
+ break;
+ case "End":
+ nextInterval = "year";
+ break;
+ }
+ if (!nextInterval) return;
+ event.preventDefault();
+ selectInterval(nextInterval);
+ (nextInterval === "month" ? monthlyButton : annualButton).current?.focus();
+ };
return (
{
- setInterval("month");
- capturePricingEvent(
- "cmuxterm_pricing_interval_selected",
- "month",
- surface,
- );
- }}
+ onSelect={() => selectInterval("month")}
>
{monthlyLabel}
{
- setInterval("year");
- capturePricingEvent(
- "cmuxterm_pricing_interval_selected",
- "year",
- surface,
- );
- }}
+ onSelect={() => selectInterval("year")}
>
{annualLabel}
;
selected: boolean;
onSelect: () => void;
children: ReactNode;
}) {
return (