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..b0fe3da5757b --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppTheme.swift @@ -0,0 +1,103 @@ +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; + })() + """ + } + + /// 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", + BrowserAppWebOrigin(trustedOrigin).containsAppSurface(url) else { + return false + } + return true + } + + 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/BrowserAppWebOrigin.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift index 7a7fe5fe528c..599e284f91bf 100644 --- a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserAppWebOrigin.swift @@ -8,19 +8,62 @@ struct BrowserAppWebOrigin { } func contains(_ candidate: URL) -> Bool { - guard candidate.scheme?.lowercased() == url.scheme?.lowercased(), - candidate.host?.lowercased() == url.host?.lowercased() else { + 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 { + 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" + } + + /// 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" } -} -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 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) + } + + 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/AppSession/BrowserExternalNavigationPolicy.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift new file mode 100644 index 000000000000..42cb329ce7fa --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/AppSession/BrowserExternalNavigationPolicy.swift @@ -0,0 +1,33 @@ +public import Foundation + +/// Decides whether a trusted app-web surface explicitly requests the system browser. +public struct BrowserExternalNavigationPolicy: Equatable, Sendable { + public let trustedOrigin: URL + + /// Creates a policy pinned to the exact app-web origin. + public init(trustedOrigin: URL) { + self.trustedOrigin = trustedOrigin + } + + /// 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? + ) -> Bool { + guard let sourceURL, + BrowserAppWebOrigin(trustedOrigin).contains(sourceURL), + isSafeWebDestination(url), + let components = URLComponents(url: url, resolvingAgainstBaseURL: false), + components.queryItems?.contains(where: { + $0.name == "cmux_external_browser" && $0.value == "1" + }) == true else { + return false + } + return true + } + + private func isSafeWebDestination(_ url: URL) -> Bool { + BrowserAppWebOrigin(trustedOrigin).contains(url) + } +} diff --git a/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Omnibar/BrowserURLResolver.swift b/Packages/macOS/CmuxBrowser/Sources/CmuxBrowser/Omnibar/BrowserURLResolver.swift index 37554e58a20e..6e7f834506fa 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 BrowserAppWebOrigin.isLoopbackHost(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 } 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..a454214cea38 --- /dev/null +++ b/Packages/macOS/CmuxBrowser/Tests/CmuxBrowserTests/AppSession/BrowserAppWebPolicyTests.swift @@ -0,0 +1,185 @@ +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")) + ) + let trustedSource = try #require( + URL(string: "https://cmux.com/app-pricing") + ) + let publicPricingSource = try #require( + URL(string: "https://cmux.com/pricing") + ) + let dashboardBillingSource = try #require( + URL(string: "https://cmux.com/en/dashboard/billing") + ) + 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")), + sourceURL: trustedSource + )) + #expect(policy.shouldOpenInSystemBrowser( + try #require(URL(string: "https://cmux.com/enterprise?cmux_external_browser=1")), + sourceURL: trustedSource + )) + #expect(policy.shouldOpenInSystemBrowser( + try #require(URL(string: "https://cmux.com/api/billing/checkout?cmux_external_browser=1")), + sourceURL: publicPricingSource + )) + #expect(policy.shouldOpenInSystemBrowser( + try #require(URL(string: "https://cmux.com/api/billing/checkout?cmux_external_browser=1")), + sourceURL: dashboardBillingSource + )) + #expect(!policy.shouldOpenInSystemBrowser( + try #require( + URL( + string: "https://billing.example/api/billing/checkout?cmux_external_browser=1" + ) + ), + 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 + )) + #expect(!policy.shouldOpenInSystemBrowser( + 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")), + sourceURL: trustedSource + )) + #expect(!policy.shouldOpenInSystemBrowser( + try #require(URL(string: "cmux://enterprise?cmux_external_browser=1")), + sourceURL: trustedSource + )) + #expect(!policy.shouldOpenInSystemBrowser( + 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")), + sourceURL: trustedSource + )) + #expect(!policy.shouldOpenInSystemBrowser( + try #require(URL(string: "https://billing.example/?cmux_external_browser=1")), + sourceURL: wrongPortSource + )) + } + + @Test("external intent supports a loopback development origin") + func externalIntentSupportsLoopbackDevelopmentOrigin() throws { + 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") + 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")) + 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(BrowserAppTheme.supportsAppSurface( + url: URL(string: "https://cmux.com/app-pricing"), + trustedOrigin: trustedOrigin + )) + #expect(BrowserAppTheme.supportsAppSurface( + url: URL(string: "https://cmux.com/app-pro-welcome"), + trustedOrigin: trustedOrigin + )) + #expect(BrowserAppTheme.supportsAppSurface( + url: URL(string: "https://cmux.com/app-pricing/"), + trustedOrigin: trustedOrigin + )) + #expect(BrowserAppTheme.supportsAppSurface( + url: URL(string: "https://cmux.com/app-pro-welcome/"), + trustedOrigin: trustedOrigin + )) + #expect(!BrowserAppTheme.supportsAppSurface( + url: URL(string: "https://cmux.com/pricing"), + trustedOrigin: trustedOrigin + )) + #expect(!BrowserAppTheme.supportsAppSurface( + url: URL(string: "https://attacker.example/app-pricing"), + trustedOrigin: trustedOrigin + )) + } +} 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 a47156acd108..2bbfb539e73b 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -145561,13 +145561,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/BrowserNavigationDelegate.swift b/Sources/Panels/BrowserNavigationDelegate.swift index 22494a5ae3a0..07916bf21525 100644 --- a/Sources/Panels/BrowserNavigationDelegate.swift +++ b/Sources/Panels/BrowserNavigationDelegate.swift @@ -6,6 +6,9 @@ import WebKit @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)? @@ -353,13 +356,13 @@ import WebKit } if let url = navigationAction.request.url, - shouldOpenCheckoutInSystemBrowser(navigationAction, url: url) { + shouldOpenInSystemBrowser(navigationAction, url: url) { clearAttemptedRequest(discardPendingBypasses: true) let reportTerminalCancellation = terminalPolicyCancellationReporter?(navigationAction, webView) ?? {} let opened = NSWorkspace.shared.open(url) #if DEBUG cmuxDebugLog( - "browser.nav.decidePolicy.action kind=openCheckoutInSystemBrowser opened=\(opened ? 1 : 0) " + + "browser.nav.decidePolicy.action kind=openExternalIntentInSystemBrowser opened=\(opened ? 1 : 0) " + "url=\(browserNavigationDebugURL(url))" ) #endif @@ -535,20 +538,14 @@ import WebKit ) } - private func shouldOpenCheckoutInSystemBrowser(_ navigationAction: WKNavigationAction, url: URL) -> Bool { + private func shouldOpenInSystemBrowser(_ navigationAction: WKNavigationAction, url: URL) -> Bool { guard navigationAction.targetFrame?.isMainFrame != false else { return false } guard navigationAction.navigationType == .linkActivated else { return false } - guard let scheme = url.scheme?.lowercased(), scheme == "http" || scheme == "https" else { - return false - } - guard url.path == "/api/billing/checkout", - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - components.queryItems?.contains(where: { - $0.name == "cmux_external_browser" && $0.value != "0" - }) == true else { - return false - } - return true + return externalNavigationPolicy.shouldOpenInSystemBrowser( + url, + sourceURL: navigationAction.sourceFrame.request.url + ?? owner?.webView.url + ) } func canHandleSSLTrustBypassToken(_ token: String) -> Bool { diff --git a/Sources/Panels/BrowserPanel.swift b/Sources/Panels/BrowserPanel.swift index a98b538fd0a7..91a4a45190e9 100644 --- a/Sources/Panels/BrowserPanel.swift +++ b/Sources/Panels/BrowserPanel.swift @@ -3868,6 +3868,7 @@ final class BrowserPanel: Panel, ObservableObject { boundHistoryStore.recordVisit(url: webView.url, title: webView.title) self.refreshFavicon(from: webView) } + self.applyCurrentAppWebTheme(to: webView) // Keep find-in-page open through load completion and refresh matches for the new DOM. self.restoreFindStateAfterNavigation(replaySearch: true) } @@ -5243,6 +5244,8 @@ 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) @@ -5255,6 +5258,27 @@ final class BrowserPanel: Panel, ObservableObject { // Apply the configured background for the freshly bound webview (covers // the initial bind and every post-crash replacement). applyConfiguredWebViewBackground() + applyCurrentAppWebTheme(to: webView) + } + + 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 let script = browserTheme.applyingJavaScript() else { + return + } + webView.evaluateJavaScript(script, completionHandler: nil) } /// Configures the live webview's background for the current Ghostty theme. diff --git a/Sources/ProWelcomeChecklist.swift b/Sources/ProWelcomeChecklist.swift index 56a537897a79..12a1c3f33101 100644 --- a/Sources/ProWelcomeChecklist.swift +++ b/Sources/ProWelcomeChecklist.swift @@ -1,6 +1,193 @@ import AppKit +import CmuxBrowser +import CmuxTerminalCore import Foundation +@MainActor +struct AppWebThemeSnapshot: Equatable { + let appearance: String + let background: String + let foreground: String + let accent: String + + static func current(notification: Notification? = nil) -> AppWebThemeSnapshot { + let userInfo = notification?.userInfo + let backgroundColor = GhosttyBackgroundTheme.color(from: notification) + let foregroundColor = + (userInfo?[GhosttyNotificationKey.foregroundColor] as? NSColor) + ?? GhosttyApp.shared.defaultForegroundColor + return resolved( + backgroundColor: backgroundColor, + foregroundColor: foregroundColor + ) + } + + static func resolved( + backgroundColor: NSColor, + foregroundColor: NSColor + ) -> AppWebThemeSnapshot { + let colorScheme = cmuxReadableColorScheme(for: backgroundColor) + let appearance = colorScheme == .dark ? "dark" : "light" + return AppWebThemeSnapshot( + appearance: appearance, + background: backgroundColor.hexString(), + foreground: foregroundColor.hexString(), + accent: cmuxAccentNSColor(for: colorScheme).hexString() + ) + } + + var accentOnBackground: String { + contrastAdjustedAccent(on: background) + } + + var accentOnForeground: String { + contrastAdjustedAccent(on: foreground) + } + + var browserTheme: BrowserAppTheme { + BrowserAppTheme( + appearance: appearance, + background: background, + foreground: foreground, + accent: accent, + accentOnBackground: accentOnBackground, + accentOnForeground: accentOnForeground + ) + } + + private func contrastAdjustedAccent(on backgroundHex: String) -> String { + guard let accentColor = NSColor(hex: accent), + let backgroundColor = NSColor(hex: backgroundHex) else { + return accent + } + 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.distanceSquared == $1.distanceSquared { + return $0.contrast > $1.contrast + } + return $0.distanceSquared < $1.distanceSquared + }) { + 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) + ) + } + + 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 distanceSquared: Int + let contrast: CGFloat + } + + private static func contrastAdjustmentCandidate( + preferred: RGBBytes, + background: NSColor, + targetComponent: Int, + minimumContrast: CGFloat + ) -> ContrastAdjustmentCandidate? { + for step in 1...255 { + let adjusted = preferred.mixed( + toward: targetComponent, + step: step + ) + let color = adjusted.color + let contrast = cmuxContrastRatio( + foreground: color, + background: background + ) + if contrast >= minimumContrast { + return ContrastAdjustmentCandidate( + color: color, + distanceSquared: preferred.squaredDistance(to: adjusted), + contrast: contrast + ) + } + } + return nil + } +} + /// Presents the one-time "Welcome to cmux Pro" checklist after a user becomes /// Pro. The checklist is a chromeless in-app web page (`/app-pro-welcome`) /// shown in the same dedicated workspace surface as the pricing page, so it @@ -102,19 +289,22 @@ extension ProUpgradePresenter { } /// Builds an app web URL (pricing or Pro welcome) decorated with the current - /// appearance, background color, and cmux app/scheme query parameters. + /// Ghostty colors and cmux app/scheme query parameters. @MainActor static func decoratedAppWebURL(_ base: URL) -> URL { + 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 ?? [] - queryItems.removeAll { $0.name == "appearance" } - queryItems.removeAll { $0.name == "background" } + 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" } - let backgroundColor = GhosttyBackgroundTheme.currentColor() - let appearance = cmuxReadableColorScheme(for: backgroundColor) == .dark ? "dark" : "light" - queryItems.append(URLQueryItem(name: "appearance", value: appearance)) - queryItems.append(URLQueryItem(name: "background", value: backgroundColor.hexString())) + 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 616c19ce9e74..a2de3a23f4ca 100644 --- a/cmuxTests/AuthEnvironmentTests.swift +++ b/cmuxTests/AuthEnvironmentTests.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import Testing @@ -310,6 +311,101 @@ 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")) + let theme = AppWebThemeSnapshot( + appearance: "dark", + background: "#112233", + foreground: "#DDEEFF", + accent: "#0091FF" + ) + + let url = ProUpgradePresenter.decoratedAppWebURL(base, theme: theme) + let query = try #require(URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems) + let values = Dictionary(uniqueKeysWithValues: query.compactMap { item in + item.value.map { (item.name, $0) } + }) + + #expect(values["interval"] == "year") + #expect(values["appearance"] == "dark") + #expect(values["background"] == "#112233") + #expect(values["foreground"] == "#DDEEFF") + #expect(values["accent"] == "#0091FF") + #expect(values["accent_on_background"] == theme.accentOnBackground) + #expect(values["accent_on_foreground"] == theme.accentOnForeground) + #expect(values["cmux_app"] == "1") + } + + @MainActor + @Test("app web appearance and product accent follow the Ghostty background") + func appWebAppearanceAndProductAccentFollowGhosttyBackground() throws { + let darkSnapshot = AppWebThemeSnapshot.resolved( + backgroundColor: try #require(NSColor(hex: "#101010")), + foregroundColor: try #require(NSColor(hex: "#F0F0F0")) + ) + let lightSnapshot = AppWebThemeSnapshot.resolved( + backgroundColor: try #require(NSColor(hex: "#F0F0F0")), + foregroundColor: try #require(NSColor(hex: "#101010")) + ) + + #expect(darkSnapshot.appearance == "dark") + #expect(darkSnapshot.background == "#101010") + #expect(darkSnapshot.foreground == "#F0F0F0") + #expect(darkSnapshot.accent == "#0091FF") + #expect(lightSnapshot.appearance == "light") + #expect(lightSnapshot.background == "#F0F0F0") + #expect(lightSnapshot.foreground == "#101010") + #expect(lightSnapshot.accent == "#0088FF") + #expect(darkSnapshot.accentOnBackground == "#0091FF") + #expect(darkSnapshot.accentOnForeground != darkSnapshot.accent) + #expect(lightSnapshot.accentOnBackground != lightSnapshot.accent) + #expect(lightSnapshot.accentOnForeground == "#0088FF") + } + + @MainActor + @Test("app web theme serializes translucent native colors as opaque RGB") + func appWebThemeSerializesTranslucentNativeColorsAsOpaqueRGB() { + let snapshot = AppWebThemeSnapshot.resolved( + backgroundColor: NSColor( + srgbRed: 17.0 / 255.0, + green: 34.0 / 255.0, + blue: 51.0 / 255.0, + alpha: 0.25 + ), + foregroundColor: NSColor( + srgbRed: 221.0 / 255.0, + green: 238.0 / 255.0, + blue: 255.0 / 255.0, + alpha: 0.5 + ) + ) + + #expect(snapshot.background == "#112233") + #expect(snapshot.foreground == "#DDEEFF") + } + + @MainActor + @Test("app web theme JavaScript updates every shared theme variable") + func appWebThemeJavaScriptUpdatesEverySharedThemeVariable() throws { + let theme = AppWebThemeSnapshot( + appearance: "light", + background: "#FAFAFA", + foreground: "#171717", + accent: "#0088FF" + ) + let browserTheme = theme.browserTheme + let script = try #require(browserTheme.applyingJavaScript()) + + #expect(script.contains("[data-cmux-app-theme]")) + #expect(script.contains("--ghostty-background")) + #expect(script.contains("--ghostty-foreground")) + #expect(script.contains("--cmux-product-blue")) + #expect(script.contains("--cmux-product-blue-on-background")) + #expect(script.contains("--cmux-product-blue-on-foreground")) + } + @Test("app session handoff pins credentials to production or debug loopback") func appSessionHandoffPinsCredentialOrigin() { let production = AuthEnvironment.resolvedAppSessionHandoffOrigin( diff --git a/cmuxTests/SidebarWidthPolicyTests.swift b/cmuxTests/SidebarWidthPolicyTests.swift index 4eb59f3d9341..2bc93107b7e0 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,66 @@ 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 + ) + } + + @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 { func testSelectedColoredWorkspaceUsesStandardSelectionBackgroundInLightAndDark() { for colorScheme in [ColorScheme.light, .dark] { diff --git a/docs/ghostty-fork.md b/docs/ghostty-fork.md index a7e7cb56f38c..16c29f056da0 100644 --- a/docs/ghostty-fork.md +++ b/docs/ghostty-fork.md @@ -12,10 +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 `da1ddcf41`, the fork-main merge of -https://github.com/manaflow-ai/ghostty/pull/176. It descends from the previously -documented `36a46414a` renderer/font integration and adds terminal-owned -semantic-prompt row lifecycle enforcement through `2d6e944e3`. +The submodule pinned by this branch is `88357634c`, the fork-main merge of +https://github.com/manaflow-ai/ghostty/pull/175. It combines the initial cmux +theme-picker render fix at `5068b3a37` with terminal-owned semantic-prompt row +lifecycle enforcement through `2d6e944e3` from +https://github.com/manaflow-ai/ghostty/pull/176. The earlier integration combines the hidden-renderer reclamation and retry-deadline line through `4d6f0014f` with the resolved font-binding action callbacks originally ending at `80d7fb35a`. @@ -42,6 +43,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`. + ### Semantic prompt row lifecycle - Pull request: @@ -218,13 +233,14 @@ The final font integration landed in merge commits `23003282d` and callback userdata alive until `ghostty_surface_free` returns, and never destroy or otherwise reenter the surface from the synchronous callback. -The pinned `da1ddcf41` universal ReleaseFast GhosttyKit archive was built with -Zig 0.16.0. It is published at -https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-da1ddcf41f6fd763c39bde4c69d1ac7323cb9bd0-crashsubdir-cmux-crash-v1 -and its SHA-256 is pinned in `scripts/ghosttykit-checksums.txt`. The published +The pinned `88357634c4` universal ReleaseFast GhosttyKit archive combines the +initial theme-picker render and semantic prompt lifecycle fixes. It was built +with Zig 0.16.0 and is published at +https://github.com/manaflow-ai/ghostty/releases/tag/xcframework-88357634c4dbadc87981e2ebb64eb599c53aa012-crashsubdir-cmux-crash-v1 +with its SHA-256 pinned in `scripts/ghosttykit-checksums.txt`. The published asset was downloaded again, passed `scripts/validate-xcframework-archive.py`, and matched SHA-256 -`51bb73625dd8e53a98675fb75dc573931ab3b65646e02e5f0ef6bf7db89308da`. +`0448351c3f8b07fd2698c905260a97d064e4e186d0544766965effb41aedfbd5`. ### Ordered writes survive transient backpressure diff --git a/ghostty b/ghostty index da1ddcf41f6f..88357634c4db 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit da1ddcf41f6fd763c39bde4c69d1ac7323cb9bd0 +Subproject commit 88357634c4dbadc87981e2ebb64eb599c53aa012 diff --git a/scripts/ghosttykit-checksums.txt b/scripts/ghosttykit-checksums.txt index 2c020855fc00..1ed7c1376ea7 100644 --- a/scripts/ghosttykit-checksums.txt +++ b/scripts/ghosttykit-checksums.txt @@ -108,4 +108,6 @@ cd1f8e0120f534cabc7d89257baccc42c166d369 578864e66ed7483d8c28fd2418d6b9961467d4a 36a46414a7c5dc122ffbf2992fec6d4a73cf7c65 8784a1bd29d3d13250b9557b8982d362054fd326d48b8fc8c0deac56f4f71c0d 6143bac777724854e5a3bab8c7a4e47735f17113 e4c976b234dac24f6261f27af64fbf1b013d618d5b9c903ac876d9046678ff17 abcf5697d4fcd05e29a83ccfc090d6e234952849 61f0034da86c4907d6b1c607fcfd6810425cd02406dcc7a0652d4289c9b530ea +59f2b5d2ec67a5f9dfe9138f6e5a4353b75d238e 3767b7bba0931f9cab359d0c8147885e14a2b6ce420044e5946b4b823fc093da da1ddcf41f6fd763c39bde4c69d1ac7323cb9bd0 51bb73625dd8e53a98675fb75dc573931ab3b65646e02e5f0ef6bf7db89308da +88357634c4dbadc87981e2ebb64eb599c53aa012 0448351c3f8b07fd2698c905260a97d064e4e186d0544766965effb41aedfbd5 diff --git a/skills/cmux-billing/SKILL.md b/skills/cmux-billing/SKILL.md index f31a24aae65a..cff245fc4d4e 100644 --- a/skills/cmux-billing/SKILL.md +++ b/skills/cmux-billing/SKILL.md @@ -26,7 +26,7 @@ Read before changing billing, pricing, Stripe, Pro entitlement, checkout, webhoo ## Test-mode resources -Product `prod_UpIQRE6cj0nFjs`. Lookup keys `cmux-pro-monthly` ($30/mo) and `cmux-pro-yearly` ($240/yr). Staging webhook endpoint `we_1Tq1SZGhInAdn3JbWJReKNEN` forwards to `cmux-staging.vercel.app`; its secrets are already in the `cmux-staging` Vercel project. +Product `prod_UpIQRE6cj0nFjs`. New checkouts use `cmux-pro-monthly` ($30/mo) and `cmux-pro-yearly-288` ($288/yr, equivalent to $24/mo). Keep `cmux-pro-yearly` ($240/yr) active for grandfathered subscriptions. Staging webhook endpoint `we_1Tq1SZGhInAdn3JbWJReKNEN` forwards to `cmux-staging.vercel.app`; its secrets are already in the `cmux-staging` Vercel project. ## Feature flags diff --git a/web/.env.example b/web/.env.example index 7d91dd32db2c..873bb80f2763 100644 --- a/web/.env.example +++ b/web/.env.example @@ -35,8 +35,16 @@ CMUX_PRO_FROM_EMAIL= STRIPE_SECRET_KEY= STRIPE_WEBHOOK_SECRET= STRIPE_PRO_MONTHLY_PRICE_ID= +# Retired. Keep unset; grandfathered $240 subscriptions remain in Stripe, while +# new annual checkout overrides use STRIPE_PRO_YEARLY_288_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. Only loopback requests can preserve tagged +# callback schemes. Source and destination must share the 32+ character secret. +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/[locale]/components/pro-cta-link.tsx b/web/app/[locale]/components/pro-cta-link.tsx index 9834aa21b9db..2a98bb8c1de8 100644 --- a/web/app/[locale]/components/pro-cta-link.tsx +++ b/web/app/[locale]/components/pro-cta-link.tsx @@ -1,46 +1,29 @@ "use client"; -import posthog from "posthog-js"; import { - pricingActionClassName, - type PricingActionSize, -} from "../../components/pricing-shared"; -import { - CheckoutPendingContent, - useCheckoutRedirect, -} from "../../components/checkout-navigation"; + PricingCheckoutButton, + type ProCheckoutHrefs, +} from "../../components/pricing-interval-selector"; +import type { PricingActionSize } from "../../components/pricing-shared"; export function ProCtaLink({ - checkoutHref, + checkoutHrefs, children, size = "default", location = "pricing_page", }: { - checkoutHref: string; + checkoutHrefs: ProCheckoutHrefs; children: React.ReactNode; size?: PricingActionSize; location?: string; }) { - const { pending, start } = useCheckoutRedirect(); return ( - { - posthog.capture("cmuxterm_pro_cta_clicked", { - location, - checkout: true, - }); - start(checkoutHref, event); - }} - aria-busy={pending} - className={`${pricingActionClassName("primary", size)} relative`} - style={{ - color: "var(--background)", - textDecoration: "none", - pointerEvents: pending ? "none" : undefined, - }} + - {children} - + {children} + ); } 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 88d498f99f96..a0460b4f0fd8 100644 --- a/web/app/[locale]/dashboard/billing/page.tsx +++ b/web/app/[locale]/dashboard/billing/page.tsx @@ -2,7 +2,11 @@ import { and, desc, eq, inArray } from "drizzle-orm"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; -import { PRO_CHECKOUT_URL, TEAM_CHECKOUT_URL } from "@/app/lib/billing"; +import { + PRO_CHECKOUT_URL, + TEAM_CHECKOUT_URL, + withCheckoutInterval, +} from "@/app/lib/billing"; import { getStackServerApp, isStackConfigured } from "@/app/lib/stack"; import { localizedVaultPath, vaultSignInHref } from "@/app/lib/vault-auth"; import { @@ -11,6 +15,12 @@ import { PrimaryLink, visibleProFeatures, } from "@/app/components/pricing-shared"; +import { + PricingCheckoutButton, + PricingIntervalProvider, + PricingIntervalSelector, + PricingIntervalValue, +} from "@/app/components/pricing-interval-selector"; import { cloudDb } from "@/db/client"; import { stripeCustomers, stripeSubscriptions } from "@/db/schema"; import { Link } from "@/i18n/navigation"; @@ -21,12 +31,19 @@ import { resolveProPlanStatus, } from "@/services/billing/pro"; import { resolveBillingTeam, type BillingTeamLike } from "@/services/billing/teamResolution"; +import { + LEGACY_PRO_YEARLY_LOOKUP_KEY, + PRO_PRICING_USD, + TEAM_PRICING_USD, + proBillingInterval, +} from "@/services/billing/plans"; import { AccountPlanBadge } from "../components/account-plan-badge"; export const dynamic = "force-dynamic"; type SearchParams = { billing?: string | string[]; + interval?: string | string[]; }; type StripeSubscriptionRow = { @@ -80,6 +97,9 @@ export default async function DashboardBillingPage({ billingTeam ? hasTeamCustomerRow(billingTeam.id) : Promise.resolve(false), ]); const banner = billingBanner(Array.isArray(query?.billing) ? query?.billing[0] : query?.billing); + const interval = proBillingInterval( + Array.isArray(query?.interval) ? query.interval[0] : query?.interval, + ); const isFreePlan = !status.isPro && !teamSubscription; return ( @@ -100,7 +120,7 @@ export default async function DashboardBillingPage({ ) : null} {isFreePlan ? ( - + ) : !status.isPro ? ( ) : subscription ? ( @@ -213,9 +233,11 @@ function FreePlan({ t }: { t: Awaited> }) { function FreePlanUpsell({ t, pricingT, + interval, }: { t: Awaited>; pricingT: Awaited>; + interval: "month" | "year"; }) { const proFeatures = visibleProFeatures({ base: pricingT.raw("pro.features") as string[], @@ -223,53 +245,129 @@ function FreePlanUpsell({ hostedNetworking: pricingT.raw("pro.hostedNetworkingFeatures") as string[], }); const teamFeatures = pricingT.raw("team.features") as string[]; + const proCheckoutHrefs = { + 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"), + }; return ( -
-
-

{t("free.name")}

-

{t("free.body")}

-
- -
-
-

{t("free.upsellTitle")}

-

{t("free.upsellBody")}

-
-
- - {pricingT("pro.cta")} -

{pricingT("pro.featuresLead")}

- -
- - +
+
+

{t("free.name")}

+

{t("free.body")}

+
+ +
+
+

{t("free.upsellTitle")}

+

{t("free.upsellBody")}

+ +
+
+ + } + period={ + + } + priceDetail={ + + {pricingT("annualPriceDetail", { + amount: PRO_PRICING_USD.year.billedAmount, + discount: PRO_PRICING_USD.year.discountPercent, + })} +

+ } + /> + } + > + + {pricingT("pro.cta")} + +

{pricingT("pro.featuresLead")}

+ +
+ + + } + period={ + + } + priceDetail={ + + {pricingT("annualPriceDetail", { + amount: TEAM_PRICING_USD.year.billedAmount, + discount: TEAM_PRICING_USD.year.discountPercent, + })} +

+ } + /> + } + > + + {pricingT("team.cta")} + +

{pricingT("team.featuresLead")}

+ +
+
+
+ +
+

{t("free.testflightTitle")}

+

{t("free.testflightBody")}

+ - {pricingT("team.cta")} -

{pricingT("team.featuresLead")}

- - -
-
- -
-

{t("free.testflightTitle")}

-

{t("free.testflightBody")}

- - {t("free.testflightCta")} - -
-
+ {t("free.testflightCta")} + + + + ); } @@ -284,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"); @@ -344,6 +442,9 @@ function StripePlan({ )} {canManageBilling ? ( + // This API route creates a Stripe portal session and must perform a + // full document navigation rather than a Next.js client transition. + // eslint-disable-next-line @next/next/no-html-link-for-pages @@ -389,7 +493,7 @@ function TeamPlan({ value={periodDate} /> - +
@@ -434,6 +538,9 @@ function TeamPlan({ )} {canManageBilling ? ( + // This API route creates a Stripe portal session and must perform a + // full document navigation rather than a Next.js client transition. + // eslint-disable-next-line @next/next/no-html-link-for-pages >, +): string | null { const lookupKey = priceLookupKey(subscription) ?? subscription.priceId; - if (lookupKey === "cmux-pro-monthly") return "$30/month"; - if (lookupKey === "cmux-pro-yearly") return "$240/year"; + if (lookupKey === PRO_PRICING_USD.month.lookupKey) { + return t("pro.monthlyPrice"); + } + if (lookupKey === LEGACY_PRO_YEARLY_LOOKUP_KEY) { + return t("pro.legacyAnnualPrice"); + } + if (lookupKey === PRO_PRICING_USD.year.lookupKey) { + 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 @@ -478,12 +597,33 @@ 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 { + 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 { return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(date); } diff --git a/web/app/[locale]/posthog.tsx b/web/app/[locale]/posthog.tsx index 21e81ae9edcb..a6f00f28a204 100644 --- a/web/app/[locale]/posthog.tsx +++ b/web/app/[locale]/posthog.tsx @@ -1,20 +1,9 @@ "use client"; -import posthog from "posthog-js"; import { PostHogProvider as PHProvider } from "posthog-js/react"; import { usePathname, useSearchParams } from "next/navigation"; import { useEffect, Suspense } from "react"; - -if (typeof window !== "undefined") { - posthog.init("phc_opOVu7oFzR9wD3I6ZahFGOV2h3mqGpl5EHyQvmHciDP", { - api_host: "https://r.cmux.com", - ui_host: "https://us.posthog.com", - person_profiles: "identified_only", - capture_pageview: false, - capture_pageleave: true, - advanced_disable_feature_flags: true, - }); -} +import { posthog } from "../lib/posthog-client"; function PageviewTracker() { const pathname = usePathname(); diff --git a/web/app/[locale]/pricing/page.tsx b/web/app/[locale]/pricing/page.tsx index f6c8d9bd50c9..5a03a38ee5dc 100644 --- a/web/app/[locale]/pricing/page.tsx +++ b/web/app/[locale]/pricing/page.tsx @@ -3,7 +3,11 @@ import { Suspense } from "react"; import { SiteHeader } from "../components/site-header"; import { ProCtaLink } from "../components/pro-cta-link"; import { ProWelcomeBanner } from "../components/pro-welcome-banner"; -import { PRO_CHECKOUT_URL, TEAM_CHECKOUT_URL } from "../../lib/billing"; +import { + PRO_CHECKOUT_URL, + TEAM_CHECKOUT_URL, + withCheckoutInterval, +} from "../../lib/billing"; import { DOWNLOAD_CONFIRMATION_HREF } from "../../lib/download"; import { getStackServerApp, isStackConfigured } from "../../lib/stack"; import { resolveProPlanStatus } from "../../../services/billing/pro"; @@ -34,7 +38,17 @@ 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"; const ENTERPRISE_CTA_URL = "/enterprise"; const ANONYMOUS_IF_EXISTS = "anonymous-if-exists[deprecated]" as const; @@ -77,12 +91,35 @@ export async function generateMetadata({ export default async function PricingPage({ params, + searchParams, }: { params: Promise<{ locale: string }>; + searchParams?: Promise>; }) { const { locale } = await params; + const query = searchParams ? await searchParams : {}; const t = await getTranslations({ locale, namespace: "pricing" }); const snapshot = await currentPlanSnapshot(); + const interval = proBillingInterval(firstParam(query.interval)); + const proCheckoutHrefs = { + 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[]; @@ -112,127 +149,205 @@ export default async function PricingPage({ - {/* Title */} -

{t("title")}

+ + {/* Title */} +

{t("title")}

+ - {/* Tier cards */} -
- {/* Free */} - - {t("free.cta")} -

- {t("free.featuresLead")} -

- -
+ {/* Tier cards */} +
+ {/* Free */} + + {t("free.cta")} +

+ {t("free.featuresLead")} +

+ +
- {/* Pro */} - {t("currentPlan")} - ) : null - } - > - {snapshot.isPro ? ( -
- {t("currentPlan")} - - {t("manageBilling")} - -
- ) : ( - - {t("pro.cta")} - - )} -

{t("pro.featuresLead")}

- -
+ {/* Pro */} + + } + period={ + + } + priceDetail={ + + {t("annualPriceDetail", { + amount: PRO_PRICING_USD.year.billedAmount, + discount: PRO_PRICING_USD.year.discountPercent, + })} +

+ } + /> + } + badge={ + snapshot.isPro ? ( + {t("currentPlan")} + ) : null + } + > + {snapshot.isPro ? ( +
+ {t("currentPlan")} + + {t("manageBilling")} + +
+ ) : ( + + {t("pro.cta")} + + )} +

{t("pro.featuresLead")}

+ +
- {/* Team */} - - {t("team.cta")} -

{t("team.featuresLead")}

- -
+ {/* Team */} + + } + period={ + + } + priceDetail={ + + {t("annualPriceDetail", { + amount: TEAM_PRICING_USD.year.billedAmount, + discount: TEAM_PRICING_USD.year.discountPercent, + })} +

+ } + /> + } + > + + {t("team.cta")} + +

{t("team.featuresLead")}

+ +
- {/* Enterprise */} - - - {t("enterprise.cta")} - -

- {t("enterprise.featuresLead")} -

- -
-
+ {/* Enterprise */} + + + {t("enterprise.cta")} + +

+ {t("enterprise.featuresLead")} +

+ +
+
- {/* Compare plans. Header row is sticky under the 48px h-12 site header. - Horizontal scrolling is mobile-only so desktop keeps the page as the - sticky scroll container. */} -
- - {t("free.cta")} - - ), - pro: ( - snapshot.isPro ? ( - {t("currentPlan")} - ) : ( - + + ), + team: ( + + ), + enterprise: t("enterprise.price"), + }} + actions={{ + free: ( + + {t("free.cta")} + + ), + pro: ( + snapshot.isPro ? ( + {t("currentPlan")} + ) : ( + + {t("pro.cta")} + + ) + ), + team: ( + - {t("pro.cta")} - - ) - ), - team: ( - - {t("team.cta")} - - ), - enterprise: ( - - {t("enterprise.cta")} - - ), - }} - /> -
+ {t("team.cta")} + + ), + enterprise: ( + + {t("enterprise.cta")} + + ), + }} + /> + +
{/* Cloud VM sizes */} { return NextResponse.redirect(appStorePricingUnavailableURL(request.nextUrl)); } + const plan = checkoutPlan(request.nextUrl.searchParams.get("plan")); + 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 = + verifiedRelayScheme ?? + validatedNativeCallbackScheme( + rawCallbackScheme, + request, + ); + const configuredRelayURL = appPricingCheckoutRelayURL(request.nextUrl, { + plan, + interval, + cmuxScheme: callbackScheme, + }); + if (configuredRelayURL) { + return NextResponse.redirect(configuredRelayURL); + } + const stackServerApp = await checkoutStackServerApp(); if (!stackServerApp) { 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)); } + 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, + callbackScheme, + ); } if (plan === "team") { - return stripeTeamCheckout(request, stackServerApp); + 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. @@ -79,6 +132,8 @@ async function resolveCheckout(request: NextRequest): Promise { async function stripeProCheckout( request: NextRequest, stackServerApp: CheckoutStackServerApp, + interval: BillingInterval, + callbackScheme: string, ) { const user = (await stackServerApp.getUser({ or: "return-null" })) ?? @@ -92,19 +147,17 @@ async function stripeProCheckout( return NextResponse.redirect(new URL("/pricing?welcome=active", request.url)); } - const scheme = validatedNativeCallbackScheme( - request.nextUrl.searchParams.get("cmux_scheme"), - request, - ); - const interval = checkoutInterval(request.nextUrl.searchParams.get("interval")); 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 = { stackUserId: user.id, plan: "pro", app: "cmux", + billingInterval: interval, + nativeCallbackScheme: callbackScheme, }; try { @@ -139,6 +192,8 @@ async function stripeProCheckout( async function stripeTeamCheckout( request: NextRequest, stackServerApp: CheckoutStackServerApp, + interval: BillingInterval, + callbackScheme: string, ) { const user = (await stackServerApp.getUser({ or: "return-null" })) ?? @@ -152,18 +207,17 @@ 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 = { stackTeamId: teamId, plan: "team", app: "cmux", + billingInterval: interval, + nativeCallbackScheme: callbackScheme, }; try { @@ -172,7 +226,7 @@ async function stripeTeamCheckout( mode: "subscription", line_items: [ { - price: await resolveTeamPrice(), + price: await resolveTeamPrice(interval), quantity: await checkoutTeamSeatCount(team), adjustable_quantity: { enabled: true, @@ -194,6 +248,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)); @@ -299,8 +354,9 @@ function checkoutPlan(raw: string | null): "pro" | "team" | null { return null; } -function checkoutInterval(raw: string | null): ProBillingInterval { - return raw === "year" ? "year" : "month"; +function checkoutBillingInterval(raw: string | null): BillingInterval | null { + if (raw === null) return billingInterval(raw); + return raw === "month" || raw === "year" ? raw : null; } async function checkoutStackServerApp(): Promise { 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/app-pricing/appearance.ts b/web/app/app-pricing/appearance.ts index b26f38449943..8d551f5a6c91 100644 --- a/web/app/app-pricing/appearance.ts +++ b/web/app/app-pricing/appearance.ts @@ -2,6 +2,15 @@ import type { CSSProperties } from "react"; type SearchParams = Record; +export type AppPricingTheme = { + appearance: "light" | "dark"; + background: string; + foreground: string; + accent: string; + accentOnBackground: string; + accentOnForeground: string; +}; + export function appPricingFirstParam( value: string | string[] | undefined, ): string | null { @@ -18,38 +27,151 @@ export function appPricingPageBackground( appearance: "light" | "dark", ): string { const background = appPricingFirstParam(params.background); - if (background && /^#[0-9a-fA-F]{6}([0-9a-fA-F]{2})?$/.test(background)) { + if (background && /^#[0-9a-fA-F]{6}$/.test(background)) { return background; } return appearance === "dark" ? "#272822" : "#fafafa"; } -export function appPricingStyle( - appearance: "light" | "dark", - pageBackground: string, -): CSSProperties { - if (appearance === "dark") { - return { - "--foreground": "#ededed", - "--muted": "#a3a3a3", - "--border": "rgba(255, 255, 255, 0.18)", - "--code-bg": "rgba(24, 24, 24, 0.72)", - "--background": pageBackground, - "--pricing-sticky-bg": pageBackground, - "--button-foreground": pageBackground, - backgroundColor: pageBackground, - colorScheme: "dark", - } as CSSProperties; - } +export function appPricingTheme(params: SearchParams): AppPricingTheme { + const appearance = appPricingAppearance(params); + const background = appPricingPageBackground(params, appearance); + const foreground = appPricingColorParam( + params.foreground, + appearance === "dark" ? "#ededed" : "#171717", + ); + const accent = appPricingColorParam( + params.accent, + appearance === "dark" ? "#0091ff" : "#0088ff", + ); return { - "--foreground": "#171717", - "--muted": "#5f6368", - "--border": "rgba(0, 0, 0, 0.14)", - "--code-bg": "rgba(245, 245, 245, 0.78)", - "--background": pageBackground, - "--pricing-sticky-bg": pageBackground, - "--button-foreground": "#ffffff", - backgroundColor: pageBackground, - colorScheme: "light", + appearance, + background, + foreground, + accent, + accentOnBackground: appPricingColorParam( + params.accent_on_background, + appPricingContrastAdjustedAccent(accent, background), + ), + accentOnForeground: appPricingColorParam( + params.accent_on_foreground, + appPricingContrastAdjustedAccent(accent, foreground), + ), + }; +} + +export function appPricingStyle(theme: AppPricingTheme): CSSProperties { + return { + "--ghostty-background": theme.background, + "--ghostty-foreground": theme.foreground, + "--cmux-product-blue": theme.accent, + "--cmux-product-blue-on-background": theme.accentOnBackground, + "--cmux-product-blue-on-foreground": theme.accentOnForeground, + "--foreground": "var(--ghostty-foreground)", + "--muted": + "color-mix(in srgb, var(--ghostty-foreground) 62%, var(--ghostty-background))", + "--border": + "color-mix(in srgb, var(--ghostty-foreground) 18%, transparent)", + "--code-bg": + "color-mix(in srgb, var(--ghostty-foreground) 8%, var(--ghostty-background))", + "--background": "var(--ghostty-background)", + "--pricing-sticky-bg": "var(--ghostty-background)", + "--button-foreground": "var(--ghostty-background)", + backgroundColor: "var(--ghostty-background)", + colorScheme: theme.appearance, } as CSSProperties; } + +function appPricingColorParam( + value: string | string[] | undefined, + fallback: string, +): string { + const color = appPricingFirstParam(value); + return color && /^#[0-9a-fA-F]{6}$/.test(color) ? color : fallback; +} + +type RGB = readonly [red: number, green: number, blue: number]; + +export function appPricingContrastAdjustedAccent( + preferredColor: string, + backgroundColor: string, + minimumContrast = 4.5, +): string { + const preferred = appPricingRGB(preferredColor); + const background = appPricingRGB(backgroundColor); + if (!preferred || !background) return preferredColor; + if (appPricingContrastRatio(preferred, background) >= minimumContrast) { + return preferredColor; + } + + const candidates = [0, 255] + .map((target) => { + for (let step = 1; step <= 255; step += 1) { + const mix = (component: number) => + Math.floor((component * (255 - step) + target * step) / 255); + const color: RGB = [ + mix(preferred[0]), + mix(preferred[1]), + mix(preferred[2]), + ]; + const contrast = appPricingContrastRatio(color, background); + if (contrast >= minimumContrast) { + const distanceSquared = color.reduce( + (sum, component, index) => + sum + (component - preferred[index]) ** 2, + 0, + ); + return { color, contrast, distanceSquared }; + } + } + return null; + }) + .filter( + ( + candidate, + ): candidate is { + color: RGB; + contrast: number; + distanceSquared: number; + } => + candidate !== null, + ) + .sort( + (lhs, rhs) => + lhs.distanceSquared - rhs.distanceSquared + || rhs.contrast - lhs.contrast, + ); + + return candidates[0] ? appPricingHex(candidates[0].color) : preferredColor; +} + +function appPricingRGB(color: string): RGB | null { + if (!/^#[0-9a-fA-F]{6}$/.test(color)) return null; + const value = Number.parseInt(color.slice(1), 16); + return [(value >> 16) & 0xff, (value >> 8) & 0xff, value & 0xff]; +} + +function appPricingHex([red, green, blue]: RGB): string { + return `#${[red, green, blue] + .map((component) => component.toString(16).padStart(2, "0")) + .join("") + .toUpperCase()}`; +} + +function appPricingContrastRatio(foreground: RGB, background: RGB): number { + const foregroundLuminance = appPricingRelativeLuminance(foreground); + const backgroundLuminance = appPricingRelativeLuminance(background); + const lighter = Math.max(foregroundLuminance, backgroundLuminance); + const darker = Math.min(foregroundLuminance, backgroundLuminance); + return (lighter + 0.05) / (darker + 0.05); +} + +function appPricingRelativeLuminance(color: RGB): number { + const [red, green, blue] = color.map((component) => { + const srgb = component / 255; + return srgb <= 0.03928 + ? srgb / 12.92 + : ((srgb + 0.055) / 1.055) ** 2.4; + }); + return 0.2126 * red + 0.7152 * green + 0.0722 * blue; +} diff --git a/web/app/app-pricing/page.tsx b/web/app/app-pricing/page.tsx index 7ee86736ff27..76e4c3461aba 100644 --- a/web/app/app-pricing/page.tsx +++ b/web/app/app-pricing/page.tsx @@ -5,11 +5,14 @@ import { getStackServerApp, isStackConfigured } from "../lib/stack"; import { validatedNativeCallbackScheme } from "../lib/native-callback"; import { FREE_PLAN_ID, resolveProPlanStatus } from "../../services/billing/pro"; import enMessages from "../../messages/en.json"; -import { appPricingCheckoutURL, isAppStoreDistributionMode } from "../lib/billing"; +import { + appPricingCheckoutURL, + isAppStoreDistributionMode, + withExternalBrowserIntent, +} from "../lib/billing"; import { DOWNLOAD_CONFIRMATION_HREF } from "../lib/download"; import { - appPricingAppearance, - appPricingPageBackground, + appPricingTheme, appPricingStyle, } from "./appearance"; import { @@ -28,9 +31,19 @@ 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"; -const ENTERPRISE_CTA_URL = "/enterprise"; +const ENTERPRISE_CTA_URL = withExternalBrowserIntent("/enterprise"); const pricing = enMessages.pricing; const ANONYMOUS_IF_EXISTS = "anonymous-if-exists[deprecated]" as const; @@ -52,11 +65,17 @@ export default async function AppPricingPage({ appPricingRequest(headersList), ); const appStorePaymentGated = isAppStoreDistributionMode(params); - const proCheckoutURL = appPricingCheckoutURL("pro", requestOrigin, cmuxScheme); - const teamCheckoutURL = appPricingCheckoutURL("team", requestOrigin, cmuxScheme); + const interval = proBillingInterval(firstParam(params.interval)); + const proCheckoutHrefs = { + month: appPricingCheckoutURL("pro", requestOrigin, cmuxScheme, "month"), + year: appPricingCheckoutURL("pro", requestOrigin, cmuxScheme, "year"), + }; + const teamCheckoutHrefs = { + month: appPricingCheckoutURL("team", requestOrigin, cmuxScheme, "month"), + year: appPricingCheckoutURL("team", requestOrigin, cmuxScheme, "year"), + }; const banner = appPricingBanner(params); - const appearance = appPricingAppearance(params); - const pageBackground = appPricingPageBackground(params, appearance); + const theme = appPricingTheme(params); const proFeatures = visibleProFeatures({ base: pricing.pro.features, vault: pricing.pro.vaultFeatures, @@ -65,113 +84,200 @@ export default async function AppPricingPage({ const compareRows = visibleCompareRows(pricing.compare.rows as CompareRow[]); const sizeRows = pricing.sizes.rows as SizeRow[]; const faqItems = visibleFaqItems(pricing.faq.items as FaqItem[]); + const annualComparePrice = pricingMessage(pricing.annualComparePrice, { + 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 ( <>
{banner ? : null} -

{pricing.title}

- -
- {pricing.currentPlan} - ) : null - } - > - {snapshot.planId === FREE_PLAN_ID ? ( - {pricing.currentPlan} - ) : ( - - {pricing.free.cta} - - )} -

- {pricing.free.featuresLead} -

- -
+ +

{pricing.title}

+ - {pricing.currentPlan} - ) : null - } - > - {snapshot.isPro ? ( -
+
+ {pricing.currentPlan} + ) : null + } + > + {snapshot.planId === FREE_PLAN_ID ? ( {pricing.currentPlan} - {appStorePaymentGated ? null : ( - - {pricing.manageBilling} - - )} -
- ) : appStorePaymentGated ? ( - {pricing.billingUnavailable} - ) : ( - {pricing.pro.cta} - )} -

- {pricing.pro.featuresLead} -

- - + ) : ( + + {pricing.free.cta} + + )} +

+ {pricing.free.featuresLead} +

+ + - - {appStorePaymentGated ? ( - {pricing.billingUnavailable} - ) : ( - {pricing.team.cta} - )} -

- {pricing.team.featuresLead} -

- -
+ + } + period={ + + } + priceDetail={ + + {pricingMessage(pricing.annualPriceDetail, { + amount: PRO_PRICING_USD.year.billedAmount, + discount: PRO_PRICING_USD.year.discountPercent, + })} +

+ } + /> + } + badge={ + snapshot.isPro ? ( + {pricing.currentPlan} + ) : null + } + > + {snapshot.isPro ? ( +
+ {pricing.currentPlan} + {appStorePaymentGated ? null : ( + + {pricing.manageBilling} + + )} +
+ ) : appStorePaymentGated ? ( + {pricing.billingUnavailable} + ) : ( + + {pricing.pro.cta} + + )} +

+ {pricing.pro.featuresLead} +

+ +
- - {appStorePaymentGated ? ( - {pricing.billingUnavailable} - ) : ( - - {pricing.enterprise.cta} - - )} -

- {pricing.enterprise.featuresLead} -

- -
-
+ + } + period={ + + } + priceDetail={ + + {pricingMessage(pricing.annualPriceDetail, { + amount: TEAM_PRICING_USD.year.billedAmount, + discount: TEAM_PRICING_USD.year.discountPercent, + })} +

+ } + /> + } + > + {appStorePaymentGated ? ( + {pricing.billingUnavailable} + ) : ( + + {pricing.team.cta} + + )} +

+ {pricing.team.featuresLead} +

+ +
+ + + {appStorePaymentGated ? ( + {pricing.billingUnavailable} + ) : ( + + {pricing.enterprise.cta} + + )} +

+ {pricing.enterprise.featuresLead} +

+ +
+
+

+ {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} - + pro: ( + ), - enterprise: appStorePaymentGated ? ( - {pricing.billingUnavailable} - ) : ( - - {pricing.enterprise.cta} - + team: ( + ), + enterprise: pricing.enterprise.price, }} />
+ , +): string { + return message.replace(/\{(\w+)\}/g, (match, key: string) => { + const value = values[key]; + return value === undefined ? match : String(value); + }); +} + function BillingBanner({ banner }: { banner: BillingBannerModel }) { return (

{welcome.eyebrow}

@@ -105,6 +105,8 @@ export default async function AppProWelcomePage({
+ {/* Keep app-web navigation as a full document load in WKWebView. */} + {/* eslint-disable-next-line @next/next/no-html-link-for-pages */} > = null; try { @@ -91,8 +103,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/components/checkout-navigation.tsx b/web/app/components/checkout-navigation.tsx index 174927aca3d6..63787c2aa1fd 100644 --- a/web/app/components/checkout-navigation.tsx +++ b/web/app/components/checkout-navigation.tsx @@ -2,6 +2,7 @@ import { useCallback, useState, type CSSProperties, type MouseEvent, type ReactNode } from "react"; +import { posthog } from "../lib/posthog-client"; import { pricingActionClassName, type PricingActionSize } from "./pricing-shared"; const CHECKOUT_PATH = "/api/billing/checkout"; @@ -108,11 +109,16 @@ export function CheckoutButton({ children, size = "default", onClick, + analytics, }: { href: string; children: ReactNode; size?: PricingActionSize; onClick?: (event: MouseEvent) => void; + analytics?: { + event: string; + properties: Record; + }; }) { const { pending, start } = useCheckoutRedirect(); return ( @@ -120,6 +126,9 @@ export function CheckoutButton({ href={href} onClick={(event) => { onClick?.(event); + if (!event.defaultPrevented && analytics) { + posthog.capture(analytics.event, analytics.properties); + } start(href, event); }} aria-busy={pending} diff --git a/web/app/components/pricing-interval-selector.tsx b/web/app/components/pricing-interval-selector.tsx new file mode 100644 index 000000000000..94b06baf8b24 --- /dev/null +++ b/web/app/components/pricing-interval-selector.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { + createContext, + useCallback, + useContext, + useRef, + useState, + type KeyboardEvent, + type ReactNode, + type Ref, +} from "react"; + +import { posthog } from "../lib/posthog-client"; +import { + PRO_PRICING_USD, + 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"; +type PricingPlan = "pro" | "team"; +export type PricingCheckoutHrefs = Record; +export type ProCheckoutHrefs = PricingCheckoutHrefs; + +type PricingIntervalContextValue = { + interval: BillingInterval; + setInterval: (interval: BillingInterval) => void; +}; + +const PricingIntervalContext = + createContext(null); + +export function PricingIntervalProvider({ + initialInterval, + children, +}: { + initialInterval: BillingInterval; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +function PricingIntervalState({ + initialInterval, + children, +}: { + initialInterval: BillingInterval; + children: ReactNode; +}) { + const [interval, setInterval] = useState(initialInterval); + + return ( + + {children} + + ); +} + +export function PricingIntervalSelector({ + billingPeriodLabel, + monthlyLabel, + annualLabel, + savingsLabel, + surface, +}: { + billingPeriodLabel: string; + monthlyLabel: string; + annualLabel: string; + savingsLabel: string; + surface: PricingSurface; +}) { + 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; + capturedView.current = true; + capturePricingEvent("cmuxterm_pricing_viewed", interval, surface); + }, + [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 ( +
+ selectInterval("month")} + > + {monthlyLabel} + + selectInterval("year")} + > + {annualLabel} + + {savingsLabel} + + +
+ ); +} + +export function PricingIntervalValue({ + monthly, + annual, +}: { + monthly: ReactNode; + annual: ReactNode; +}) { + const { interval } = usePricingInterval(); + return interval === "year" ? annual : monthly; +} + +export function PricingCheckoutButton({ + hrefs, + children, + location, + plan = "pro", + size = "default", +}: { + hrefs: PricingCheckoutHrefs; + children: ReactNode; + location: string; + plan?: PricingPlan; + size?: PricingActionSize; +}) { + const { interval } = usePricingInterval(); + const pricing = plan === "pro" + ? PRO_PRICING_USD[interval] + : TEAM_PRICING_USD[interval]; + + return ( + + {children} + + ); +} + +function IntervalButton({ + buttonRef, + selected, + onSelect, + children, +}: { + buttonRef: Ref; + selected: boolean; + onSelect: () => void; + children: ReactNode; +}) { + return ( + + ); +} + +function usePricingInterval() { + const context = useContext(PricingIntervalContext); + if (!context) { + throw new Error( + "Pricing interval controls must be wrapped in PricingIntervalProvider", + ); + } + return context; +} + +function capturePricingEvent( + event: "cmuxterm_pricing_viewed" | "cmuxterm_pricing_interval_selected", + interval: BillingInterval, + surface: PricingSurface, +) { + const proPricing = PRO_PRICING_USD[interval]; + const teamPricing = TEAM_PRICING_USD[interval]; + posthog.capture(event, { + surface, + interval, + currency: "usd", + billed_amount_usd: proPricing.billedAmount, + monthly_equivalent_usd: proPricing.monthlyEquivalent, + discount_percent: proPricing.discountPercent, + team_billed_amount_usd: teamPricing.billedAmount, + team_monthly_equivalent_usd: teamPricing.monthlyEquivalent, + team_discount_percent: teamPricing.discountPercent, + }); +} diff --git a/web/app/components/pricing-shared.tsx b/web/app/components/pricing-shared.tsx index 002b1824399a..4230609ff6bd 100644 --- a/web/app/components/pricing-shared.tsx +++ b/web/app/components/pricing-shared.tsx @@ -66,12 +66,14 @@ export function PlanCard({ name, price, period, + priceDetail, badge, children, }: { name: string; - price: string; - period?: string; + price: ReactNode; + period?: ReactNode; + priceDetail?: ReactNode; badge?: ReactNode; children: ReactNode; }) { @@ -79,11 +81,18 @@ export function PlanCard({
{badge ?
{badge}
: null}

{name}

-
- {price} - {period ? {period} : null} +
+
+ {price} + {period ? ( + + {period} + + ) : null} +
+ {priceDetail}
-
{children}
+
{children}
); } @@ -177,7 +186,7 @@ export function PricingCompareTable({ }: { rows: CompareRow[]; names: Record; - prices: Record; + prices: Record; actions?: Partial>; stickyTopClassName?: string; }) { @@ -296,7 +305,7 @@ function ColumnHead({ action, }: { name: string; - price: string; + price: ReactNode; action?: ReactNode; }) { return ( diff --git a/web/app/env.ts b/web/app/env.ts index 44169c58e64b..4e1891173627 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -66,6 +66,18 @@ const requireVercelRelayValue = ( }); } }); +const retiredEnvValue = ( + name: string, + replacement: string, +): z.ZodType => + z.string().optional().superRefine((value, context) => { + if (value) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: `${name} is retired; use ${replacement}`, + }); + } + }); const privateRelayEnvNames = new Set([ "CMUX_RELAY_JWT_PRIVATE_KEY_PEM", "CMUX_RELAY_POLICY_KEY_ID", @@ -173,8 +185,17 @@ 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_PRICE_ID: retiredEnvValue( + "STRIPE_PRO_YEARLY_PRICE_ID", + "STRIPE_PRO_YEARLY_288_PRICE_ID", + ), + 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; @@ -306,7 +327,17 @@ export const env = createEnv({ 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), + 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/globals.css b/web/app/globals.css index 804e52aa4549..1a054c4ca052 100644 --- a/web/app/globals.css +++ b/web/app/globals.css @@ -9,6 +9,9 @@ --border: #e5e5e5; --link-underline: #c9c9c9; --code-bg: #f5f5f5; + --cmux-product-blue: #0088ff; + --cmux-product-blue-on-background: #0073d9; + --cmux-product-blue-on-foreground: #0088ff; } .dark { @@ -18,6 +21,9 @@ --border: #262626; --link-underline: #666666; --code-bg: #171717; + --cmux-product-blue: #0091ff; + --cmux-product-blue-on-background: #0091ff; + --cmux-product-blue-on-foreground: #006dc1; } @theme inline { 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 89f270a4dcfb..3f2b3b9c4746 100644 --- a/web/app/lib/billing.ts +++ b/web/app/lib/billing.ts @@ -1,37 +1,205 @@ -export const CHECKOUT_EXTERNAL_BROWSER_PARAM = "cmux_external_browser"; +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_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"; +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 = withCheckoutExternalBrowserIntent(PRO_CHECKOUT_PATH); -export const TEAM_CHECKOUT_URL = withCheckoutExternalBrowserIntent(TEAM_CHECKOUT_PATH); +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; +const APP_PRICING_RELAY_CLOCK_SKEW_SECONDS = 30; type SearchParamValue = string | string[] | null | undefined; -export function withCheckoutExternalBrowserIntent(href: string): string { - return withSearchParam(href, CHECKOUT_EXTERNAL_BROWSER_PARAM, "1"); +export function withExternalBrowserIntent(href: string): string { + return withSearchParam(href, EXTERNAL_BROWSER_PARAM, "1"); } +export const withCheckoutExternalBrowserIntent = withExternalBrowserIntent; + export function withCheckoutPlan(href: string, plan: CheckoutPlan): string { return withSearchParam(href, CHECKOUT_PLAN_PARAM, plan); } +export function withCheckoutInterval( + href: string, + interval: CheckoutInterval, +): string { + return withSearchParam(href, CHECKOUT_INTERVAL_PARAM, interval); +} + export function appPricingCheckoutURL( plan: CheckoutPlan, requestOrigin: string | null, cmuxScheme?: string | null, + interval?: CheckoutInterval, ): string { - let href = withCheckoutExternalBrowserIntent( - withCheckoutPlan(configuredAppPricingCheckoutURL(requestOrigin), plan), + let href = withExternalBrowserIntent( + 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, + parameters: AppPricingCheckoutRelayParameters, +): URL | null { + if (requestURL.searchParams.get(CHECKOUT_APP_RELAY_PARAM) !== "1") { + 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, 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; + } + if (!validRelayExpiry(expires)) return null; + const expected = relaySignature( + requestURL, + { plan, interval, cmuxScheme: scheme }, + expires, + secret, + ); + if (!constantTimeHexEqual(signature, expected)) return 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; @@ -47,10 +215,14 @@ export function appStorePricingUnavailableURL(requestUrl: URL): URL { redirectURL.searchParams.set("cmux_distribution", "appstore"); redirectURL.searchParams.set("billing", "unavailable"); - for (const key of ["appearance", "background"]) { + for (const key of ["appearance", "background", "foreground", "accent"]) { 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; } @@ -67,9 +239,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(); @@ -79,3 +249,130 @@ 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; +} + +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 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; +} + +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/app/lib/posthog-client.ts b/web/app/lib/posthog-client.ts new file mode 100644 index 000000000000..c2a8165cb641 --- /dev/null +++ b/web/app/lib/posthog-client.ts @@ -0,0 +1,16 @@ +"use client"; + +import posthog from "posthog-js"; + +if (typeof window !== "undefined") { + posthog.init("phc_opOVu7oFzR9wD3I6ZahFGOV2h3mqGpl5EHyQvmHciDP", { + api_host: "https://r.cmux.com", + ui_host: "https://us.posthog.com", + person_profiles: "identified_only", + capture_pageview: false, + capture_pageleave: true, + advanced_disable_feature_flags: true, + }); +} + +export { posthog }; diff --git a/web/messages/en.json b/web/messages/en.json index db537e3dd61b..a07e493bb56c 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -142,14 +142,18 @@ "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", "fallbackName": "your team", "activeBody": "{team} renews on {date}.", "pendingBody": "{team} is scheduled to end on {date}.", - "price": "$35/seat/month" + "price": "$35/seat/month", + "annualPrice": "$336/seat/year" }, "details": { "renewsOn": "Renews on", @@ -399,8 +403,18 @@ "metaDescriptionNoVault": "cmux is a free, open source macOS terminal for AI coding agents. Upgrade to Pro for Cloud VMs and a model gateway. Enterprise adds SSO and self-hosting.", "metaDescriptionNoVaultShort": "cmux is a free, open source macOS terminal. Pro adds Cloud VMs and a model gateway; Enterprise adds SSO and self-hosting.", "title": "Pricing", - "perMonth": "/month", - "perUserMonth": "/user/month", + "perMonth": "per month", + "perMonthBilledYearly": "per month billed yearly", + "perUserMonth": "per user per month", + "perUserMonthBilledYearly": "per user per month billed yearly", + "billingPeriod": "Billing period", + "monthly": "Monthly", + "annual": "Annual", + "saveAnnual": "Save {discount}%", + "annualPriceDetail": "Billed ${amount} annually · save {discount}%", + "annualComparePrice": "${monthly}/month · ${annual}/year", + "teamMonthlyComparePrice": "${monthly}/user/month", + "teamAnnualComparePrice": "${monthly}/user/month · ${annual}/user/year", "currentPlan": "Current plan", "manageBilling": "Manage billing", "free": { @@ -658,7 +672,7 @@ }, { "q": "How does billing work?", - "a": "Pro is $30 per month, or $240 per year (about $20 per month billed annually) and includes 20 active compute-hours per month on the 4 vCPU / 16 GB Cloud VM. Beyond that, cloud usage is billed per active compute-hour by VM size (see Cloud VM sizes above). Idle VMs suspend automatically, and running agents in parallel just uses more hours. The terminal and local agents stay free." + "a": "Pro is $30 per month, or $288 per year ($24 per month billed annually, a 20% savings), and includes 20 active compute-hours per month on the 4 vCPU / 16 GB Cloud VM. Beyond that, cloud usage is billed per active compute-hour by VM size (see Cloud VM sizes above). Idle VMs suspend automatically, and running agents in parallel just uses more hours. The terminal and local agents stay free." }, { "q": "Can I self-host the cloud?", @@ -666,7 +680,7 @@ }, { "q": "Do you have a team plan?", - "a": "Yes. Team is $35 per user per month: unified billing for the whole team, centralized seat management, pooled Cloud VM hours, and shared team rules. Enterprise adds SSO, self-hosting, and audit logs." + "a": "Yes. Team is $35 per user per month, or $336 per user per year ($28 per user per month billed annually): unified billing for the whole team, centralized seat management, pooled Cloud VM hours, and shared team rules. Enterprise adds SSO, self-hosting, and audit logs." } ] }, @@ -678,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 bc7663179dc9..f476a009c56c 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -142,14 +142,18 @@ "pro": { "name": "cmux Pro", "activeBody": "プランは{date}に更新されます。", - "pendingBody": "プランは{date}に終了する予定です。" + "pendingBody": "プランは{date}に終了する予定です。", + "monthlyPrice": "$30/月", + "annualPrice": "$288/年", + "legacyAnnualPrice": "$240/年" }, "team": { "name": "cmux Team", "fallbackName": "チーム", "activeBody": "{team}は{date}に更新されます。", "pendingBody": "{team}は{date}に終了する予定です。", - "price": "$35/シート/月" + "price": "$35/シート/月", + "annualPrice": "$336/シート/年" }, "details": { "renewsOn": "更新日", @@ -399,8 +403,18 @@ "metaDescriptionNoVault": "cmux はAIコーディングエージェント向けの無料・オープンソースの macOS ターミナルです。Pro では Cloud VM とモデルゲートウェイが使えます。Enterprise は SSO とセルフホストに対応します。", "metaDescriptionNoVaultShort": "cmux は無料・オープンソースの macOS ターミナルです。Pro では Cloud VM とモデルゲートウェイを利用でき、Enterprise では SSO とセルフホストに対応します。", "title": "料金", - "perMonth": "/月", - "perUserMonth": "/ユーザー/月", + "perMonth": "月額", + "perMonthBilledYearly": "月額(年払い)", + "perUserMonth": "ユーザーあたり月額", + "perUserMonthBilledYearly": "ユーザーあたり月額(年払い)", + "billingPeriod": "請求期間", + "monthly": "月払い", + "annual": "年払い", + "saveAnnual": "{discount}%割引", + "annualPriceDetail": "年額 ${amount} の一括払い · {discount}%割引", + "annualComparePrice": "${monthly}/月 · ${annual}/年", + "teamMonthlyComparePrice": "${monthly}/ユーザー/月", + "teamAnnualComparePrice": "${monthly}/ユーザー/月 · ${annual}/ユーザー/年", "currentPlan": "現在のプラン", "manageBilling": "請求を管理", "free": { @@ -658,7 +672,7 @@ }, { "q": "課金はどのように行われますか?", - "a": "Pro は月額 $30、または年額 $240(年払いでは月あたり約 $20)で、4 vCPU / 16 GB の Cloud VM で毎月20時間のアクティブ計算時間が含まれます。それを超えると、VM サイズに応じてアクティブな計算時間あたりで課金されます(上記の Cloud VM サイズを参照)。アイドルの VM は自動的に一時停止し、エージェントを並列実行するとより多くの時間を使うだけです。ターミナルとローカルエージェントは無料のままです。" + "a": "Pro は月額 $30、または年額 $288(年払いでは月あたり $24、20%割引)で、4 vCPU / 16 GB の Cloud VM で毎月20時間のアクティブ計算時間が含まれます。それを超えると、VM サイズに応じてアクティブな計算時間あたりで課金されます(上記の Cloud VM サイズを参照)。アイドルの VM は自動的に一時停止し、エージェントを並列実行するとより多くの時間を使うだけです。ターミナルとローカルエージェントは無料のままです。" }, { "q": "クラウドをセルフホストできますか?", @@ -666,7 +680,7 @@ }, { "q": "チームプランはありますか?", - "a": "はい。Team は1ユーザーあたり月額 $35 です。チーム全体の一元請求、シート管理、Cloud VM 時間のプール、共有チームルールが含まれます。Enterprise では SSO、セルフホスト、監査ログが追加されます。" + "a": "はい。Team は1ユーザーあたり月額 $35、または年額 $336(月額換算 $28、20%割引)です。チーム全体の一元請求、シート管理、Cloud VM 時間のプール、共有チームルールが含まれます。Enterprise では SSO、セルフホスト、監査ログが追加されます。" } ] }, @@ -678,6 +692,7 @@ "billingUnavailable": "現在、決済をご利用いただけません。時間をおいてお試しください。", "billingCancelled": "チェックアウトはキャンセルされました。請求は発生していません。", "billingInvalidPlan": "指定されたプランはご利用いただけません。以下からプランをお選びください。", + "billingInvalidRelay": "アプリのチェックアウトリンクの有効期限が切れているか、検証できませんでした。cmuxに戻ってもう一度お試しください。", "welcomeTeam": "cmux Team のご購入が完了しました。" }, "home": { diff --git a/web/scripts/stripe/provision-catalog.sh b/web/scripts/stripe/provision-catalog.sh new file mode 100755 index 000000000000..d00583761013 --- /dev/null +++ b/web/scripts/stripe/provision-catalog.sh @@ -0,0 +1,316 @@ +#!/usr/bin/env bash +set -euo pipefail + +MODE="${1:-}" +case "$MODE" in + live) + SECRET_FILE="${HOME}/.secrets/cmux-stripe-live.env" + KEY_NAME="STRIPE_LIVE_PROVISION_KEY" + KEY_PREFIX="live" + ;; + test) + SECRET_FILE="${HOME}/.secrets/cmux-stripe-test.env" + KEY_NAME="STRIPE_TEST_PROVISION_KEY" + KEY_PREFIX="test" + ;; + *) + echo "Usage: $(basename "$0") " >&2 + exit 2 + ;; +esac + +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" + "checkout.session.async_payment_succeeded" + "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 + printf 'Authorization: Bearer %s\n' "$STRIPE_PROVISION_KEY" | + curl -fsS --header @- --get "$@" "${STRIPE_API_BASE}${path}" +} + +stripe_post() { + local path="$1" + shift + 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_json product_id starting_after + local -a matching_product_ids=() + local -a page_args=(--data-urlencode "limit=100") + + starting_after="" + while :; do + page_args=(--data-urlencode "limit=100") + if [[ -n "$starting_after" ]]; then + page_args+=(--data-urlencode "starting_after=${starting_after}") + fi + response="$( + stripe_get "/products" "${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 + starting_after="$(jq -er '.data[-1].id' <<<"$response")" + done + + 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 + else + response="$( + stripe_post "/products" \ + --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 + fi + printf '%s' "$product_id" +} + +canonical_product() { + local monthly_lookup_key="$1" + local name="$2" + local plan="$3" + local response product_json product_id + + 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_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 + + 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_interval_count + 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_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 + 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 + webhook_ids="" + starting_after="" + webhook_page_args=(--data-urlencode "limit=100") + while :; do + webhook_page_args=(--data-urlencode "limit=100") + if [[ -n "$starting_after" ]]; then + webhook_page_args+=(--data-urlencode "starting_after=${starting_after}") + fi + webhooks_response="$( + stripe_get "/webhook_endpoints" "${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 + exit 1 + fi + webhook_id="$(sed -n '1p' <<<"$webhook_ids")" + + event_args=() + for event in "${EVENTS[@]}"; do + event_args+=(-d "enabled_events[]=${event}") + done + + if [[ -n "$webhook_id" ]]; then + echo "Found webhook endpoint: $webhook_id" + stripe_post "/webhook_endpoints/${webhook_id}" "${event_args[@]}" >/dev/null + echo "Updated webhook event subscriptions." + echo "Webhook signing secrets are only returned at creation time; use the existing production STRIPE_WEBHOOK_SECRET." + else + 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 + 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)." + 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 254f57534675..f2c85c814bcd 100755 --- a/web/scripts/stripe/provision-live.sh +++ b/web/scripts/stripe/provision-live.sh @@ -1,165 +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" - "checkout.session.async_payment_succeeded" - "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 - - 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 - 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" -ensure_price "cmux-pro-yearly" "24000" "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 -)" - -event_args=() -for event in "${EVENTS[@]}"; do - event_args+=(-d "enabled_events[]=${event}") -done - -if [[ -n "$webhook_id" ]]; then - echo "Found webhook endpoint: $webhook_id" - stripe_post "/webhook_endpoints/${webhook_id}" "${event_args[@]}" >/dev/null - echo "Updated webhook event subscriptions." - echo "Webhook signing secrets are only returned at creation time; use the existing production STRIPE_WEBHOOK_SECRET." -else - 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 new file mode 100644 index 000000000000..6b991f079bde --- /dev/null +++ b/web/services/billing/plans.ts @@ -0,0 +1,50 @@ +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: { + billedAmount: 30, + monthlyEquivalent: 30, + discountPercent: 0, + lookupKey: "cmux-pro-monthly", + }, + year: { + billedAmount: 288, + monthlyEquivalent: 24, + discountPercent: 20, + lookupKey: "cmux-pro-yearly-288", + }, +} 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 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 15eba657ae80..c6c4c4d0e8e4 100644 --- a/web/services/billing/stripe.ts +++ b/web/services/billing/stripe.ts @@ -1,18 +1,17 @@ import Stripe from "stripe"; import { env } from "../../app/env"; +import { + PRO_PRICING_USD, + TEAM_PRICING_USD, + type BillingInterval, +} from "./plans"; -export type ProBillingInterval = "month" | "year"; - -const PRO_PRICE_LOOKUP_KEYS: Record = { - month: "cmux-pro-monthly", - year: "cmux-pro-yearly", -}; -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); @@ -28,16 +27,16 @@ 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; + : env.STRIPE_PRO_YEARLY_288_PRICE_ID; if (overridden) return overridden; - const cached = resolvedPriceIds.get(interval); + const cached = resolvedProPriceIds.get(interval); if (cached) return cached; - const lookupKey = PRO_PRICE_LOOKUP_KEYS[interval]; + const lookupKey = PRO_PRICING_USD[interval].lookupKey; const prices = await stripe().prices.list({ active: true, lookup_keys: [lookupKey], @@ -47,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; } diff --git a/web/tests/after-sign-in-route.test.ts b/web/tests/after-sign-in-route.test.ts index 1853885ac541..f1805ba30701 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,54 @@ 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); + 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", + "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/app-pricing-appearance.test.ts b/web/tests/app-pricing-appearance.test.ts new file mode 100644 index 000000000000..79c4d5e1e57d --- /dev/null +++ b/web/tests/app-pricing-appearance.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from "bun:test"; + +import { + appPricingContrastAdjustedAccent, + appPricingTheme, +} from "../app/app-pricing/appearance"; + +describe("app pricing contrast-adjusted accent", () => { + test("keeps cmux blue when it already clears the small-text threshold", () => { + expect(appPricingContrastAdjustedAccent("#0091ff", "#0a0a0a")).toBe( + "#0091ff", + ); + expect(appPricingContrastAdjustedAccent("#0088ff", "#171717")).toBe( + "#0088ff", + ); + }); + + test("makes the smallest dark or light adjustment needed for the background", () => { + expect(appPricingContrastAdjustedAccent("#0088ff", "#fdf6e3")).toBe( + "#0071D5", + ); + expect(appPricingContrastAdjustedAccent("#0088ff", "#4a4543")).toBe( + "#6BB9FF", + ); + }); + + test("uses RGB distance when both contrast directions are readable", () => { + expect(appPricingContrastAdjustedAccent("#000040", "#8060D0")).toBe( + "#000000", + ); + }); + + test("derives separate canvas and selected-button tokens from Ghostty colors", () => { + const theme = appPricingTheme({ + appearance: "light", + background: "#fdf6e3", + foreground: "#4a4543", + accent: "#0088ff", + }); + + expect(theme.accent).toBe("#0088ff"); + expect(theme.accentOnBackground).toBe("#0071D5"); + expect(theme.accentOnForeground).toBe("#6BB9FF"); + }); + + test("accepts native precomputed tokens for first-paint parity", () => { + const theme = appPricingTheme({ + appearance: "light", + background: "#fdf6e3", + foreground: "#4a4543", + accent: "#0088ff", + accent_on_background: "#123456", + accent_on_foreground: "#abcdef", + }); + + 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("#0F8FFF"); + }); +}); diff --git a/web/tests/app-pricing-page.test.tsx b/web/tests/app-pricing-page.test.tsx index c506bb3ede69..295c10fc5eb5 100644 --- a/web/tests/app-pricing-page.test.tsx +++ b/web/tests/app-pricing-page.test.tsx @@ -96,9 +96,58 @@ describe("app pricing page", () => { expect(html).toContain( "http://localhost:9210/api/billing/checkout?plan=team&cmux_external_browser=1&cmux_scheme=cmux-dev-test", ); + 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"); }); + test("renders annual pricing and preserves native checkout context", async () => { + const element = await AppPricingPage({ + searchParams: Promise.resolve({ + cmux_app: "1", + cmux_scheme: "cmux-dev-test", + appearance: "dark", + background: "#112233", + foreground: "#ddeeff", + accent: "#0091ff", + interval: "year", + }), + }); + const html = renderToStaticMarkup(element); + + 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).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", + ); + expect(html).toContain( + "http://localhost:9210/api/billing/checkout?plan=team&cmux_external_browser=1&cmux_scheme=cmux-dev-test&interval=year", + ); + expect(html).toContain('role="radiogroup"'); + expect(html).toContain('