288 lines
9.5 KiB
TypeScript

import { env } from "cloudflare:workers";
import {
customerHasManagedAccess,
customerHasPaidPlan,
getOrCreateOrganizationCustomer,
type BillingCustomerContext,
} from "@/server/billing/subscription";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import {
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";
import {
parseAuditConfig,
type AuditConfig,
type LighthouseStrategy,
} from "@/server/lib/audit/types";
import {
normalizeAndValidateStartUrl,
resolveStartUrlRedirects,
} from "@/server/lib/audit/url-policy";
import { reconcileRunningAudit } from "@/server/features/audit/services/auditReconciler";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
// Plan-tier limits are the abuse bound in hosted mode: free accounts get small
// audits with a bounded burst, paid keeps the full limits, and customers with
// no Autumn product at all are turned away. Self-hosted isn't gated.
async function resolveAuditLimitTier(
customer: BillingCustomerContext,
): Promise<AuditLimitTier> {
if (!(await isHostedServerAuthMode())) return "self_hosted";
// An org minted outside a billing path (better-auth hooks, MCP auth) has no
// Autumn customer yet, and `check` 404s instead of reporting no access — a
// brand-new MCP user's first audit failed with a raw billing error.
await getOrCreateOrganizationCustomer(customer);
const [hasManagedAccess, hasPaidPlan] = await Promise.all([
customerHasManagedAccess(customer.organizationId),
customerHasPaidPlan(customer.organizationId),
]);
if (!hasManagedAccess) {
throw new AppError("PAYMENT_REQUIRED", "Subscribe to run site audits");
}
return hasPaidPlan ? "paid" : "free";
}
async function startAudit(input: {
actorUserId: string;
billingCustomer: BillingCustomerContext;
projectId: string;
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 auditId = crypto.randomUUID();
const config: AuditConfig = { maxPages, lighthouseStrategy };
// Anchor the audit to the site's real origin: a start domain that 301s
// elsewhere (…net -> …com, apex -> www) would otherwise dead-end after
// one page at the same-origin crawl boundary.
const startUrl = await resolveStartUrlRedirects(
await normalizeAndValidateStartUrl(input.startUrl),
);
await AuditRepository.createAudit({
id: auditId,
projectId: input.projectId,
startedByUserId: input.actorUserId,
startUrl,
workflowInstanceId: auditId,
config,
pagesTotal: reservation.pagesTotal,
lighthouseTotal: reservation.lighthouseTotal,
});
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 running-audits gate. Post-insert, each request sees
// at least its own row, so racers can't all slip under the limit; the
// losers roll back via the catch below. Racers at the boundary may all
// abort — the user just retries. Usage counts per ORGANIZATION, not per
// user: the free ceiling is the org's, so N members don't multiply it.
const usage = await AuditRepository.getAuditUsageForOrganization(
input.billingCustomer.organizationId,
);
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: {
auditId,
billingCustomer: {
userId: input.billingCustomer.userId,
userEmail: input.billingCustomer.userEmail,
organizationId: input.billingCustomer.organizationId,
projectId: input.billingCustomer.projectId,
},
projectId: input.projectId,
startUrl,
config,
},
});
} catch (error) {
try {
const instance = await env.SITE_AUDIT_WORKFLOW.get(auditId);
await instance.terminate();
} catch {
// The workflow may never have been created, or may already be gone.
}
await AuditRepository.deleteAuditForProject(auditId, input.projectId);
throw error;
}
return { auditId };
}
async function getStatus(auditId: string, projectId: string) {
let audit = await AuditRepository.getAuditForProject(auditId, projectId);
if (!audit)
throw new AppError("NOT_FOUND", "Audit not found in this project.");
// Self-heal audits whose workflow died without reaching the mark-failed
// step (instance terminated/errored, instance expired from retention, ...).
// Without this they stay "running" forever and hold capacity.
if (audit.status === "running") {
const reconciled = await reconcileRunningAudit(audit);
if (reconciled) {
audit =
(await AuditRepository.getAuditForProject(auditId, projectId)) ?? audit;
}
}
return {
id: audit.id,
startUrl: audit.startUrl,
status: audit.status,
pagesCrawled: audit.pagesCrawled,
pagesTotal: audit.pagesTotal,
lighthouseTotal: audit.lighthouseTotal,
lighthouseCompleted: audit.lighthouseCompleted,
lighthouseFailed: audit.lighthouseFailed,
currentPhase: audit.currentPhase,
errorCode: audit.errorCode,
startedAt: audit.startedAt,
completedAt: audit.completedAt,
};
}
async function getResults(auditId: string, projectId: string) {
const { audit, pages, lighthouse, issues } =
await AuditRepository.getAuditResultsForProject(auditId, projectId);
if (!audit) throw new AppError("NOT_FOUND");
const parsedConfig = parseAuditConfig(audit.config);
if (!parsedConfig) {
throw new AppError("INTERNAL_ERROR", "Invalid audit configuration");
}
return {
audit: {
id: audit.id,
startUrl: audit.startUrl,
status: audit.status,
pagesCrawled: audit.pagesCrawled,
pagesTotal: audit.pagesTotal,
startedAt: audit.startedAt,
completedAt: audit.completedAt,
config: parsedConfig,
},
pages,
lighthouse,
issues,
};
}
async function getHistory(projectId: string) {
const auditList = await AuditRepository.getAuditsByProject(projectId);
return auditList.map((audit) => {
const parsedConfig = parseAuditConfig(audit.config);
const ranLighthouse = parsedConfig?.lighthouseStrategy !== "none";
return {
id: audit.id,
startUrl: audit.startUrl,
status: audit.status,
pagesCrawled: audit.pagesCrawled,
pagesTotal: audit.pagesTotal,
ranLighthouse,
startedAt: audit.startedAt,
completedAt: audit.completedAt,
};
});
}
async function getCrawlProgress(auditId: string, projectId: string) {
const audit = await AuditRepository.getAuditForProject(auditId, projectId);
if (!audit) {
throw new AppError("NOT_FOUND");
}
return AuditProgressKV.getCrawledUrls(auditId);
}
async function remove(auditId: string, projectId: string) {
const audit = await AuditRepository.getAuditForProject(auditId, projectId);
if (!audit) {
throw new AppError("NOT_FOUND");
}
if (audit.status === "running") {
if (!audit.workflowInstanceId) {
throw new AppError(
"CONFLICT",
"Cannot delete a running audit without workflow context.",
);
}
// 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();
} 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 stillRunning =
status != null &&
["queued", "running", "paused", "waiting", "waitingForPause"].includes(
status.status,
);
if (stillRunning) {
console.error(`Failed to terminate audit workflow ${audit.id}:`, error);
throw new AppError("CONFLICT", "Unable to stop the running audit.");
}
}
}
await AuditRepository.deleteAuditForProject(auditId, projectId);
// Best-effort: drop the crawl scratchpad DO with the audit (it lives in
// the open-seo-audit worker, behind the AuditEngine RPC). A missed destroy
// self-cleans via the DO's 7-day alarm.
try {
await env.AUDIT_ENGINE.destroyScratchpad(auditId);
} catch (error) {
console.warn(`Failed to destroy audit scratchpad ${auditId}:`, error);
}
}
export const AuditService = {
resolveAuditLimitTier,
startAudit,
getStatus,
getCrawlProgress,
getResults,
getHistory,
remove,
} as const;