288 lines
9.5 KiB
TypeScript
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;
|