From 264575067109dae066cc7155087e83d78a0abbfc Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Sun, 5 Jul 2026 18:56:19 -0400 Subject: [PATCH] Free-plan audit limits: 50 pages, one at a time; remove 'all' lighthouse strategy (#352) --- .../features/audit/launch/LaunchFormCard.tsx | 41 +++++++++++--- .../features/audit/launch/LaunchView.tsx | 55 +++++++++++++++++-- src/client/features/audit/launch/types.ts | 16 +++++- .../audit/launch/useLaunchController.ts | 23 +++++--- src/client/lib/error-messages.ts | 4 ++ .../audit/repositories/AuditRepository.ts | 16 ++++-- .../features/audit/services/AuditService.ts | 40 ++++++++++---- .../audit/services/audit-capacity.test.ts | 22 ++++---- .../features/audit/services/audit-capacity.ts | 54 ++++++++++++------ src/server/lib/audit/lighthouse.ts | 4 -- src/server/lib/audit/types.ts | 10 +++- src/serverFunctions/audit.ts | 28 +++++++--- src/shared/audit-limits.ts | 6 ++ src/shared/error-codes.test.ts | 3 + src/shared/error-codes.ts | 5 ++ src/types/schemas/audit.ts | 15 ++++- 16 files changed, 256 insertions(+), 86 deletions(-) create mode 100644 src/shared/audit-limits.ts diff --git a/src/client/features/audit/launch/LaunchFormCard.tsx b/src/client/features/audit/launch/LaunchFormCard.tsx index 72997e4..9009991 100644 --- a/src/client/features/audit/launch/LaunchFormCard.tsx +++ b/src/client/features/audit/launch/LaunchFormCard.tsx @@ -1,17 +1,22 @@ +import { Link } from "@tanstack/react-router"; import { Loader2 } from "lucide-react"; -import { - MAX_PAGES_LIMIT, - MIN_PAGES, -} from "@/client/features/audit/launch/types"; +import { MIN_PAGES } from "@/client/features/audit/launch/types"; import type { useLaunchController } from "@/client/features/audit/launch/useLaunchController"; import { getFieldError, getFormError } from "@/client/lib/forms"; +import { PAID_MAX_AUDIT_PAGES } from "@/shared/audit-limits"; +import { SUBSCRIBE_ROUTE } from "@/shared/billing"; type Props = { launchForm: ReturnType["launchForm"]; commitMaxPagesInput: () => number; + maxPagesLimit: number; }; -export function LaunchFormCard({ commitMaxPagesInput, launchForm }: Props) { +export function LaunchFormCard({ + commitMaxPagesInput, + launchForm, + maxPagesLimit, +}: Props) { return (
@@ -69,6 +74,7 @@ export function LaunchFormCard({ commitMaxPagesInput, launchForm }: Props) {
@@ -80,7 +86,13 @@ export function LaunchFormCard({ commitMaxPagesInput, launchForm }: Props) { ); } -function LaunchOptions({ launchForm, commitMaxPagesInput }: Props) { +function LaunchOptions({ + launchForm, + commitMaxPagesInput, + maxPagesLimit, +}: Props) { + const isFreeLimited = maxPagesLimit < PAID_MAX_AUDIT_PAGES; + return (

- Enter any value from {MIN_PAGES} to {MAX_PAGES_LIMIT}. + Enter any value from {MIN_PAGES} to {maxPagesLimit.toLocaleString()}. + {isFreeLimited ? ( + <> + {" "} + + Upgrade + {" "} + to crawl up to {PAID_MAX_AUDIT_PAGES.toLocaleString()} pages. + + ) : null}

); diff --git a/src/client/features/audit/launch/LaunchView.tsx b/src/client/features/audit/launch/LaunchView.tsx index 3453a29..e5db4a1 100644 --- a/src/client/features/audit/launch/LaunchView.tsx +++ b/src/client/features/audit/launch/LaunchView.tsx @@ -1,15 +1,57 @@ +import { AutumnProvider, useCustomer } from "autumn-js/react"; import { AuditHistorySection } from "@/client/features/audit/launch/AuditHistorySection"; import { LaunchFormCard } from "@/client/features/audit/launch/LaunchFormCard"; import { useLaunchController } from "@/client/features/audit/launch/useLaunchController"; +import { getCustomerPlanStatus } from "@/client/features/billing/plan-detection"; +import { useSession } from "@/lib/auth-client"; +import { isHostedClientAuthMode } from "@/lib/auth-mode"; -export function LaunchView({ - projectId, - onAuditStarted, -}: { +type LaunchViewProps = { projectId: string; onAuditStarted: (auditId: string) => void; -}) { - const controller = useLaunchController({ projectId, onAuditStarted }); +}; + +export function LaunchView(props: LaunchViewProps) { + // Self-hosted has no Autumn customer and resolves to the paid tier on the + // server, so only hosted mode needs to look up the plan. + if (!isHostedClientAuthMode()) { + return ; + } + + return ( + + + + ); +} + +function HostedLaunchView(props: LaunchViewProps) { + const { data: session } = useSession(); + const customerQuery = useCustomer({ + queryOptions: { + enabled: Boolean(session?.user?.id), + }, + }); + + // Until the customer loads, leave the form unrestricted rather than flash + // free-plan copy at paid users; the server enforces the limit regardless. + const isFreePlan = + customerQuery.data != null && + getCustomerPlanStatus(customerQuery.data) === "free"; + + return ; +} + +function LaunchContent({ + projectId, + isFreePlan, + onAuditStarted, +}: LaunchViewProps & { isFreePlan: boolean }) { + const controller = useLaunchController({ + projectId, + isFreePlan, + onAuditStarted, + }); return (
@@ -19,6 +61,7 @@ export function LaunchView({ void; }) { + const maxPagesLimit = getMaxPagesLimit(isFreePlan); const historyQuery = useQuery({ queryKey: ["audit-history", projectId], queryFn: () => getAuditHistory({ data: { projectId } }), @@ -64,7 +67,7 @@ export function useLaunchController({ onSubmit: ({ value }) => getLaunchValidationErrors(value, true), }, onSubmit: async ({ formApi, value }) => { - const effectiveMaxPages = commitMaxPagesInput(launchForm); + const effectiveMaxPages = commitMaxPagesInput(launchForm, maxPagesLimit); formApi.setErrorMap({ onSubmit: undefined }); if (effectiveMaxPages > 500) { @@ -98,7 +101,8 @@ export function useLaunchController({ return { launchForm, historyQuery, - commitMaxPagesInput: () => commitMaxPagesInput(launchForm), + maxPagesLimit, + commitMaxPagesInput: () => commitMaxPagesInput(launchForm, maxPagesLimit), deleteAudit: (auditId: string) => deleteMutation.mutate(auditId), }; } @@ -131,14 +135,17 @@ function useLaunchMutations({ return { startMutation, deleteMutation }; } -function commitMaxPagesInput(launchForm: { - state: { values: { maxPagesInput: string } }; - setFieldValue: (field: "maxPagesInput", value: string) => void; -}) { +function commitMaxPagesInput( + launchForm: { + state: { values: { maxPagesInput: string } }; + setFieldValue: (field: "maxPagesInput", value: string) => void; + }, + maxPagesLimit: number, +) { const maxPagesInput = launchForm.state.values.maxPagesInput; const value = maxPagesInput ? Number.parseInt(maxPagesInput, 10) : MIN_PAGES; const safeValue = Number.isFinite(value) - ? Math.max(MIN_PAGES, Math.min(MAX_PAGES_LIMIT, Math.round(value))) + ? Math.max(MIN_PAGES, Math.min(maxPagesLimit, Math.round(value))) : MIN_PAGES; launchForm.setFieldValue("maxPagesInput", String(safeValue)); return safeValue; diff --git a/src/client/lib/error-messages.ts b/src/client/lib/error-messages.ts index 5486409..d62041a 100644 --- a/src/client/lib/error-messages.ts +++ b/src/client/lib/error-messages.ts @@ -1,3 +1,4 @@ +import { FREE_MAX_AUDIT_PAGES } from "@/shared/audit-limits"; import { isErrorCode, type ErrorCode } from "@/shared/error-codes"; const STANDARD_MESSAGES: Record = { @@ -12,6 +13,9 @@ const STANDARD_MESSAGES: Record = { NOT_FOUND: "The requested resource was not found.", AUDIT_CAPACITY_REACHED: "You've reached audit capacity for your account. Delete old audits from your projects to start a new one.", + AUDIT_PAGE_LIMIT_EXCEEDED: `Free plan audits are limited to ${FREE_MAX_AUDIT_PAGES} pages. Upgrade to run larger audits.`, + AUDIT_ALREADY_RUNNING: + "You already have an audit running. Wait for it to finish or delete it before starting another.", VALIDATION_ERROR: "Please check your input and try again.", CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.", BACKLINKS_NOT_ENABLED: diff --git a/src/server/features/audit/repositories/AuditRepository.ts b/src/server/features/audit/repositories/AuditRepository.ts index 61573b2..a01a075 100644 --- a/src/server/features/audit/repositories/AuditRepository.ts +++ b/src/server/features/audit/repositories/AuditRepository.ts @@ -201,19 +201,23 @@ async function getAuditsByProject(projectId: string) { return rows.map(({ audit }) => audit); } -async function getAuditCapacityUsageForUser(userId: string) { +async function getAuditUsageForUser(userId: string) { const rows = await db.query.audits.findMany({ where: eq(audits.startedByUserId, userId), columns: { + status: true, pagesTotal: true, lighthouseTotal: true, }, }); - return rows.reduce( - (total, row) => total + row.pagesTotal + row.lighthouseTotal, - 0, - ); + return { + capacityUnits: rows.reduce( + (total, row) => total + row.pagesTotal + row.lighthouseTotal, + 0, + ), + runningCount: rows.filter((row) => row.status === "running").length, + }; } async function getAuditResultsForProject(auditId: string, projectId: string) { @@ -284,7 +288,7 @@ export const AuditRepository = { batchWriteResults, getAuditForProject, getAuditsByProject, - getAuditCapacityUsageForUser, + getAuditUsageForUser, getAuditResultsForProject, getLighthouseResultById, deleteAuditForProject, diff --git a/src/server/features/audit/services/AuditService.ts b/src/server/features/audit/services/AuditService.ts index f41c9c0..d0ff340 100644 --- a/src/server/features/audit/services/AuditService.ts +++ b/src/server/features/audit/services/AuditService.ts @@ -2,9 +2,10 @@ import { env } from "cloudflare:workers"; import type { BillingCustomerContext } from "@/server/billing/subscription"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import { - MAX_USER_AUDIT_USAGE, + AUDIT_LIMITS, clampAuditMaxPages, getEstimatedAuditCapacity, + type AuditLimitTier, } from "@/server/features/audit/services/audit-capacity"; import { AppError } from "@/server/lib/errors"; import { AuditProgressKV } from "@/server/lib/audit/progress-kv"; @@ -22,22 +23,20 @@ async function startAudit(input: { startUrl: string; maxPages?: number; lighthouseStrategy?: LighthouseStrategy; + limitTier: AuditLimitTier; }) { + const limits = AUDIT_LIMITS[input.limitTier]; const maxPages = clampAuditMaxPages(input.maxPages); + if (maxPages > limits.maxPagesPerAudit) { + throw new AppError("AUDIT_PAGE_LIMIT_EXCEEDED"); + } + const lighthouseStrategy = input.lighthouseStrategy ?? "auto"; const reservation = getEstimatedAuditCapacity({ maxPages, lighthouseStrategy, }); - const currentUsage = await AuditRepository.getAuditCapacityUsageForUser( - input.actorUserId, - ); - - if (currentUsage + reservation.total > MAX_USER_AUDIT_USAGE) { - throw new AppError("AUDIT_CAPACITY_REACHED"); - } - const auditId = crypto.randomUUID(); const config: AuditConfig = { maxPages, lighthouseStrategy }; const startUrl = await normalizeAndValidateStartUrl(input.startUrl); @@ -54,6 +53,20 @@ async function startAudit(input: { }); try { + // Concurrency and capacity are enforced after the insert, not before: a + // pre-insert read is a check-then-act race, so parallel requests would all + // pass the free tier's one-running-audit gate. Post-insert, each request + // sees at least its own row, so at most one racer can pass; the losers + // roll back via the catch below. Two true racers may both abort — the + // user just retries. + const usage = await AuditRepository.getAuditUsageForUser(input.actorUserId); + if (usage.runningCount > limits.maxRunningAudits) { + throw new AppError("AUDIT_ALREADY_RUNNING"); + } + if (usage.capacityUnits > limits.maxCapacityUnits) { + throw new AppError("AUDIT_CAPACITY_REACHED"); + } + await env.SITE_AUDIT_WORKFLOW.create({ id: auditId, params: { @@ -173,17 +186,20 @@ async function remove(auditId: string, projectId: string) { ); } + // A row can be "running" with no live workflow instance if a start failed + // between the row insert and workflow creation and its rollback delete + // also failed. Nothing to terminate then — deleting the row is the fix. const instance = await env.SITE_AUDIT_WORKFLOW.get( audit.workflowInstanceId, - ); + ).catch(() => null); try { - await instance.terminate(); + await instance?.terminate(); } catch (error) { // terminate() throws when the instance already reached a terminal state // (it completed or errored in the moment before the user hit stop). That // race shouldn't block deletion — re-check the live status and only fail // if the workflow is genuinely still running. - const status = await instance.status().catch(() => null); + const status = await instance?.status().catch(() => null); const stillRunning = status != null && ["queued", "running", "paused", "waiting", "waitingForPause"].includes( diff --git a/src/server/features/audit/services/audit-capacity.test.ts b/src/server/features/audit/services/audit-capacity.test.ts index 0739e56..adc6432 100644 --- a/src/server/features/audit/services/audit-capacity.test.ts +++ b/src/server/features/audit/services/audit-capacity.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "vitest"; import { + AUDIT_LIMITS, clampAuditMaxPages, getEstimatedAuditCapacity, - MAX_USER_AUDIT_USAGE, } from "@/server/features/audit/services/audit-capacity"; describe("audit capacity helpers", () => { @@ -38,21 +38,23 @@ describe("audit capacity helpers", () => { lighthouseTotal: 20, total: 120, }); - expect( - getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "all" }), - ).toEqual({ - pagesTotal: 100, - lighthouseTotal: 200, - total: 300, - }); }); - it("stays within the global capacity limit for the maximum auto audit", () => { + it("stays within the paid capacity limit for the maximum auto audit", () => { expect( getEstimatedAuditCapacity({ maxPages: 10_000, lighthouseStrategy: "auto", }).total, - ).toBeLessThan(MAX_USER_AUDIT_USAGE); + ).toBeLessThan(AUDIT_LIMITS.paid.maxCapacityUnits); + }); + + it("fits a maximum free audit within the free capacity budget", () => { + const freeAudit = getEstimatedAuditCapacity({ + maxPages: AUDIT_LIMITS.free.maxPagesPerAudit, + lighthouseStrategy: "auto", + }); + expect(freeAudit.pagesTotal).toBe(AUDIT_LIMITS.free.maxPagesPerAudit); + expect(freeAudit.total).toBeLessThan(AUDIT_LIMITS.free.maxCapacityUnits); }); }); diff --git a/src/server/features/audit/services/audit-capacity.ts b/src/server/features/audit/services/audit-capacity.ts index 9e5cf47..280d5b7 100644 --- a/src/server/features/audit/services/audit-capacity.ts +++ b/src/server/features/audit/services/audit-capacity.ts @@ -1,9 +1,43 @@ import type { LighthouseStrategy } from "@/server/lib/audit/types"; +import { + DEFAULT_AUDIT_PAGES, + FREE_MAX_AUDIT_PAGES, + MIN_AUDIT_PAGES, + PAID_MAX_AUDIT_PAGES, +} from "@/shared/audit-limits"; -export const MAX_USER_AUDIT_USAGE = 100_000; +export type AuditLimitTier = "free" | "paid"; + +// The crawler runs on our Workers compute and isn't credit-metered, so these +// per-tier bounds are the abuse control: free accounts cost nothing to create, +// so they get one small audit at a time and a modest total budget. Paid gets +// bounds sized for real sites rather than abuse (a payment method on file is +// the deterrent). Self-hosted deployments resolve to the paid tier. +export const AUDIT_LIMITS: Record< + AuditLimitTier, + { + maxPagesPerAudit: number; + maxCapacityUnits: number; + maxRunningAudits: number; + } +> = { + free: { + maxPagesPerAudit: FREE_MAX_AUDIT_PAGES, + maxCapacityUnits: 2_000, + maxRunningAudits: 1, + }, + paid: { + maxPagesPerAudit: PAID_MAX_AUDIT_PAGES, + maxCapacityUnits: 100_000, + maxRunningAudits: Number.POSITIVE_INFINITY, + }, +}; export function clampAuditMaxPages(maxPages?: number) { - return Math.min(Math.max(maxPages ?? 50, 10), 10_000); + return Math.min( + Math.max(maxPages ?? DEFAULT_AUDIT_PAGES, MIN_AUDIT_PAGES), + PAID_MAX_AUDIT_PAGES, + ); } export function getEstimatedAuditCapacity(input: { @@ -12,20 +46,8 @@ export function getEstimatedAuditCapacity(input: { }) { const pagesTotal = clampAuditMaxPages(input.maxPages); const lighthouseStrategy = input.lighthouseStrategy ?? "auto"; - - let lighthouseChecks = 0; - switch (lighthouseStrategy) { - case "all": - lighthouseChecks = pagesTotal * 2; - break; - case "auto": - lighthouseChecks = 20; - break; - case "manual": - case "none": - lighthouseChecks = 0; - break; - } + // "auto" samples up to 10 pages, checked on mobile + desktop. + const lighthouseChecks = lighthouseStrategy === "auto" ? 20 : 0; return { pagesTotal, diff --git a/src/server/lib/audit/lighthouse.ts b/src/server/lib/audit/lighthouse.ts index 85a1b19..5194ed0 100644 --- a/src/server/lib/audit/lighthouse.ts +++ b/src/server/lib/audit/lighthouse.ts @@ -127,10 +127,6 @@ export function selectLighthouseSample( (p) => p.statusCode >= 200 && p.statusCode < 300, ); - if (strategy === "all") { - return validPages.map((p) => p.url); - } - if (strategy === "manual") { // manual = user picks after crawl; for now return empty return []; diff --git a/src/server/lib/audit/types.ts b/src/server/lib/audit/types.ts index 4aab81d..bb2bf15 100644 --- a/src/server/lib/audit/types.ts +++ b/src/server/lib/audit/types.ts @@ -3,18 +3,22 @@ */ import { z } from "zod"; +import { MIN_AUDIT_PAGES, PAID_MAX_AUDIT_PAGES } from "@/shared/audit-limits"; import { jsonCodec } from "@/shared/json"; -export type LighthouseStrategy = "auto" | "all" | "manual" | "none"; +export type LighthouseStrategy = "auto" | "manual" | "none"; export interface AuditConfig { maxPages: number; lighthouseStrategy: LighthouseStrategy; } +// Read-side only (writes stringify a typed AuditConfig). Stored rows may hold +// retired strategies (e.g. "all"); fall back to "auto" instead of failing the +// whole config parse and making the audit's results unviewable. const auditConfigSchema = z.object({ - maxPages: z.number().int().min(10).max(10_000), - lighthouseStrategy: z.enum(["auto", "all", "manual", "none"]), + maxPages: z.number().int().min(MIN_AUDIT_PAGES).max(PAID_MAX_AUDIT_PAGES), + lighthouseStrategy: z.enum(["auto", "manual", "none"]).catch("auto"), }); const auditConfigCodec = jsonCodec(auditConfigSchema); diff --git a/src/serverFunctions/audit.ts b/src/serverFunctions/audit.ts index 13f43f2..5aec603 100644 --- a/src/serverFunctions/audit.ts +++ b/src/serverFunctions/audit.ts @@ -1,7 +1,11 @@ import { createServerFn } from "@tanstack/react-start"; import { waitUntil } from "cloudflare:workers"; import { AuditService } from "@/server/features/audit/services/AuditService"; -import { customerHasManagedAccess } from "@/server/billing/subscription"; +import type { AuditLimitTier } from "@/server/features/audit/services/audit-capacity"; +import { + customerHasManagedAccess, + customerHasPaidPlan, +} from "@/server/billing/subscription"; import { AppError } from "@/server/lib/errors"; import { captureServerEvent } from "@/server/lib/posthog"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; @@ -20,13 +24,19 @@ 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 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)) - ) { - throw new AppError("PAYMENT_REQUIRED", "Subscribe to run site audits"); + // plan-tier limits are the abuse bound in hosted mode: free accounts get + // one small audit at a time, paid keeps the full limits, and customers + // with no Autumn product at all are turned away. Self-hosted isn't gated. + let limitTier: AuditLimitTier = "paid"; + if (await isHostedServerAuthMode()) { + const [hasManagedAccess, hasPaidPlan] = await Promise.all([ + customerHasManagedAccess(context.organizationId), + customerHasPaidPlan(context.organizationId), + ]); + if (!hasManagedAccess) { + throw new AppError("PAYMENT_REQUIRED", "Subscribe to run site audits"); + } + limitTier = hasPaidPlan ? "paid" : "free"; } const result = await AuditService.startAudit({ @@ -36,6 +46,7 @@ export const startAudit = createServerFn({ method: "POST" }) startUrl: data.startUrl, maxPages: data.maxPages, lighthouseStrategy: data.lighthouseStrategy, + limitTier, }); waitUntil( @@ -47,6 +58,7 @@ export const startAudit = createServerFn({ method: "POST" }) project_id: context.projectId, max_pages: data.maxPages ?? 50, run_lighthouse: data.lighthouseStrategy !== "none", + plan_tier: limitTier, }, }), ); diff --git a/src/shared/audit-limits.ts b/src/shared/audit-limits.ts new file mode 100644 index 0000000..0cc2b4f --- /dev/null +++ b/src/shared/audit-limits.ts @@ -0,0 +1,6 @@ +// Per-audit page bounds. Shared so the launch form, the input schema, and the +// server-side tier gate all read the same numbers and can't drift apart. +export const MIN_AUDIT_PAGES = 10; +export const DEFAULT_AUDIT_PAGES = 50; +export const FREE_MAX_AUDIT_PAGES = 50; +export const PAID_MAX_AUDIT_PAGES = 10_000; diff --git a/src/shared/error-codes.test.ts b/src/shared/error-codes.test.ts index 5a49462..b724036 100644 --- a/src/shared/error-codes.test.ts +++ b/src/shared/error-codes.test.ts @@ -7,6 +7,9 @@ describe("shouldCaptureAppErrorCode", () => { "NOT_FOUND", "PAYMENT_REQUIRED", "VALIDATION_ERROR", + "AUDIT_CAPACITY_REACHED", + "AUDIT_PAGE_LIMIT_EXCEEDED", + "AUDIT_ALREADY_RUNNING", ] as const)("skips expected %s errors", (code) => { expect(shouldCaptureAppErrorCode(code)).toBe(false); }); diff --git a/src/shared/error-codes.ts b/src/shared/error-codes.ts index b87df1a..fb36d19 100644 --- a/src/shared/error-codes.ts +++ b/src/shared/error-codes.ts @@ -8,6 +8,8 @@ const ERROR_CODES = [ "FORBIDDEN", "NOT_FOUND", "AUDIT_CAPACITY_REACHED", + "AUDIT_PAGE_LIMIT_EXCEEDED", + "AUDIT_ALREADY_RUNNING", "VALIDATION_ERROR", "CRAWL_TARGET_BLOCKED", "BACKLINKS_NOT_ENABLED", @@ -31,6 +33,9 @@ const NON_REPORTABLE_ERROR_CODES = new Set([ "PAYMENT_REQUIRED", "INSUFFICIENT_CREDITS", "VALIDATION_ERROR", + "AUDIT_CAPACITY_REACHED", + "AUDIT_PAGE_LIMIT_EXCEEDED", + "AUDIT_ALREADY_RUNNING", ]); export function isErrorCode(value: string): value is ErrorCode { diff --git a/src/types/schemas/audit.ts b/src/types/schemas/audit.ts index 450e3c6..b12c783 100644 --- a/src/types/schemas/audit.ts +++ b/src/types/schemas/audit.ts @@ -1,13 +1,24 @@ import { z } from "zod"; +import { + DEFAULT_AUDIT_PAGES, + MIN_AUDIT_PAGES, + PAID_MAX_AUDIT_PAGES, +} from "@/shared/audit-limits"; // ─── Server function input schemas ────────────────────────────────────────── export const startAuditSchema = z.object({ projectId: z.string().min(1), startUrl: z.string().min(1, "URL is required").max(2048), - maxPages: z.number().int().min(10).max(10_000).optional().default(50), + maxPages: z + .number() + .int() + .min(MIN_AUDIT_PAGES) + .max(PAID_MAX_AUDIT_PAGES) + .optional() + .default(DEFAULT_AUDIT_PAGES), lighthouseStrategy: z - .enum(["auto", "all", "manual", "none"]) + .enum(["auto", "manual", "none"]) .optional() .default("auto"), });