Free-plan audit limits: 50 pages, one at a time; remove 'all' lighthouse strategy (#352)
This commit is contained in:
parent
053ac4c4cf
commit
2645750671
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 [];
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
6
src/shared/audit-limits.ts
Normal file
6
src/shared/audit-limits.ts
Normal 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;
|
||||||
@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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"),
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user