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:
RainbowBird
2026-05-26 01:56:47 +08:00
parent 223a1bfffe
commit 7e96386ec0
5 changed files with 94 additions and 11 deletions
+7
View File
@@ -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(
+3 -9
View File
@@ -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,
+23
View File
@@ -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
+44 -1
View File
@@ -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 {