autumn-js's client hooks call response.json() on every response regardless
of status. The plain-text "Not found" body from the previous commit's guard
made that throw a SyntaxError client-side ("Unexpected token 'N'...") in
hosted mode once Autumn-dependent UI actually mounted and called it.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
112 lines
4.0 KiB
TypeScript
112 lines
4.0 KiB
TypeScript
import { createFileRoute } from "@tanstack/react-router";
|
|
import type { autumnHandler } from "autumn-js/fetch";
|
|
import { env } from "cloudflare:workers";
|
|
import { isHostedAuthMode } from "@/lib/auth-mode";
|
|
import { hasOrgPermission } from "@/lib/org-permissions";
|
|
import { resolveHostedContext } from "@/middleware/ensure-user/hosted";
|
|
import type { EnsuredUserContext } from "@/middleware/ensure-user/types";
|
|
|
|
// Autumn routes any org member may call: balance/customer reads that power
|
|
// credit meters and usage views. Every other route (attach, updateSubscription,
|
|
// openCustomerPortal, setupPayment, referrals, previews, ...) changes the
|
|
// org's subscription or payment state and is owner-only — unknown/new routes
|
|
// fail closed into the owner-only branch.
|
|
const MEMBER_READABLE_AUTUMN_ROUTES = new Set([
|
|
"getOrCreateCustomer",
|
|
"getEntity",
|
|
"listPlans",
|
|
"listEvents",
|
|
"aggregateEvents",
|
|
]);
|
|
|
|
// The caller's context is resolved exactly once per request, in
|
|
// handleAutumnRequest, and shared with Autumn's identify callback through this
|
|
// map. Resolving twice (identify re-reads the same headers) would let an
|
|
// active-org switch or membership change between the two reads authorize
|
|
// against one organization but bill another.
|
|
const contextByRequest = new WeakMap<Request, EnsuredUserContext>();
|
|
|
|
let handlerPromise: Promise<ReturnType<typeof autumnHandler>> | undefined;
|
|
|
|
// Lazy: keeps autumn-js/fetch out of the eager isolate startup graph;
|
|
// resolves instantly after the first request.
|
|
function loadHandler() {
|
|
return (handlerPromise ??= import("autumn-js/fetch").then(
|
|
({ autumnHandler }) =>
|
|
autumnHandler({
|
|
identify: async (request) => {
|
|
const context = contextByRequest.get(request);
|
|
// identify is only reachable via handleAutumnRequest, which resolves
|
|
// first — fail closed rather than re-resolving if that ever breaks.
|
|
if (!context) {
|
|
throw new Error(
|
|
"Autumn identify called without a resolved context",
|
|
);
|
|
}
|
|
|
|
return {
|
|
customerId: context.organizationId,
|
|
};
|
|
},
|
|
}),
|
|
));
|
|
}
|
|
|
|
async function handleAutumnRequest(request: Request) {
|
|
// 404 in non-hosted modes, and also in hosted mode when Autumn isn't
|
|
// configured — a direct-Stripe deployment runs the local-ledger billing
|
|
// fallback instead (see subscription.ts) and has no Autumn customer to
|
|
// read, so this route has nothing to serve. Checking here, before
|
|
// loadHandler(), avoids autumn-js throwing "secret key not found" deep
|
|
// inside the handler once a client request reaches it.
|
|
if (!isHostedAuthMode(env.AUTH_MODE) || !env.AUTUMN_SECRET_KEY?.trim()) {
|
|
// JSON, not a plain-text body: the client-side autumn-js hooks call
|
|
// response.json() on every response regardless of status, and a
|
|
// non-JSON body throws a SyntaxError there instead of handling the 404.
|
|
return Response.json(
|
|
{ message: "Not found", code: "not_found" },
|
|
{ status: 404 },
|
|
);
|
|
}
|
|
|
|
let context;
|
|
try {
|
|
context = await resolveHostedContext(request.headers);
|
|
} catch {
|
|
return Response.json(
|
|
{ message: "Authentication required.", code: "unauthenticated" },
|
|
{ status: 401 },
|
|
);
|
|
}
|
|
contextByRequest.set(request, context);
|
|
|
|
const route = new URL(request.url).pathname.split("/").pop() ?? "";
|
|
if (
|
|
!MEMBER_READABLE_AUTUMN_ROUTES.has(route) &&
|
|
!hasOrgPermission(context.role, { billing: ["manage"] })
|
|
) {
|
|
return Response.json(
|
|
{
|
|
message: "Only the organization owner can manage billing.",
|
|
code: "billing_owner_required",
|
|
},
|
|
{ status: 403 },
|
|
);
|
|
}
|
|
|
|
return (await loadHandler())(request);
|
|
}
|
|
|
|
export const Route = createFileRoute("/api/autumn/$")({
|
|
server: {
|
|
handlers: {
|
|
GET: async ({ request }: { request: Request }) => {
|
|
return handleAutumnRequest(request);
|
|
},
|
|
POST: async ({ request }: { request: Request }) => {
|
|
return handleAutumnRequest(request);
|
|
},
|
|
},
|
|
},
|
|
});
|