feat: gate llm mentions in self hosted product (#134)

This commit is contained in:
Ben Senescu 2026-04-23 18:02:24 -04:00 committed by GitHub
parent 65d62f0eed
commit 1108cdd9a7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 748 additions and 811 deletions

View File

@ -0,0 +1,83 @@
import type { ReactNode } from "react";
import { ShieldAlert, Wrench } from "lucide-react";
export function AccessGateLoadingState() {
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
<div className="skeleton h-6 w-48" />
<div className="skeleton h-4 w-full" />
<div className="skeleton h-4 w-4/5" />
<div className="skeleton h-10 w-48" />
</div>
</div>
);
}
export function AccessGate({
title,
bodyText,
helperText,
buttonLabel,
refetchingLabel = "Confirming...",
externalUrl,
externalLabel,
errorMessage,
isRefetching,
onRetry,
}: {
title: string;
bodyText: ReactNode;
helperText?: ReactNode;
buttonLabel: string;
refetchingLabel?: string;
externalUrl: string;
externalLabel: string;
errorMessage: string | null;
isRefetching: boolean;
onRetry: () => void;
}) {
return (
<section>
<div className="rounded-2xl border border-base-300 bg-base-100 p-6 md:p-7 space-y-5">
<div className="flex items-start gap-3">
<div className="rounded-xl bg-warning/15 p-2.5 text-warning shrink-0">
<Wrench className="size-5" />
</div>
<div className="max-w-3xl space-y-1.5">
<h2 className="text-xl font-semibold">{title}</h2>
<div className="text-sm text-base-content/68">{bodyText}</div>
{helperText ? (
<div className="text-xs text-base-content/50">{helperText}</div>
) : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
className="btn btn-primary"
onClick={onRetry}
disabled={isRefetching}
>
{isRefetching ? refetchingLabel : buttonLabel}
</button>
<a
className="btn btn-outline"
href={externalUrl}
target="_blank"
rel="noreferrer"
>
{externalLabel}
</a>
</div>
{errorMessage ? (
<div className="alert alert-warning">
<ShieldAlert className="size-4 shrink-0" />
<span>{errorMessage}</span>
</div>
) : null}
</div>
</section>
);
}

View File

@ -0,0 +1,46 @@
import { useCallback } from "react";
import { useQuery } from "@tanstack/react-query";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
type AccessGateStatus = {
enabled: boolean;
errorMessage: string | null;
};
export type UseAccessGateResult = {
enabled: boolean;
isLoading: boolean;
isRefetching: boolean;
errorMessage: string | null;
statusErrorMessage: string | null;
onRetry: () => void;
};
export function useAccessGate(config: {
queryKey: readonly unknown[];
queryFn: () => Promise<AccessGateStatus>;
statusErrorFallback: string;
}): UseAccessGateResult {
const { data, error, isPending, isRefetching, refetch } = useQuery({
queryKey: config.queryKey,
queryFn: config.queryFn,
refetchOnWindowFocus: false,
staleTime: 60 * 1000,
});
const statusErrorMessage = error
? getStandardErrorMessage(error, config.statusErrorFallback)
: null;
const onRetry = useCallback(() => {
void refetch();
}, [refetch]);
return {
enabled: data?.enabled ?? false,
isLoading: isPending,
isRefetching,
errorMessage: data?.errorMessage ?? null,
statusErrorMessage,
onRetry,
};
}

View File

@ -1,6 +1,5 @@
import { useEffect, useState, type FormEvent } from "react";
import { useQuery } from "@tanstack/react-query";
import { AutumnProvider, useCustomer } from "autumn-js/react";
import {
AlertCircle,
ArrowLeft,
@ -9,14 +8,21 @@ import {
TrendingUp,
} from "lucide-react";
import { lookupBrand } from "@/serverFunctions/ai-search";
import { useSession } from "@/lib/auth-client";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import {
HostedPlanGate,
type HostedPlanGateState,
} from "@/client/features/billing/HostedPlanGate";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { BrandLookupResults } from "@/client/features/ai-search/components/BrandLookupResults";
import { BrandLookupSearchCard } from "@/client/features/ai-search/components/BrandLookupSearchCard";
import { BrandLookupHistorySection } from "@/client/features/ai-search/components/BrandLookupHistorySection";
import { AiSearchLoadingState } from "@/client/features/ai-search/components/AiSearchLoadingState";
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate";
import {
AiSearchAccessLoadingState,
AiSearchSetupGate,
} from "@/client/features/ai-search/components/AiSearchSetupGate";
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
import { useBrandLookupSearchHistory } from "@/client/hooks/useBrandLookupSearchHistory";
import { BRAND_LOOKUP_MAX_INPUT_LENGTH } from "@/types/schemas/ai-search";
@ -46,9 +52,9 @@ const BRAND_LOOKUP_BULLETS = [
export function BrandLookupPage(props: Props) {
return (
<AutumnProvider>
<BrandLookupPageInner {...props} />
</AutumnProvider>
<HostedPlanGate>
{(planGate) => <BrandLookupPageInner {...props} planGate={planGate} />}
</HostedPlanGate>
);
}
@ -56,18 +62,12 @@ function BrandLookupPageInner({
projectId,
initialQuery,
onQueryChange,
}: Props) {
planGate,
}: Props & { planGate: HostedPlanGateState }) {
const [query, setQuery] = useState(initialQuery);
const [validationError, setValidationError] = useState<string | null>(null);
const { data: session } = useSession();
const customerQuery = useCustomer({
queryOptions: { enabled: Boolean(session?.user?.id) },
});
const planKnown = customerQuery.isSuccess || customerQuery.isError;
const isFreePlan =
!!customerQuery.data &&
getCustomerPlanStatus(customerQuery.data) === "free";
const access = useAiSearchAccess(projectId);
const trimmedInitialQuery = initialQuery.trim();
const hasActiveQuery = trimmedInitialQuery.length > 0;
@ -83,7 +83,7 @@ function BrandLookupPageInner({
languageCode: "en",
},
}),
enabled: hasActiveQuery && !isFreePlan,
enabled: hasActiveQuery && !planGate.isFreePlan && access.enabled,
staleTime: 5 * 60 * 1000,
retry: false,
});
@ -137,7 +137,7 @@ function BrandLookupPageInner({
: null;
const resultData = hasActiveQuery ? lookupQuery.data : undefined;
if (!planKnown) return null;
if (planGate.isLoading) return null;
return (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
@ -149,7 +149,15 @@ function BrandLookupPageInner({
</p>
</div>
{isFreePlan ? (
{access.isLoading ? (
<AiSearchAccessLoadingState />
) : !access.enabled ? (
<AiSearchSetupGate
errorMessage={access.errorMessage ?? access.statusErrorMessage}
isRefetching={access.isRefetching}
onRetry={access.onRetry}
/>
) : planGate.isFreePlan ? (
<AiSearchPaidPlanGate
feature="Brand Lookup"
description="See how ChatGPT and Google AI Overview cite any brand or domain — total mentions, the prompts driving them, and the pages cited alongside yours."

View File

@ -1,6 +1,5 @@
import { useState, type FormEvent } from "react";
import { useMutation } from "@tanstack/react-query";
import { AutumnProvider, useCustomer } from "autumn-js/react";
import {
AlertCircle,
ArrowLeft,
@ -9,14 +8,21 @@ import {
Sparkles,
} from "lucide-react";
import { explorePrompt } from "@/serverFunctions/ai-search";
import { useSession } from "@/lib/auth-client";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
import {
HostedPlanGate,
type HostedPlanGateState,
} from "@/client/features/billing/HostedPlanGate";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
import { PromptExplorerForm } from "@/client/features/ai-search/components/PromptExplorerForm";
import { PromptExplorerResults } from "@/client/features/ai-search/components/PromptExplorerResults";
import { PromptExplorerLoadingState } from "@/client/features/ai-search/components/PromptExplorerLoadingState";
import { PromptExplorerHistorySection } from "@/client/features/ai-search/components/PromptExplorerHistorySection";
import { AiSearchPaidPlanGate } from "@/client/features/ai-search/components/AiSearchPaidPlanGate";
import {
AiSearchAccessLoadingState,
AiSearchSetupGate,
} from "@/client/features/ai-search/components/AiSearchSetupGate";
import { useAiSearchAccess } from "@/client/features/ai-search/useAiSearchAccess";
import {
usePromptExplorerSearchHistory,
type PromptExplorerSearchHistoryItem,
@ -68,24 +74,19 @@ const INITIAL_FORM_STATE: FormState = {
export function PromptExplorerPage(props: Props) {
return (
<AutumnProvider>
<PromptExplorerPageInner {...props} />
</AutumnProvider>
<HostedPlanGate>
{(planGate) => <PromptExplorerPageInner {...props} planGate={planGate} />}
</HostedPlanGate>
);
}
function PromptExplorerPageInner({ projectId }: Props) {
function PromptExplorerPageInner({
projectId,
planGate,
}: Props & { planGate: HostedPlanGateState }) {
const [form, setForm] = useState<FormState>(INITIAL_FORM_STATE);
const [validationError, setValidationError] = useState<string | null>(null);
const { data: session } = useSession();
const customerQuery = useCustomer({
queryOptions: { enabled: Boolean(session?.user?.id) },
});
const planKnown = customerQuery.isSuccess || customerQuery.isError;
const isFreePlan =
!!customerQuery.data &&
getCustomerPlanStatus(customerQuery.data) === "free";
const access = useAiSearchAccess(projectId);
const {
history,
@ -177,7 +178,7 @@ function PromptExplorerPageInner({ projectId }: Props) {
if (validationError) setValidationError(null);
};
if (!planKnown) return null;
if (planGate.isLoading) return null;
return (
<div className="px-4 py-4 pb-24 overflow-auto md:px-6 md:py-6 md:pb-8">
@ -190,7 +191,15 @@ function PromptExplorerPageInner({ projectId }: Props) {
</p>
</div>
{isFreePlan ? (
{access.isLoading ? (
<AiSearchAccessLoadingState />
) : !access.enabled ? (
<AiSearchSetupGate
errorMessage={access.errorMessage ?? access.statusErrorMessage}
isRefetching={access.isRefetching}
onRetry={access.onRetry}
/>
) : planGate.isFreePlan ? (
<AiSearchPaidPlanGate
feature="Prompt Explorer"
description="Ask one prompt across ChatGPT, Claude, Gemini, and Perplexity at the same time and compare their answers — including which sources each model cites."

View File

@ -0,0 +1,50 @@
import {
AccessGate,
AccessGateLoadingState,
} from "@/client/features/access-gate/AccessGate";
export function AiSearchAccessLoadingState() {
return <AccessGateLoadingState />;
}
export function AiSearchSetupGate({
errorMessage,
isRefetching,
onRetry,
}: {
errorMessage: string | null;
isRefetching: boolean;
onRetry: () => void;
}) {
return (
<AccessGate
title="Enable AI Optimization"
bodyText="AI Optimization is not enabled for your DataForSEO account yet. You can enable it in DataForSEO, or use managed OpenSEO for long-term LLM Mentions access at $10/month."
helperText={
<>
We are also planning an API so self-hosted apps can use OpenSEO's LLM
Mentions data directly. Until then, <InlineManagedOpenSeoLink />.
</>
}
buttonLabel="Confirm AI Optimization Access"
externalUrl="https://app.dataforseo.com/api-access-subscriptions"
externalLabel="Open DataForSEO API Access"
errorMessage={errorMessage}
isRefetching={isRefetching}
onRetry={onRetry}
/>
);
}
function InlineManagedOpenSeoLink() {
return (
<a
className="underline underline-offset-2 hover:text-base-content/70"
href="https://openseo.so/?utm_source=self_hosted_app&utm_medium=access_gate&utm_campaign=llm_mentions"
target="_blank"
rel="noreferrer"
>
use managed OpenSEO
</a>
);
}

View File

@ -0,0 +1,10 @@
import { useAccessGate } from "@/client/features/access-gate/useAccessGate";
import { getAiSearchAccessSetupStatus } from "@/serverFunctions/aiSearchAccess";
export function useAiSearchAccess(projectId: string) {
return useAccessGate({
queryKey: ["aiSearchAccessStatus", projectId],
queryFn: () => getAiSearchAccessSetupStatus({ data: { projectId } }),
statusErrorFallback: "Could not load AI Optimization setup status.",
});
}

View File

@ -9,7 +9,6 @@ import {
} from "./useBacklinksPageData";
import { useBacklinksFilters } from "./useBacklinksFilters";
import { useBacklinksSearchHistory } from "@/client/hooks/useBacklinksSearchHistory";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
export function BacklinksPage({
projectId,
@ -18,17 +17,13 @@ export function BacklinksPage({
}: BacklinksPageProps) {
const filters = useBacklinksFilters();
const {
accessStatus,
accessStatusErrorMessage,
accessStatusQuery,
accessGate,
activeTabErrorMessage,
backlinksDisabledByError,
backlinksEnabled,
overviewErrorMessage,
overviewQuery,
referringDomainsQuery,
searchCardInitialValues,
testAccessMutation,
topPagesQuery,
} = useBacklinksPageData({
projectId,
@ -63,8 +58,8 @@ export function BacklinksPage({
</p>
</div>
{!accessStatusQuery.isLoading &&
backlinksEnabled &&
{!accessGate.isLoading &&
accessGate.enabled &&
!backlinksDisabledByError ? (
<BacklinksSearchCard
errorMessage={overviewErrorMessage}
@ -82,13 +77,10 @@ export function BacklinksPage({
) : null}
<BacklinksBody
accessStatus={accessStatus}
accessStatusError={accessStatusErrorMessage}
accessGate={accessGate}
backlinksDisabledByError={backlinksDisabledByError}
backlinksEnabled={backlinksEnabled}
history={history}
historyLoaded={historyLoaded}
isAccessStatusLoading={accessStatusQuery.isLoading}
overviewData={overviewQuery.data}
overviewError={overviewErrorMessage}
overviewLoading={overviewQuery.isLoading}
@ -101,23 +93,12 @@ export function BacklinksPage({
referringDomainsQuery.isLoading) ||
(searchState.tab === "pages" && topPagesQuery.isLoading)
}
testError={
testAccessMutation.error
? getStandardErrorMessage(
testAccessMutation.error,
"Could not test Backlinks access.",
)
: null
}
testIsPending={testAccessMutation.isPending}
topPages={topPagesQuery.data}
onRemoveHistoryItem={removeHistoryItem}
onRetryAccess={() => void accessStatusQuery.refetch()}
onSelectHistoryItem={handleHistorySelect}
onShowHistory={() => navigateToBacklinksHistory(navigate)}
onSetActiveTab={(tab) => navigateToBacklinksTab(navigate, tab)}
onRetryOverview={() => void overviewQuery.refetch()}
onTestAccess={() => testAccessMutation.mutate()}
/>
</div>
</div>

View File

@ -12,12 +12,12 @@ import {
import { BacklinksHistorySection } from "./BacklinksHistorySection";
import type { BacklinksSearchHistoryItem } from "@/client/hooks/useBacklinksSearchHistory";
import type {
BacklinksAccessStatusData,
BacklinksOverviewData,
BacklinksReferringDomainsData,
BacklinksSearchState,
BacklinksTopPagesData,
} from "./backlinksPageTypes";
import type { UseAccessGateResult } from "@/client/features/access-gate/useAccessGate";
import { buildSummaryStats } from "./backlinksPageUtils";
import {
filterBacklinkRows,
@ -27,13 +27,10 @@ import {
import type { BacklinksFiltersState } from "./useBacklinksFilters";
type BacklinksBodyProps = {
accessStatus: BacklinksAccessStatusData | undefined;
accessStatusError: string | null;
accessGate: UseAccessGateResult;
backlinksDisabledByError: boolean;
backlinksEnabled: boolean;
history: BacklinksSearchHistoryItem[];
historyLoaded: boolean;
isAccessStatusLoading: boolean;
overviewData: BacklinksOverviewData | undefined;
overviewError: string | null;
overviewLoading: boolean;
@ -42,26 +39,19 @@ type BacklinksBodyProps = {
filters: BacklinksFiltersState;
tabErrorMessage: string | null;
tabLoading: boolean;
testError: string | null;
testIsPending: boolean;
topPages: BacklinksTopPagesData | undefined;
onRemoveHistoryItem: (timestamp: number) => void;
onRetryAccess: () => void;
onSelectHistoryItem: (item: BacklinksSearchHistoryItem) => void;
onShowHistory: () => void;
onSetActiveTab: (tab: BacklinksSearchState["tab"]) => void;
onRetryOverview: () => void;
onTestAccess: () => void;
};
export function BacklinksBody({
accessStatus,
accessStatusError,
accessGate,
backlinksDisabledByError,
backlinksEnabled,
history,
historyLoaded,
isAccessStatusLoading,
overviewData,
overviewError,
overviewLoading,
@ -70,16 +60,12 @@ export function BacklinksBody({
filters,
tabErrorMessage,
tabLoading,
testError,
testIsPending,
topPages,
onRemoveHistoryItem,
onRetryAccess,
onSelectHistoryItem,
onShowHistory,
onSetActiveTab,
onRetryOverview,
onTestAccess,
}: BacklinksBodyProps) {
const mergedData = useMemo(
() => mergeTabData(overviewData, referringDomains, topPages),
@ -111,26 +97,25 @@ export function BacklinksBody({
[mergedData],
);
if (isAccessStatusLoading) {
if (accessGate.isLoading) {
return <BacklinksAccessLoadingState />;
}
if (accessStatusError) {
if (accessGate.statusErrorMessage) {
return (
<BacklinksErrorState
errorMessage={accessStatusError}
onRetry={onRetryAccess}
errorMessage={accessGate.statusErrorMessage}
onRetry={accessGate.onRetry}
/>
);
}
if (!backlinksEnabled || backlinksDisabledByError) {
if (!accessGate.enabled || backlinksDisabledByError) {
return (
<BacklinksSetupGate
status={accessStatus}
isTesting={testIsPending}
testError={testError}
onTest={onTestAccess}
errorMessage={accessGate.errorMessage}
isRefetching={accessGate.isRefetching}
onRetry={accessGate.onRetry}
/>
);
}

View File

@ -1,73 +1,40 @@
import { ShieldAlert, Wrench } from "lucide-react";
import type { BacklinksAccessStatusData } from "./backlinksPageTypes";
import { formatRelativeTimestamp } from "./backlinksPageUtils";
import { ShieldAlert } from "lucide-react";
import {
AccessGate,
AccessGateLoadingState,
} from "@/client/features/access-gate/AccessGate";
export function BacklinksAccessLoadingState() {
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
<div className="skeleton h-6 w-48" />
<div className="skeleton h-4 w-full" />
<div className="skeleton h-4 w-4/5" />
<div className="skeleton h-10 w-48" />
</div>
</div>
);
return <AccessGateLoadingState />;
}
export function BacklinksSetupGate({
status,
isTesting,
testError,
onTest,
errorMessage,
isRefetching,
onRetry,
}: {
status: BacklinksAccessStatusData | undefined;
isTesting: boolean;
testError: string | null;
onTest: () => void;
errorMessage: string | null;
isRefetching: boolean;
onRetry: () => void;
}) {
return (
<section>
<div className="rounded-2xl border border-base-300 bg-base-100 p-6 md:p-7 space-y-5">
<div className="flex items-start gap-3">
<div className="rounded-xl bg-warning/15 p-2.5 text-warning shrink-0">
<Wrench className="size-5" />
</div>
<div className="max-w-3xl space-y-1.5">
<h2 className="text-xl font-semibold">Enable Backlinks</h2>
<p className="text-sm text-base-content/68">
Backlinks is not enabled for your DataForSEO account yet. Turn it
on in DataForSEO, then test access here.
</p>
<p className="text-xs text-base-content/50">
DataForSEO offers a free 14-day trial for Backlinks. Then, it's
$100/month. We're gauging interest in building out a lower-cost
alternative, <InlineMailingListLink /> if you're interested.
</p>
</div>
</div>
<div className="flex flex-wrap items-center gap-3">
<button
className="btn btn-primary"
onClick={onTest}
disabled={isTesting}
>
{isTesting ? "Confirming..." : "Confirm Backlinks Access"}
</button>
<a
className="btn btn-outline"
href="https://app.dataforseo.com/api-access-subscriptions"
target="_blank"
rel="noreferrer"
>
Open DataForSEO Backlinks
</a>
</div>
<BacklinksSetupFeedback status={status} testError={testError} />
</div>
</section>
<AccessGate
title="Enable Backlinks"
bodyText="Backlinks are not enabled for your DataForSEO account yet. You can enable them in DataForSEO, or use managed OpenSEO for long-term backlinks access at $10/month."
helperText={
<>
We are also planning a Backlinks API so self-hosted apps can use
OpenSEO's backlinks data directly. Until then,{" "}
<InlineManagedOpenSeoLink />.
</>
}
buttonLabel="Confirm DataForSEO Access"
externalUrl="https://app.dataforseo.com/api-access-subscriptions"
externalLabel="Open DataForSEO Backlinks"
errorMessage={errorMessage}
isRefetching={isRefetching}
onRetry={onRetry}
/>
);
}
@ -131,45 +98,15 @@ export function BacklinksErrorState({
);
}
function BacklinksSetupFeedback({
status,
testError,
}: {
status: BacklinksAccessStatusData | undefined;
testError: string | null;
}) {
return (
<div className="space-y-3">
{status?.lastCheckedAt ? (
<div className="text-sm text-base-content/60">
Last checked {formatRelativeTimestamp(status.lastCheckedAt)}.
</div>
) : null}
{status?.lastErrorMessage ? (
<div className="alert alert-warning">
<ShieldAlert className="size-4 shrink-0" />
<span>{status.lastErrorMessage}</span>
</div>
) : null}
{testError ? (
<div className="alert alert-error">
<ShieldAlert className="size-4 shrink-0" />
<span>{testError}</span>
</div>
) : null}
</div>
);
}
function InlineMailingListLink() {
function InlineManagedOpenSeoLink() {
return (
<a
className="underline underline-offset-2 hover:text-base-content/70"
href="https://openseo.so"
href="https://openseo.so/?utm_source=self_hosted_app&utm_medium=access_gate&utm_campaign=backlinks"
target="_blank"
rel="noreferrer"
>
join the OpenSEO mailing list
use managed OpenSEO
</a>
);
}

View File

@ -7,14 +7,10 @@ import type {
getBacklinksReferringDomains,
getBacklinksTopPages,
} from "@/serverFunctions/backlinks";
import type { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess";
export type BacklinksOverviewData = Awaited<
ReturnType<typeof getBacklinksOverview>
>;
export type BacklinksAccessStatusData = Awaited<
ReturnType<typeof getBacklinksAccessSetupStatus>
>;
export type BacklinksReferringDomainsData = Awaited<
ReturnType<typeof getBacklinksReferringDomains>
>;

View File

@ -1,9 +1,10 @@
import { useEffect, useMemo } from "react";
import { useMutation, useQuery } from "@tanstack/react-query";
import { useQuery } from "@tanstack/react-query";
import type {
BacklinksPageProps,
BacklinksSearchState,
} from "./backlinksPageTypes";
import { useAccessGate } from "@/client/features/access-gate/useAccessGate";
import {
getErrorCode,
getStandardErrorMessage,
@ -13,10 +14,7 @@ import {
getBacklinksReferringDomains,
getBacklinksTopPages,
} from "@/serverFunctions/backlinks";
import {
getBacklinksAccessSetupStatus,
testBacklinksAccess,
} from "@/serverFunctions/backlinksAccess";
import { getBacklinksAccessSetupStatus } from "@/serverFunctions/backlinksAccess";
import { getPersistedBacklinksSearchScope } from "./backlinksSearchScope";
type UseBacklinksPageDataArgs = {
@ -40,18 +38,13 @@ export function useBacklinksPageData({
projectId,
searchState,
}: UseBacklinksPageDataArgs) {
const accessStatusQuery = useQuery({
const accessGate = useAccessGate({
queryKey: ["backlinksAccessStatus", projectId],
queryFn: () => getBacklinksAccessSetupStatus({ data: { projectId } }),
statusErrorFallback: "Could not load Backlinks setup status.",
});
const accessStatus = accessStatusQuery.data;
const accessStatusErrorMessage = accessStatusQuery.error
? getStandardErrorMessage(
accessStatusQuery.error,
"Could not load Backlinks setup status.",
)
: null;
const backlinksEnabled = accessStatus?.enabled ?? false;
const backlinksEnabled = accessGate.enabled;
const retryAccessGate = accessGate.onRetry;
const requestInput = buildBacklinksRequestInput(projectId, searchState);
const searchCardInitialValues = useMemo(
() => ({
@ -61,13 +54,6 @@ export function useBacklinksPageData({
[searchState.scope, searchState.target],
);
const testAccessMutation = useMutation({
mutationFn: () => testBacklinksAccess({ data: { projectId } }),
onSuccess: async () => {
await accessStatusQuery.refetch();
},
});
const baseQueryKeyParts = [
projectId,
searchState.scope,
@ -118,29 +104,25 @@ export function useBacklinksPageData({
useEffect(() => {
if (
(backlinksDisabledByError || backlinksDisabledByTabError) &&
accessStatus?.enabled
backlinksEnabled
) {
void accessStatusQuery.refetch();
retryAccessGate();
}
}, [
accessStatus?.enabled,
accessStatusQuery,
backlinksDisabledByError,
backlinksDisabledByTabError,
backlinksEnabled,
retryAccessGate,
]);
return {
accessStatus,
accessStatusErrorMessage,
accessStatusQuery,
accessGate,
activeTabErrorMessage,
backlinksDisabledByError,
backlinksEnabled,
overviewErrorMessage,
overviewQuery,
referringDomainsQuery,
searchCardInitialValues,
testAccessMutation,
topPagesQuery,
};
}

View File

@ -0,0 +1,50 @@
import type { ReactNode } from "react";
import { AutumnProvider, useCustomer } from "autumn-js/react";
import { useSession } from "@/lib/auth-client";
import { isHostedClientAuthMode } from "@/lib/auth-mode";
import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection";
export type HostedPlanGateState = {
isLoading: boolean;
isFreePlan: boolean;
};
const SELF_HOSTED_PLAN_GATE: HostedPlanGateState = {
isLoading: false,
isFreePlan: false,
};
export function HostedPlanGate({
children,
}: {
children: (state: HostedPlanGateState) => ReactNode;
}) {
if (!isHostedClientAuthMode()) {
return children(SELF_HOSTED_PLAN_GATE);
}
return (
<AutumnProvider>
<HostedPlanGateContent>{children}</HostedPlanGateContent>
</AutumnProvider>
);
}
function HostedPlanGateContent({
children,
}: {
children: (state: HostedPlanGateState) => ReactNode;
}) {
const { data: session, isPending: isSessionPending } = useSession();
const hasSession = Boolean(session?.user?.id);
const customerQuery = useCustomer({
queryOptions: { enabled: hasSession },
});
return children({
isLoading: isSessionPending || !hasSession || customerQuery.isLoading,
isFreePlan:
!!customerQuery.data &&
getCustomerPlanStatus(customerQuery.data) === "free",
});
}

View File

@ -18,6 +18,10 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
"Backlinks is not enabled for the connected DataForSEO account yet.",
BACKLINKS_BILLING_ISSUE:
"The connected DataForSEO account has a billing or balance issue.",
AI_SEARCH_NOT_ENABLED:
"AI Optimization is not enabled for the connected DataForSEO account yet.",
AI_SEARCH_BILLING_ISSUE:
"The connected DataForSEO account has a billing or balance issue.",
RATE_LIMITED: "Too many requests. Please wait and try again.",
CONFLICT: "This request conflicts with existing data.",
INTERNAL_ERROR:

View File

@ -66,18 +66,7 @@ export async function getBrandLookup(
),
);
// Credits exhaustion is global, not per-platform — re-throw immediately so
// the user gets a single clear "out of credits" error instead of a
// half-rendered result.
for (const settledResult of settled) {
if (
settledResult.status === "rejected" &&
settledResult.reason instanceof AppError &&
settledResult.reason.code === "INSUFFICIENT_CREDITS"
) {
throw settledResult.reason;
}
}
rethrowIfBlockingAiSearchError(settled);
const platformBundles: PlatformOutcome[] = settled.map((settledResult, i) => {
const platform = PLATFORMS[i];
@ -173,7 +162,7 @@ async function fetchPlatformData(
}),
]);
rethrowIfCreditsExhausted(aggregated, topPages, mentions);
rethrowIfBlockingAiSearchError([aggregated, topPages, mentions]);
// If every sub-call failed we have nothing to render for this platform —
// reject so the outer `allSucceeded` gate refuses to cache a blank result.
@ -190,14 +179,16 @@ async function fetchPlatformData(
};
}
function rethrowIfCreditsExhausted(
...results: Array<PromiseSettledResult<unknown>>
function rethrowIfBlockingAiSearchError(
results: Array<PromiseSettledResult<unknown>>,
): void {
for (const result of results) {
if (
result.status === "rejected" &&
result.reason instanceof AppError &&
result.reason.code === "INSUFFICIENT_CREDITS"
(result.reason.code === "INSUFFICIENT_CREDITS" ||
result.reason.code === "AI_SEARCH_NOT_ENABLED" ||
result.reason.code === "AI_SEARCH_BILLING_ISSUE")
) {
throw result.reason;
}

View File

@ -276,10 +276,14 @@ function mapErrorToResult(
model: PromptExplorerModel,
reason: unknown,
): PromptExplorerModelResult {
if (reason instanceof AppError && reason.code === "INSUFFICIENT_CREDITS") {
// Re-throw INSUFFICIENT_CREDITS so the whole request surfaces it instead
// of silently degrading to "Claude failed" — credits exhaustion is global,
// not per-model.
if (
reason instanceof AppError &&
(reason.code === "INSUFFICIENT_CREDITS" ||
reason.code === "AI_SEARCH_NOT_ENABLED" ||
reason.code === "AI_SEARCH_BILLING_ISSUE")
) {
// These account-level failures apply to every model, so surface one clear
// error instead of silently degrading to per-model failures.
throw reason;
}

View File

@ -1,39 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
buildVerifiedBacklinksAccessStatus,
getBacklinksAccessStatus,
setBacklinksAccessStatus,
} from "@/server/features/backlinks/backlinksAccess";
const { kvState } = vi.hoisted(() => ({
kvState: new Map<string, string>(),
}));
vi.mock("@/server/lib/runtime-env", () => ({
getEnvValue: vi.fn(async () => undefined),
isHostedServerAuthMode: vi.fn(async () => false),
getWorkersBinding: vi.fn(async () => ({
get: vi.fn(async (key: string) => kvState.get(key) ?? null),
put: vi.fn(async (key: string, value: string) => {
kvState.set(key, value);
}),
})),
}));
describe("backlinksAccess", () => {
beforeEach(() => {
kvState.clear();
});
it("stores access status globally", async () => {
const checkedAt = "2026-03-14T00:00:00.000Z";
await setBacklinksAccessStatus(
buildVerifiedBacklinksAccessStatus(checkedAt),
);
await expect(getBacklinksAccessStatus()).resolves.toMatchObject({
enabled: true,
verifiedAt: checkedAt,
});
});
});

View File

@ -1,130 +0,0 @@
import { z } from "zod";
import {
getWorkersBinding,
isHostedServerAuthMode,
} from "@/server/lib/runtime-env";
const BACKLINKS_ACCESS_STATUS_KEY = "settings:backlinks-access:v2:global";
const backlinksAccessStatusSchema = z.object({
enabled: z.boolean(),
verifiedAt: z.string().nullable(),
lastCheckedAt: z.string().nullable(),
lastErrorCode: z.string().nullable(),
lastErrorMessage: z.string().nullable(),
});
type BacklinksAccessStatus = z.infer<typeof backlinksAccessStatusSchema>;
const BACKLINKS_NOT_ENABLED_MESSAGE =
"Backlinks access check failed - it's still not enabled for your DataForSEO account. Enable it in DataForSEO, then try again.";
export async function getBacklinksAccessStatus(): Promise<BacklinksAccessStatus> {
if (await isHostedServerAuthMode()) {
// Hosted mode treats backlinks as platform-managed, so we intentionally
// skip self-service verification and surface backlinks as available.
return getHostedBacklinksAccessStatus();
}
const kv = await getKvNamespace();
const raw = await kv.get(BACKLINKS_ACCESS_STATUS_KEY, "text");
if (!raw) {
return getDefaultBacklinksAccessStatus();
}
const json = parseJsonUnknown(raw);
if (json === null) {
return getDefaultBacklinksAccessStatus();
}
const parsed = backlinksAccessStatusSchema.safeParse(json);
if (!parsed.success) {
return getDefaultBacklinksAccessStatus();
}
return parsed.data;
}
export async function setBacklinksAccessStatus(
status: BacklinksAccessStatus,
): Promise<void> {
if (await isHostedServerAuthMode()) {
return;
}
const kv = await getKvNamespace();
await kv.put(BACKLINKS_ACCESS_STATUS_KEY, JSON.stringify(status));
}
export function buildVerifiedBacklinksAccessStatus(
checkedAt: string,
): BacklinksAccessStatus {
return {
enabled: true,
verifiedAt: checkedAt,
lastCheckedAt: checkedAt,
lastErrorCode: null,
lastErrorMessage: null,
};
}
export function buildBacklinksDisabledAccessStatus(
checkedAt: string,
errorCode: string,
): BacklinksAccessStatus {
return {
enabled: false,
verifiedAt: null,
lastCheckedAt: checkedAt,
lastErrorCode: errorCode,
lastErrorMessage: BACKLINKS_NOT_ENABLED_MESSAGE,
};
}
function getDefaultBacklinksAccessStatus(): BacklinksAccessStatus {
return {
enabled: false,
verifiedAt: null,
lastCheckedAt: null,
lastErrorCode: null,
lastErrorMessage: null,
};
}
function getHostedBacklinksAccessStatus(): BacklinksAccessStatus {
return {
enabled: true,
verifiedAt: null,
lastCheckedAt: null,
lastErrorCode: null,
lastErrorMessage: null,
};
}
async function getKvNamespace(): Promise<KVNamespace> {
const binding = await getWorkersBinding("KV");
if (isKvNamespace(binding)) {
return binding;
}
throw new Error("KV binding is not configured correctly");
}
function isKvNamespace(value: unknown): value is KVNamespace {
return (
typeof value === "object" &&
value !== null &&
"get" in value &&
typeof value.get === "function" &&
"put" in value &&
typeof value.put === "function"
);
}
function parseJsonUnknown(raw: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch {
return null;
}
}

View File

@ -0,0 +1,71 @@
import { describe, expect, it } from "vitest";
import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification";
const classify = createDataforseoAccessClassifier({
pathPrefix: "/backlinks/",
notEnabledCode: "BACKLINKS_NOT_ENABLED",
notEnabledMessage: "not enabled",
billingIssueCode: "BACKLINKS_BILLING_ISSUE",
billingIssueMessage: "billing issue",
});
describe("createDataforseoAccessClassifier", () => {
it("returns null when the path is outside the configured prefix", () => {
expect(classify(402, "payment required", "/v3/serp/google/live")).toBe(
null,
);
});
it.each([40204, 403])(
"translates status %s into the configured error code when inside the path prefix",
(status) => {
const err = classify(status, "", "/v3/backlinks/summary/live");
expect(err?.code).toBe("BACKLINKS_NOT_ENABLED");
},
);
it.each([40200, 40210, 402])(
"translates billing status %s into the configured billing error code",
(status) => {
const err = classify(status, "", "/v3/backlinks/summary/live");
expect(err?.code).toBe("BACKLINKS_BILLING_ISSUE");
},
);
it.each([
"subscription required",
"plans and subscriptions",
"access denied",
"forbidden",
])("translates signal %s into the configured error code", (message) => {
const err = classify(undefined, message, "/v3/backlinks/summary/live");
expect(err?.code).toBe("BACKLINKS_NOT_ENABLED");
});
it.each([
"insufficient funds",
"payment required",
"balance is too low",
"problem billing",
"account was not recharged",
])(
"translates billing signal %s into the configured billing code",
(message) => {
const err = classify(undefined, message, "/v3/backlinks/summary/live");
expect(err?.code).toBe("BACKLINKS_BILLING_ISSUE");
},
);
it("returns null when neither status nor text matches", () => {
expect(classify(500, "boom", "/v3/backlinks/summary/live")).toBe(null);
});
it("matches signals case-insensitively", () => {
const err = classify(
undefined,
"SUBSCRIPTION required",
"/v3/backlinks/summary/live",
);
expect(err?.code).toBe("BACKLINKS_NOT_ENABLED");
});
});

View File

@ -0,0 +1,66 @@
import { AppError } from "@/server/lib/errors";
import type { ErrorCode } from "@/shared/error-codes";
const ACCESS_SIGNALS = [
"not available",
"not enabled",
"not allowed",
"access denied",
"forbidden",
"insufficient",
"subscription",
"upgrade",
"plan",
"activate your subscription",
"plans and subscriptions",
];
const BILLING_SIGNALS = [
"insufficient funds",
"balance is too low",
"payment required",
"billing",
"balance",
"problem billing",
"recharged",
];
const ACCESS_STATUS_CODES = new Set([40204, 403]);
const BILLING_STATUS_CODES = new Set([40200, 40210, 402]);
type DataforseoAccessClassifier = (
status: number | undefined,
details: string,
path: string,
) => AppError | null;
export function createDataforseoAccessClassifier(config: {
pathPrefix: string;
notEnabledCode: ErrorCode;
notEnabledMessage: string;
billingIssueCode: ErrorCode;
billingIssueMessage: string;
}): DataforseoAccessClassifier {
return (status, details, path) => {
if (!path.includes(config.pathPrefix)) return null;
const text = details.toLowerCase();
const matchesBillingStatus =
status != null && BILLING_STATUS_CODES.has(status);
const matchesBillingText = BILLING_SIGNALS.some((signal) =>
text.includes(signal),
);
if (matchesBillingStatus || matchesBillingText) {
return new AppError(config.billingIssueCode, config.billingIssueMessage);
}
const matchesAccessStatus =
status != null && ACCESS_STATUS_CODES.has(status);
const matchesAccessText = ACCESS_SIGNALS.some((signal) =>
text.includes(signal),
);
if (!matchesAccessStatus && !matchesAccessText) return null;
return new AppError(config.notEnabledCode, config.notEnabledMessage);
};
}

View File

@ -0,0 +1,83 @@
import { z } from "zod";
import { AppError } from "@/server/lib/errors";
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
const API_BASE = "https://api.dataforseo.com";
const userDataResponseSchema = z
.object({
status_code: z.number().optional(),
tasks: z
.array(
z
.object({
status_code: z.number().optional(),
result: z
.array(
z
.object({
backlinks_subscription_expiry_date: z
.string()
.nullable()
.optional(),
llm_mentions_subscription_expiry_date: z
.string()
.nullable()
.optional(),
})
.passthrough(),
)
.nullable()
.optional(),
})
.passthrough(),
)
.optional(),
})
.passthrough();
type DataforseoAccountState = {
backlinksSubscriptionExpiryDate: string | null;
llmMentionsSubscriptionExpiryDate: string | null;
};
export function hasActiveDataforseoSubscription(
expiryDate: string | null,
): boolean {
if (!expiryDate) return false;
const expiryTime = Date.parse(expiryDate);
return Number.isFinite(expiryTime) && expiryTime > Date.now();
}
export async function fetchDataforseoAccountState(): Promise<DataforseoAccountState | null> {
const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY");
const response = await fetch(`${API_BASE}/v3/appendix/user_data`, {
method: "GET",
headers: { Authorization: `Basic ${apiKey}` },
});
if (!response.ok) {
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO HTTP ${response.status} on /v3/appendix/user_data`,
);
}
const raw = await response.json();
const parsed = userDataResponseSchema.safeParse(raw);
if (!parsed.success || parsed.data.status_code !== 20000) return null;
const task = parsed.data.tasks?.[0];
if (!task || task.status_code !== 20000) return null;
const result = task.result?.[0];
if (!result) return null;
return {
backlinksSubscriptionExpiryDate:
result.backlinks_subscription_expiry_date ?? null,
llmMentionsSubscriptionExpiryDate:
result.llm_mentions_subscription_expiry_date ?? null,
};
}

View File

@ -1,21 +1,28 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppError } from "@/server/lib/errors";
import type * as DataforseoBacklinksSupport from "@/server/lib/dataforseoBacklinksSupport";
vi.mock("@/server/lib/runtime-env", () => ({
getRequiredEnvValue: vi.fn(async () => "test-api-key"),
}));
vi.mock("@/server/lib/dataforseoBacklinksAccount", () => ({
classifyBacklinksErrorWithAccountState: vi.fn(),
const { classifyBacklinksError } = vi.hoisted(() => ({
classifyBacklinksError: vi.fn(),
}));
vi.mock("@/server/lib/dataforseoBacklinksSupport", async () => {
const actual = await vi.importActual<typeof DataforseoBacklinksSupport>(
"@/server/lib/dataforseoBacklinksSupport",
);
return { ...actual, classifyBacklinksError };
});
import {
fetchBacklinksHistoryRaw,
fetchBacklinksRowsRaw,
fetchBacklinksSummaryRaw,
normalizeBacklinksTarget,
} from "@/server/lib/dataforseoBacklinks";
import { classifyBacklinksErrorWithAccountState } from "@/server/lib/dataforseoBacklinksAccount";
describe("normalizeBacklinksTarget", () => {
it("treats explicit homepage URLs as page lookups", () => {
@ -121,18 +128,15 @@ describe("fetchBacklinksSummaryRaw", () => {
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.mocked(classifyBacklinksErrorWithAccountState).mockImplementation(
async (status: number | undefined) => {
classifyBacklinksError.mockImplementation((status: number | undefined) => {
if (status === 40204) {
return new AppError(
"BACKLINKS_NOT_ENABLED",
"Backlinks is not enabled",
);
}
return null;
},
);
});
await expect(
fetchBacklinksSummaryRaw({
@ -140,7 +144,7 @@ describe("fetchBacklinksSummaryRaw", () => {
}),
).rejects.toMatchObject({ code: "BACKLINKS_NOT_ENABLED" });
expect(classifyBacklinksErrorWithAccountState).toHaveBeenCalledWith(
expect(classifyBacklinksError).toHaveBeenCalledWith(
40204,
expect.stringContaining("Backlinks subscription required"),
"/v3/backlinks/summary/live",
@ -164,7 +168,7 @@ describe("fetchBacklinksSummaryRaw", () => {
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.mocked(classifyBacklinksErrorWithAccountState).mockResolvedValue(null);
classifyBacklinksError.mockReturnValue(null);
await expect(
fetchBacklinksSummaryRaw({
@ -190,7 +194,7 @@ describe("fetchBacklinksSummaryRaw", () => {
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.mocked(classifyBacklinksErrorWithAccountState).mockResolvedValue(null);
classifyBacklinksError.mockReturnValue(null);
await expect(
fetchBacklinksSummaryRaw({
@ -233,7 +237,7 @@ describe("fetchBacklinksSummaryRaw", () => {
{ status: 200, headers: { "Content-Type": "application/json" } },
),
);
vi.mocked(classifyBacklinksErrorWithAccountState).mockResolvedValue(null);
classifyBacklinksError.mockReturnValue(null);
await expect(
fetchBacklinksRowsRaw({

View File

@ -9,6 +9,7 @@ import type {
} from "@/server/lib/dataforseoCost";
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
import {
classifyBacklinksError,
type BacklinksTaskResult,
backlinksHistoryItemSchema,
backlinksItemSchema,
@ -18,7 +19,6 @@ import {
referringDomainItemSchema,
responseSchema,
} from "@/server/lib/dataforseoBacklinksSupport";
import { classifyBacklinksErrorWithAccountState } from "@/server/lib/dataforseoBacklinksAccount";
export { normalizeBacklinksTarget } from "@/server/lib/dataforseoBacklinksTarget";
const API_BASE = "https://api.dataforseo.com";
@ -69,7 +69,7 @@ async function postBacklinks(path: string, payload: unknown) {
const rawText = await response.text();
if (!response.ok) {
const classifiedError = await classifyBacklinksErrorWithAccountState(
const classifiedError = classifyBacklinksError(
response.status,
rawText,
path,
@ -85,7 +85,7 @@ async function postBacklinks(path: string, payload: unknown) {
try {
raw = JSON.parse(rawText);
} catch {
const classifiedError = await classifyBacklinksErrorWithAccountState(
const classifiedError = classifyBacklinksError(
response.status,
rawText,
path,
@ -103,7 +103,7 @@ async function postBacklinks(path: string, payload: unknown) {
const parsed = responseSchema.safeParse(raw);
if (!parsed.success) {
const classifiedError = await classifyBacklinksErrorWithAccountState(
const classifiedError = classifyBacklinksError(
response.status,
rawText,
path,
@ -121,7 +121,7 @@ async function postBacklinks(path: string, payload: unknown) {
const responseData = parsed.data;
if (responseData.status_code !== 20000) {
const classifiedError = await classifyBacklinksErrorWithAccountState(
const classifiedError = classifyBacklinksError(
responseData.status_code,
`${responseData.status_message ?? ""} ${rawText}`,
path,
@ -139,7 +139,7 @@ async function postBacklinks(path: string, payload: unknown) {
}
if (task.status_code !== 20000) {
const classifiedError = await classifyBacklinksErrorWithAccountState(
const classifiedError = classifyBacklinksError(
task.status_code,
`${task.status_message ?? ""} ${rawText}`,
path,

View File

@ -1,154 +0,0 @@
import { z } from "zod";
import { AppError } from "@/server/lib/errors";
import { classifyBacklinksError } from "@/server/lib/dataforseoBacklinksSupport";
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
const API_BASE = "https://api.dataforseo.com";
const userDataResponseSchema = z
.object({
status_code: z.number().optional(),
status_message: z.string().optional(),
tasks: z
.array(
z
.object({
status_code: z.number().optional(),
status_message: z.string().optional(),
result: z
.array(
z
.object({
money: z
.object({
balance: z.number().nullable().optional(),
})
.passthrough()
.optional(),
backlinks_subscription_expiry_date: z
.string()
.nullable()
.optional(),
})
.passthrough(),
)
.nullable()
.optional(),
})
.passthrough(),
)
.optional(),
})
.passthrough();
async function createAuthenticatedFetch() {
const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY");
return (url: RequestInfo, init?: RequestInit): Promise<Response> => {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Basic ${apiKey}`);
return fetch(url, {
...init,
headers,
});
};
}
async function getDataforseo(path: string) {
const authenticatedFetch = await createAuthenticatedFetch();
const response = await authenticatedFetch(`${API_BASE}${path}`, {
method: "GET",
});
if (!response.ok) {
throw new AppError(
"INTERNAL_ERROR",
`DataForSEO HTTP ${response.status} on ${path}`,
);
}
return await response.json();
}
async function fetchBacklinksAccountState() {
const raw = await getDataforseo("/v3/appendix/user_data");
const parsed = userDataResponseSchema.safeParse(raw);
if (!parsed.success || parsed.data.status_code !== 20000) {
return null;
}
const task = parsed.data.tasks?.[0];
if (!task || task.status_code !== 20000) {
return null;
}
const result = task.result?.[0];
if (!result) {
return null;
}
return {
balance: result.money?.balance ?? null,
backlinksSubscriptionExpiryDate:
result.backlinks_subscription_expiry_date ?? null,
};
}
function hasActiveBacklinksSubscription(value: string | null) {
if (!value) return false;
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return true;
return parsed.getTime() > Date.now();
}
export async function classifyBacklinksErrorWithAccountState(
status: number | undefined,
details: string,
path: string,
) {
const classifiedError = classifyBacklinksError(status, details, path);
if (classifiedError) {
return classifiedError;
}
const text = details.toLowerCase();
const needsAccountLookup =
path.includes("/backlinks/") &&
(status === 402 ||
status === 403 ||
text.includes("backlinks") ||
text.includes("subscription") ||
text.includes("billing") ||
text.includes("balance") ||
text.includes("payment"));
if (!needsAccountLookup) {
return null;
}
const accountState = await fetchBacklinksAccountState().catch(() => null);
if (!accountState) {
return null;
}
if (
!hasActiveBacklinksSubscription(
accountState.backlinksSubscriptionExpiryDate,
)
) {
return new AppError(
"BACKLINKS_NOT_ENABLED",
"Backlinks is not enabled for the connected DataForSEO account",
);
}
if (typeof accountState.balance === "number" && accountState.balance <= 0) {
return new AppError(
"BACKLINKS_BILLING_ISSUE",
"The connected DataForSEO account has a billing or balance issue",
);
}
return null;
}

View File

@ -1,4 +1,5 @@
import { z } from "zod";
import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification";
import { AppError } from "@/server/lib/errors";
const taskResultSchema = z
@ -120,88 +121,15 @@ export const backlinksHistoryItemSchema = z
})
.passthrough();
export function classifyBacklinksError(
status: number | undefined,
details: string,
path: string,
): AppError | null {
const text = details.toLowerCase();
const looksLikeBacklinksAccessIssue =
path.includes("/backlinks/") &&
(text.includes("backlinks") ||
text.includes("subscription") ||
text.includes("access") ||
text.includes("plan") ||
text.includes("balance") ||
text.includes("payment") ||
text.includes("billing") ||
text.includes("available") ||
text.includes("enabled") ||
status === 402 ||
status === 403);
if (!looksLikeBacklinksAccessIssue) return null;
if (status === 40204) {
return new AppError(
"BACKLINKS_NOT_ENABLED",
export const classifyBacklinksError = createDataforseoAccessClassifier({
pathPrefix: "/backlinks/",
notEnabledCode: "BACKLINKS_NOT_ENABLED",
notEnabledMessage:
"Backlinks is not enabled for the connected DataForSEO account",
);
}
if (status === 40200 || status === 40210 || status === 402) {
return new AppError(
"BACKLINKS_BILLING_ISSUE",
billingIssueCode: "BACKLINKS_BILLING_ISSUE",
billingIssueMessage:
"The connected DataForSEO account has a billing or balance issue",
);
}
const unavailableSignals = [
"not available",
"not enabled",
"not allowed",
"access denied",
"forbidden",
"insufficient",
"subscription",
"upgrade",
"plan",
"activate your subscription",
"plans and subscriptions",
];
const billingSignals = [
"payment required",
"billing",
"balance",
"insufficient funds",
"balance is too low",
"problem billing",
"recharged",
];
if (billingSignals.some((signal) => text.includes(signal))) {
return new AppError(
"BACKLINKS_BILLING_ISSUE",
"The connected DataForSEO account has a billing or balance issue",
);
}
if (unavailableSignals.some((signal) => text.includes(signal))) {
return new AppError(
"BACKLINKS_NOT_ENABLED",
"Backlinks is not enabled for the connected DataForSEO account",
);
}
if (status === 403) {
return new AppError(
"BACKLINKS_NOT_ENABLED",
"Backlinks is not enabled for the connected DataForSEO account",
);
}
return null;
}
});
export function parseItems<T extends z.ZodTypeAny>(
endpointName: string,

View File

@ -12,6 +12,7 @@ import {
type LlmTopPagesItem,
} from "@/server/lib/dataforseoLlmSchemas";
import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost";
import { createDataforseoAccessClassifier } from "@/server/lib/dataforseoAccessClassification";
import { AppError } from "@/server/lib/errors";
import { getRequiredEnvValue } from "@/server/lib/runtime-env";
@ -50,9 +51,12 @@ async function postLlm(path: string, payload: unknown): Promise<unknown> {
const rawText = await response.text();
if (!response.ok) {
throw new AppError(
throw (
classifyAiSearchError(response.status, rawText, path) ??
new AppError(
"INTERNAL_ERROR",
`DataForSEO HTTP ${response.status} on ${path}. Response: ${truncate(rawText)}`,
)
);
}
@ -66,6 +70,16 @@ async function postLlm(path: string, payload: unknown): Promise<unknown> {
}
}
const classifyAiSearchError = createDataforseoAccessClassifier({
pathPrefix: "/ai_optimization/",
notEnabledCode: "AI_SEARCH_NOT_ENABLED",
notEnabledMessage:
"AI Optimization is not enabled for the connected DataForSEO account",
billingIssueCode: "AI_SEARCH_BILLING_ISSUE",
billingIssueMessage:
"The connected DataForSEO account has a billing or balance issue",
});
function truncate(text: string): string {
return text.length > MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH
? `${text.slice(0, MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH)}... [truncated]`
@ -86,9 +100,10 @@ function parseEnvelope(path: string, raw: unknown): LlmDataforseoTask {
const data = envelope.data;
if (data.status_code !== 20000) {
throw new AppError(
"INTERNAL_ERROR",
data.status_message || `DataForSEO ${path} request failed`,
const message = data.status_message || `DataForSEO ${path} request failed`;
throw (
classifyAiSearchError(data.status_code, message, path) ??
new AppError("INTERNAL_ERROR", message)
);
}
@ -101,9 +116,10 @@ function parseEnvelope(path: string, raw: unknown): LlmDataforseoTask {
}
if (task.status_code !== 20000) {
throw new AppError(
"INTERNAL_ERROR",
task.status_message || `DataForSEO ${path} task failed`,
const message = task.status_message || `DataForSEO ${path} task failed`;
throw (
classifyAiSearchError(task.status_code, message, path) ??
new AppError("INTERNAL_ERROR", message)
);
}

View File

@ -26,15 +26,6 @@ export async function isHostedServerAuthMode(): Promise<boolean> {
return isHostedAuthMode(await getEnvValue("AUTH_MODE"));
}
export async function getWorkersBinding(name: string): Promise<unknown> {
const workersEnv = await getWorkersEnv();
const binding = workersEnv?.[name];
if (!binding) {
throw new Error(`Missing required Worker binding: ${name}`);
}
return binding;
}
async function getWorkersEnv(): Promise<Record<string, unknown> | null> {
if (!workersEnvPromise) {
workersEnvPromise = loadWorkersEnv();

View File

@ -0,0 +1,34 @@
import { createServerFn } from "@tanstack/react-start";
import {
fetchDataforseoAccountState,
hasActiveDataforseoSubscription,
} from "@/server/lib/dataforseoAccountState";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import { requireProjectContext } from "@/serverFunctions/middleware";
import { aiSearchProjectSchema } from "@/types/schemas/ai-search";
const AI_SEARCH_NOT_ENABLED_MESSAGE =
"AI Optimization is not enabled for the connected DataForSEO account yet. Turn it on in DataForSEO, then confirm here.";
type AiSearchAccessStatus = {
enabled: boolean;
errorMessage: string | null;
};
export const getAiSearchAccessSetupStatus = createServerFn({ method: "GET" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => aiSearchProjectSchema.parse(data))
.handler(async (): Promise<AiSearchAccessStatus> => {
if (await isHostedServerAuthMode()) {
return { enabled: true, errorMessage: null };
}
const state = await fetchDataforseoAccountState();
const enabled = hasActiveDataforseoSubscription(
state?.llmMentionsSubscriptionExpiryDate ?? null,
);
return {
enabled,
errorMessage: enabled ? null : AI_SEARCH_NOT_ENABLED_MESSAGE,
};
});

View File

@ -1,10 +1,5 @@
import { createServerFn } from "@tanstack/react-start";
import {
buildBacklinksDisabledAccessStatus,
setBacklinksAccessStatus,
} from "@/server/features/backlinks/backlinksAccess";
import { BacklinksService } from "@/server/features/backlinks/services/BacklinksService";
import { AppError } from "@/server/lib/errors";
import { requireProjectContext } from "@/serverFunctions/middleware";
import { backlinksOverviewInputSchema } from "@/types/schemas/backlinks";
@ -14,7 +9,6 @@ export const getBacklinksOverview = createServerFn({
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => {
try {
const input = {
target: data.target,
scope: data.scope,
@ -29,16 +23,6 @@ export const getBacklinksOverview = createServerFn({
spamOptions,
);
return profile.overview;
} catch (error) {
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {
const checkedAt = new Date().toISOString();
await setBacklinksAccessStatus(
buildBacklinksDisabledAccessStatus(checkedAt, error.code),
);
}
throw error;
}
});
export const getBacklinksReferringDomains = createServerFn({
@ -47,7 +31,6 @@ export const getBacklinksReferringDomains = createServerFn({
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => {
try {
const input = {
target: data.target,
scope: data.scope,
@ -57,10 +40,6 @@ export const getBacklinksReferringDomains = createServerFn({
context,
);
return profile.rows;
} catch (error) {
await updateBacklinksAccessStatusOnError(error);
throw error;
}
});
export const getBacklinksTopPages = createServerFn({
@ -69,24 +48,10 @@ export const getBacklinksTopPages = createServerFn({
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksOverviewInputSchema.parse(data))
.handler(async ({ data, context }) => {
try {
const input = {
target: data.target,
scope: data.scope,
};
const profile = await BacklinksService.profileTopPages(input, context);
return profile.rows;
} catch (error) {
await updateBacklinksAccessStatusOnError(error);
throw error;
}
});
async function updateBacklinksAccessStatusOnError(error: unknown) {
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {
const checkedAt = new Date().toISOString();
await setBacklinksAccessStatus(
buildBacklinksDisabledAccessStatus(checkedAt, error.code),
);
}
}

View File

@ -1,78 +1,34 @@
import { createServerFn } from "@tanstack/react-start";
import {
buildBacklinksDisabledAccessStatus,
buildVerifiedBacklinksAccessStatus,
getBacklinksAccessStatus,
setBacklinksAccessStatus,
} from "@/server/features/backlinks/backlinksAccess";
import { AppError } from "@/server/lib/errors";
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
fetchDataforseoAccountState,
hasActiveDataforseoSubscription,
} from "@/server/lib/dataforseoAccountState";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import { requireProjectContext } from "@/serverFunctions/middleware";
import { backlinksProjectSchema } from "@/types/schemas/backlinks";
const BACKLINKS_ACCESS_CHECK_COOLDOWN_MS = 15 * 60 * 1000;
const BACKLINKS_NOT_ENABLED_MESSAGE =
"Backlinks is not enabled for the connected DataForSEO account yet. Turn it on in DataForSEO, then confirm here.";
export const getBacklinksAccessSetupStatus = createServerFn({
method: "GET",
})
type BacklinksAccessStatus = {
enabled: boolean;
errorMessage: string | null;
};
export const getBacklinksAccessSetupStatus = createServerFn({ method: "GET" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksProjectSchema.parse(data))
.handler(async () => getBacklinksAccessStatus());
export const testBacklinksAccess = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => backlinksProjectSchema.parse(data))
.handler(async ({ context }) => {
.handler(async (): Promise<BacklinksAccessStatus> => {
if (await isHostedServerAuthMode()) {
// Hosted deployments do not run the manual DataForSEO access test here;
// backlinks access is treated as platform-managed in this mode.
return getBacklinksAccessStatus();
return { enabled: true, errorMessage: null };
}
const cachedStatus = await getBacklinksAccessStatus();
if (isRecentVerifiedBacklinksAccessCheck(cachedStatus)) {
return cachedStatus;
}
const checkedAt = new Date().toISOString();
const dataforseo = createDataforseoClient(context);
try {
await dataforseo.backlinks.summary({
target: "dataforseo.com",
});
const status = buildVerifiedBacklinksAccessStatus(checkedAt);
await setBacklinksAccessStatus(status);
return status;
} catch (error) {
if (error instanceof AppError && error.code === "BACKLINKS_NOT_ENABLED") {
const status = buildBacklinksDisabledAccessStatus(
checkedAt,
error.code,
const state = await fetchDataforseoAccountState();
const enabled = hasActiveDataforseoSubscription(
state?.backlinksSubscriptionExpiryDate ?? null,
);
await setBacklinksAccessStatus(status);
return status;
}
throw error;
}
return {
enabled,
errorMessage: enabled ? null : BACKLINKS_NOT_ENABLED_MESSAGE,
};
});
function isRecentVerifiedBacklinksAccessCheck(
status: Awaited<ReturnType<typeof getBacklinksAccessStatus>>,
) {
if (!status.enabled || !status.lastCheckedAt) {
return false;
}
const lastChecked = Date.parse(status.lastCheckedAt);
if (Number.isNaN(lastChecked)) {
return false;
}
return Date.now() - lastChecked < BACKLINKS_ACCESS_CHECK_COOLDOWN_MS;
}

View File

@ -12,6 +12,8 @@ const ERROR_CODES = [
"CRAWL_TARGET_BLOCKED",
"BACKLINKS_NOT_ENABLED",
"BACKLINKS_BILLING_ISSUE",
"AI_SEARCH_NOT_ENABLED",
"AI_SEARCH_BILLING_ISSUE",
"RATE_LIMITED",
"CONFLICT",
"INTERNAL_ERROR",

View File

@ -7,6 +7,14 @@ import { z } from "zod";
* by server functions, services, R2 cache validation, and the client UI.
*/
// ---------------------------------------------------------------------------
// AI Search access setup (self-hosted mode only)
// ---------------------------------------------------------------------------
export const aiSearchProjectSchema = z.object({
projectId: z.string().min(1),
});
// ---------------------------------------------------------------------------
// Brand Lookup
// ---------------------------------------------------------------------------