Free-plan audit limits: 50 pages, one at a time; remove 'all' lighthouse strategy (#352)

This commit is contained in:
Ben Senescu 2026-07-05 18:56:19 -04:00 committed by GitHub
parent 053ac4c4cf
commit 2645750671
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 256 additions and 86 deletions

View File

@ -1,17 +1,22 @@
import { Link } from "@tanstack/react-router";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
import { import { MIN_PAGES } from "@/client/features/audit/launch/types";
MAX_PAGES_LIMIT,
MIN_PAGES,
} from "@/client/features/audit/launch/types";
import type { useLaunchController } from "@/client/features/audit/launch/useLaunchController"; import type { useLaunchController } from "@/client/features/audit/launch/useLaunchController";
import { getFieldError, getFormError } from "@/client/lib/forms"; import { getFieldError, getFormError } from "@/client/lib/forms";
import { PAID_MAX_AUDIT_PAGES } from "@/shared/audit-limits";
import { SUBSCRIBE_ROUTE } from "@/shared/billing";
type Props = { type Props = {
launchForm: ReturnType<typeof useLaunchController>["launchForm"]; launchForm: ReturnType<typeof useLaunchController>["launchForm"];
commitMaxPagesInput: () => number; commitMaxPagesInput: () => number;
maxPagesLimit: number;
}; };
export function LaunchFormCard({ commitMaxPagesInput, launchForm }: Props) { export function LaunchFormCard({
commitMaxPagesInput,
launchForm,
maxPagesLimit,
}: Props) {
return ( return (
<div className="card bg-base-100 border border-base-300"> <div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4"> <div className="card-body gap-4">
@ -69,6 +74,7 @@ export function LaunchFormCard({ commitMaxPagesInput, launchForm }: Props) {
<LaunchOptions <LaunchOptions
launchForm={launchForm} launchForm={launchForm}
commitMaxPagesInput={commitMaxPagesInput} commitMaxPagesInput={commitMaxPagesInput}
maxPagesLimit={maxPagesLimit}
/> />
<LighthouseOptions launchForm={launchForm} /> <LighthouseOptions launchForm={launchForm} />
</div> </div>
@ -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 ( return (
<div className="rounded-lg border border-base-300 bg-base-200/20 p-3 space-y-2"> <div className="rounded-lg border border-base-300 bg-base-200/20 p-3 space-y-2">
<label className="text-xs font-medium uppercase tracking-wide text-base-content/60"> <label className="text-xs font-medium uppercase tracking-wide text-base-content/60">
@ -93,7 +105,7 @@ function LaunchOptions({ launchForm, commitMaxPagesInput }: Props) {
<input <input
type="number" type="number"
min={MIN_PAGES} min={MIN_PAGES}
max={MAX_PAGES_LIMIT} max={maxPagesLimit}
className="input input-bordered input-sm w-28" className="input input-bordered input-sm w-28"
value={field.state.value} value={field.state.value}
onChange={(event) => { onChange={(event) => {
@ -110,7 +122,20 @@ function LaunchOptions({ launchForm, commitMaxPagesInput }: Props) {
</launchForm.Field> </launchForm.Field>
</div> </div>
<p className="text-xs text-base-content/50"> <p className="text-xs text-base-content/50">
Enter any value from {MIN_PAGES} to {MAX_PAGES_LIMIT}. Enter any value from {MIN_PAGES} to {maxPagesLimit.toLocaleString()}.
{isFreeLimited ? (
<>
{" "}
<Link
to={SUBSCRIBE_ROUTE}
search={{ upgrade: true }}
className="link link-primary"
>
Upgrade
</Link>{" "}
to crawl up to {PAID_MAX_AUDIT_PAGES.toLocaleString()} pages.
</>
) : null}
</p> </p>
</div> </div>
); );

View File

@ -1,15 +1,57 @@
import { AutumnProvider, useCustomer } from "autumn-js/react";
import { AuditHistorySection } from "@/client/features/audit/launch/AuditHistorySection"; import { AuditHistorySection } from "@/client/features/audit/launch/AuditHistorySection";
import { LaunchFormCard } from "@/client/features/audit/launch/LaunchFormCard"; import { LaunchFormCard } from "@/client/features/audit/launch/LaunchFormCard";
import { useLaunchController } from "@/client/features/audit/launch/useLaunchController"; 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({ type LaunchViewProps = {
projectId,
onAuditStarted,
}: {
projectId: string; projectId: string;
onAuditStarted: (auditId: string) => void; 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 <LaunchContent {...props} isFreePlan={false} />;
}
return (
<AutumnProvider>
<HostedLaunchView {...props} />
</AutumnProvider>
);
}
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 <LaunchContent {...props} isFreePlan={isFreePlan} />;
}
function LaunchContent({
projectId,
isFreePlan,
onAuditStarted,
}: LaunchViewProps & { isFreePlan: boolean }) {
const controller = useLaunchController({
projectId,
isFreePlan,
onAuditStarted,
});
return ( return (
<div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto"> <div className="px-4 py-4 md:px-6 md:py-6 pb-24 md:pb-8 overflow-auto">
@ -19,6 +61,7 @@ export function LaunchView({
<LaunchFormCard <LaunchFormCard
launchForm={controller.launchForm} launchForm={controller.launchForm}
commitMaxPagesInput={controller.commitMaxPagesInput} commitMaxPagesInput={controller.commitMaxPagesInput}
maxPagesLimit={controller.maxPagesLimit}
/> />
<AuditHistorySection <AuditHistorySection

View File

@ -1,5 +1,15 @@
export const MIN_PAGES = 10; import {
export const MAX_PAGES_LIMIT = 10_000; DEFAULT_AUDIT_PAGES,
FREE_MAX_AUDIT_PAGES,
MIN_AUDIT_PAGES,
PAID_MAX_AUDIT_PAGES,
} from "@/shared/audit-limits";
export const MIN_PAGES = MIN_AUDIT_PAGES;
export function getMaxPagesLimit(isFreePlan: boolean) {
return isFreePlan ? FREE_MAX_AUDIT_PAGES : PAID_MAX_AUDIT_PAGES;
}
export type LaunchFormValues = { export type LaunchFormValues = {
url: string; url: string;
@ -9,6 +19,6 @@ export type LaunchFormValues = {
export const DEFAULT_LAUNCH_FORM_VALUES: LaunchFormValues = { export const DEFAULT_LAUNCH_FORM_VALUES: LaunchFormValues = {
url: "", url: "",
maxPagesInput: "50", maxPagesInput: String(DEFAULT_AUDIT_PAGES),
runLighthouse: false, runLighthouse: false,
}; };

View File

@ -8,7 +8,7 @@ import {
} from "@/serverFunctions/audit"; } from "@/serverFunctions/audit";
import { import {
DEFAULT_LAUNCH_FORM_VALUES, DEFAULT_LAUNCH_FORM_VALUES,
MAX_PAGES_LIMIT, getMaxPagesLimit,
MIN_PAGES, MIN_PAGES,
type LaunchFormValues, type LaunchFormValues,
} from "@/client/features/audit/launch/types"; } from "@/client/features/audit/launch/types";
@ -39,11 +39,14 @@ function getLaunchValidationErrors(
export function useLaunchController({ export function useLaunchController({
projectId, projectId,
isFreePlan,
onAuditStarted, onAuditStarted,
}: { }: {
projectId: string; projectId: string;
isFreePlan: boolean;
onAuditStarted: (auditId: string) => void; onAuditStarted: (auditId: string) => void;
}) { }) {
const maxPagesLimit = getMaxPagesLimit(isFreePlan);
const historyQuery = useQuery({ const historyQuery = useQuery({
queryKey: ["audit-history", projectId], queryKey: ["audit-history", projectId],
queryFn: () => getAuditHistory({ data: { projectId } }), queryFn: () => getAuditHistory({ data: { projectId } }),
@ -64,7 +67,7 @@ export function useLaunchController({
onSubmit: ({ value }) => getLaunchValidationErrors(value, true), onSubmit: ({ value }) => getLaunchValidationErrors(value, true),
}, },
onSubmit: async ({ formApi, value }) => { onSubmit: async ({ formApi, value }) => {
const effectiveMaxPages = commitMaxPagesInput(launchForm); const effectiveMaxPages = commitMaxPagesInput(launchForm, maxPagesLimit);
formApi.setErrorMap({ onSubmit: undefined }); formApi.setErrorMap({ onSubmit: undefined });
if (effectiveMaxPages > 500) { if (effectiveMaxPages > 500) {
@ -98,7 +101,8 @@ export function useLaunchController({
return { return {
launchForm, launchForm,
historyQuery, historyQuery,
commitMaxPagesInput: () => commitMaxPagesInput(launchForm), maxPagesLimit,
commitMaxPagesInput: () => commitMaxPagesInput(launchForm, maxPagesLimit),
deleteAudit: (auditId: string) => deleteMutation.mutate(auditId), deleteAudit: (auditId: string) => deleteMutation.mutate(auditId),
}; };
} }
@ -131,14 +135,17 @@ function useLaunchMutations({
return { startMutation, deleteMutation }; return { startMutation, deleteMutation };
} }
function commitMaxPagesInput(launchForm: { function commitMaxPagesInput(
launchForm: {
state: { values: { maxPagesInput: string } }; state: { values: { maxPagesInput: string } };
setFieldValue: (field: "maxPagesInput", value: string) => void; setFieldValue: (field: "maxPagesInput", value: string) => void;
}) { },
maxPagesLimit: number,
) {
const maxPagesInput = launchForm.state.values.maxPagesInput; const maxPagesInput = launchForm.state.values.maxPagesInput;
const value = maxPagesInput ? Number.parseInt(maxPagesInput, 10) : MIN_PAGES; const value = maxPagesInput ? Number.parseInt(maxPagesInput, 10) : MIN_PAGES;
const safeValue = Number.isFinite(value) 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; : MIN_PAGES;
launchForm.setFieldValue("maxPagesInput", String(safeValue)); launchForm.setFieldValue("maxPagesInput", String(safeValue));
return safeValue; return safeValue;

View File

@ -1,3 +1,4 @@
import { FREE_MAX_AUDIT_PAGES } from "@/shared/audit-limits";
import { isErrorCode, type ErrorCode } from "@/shared/error-codes"; import { isErrorCode, type ErrorCode } from "@/shared/error-codes";
const STANDARD_MESSAGES: Record<ErrorCode, string> = { const STANDARD_MESSAGES: Record<ErrorCode, string> = {
@ -12,6 +13,9 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
NOT_FOUND: "The requested resource was not found.", NOT_FOUND: "The requested resource was not found.",
AUDIT_CAPACITY_REACHED: AUDIT_CAPACITY_REACHED:
"You've reached audit capacity for your account. Delete old audits from your projects to start a new one.", "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.", VALIDATION_ERROR: "Please check your input and try again.",
CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.", CRAWL_TARGET_BLOCKED: "This crawl target is blocked by security policy.",
BACKLINKS_NOT_ENABLED: BACKLINKS_NOT_ENABLED:

View File

@ -201,19 +201,23 @@ async function getAuditsByProject(projectId: string) {
return rows.map(({ audit }) => audit); return rows.map(({ audit }) => audit);
} }
async function getAuditCapacityUsageForUser(userId: string) { async function getAuditUsageForUser(userId: string) {
const rows = await db.query.audits.findMany({ const rows = await db.query.audits.findMany({
where: eq(audits.startedByUserId, userId), where: eq(audits.startedByUserId, userId),
columns: { columns: {
status: true,
pagesTotal: true, pagesTotal: true,
lighthouseTotal: true, lighthouseTotal: true,
}, },
}); });
return rows.reduce( return {
capacityUnits: rows.reduce(
(total, row) => total + row.pagesTotal + row.lighthouseTotal, (total, row) => total + row.pagesTotal + row.lighthouseTotal,
0, 0,
); ),
runningCount: rows.filter((row) => row.status === "running").length,
};
} }
async function getAuditResultsForProject(auditId: string, projectId: string) { async function getAuditResultsForProject(auditId: string, projectId: string) {
@ -284,7 +288,7 @@ export const AuditRepository = {
batchWriteResults, batchWriteResults,
getAuditForProject, getAuditForProject,
getAuditsByProject, getAuditsByProject,
getAuditCapacityUsageForUser, getAuditUsageForUser,
getAuditResultsForProject, getAuditResultsForProject,
getLighthouseResultById, getLighthouseResultById,
deleteAuditForProject, deleteAuditForProject,

View File

@ -2,9 +2,10 @@ import { env } from "cloudflare:workers";
import type { BillingCustomerContext } from "@/server/billing/subscription"; import type { BillingCustomerContext } from "@/server/billing/subscription";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import { import {
MAX_USER_AUDIT_USAGE, AUDIT_LIMITS,
clampAuditMaxPages, clampAuditMaxPages,
getEstimatedAuditCapacity, getEstimatedAuditCapacity,
type AuditLimitTier,
} from "@/server/features/audit/services/audit-capacity"; } from "@/server/features/audit/services/audit-capacity";
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { AuditProgressKV } from "@/server/lib/audit/progress-kv"; import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
@ -22,22 +23,20 @@ async function startAudit(input: {
startUrl: string; startUrl: string;
maxPages?: number; maxPages?: number;
lighthouseStrategy?: LighthouseStrategy; lighthouseStrategy?: LighthouseStrategy;
limitTier: AuditLimitTier;
}) { }) {
const limits = AUDIT_LIMITS[input.limitTier];
const maxPages = clampAuditMaxPages(input.maxPages); const maxPages = clampAuditMaxPages(input.maxPages);
if (maxPages > limits.maxPagesPerAudit) {
throw new AppError("AUDIT_PAGE_LIMIT_EXCEEDED");
}
const lighthouseStrategy = input.lighthouseStrategy ?? "auto"; const lighthouseStrategy = input.lighthouseStrategy ?? "auto";
const reservation = getEstimatedAuditCapacity({ const reservation = getEstimatedAuditCapacity({
maxPages, maxPages,
lighthouseStrategy, 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 auditId = crypto.randomUUID();
const config: AuditConfig = { maxPages, lighthouseStrategy }; const config: AuditConfig = { maxPages, lighthouseStrategy };
const startUrl = await normalizeAndValidateStartUrl(input.startUrl); const startUrl = await normalizeAndValidateStartUrl(input.startUrl);
@ -54,6 +53,20 @@ async function startAudit(input: {
}); });
try { 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({ await env.SITE_AUDIT_WORKFLOW.create({
id: auditId, id: auditId,
params: { 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( const instance = await env.SITE_AUDIT_WORKFLOW.get(
audit.workflowInstanceId, audit.workflowInstanceId,
); ).catch(() => null);
try { try {
await instance.terminate(); await instance?.terminate();
} catch (error) { } catch (error) {
// terminate() throws when the instance already reached a terminal state // terminate() throws when the instance already reached a terminal state
// (it completed or errored in the moment before the user hit stop). That // (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 // race shouldn't block deletion — re-check the live status and only fail
// if the workflow is genuinely still running. // if the workflow is genuinely still running.
const status = await instance.status().catch(() => null); const status = await instance?.status().catch(() => null);
const stillRunning = const stillRunning =
status != null && status != null &&
["queued", "running", "paused", "waiting", "waitingForPause"].includes( ["queued", "running", "paused", "waiting", "waitingForPause"].includes(

View File

@ -1,8 +1,8 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { import {
AUDIT_LIMITS,
clampAuditMaxPages, clampAuditMaxPages,
getEstimatedAuditCapacity, getEstimatedAuditCapacity,
MAX_USER_AUDIT_USAGE,
} from "@/server/features/audit/services/audit-capacity"; } from "@/server/features/audit/services/audit-capacity";
describe("audit capacity helpers", () => { describe("audit capacity helpers", () => {
@ -38,21 +38,23 @@ describe("audit capacity helpers", () => {
lighthouseTotal: 20, lighthouseTotal: 20,
total: 120, 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( expect(
getEstimatedAuditCapacity({ getEstimatedAuditCapacity({
maxPages: 10_000, maxPages: 10_000,
lighthouseStrategy: "auto", lighthouseStrategy: "auto",
}).total, }).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);
}); });
}); });

View File

@ -1,9 +1,43 @@
import type { LighthouseStrategy } from "@/server/lib/audit/types"; 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) { 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: { export function getEstimatedAuditCapacity(input: {
@ -12,20 +46,8 @@ export function getEstimatedAuditCapacity(input: {
}) { }) {
const pagesTotal = clampAuditMaxPages(input.maxPages); const pagesTotal = clampAuditMaxPages(input.maxPages);
const lighthouseStrategy = input.lighthouseStrategy ?? "auto"; const lighthouseStrategy = input.lighthouseStrategy ?? "auto";
// "auto" samples up to 10 pages, checked on mobile + desktop.
let lighthouseChecks = 0; const lighthouseChecks = lighthouseStrategy === "auto" ? 20 : 0;
switch (lighthouseStrategy) {
case "all":
lighthouseChecks = pagesTotal * 2;
break;
case "auto":
lighthouseChecks = 20;
break;
case "manual":
case "none":
lighthouseChecks = 0;
break;
}
return { return {
pagesTotal, pagesTotal,

View File

@ -127,10 +127,6 @@ export function selectLighthouseSample(
(p) => p.statusCode >= 200 && p.statusCode < 300, (p) => p.statusCode >= 200 && p.statusCode < 300,
); );
if (strategy === "all") {
return validPages.map((p) => p.url);
}
if (strategy === "manual") { if (strategy === "manual") {
// manual = user picks after crawl; for now return empty // manual = user picks after crawl; for now return empty
return []; return [];

View File

@ -3,18 +3,22 @@
*/ */
import { z } from "zod"; import { z } from "zod";
import { MIN_AUDIT_PAGES, PAID_MAX_AUDIT_PAGES } from "@/shared/audit-limits";
import { jsonCodec } from "@/shared/json"; import { jsonCodec } from "@/shared/json";
export type LighthouseStrategy = "auto" | "all" | "manual" | "none"; export type LighthouseStrategy = "auto" | "manual" | "none";
export interface AuditConfig { export interface AuditConfig {
maxPages: number; maxPages: number;
lighthouseStrategy: LighthouseStrategy; 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({ const auditConfigSchema = z.object({
maxPages: z.number().int().min(10).max(10_000), maxPages: z.number().int().min(MIN_AUDIT_PAGES).max(PAID_MAX_AUDIT_PAGES),
lighthouseStrategy: z.enum(["auto", "all", "manual", "none"]), lighthouseStrategy: z.enum(["auto", "manual", "none"]).catch("auto"),
}); });
const auditConfigCodec = jsonCodec(auditConfigSchema); const auditConfigCodec = jsonCodec(auditConfigSchema);

View File

@ -1,7 +1,11 @@
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { waitUntil } from "cloudflare:workers"; import { waitUntil } from "cloudflare:workers";
import { AuditService } from "@/server/features/audit/services/AuditService"; 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 { AppError } from "@/server/lib/errors";
import { captureServerEvent } from "@/server/lib/posthog"; import { captureServerEvent } from "@/server/lib/posthog";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env"; import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
@ -20,14 +24,20 @@ export const startAudit = createServerFn({ method: "POST" })
.inputValidator((data: unknown) => startAuditSchema.parse(data)) .inputValidator((data: unknown) => startAuditSchema.parse(data))
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
// The crawler runs on our Workers compute and isn't credit-metered, so // 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 // plan-tier limits are the abuse bound in hosted mode: free accounts get
// it; only customers with no Autumn product at all are turned away. // one small audit at a time, paid keeps the full limits, and customers
if ( // with no Autumn product at all are turned away. Self-hosted isn't gated.
(await isHostedServerAuthMode()) && let limitTier: AuditLimitTier = "paid";
!(await customerHasManagedAccess(context.organizationId)) 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"); throw new AppError("PAYMENT_REQUIRED", "Subscribe to run site audits");
} }
limitTier = hasPaidPlan ? "paid" : "free";
}
const result = await AuditService.startAudit({ const result = await AuditService.startAudit({
actorUserId: context.userId, actorUserId: context.userId,
@ -36,6 +46,7 @@ export const startAudit = createServerFn({ method: "POST" })
startUrl: data.startUrl, startUrl: data.startUrl,
maxPages: data.maxPages, maxPages: data.maxPages,
lighthouseStrategy: data.lighthouseStrategy, lighthouseStrategy: data.lighthouseStrategy,
limitTier,
}); });
waitUntil( waitUntil(
@ -47,6 +58,7 @@ export const startAudit = createServerFn({ method: "POST" })
project_id: context.projectId, project_id: context.projectId,
max_pages: data.maxPages ?? 50, max_pages: data.maxPages ?? 50,
run_lighthouse: data.lighthouseStrategy !== "none", run_lighthouse: data.lighthouseStrategy !== "none",
plan_tier: limitTier,
}, },
}), }),
); );

View File

@ -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;

View File

@ -7,6 +7,9 @@ describe("shouldCaptureAppErrorCode", () => {
"NOT_FOUND", "NOT_FOUND",
"PAYMENT_REQUIRED", "PAYMENT_REQUIRED",
"VALIDATION_ERROR", "VALIDATION_ERROR",
"AUDIT_CAPACITY_REACHED",
"AUDIT_PAGE_LIMIT_EXCEEDED",
"AUDIT_ALREADY_RUNNING",
] as const)("skips expected %s errors", (code) => { ] as const)("skips expected %s errors", (code) => {
expect(shouldCaptureAppErrorCode(code)).toBe(false); expect(shouldCaptureAppErrorCode(code)).toBe(false);
}); });

View File

@ -8,6 +8,8 @@ const ERROR_CODES = [
"FORBIDDEN", "FORBIDDEN",
"NOT_FOUND", "NOT_FOUND",
"AUDIT_CAPACITY_REACHED", "AUDIT_CAPACITY_REACHED",
"AUDIT_PAGE_LIMIT_EXCEEDED",
"AUDIT_ALREADY_RUNNING",
"VALIDATION_ERROR", "VALIDATION_ERROR",
"CRAWL_TARGET_BLOCKED", "CRAWL_TARGET_BLOCKED",
"BACKLINKS_NOT_ENABLED", "BACKLINKS_NOT_ENABLED",
@ -31,6 +33,9 @@ const NON_REPORTABLE_ERROR_CODES = new Set<ErrorCode>([
"PAYMENT_REQUIRED", "PAYMENT_REQUIRED",
"INSUFFICIENT_CREDITS", "INSUFFICIENT_CREDITS",
"VALIDATION_ERROR", "VALIDATION_ERROR",
"AUDIT_CAPACITY_REACHED",
"AUDIT_PAGE_LIMIT_EXCEEDED",
"AUDIT_ALREADY_RUNNING",
]); ]);
export function isErrorCode(value: string): value is ErrorCode { export function isErrorCode(value: string): value is ErrorCode {

View File

@ -1,13 +1,24 @@
import { z } from "zod"; import { z } from "zod";
import {
DEFAULT_AUDIT_PAGES,
MIN_AUDIT_PAGES,
PAID_MAX_AUDIT_PAGES,
} from "@/shared/audit-limits";
// ─── Server function input schemas ────────────────────────────────────────── // ─── Server function input schemas ──────────────────────────────────────────
export const startAuditSchema = z.object({ export const startAuditSchema = z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
startUrl: z.string().min(1, "URL is required").max(2048), 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 lighthouseStrategy: z
.enum(["auto", "all", "manual", "none"]) .enum(["auto", "manual", "none"])
.optional() .optional()
.default("auto"), .default("auto"),
}); });