From b5bda159eaac431480f2e86f2e41a5975865e44c Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Tue, 30 Jun 2026 22:26:26 -0400 Subject: [PATCH] Re-add free plan as the floor; remove subscribe gate (#321) --- src/client/features/billing/managed-access.ts | 11 --- src/client/features/billing/route-state.ts | 4 +- .../features/billing/useSubscribeRedirect.ts | 70 ------------------- .../onboarding/PostSignupOnboarding.tsx | 51 -------------- src/lib/auth.ts | 16 +++++ src/routes/_app/route.tsx | 4 +- .../_authenticated.onboarding.index.tsx | 51 ++------------ src/routes/_authenticated.subscribe.tsx | 13 ---- src/routes/_project/p/$projectId/route.tsx | 4 +- src/server/auth/disposable-email.ts | 53 ++++++++++++++ src/serverFunctions/audit.ts | 3 +- src/serverFunctions/billing.ts | 18 ----- src/shared/billing.ts | 5 +- 13 files changed, 82 insertions(+), 221 deletions(-) delete mode 100644 src/client/features/billing/managed-access.ts delete mode 100644 src/client/features/billing/useSubscribeRedirect.ts create mode 100644 src/server/auth/disposable-email.ts diff --git a/src/client/features/billing/managed-access.ts b/src/client/features/billing/managed-access.ts deleted file mode 100644 index e146f78..0000000 --- a/src/client/features/billing/managed-access.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { queryOptions } from "@tanstack/react-query"; -import { getManagedAccessStatus } from "@/serverFunctions/billing"; - -export const MANAGED_ACCESS_QUERY_KEY = ["managedAccessStatus"]; - -export const managedAccessQueryOptions = () => - queryOptions({ - queryKey: MANAGED_ACCESS_QUERY_KEY, - queryFn: () => getManagedAccessStatus(), - staleTime: 30_000, - }); diff --git a/src/client/features/billing/route-state.ts b/src/client/features/billing/route-state.ts index fff08ac..098d359 100644 --- a/src/client/features/billing/route-state.ts +++ b/src/client/features/billing/route-state.ts @@ -38,8 +38,8 @@ export function getSubscribeRouteState(args: { return "redirectToApp" as const; } - // Grandfathered free-plan users landing here outside the upgrade flow - // belong in the app, not on the paywall. + // Free-plan users landing here outside the upgrade flow belong in the app, + // not on the paywall. if (args.hasManagedAccess && !args.isUpgradeFlow) { return "redirectToApp" as const; } diff --git a/src/client/features/billing/useSubscribeRedirect.ts b/src/client/features/billing/useSubscribeRedirect.ts deleted file mode 100644 index f5ed6dd..0000000 --- a/src/client/features/billing/useSubscribeRedirect.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { useQuery } from "@tanstack/react-query"; -import { useLocation, useNavigate } from "@tanstack/react-router"; -import { useEffect } from "react"; -import { managedAccessQueryOptions } from "@/client/features/billing/managed-access"; -import { onboardingAnswersQueryOptions } from "@/client/features/onboarding/onboardingModel"; -import { useSession } from "@/lib/auth-client"; -import { - isEmailVerificationBypassed, - isHostedClientAuthMode, -} from "@/lib/auth-mode"; -import { SUBSCRIBE_ROUTE } from "@/shared/billing"; - -// Account-management pages stay reachable without a subscription so gated -// users can change settings, read docs, or contact support (no dead ends). -const GATE_EXEMPT_PATH_PREFIXES = ["/settings", "/support", "/help"]; - -// Sends hosted users without managed access (no plan in Autumn) to the -// subscribe paywall. Runs only after onboarding completes so the onboarding -// redirect always wins first, and fails open on query errors — billing being -// down must not lock paying users out (spend is gated server-side anyway). -export function useSubscribeRedirect() { - const navigate = useNavigate(); - const { pathname } = useLocation(); - const { data: session } = useSession(); - const isHostedMode = isHostedClientAuthMode(); - const isEmailVerified = - session?.user?.emailVerified === true || isEmailVerificationBypassed(); - const isEligible = - isHostedMode && Boolean(session?.user?.id) && isEmailVerified; - - const onboardingQuery = useQuery({ - ...onboardingAnswersQueryOptions(), - enabled: isEligible, - }); - const hasCompletedOnboarding = Boolean(onboardingQuery.data?.completedAt); - - const accessQuery = useQuery({ - ...managedAccessQueryOptions(), - enabled: isEligible && hasCompletedOnboarding, - }); - - const isExemptPath = GATE_EXEMPT_PATH_PREFIXES.some((prefix) => - pathname.startsWith(prefix), - ); - const shouldRedirect = - isEligible && - hasCompletedOnboarding && - accessQuery.data?.hasManagedAccess === false && - !isExemptPath; - - useEffect(() => { - if (!shouldRedirect) return; - void navigate({ - to: SUBSCRIBE_ROUTE, - search: { redirect: pathname }, - replace: true, - }); - }, [navigate, pathname, shouldRedirect]); - - // Hold rendering until we know whether the user may see the app, and while - // a redirect is imminent. This avoids flashing gated pages (which would - // fire their data queries and create default projects for users who never - // pass the paywall). - const isBlocking = - isEligible && - hasCompletedOnboarding && - (accessQuery.isLoading || shouldRedirect); - - return { isBlocking }; -} diff --git a/src/client/features/onboarding/PostSignupOnboarding.tsx b/src/client/features/onboarding/PostSignupOnboarding.tsx index 8b9ef1b..a522e70 100644 --- a/src/client/features/onboarding/PostSignupOnboarding.tsx +++ b/src/client/features/onboarding/PostSignupOnboarding.tsx @@ -23,7 +23,6 @@ type PostSignupOnboardingProps = { onBack: () => void; onSkip: () => void; onFinish: (mcpSetupIntent: "yes" | "no") => void; - onUpgradeAcknowledged: () => void; isSaving: boolean; accountMenu: ReactNode; }; @@ -39,7 +38,6 @@ export function PostSignupOnboarding({ onBack, onSkip, onFinish, - onUpgradeAcknowledged, isSaving, accountMenu, }: PostSignupOnboardingProps) { @@ -55,55 +53,6 @@ export function PostSignupOnboarding({ const updateAnswers = (patch: Partial) => onAnswersChange({ ...answers, ...patch }); - // After a successful checkout the user lands on the GSC step with - // `?checkout=success`. Show a one-time "you're in" screen (same layout as the - // steps) and only reveal the actual GSC step once they continue, which drops - // the param. - const justUpgraded = - step === 3 && - typeof window !== "undefined" && - new URLSearchParams(window.location.search).get("checkout") === "success"; - - if (justUpgraded) { - return ( -
- {accountMenu} - -
- OpenSEO -

You’re in! 🎉

-

- Your subscription’s active. -

-
- -
-

- Finish setting up your account -

-

- Two quick steps left — connect Google Search Console, then set up - MCP for your agent. -

-
- -
-
-
- ); - } - return (
{accountMenu} diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 4493439..a1060a0 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,7 +1,9 @@ import { env } from "cloudflare:workers"; import { betterAuth } from "better-auth"; +import { APIError } from "better-auth/api"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { tanstackStartCookies } from "better-auth/tanstack-start"; +import { isDisposableEmailDomain } from "@/server/auth/disposable-email"; import * as d1Schema from "@/db/d1/schema"; import { d1Db } from "@/db/d1/client"; import { pgDb } from "@/db/pg/client"; @@ -84,6 +86,20 @@ function createAuth() { databaseHooks: { user: { create: { + // Hosted only: keep cheap mass-signups off the free plan by rejecting + // throwaway-inbox domains before the user row is created. Self-hosted + // has no shared credit pool to protect, so it's left untouched. + before: async (user) => { + if ( + isHostedAuthMode(env.AUTH_MODE) && + isDisposableEmailDomain(user.email) + ) { + throw new APIError("BAD_REQUEST", { + message: "Please sign up with a non-disposable email address.", + }); + } + return { data: user }; + }, after: async (user) => { await syncHostedSignupContact(user); }, diff --git a/src/routes/_app/route.tsx b/src/routes/_app/route.tsx index a846f13..15582ab 100644 --- a/src/routes/_app/route.tsx +++ b/src/routes/_app/route.tsx @@ -2,7 +2,6 @@ import { Outlet, createFileRoute } from "@tanstack/react-router"; import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard"; import { AuthenticatedAppLayout } from "@/client/layout/AppShell"; import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect"; -import { useSubscribeRedirect } from "@/client/features/billing/useSubscribeRedirect"; export const Route = createFileRoute("/_app")({ component: AppRouteLayout, @@ -11,9 +10,8 @@ export const Route = createFileRoute("/_app")({ function AppRouteLayout() { const authGate = useHostedAuthRouteGuard(); useOnboardingRedirect(); - const subscribeGate = useSubscribeRedirect(); - if (!authGate.canRenderAuthenticatedContent || subscribeGate.isBlocking) { + if (!authGate.canRenderAuthenticatedContent) { return null; } diff --git a/src/routes/_authenticated.onboarding.index.tsx b/src/routes/_authenticated.onboarding.index.tsx index 2ca67ea..244cb3c 100644 --- a/src/routes/_authenticated.onboarding.index.tsx +++ b/src/routes/_authenticated.onboarding.index.tsx @@ -10,12 +10,9 @@ import { onboardingAnswersQueryOptions, restoreOnboardingAnswers, } from "@/client/features/onboarding/onboardingModel"; -import { managedAccessQueryOptions } from "@/client/features/billing/managed-access"; import { captureClientEvent } from "@/client/lib/posthog"; import { queryClient } from "@/client/tanstack-db"; import { useSession } from "@/lib/auth-client"; -import { isHostedClientAuthMode } from "@/lib/auth-mode"; -import { SUBSCRIBE_ROUTE } from "@/shared/billing"; import { saveOnboardingAnswers } from "@/serverFunctions/onboarding"; const ONBOARDING_EXISTING_USER_CUTOFF = "2026-05-27T00:00:00.000Z"; @@ -25,17 +22,9 @@ const clampStep = (step: number) => export const Route = createFileRoute("/_authenticated/onboarding/")({ // Step lives in the URL so it survives refresh and works with back/forward. - // The subscribe route appends `checkout=success` when it sends a just-paid - // user back here; it triggers the one-time "You're in!" screen on the GSC - // step. Preserve it through validation so the router doesn't strip it. - validateSearch: ( - search: Record, - ): { step: number; checkout?: string } => { + validateSearch: (search: Record): { step: number } => { const raw = Number(search.step); - return { - step: Number.isFinite(raw) ? clampStep(raw) : 0, - ...(search.checkout === "success" ? { checkout: "success" } : {}), - }; + return { step: Number.isFinite(raw) ? clampStep(raw) : 0 }; }, // Send users who already finished onboarding home before rendering. Running // this in beforeLoad (not a component effect) means it can't race with the @@ -91,16 +80,6 @@ function OnboardingFlow({ const { step } = Route.useSearch(); const [answers, setAnswers] = useState(initialAnswers); - // Self-hosted has no paywall. Hosted users hit the subscribe gate after the - // three intro questions, before the paid GSC/MCP connect steps. - const isHostedMode = isHostedClientAuthMode(); - const accessQuery = useQuery({ - ...managedAccessQueryOptions(), - enabled: isHostedMode, - }); - const needsSubscription = - isHostedMode && accessQuery.data?.hasManagedAccess === false; - const saveMutation = useMutation({ mutationFn: (extra: { mcpSetupIntent?: "yes" | "no"; @@ -117,25 +96,6 @@ function OnboardingFlow({ const goToStep = (next: number) => void navigate({ to: "/onboarding", search: { step: clampStep(next) } }); - const advanceFromCurrentStep = () => { - const next = clampStep(step + 1); - // After the three intro questions, hosted users hit the subscribe paywall - // before the GSC/MCP connect steps. We return to step 3 (GSC) afterward; - // the subscribe route appends `checkout=success` once payment lands, which - // drives the one-time "You're in!" screen there. - // The strategy chat that used to sit here is parked for now; see - // /onboarding/chat (still routed) — we'll revisit it later. - if (step === 2 && needsSubscription) { - void navigate({ - to: SUBSCRIBE_ROUTE, - search: { redirect: `/onboarding?step=${next}` }, - replace: true, - }); - return; - } - goToStep(next); - }; - const handleNext = () => { if (step === 0) { captureClientEvent("onboarding:interests_selected", { @@ -144,13 +104,13 @@ function OnboardingFlow({ }); } saveMutation.mutate({}); - advanceFromCurrentStep(); + goToStep(step + 1); }; const handleSkip = () => { saveMutation.mutate({}); captureClientEvent("onboarding:step_skipped", { step }); - advanceFromCurrentStep(); + goToStep(step + 1); }; const handleFinish = async (mcpSetupIntent: "yes" | "no") => { @@ -191,9 +151,6 @@ function OnboardingFlow({ onBack={() => goToStep(step - 1)} onSkip={handleSkip} onFinish={handleFinish} - onUpgradeAcknowledged={() => - void navigate({ to: "/onboarding", search: { step }, replace: true }) - } isSaving={saveMutation.isPending} accountMenu={} /> diff --git a/src/routes/_authenticated.subscribe.tsx b/src/routes/_authenticated.subscribe.tsx index 1d13d2f..2cee1b7 100644 --- a/src/routes/_authenticated.subscribe.tsx +++ b/src/routes/_authenticated.subscribe.tsx @@ -10,9 +10,7 @@ import { isHostedClientAuthMode } from "@/lib/auth-mode"; import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getSubscribeRouteState } from "@/client/features/billing/route-state"; import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; -import { MANAGED_ACCESS_QUERY_KEY } from "@/client/features/billing/managed-access"; import { normalizeAuthRedirect } from "@/lib/auth-redirect"; -import { queryClient } from "@/client/tanstack-db"; import { AUTUMN_MANAGED_ACCESS_FEATURE_ID, AUTUMN_PAID_PLAN_ID, @@ -99,22 +97,11 @@ function SubscribePageContent() { useEffect(() => { if (subscribeRouteState === "redirectToApp") { - // The app layouts gate on this query; make sure they see fresh access - // state instead of a cached "no access" that would bounce back here. - void queryClient.invalidateQueries({ - queryKey: MANAGED_ACCESS_QUERY_KEY, - }); const destination = redirect ?? "/"; const [destinationPath, destinationQuery] = destination.split("?"); const destinationSearch: Record = destinationQuery ? Object.fromEntries(new URLSearchParams(destinationQuery)) : {}; - // Tell the destination a fresh checkout just landed (the onboarding GSC - // step uses this to show its one-time "You're in!" screen). Only set it - // when payment actually completed, not speculatively at redirect time. - if (checkoutCompleted) { - destinationSearch.checkout = "success"; - } const goToApp = () => void navigate({ to: destinationPath, diff --git a/src/routes/_project/p/$projectId/route.tsx b/src/routes/_project/p/$projectId/route.tsx index ec920c5..79b198b 100644 --- a/src/routes/_project/p/$projectId/route.tsx +++ b/src/routes/_project/p/$projectId/route.tsx @@ -8,7 +8,6 @@ import { useEffect } from "react"; import { setLastProjectId } from "@/client/lib/active-project"; import { useHostedAuthRouteGuard } from "@/client/features/auth/useHostedAuthRouteGuard"; import { FreePlanBanner } from "@/client/features/billing/FreePlanBanner"; -import { useSubscribeRedirect } from "@/client/features/billing/useSubscribeRedirect"; import { useOnboardingRedirect } from "@/client/features/onboarding/useOnboardingRedirect"; import { getErrorCode } from "@/client/lib/error-messages"; import { AuthenticatedAppLayout } from "@/client/layout/AppShell"; @@ -44,7 +43,6 @@ function ProjectLayout() { const { projectId } = Route.useParams(); const authGate = useHostedAuthRouteGuard(); useOnboardingRedirect(); - const subscribeGate = useSubscribeRedirect(); // Remember this as the last-visited project for the landing redirect. // Settings is excluded: editing another project's settings is @@ -58,7 +56,7 @@ function ProjectLayout() { setLastProjectId(projectId); }, [projectId, isSettingsPage]); - if (!authGate.canRenderAuthenticatedContent || subscribeGate.isBlocking) { + if (!authGate.canRenderAuthenticatedContent) { return null; } diff --git a/src/server/auth/disposable-email.ts b/src/server/auth/disposable-email.ts new file mode 100644 index 0000000..d48ff49 --- /dev/null +++ b/src/server/auth/disposable-email.ts @@ -0,0 +1,53 @@ +// A curated blocklist of the highest-volume disposable / throwaway email +// providers. The free plan grants real credit spend off nothing but a verified +// email, so the cheapest way to farm it is a temp-inbox service that can still +// receive the verification link. Blocking the busiest of those raises the cost +// of mass signups without touching legitimate users. +// +// This is deliberately a small, zero-dependency list — it is not exhaustive. +// For comprehensive coverage, swap this for the `disposable-email-domains` +// package or a captcha on signup (see the PR notes). +const DISPOSABLE_EMAIL_DOMAINS = new Set([ + "0clock.net", + "10minutemail.com", + "20minutemail.com", + "33mail.com", + "burnermail.io", + "dispostable.com", + "emailondeck.com", + "fakeinbox.com", + "getairmail.com", + "getnada.com", + "guerrillamail.com", + "guerrillamail.net", + "guerrillamail.org", + "inboxbear.com", + "inboxkitten.com", + "mail-temp.com", + "mailcatch.com", + "maildrop.cc", + "mailinator.com", + "mailnesia.com", + "moakt.com", + "mohmal.com", + "mytemp.email", + "sharklasers.com", + "spam4.me", + "temp-mail.io", + "temp-mail.org", + "tempmail.com", + "tempmail.dev", + "tempmailo.com", + "tempr.email", + "throwawaymail.com", + "trashmail.com", + "trashmail.de", + "yopmail.com", + "yopmail.fr", + "yopmail.net", +]); + +export function isDisposableEmailDomain(email: string): boolean { + const domain = email.split("@").at(-1)?.trim().toLowerCase(); + return domain !== undefined && DISPOSABLE_EMAIL_DOMAINS.has(domain); +} diff --git a/src/serverFunctions/audit.ts b/src/serverFunctions/audit.ts index 6886783..13f43f2 100644 --- a/src/serverFunctions/audit.ts +++ b/src/serverFunctions/audit.ts @@ -20,7 +20,8 @@ export const startAudit = createServerFn({ method: "POST" }) .inputValidator((data: unknown) => startAuditSchema.parse(data)) .handler(async ({ data, context }) => { // The crawler runs on our Workers compute and isn't credit-metered, so - // gate it on plan access in hosted mode (grandfathered free plans pass). + // gate it on managed access in hosted mode. Free and paid plans both grant + // it; only customers with no Autumn product at all are turned away. if ( (await isHostedServerAuthMode()) && !(await customerHasManagedAccess(context.organizationId)) diff --git a/src/serverFunctions/billing.ts b/src/serverFunctions/billing.ts index 5c2d678..329dc22 100644 --- a/src/serverFunctions/billing.ts +++ b/src/serverFunctions/billing.ts @@ -10,10 +10,6 @@ import { isHostedServerAuthMode, } from "@/server/lib/runtime-env"; import { requireAuthenticatedContext } from "@/serverFunctions/middleware"; -import { - customerHasManagedAccess, - getOrCreateOrganizationCustomer, -} from "@/server/billing/subscription"; const AUTUMN_EVENTS_LIST_URL = "https://api.useautumn.com/v1/events.list"; const EVENT_PAGE_LIMIT = 1000; @@ -52,20 +48,6 @@ export type BillingUsageEvent = { properties: Record>; }; -// Whether this organization may use the managed product at all. Both the -// legacy free plan (grandfathered users) and the paid base plan grant the -// feature; new customers get no default product and must subscribe. -export const getManagedAccessStatus = createServerFn({ method: "POST" }) - .middleware(requireAuthenticatedContext) - .handler(async ({ context }) => { - if (!(await isHostedServerAuthMode())) { - return { hasManagedAccess: true }; - } - - const customer = await getOrCreateOrganizationCustomer(context); - return { hasManagedAccess: await customerHasManagedAccess(customer.id) }; - }); - export const getBillingUsageEvents = createServerFn({ method: "POST" }) .middleware(requireAuthenticatedContext) .inputValidator((data: unknown) => billingUsageRangeSchema.parse(data)) diff --git a/src/shared/billing.ts b/src/shared/billing.ts index 52dbf8d..a539343 100644 --- a/src/shared/billing.ts +++ b/src/shared/billing.ts @@ -4,8 +4,9 @@ export const SUBSCRIBE_ROUTE = "/subscribe"; export const AUTUMN_PAID_PLAN_ID = "base-plan"; export const AUTUMN_SEO_DATA_TOP_UP_PLAN_ID = "credit-top-up"; export const AUTUMN_PAID_PLAN_FEATURE_ID = "paid_plan"; -// Granted by both the legacy free plan (grandfathered users) and the paid -// base plan. New customers get no default product, so this is the paywall. +// Granted by both the free plan (now the Autumn Default, so every non-paid +// user gets it) and the paid base plan. It's the floor for using the managed +// service at all — paid-only features gate on AUTUMN_PAID_PLAN_FEATURE_ID. export const AUTUMN_MANAGED_ACCESS_FEATURE_ID = "managed_service_access"; // The shared usage-credit pool. Both DataForSEO and onboarding-LLM spend deduct // from these (monthly usage_credits first, then rolled-over topup_credits).