From 7e96386ec009df7427f04d4ad43f7ed9d55a454b Mon Sep 17 00:00:00 2001 From: RainbowBird Date: Tue, 26 May 2026 01:32:01 +0800 Subject: [PATCH] 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. --- apps/server/src/libs/env.ts | 7 +++ apps/server/src/routes/stripe/index.ts | 12 ++--- apps/server/src/utils/origin.ts | 23 ++++++++++ apps/server/src/utils/tests/origin.test.ts | 45 ++++++++++++++++++- .../stage-pages/src/pages/settings/flux.vue | 18 +++++++- 5 files changed, 94 insertions(+), 11 deletions(-) diff --git a/apps/server/src/libs/env.ts b/apps/server/src/libs/env.ts index dc19de6c4..e56a1a571 100644 --- a/apps/server/src/libs/env.ts +++ b/apps/server/src/libs/env.ts @@ -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( diff --git a/apps/server/src/routes/stripe/index.ts b/apps/server/src/routes/stripe/index.ts index c98270415..418f1947e 100644 --- a/apps/server/src/routes/stripe/index.ts +++ b/apps/server/src/routes/stripe/index.ts @@ -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, diff --git a/apps/server/src/utils/origin.ts b/apps/server/src/utils/origin.ts index 7bf31195f..557006451 100644 --- a/apps/server/src/utils/origin.ts +++ b/apps/server/src/utils/origin.ts @@ -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 diff --git a/apps/server/src/utils/tests/origin.test.ts b/apps/server/src/utils/tests/origin.test.ts index ef8faecc6..790fd6950 100644 --- a/apps/server/src/utils/tests/origin.test.ts +++ b/apps/server/src/utils/tests/origin.test.ts @@ -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', diff --git a/packages/stage-pages/src/pages/settings/flux.vue b/packages/stage-pages/src/pages/settings/flux.vue index be9481e61..5344d3e7f 100644 --- a/packages/stage-pages/src/pages/settings/flux.vue +++ b/packages/stage-pages/src/pages/settings/flux.vue @@ -1,8 +1,10 @@