192 lines
6.9 KiB
TypeScript

import {
createStartHandler,
defaultStreamHandler,
} from "@tanstack/react-start/server";
import { routeAgentRequest } from "agents";
import { resolveUserContextFromHeaders } from "@/middleware/ensure-user/resolve";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { SamSessionRepository } from "@/server/features/sam/SamSessionRepository";
import { runScheduledRankChecks } from "@/server/features/rank-tracking/services/scheduledRankChecks";
import { getOrCreateOrganizationCustomer } from "@/server/billing/subscription";
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import { getAuthMode, isHostedAuthMode } from "@/lib/auth-mode";
import {
createOpenSeoOAuthProvider,
type OpenSeoOAuthEnv,
} from "@/server/mcp/oauth-provider";
import { requestWithPublicOrigin } from "@/server/mcp/public-origin";
import { MCP_ROUTE } from "@/server/mcp/context";
import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport";
import { withPgClient } from "@/db";
import {
AUTUMN_WEBHOOK_PATH,
handleAutumnWebhookRequest,
} from "@/server/billing/autumn-webhook";
import { maybeSendSelfHostHeartbeat } from "@/server/lib/self-host-telemetry";
const appFetch = createStartHandler(defaultStreamHandler);
const openSeoOAuthProvider = createOpenSeoOAuthProvider(appFetch);
// Authorize an onboarding-chat connection in the Worker, before it reaches the
// Durable Object. The DO instance name is the projectId (set client-side); we
// resolve the session here and confirm the caller's org owns that project, so
// the DO can trust its `name`. Returning a Response rejects; void lets it through.
async function authorizeOnboardingChat(
request: Request,
projectId: string,
): Promise<Response | undefined> {
let context;
try {
context = await resolveUserContextFromHeaders(request.headers);
} catch {
return new Response("Unauthorized", { status: 401 });
}
const project = await ProjectRepository.getProjectForOrganization(
projectId,
context.organizationId,
);
if (!project) {
return new Response("Forbidden", { status: 403 });
}
// Ensure the org's Autumn customer exists (and gets its default onboarding-plan
// credits) before the DO checks the balance — otherwise a brand-new org's first
// message can hit a false "out of credits" gate. Hosted-only; self-hosted has
// no Autumn.
if (await isHostedServerAuthMode()) {
await getOrCreateOrganizationCustomer(context);
}
return undefined;
}
// Authorize a SAM agent connection in the Worker, before it reaches the Durable
// Object. The DO instance name is the sessionId (set client-side); we resolve
// the session here and authorize the caller against the session's project via
// the same canonical project-access check the rest of the app uses, so the DO
// can trust its `name` and derive org/project/user from the session row.
async function authorizeSamChat(
request: Request,
sessionId: string,
): Promise<Response | undefined> {
let context;
try {
context = await resolveUserContextFromHeaders(request.headers);
} catch {
return new Response("Unauthorized", { status: 401 });
}
const session = await SamSessionRepository.getActiveSession(
sessionId,
context.userId,
);
const project = session
? await ProjectRepository.getProjectForOrganization(
session.projectId,
context.organizationId,
)
: null;
if (!session || !project) {
return new Response("Forbidden", { status: 403 });
}
// Same as onboarding above: make sure the Autumn customer (and its default
// free-plan credits) exists before the DO's balance gate runs, or a brand-new
// org's first message hits a false "out of credits".
if (await isHostedServerAuthMode()) {
await getOrCreateOrganizationCustomer(context);
}
return undefined;
}
// Both chat DOs live behind /agents/*. Dispatch on the DO binding partyserver
// resolved for the request (rather than re-parsing the path), and fail closed
// on anything unrecognized.
function authorizeChatAgent(
request: Request,
lobby: { className: string; name: string },
): Promise<Response | undefined> | Response {
switch (lobby.className) {
case "SAM_CHAT":
return authorizeSamChat(request, lobby.name);
case "ONBOARDING_CHAT":
return authorizeOnboardingChat(request, lobby.name);
default:
return new Response("Forbidden", { status: 403 });
}
}
// Route /agents/* to the onboarding and SAM chat DOs. Auth happens here (both
// the WS upgrade and any HTTP message-history fetch), keeping it off the OAuth
// wrapper and TanStack route guard below.
async function routeChatAgents(request: Request, env: Env): Promise<Response> {
const response = await routeAgentRequest(request, env, {
onBeforeConnect: (req, lobby) => authorizeChatAgent(req, lobby),
onBeforeRequest: (req, lobby) => authorizeChatAgent(req, lobby),
});
return response ?? new Response("Not found", { status: 404 });
}
function fetch(
request: Request,
env: Env,
ctx: ExecutionContext,
): Promise<Response> {
// Scope a per-request Postgres client (no-op in D1 mode). The client isn't
// closed here — the Workers↔Hyperdrive socket is reclaimed at invocation end.
return withPgClient(() => Promise.resolve(handleFetch(request, env, ctx)));
}
function handleFetch(
request: Request,
env: Env,
ctx: ExecutionContext,
): Response | Promise<Response> {
ctx.waitUntil(maybeSendSelfHostHeartbeat());
const authMode = getAuthMode(env.AUTH_MODE);
const publicRequest = requestWithPublicOrigin(request);
const pathname = new URL(publicRequest.url).pathname;
if (pathname.startsWith("/agents/")) {
return routeChatAgents(publicRequest, env);
}
if (isHostedAuthMode(authMode)) {
if (pathname === AUTUMN_WEBHOOK_PATH) {
return handleAutumnWebhookRequest(publicRequest);
}
return openSeoOAuthProvider.fetch(
publicRequest,
env as OpenSeoOAuthEnv,
ctx,
);
}
if (
(authMode === "cloudflare_access" || authMode === "local_noauth") &&
pathname === MCP_ROUTE
) {
return handleSelfHostedOpenSeoMcpRequest(publicRequest, authMode, env, ctx);
}
return appFetch(request);
}
// Export Workflow classes as named exports
export { SiteAuditWorkflow } from "./server/workflows/SiteAuditWorkflow";
export { RankCheckWorkflow } from "./server/workflows/RankCheckWorkflow";
// Durable Object class for the onboarding strategy chat (Agents SDK).
export { OnboardingChatAgent } from "./server/features/onboarding/OnboardingChatAgent";
// Durable Object class for the SAM in-app agent (Agents SDK).
export { SamChatAgent } from "./server/features/sam/SamChatAgent";
export default {
fetch,
async scheduled(
_controller: ScheduledController,
env: Env,
_ctx: ExecutionContext,
) {
// Scope a per-request Postgres client for the cron run (no-op in D1 mode).
await withPgClient(() => runScheduledRankChecks(env));
},
};