diff --git a/packages/backend/src/billing/billing.constants.ts b/packages/backend/src/billing/billing.constants.ts index b55d2d3ca..1496594b6 100644 --- a/packages/backend/src/billing/billing.constants.ts +++ b/packages/backend/src/billing/billing.constants.ts @@ -53,5 +53,5 @@ export function getStripePriceId(): string { if (!priceId) { throw new Error("STRIPE_PRICE_ID is not configured"); } - return priceId; + return priceId.trim(); } diff --git a/packages/backend/src/billing/billing.errors.ts b/packages/backend/src/billing/billing.errors.ts new file mode 100644 index 000000000..8096f2130 --- /dev/null +++ b/packages/backend/src/billing/billing.errors.ts @@ -0,0 +1,33 @@ +import Stripe from "stripe"; +import { Status } from "@core/errors/status.codes"; + +export class BillingHttpError extends Error { + readonly status: number; + readonly clientMessage: string; + + constructor(status: number, clientMessage: string, cause?: unknown) { + const detail = cause instanceof Error ? cause.message : clientMessage; + super(detail); + this.name = "BillingHttpError"; + this.status = status; + this.clientMessage = clientMessage; + if (cause !== undefined) { + this.cause = cause; + } + } +} + +const BILLING_CLIENT_MESSAGE = + "Couldn't start billing. Please try again in a moment."; + +export function wrapStripeFailure(e: unknown): never { + if (e instanceof Stripe.errors.StripeError) { + const stripeStatus = e.statusCode ?? 0; + const status = + stripeStatus === 429 || stripeStatus >= 500 + ? Status.BAD_GATEWAY + : Status.BAD_REQUEST; + throw new BillingHttpError(status, BILLING_CLIENT_MESSAGE, e); + } + throw e; +} diff --git a/packages/backend/src/billing/controllers/billing.controller.test.ts b/packages/backend/src/billing/controllers/billing.controller.test.ts index 1fb94e013..df4c4d482 100644 --- a/packages/backend/src/billing/controllers/billing.controller.test.ts +++ b/packages/backend/src/billing/controllers/billing.controller.test.ts @@ -81,4 +81,29 @@ describe("BillingController", () => { ); expect(json).toHaveBeenCalledWith({ error: "Internal server error" }); }); + + it("maps BillingHttpError to its status and client message", async () => { + const { BillingHttpError } = await import( + "@backend/billing/billing.errors" + ); + spyOn(billingService, "getStatus").mockRejectedValue( + new BillingHttpError( + 502, + "Couldn't start billing. Please try again in a moment.", + ), + ); + + const { res, json } = jsonRes(); + await billingController.getStatus( + sessionReq("507f1f77bcf86cd799439011"), + res, + ); + + expect((res.status as ReturnType).mock.calls[0]?.[0]).toBe( + 502, + ); + expect(json).toHaveBeenCalledWith({ + error: "Couldn't start billing. Please try again in a moment.", + }); + }); }); diff --git a/packages/backend/src/billing/controllers/billing.controller.ts b/packages/backend/src/billing/controllers/billing.controller.ts index 50c4a45af..b2428329b 100644 --- a/packages/backend/src/billing/controllers/billing.controller.ts +++ b/packages/backend/src/billing/controllers/billing.controller.ts @@ -7,12 +7,18 @@ import { type BillingStatusResponse, } from "@core/types/billing.types"; import { zObjectId } from "@core/types/type.utils"; +import { BillingHttpError } from "@backend/billing/billing.errors"; import billingService from "@backend/billing/services/billing.service"; import stripeService from "@backend/billing/services/stripe.service"; const logger = Logger("app:billing"); const sendBillingError = (res: Response, e: unknown) => { + if (e instanceof BillingHttpError) { + logger.error(e.message, e.cause ?? e); + res.status(e.status).json({ error: e.clientMessage }); + return; + } const message = e instanceof Error ? e.message : "Unexpected error"; if (message === "User not found") { res.status(Status.NOT_FOUND).json({ error: "User not found" }); diff --git a/packages/backend/src/billing/services/stripe.service.db.test.ts b/packages/backend/src/billing/services/stripe.service.db.test.ts index 7caa7c7ab..75de81318 100644 --- a/packages/backend/src/billing/services/stripe.service.db.test.ts +++ b/packages/backend/src/billing/services/stripe.service.db.test.ts @@ -1,4 +1,4 @@ -import type Stripe from "stripe"; +import Stripe from "stripe"; import { cleanupCollections, cleanupTestDb, @@ -72,9 +72,16 @@ describe("StripeService", () => { expect(sessionArgs.client_reference_id).toBe(userId.toString()); expect(sessionArgs.subscription_data.trial_period_days).toBe(7); expect(sessionArgs.line_items[0]?.price).toBe("price_test"); + expect( + (sessionArgs as { payment_method_collection?: string }) + .payment_method_collection, + ).toBe("always"); expect( (sessionArgs as { payment_method_types?: unknown }).payment_method_types, ).toBeUndefined(); + expect(sessionsCreate.mock.calls[0]?.[1]).toEqual({ + idempotencyKey: `compass-checkout-v2-${userId.toString()}`, + }); const stored = await mongoService.user.findOne({ _id: userId }); expect(stored?.billing?.stripeCustomerId).toBe("cus_1"); @@ -248,4 +255,41 @@ describe("StripeService", () => { return_url: "http://localhost:9080", }); }); + + it("maps a Stripe invalid-request error to BillingHttpError", async () => { + using _env = mockEnv(stripeConfigured); + const userId = mongoService.objectId(); + await mongoService.user.insertOne({ + _id: userId, + email: "pay@example.com", + name: "Pay User", + firstName: "Pay", + lastName: "User", + locale: "en", + }); + + const stripeError = new Stripe.errors.StripeInvalidRequestError({ + message: "No such price: 'price_test'", + type: "invalid_request_error", + statusCode: 400, + }); + setStripeClientForTests({ + customers: { + create: mock(() => Promise.resolve({ id: "cus_1" })), + }, + checkout: { + sessions: { + create: mock(() => Promise.reject(stripeError)), + }, + }, + } as unknown as Stripe); + + await expect( + stripeService.createCheckoutSession(userId.toString()), + ).rejects.toMatchObject({ + name: "BillingHttpError", + status: 400, + clientMessage: "Couldn't start billing. Please try again in a moment.", + }); + }); }); diff --git a/packages/backend/src/billing/services/stripe.service.ts b/packages/backend/src/billing/services/stripe.service.ts index d458e7581..b4fecde7e 100644 --- a/packages/backend/src/billing/services/stripe.service.ts +++ b/packages/backend/src/billing/services/stripe.service.ts @@ -3,11 +3,15 @@ import { BILLING_PLAN, getStripePriceId, } from "@backend/billing/billing.constants"; +import { wrapStripeFailure } from "@backend/billing/billing.errors"; import { getStripeClient } from "@backend/billing/services/stripe.client"; import { CONFIG } from "@backend/common/constants/config.constants"; import { isStripeConfigured } from "@backend/common/constants/config.util"; import mongoService from "@backend/common/services/mongo.service"; +/** Bump when Checkout Session create params change so Stripe does not replay a failed create. */ +const CHECKOUT_IDEMPOTENCY_PREFIX = "compass-checkout-v2-"; + const checkoutReturnUrl = (outcome: "success" | "cancel"): string => { const url = new URL(CONFIG.FRONTEND_URL); url.searchParams.set("checkout", outcome); @@ -45,13 +49,15 @@ class StripeService { const grantTrial = !user.billing?.stripeSubscriptionId; if (!customerId) { - const customer = await stripe.customers.create( - { - email: user.email, - metadata: { compassUserId: userId }, - }, - { idempotencyKey: `compass-customer-${userId}` }, - ); + const customer = await stripe.customers + .create( + { + email: user.email, + metadata: { compassUserId: userId }, + }, + { idempotencyKey: `compass-customer-${userId}` }, + ) + .catch(wrapStripeFailure); customerId = customer.id; await mongoService.user.updateOne( { _id }, @@ -59,23 +65,35 @@ class StripeService { ); } - const session = await stripe.checkout.sessions.create( - { - mode: "subscription", - customer: customerId, - client_reference_id: userId, - line_items: [{ price: getStripePriceId(), quantity: 1 }], - subscription_data: { - ...(grantTrial - ? { trial_period_days: BILLING_PLAN.TRIAL_LENGTH_DAYS } - : {}), - metadata: { compassUserId: userId }, + const session = await stripe.checkout.sessions + .create( + { + mode: "subscription", + customer: customerId, + client_reference_id: userId, + line_items: [{ price: getStripePriceId(), quantity: 1 }], + // Card is required to start the trial. Default is `always`, but pin + // it so a Dashboard setting cannot silently skip collection. + payment_method_collection: "always", + subscription_data: { + ...(grantTrial + ? { + trial_period_days: BILLING_PLAN.TRIAL_LENGTH_DAYS, + trial_settings: { + end_behavior: { missing_payment_method: "cancel" }, + }, + } + : {}), + metadata: { compassUserId: userId }, + }, + success_url: checkoutReturnUrl("success"), + cancel_url: checkoutReturnUrl("cancel"), }, - success_url: checkoutReturnUrl("success"), - cancel_url: checkoutReturnUrl("cancel"), - }, - grantTrial ? { idempotencyKey: `compass-checkout-${userId}` } : undefined, - ); + grantTrial + ? { idempotencyKey: `${CHECKOUT_IDEMPOTENCY_PREFIX}${userId}` } + : undefined, + ) + .catch(wrapStripeFailure); if (!session.url) { throw new Error("Stripe Checkout did not return a URL"); @@ -103,10 +121,12 @@ class StripeService { } const stripe = getStripeClient(); - const session = await stripe.billingPortal.sessions.create({ - customer: customerId, - return_url: CONFIG.FRONTEND_URL, - }); + const session = await stripe.billingPortal.sessions + .create({ + customer: customerId, + return_url: CONFIG.FRONTEND_URL, + }) + .catch(wrapStripeFailure); return { url: session.url }; }; diff --git a/packages/backend/src/common/constants/config.constants.test.ts b/packages/backend/src/common/constants/config.constants.test.ts index 5f18b6180..ddb6accb4 100644 --- a/packages/backend/src/common/constants/config.constants.test.ts +++ b/packages/backend/src/common/constants/config.constants.test.ts @@ -82,6 +82,28 @@ describe("config.constants", () => { expect(isStripeConfigured(env)).toBe(true); }); + it("trims Stripe values from env and compass.yaml", () => { + const fromEnv = parseConfigFromEnv({ + ...validEnv, + STRIPE_SECRET_KEY: " rk_test_123\n", + STRIPE_WEBHOOK_SECRET: " whsec_test ", + STRIPE_PRICE_ID: "price_test\n", + }); + expect(fromEnv.STRIPE_PRICE_ID).toBe("price_test"); + expect(fromEnv.STRIPE_SECRET_KEY).toBe("rk_test_123"); + + const fromFile = parseRawConfig({ + ...baseRawConfig, + stripe: { + secretKey: " sk_test_abc ", + webhookSecret: "whsec_file", + priceId: " price_file\n", + }, + }); + expect(fromFile.STRIPE_PRICE_ID).toBe("price_file"); + expect(fromFile.STRIPE_SECRET_KEY).toBe("sk_test_abc"); + }); + it("rejects partially configured Google credentials", () => { expect(() => parseConfigFromEnv({ diff --git a/packages/backend/src/common/constants/config.constants.ts b/packages/backend/src/common/constants/config.constants.ts index 7e6081a81..4de78eac4 100644 --- a/packages/backend/src/common/constants/config.constants.ts +++ b/packages/backend/src/common/constants/config.constants.ts @@ -91,8 +91,10 @@ const toStr = ( value: string | number | null | undefined, ): string | undefined => (value != null ? String(value) : undefined); -const nonEmpty = (value: string | null | undefined): string | undefined => - value?.trim() ? value : undefined; +const nonEmpty = (value: string | null | undefined): string | undefined => { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +}; export function parseRawConfig(config: CompassConfig): Config { const nodeEnv = config.runtime.nodeEnv as NodeEnv; @@ -151,9 +153,9 @@ export function parseConfigFromEnv( SYNC_EXECUTION: nonEmpty(rawEnv["SYNC_EXECUTION"]), POSTHOG_KEY: rawEnv["POSTHOG_KEY"], POSTHOG_HOST: rawEnv["POSTHOG_HOST"] || DEFAULT_POSTHOG_HOST, - STRIPE_SECRET_KEY: rawEnv["STRIPE_SECRET_KEY"], - STRIPE_WEBHOOK_SECRET: rawEnv["STRIPE_WEBHOOK_SECRET"], - STRIPE_PRICE_ID: rawEnv["STRIPE_PRICE_ID"], + STRIPE_SECRET_KEY: nonEmpty(rawEnv["STRIPE_SECRET_KEY"]), + STRIPE_WEBHOOK_SECRET: nonEmpty(rawEnv["STRIPE_WEBHOOK_SECRET"]), + STRIPE_PRICE_ID: nonEmpty(rawEnv["STRIPE_PRICE_ID"]), }); } diff --git a/packages/backend/src/servers/express/express.server.test.ts b/packages/backend/src/servers/express/express.server.test.ts new file mode 100644 index 000000000..c2f1d9e48 --- /dev/null +++ b/packages/backend/src/servers/express/express.server.test.ts @@ -0,0 +1,9 @@ +import { initExpressServer } from "@backend/servers/express/express.server"; +import { describe, expect, it } from "bun:test"; + +describe("initExpressServer", () => { + it("trusts one proxy hop so Caddy X-Forwarded-For does not collapse rate-limit keys", () => { + const app = initExpressServer(); + expect(app.get("trust proxy")).toBe(1); + }); +}); diff --git a/packages/backend/src/servers/express/express.server.ts b/packages/backend/src/servers/express/express.server.ts index c41469eb4..e2b899ac6 100644 --- a/packages/backend/src/servers/express/express.server.ts +++ b/packages/backend/src/servers/express/express.server.ts @@ -25,6 +25,9 @@ import { UserRoutes } from "@backend/user/user.routes.config"; export const initExpressServer = () => { /* Express Configuration */ const app: Application = express(); + // Caddy terminates TLS and proxies `/api/*` with X-Forwarded-For. One hop + // of trust keeps express-rate-limit from treating every visitor as Caddy. + app.set("trust proxy", 1); initSupertokens(); diff --git a/packages/web/src/api/util/api.util.test.ts b/packages/web/src/api/util/api.util.test.ts index e595e8b43..cc1c0a532 100644 --- a/packages/web/src/api/util/api.util.test.ts +++ b/packages/web/src/api/util/api.util.test.ts @@ -14,6 +14,7 @@ import { import { createApiError as buildApiError, getApiErrorCode, + getApiErrorMessage, getErrorStatus, handleErrorResponse, isSessionLevelError, @@ -142,6 +143,24 @@ describe("shouldShowContextualLoadError", () => { }); }); +describe("getApiErrorMessage", () => { + it("returns the error string from a JSON body", () => { + const error = createApiError({ + data: { error: "Couldn't start billing. Please try again in a moment." }, + }); + expect(getApiErrorMessage(error)).toBe( + "Couldn't start billing. Please try again in a moment.", + ); + }); + + it("returns undefined when the body has no error string", () => { + expect( + getApiErrorMessage(createApiError({ data: { code: "X" } })), + ).toBeUndefined(); + expect(getApiErrorMessage(new Error("nope"))).toBeUndefined(); + }); +}); + describe("getApiErrorCode", () => { it("returns the code when response.data has a string code property", () => { const error = createApiError({ data: { code: "GOOGLE_REVOKED" } }); diff --git a/packages/web/src/api/util/api.util.ts b/packages/web/src/api/util/api.util.ts index da332d588..a7765a6d7 100644 --- a/packages/web/src/api/util/api.util.ts +++ b/packages/web/src/api/util/api.util.ts @@ -93,6 +93,15 @@ export const getApiErrorCode = (error: ApiError): string | undefined => { return typeof code === "string" ? code : undefined; }; +/** Safe `error` string from a JSON `{ error: string }` API body, if present. */ +export const getApiErrorMessage = (error: unknown): string | undefined => { + if (!isApiError(error)) return undefined; + const data = getApiErrorData(error); + if (!data || typeof data !== "object" || !("error" in data)) return undefined; + const message = (data as { error?: unknown }).error; + return typeof message === "string" && message.trim() ? message : undefined; +}; + export const parseApiError = ( error: ApiError, schema: ZodType, diff --git a/packages/web/src/billing/BillingGateModal.test.tsx b/packages/web/src/billing/BillingGateModal.test.tsx new file mode 100644 index 000000000..c56a12dab --- /dev/null +++ b/packages/web/src/billing/BillingGateModal.test.tsx @@ -0,0 +1,77 @@ +import "@testing-library/jest-dom"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { Status } from "@core/errors/status.codes"; +import { type ApiError } from "@web/api/api.types"; +import { BillingApi } from "@web/api/billing.api"; +import { SessionContext } from "@web/auth/compass/session/session.context"; +import * as errorToast from "@web/common/utils/toast/error-toast.util"; +import { BillingGateModal } from "./BillingGateModal"; +import { afterEach, describe, expect, it, spyOn } from "bun:test"; + +const assign = spyOn(window.location, "assign").mockImplementation(() => {}); +const showErrorToast = spyOn(errorToast, "showErrorToast"); + +const checkoutFailed = (): ApiError => { + const error = new Error( + "Request failed for POST /billing/checkout/session with status 500", + ) as ApiError; + error.name = "ApiError"; + error.response = { + config: {}, + data: { error: "Internal server error" }, + headers: new Headers(), + status: Status.INTERNAL_SERVER, + statusText: "Internal Server Error", + }; + return error; +}; + +const renderGate = () => + render( + {} }} + > + + , + ); + +describe("BillingGateModal", () => { + afterEach(() => { + assign.mockClear(); + showErrorToast.mockClear(); + }); + + it("redirects to Stripe Checkout from Start trial", async () => { + const createCheckoutSession = spyOn( + BillingApi, + "createCheckoutSession", + ).mockResolvedValue({ url: "https://checkout.stripe.com/c/ok" }); + const user = userEvent.setup(); + renderGate(); + + await user.click(screen.getByRole("button", { name: "Start trial" })); + + expect(createCheckoutSession).toHaveBeenCalled(); + expect(assign).toHaveBeenCalledWith("https://checkout.stripe.com/c/ok"); + expect(showErrorToast).not.toHaveBeenCalled(); + createCheckoutSession.mockRestore(); + }); + + it("shows a toast when checkout fails instead of failing silently", async () => { + const createCheckoutSession = spyOn( + BillingApi, + "createCheckoutSession", + ).mockRejectedValue(checkoutFailed()); + const user = userEvent.setup(); + renderGate(); + + await user.click(screen.getByRole("button", { name: "Start trial" })); + + expect(assign).not.toHaveBeenCalled(); + expect(showErrorToast).toHaveBeenCalledWith( + "Couldn't start checkout. Please try again.", + ); + createCheckoutSession.mockRestore(); + }); +}); diff --git a/packages/web/src/billing/BillingGateModal.tsx b/packages/web/src/billing/BillingGateModal.tsx index db7619a35..4882fb63f 100644 --- a/packages/web/src/billing/BillingGateModal.tsx +++ b/packages/web/src/billing/BillingGateModal.tsx @@ -1,10 +1,15 @@ import { type FC, useEffect, useRef, useState } from "react"; import { BILLING_PLAN } from "@core/constants/billing.constants"; import { BillingApi } from "@web/api/billing.api"; +import { + getApiErrorMessage, + isSessionLevelError, +} from "@web/api/util/api.util"; import { useLogout } from "@web/auth/compass/hooks/useLogout"; import { track } from "@web/auth/posthog/track"; import { Z_INDEX_MODAL } from "@web/common/constants/web.constants"; import { runExportMyData } from "@web/common/storage/offline-data/export-user-data.util"; +import { showErrorToast } from "@web/common/utils/toast/error-toast.util"; import { PixelPirateScouting } from "@web/components/WelcomeModal/PixelPirateScouting"; import { useAppLockReason } from "@web/shortcuts/app-lock"; @@ -49,6 +54,15 @@ export const BillingGateModal: FC = ({ status }) => { ? await BillingApi.createCheckoutSession() : await BillingApi.createPortalSession(); window.location.assign(url); + } catch (error) { + if (!isSessionLevelError(error)) { + const fromApi = getApiErrorMessage(error); + showErrorToast( + fromApi && fromApi !== "Internal server error" + ? fromApi + : "Couldn't start checkout. Please try again.", + ); + } } finally { setIsRedirecting(false); } diff --git a/packages/web/src/billing/useAppAccess.test.tsx b/packages/web/src/billing/useAppAccess.test.tsx index 51d8bfe8d..00f6caa67 100644 --- a/packages/web/src/billing/useAppAccess.test.tsx +++ b/packages/web/src/billing/useAppAccess.test.tsx @@ -4,24 +4,28 @@ import { rest } from "msw"; import { type PropsWithChildren } from "react"; import { server } from "@web/__tests__/__mocks__/server/mock.server"; import { SessionContext } from "@web/auth/compass/session/session.context"; +import * as authStateUtil from "@web/auth/compass/state/auth.state.util"; +import { billingQueryKeys } from "@web/billing/billing.query"; import { useAppAccess } from "@web/billing/useAppAccess"; import { ENV_WEB } from "@web/common/constants/env.constants"; import { STORAGE_KEYS } from "@web/common/constants/storage.constants"; import { persistentBrowserStore } from "@web/common/storage/browser-key-value.store"; -import { beforeEach, describe, expect, it } from "bun:test"; +import { beforeEach, describe, expect, it, spyOn } from "bun:test"; const daysAgo = (days: number) => new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); -const createWrapper = (authenticated = false) => { - const client = new QueryClient({ - defaultOptions: { queries: { retry: false } }, - }); +const createWrapper = (authenticated = false, client?: QueryClient) => { + const queryClient = + client ?? + new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); return ({ children }: PropsWithChildren) => ( {} }} > - {children} + {children} ); }; @@ -179,4 +183,56 @@ describe("useAppAccess", () => { }); }); }); + + it("does not fetch billing status after sign-out when hasAuthenticated is still set", () => { + const hasUserEverAuthenticatedSpy = spyOn( + authStateUtil, + "hasUserEverAuthenticated", + ).mockReturnValue(true); + stubConfig(true); + let billingHits = 0; + server.use( + rest.get(`${ENV_WEB.API_BASEURL}/billing/status`, (_req, res, ctx) => { + billingHits += 1; + return res(ctx.status(401)); + }), + ); + + const { result } = renderHook(() => useAppAccess(), { + wrapper: createWrapper(false), + }); + expect(result.current).toEqual({ kind: "open" }); + expect(billingHits).toBe(0); + hasUserEverAuthenticatedSpy.mockRestore(); + }); + + it("does not keep a cached read-only billing gate after the session is gone", () => { + const hasUserEverAuthenticatedSpy = spyOn( + authStateUtil, + "hasUserEverAuthenticated", + ).mockReturnValue(true); + stubConfig(true); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + client.setQueryData(billingQueryKeys.status, { + subscriptionStatus: "awaiting_checkout", + trialEndsAt: null, + isReadOnly: true, + }); + let billingHits = 0; + server.use( + rest.get(`${ENV_WEB.API_BASEURL}/billing/status`, (_req, res, ctx) => { + billingHits += 1; + return res(ctx.status(401)); + }), + ); + + const { result } = renderHook(() => useAppAccess(), { + wrapper: createWrapper(false, client), + }); + expect(result.current).toEqual({ kind: "open" }); + expect(billingHits).toBe(0); + hasUserEverAuthenticatedSpy.mockRestore(); + }); }); diff --git a/packages/web/src/billing/useAppAccess.ts b/packages/web/src/billing/useAppAccess.ts index 82de50328..319263da6 100644 --- a/packages/web/src/billing/useAppAccess.ts +++ b/packages/web/src/billing/useAppAccess.ts @@ -1,4 +1,6 @@ +import { useContext } from "react"; import { type BillingSubscriptionStatus } from "@core/types/user.types"; +import { SessionContext } from "@web/auth/compass/session/session.context"; import { useAppConfigQuery, useBillingStatusQuery, @@ -27,10 +29,13 @@ export type AppAccess = * unconfigured-Stripe state never locks a paying user out of their calendar. */ export function useAppAccess(): AppAccess { + const { authenticated } = useContext(SessionContext); const trial = useTrialStatus(); const configQuery = useAppConfigQuery(); const billingEnabled = - !trial.isAnonymousTrial && configQuery.data?.billing.isConfigured === true; + authenticated && + !trial.isAnonymousTrial && + configQuery.data?.billing.isConfigured === true; const billingQuery = useBillingStatusQuery(billingEnabled); if (trial.isAnonymousTrial) { @@ -41,6 +46,10 @@ export function useAppAccess(): AppAccess { }; } + if (!authenticated) { + return { kind: "open" }; + } + if ( configQuery.isError || configQuery.isPending ||