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 {
|
||||
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>
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 [];
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
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",
|
||||
"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);
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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"),
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user