Compare commits

...

11 Commits

Author SHA1 Message Date
Matt Miller
5f42ce95a7 Merge branch 'main' into mattmillerai/fix-customer-create-409 2026-06-22 13:10:06 -07:00
GitHub Action
76993b21c0 [automated] Apply ESLint and Oxfmt fixes 2026-06-19 02:50:57 +00:00
mattmiller
737c7c2190 fix: guard customerCreated and customerRecovery against session races
Two concurrent-auth bugs in recoverMissingCustomer:

1. Stale rejection race: the .catch cleared customerRecovery
   unconditionally, so a failed promise from session N could null
   out the in-flight promise for session N+1. Fix: capture the
   promise in a local ref and only null customerRecovery when it
   still points to this attempt.

2. createCustomer bleed: customerCreated.value = true fired after
   await even if the auth state had already reset to a different
   (or absent) session. Fix: snapshot the user uid before the
   fetch and skip the assignment if the session changed mid-flight.

Both races are covered by new Vitest tests.
2026-06-18 19:46:52 -07:00
Matt Miller
f528b7cd1a fix: harden customer 409 recovery (review findings)
- Reset customer provisioning state on auth state changes: without this,
  a second account in the same browser session was short-circuited by
  the previous account's memoized recovery and stayed stuck on 409s.
