feat: improve worker routing and auth startup
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,11 @@
|
||||
import type { Context } from "hono";
|
||||
import { Stripe } from "stripe";
|
||||
|
||||
import { handleStripeInvoicePaid } from "@/external/stripe/webhookHandlers/handleStripeInvoicePaid/handleStripeInvoicePaid.js";
|
||||
import { handleStripeSubscriptionUpdated } from "@/external/stripe/webhookHandlers/handleStripeSubscriptionUpdated/handleStripeSubscriptionUpdated.js";
|
||||
import { unsetOrgStripeKeys } from "@/internal/orgs/orgUtils.js";
|
||||
import { handleWebhookErrorSkip } from "@/utils/routerUtils/webhookErrorSkip.js";
|
||||
import { captureException, getSentryTags } from "../sentry/sentryUtils.js";
|
||||
import { isStripeError } from "./stripeErrorUtils.js";
|
||||
import { handleCusDiscountDeleted } from "./webhookHandlers/handleCusDiscountDeleted.js";
|
||||
import { handleStripeCustomerUpdated } from "./webhookHandlers/handleStripeCustomerUpdated.js";
|
||||
import { handleInvoiceUpdated } from "./webhookHandlers/handleInvoiceUpdated.js";
|
||||
@@ -103,7 +103,7 @@ export const handleStripeWebhookEvent = async (
|
||||
}),
|
||||
});
|
||||
|
||||
if (error instanceof Stripe.errors.StripeError) {
|
||||
if (isStripeError(error)) {
|
||||
if (error.message.includes("No such customer")) {
|
||||
logger.warn(`stripe customer missing: ${error.message}`);
|
||||
return c.json({ success: true }, 200);
|
||||
|
||||
11
server/src/external/stripe/stripeErrorUtils.ts
vendored
Normal file
11
server/src/external/stripe/stripeErrorUtils.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
import type Stripe from "stripe";
|
||||
|
||||
export const isStripeError = (error: unknown): error is Stripe.errors.StripeError => {
|
||||
if (!(error instanceof Error)) return false;
|
||||
|
||||
const stripeError = error as Partial<Stripe.errors.StripeError>;
|
||||
return (
|
||||
typeof stripeError.type === "string" &&
|
||||
stripeError.type.startsWith("Stripe")
|
||||
);
|
||||
};
|
||||
@@ -9,11 +9,14 @@ export type TinybirdConfig = {
|
||||
export let tinybirdConfig: TinybirdConfig | null = null;
|
||||
|
||||
export const initTinybirdConfig = (env: Env) => {
|
||||
const baseUrl = env.TINYBIRD_US_EAST_API_URL ?? env.TINYBIRD_API_URL;
|
||||
const token = env.TINYBIRD_US_EAST_TOKEN ?? env.TINYBIRD_TOKEN;
|
||||
|
||||
tinybirdConfig =
|
||||
env.TINYBIRD_API_URL && env.TINYBIRD_TOKEN
|
||||
baseUrl && token
|
||||
? {
|
||||
baseUrl: env.TINYBIRD_API_URL,
|
||||
token: env.TINYBIRD_TOKEN,
|
||||
baseUrl,
|
||||
token,
|
||||
}
|
||||
: null;
|
||||
};
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { ErrCode, RecaseError } from "@autumn/shared";
|
||||
import type { Context } from "hono";
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import Stripe from "stripe";
|
||||
import { ZodError } from "zod/v4";
|
||||
import { formatZodError } from "@/errors/formatZodError.js";
|
||||
import { isStripeError } from "@/external/stripe/stripeErrorUtils.js";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import {
|
||||
captureException,
|
||||
@@ -68,17 +68,8 @@ export const errorMiddleware = (err: Error, c: Context<HonoEnv>) => {
|
||||
);
|
||||
}
|
||||
|
||||
// If we got here, it's an error worth tracking - capture to Sentry
|
||||
captureException(err, {
|
||||
tags: getSentryTags({
|
||||
ctx,
|
||||
path: c.req.path,
|
||||
method: c.req.method,
|
||||
}),
|
||||
});
|
||||
|
||||
// 2. Handle Stripe errors
|
||||
if (err instanceof Stripe.errors.StripeError) {
|
||||
if (isStripeError(err)) {
|
||||
logger.error(
|
||||
`STRIPE ERROR (${ctx.org?.slug || "unknown"}): ${err.message}`,
|
||||
{
|
||||
@@ -121,6 +112,15 @@ export const errorMiddleware = (err: Error, c: Context<HonoEnv>) => {
|
||||
);
|
||||
}
|
||||
|
||||
// If we got here, it's an error worth tracking - capture to Sentry
|
||||
captureException(err, {
|
||||
tags: getSentryTags({
|
||||
ctx,
|
||||
path: c.req.path,
|
||||
method: c.req.method,
|
||||
}),
|
||||
});
|
||||
|
||||
// 4. Handle unknown errors
|
||||
logger.error(
|
||||
`UNKNOWN ERROR (${ctx.org?.slug || "unknown"}): ${err.message}`,
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { ErrCode, RecaseError } from "@autumn/shared";
|
||||
import type { Context } from "hono";
|
||||
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
||||
import Stripe from "stripe";
|
||||
import type Stripe from "stripe";
|
||||
import { ZodError } from "zod/v4";
|
||||
import { formatZodError } from "@/errors/formatZodError.js";
|
||||
import { isStripeError } from "@/external/stripe/stripeErrorUtils.js";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { matchRoute } from "./middlewareUtils.js";
|
||||
|
||||
@@ -57,7 +58,7 @@ const STRIPE_RULES = [
|
||||
{
|
||||
name: "Stripe rate limit exceeded",
|
||||
match: (err: Error) =>
|
||||
err instanceof Stripe.errors.StripeError &&
|
||||
isStripeError(err) &&
|
||||
(err.type === "StripeRateLimitError" || err.statusCode === 429),
|
||||
statusCode: 429,
|
||||
code: "stripe_rate_limit_exceeded",
|
||||
@@ -65,7 +66,7 @@ const STRIPE_RULES = [
|
||||
{
|
||||
name: "Exchange router invalid API key",
|
||||
match: (err: Error, c: Context<HonoEnv>) =>
|
||||
err instanceof Stripe.errors.StripeError &&
|
||||
isStripeError(err) &&
|
||||
c.req.url.includes("/exchange") &&
|
||||
err.message.includes("Invalid API Key provided"),
|
||||
statusCode: 400,
|
||||
@@ -74,7 +75,7 @@ const STRIPE_RULES = [
|
||||
{
|
||||
name: "Billing portal config error",
|
||||
match: (err: Error, c: Context<HonoEnv>) =>
|
||||
err instanceof Stripe.errors.StripeError &&
|
||||
isStripeError(err) &&
|
||||
c.req.url.includes("/billing_portal") &&
|
||||
err.message.includes("Provide a configuration or create your default"),
|
||||
statusCode: 404,
|
||||
@@ -83,7 +84,7 @@ const STRIPE_RULES = [
|
||||
{
|
||||
name: "Billing portal return_url error",
|
||||
match: (err: Error, c: Context<HonoEnv>) =>
|
||||
err instanceof Stripe.errors.StripeError &&
|
||||
isStripeError(err) &&
|
||||
c.req.url.includes("/billing_portal") &&
|
||||
err.message.includes("Invalid URL: An explicit scheme (such as https)"),
|
||||
statusCode: 400,
|
||||
@@ -92,15 +93,14 @@ const STRIPE_RULES = [
|
||||
{
|
||||
name: "Card declined error",
|
||||
match: (err: Error) =>
|
||||
err instanceof Stripe.errors.StripeError &&
|
||||
err.message.includes("Your card was declined."),
|
||||
isStripeError(err) && err.message.includes("Your card was declined."),
|
||||
statusCode: 400,
|
||||
code: ErrCode.InvalidRequest,
|
||||
},
|
||||
{
|
||||
name: "Cannot delete org with production customers",
|
||||
match: (err: Error) =>
|
||||
err instanceof Stripe.errors.StripeError &&
|
||||
isStripeError(err) &&
|
||||
err.message.includes("Cannot delete org with production mode customers"),
|
||||
statusCode: 400,
|
||||
code: ErrCode.InvalidRequest,
|
||||
@@ -108,7 +108,7 @@ const STRIPE_RULES = [
|
||||
{
|
||||
name: "Webhook endpoint limit reached",
|
||||
match: (err: Error) =>
|
||||
err instanceof Stripe.errors.StripeError &&
|
||||
isStripeError(err) &&
|
||||
err.message.includes(
|
||||
"You have reached the maximum of 16 test webhook endpoints",
|
||||
),
|
||||
@@ -118,7 +118,7 @@ const STRIPE_RULES = [
|
||||
{
|
||||
name: "Invalid URL scheme error",
|
||||
match: (err: Error) =>
|
||||
err instanceof Stripe.errors.StripeError &&
|
||||
isStripeError(err) &&
|
||||
err.message.includes(
|
||||
"Invalid URL: An explicit scheme (such as https) must be provided",
|
||||
),
|
||||
@@ -128,8 +128,7 @@ const STRIPE_RULES = [
|
||||
{
|
||||
name: "Not a valid URL error",
|
||||
match: (err: Error) =>
|
||||
err instanceof Stripe.errors.StripeError &&
|
||||
err.message.includes("Not a valid URL"),
|
||||
isStripeError(err) && err.message.includes("Not a valid URL"),
|
||||
statusCode: 400,
|
||||
code: ErrCode.InvalidRequest,
|
||||
},
|
||||
|
||||
@@ -66,10 +66,17 @@ const memoizeRouter = (createRouter: (env: Env) => LazyRouter) => {
|
||||
};
|
||||
|
||||
const routeToRouter =
|
||||
(getRouter: (env: Env) => LazyRouter) => async (c: Context<HonoEnv>) => {
|
||||
(
|
||||
getRouter: (env: Env) => LazyRouter,
|
||||
options?: { stripPrefix?: string },
|
||||
) =>
|
||||
async (c: Context<HonoEnv>) => {
|
||||
const router = getRouter(c.env);
|
||||
const request = options?.stripPrefix
|
||||
? rewriteMountedRequestPath(c.req.raw, options.stripPrefix)
|
||||
: c.req.raw;
|
||||
return router.fetch(
|
||||
c.req.raw,
|
||||
request,
|
||||
c.env,
|
||||
c.executionCtx as ExecutionContext<unknown>,
|
||||
);
|
||||
@@ -79,12 +86,49 @@ const mountRouter = (
|
||||
app: Hono<HonoEnv>,
|
||||
path: string,
|
||||
getRouter: (env: Env) => LazyRouter,
|
||||
options?: { stripPrefix?: boolean },
|
||||
) => {
|
||||
const handler = routeToRouter(getRouter);
|
||||
const handler = routeToRouter(
|
||||
getRouter,
|
||||
options?.stripPrefix ? { stripPrefix: path } : undefined,
|
||||
);
|
||||
app.all(path, handler);
|
||||
app.all(`${path}/*`, handler);
|
||||
};
|
||||
|
||||
export const rewriteMountedRequestPath = (
|
||||
request: Request,
|
||||
mountPath: string,
|
||||
): Request => {
|
||||
const url = new URL(request.url);
|
||||
const suffix =
|
||||
url.pathname === mountPath ? "" : url.pathname.slice(mountPath.length);
|
||||
url.pathname = suffix || "/";
|
||||
return new Request(url, request);
|
||||
};
|
||||
|
||||
export const rewriteAutumnAuthRequest = (request: Request): Request => {
|
||||
const url = new URL(request.url);
|
||||
url.pathname = `/api/auth/autumn${url.pathname.slice("/api/autumn".length)}`;
|
||||
return new Request(url, request);
|
||||
};
|
||||
|
||||
export const isPublicDevRoute = (method: string, pathname: string): boolean => {
|
||||
if (method === "POST" && pathname === "/dev/cli/stripe") return true;
|
||||
return method === "GET" && /^\/dev\/otp\/[^/]+$/.test(pathname);
|
||||
};
|
||||
|
||||
const handleAuthRequest = async (c: Context<HonoEnv>, request: Request) => {
|
||||
const requestDb = createRequestScopedDb(c.env);
|
||||
try {
|
||||
return await createAuth(c.env, {
|
||||
db: requestDb.db,
|
||||
}).handler(request);
|
||||
} finally {
|
||||
c.executionCtx.waitUntil(requestDb.dispose());
|
||||
}
|
||||
};
|
||||
|
||||
export const createHonoApp = (_env: Env) => {
|
||||
const app = new Hono<HonoEnv>();
|
||||
const getChatProxyRouter = memoizeRouter((env) => createChatProxyRouter(env));
|
||||
@@ -138,15 +182,12 @@ export const createHonoApp = (_env: Env) => {
|
||||
return handleListAuthOrganizations(c);
|
||||
});
|
||||
|
||||
app.on(["POST", "GET"], "/api/autumn/*", async (c) => {
|
||||
return handleAuthRequest(c, rewriteAutumnAuthRequest(c.req.raw));
|
||||
});
|
||||
|
||||
app.on(["POST", "GET"], "/api/auth/*", async (c) => {
|
||||
const requestDb = createRequestScopedDb(c.env);
|
||||
try {
|
||||
return await createAuth(c.env, {
|
||||
db: requestDb.db,
|
||||
}).handler(c.req.raw);
|
||||
} finally {
|
||||
c.executionCtx.waitUntil(requestDb.dispose());
|
||||
}
|
||||
return handleAuthRequest(c, c.req.raw);
|
||||
});
|
||||
|
||||
app.get("/ready/:token", handleReadyCheck);
|
||||
@@ -170,6 +211,14 @@ export const createHonoApp = (_env: Env) => {
|
||||
});
|
||||
|
||||
mountRouter(app, "/cli", getCliRouter);
|
||||
const publicRouterHandler = routeToRouter(getPublicRouter);
|
||||
app.all("/dev/otp/:otp", async (c, next) => {
|
||||
if (!isPublicDevRoute(c.req.method, new URL(c.req.url).pathname)) {
|
||||
return next();
|
||||
}
|
||||
return publicRouterHandler(c);
|
||||
});
|
||||
app.post("/dev/cli/stripe", publicRouterHandler);
|
||||
|
||||
// Add Render region identifier header for load balancer verification
|
||||
app.use("*", async (c, next) => {
|
||||
@@ -189,10 +238,9 @@ export const createHonoApp = (_env: Env) => {
|
||||
|
||||
mountRouter(app, "/checkouts", getPublicRouter);
|
||||
mountRouter(app, "/invoices", getPublicRouter);
|
||||
mountRouter(app, "/dev", getPublicRouter);
|
||||
mountRouter(app, "/trmnl", getPublicRouter);
|
||||
|
||||
mountRouter(app, "/v1", getApiRouter);
|
||||
mountRouter(app, "/v1", getApiRouter, { stripPrefix: true });
|
||||
|
||||
for (const path of [
|
||||
"/admin",
|
||||
@@ -200,6 +248,7 @@ export const createHonoApp = (_env: Env) => {
|
||||
"/invoice_templates",
|
||||
"/products",
|
||||
"/customers",
|
||||
"/dev",
|
||||
"/consents",
|
||||
"/pricing-agent",
|
||||
"/feedback",
|
||||
|
||||
@@ -5,6 +5,7 @@ import { analyticsMiddleware } from "../honoMiddlewares/analyticsMiddleware";
|
||||
import { apiVersionMiddleware } from "../honoMiddlewares/apiVersionMiddleware";
|
||||
import { baseMiddleware } from "../honoMiddlewares/baseMiddleware.js";
|
||||
import { betterAuthMiddleware } from "../honoMiddlewares/betterAuthMiddleware";
|
||||
import { errorMiddleware } from "../honoMiddlewares/errorMiddleware.js";
|
||||
import { orgConfigMiddleware } from "../honoMiddlewares/orgConfigMiddleware";
|
||||
import { queryMiddleware } from "../honoMiddlewares/queryMiddleware";
|
||||
import { refreshCacheMiddleware } from "../honoMiddlewares/refreshCacheMiddleware";
|
||||
@@ -58,5 +59,7 @@ export const createInternalRouter = () => {
|
||||
internalRouter.route("", migrationRpcRouter);
|
||||
internalRouter.route("/workbench", workbenchRouter);
|
||||
|
||||
internalRouter.onError(errorMiddleware);
|
||||
|
||||
return internalRouter;
|
||||
};
|
||||
|
||||
@@ -24,6 +24,7 @@ import { createAfterSessionCreated } from "./authUtils/afterSessionCreated.js";
|
||||
import { createAfterSessionDeleted } from "./authUtils/afterSessionDeleted.js";
|
||||
import { createBeforeSessionCreated } from "./authUtils/beforeSessionCreated.js";
|
||||
import { getScopesForUserInOrg } from "./authUtils/customSessionScopes.js";
|
||||
import { createAutumnBetterAuthPlugin } from "./autumnBetterAuthPlugin.js";
|
||||
import { ADMIN_USER_IDs } from "./constants.js";
|
||||
import { isAllowedOrigin } from "./corsOrigins.js";
|
||||
import { ensureHeadersGetSetCookie } from "./headersGetSetCookiePolyfill.js";
|
||||
@@ -104,6 +105,30 @@ export const getAuthCookieAdvancedOptions = (env: Env) => {
|
||||
};
|
||||
};
|
||||
|
||||
const isLoopbackUrl = (rawUrl?: string) => {
|
||||
if (!rawUrl) return false;
|
||||
try {
|
||||
const { hostname } = new URL(rawUrl);
|
||||
return hostname === "localhost" || hostname === "127.0.0.1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getAutumnBetterAuthApiUrl = (env: Env) => {
|
||||
const configuredUrl = env.BETTER_AUTH_URL ?? env.SERVER_URL;
|
||||
|
||||
if (
|
||||
env.NODE_ENV === "production" &&
|
||||
env.WORKER === "true" &&
|
||||
isLoopbackUrl(configuredUrl)
|
||||
) {
|
||||
return "https://autumn-api.bowong.cc";
|
||||
}
|
||||
|
||||
return configuredUrl;
|
||||
};
|
||||
|
||||
export const createJwtOptions = () => ({
|
||||
disableSettingJwtHeader: true,
|
||||
});
|
||||
@@ -376,6 +401,11 @@ export const createAuth = (env: Env, overrides: { db: DrizzleCli }) => {
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
createAutumnBetterAuthPlugin({
|
||||
env,
|
||||
autumnURL: getAutumnBetterAuthApiUrl(env),
|
||||
}),
|
||||
],
|
||||
} satisfies BetterAuthOptions;
|
||||
|
||||
|
||||
170
server/src/utils/autumnBetterAuthPlugin.ts
Normal file
170
server/src/utils/autumnBetterAuthPlugin.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import { createAuthEndpoint } from "@better-auth/core/api";
|
||||
import { Autumn } from "@useautumn/sdk";
|
||||
import { getSessionFromCtx } from "better-auth/api";
|
||||
|
||||
type BetterAuthSession = {
|
||||
user?: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
session?: {
|
||||
activeOrganizationId?: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
type BetterAuthOrganization = {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
const getActiveOrganization = async (
|
||||
ctx: unknown,
|
||||
session: BetterAuthSession | null,
|
||||
): Promise<BetterAuthOrganization | null> => {
|
||||
const organizationId = session?.session?.activeOrganizationId;
|
||||
if (!organizationId) return null;
|
||||
|
||||
const context = (ctx as { context?: { adapter?: unknown } }).context;
|
||||
const adapter = context?.adapter as
|
||||
| {
|
||||
findOne: (params: {
|
||||
model: string;
|
||||
where: { field: string; value: string }[];
|
||||
}) => Promise<BetterAuthOrganization | null>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
return (
|
||||
(await adapter?.findOne({
|
||||
model: "organization",
|
||||
where: [{ field: "id", value: organizationId }],
|
||||
})) ?? null
|
||||
);
|
||||
};
|
||||
|
||||
const resolveIdentity = (
|
||||
session: BetterAuthSession | null,
|
||||
organization: BetterAuthOrganization | null,
|
||||
) => {
|
||||
if (organization) {
|
||||
return {
|
||||
customerId: organization.id,
|
||||
customerData: { name: organization.name ?? undefined },
|
||||
};
|
||||
}
|
||||
|
||||
if (!session?.user) return null;
|
||||
|
||||
return {
|
||||
customerId: session.user.id,
|
||||
customerData: {
|
||||
name: session.user.name ?? undefined,
|
||||
email: session.user.email ?? undefined,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const readBodyObject = (body: unknown): Record<string, unknown> => {
|
||||
return body && typeof body === "object" && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>)
|
||||
: {};
|
||||
};
|
||||
|
||||
const jsonError = (error: unknown) => {
|
||||
if (
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"statusCode" in error &&
|
||||
"body" in error
|
||||
) {
|
||||
const autumnError = error as {
|
||||
statusCode: number;
|
||||
body: string;
|
||||
message?: string;
|
||||
};
|
||||
const body = (() => {
|
||||
try {
|
||||
return JSON.parse(autumnError.body);
|
||||
} catch {
|
||||
return {
|
||||
message: autumnError.message ?? "Request failed",
|
||||
code: "autumn_request_failed",
|
||||
statusCode: autumnError.statusCode,
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
return {
|
||||
status: autumnError.statusCode,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 500,
|
||||
body: {
|
||||
message: error instanceof Error ? error.message : "Request failed",
|
||||
code: "autumn_request_failed",
|
||||
statusCode: 500,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const createAutumnBetterAuthPlugin = ({
|
||||
env,
|
||||
autumnURL,
|
||||
}: {
|
||||
env: Env;
|
||||
autumnURL?: string;
|
||||
}) => ({
|
||||
id: "autumn",
|
||||
endpoints: {
|
||||
getOrCreateCustomer: createAuthEndpoint(
|
||||
"/autumn/getOrCreateCustomer",
|
||||
{ method: "POST" },
|
||||
async (ctx) => {
|
||||
const session = (await getSessionFromCtx(
|
||||
ctx as Parameters<typeof getSessionFromCtx>[0],
|
||||
)) as BetterAuthSession | null;
|
||||
const organization = await getActiveOrganization(ctx, session);
|
||||
const identity = resolveIdentity(session, organization);
|
||||
const body = readBodyObject((ctx as { body?: unknown }).body);
|
||||
|
||||
if (!identity?.customerId && body.errorOnNotFound === false) {
|
||||
return ctx.json(null, { status: 204 });
|
||||
}
|
||||
|
||||
if (!identity?.customerId) {
|
||||
return ctx.json(
|
||||
{
|
||||
message: "customerId not found",
|
||||
code: "no_customer_id",
|
||||
statusCode: 401,
|
||||
},
|
||||
{ status: 401 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const autumn = new Autumn({
|
||||
secretKey: env.AUTUMN_SECRET_KEY,
|
||||
...(autumnURL && { serverURL: autumnURL }),
|
||||
});
|
||||
const expand = Array.isArray(body.expand) ? body.expand : [];
|
||||
const customer = await autumn.customers.getOrCreate({
|
||||
...body,
|
||||
customerId: identity.customerId,
|
||||
...identity.customerData,
|
||||
expand: [...expand, "balances.feature"],
|
||||
});
|
||||
|
||||
return ctx.json(customer);
|
||||
} catch (error) {
|
||||
const result = jsonError(error);
|
||||
return ctx.json(result.body, { status: result.status });
|
||||
}
|
||||
},
|
||||
),
|
||||
},
|
||||
});
|
||||
@@ -26,3 +26,54 @@ test("error middleware returns RecaseError status before request context exists"
|
||||
code: ErrCode.NoAuthHeader,
|
||||
});
|
||||
});
|
||||
|
||||
test("error middleware maps Stripe-shaped errors to a 400", async () => {
|
||||
const app = new Hono<HonoEnv>();
|
||||
const consoleErrors: unknown[][] = [];
|
||||
const originalConsoleError = console.error;
|
||||
console.error = (...args: unknown[]) => {
|
||||
consoleErrors.push(args);
|
||||
};
|
||||
|
||||
app.use("*", async (c, next) => {
|
||||
c.set("ctx", {
|
||||
env: "sandbox",
|
||||
logger: {
|
||||
error: () => {},
|
||||
warn: () => {},
|
||||
},
|
||||
} as never);
|
||||
await next();
|
||||
});
|
||||
|
||||
app.post("/v1/rewards", () => {
|
||||
const error = new Error("No such product: prod_missing") as Error & {
|
||||
type: string;
|
||||
statusCode: number;
|
||||
code: string;
|
||||
};
|
||||
error.type = "StripeInvalidRequestError";
|
||||
error.statusCode = 400;
|
||||
error.code = "resource_missing";
|
||||
throw error;
|
||||
});
|
||||
app.onError(errorMiddleware);
|
||||
|
||||
try {
|
||||
const response = await app.fetch(
|
||||
new Request("https://autumn-api.bowong.cc/v1/rewards", {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
message: "(Stripe Error) No such product: prod_missing",
|
||||
code: ErrCode.StripeError,
|
||||
env: "sandbox",
|
||||
});
|
||||
expect(consoleErrors).toEqual([]);
|
||||
} finally {
|
||||
console.error = originalConsoleError;
|
||||
}
|
||||
});
|
||||
|
||||
31
server/tests/unit/auth/autumn-auth-route-alias.test.ts
Normal file
31
server/tests/unit/auth/autumn-auth-route-alias.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { rewriteAutumnAuthRequest } from "@/initHono.js";
|
||||
|
||||
test("legacy Autumn SDK route rewrites to the better-auth Autumn endpoint", async () => {
|
||||
const request = new Request(
|
||||
"https://autumn-api.bowong.cc/api/autumn/getOrCreateCustomer?expand=balances",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Cookie: "__Secure-better-auth.session_token=session-token",
|
||||
Origin: "http://192.168.0.15:3000",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ errorOnNotFound: false }),
|
||||
},
|
||||
);
|
||||
|
||||
const rewritten = rewriteAutumnAuthRequest(request);
|
||||
const rewrittenUrl = new URL(rewritten.url);
|
||||
|
||||
expect(rewrittenUrl.pathname).toBe(
|
||||
"/api/auth/autumn/getOrCreateCustomer",
|
||||
);
|
||||
expect(rewrittenUrl.search).toBe("?expand=balances");
|
||||
expect(rewritten.method).toBe("POST");
|
||||
expect(rewritten.headers.get("cookie")).toContain(
|
||||
"__Secure-better-auth.session_token=session-token",
|
||||
);
|
||||
const body = (await rewritten.json()) as { errorOnNotFound: boolean };
|
||||
expect(body).toMatchObject({ errorOnNotFound: false });
|
||||
});
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
createEmailOtpOptions,
|
||||
createEmailVerificationOptions,
|
||||
createJwtOptions,
|
||||
getAutumnBetterAuthApiUrl,
|
||||
getAuthCookieAdvancedOptions,
|
||||
getAuthTrustedOrigins,
|
||||
} from "@/utils/auth.js";
|
||||
@@ -76,6 +77,17 @@ test("production Worker auth cookies are cross-site secure even when base URL en
|
||||
});
|
||||
});
|
||||
|
||||
test("production Worker Autumn better-auth API URL falls back from stale localhost config", () => {
|
||||
expect(
|
||||
getAutumnBetterAuthApiUrl({
|
||||
NODE_ENV: "production",
|
||||
WORKER: "true",
|
||||
BETTER_AUTH_URL: "http://localhost:8080",
|
||||
SERVER_URL: "http://localhost:8080",
|
||||
} as unknown as Env),
|
||||
).toBe("https://autumn-api.bowong.cc");
|
||||
});
|
||||
|
||||
test("session responses do not mint unused JWT headers", () => {
|
||||
expect(createJwtOptions()).toEqual({
|
||||
disableSettingJwtHeader: true,
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
test("auth setup does not import the generated Autumn better-auth plugin at startup", () => {
|
||||
const source = readFileSync("src/utils/auth.ts", "utf8");
|
||||
|
||||
expect(source).not.toContain("autumn-js/better-auth");
|
||||
});
|
||||
74
server/tests/unit/router/mounted-route-prefix.test.ts
Normal file
74
server/tests/unit/router/mounted-route-prefix.test.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import {
|
||||
createHonoApp,
|
||||
isPublicDevRoute,
|
||||
rewriteMountedRequestPath,
|
||||
} from "@/initHono.js";
|
||||
|
||||
const createExecutionContext = (): ExecutionContext =>
|
||||
({
|
||||
waitUntil: () => undefined,
|
||||
passThroughOnException: () => undefined,
|
||||
props: {},
|
||||
}) as unknown as ExecutionContext;
|
||||
|
||||
test("mounted API router requests strip the /v1 prefix before reaching apiRouter", async () => {
|
||||
const request = new Request(
|
||||
"https://autumn-api.bowong.cc/v1/organization/flags?env=sandbox",
|
||||
{
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const rewritten = rewriteMountedRequestPath(request, "/v1");
|
||||
const rewrittenUrl = new URL(rewritten.url);
|
||||
|
||||
expect(rewrittenUrl.pathname).toBe("/organization/flags");
|
||||
expect(rewrittenUrl.search).toBe("?env=sandbox");
|
||||
expect(rewritten.method).toBe("GET");
|
||||
expect(rewritten.headers.get("accept")).toBe("application/json");
|
||||
});
|
||||
|
||||
test("mounted API router root requests rewrite to /", () => {
|
||||
const request = new Request("https://autumn-api.bowong.cc/v1");
|
||||
|
||||
const rewritten = rewriteMountedRequestPath(request, "/v1");
|
||||
|
||||
expect(new URL(rewritten.url).pathname).toBe("/");
|
||||
});
|
||||
|
||||
test("only unauthenticated dev routes stay on the public router", () => {
|
||||
expect(isPublicDevRoute("GET", "/dev/otp/123456")).toBe(true);
|
||||
expect(isPublicDevRoute("POST", "/dev/cli/stripe")).toBe(true);
|
||||
|
||||
expect(isPublicDevRoute("GET", "/dev/data")).toBe(false);
|
||||
expect(isPublicDevRoute("POST", "/dev/otp")).toBe(false);
|
||||
expect(isPublicDevRoute("POST", "/dev/api_key")).toBe(false);
|
||||
});
|
||||
|
||||
test("mounted internal router errors use the app error middleware", async () => {
|
||||
const env = {
|
||||
AWS_REGION: "test",
|
||||
BETTER_AUTH_SECRET: "test-secret",
|
||||
BETTER_AUTH_URL: "https://autumn-api.bowong.cc",
|
||||
CLIENT_URL: "https://app.bowong.cc",
|
||||
DATABASE_URL: "postgres://autumn:autumn@127.0.0.1:5432/autumn",
|
||||
NODE_ENV: "test",
|
||||
} as Env;
|
||||
const app = createHonoApp(env);
|
||||
|
||||
const response = await app.fetch(
|
||||
new Request("https://autumn-api.bowong.cc/query/event_names/list"),
|
||||
env,
|
||||
createExecutionContext(),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
message: "Unauthorized - no session found",
|
||||
code: "no_auth_header",
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { expect, test } from "bun:test";
|
||||
import { getTinybirdPipes } from "@/external/tinybird/initTinybird.js";
|
||||
import { initTinybirdConfig } from "@/external/tinybird/tinybirdUtils.js";
|
||||
import {
|
||||
initTinybirdConfig,
|
||||
tinybirdConfig,
|
||||
} from "@/external/tinybird/tinybirdUtils.js";
|
||||
|
||||
test("Tinybird pipes initialize after Worker env config arrives", () => {
|
||||
initTinybirdConfig({
|
||||
@@ -10,3 +13,17 @@ test("Tinybird pipes initialize after Worker env config arrives", () => {
|
||||
|
||||
expect(() => getTinybirdPipes()).not.toThrow();
|
||||
});
|
||||
|
||||
test("Tinybird config prefers the new us-east workspace when both envs exist", () => {
|
||||
initTinybirdConfig({
|
||||
TINYBIRD_API_URL: "https://api.tinybird.co",
|
||||
TINYBIRD_TOKEN: "legacy-token",
|
||||
TINYBIRD_US_EAST_API_URL: "https://api.us-east.aws.tinybird.co",
|
||||
TINYBIRD_US_EAST_TOKEN: "new-token",
|
||||
} as Env);
|
||||
|
||||
expect(tinybirdConfig).toEqual({
|
||||
baseUrl: "https://api.us-east.aws.tinybird.co",
|
||||
token: "new-token",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -50,6 +50,9 @@
|
||||
"autumn-js": [
|
||||
"../packages/autumn-js/src/sdk/index.ts"
|
||||
],
|
||||
"autumn-js/better-auth": [
|
||||
"../packages/autumn-js/src/better-auth/index.ts"
|
||||
],
|
||||
"autumn-js/backend/hono": [
|
||||
"../packages/autumn-js/src/backend/adapters/hono.ts"
|
||||
],
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,2 +1,3 @@
|
||||
VITE_FRONTEND_URL=https://autumn-api.bowong.cc
|
||||
VITE_BACKEND_URL=https://autumn-api.bowong.cc
|
||||
AUTUMN_API_KEY=sk_test_51SBUgtGv9HLu1VZC7T2KRIfwcmcxlUmodws3pddPIUDtgOI5998PTHenfEOkzXBuk88lFKGP1ZxPIya7GZgDAjBN00vLLZoZTW
|
||||
@@ -33,6 +33,7 @@ export function OnboardingLayout() {
|
||||
"$1",
|
||||
)}
|
||||
includeCredentials={true}
|
||||
useBetterAuth={true}
|
||||
>
|
||||
<div className="w-screen h-screen flex items-center justify-center bg-background">
|
||||
<LoadingScreen />
|
||||
@@ -54,6 +55,7 @@ export function OnboardingLayout() {
|
||||
"$1",
|
||||
)}
|
||||
includeCredentials={true}
|
||||
useBetterAuth={true}
|
||||
>
|
||||
<NuqsAdapter>
|
||||
<main className="w-screen h-screen bg-background">
|
||||
|
||||
@@ -73,6 +73,7 @@ export function MainLayout() {
|
||||
<AutumnProvider
|
||||
backendUrl={import.meta.env.VITE_BACKEND_URL}
|
||||
includeCredentials={true}
|
||||
useBetterAuth={true}
|
||||
>
|
||||
<div className="w-screen h-screen flex bg-outer-background">
|
||||
<div className="hidden sm:flex">
|
||||
@@ -100,6 +101,7 @@ export function MainLayout() {
|
||||
backendUrl={import.meta.env.VITE_BACKEND_URL}
|
||||
// backendUrl="http://localhost:8080"
|
||||
includeCredentials={true}
|
||||
useBetterAuth={true}
|
||||
>
|
||||
<NuqsAdapter>
|
||||
<PortalContainerContext.Provider value={containerRef}>
|
||||
|
||||
Reference in New Issue
Block a user