import { env } from "cloudflare:workers"; import { Cause, Effect } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; import { autumnHandler } from "autumn-js/backend"; import { WorkOSClient } from "../../auth/workos"; import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../../auth/organization"; import { HttpResponseError, isServerError, toErrorServerResponseEffect, } from "../../api/error-response"; type BillingSession = { readonly userId: string; }; export const resolveBillingOrganization = (request: Request, session: BillingSession) => Effect.gen(function* () { // FAIL CLOSED: no header, no org. The AutumnProvider always sends the // URL-scoped header (see __root.tsx billingHeaders); the sealed cookie's // org is a browser-global that can name a DIFFERENT org for a multi-org // user (see workos-auth-provider.resolveSessionPrincipal). const selector = request.headers.get(ORG_SELECTOR_HEADER); if (!selector) { return yield* new HttpResponseError({ status: 401, code: "unauthorized", message: "Unauthorized", }); } const org = yield* authorizeOrganizationSelector(session.userId, selector); if (!org) { return yield* new HttpResponseError({ status: 403, code: "forbidden", message: "Forbidden", }); } return org; }); const handler = Effect.gen(function* () { const request = yield* HttpServerRequest.HttpServerRequest; const webRequest = yield* Effect.mapError( HttpServerRequest.toWeb(request), () => new HttpResponseError({ status: 500, code: "invalid_request", message: "Invalid request", }), ); const workos = yield* WorkOSClient; const session = yield* workos.authenticateRequest(webRequest); if (!session) { return yield* new HttpResponseError({ status: 401, code: "unauthorized", message: "Unauthorized", }); } const org = yield* resolveBillingOrganization(webRequest, session); const url = new URL(webRequest.url); const body = request.method !== "GET" && request.method !== "HEAD" ? yield* Effect.mapError( request.json, () => new HttpResponseError({ status: 400, code: "invalid_json", message: "Invalid request body", }), ) : undefined; const { statusCode, response } = yield* Effect.promise(() => autumnHandler({ request: { url: url.pathname, method: request.method, body, }, customerId: org.id, customerData: { name: session.email, email: session.email, }, clientOptions: { secretKey: env.AUTUMN_SECRET_KEY ?? "", // autumn-js's handler reads `baseURL` to override the Autumn endpoint // (not `serverURL`, which it silently ignores). Without this, a non-prod // AUTUMN_API_URL (the e2e emulator, a self-hosted Autumn) is dropped and // every billing request goes to production Autumn and 401s. ...(env.AUTUMN_API_URL ? { baseURL: env.AUTUMN_API_URL } : {}), }, pathPrefix: "/api/billing", }), ); if (statusCode >= 400) { console.error("[autumn] upstream error:", statusCode, response); return yield* new HttpResponseError({ status: statusCode, code: "billing_request_failed", message: "Billing request failed", }); } return HttpServerResponse.jsonUnsafe(response, { status: statusCode }); }).pipe( Effect.catchCause((err) => { if (isServerError(err)) { console.error("[autumn] request failed:", Cause.pretty(err)); } return toErrorServerResponseEffect(err); }), ); export const AutumnRoutesLive = HttpRouter.add("*", "/api/billing/*", handler);