fix(server): unblock FLUX checkout from Electron desktop renderer
The packaged Electron renderer loads from file://, so its Stripe checkout
and portal requests carry no Referer and an opaque/absent Origin.
resolveTrustedRequestOrigin() returned undefined and the routes threw
INVALID_ORIGIN ("Missing trusted request origin"), blocking FLUX purchases
on desktop. Web and mobile were unaffected because they send a trusted web
origin. CORS was not the blocker: file:// requests already reach the API
(balance, providers, characters all work), so only the checkout-specific
redirect-base dependency failed.
Stripe success_url/cancel_url/return_url must be http(s) URLs, so file://
can never be a valid redirect base. Replace the trusted-origin requirement
with resolveCheckoutRedirectBase(), which prefers the request's trusted
origin (web/mobile return to where they started) and falls back to a
configured canonical web app URL (WEB_APP_URL, default https://airi.moeru.ai)
for origin-less clients like the desktop app.
On the client, the desktop app opens checkout in the system browser via
window.open (routed through setWindowOpenHandler -> shell.openExternal)
instead of navigating the file:// window to Stripe, and refreshes the FLUX
balance on window focus since it never receives the success_url redirect.
This commit is contained in:
@@ -80,6 +80,13 @@ const EnvSchema = object({
|
||||
|
||||
API_SERVER_URL: optional(string(), 'http://localhost:3000'),
|
||||
|
||||
// Canonical user-facing web app origin. Used as the Stripe redirect base
|
||||
// (success_url / cancel_url / portal return_url) when a request has no trusted
|
||||
// browser origin — notably the Electron desktop renderer, which loads from
|
||||
// file:// and sends no usable web origin. Web/mobile requests keep returning to
|
||||
// their own origin; only origin-less clients fall back to this.
|
||||
WEB_APP_URL: optional(string(), 'https://airi.moeru.ai'),
|
||||
|
||||
// Comma-separated exact origins (e.g. Capacitor dev server `https://10.x:5273`).
|
||||
// Prefer this over broad private-IP regex heuristics in production-like configs.
|
||||
ADDITIONAL_TRUSTED_ORIGINS: optional(
|
||||
|
||||
@@ -20,7 +20,7 @@ import { rateLimiter } from '../../middlewares/rate-limit'
|
||||
import { captureSafe } from '../../services/adapters/posthog'
|
||||
import { createBadRequestError, createServiceUnavailableError } from '../../utils/error'
|
||||
import { errorMessageFromUnknown } from '../../utils/error-message'
|
||||
import { resolveTrustedRequestOrigin } from '../../utils/origin'
|
||||
import { resolveCheckoutRedirectBase } from '../../utils/origin'
|
||||
import { createRedisKey } from '../../utils/redis-keys'
|
||||
import { CheckoutBodySchema } from './schema'
|
||||
|
||||
@@ -177,10 +177,7 @@ export function createStripeRoutes(
|
||||
const customer = await stripeService.getCustomerByUserId(user.id)
|
||||
const stripeCustomerId = customer?.stripeCustomerId
|
||||
|
||||
const redirectBase = resolveTrustedRequestOrigin(c.req.raw, env.ADDITIONAL_TRUSTED_ORIGINS)
|
||||
if (!redirectBase) {
|
||||
throw createBadRequestError('Missing trusted request origin', 'INVALID_ORIGIN')
|
||||
}
|
||||
const redirectBase = resolveCheckoutRedirectBase(c.req.raw, env.ADDITIONAL_TRUSTED_ORIGINS, env.WEB_APP_URL)
|
||||
|
||||
const paymentMethods = await configKV.getOptional('STRIPE_PAYMENT_METHODS')
|
||||
const paymentMethodOptions = await configKV.getOptional('STRIPE_PAYMENT_METHOD_OPTIONS') ?? {}
|
||||
@@ -252,10 +249,7 @@ export function createStripeRoutes(
|
||||
if (!customer)
|
||||
throw createBadRequestError('No billing account found', 'NO_CUSTOMER')
|
||||
|
||||
const portalReturnBase = resolveTrustedRequestOrigin(c.req.raw, env.ADDITIONAL_TRUSTED_ORIGINS)
|
||||
if (!portalReturnBase) {
|
||||
throw createBadRequestError('Missing trusted request origin', 'INVALID_ORIGIN')
|
||||
}
|
||||
const portalReturnBase = resolveCheckoutRedirectBase(c.req.raw, env.ADDITIONAL_TRUSTED_ORIGINS, env.WEB_APP_URL)
|
||||
|
||||
const portalSession = await stripe.billingPortal.sessions.create({
|
||||
customer: customer.stripeCustomerId,
|
||||
|
||||
@@ -85,6 +85,29 @@ export function resolveTrustedRequestOrigin(
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the base URL for Stripe redirect targets (`success_url` / `cancel_url` / portal `return_url`).
|
||||
*
|
||||
* Prefers the request's trusted browser origin so web and mobile users return to the surface they
|
||||
* started from. Falls back to the configured web app URL when the request carries no trusted origin —
|
||||
* notably the Electron desktop renderer, which loads from `file://` and sends no usable web origin,
|
||||
* so Stripe (which only accepts http/https redirect URLs) can still land users on a real page.
|
||||
*
|
||||
* Expects:
|
||||
* - Same trust inputs as {@link resolveTrustedRequestOrigin}.
|
||||
* - `webAppFallbackUrl` is an absolute origin used verbatim as the base.
|
||||
*
|
||||
* Returns:
|
||||
* - The trusted request origin when present, otherwise `webAppFallbackUrl` (always a usable base).
|
||||
*/
|
||||
export function resolveCheckoutRedirectBase(
|
||||
request: Request,
|
||||
additionalTrustedOrigins: readonly string[],
|
||||
webAppFallbackUrl: string,
|
||||
): string {
|
||||
return resolveTrustedRequestOrigin(request, additionalTrustedOrigins) ?? webAppFallbackUrl
|
||||
}
|
||||
|
||||
// NOTICE:
|
||||
// Better Auth's callbackURL validation walks `trustedOrigins`. Static entries
|
||||
// support `*` wildcards via the framework's wildcardMatch (see
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { getAuthTrustedOrigins, getTrustedOrigin, resolveTrustedRequestOrigin } from '../origin'
|
||||
import { getAuthTrustedOrigins, getTrustedOrigin, resolveCheckoutRedirectBase, resolveTrustedRequestOrigin } from '../origin'
|
||||
|
||||
describe('origin utils', () => {
|
||||
it('allows localhost origins', () => {
|
||||
@@ -66,6 +66,49 @@ describe('origin utils', () => {
|
||||
])
|
||||
})
|
||||
|
||||
describe('resolveCheckoutRedirectBase', () => {
|
||||
const fallback = 'https://airi.moeru.ai'
|
||||
|
||||
it('prefers the trusted request origin over the fallback', () => {
|
||||
const request = new Request('http://localhost/api/v1/stripe/checkout', {
|
||||
headers: { referer: 'http://localhost:5173/settings/flux' },
|
||||
})
|
||||
|
||||
expect(resolveCheckoutRedirectBase(request, [], fallback)).toBe('http://localhost:5173')
|
||||
})
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// The packaged Electron renderer loads from file://, so its Stripe checkout
|
||||
// request carries no Referer and an opaque/absent Origin. resolveTrustedRequestOrigin
|
||||
// then returns undefined and the checkout route threw
|
||||
// `createBadRequestError('Missing trusted request origin', 'INVALID_ORIGIN')`,
|
||||
// blocking FLUX purchases on desktop (web/mobile were unaffected because they
|
||||
// send a trusted web origin).
|
||||
//
|
||||
// Before patch: no trusted origin -> undefined -> route throws INVALID_ORIGIN.
|
||||
// After patch: no trusted origin -> falls back to the configured web app URL,
|
||||
// which Stripe accepts as a success_url/cancel_url base.
|
||||
it('falls back to the web app URL when the request has no trusted origin (Electron file://)', () => {
|
||||
const request = new Request('http://localhost/api/v1/stripe/checkout', {
|
||||
method: 'POST',
|
||||
// file:// renderers send no Referer; Origin is absent or the opaque literal "null".
|
||||
headers: { origin: 'null' },
|
||||
})
|
||||
|
||||
expect(resolveTrustedRequestOrigin(request, [])).toBeUndefined()
|
||||
expect(resolveCheckoutRedirectBase(request, [], fallback)).toBe(fallback)
|
||||
})
|
||||
|
||||
it('falls back to the web app URL for an untrusted web origin', () => {
|
||||
const request = new Request('http://localhost/api/v1/stripe/checkout', {
|
||||
headers: { origin: 'https://evil.example.com' },
|
||||
})
|
||||
|
||||
expect(resolveCheckoutRedirectBase(request, [], fallback)).toBe(fallback)
|
||||
})
|
||||
})
|
||||
|
||||
it('includes ADDITIONAL_TRUSTED_ORIGINS in Better Auth trustedOrigins list', () => {
|
||||
expect(getAuthTrustedOrigins({
|
||||
API_SERVER_URL: 'https://api.airi.moeru.ai',
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { isStageTamagotchi } from '@proj-airi/stage-shared'
|
||||
import { client } from '@proj-airi/stage-ui/composables/api'
|
||||
import { useAnalytics } from '@proj-airi/stage-ui/composables/use-analytics'
|
||||
import { useAuthStore } from '@proj-airi/stage-ui/stores/auth'
|
||||
import { Button, SelectTab } from '@proj-airi/ui'
|
||||
import { useEventListener } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
@@ -15,6 +17,13 @@ const authStore = useAuthStore()
|
||||
const { credits } = storeToRefs(authStore)
|
||||
const { trackPricingViewed, trackPlanSelected, trackCheckoutStarted } = useAnalytics()
|
||||
|
||||
// On desktop, checkout happens in the external system browser (see handleBuy), so
|
||||
// the app never receives the success_url redirect that web/mobile use to refresh.
|
||||
// Re-pull the FLUX balance whenever the window regains focus; the balance source
|
||||
// of truth is the server (credited by the Stripe webhook).
|
||||
if (isStageTamagotchi())
|
||||
useEventListener(window, 'focus', () => authStore.updateCredits())
|
||||
|
||||
interface FluxPackage {
|
||||
stripePriceId: string
|
||||
label: string
|
||||
@@ -260,7 +269,14 @@ async function handleBuy(stripePriceId: string) {
|
||||
// the page nav so the event is sent (PostHog's beforeunload handler
|
||||
// would otherwise race the navigation).
|
||||
trackCheckoutStarted(stripePriceId, { currency: selectedCurrency.value })
|
||||
window.location.href = data.url
|
||||
// Electron renderer runs from file:// and cannot navigate to Stripe in-window
|
||||
// (the settings window would load checkout.stripe.com and never come back).
|
||||
// window.open routes through setWindowOpenHandler -> shell.openExternal, so the
|
||||
// system browser handles payment. Web keeps the in-window redirect.
|
||||
if (isStageTamagotchi())
|
||||
window.open(data.url, '_blank')
|
||||
else
|
||||
window.location.href = data.url
|
||||
}
|
||||
}
|
||||
catch {
|
||||
|
||||
Reference in New Issue
Block a user