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 {
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<typeof useLaunchController>["launchForm"];
commitMaxPagesInput: () => number;
maxPagesLimit: number;
};
export function LaunchFormCard({ commitMaxPagesInput, launchForm }: Props) {
export function LaunchFormCard({
commitMaxPagesInput,
launchForm,
maxPagesLimit,
}: Props) {
return (
<div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4">
@ -69,6 +74,7 @@ export function LaunchFormCard({ commitMaxPagesInput, launchForm }: Props) {
<LaunchOptions
launchForm={launchForm}
commitMaxPagesInput={commitMaxPagesInput}
maxPagesLimit={maxPagesLimit}
/>
<LighthouseOptions launchForm={launchForm} />
</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 (
<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">
@ -93,7 +105,7 @@ function LaunchOptions({ launchForm, commitMaxPagesInput }: Props) {
<input
type="number"
min={MIN_PAGES}
max={MAX_PAGES_LIMIT}
max={maxPagesLimit}
className="input input-bordered input-sm w-28"
value={field.state.value}
onChange={(event) => {
@ -110,7 +122,20 @@ function LaunchOptions({ launchForm, commitMaxPagesInput }: Props) {
</launchForm.Field>
</div>
<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>
</div>
);

View File

@ -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 <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 (
<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
launchForm={controller.launchForm}
commitMaxPagesInput={controller.commitMaxPagesInput}
maxPagesLimit={controller.maxPagesLimit}
/>
<AuditHistorySection

View File

@ -1,5 +1,15 @@
export const MIN_PAGES = 10;
export const MAX_PAGES_LIMIT = 10_000;
import {
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 = {
url: string;
@ -9,6 +19,6 @@ export type LaunchFormValues = {
export const DEFAULT_LAUNCH_FORM_VALUES: LaunchFormValues = {
url: "",
maxPagesInput: "50",
maxPagesInput: String(DEFAULT_AUDIT_PAGES),
runLighthouse: false,
};

View File

@ -8,7 +8,7 @@ import {
} from "@/serverFunctions/audit";
import {
DEFAULT_LAUNCH_FORM_VALUES,
MAX_PAGES_LIMIT,
getMaxPagesLimit,
MIN_PAGES,
type LaunchFormValues,
} from "@/client/features/audit/launch/types";
@ -39,11 +39,14 @@ function getLaunchValidationErrors(
export function useLaunchController({
projectId,
isFreePlan,
onAuditStarted,
}: {
projectId: string;
isFreePlan: boolean;
onAuditStarted: (auditId: string) => 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;

View File

@ -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<ErrorCode, string> = {
@ -12,6 +13,9 @@ const STANDARD_MESSAGES: Record<ErrorCode, string> = {
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:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,
},
}),
);

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",
"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);
});

View File

@ -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<ErrorCode>([
"PAYMENT_REQUIRED",
"INSUFFICIENT_CREDITS",
"VALIDATION_ERROR",
"AUDIT_CAPACITY_REACHED",
"AUDIT_PAGE_LIMIT_EXCEEDED",
"AUDIT_ALREADY_RUNNING",
]);
export function isErrorCode(value: string): value is ErrorCode {

View File

@ -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"),
});