diff --git a/src/client/features/domain/utils.ts b/src/client/features/domain/utils.ts index 3ee4e5b..5ae3ff2 100644 --- a/src/client/features/domain/utils.ts +++ b/src/client/features/domain/utils.ts @@ -4,6 +4,7 @@ import type { PageRow, SortOrder, } from "@/client/features/domain/types"; +import { isValidDomainHost } from "@/types/schemas/domain"; export function toSortMode(value: string | null): DomainSortMode | undefined { if ( @@ -67,6 +68,7 @@ export function normalizeDomainTarget(input: string): string | null { const hostname = parsed.hostname.toLowerCase(); if (!hostname || !hostname.includes(".")) return null; if (!/^[a-z\d.-]+$/.test(hostname)) return null; + if (!isValidDomainHost(hostname)) return null; const path = parsed.pathname === "/" ? "" : parsed.pathname; return `${hostname}${path}`; diff --git a/src/client/lib/error-messages.ts b/src/client/lib/error-messages.ts index 48439c8..4ce44fa 100644 --- a/src/client/lib/error-messages.ts +++ b/src/client/lib/error-messages.ts @@ -23,6 +23,8 @@ const STANDARD_MESSAGES: Record = { AI_SEARCH_BILLING_ISSUE: "The connected DataForSEO account has a billing or balance issue.", RATE_LIMITED: "Too many requests. Please wait and try again.", + UPSTREAM_UNAVAILABLE: + "The data provider is temporarily unavailable. Please retry in a moment.", CONFLICT: "This request conflicts with existing data.", INTERNAL_ERROR: "An unexpected error occurred. Please check server logs and try again.", diff --git a/src/routes/_auth.sign-in.tsx b/src/routes/_auth.sign-in.tsx index 4ec362e..26b3439 100644 --- a/src/routes/_auth.sign-in.tsx +++ b/src/routes/_auth.sign-in.tsx @@ -1,7 +1,6 @@ import { useForm } from "@tanstack/react-form"; -import { Link, createFileRoute } from "@tanstack/react-router"; +import { Link, createFileRoute, useNavigate } from "@tanstack/react-router"; import { useState } from "react"; -import { toast } from "sonner"; import { AuthPageCard, AuthMethodChooser, @@ -11,7 +10,7 @@ import { import { getFieldError, getFormError } from "@/client/lib/forms"; import { captureClientEvent } from "@/client/lib/posthog"; import { authClient } from "@/lib/auth-client"; -import { getSignInSearch } from "@/lib/auth-redirect"; +import { getSignInSearch, getVerifyEmailSearch } from "@/lib/auth-redirect"; import { z } from "zod"; const signInSchema = z.object({ @@ -26,14 +25,11 @@ export const Route = createFileRoute("/_auth/sign-in")({ function SignInPage() { const search = Route.useSearch(); + const navigate = useNavigate(); const { redirectTo, oauthQuery, isHostedMode } = useAuthPageState( search.redirect, ); const authCallbackURL = redirectTo; - const [verificationEmail, setVerificationEmail] = useState( - null, - ); - const [isSendingVerification, setIsSendingVerification] = useState(false); const [showEmailForm, setShowEmailForm] = useState(false); const [isStartingGoogle, setIsStartingGoogle] = useState(false); const [socialError, setSocialError] = useState(null); @@ -52,7 +48,6 @@ function SignInPage() { captureClientEvent("auth:sign_in_submit", { redirect_to: redirectTo, }); - setVerificationEmail(null); const result = await authClient.signIn.email({ email, @@ -72,12 +67,12 @@ function SignInPage() { captureClientEvent("auth:sign_in_block_unverified", { redirect_to: redirectTo, }); - setVerificationEmail(email); - formApi.setErrorMap({ - onSubmit: { - form: "Please confirm your email before signing in.", - fields: {}, - }, + // Email not verified yet: send them to the verification page (which + // shows "check your inbox" + resend) instead of leaving them on a + // sign-in form that will keep rejecting them. + void navigate({ + to: "/verify-email", + search: getVerifyEmailSearch(email, redirectTo), }); return; } @@ -99,42 +94,6 @@ function SignInPage() { }, }); - async function handleResendVerification() { - if (!verificationEmail) { - return; - } - - setIsSendingVerification(true); - - try { - const verificationCallbackURL = new URL( - "/verify-email", - window.location.origin, - ); - if (authCallbackURL !== "/") { - verificationCallbackURL.searchParams.set("redirect", authCallbackURL); - } - const result = await authClient.sendVerificationEmail({ - email: verificationEmail, - callbackURL: verificationCallbackURL.toString(), - }); - - if (result.error) { - toast.error(result.error.message || "We couldn't send another email."); - return; - } - - captureClientEvent("auth:verification_resend"); - toast.success("A new email is on the way."); - } catch { - toast.error( - "We couldn't send another email right now. Please try again.", - ); - } finally { - setIsSendingVerification(false); - } - } - async function handleContinueWithGoogle() { setSocialError(null); setIsStartingGoogle(true); @@ -266,29 +225,6 @@ function SignInPage() { }} - {verificationEmail ? ( -
-
-

- Please check {verificationEmail} for a link to confirm your - email. -

- -
-
- ) : null} - ({ submitError: state.errorMap.onSubmit, diff --git a/src/routes/verify-email.tsx b/src/routes/verify-email.tsx index f6c8625..6f0b2d0 100644 --- a/src/routes/verify-email.tsx +++ b/src/routes/verify-email.tsx @@ -73,10 +73,12 @@ function getVerifyEmailPageCopy({ }; } - if (isWaiting && email) { + if (isWaiting) { return { title: "Verify your email", - helperText: `Click the link we sent to ${email} to verify your email.`, + helperText: email + ? `Click the link we sent to ${email} to verify your email.` + : "Check your inbox for the link to verify your email.", }; } @@ -95,8 +97,8 @@ function getVerifyEmailPageCopy({ } return { - title: "Email confirmed", - helperText: "Your email is confirmed. You can sign in now.", + title: "Sign in to continue", + helperText: "Sign in to continue to your account.", }; } @@ -110,14 +112,18 @@ function VerifyEmailPage() { const verificationIssueType = search.error ? verificationIssueSchema.parse(search.error) : null; - const email = search.email; + const email = search.email ?? session?.user?.email; + const isVerified = !!session?.user?.emailVerified; + const [isResending, setIsResending] = useState(false); + // A hosted user who still needs to verify (session resolved, not verified) + // must see the resend / "check your inbox" state — never a sign-in CTA, which + // the verification gate would immediately block (the email-verify trap). const isWaiting = + isHostedMode && !errorMessage && !bypassEmailVerification && - !session?.user?.emailVerified && - !!email; - const [isResending, setIsResending] = useState(false); - const isVerified = !!session?.user?.emailVerified; + !isPending && + !isVerified; const pageCopy = getVerifyEmailPageCopy({ isHostedMode, errorMessage, @@ -222,16 +228,18 @@ function VerifyEmailPage() { ) : isWaiting ? ( -
- -
+ email ? ( +
+ +
+ ) : null ) : isPending || isVerified ? (
diff --git a/src/server/features/domain/services/DomainService.ts b/src/server/features/domain/services/DomainService.ts index fe9e48b..c1038dc 100644 --- a/src/server/features/domain/services/DomainService.ts +++ b/src/server/features/domain/services/DomainService.ts @@ -109,7 +109,7 @@ async function getSuggestedKeywords( keywordDifficulty: number | null; }> > { - const domain = input.domain.toLowerCase().trim(); + const domain = normalizeDomainInput(input.domain, true); const cacheKey = await buildCacheKey("domain:keyword-suggestions", { organizationId: billingCustomer.organizationId, diff --git a/src/server/lib/dataforseo/core.ts b/src/server/lib/dataforseo/core.ts index db8df8d..277d177 100644 --- a/src/server/lib/dataforseo/core.ts +++ b/src/server/lib/dataforseo/core.ts @@ -8,11 +8,16 @@ import { } from "dataforseo-client"; import { AppError } from "@/server/lib/errors"; import { getRequiredEnvValue } from "@/server/lib/runtime-env"; +import type { ErrorCode } from "@/shared/error-codes"; const API_BASE = "https://api.dataforseo.com"; const MAX_DATAFORSEO_ERROR_PAYLOAD_LENGTH = 1600; // Safety ceiling on any live call (Lighthouse is the slowest, ~tens of seconds). const DATAFORSEO_REQUEST_TIMEOUT_MS = 60_000; +// Retry idempotent reads on transient 5xx. Total attempts = retries + 1; the +// shared request-timeout signal still caps overall wall time. +const DATAFORSEO_MAX_RETRIES = 2; +const DATAFORSEO_RETRY_BACKOFF_MS = 250; /** * Translates a DataForSEO HTTP/task failure into a product-specific AppError @@ -63,32 +68,47 @@ function createAuthenticatedFetch(classify?: DataforseoErrorClassifier) { const apiKey = await getRequiredEnvValue("DATAFORSEO_API_KEY"); const headers = new Headers(init?.headers); headers.set("Authorization", `Basic ${apiKey}`); + // Resolve the signal once so retries share the overall request timeout + // rather than restarting a fresh 60s budget on each attempt. + const signal = + init?.signal ?? AbortSignal.timeout(DATAFORSEO_REQUEST_TIMEOUT_MS); - const response = await fetch(url, { - ...init, - headers, - signal: - init?.signal ?? AbortSignal.timeout(DATAFORSEO_REQUEST_TIMEOUT_MS), - }); - if (response.ok) return response; + for (let attempt = 0; ; attempt++) { + const response = await fetch(url, { ...init, headers, signal }); + if (response.ok) return response; - const rawText = await response.text(); - const path = formatDataforseoRequestPath(url); - const classified = classify?.(response.status, rawText, path); - if (classified) throw classified; + // Transient upstream 5xx on an idempotent read -> back off and retry. + if (response.status >= 500 && attempt < DATAFORSEO_MAX_RETRIES) { + await new Promise((resolve) => + setTimeout(resolve, DATAFORSEO_RETRY_BACKOFF_MS * (attempt + 1)), + ); + continue; + } - const error = new AppError( - response.status === 429 ? "RATE_LIMITED" : "INTERNAL_ERROR", - `DataForSEO HTTP ${response.status} on ${path}`, - { - provider: "dataforseo", - providerStatus: String(response.status), - providerPath: path, - responseBody: formatDataforseoErrorPayload(rawText), - }, - ); - error.name = "DataForSEOHttpError"; - throw error; + const rawText = await response.text(); + const path = formatDataforseoRequestPath(url); + const classified = classify?.(response.status, rawText, path); + if (classified) throw classified; + + const code: ErrorCode = + response.status >= 500 + ? "UPSTREAM_UNAVAILABLE" + : response.status === 429 + ? "RATE_LIMITED" + : "INTERNAL_ERROR"; + const error = new AppError( + code, + `DataForSEO HTTP ${response.status} on ${path}`, + { + provider: "dataforseo", + providerStatus: String(response.status), + providerPath: path, + responseBody: formatDataforseoErrorPayload(rawText), + }, + ); + error.name = "DataForSEOHttpError"; + throw error; + } }; } diff --git a/src/server/lib/domainUtils.test.ts b/src/server/lib/domainUtils.test.ts new file mode 100644 index 0000000..1ad12d2 --- /dev/null +++ b/src/server/lib/domainUtils.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from "vitest"; +import { normalizeDomainInput } from "@/server/lib/domainUtils"; +import { isValidDomainHost } from "@/types/schemas/domain"; + +describe("isValidDomainHost", () => { + it("accepts real registrable domains", () => { + expect(isValidDomainHost("example.com")).toBe(true); + expect(isValidDomainHost("sub.example.co.uk")).toBe(true); + expect(isValidDomainHost("openseo.so")).toBe(true); + }); + + it("rejects fake TLDs, IPs, and bare hosts", () => { + expect(isValidDomainHost("example.por")).toBe(false); + expect(isValidDomainHost("localhost")).toBe(false); + expect(isValidDomainHost("127.0.0.1")).toBe(false); + }); +}); + +describe("normalizeDomainInput", () => { + it("normalizes a valid domain, stripping protocol/www/path", () => { + expect( + normalizeDomainInput("https://www.Example.com/path?q=1", false), + ).toBe("example.com"); + expect(normalizeDomainInput("blog.example.com", true)).toBe( + "blog.example.com", + ); + }); + + it("rejects a fake TLD before it can reach DataForSEO", () => { + expect(() => normalizeDomainInput("victorgomez.por", false)).toThrowError( + /valid domain/i, + ); + // Validation must also run on the includeSubdomains=true path. + expect(() => normalizeDomainInput("victorgomez.por", true)).toThrowError( + /valid domain/i, + ); + }); + + it("rejects empty input", () => { + expect(() => normalizeDomainInput(" ", false)).toThrowError(/required/i); + }); +}); diff --git a/src/server/lib/domainUtils.ts b/src/server/lib/domainUtils.ts index 4223f94..f3ad828 100644 --- a/src/server/lib/domainUtils.ts +++ b/src/server/lib/domainUtils.ts @@ -1,5 +1,6 @@ import { getDomain } from "tldts"; import { AppError } from "@/server/lib/errors"; +import { isValidDomainHost } from "@/types/schemas/domain"; export function toRelativePath(url: string | null | undefined): string | null { if (!url) return null; @@ -36,6 +37,15 @@ export function normalizeDomainInput( throw new AppError("VALIDATION_ERROR", "Domain is invalid"); } + // Reject fake TLDs / non-registrable hosts (e.g. "example.por") before they + // reach DataForSEO and come back as an opaque "Invalid Field: 'target'". + if (!isValidDomainHost(host)) { + throw new AppError( + "VALIDATION_ERROR", + "Enter a valid domain like example.com", + ); + } + if (includeSubdomains) { return host; } diff --git a/src/shared/error-codes.ts b/src/shared/error-codes.ts index e3f0a3d..5eef002 100644 --- a/src/shared/error-codes.ts +++ b/src/shared/error-codes.ts @@ -15,6 +15,7 @@ const ERROR_CODES = [ "AI_SEARCH_NOT_ENABLED", "AI_SEARCH_BILLING_ISSUE", "RATE_LIMITED", + "UPSTREAM_UNAVAILABLE", "CONFLICT", "INTERNAL_ERROR", ] as const; diff --git a/src/types/schemas/domain.ts b/src/types/schemas/domain.ts index 1db4069..ac3e30f 100644 --- a/src/types/schemas/domain.ts +++ b/src/types/schemas/domain.ts @@ -1,3 +1,4 @@ +import { parse as parseTld } from "tldts"; import { z } from "zod"; /** @@ -12,6 +13,19 @@ export function normalizeDomain(input: string): string { return hostname.replace(/^www\./, ""); } +/** + * True when `host` resolves to a real registrable domain (public-suffix list), + * rejecting IPs and fake TLDs like `example.por` before they reach DataForSEO. + */ +export function isValidDomainHost(host: string): boolean { + const parsed = parseTld(host, { allowPrivateDomains: true }); + return ( + !parsed.isIp && + !!parsed.publicSuffix && + (parsed.isIcann === true || parsed.isPrivate === true) + ); +} + /** Zod field: accepts a bare domain or full URL, outputs a clean hostname. */ export const domainField = z .string() @@ -20,13 +34,19 @@ export const domainField = z .transform((val, ctx) => { try { const hostname = normalizeDomain(val); - if (!hostname.includes(".")) { - ctx.addIssue({ code: "custom", message: "Invalid domain format" }); + if (!hostname.includes(".") || !isValidDomainHost(hostname)) { + ctx.addIssue({ + code: "custom", + message: "Enter a valid domain like example.com", + }); return z.NEVER; } return hostname; } catch { - ctx.addIssue({ code: "custom", message: "Invalid domain format" }); + ctx.addIssue({ + code: "custom", + message: "Enter a valid domain like example.com", + }); return z.NEVER; } });