- Gate recovery on the missing-customer 409 body ("Failed to find
  customer") so business-level 409s from billing endpoints don't
  trigger spurious customer creation or a blind retry of a payment
  request. Also enforces the /customers/* contract via a URL path
  guard now that the wrapper is exported.
- Route addCredits' pre-flight customer creation through the shared
  recovery memo so it can't race a concurrent 409-triggered recovery
  into duplicate POST /customers.
- Guard the retry fetch: a network failure now returns the original
  409 response instead of escaping as a TypeError, and recovery
  failures are logged for observability.

Deliberately unchanged: Authorization headers are not re-resolved on
retry (the auth header source is caller-dependent — workspace vs user
scoped — and re-resolving risks swapping scopes; a stale token fails
safe into the caller's normal error path), and the shared recovery
promise has no timeout (bounded by the platform fetch timeout).

Adds tests: business-409 passthrough, non-customer-endpoint
passthrough, re-provisioning after auth state change, network-failure
retry fallback, concurrent credit pre-flight dedupe.
2026-06-17 11:49:30 -07:00
Matt Miller
44e37ee53d fix(cloud): self-heal missing customer record on 409 from /customers/* endpoints
Customer creation runs after the Firebase session is established during
sign-in/sign-up, so an interruption (navigation, closed window, network
failure) can leave a permanently signed-in user without a customer
record. Sessions restored from persisted credentials never re-run the
sign-in flow, so every /customers/* request (subscription status,
balance, checkout, billing portal) fails with 409 and nothing ever
retries the creation - affected users cannot subscribe at all.

Add a fetch wrapper in authStore that, on a 409 from a /customers/*
endpoint, provisions the customer record once (deduplicated across
concurrent callers, memoized per session on success) and retries the
original request a single time. If provisioning fails the original 409
is returned so callers keep their existing error handling. Wired into
all fetch-based /customers/* call sites.
2026-06-17 11:49:29 -07:00
Matt Miller
1fee4490d4 Merge branch 'main' into ci/cursor-review-workflow 2026-06-17 11:26:20 -07:00
Matt Miller
8d23daa33d fix: resolve review feedback 2026-06-17 11:25:19 -07:00
GitHub Action
880582ab5d [automated] Apply ESLint and Oxfmt fixes 2026-06-17 18:21:48 +00:00
Matt Miller
e498c4ae0d ci: bump cursor-review SHA to github-workflows#9, drop judge_model override 2026-06-17 11:17:58 -07:00
Matt Miller
4cadbb8af9 fix: resolve review feedback 2026-06-15 15:42:26 -07:00
Matt Miller
cc048464fa ci: add team-gated Cursor review (thin caller for github-workflows)
Calls the reusable Comfy-Org/github-workflows cursor-review.yml (single source of truth for panel, judge, prompts, scripts) instead of a standalone copy. Label-gated to the team; secret-bearing jobs skip fork PRs. Judge overridden to Opus 4.8.
2026-06-15 14:36:26 -07:00
7 changed files with 494 additions and 30 deletions

View File

@@ -104,6 +104,8 @@ vi.mock('@/stores/authStore', () => ({
useAuthStore: () =>
reactive({
getAuthHeader: mockGetAuthHeader,
fetchWithCustomerRecovery: (input: string, init?: RequestInit) =>
fetch(input, init),
userId: computed(() => mockUserId.value)
}),
AuthStoreError: class extends Error {}

View File

@@ -143,6 +143,8 @@ vi.mock('@/services/dialogService', () => ({
vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() => ({
getAuthHeader: mockGetAuthHeader,
fetchWithCustomerRecovery: (input: string, init?: RequestInit) =>
fetch(input, init),
get isInitialized() {
return mockAuthStoreInitialized.value
},

View File

@@ -53,7 +53,7 @@ function useSubscriptionInternal() {
const { showSubscriptionRequiredDialog } = useDialogService()
const authStore = useAuthStore()
const { getAuthHeader } = authStore
const { getAuthHeader, fetchWithCustomerRecovery } = authStore
const { wrapWithErrorHandlingAsync } = useErrorHandling()
const { isLoggedIn } = useCurrentUser()
@@ -326,7 +326,7 @@ function useSubscriptionInternal() {
async function fetchSubscriptionStatus(): Promise<CloudSubscriptionStatusResponse | null> {
const headers = await buildAuthHeaders()
const response = await fetch(
const response = await fetchWithCustomerRecovery(
buildApiUrl('/customers/cloud-subscription-status'),
{
headers
@@ -416,7 +416,7 @@ function useSubscriptionInternal() {
const headers = await buildAuthHeaders()
const checkoutAttribution = await getCheckoutAttributionForCloud()
const response = await fetch(
const response = await fetchWithCustomerRecovery(
buildApiUrl('/customers/cloud-subscription-checkout'),
{
method: 'POST',

View File

@@ -71,6 +71,8 @@ vi.mock('@/stores/authStore', () => ({
useAuthStore: vi.fn(() =>
reactive({
getAuthHeader: mockGetAuthHeader,
fetchWithCustomerRecovery: (input: string, init?: RequestInit) =>
fetch(input, init),
userId: computed(() => mockUserId.value)
})
),

View File

@@ -70,7 +70,7 @@ export async function performSubscriptionCheckout(
}
const checkoutPayload = { ...checkoutAttribution }
const response = await fetch(
const response = await authStore.fetchWithCustomerRecovery(
`${getComfyApiBaseUrl()}/customers/cloud-subscription-checkout/${checkoutTier}`,
{
method: 'POST',

View File

@@ -859,4 +859,330 @@ describe('useAuthStore', () => {
await expect(store.createCustomer()).rejects.toThrow()
})
})
describe('fetchWithCustomerRecovery', () => {
const make409 = (message: string) => {
const body = { message }
return {
ok: false,
status: 409,
statusText: 'Conflict',
json: () => Promise.resolve(body),
clone: () => ({ json: () => Promise.resolve(body) })
}
}
const makeConflictResponse = () => make409('Failed to find customer')
const countCustomerPosts = () =>
mockFetch.mock.calls.filter(
([url, init]) =>
typeof url === 'string' &&
url.endsWith('/customers') &&
(init as RequestInit | undefined)?.method === 'POST'
).length
it('should provision the customer and retry once when a /customers/* call returns 409', async () => {
let balanceCalls = 0
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
return Promise.resolve(mockCreateCustomerResponse)
}
if (url.endsWith('/customers/balance')) {
balanceCalls++
return Promise.resolve(
balanceCalls === 1
? makeConflictResponse()
: mockFetchBalanceResponse
)
}
return Promise.reject(new Error('Unexpected API call'))
})
const result = await store.fetchBalance()
expect(result).toEqual({ balance: 0 })
expect(balanceCalls).toBe(2)
expect(countCustomerPosts()).toBe(1)
})
it('should deduplicate concurrent recovery attempts into a single customer creation', async () => {
const seenUrls = new Set<string>()
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
return Promise.resolve(mockCreateCustomerResponse)
}
if (!seenUrls.has(url)) {
seenUrls.add(url)
return Promise.resolve(makeConflictResponse())
}
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
})
const [first, second] = await Promise.all([
store.fetchWithCustomerRecovery('https://api.test/customers/balance'),
store.fetchWithCustomerRecovery(
'https://api.test/customers/cloud-subscription-status'
)
])
expect(first.ok).toBe(true)
expect(second.ok).toBe(true)
expect(countCustomerPosts()).toBe(1)
})
it('should not provision the customer again after a successful recovery', async () => {
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
return Promise.resolve(mockCreateCustomerResponse)
}
// Endpoint keeps conflicting even after recovery succeeds
return Promise.resolve(makeConflictResponse())
})
const first = await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
const second = await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
expect(first.status).toBe(409)
expect(second.status).toBe(409)
expect(countCustomerPosts()).toBe(1)
})
it('should return the original 409 response when customer provisioning fails', async () => {
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
return Promise.resolve({
ok: false,
status: 500,
statusText: 'Internal Server Error'
})
}
return Promise.resolve(makeConflictResponse())
})
const response = await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
expect(response.status).toBe(409)
expect(countCustomerPosts()).toBe(1)
})
it('should pass through non-409 responses without provisioning', async () => {
mockFetch.mockImplementation(() =>
Promise.resolve({ ok: true, json: () => Promise.resolve({}) })
)
const response = await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
expect(response.ok).toBe(true)
expect(countCustomerPosts()).toBe(0)
})
it('should not provision for a 409 that is not a missing-customer conflict', async () => {
mockFetch.mockImplementation(() =>
Promise.resolve(make409('Subscription already active'))
)
const response = await store.fetchWithCustomerRecovery(
'https://api.test/customers/cloud-subscription-checkout/standard',
{ method: 'POST' }
)
expect(response.status).toBe(409)
expect(countCustomerPosts()).toBe(0)
})
it('should not provision for a 409 from a non-customer endpoint', async () => {
mockFetch.mockImplementation(() =>
Promise.resolve(makeConflictResponse())
)
const response = await store.fetchWithCustomerRecovery(
'https://api.test/workflows'
)
expect(response.status).toBe(409)
expect(countCustomerPosts()).toBe(0)
})
it('should re-provision after the auth state changes to a different session', async () => {
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
return Promise.resolve(mockCreateCustomerResponse)
}
return Promise.resolve(makeConflictResponse())
})
await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
expect(countCustomerPosts()).toBe(1)
// Sign out, then a different account signs in: the memoized recovery
// from the previous account must not short-circuit the new one.
authStateCallback(null)
authStateCallback(mockUser)
await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
expect(countCustomerPosts()).toBe(2)
})
it('should return the original 409 when the retry fails at the network level', async () => {
let balanceCalls = 0
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
return Promise.resolve(mockCreateCustomerResponse)
}
balanceCalls++
return balanceCalls === 1
? Promise.resolve(makeConflictResponse())
: Promise.reject(new TypeError('network down'))
})
const response = await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
expect(response.status).toBe(409)
expect(countCustomerPosts()).toBe(1)
})
it('should share one customer creation between concurrent credit pre-flights', async () => {
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
return Promise.resolve(mockCreateCustomerResponse)
}
if (url.endsWith('/customers/credit')) {
return Promise.resolve({
ok: true,
json: () =>
Promise.resolve({ checkout_url: 'https://stripe.test/checkout' })
})
}
return Promise.reject(new Error('Unexpected API call'))
})
await Promise.all([
store.initiateCreditPurchase({
amount_micros: 5_000_000,
currency: 'usd'
}),
store.initiateCreditPurchase({
amount_micros: 5_000_000,
currency: 'usd'
})
])
expect(countCustomerPosts()).toBe(1)
})
it('stale rejection from previous session does not null out a new in-flight recovery', async () => {
let rejectSession1Create!: (reason: unknown) => void
let resolveSession2Create!: (value: Response) => void
let postCount = 0
const session1CreateP = new Promise<Response>((_, reject) => {
rejectSession1Create = reject
})
const session2CreateP = new Promise<Response>((resolve) => {
resolveSession2Create = resolve
})
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
postCount++
return postCount === 1 ? session1CreateP : session2CreateP
}
return Promise.resolve(makeConflictResponse())
})
// Session 1: trigger a recovery whose POST will hang
const session1Done = store
.fetchWithCustomerRecovery('https://api.test/customers/balance')
.then(() => 'session1')
.catch(() => 'session1-failed')
// Drain microtasks so session1's POST is registered
await new Promise<void>((resolve) => setTimeout(resolve, 0))
// Auth resets; new session starts
authStateCallback(null)
authStateCallback(mockUser)
// Session 2 recovery — should create a new independent in-flight promise
const session2Done = store
.fetchWithCustomerRecovery('https://api.test/customers/balance')
.then(() => 'session2')
.catch(() => 'session2-failed')
await new Promise<void>((resolve) => setTimeout(resolve, 0))
// Session 1's POST fails — stale rejection fires; must NOT null out session 2's recovery
rejectSession1Create(new Error('network error'))
await session1Done
// Session 2's POST resolves successfully
resolveSession2Create({
ok: true,
statusText: 'OK',
json: () => Promise.resolve({ id: 'id-2' })
} as Response)
const session2Result = await session2Done
// Session 2 must succeed, proving its recovery was not nulled by session 1's rejection
expect(session2Result).toBe('session2')
})
it('does not skip re-provisioning when createCustomer resolves after sign-out', async () => {
let resolveCreate!: (value: Response) => void
const slowCreateP = new Promise<Response>((resolve) => {
resolveCreate = resolve
})
let postCount = 0
mockFetch.mockImplementation((url: string, init?: RequestInit) => {
if (url.endsWith('/customers') && init?.method === 'POST') {
postCount++
return postCount === 1
? slowCreateP
: Promise.resolve(mockCreateCustomerResponse)
}
return Promise.resolve(makeConflictResponse())
})
// Session 1 triggers recovery with a slow POST
const session1Done = store
.fetchWithCustomerRecovery('https://api.test/customers/balance')
.catch(() => {})
await new Promise<void>((resolve) => setTimeout(resolve, 0))
// User signs out before the POST resolves
authStateCallback(null)
authStateCallback(mockUser)
// Stale POST resolves successfully after session reset
resolveCreate!({
ok: true,
statusText: 'OK',
json: () => Promise.resolve({ id: 'stale-id' })
} as Response)
await session1Done
// A fresh recovery for the new session must POST again;
// customerCreated must not have been set by the stale resolution.
await store.fetchWithCustomerRecovery(
'https://api.test/customers/balance'
)
expect(postCount).toBe(2)
})
})
})

View File

@@ -69,6 +69,13 @@ export const useAuthStore = defineStore('auth', () => {
const currentUser = ref<User | null>(null)
const isInitialized = ref(false)
const customerCreated = ref(false)
/**
* Memoizes the in-flight or successful customer provisioning attempt for
* the current account (see recoverMissingCustomer). Declared here so the
* auth-state listener below can reset it before its initializer would
* otherwise run.
*/
let customerRecovery: Promise<void> | null = null
const isFetchingBalance = ref(false)
// Balance state
@@ -134,6 +141,12 @@ export const useAuthStore = defineStore('auth', () => {
// Reset balance when auth state changes
balance.value = null
lastBalanceUpdateTime.value = null
// Customer provisioning state is per-account: without this reset, a
// second account in the same browser session would be short-circuited by
// the previous account's memoized recovery and stay stuck on 409s.
customerCreated.value = false
customerRecovery = null
})
// Listen for token refresh events
@@ -261,12 +274,15 @@ export const useAuthStore = defineStore('auth', () => {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(buildApiUrl('/customers/balance'), {
headers: {
...authHeader,
'Content-Type': 'application/json'
const response = await fetchWithCustomerRecovery(
buildApiUrl('/customers/balance'),
{
headers: {
...authHeader,
'Content-Type': 'application/json'
}
}
})
)
if (!response.ok) {
if (response.status === 404) {
@@ -292,6 +308,7 @@ export const useAuthStore = defineStore('auth', () => {
}
const createCustomer = async (): Promise<CreateCustomerResponse> => {
const sessionUserId = currentUser.value?.uid
const authHeader = await getAuthHeader()
if (!authHeader) {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
@@ -322,9 +339,116 @@ export const useAuthStore = defineStore('auth', () => {
)
}
if (currentUser.value?.uid === sessionUserId) {
customerCreated.value = true
}
return createCustomerResJson
}
/**
* Memoizes the customer provisioning attempt so concurrent or repeated 409
* responses from /customers/* endpoints trigger at most one successful
* POST /customers per account. A failed attempt is cleared so a later
* request can retry, e.g. after a transient network failure.
*/
const recoverMissingCustomer = (): Promise<void> => {
if (customerRecovery === null) {
const thisRecovery: Promise<void> = createCustomer()
.then(() => undefined)
.catch((error: unknown) => {
if (customerRecovery === thisRecovery) {
customerRecovery = null
}
throw error
})
customerRecovery = thisRecovery
}
return customerRecovery
}
/**
* Fetch wrapper for /customers/* endpoints that self-heals accounts whose
* customer record was never provisioned.
*
* Customer creation runs after the Firebase session is established during
* sign-in/sign-up, so an interruption (navigation, closed window, network
* failure) can leave a permanently signed-in user without a customer
* record. Sessions restored from persisted credentials never re-run the
* sign-in flow, so every /customers/* request fails with 409 and nothing
* ever retries the creation.
*
* On a 409 response this provisions the customer record (deduplicated
* across concurrent callers) and retries the original request a single
* time. If recovery fails, the original 409 response is returned so
* callers surface their normal error handling.
*/
/**
* The auth middleware rejects requests for accounts without a customer
* record using this exact message. Business-level 409s from /customers/*
* endpoints (e.g. conflicting subscription state) must NOT trigger
* provisioning or a blind retry of a payment request.
*/
const MISSING_CUSTOMER_MESSAGE = 'Failed to find customer'
const isMissingCustomerResponse = async (
response: Response
): Promise<boolean> => {
if (response.status !== 409) return false
try {
const body: unknown = await response.clone().json()
return (
typeof body === 'object' &&
body !== null &&
'message' in body &&
(body as { message: unknown }).message === MISSING_CUSTOMER_MESSAGE
)
} catch {
return false
}
}
const isCustomerEndpoint = (input: string): boolean => {
try {
const { pathname } = new URL(input, window.location.href)
return pathname === '/customers' || pathname.includes('/customers/')
} catch {
return false
}
}
const fetchWithCustomerRecovery = async (
input: string,
init?: RequestInit
): Promise<Response> => {
const response = await fetch(input, init)
if (
!isCustomerEndpoint(input) ||
!(await isMissingCustomerResponse(response))
) {
return response
}
try {
await recoverMissingCustomer()
} catch (error) {
console.warn(
'Customer provisioning during 409 recovery failed; returning original response',
error
)
return response
}
try {
return await fetch(input, init)
} catch (error) {
console.warn(
'Retry after customer provisioning failed; returning original 409 response',
error
)
return response
}
}
const executeAuthAction = async <T>(
action: (auth: Auth) => Promise<T>,
options: {
@@ -467,20 +591,24 @@ export const useAuthStore = defineStore('auth', () => {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
// Ensure customer was created during login/registration
// Ensure customer was created during login/registration. Routed through
// recoverMissingCustomer so a concurrent 409-triggered recovery and this
// pre-flight share one POST /customers instead of racing.
if (!customerCreated.value) {
await createCustomer()
customerCreated.value = true
await recoverMissingCustomer()
}
const response = await fetch(buildApiUrl('/customers/credit'), {
method: 'POST',
headers: {
...authHeader,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBodyContent)
})
const response = await fetchWithCustomerRecovery(
buildApiUrl('/customers/credit'),
{
method: 'POST',
headers: {
...authHeader,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestBodyContent)
}
)
if (!response.ok) {
const errorData = await response.json()
@@ -507,16 +635,19 @@ export const useAuthStore = defineStore('auth', () => {
throw new AuthStoreError(t('toastMessages.userNotAuthenticated'))
}
const response = await fetch(buildApiUrl('/customers/billing'), {
method: 'POST',
headers: {
...authHeader,
'Content-Type': 'application/json'
},
...(targetTier && {
body: JSON.stringify({ target_tier: targetTier })
})
})
const response = await fetchWithCustomerRecovery(
buildApiUrl('/customers/billing'),
{
method: 'POST',
headers: {
...authHeader,
'Content-Type': 'application/json'
},
...(targetTier && {
body: JSON.stringify({ target_tier: targetTier })
})
}
)
if (!response.ok) {
const errorData = await response.json()
@@ -550,6 +681,7 @@ export const useAuthStore = defineStore('auth', () => {
register,
logout,
createCustomer,
fetchWithCustomerRecovery,
getIdToken,
loginWithGoogle,
loginWithGithub,