fix: trace stripe webhooks on cloudflare
This commit is contained in:
@@ -20,6 +20,7 @@ import type {
|
||||
StripeWebhookContext,
|
||||
StripeWebhookHonoEnv,
|
||||
} from "./webhookMiddlewares/stripeWebhookContext.js";
|
||||
import { recordStripeWebhookTrace } from "./webhookTrace/recordStripeWebhookTrace.js";
|
||||
|
||||
/**
|
||||
* Hono handler for Stripe webhook events
|
||||
@@ -33,6 +34,19 @@ export const handleStripeWebhookEvent = async (
|
||||
const event = stripeEvent;
|
||||
|
||||
try {
|
||||
await recordStripeWebhookTrace({
|
||||
ctx,
|
||||
stage: "handler_start",
|
||||
});
|
||||
logger.info("stripe_webhook_trace", {
|
||||
stage: "handler_start",
|
||||
event_id: event.id,
|
||||
event_type: event.type,
|
||||
object_id: (event.data.object as { id?: string }).id,
|
||||
metadata_id: (event.data.object as { metadata?: Record<string, string> })
|
||||
.metadata?.autumn_metadata_id,
|
||||
});
|
||||
|
||||
switch (event.type) {
|
||||
case "customer.updated":
|
||||
await handleStripeCustomerUpdated({ ctx, event });
|
||||
@@ -95,7 +109,33 @@ export const handleStripeWebhookEvent = async (
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await recordStripeWebhookTrace({
|
||||
ctx,
|
||||
stage: "handler_success",
|
||||
});
|
||||
logger.info("stripe_webhook_trace", {
|
||||
stage: "handler_success",
|
||||
event_id: event.id,
|
||||
event_type: event.type,
|
||||
});
|
||||
} catch (error) {
|
||||
await recordStripeWebhookTrace({
|
||||
ctx,
|
||||
stage: "handler_error",
|
||||
data: {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
});
|
||||
logger.error("stripe_webhook_trace", {
|
||||
stage: "handler_error",
|
||||
event_id: event.id,
|
||||
event_type: event.type,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
|
||||
captureException(error, {
|
||||
tags: getSentryTags({
|
||||
ctx,
|
||||
|
||||
@@ -9,7 +9,6 @@ import { stripeLegacySeederMiddleware } from "./webhookMiddlewares/stripeLegacyS
|
||||
import { stripeSyncMiddleware } from "./webhookMiddlewares/stripeSyncMiddleware.js";
|
||||
import { stripeToAutumnCustomerMiddleware } from "./webhookMiddlewares/stripeToAutumnCustomerMiddleware.js";
|
||||
import type { StripeWebhookHonoEnv } from "./webhookMiddlewares/stripeWebhookContext.js";
|
||||
import { stripeWebhookEarlyAckMiddleware } from "./webhookMiddlewares/stripeWebhookEarlyAckMiddleware.js";
|
||||
import { stripeWebhookRefreshMiddleware } from "./webhookMiddlewares/stripeWebhookRefreshMiddleware.js";
|
||||
|
||||
export const createStripeWebhookRouter = () => {
|
||||
@@ -28,7 +27,6 @@ export const createStripeWebhookRouter = () => {
|
||||
stripeLegacySeederMiddleware,
|
||||
stripeToAutumnCustomerMiddleware,
|
||||
stripeIdempotencyMiddleware,
|
||||
stripeWebhookEarlyAckMiddleware,
|
||||
stripeWebhookRefreshMiddleware,
|
||||
stripeSyncMiddleware,
|
||||
stripeLoggerMiddleware,
|
||||
@@ -42,7 +40,6 @@ export const createStripeWebhookRouter = () => {
|
||||
stripeConnectSeederMiddleware,
|
||||
stripeToAutumnCustomerMiddleware,
|
||||
stripeIdempotencyMiddleware,
|
||||
stripeWebhookEarlyAckMiddleware,
|
||||
stripeWebhookRefreshMiddleware,
|
||||
stripeSyncMiddleware,
|
||||
stripeLoggerMiddleware,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
getExpandedStripeSubscription,
|
||||
} from "@/external/stripe/subscriptions/operations/getExpandedStripeSubscription";
|
||||
import { getMetadataFromCheckoutSession } from "@/internal/metadata/metadataUtils.js";
|
||||
import { recordStripeWebhookTrace } from "../../webhookTrace/recordStripeWebhookTrace.js";
|
||||
import type { StripeWebhookContext } from "../../webhookMiddlewares/stripeWebhookContext.js";
|
||||
|
||||
type CheckoutSessionExpansions = ["line_items"];
|
||||
@@ -63,6 +64,38 @@ export const setupCheckoutSessionCompletedContext = async ({
|
||||
: undefined,
|
||||
]);
|
||||
|
||||
await recordStripeWebhookTrace({
|
||||
ctx,
|
||||
stage: "checkout_context_ready",
|
||||
data: {
|
||||
checkout_session_id: stripeCheckoutSession.id,
|
||||
stripe_customer_id:
|
||||
typeof stripeCheckoutSession.customer === "string"
|
||||
? stripeCheckoutSession.customer
|
||||
: stripeCheckoutSession.customer?.id,
|
||||
stripe_subscription_id: subscriptionId,
|
||||
stripe_invoice_id: invoiceId,
|
||||
metadata_id: stripeCheckoutSession.metadata?.autumn_metadata_id,
|
||||
metadata_found: !!metadata,
|
||||
metadata_type: metadata?.type,
|
||||
},
|
||||
});
|
||||
ctx.logger.info("stripe_webhook_trace", {
|
||||
stage: "checkout_context_ready",
|
||||
event_id: event.id,
|
||||
event_type: event.type,
|
||||
checkout_session_id: stripeCheckoutSession.id,
|
||||
stripe_customer_id:
|
||||
typeof stripeCheckoutSession.customer === "string"
|
||||
? stripeCheckoutSession.customer
|
||||
: stripeCheckoutSession.customer?.id,
|
||||
stripe_subscription_id: subscriptionId,
|
||||
stripe_invoice_id: invoiceId,
|
||||
metadata_id: stripeCheckoutSession.metadata?.autumn_metadata_id,
|
||||
metadata_found: !!metadata,
|
||||
metadata_type: metadata?.type,
|
||||
});
|
||||
|
||||
return {
|
||||
stripeCheckoutSession,
|
||||
stripeSubscription,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
MetadataType,
|
||||
} from "@autumn/shared";
|
||||
import type { CheckoutSessionCompletedContext } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/setupCheckoutSessionCompletedContext";
|
||||
import { recordStripeWebhookTrace } from "@/external/stripe/webhookTrace/recordStripeWebhookTrace";
|
||||
import { createStripeScheduleFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionEnabledImmediately/createStripeScheduleFromCheckout";
|
||||
import { modifyStripeSubscriptionFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/modifyStripeSubscriptionFromCheckout";
|
||||
import { syncSubscriptionItemMetadataFromCheckout } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/tasks/handleCheckoutSessionMetadataV2/syncSubscriptionItemMetadataFromCheckout";
|
||||
@@ -28,11 +29,45 @@ export const handleCheckoutSessionMetadataV2 = async ({
|
||||
}): Promise<void> => {
|
||||
const { metadata } = checkoutContext;
|
||||
|
||||
if (metadata?.type !== MetadataType.CheckoutSessionV2) return;
|
||||
if (metadata?.type !== MetadataType.CheckoutSessionV2) {
|
||||
await recordStripeWebhookTrace({
|
||||
ctx,
|
||||
stage: "checkout_v2_skip",
|
||||
data: {
|
||||
metadata_id: metadata?.id,
|
||||
metadata_type: metadata?.type,
|
||||
expected_type: MetadataType.CheckoutSessionV2,
|
||||
checkout_session_id: checkoutContext.stripeCheckoutSession.id,
|
||||
},
|
||||
});
|
||||
ctx.logger.info("stripe_webhook_trace", {
|
||||
stage: "checkout_v2_skip",
|
||||
metadata_id: metadata?.id,
|
||||
metadata_type: metadata?.type,
|
||||
expected_type: MetadataType.CheckoutSessionV2,
|
||||
checkout_session_id: checkoutContext.stripeCheckoutSession.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
ctx.logger.info(
|
||||
`[checkout.completed] Handling checkout session metadata V2: ${metadata.id}`,
|
||||
);
|
||||
await recordStripeWebhookTrace({
|
||||
ctx,
|
||||
stage: "checkout_v2_start",
|
||||
data: {
|
||||
metadata_id: metadata.id,
|
||||
metadata_type: metadata.type,
|
||||
checkout_session_id: checkoutContext.stripeCheckoutSession.id,
|
||||
},
|
||||
});
|
||||
ctx.logger.info("stripe_webhook_trace", {
|
||||
stage: "checkout_v2_start",
|
||||
metadata_id: metadata.id,
|
||||
metadata_type: metadata.type,
|
||||
checkout_session_id: checkoutContext.stripeCheckoutSession.id,
|
||||
});
|
||||
|
||||
await withClaimedCheckoutSessionMetadata({
|
||||
ctx,
|
||||
@@ -130,6 +165,19 @@ const executeCheckoutSessionMetadataV2 = async ({
|
||||
|
||||
// Delete metadata after successful execution
|
||||
await MetadataService.delete({ db: ctx.db, id: metadata.id });
|
||||
await recordStripeWebhookTrace({
|
||||
ctx,
|
||||
stage: "checkout_v2_metadata_deleted",
|
||||
data: {
|
||||
metadata_id: metadata.id,
|
||||
checkout_session_id: checkoutContext.stripeCheckoutSession.id,
|
||||
},
|
||||
});
|
||||
ctx.logger.info("stripe_webhook_trace", {
|
||||
stage: "checkout_v2_metadata_deleted",
|
||||
metadata_id: metadata.id,
|
||||
checkout_session_id: checkoutContext.stripeCheckoutSession.id,
|
||||
});
|
||||
|
||||
const newCustomerProducts =
|
||||
updatedDeferredData.billingPlan.autumn.insertCustomerProducts;
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { setStripeSubscriptionLock } from "@/external/stripe/subscriptions/utils/lockStripeSubscriptionUtils";
|
||||
import type { CheckoutSessionCompletedContext } from "@/external/stripe/webhookHandlers/handleStripeCheckoutSessionCompleted/setupCheckoutSessionCompletedContext";
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
import { recordStripeWebhookTrace } from "@/external/stripe/webhookTrace/recordStripeWebhookTrace";
|
||||
import { checkoutSessionLock } from "@/internal/billing/v2/actions/locks/checkoutSessionLock/checkoutSessionLock";
|
||||
import { MetadataService } from "@/internal/metadata/MetadataService";
|
||||
|
||||
@@ -36,8 +37,40 @@ export const withClaimedCheckoutSessionMetadata = async ({
|
||||
ctx.logger.info(
|
||||
`[checkout.completed] Metadata ${metadata.id} already claimed by another executor, skipping`,
|
||||
);
|
||||
await recordStripeWebhookTrace({
|
||||
ctx,
|
||||
stage: "checkout_v2_claim_failed",
|
||||
data: {
|
||||
metadata_id: metadata.id,
|
||||
metadata_type: metadata.type,
|
||||
checkout_session_id: checkoutContext.stripeCheckoutSession.id,
|
||||
},
|
||||
});
|
||||
ctx.logger.info("stripe_webhook_trace", {
|
||||
stage: "checkout_v2_claim_failed",
|
||||
metadata_id: metadata.id,
|
||||
metadata_type: metadata.type,
|
||||
checkout_session_id: checkoutContext.stripeCheckoutSession.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await recordStripeWebhookTrace({
|
||||
ctx,
|
||||
stage: "checkout_v2_claimed",
|
||||
data: {
|
||||
metadata_id: metadata.id,
|
||||
metadata_type: metadata.type,
|
||||
checkout_session_id: checkoutContext.stripeCheckoutSession.id,
|
||||
stripe_subscription_id: checkoutContext.stripeSubscription?.id,
|
||||
},
|
||||
});
|
||||
ctx.logger.info("stripe_webhook_trace", {
|
||||
stage: "checkout_v2_claimed",
|
||||
metadata_id: metadata.id,
|
||||
metadata_type: metadata.type,
|
||||
checkout_session_id: checkoutContext.stripeCheckoutSession.id,
|
||||
stripe_subscription_id: checkoutContext.stripeSubscription?.id,
|
||||
});
|
||||
|
||||
const deferredData = metadata.data as DeferredAutumnBillingPlanData;
|
||||
const lockCustomerId =
|
||||
@@ -55,6 +88,23 @@ export const withClaimedCheckoutSessionMetadata = async ({
|
||||
|
||||
await execute();
|
||||
} catch (error) {
|
||||
await recordStripeWebhookTrace({
|
||||
ctx,
|
||||
stage: "checkout_v2_execute_error",
|
||||
data: {
|
||||
metadata_id: metadata.id,
|
||||
checkout_session_id: checkoutContext.stripeCheckoutSession.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
},
|
||||
});
|
||||
ctx.logger.error("stripe_webhook_trace", {
|
||||
stage: "checkout_v2_execute_error",
|
||||
metadata_id: metadata.id,
|
||||
checkout_session_id: checkoutContext.stripeCheckoutSession.id,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
stack: error instanceof Error ? error.stack : undefined,
|
||||
});
|
||||
await revertMetadataClaim({ ctx, metadataId: metadata.id });
|
||||
throw error;
|
||||
} finally {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Context, Next } from "hono";
|
||||
import { resolveRequestIdempotencyStore } from "@/internal/misc/idempotency/resolveRequestIdempotencyStore.js";
|
||||
import { recordStripeWebhookTrace } from "../webhookTrace/recordStripeWebhookTrace.js";
|
||||
import type { StripeWebhookHonoEnv } from "./stripeWebhookContext";
|
||||
|
||||
const IDEMPOTENCY_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
@@ -34,6 +35,24 @@ export const stripeIdempotencyMiddleware = async (
|
||||
key: idempotencyKey,
|
||||
ttlMs: IDEMPOTENCY_TTL_MS,
|
||||
});
|
||||
await recordStripeWebhookTrace({
|
||||
ctx,
|
||||
stage: "idempotency_acquire",
|
||||
data: {
|
||||
result,
|
||||
idempotency_key: idempotencyKey,
|
||||
},
|
||||
});
|
||||
ctx.logger.info("stripe_webhook_trace", {
|
||||
stage: "idempotency_acquire",
|
||||
event_id: stripeEvent.id,
|
||||
event_type: stripeEvent.type,
|
||||
result,
|
||||
idempotency_key: idempotencyKey,
|
||||
metadata_id: (
|
||||
stripeEvent.data.object as { metadata?: Record<string, string> }
|
||||
).metadata?.autumn_metadata_id,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.logger.warn(
|
||||
`[stripeIdempotencyMiddleware] Store error, allowing through: ${error}`,
|
||||
@@ -51,8 +70,21 @@ export const stripeIdempotencyMiddleware = async (
|
||||
}
|
||||
|
||||
if (result === "duplicate") {
|
||||
await recordStripeWebhookTrace({
|
||||
ctx,
|
||||
stage: "idempotency_duplicate",
|
||||
data: {
|
||||
idempotency_key: idempotencyKey,
|
||||
},
|
||||
});
|
||||
ctx.logger.info(
|
||||
`[stripeIdempotencyMiddleware] Duplicate webhook event detected, skipping: ${stripeEvent.id}`,
|
||||
{
|
||||
stage: "idempotency_duplicate",
|
||||
event_id: stripeEvent.id,
|
||||
event_type: stripeEvent.type,
|
||||
idempotency_key: idempotencyKey,
|
||||
},
|
||||
);
|
||||
return c.json({ received: true, duplicate: true }, 200);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
StripeWebhookContext,
|
||||
StripeWebhookHonoEnv,
|
||||
} from "./stripeWebhookContext.js";
|
||||
import { recordStripeWebhookTrace } from "../webhookTrace/recordStripeWebhookTrace.js";
|
||||
|
||||
/**
|
||||
* Seeder middleware for legacy Stripe webhooks (orgs that pasted their secret keys)
|
||||
@@ -43,6 +44,11 @@ export const stripeLegacySeederMiddleware = async (
|
||||
}
|
||||
|
||||
const { org, features } = data;
|
||||
ctx.org = org;
|
||||
ctx.features = features;
|
||||
ctx.env = appEnv;
|
||||
ctx.workerEnv = workerEnv;
|
||||
ctx.authType = AuthType.Stripe;
|
||||
|
||||
// Step 2: Check if org is connected to Stripe
|
||||
if (!isStripeConnected({ org, env: appEnv })) {
|
||||
@@ -56,6 +62,30 @@ export const stripeLegacySeederMiddleware = async (
|
||||
// Step 3: Verify webhook signature
|
||||
const rawBody = await c.req.text();
|
||||
const signature = c.req.header("stripe-signature") || "";
|
||||
const unverifiedEvent = (() => {
|
||||
try {
|
||||
return JSON.parse(rawBody) as Stripe.Event;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
})();
|
||||
await recordStripeWebhookTrace({
|
||||
ctx,
|
||||
stage: "legacy_received",
|
||||
data: {
|
||||
event_id: unverifiedEvent?.id,
|
||||
event_type: unverifiedEvent?.type,
|
||||
object_id: unverifiedEvent
|
||||
? (unverifiedEvent.data.object as { id?: string }).id
|
||||
: undefined,
|
||||
metadata_id: unverifiedEvent
|
||||
? (unverifiedEvent.data.object as { metadata?: Record<string, string> })
|
||||
.metadata?.autumn_metadata_id
|
||||
: undefined,
|
||||
raw_body_length: rawBody.length,
|
||||
has_signature: !!signature,
|
||||
},
|
||||
});
|
||||
|
||||
const skipVerify =
|
||||
workerEnv.STRIPE_WEBHOOK_SKIP_VERIFY === "true" &&
|
||||
@@ -70,6 +100,17 @@ export const stripeLegacySeederMiddleware = async (
|
||||
event = JSON.parse(rawBody) as Stripe.Event;
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await recordStripeWebhookTrace({
|
||||
ctx,
|
||||
stage: "legacy_parse_failed",
|
||||
data: {
|
||||
event_id: unverifiedEvent?.id,
|
||||
event_type: unverifiedEvent?.type,
|
||||
error: message,
|
||||
raw_body_length: rawBody.length,
|
||||
has_signature: !!signature,
|
||||
},
|
||||
});
|
||||
logger.warn(
|
||||
`Stripe legacy webhook body parse error (skip-verify): ${message}`,
|
||||
);
|
||||
@@ -85,6 +126,27 @@ export const stripeLegacySeederMiddleware = async (
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
await recordStripeWebhookTrace({
|
||||
ctx,
|
||||
stage: "legacy_signature_failed",
|
||||
data: {
|
||||
event_id: unverifiedEvent?.id,
|
||||
event_type: unverifiedEvent?.type,
|
||||
object_id: unverifiedEvent
|
||||
? (unverifiedEvent.data.object as { id?: string }).id
|
||||
: undefined,
|
||||
metadata_id: unverifiedEvent
|
||||
? (
|
||||
unverifiedEvent.data.object as {
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
).metadata?.autumn_metadata_id
|
||||
: undefined,
|
||||
error: message,
|
||||
raw_body_length: rawBody.length,
|
||||
has_signature: !!signature,
|
||||
},
|
||||
});
|
||||
logger.warn(
|
||||
`Stripe legacy webhook signature verification failed: ${message}`,
|
||||
);
|
||||
@@ -93,13 +155,29 @@ export const stripeLegacySeederMiddleware = async (
|
||||
}
|
||||
|
||||
// Step 4: Set up context
|
||||
ctx.org = org;
|
||||
ctx.features = features;
|
||||
ctx.env = appEnv;
|
||||
ctx.workerEnv = workerEnv;
|
||||
ctx.authType = AuthType.Stripe;
|
||||
ctx.stripeEvent = event;
|
||||
ctx.stripeCli = createStripeCli({ org, env: appEnv, workerEnv });
|
||||
|
||||
await recordStripeWebhookTrace({
|
||||
ctx,
|
||||
stage: "legacy_seeded",
|
||||
data: {
|
||||
object_id: (event.data.object as { id?: string }).id,
|
||||
metadata_id: (event.data.object as { metadata?: Record<string, string> })
|
||||
.metadata?.autumn_metadata_id,
|
||||
},
|
||||
});
|
||||
logger.info("stripe_webhook_trace", {
|
||||
stage: "legacy_seeded",
|
||||
event_id: event.id,
|
||||
event_type: event.type,
|
||||
org_id: org.id,
|
||||
org_slug: org.slug,
|
||||
env: appEnv,
|
||||
object_id: (event.data.object as { id?: string }).id,
|
||||
metadata_id: (event.data.object as { metadata?: Record<string, string> })
|
||||
.metadata?.autumn_metadata_id,
|
||||
});
|
||||
|
||||
await next();
|
||||
};
|
||||
|
||||
81
server/src/external/stripe/webhookTrace/recordStripeWebhookTrace.ts
vendored
Normal file
81
server/src/external/stripe/webhookTrace/recordStripeWebhookTrace.ts
vendored
Normal file
@@ -0,0 +1,81 @@
|
||||
import type { StripeWebhookContext } from "@/external/stripe/webhookMiddlewares/stripeWebhookContext";
|
||||
|
||||
const TRACE_TTL_SECONDS = 60 * 60;
|
||||
const MAX_TRACE_ENTRIES = 100;
|
||||
|
||||
export type StripeWebhookTraceEntry = {
|
||||
stage: string;
|
||||
event_id?: string;
|
||||
event_type?: string;
|
||||
object_id?: string;
|
||||
metadata_id?: string;
|
||||
org_id?: string;
|
||||
org_slug?: string;
|
||||
env?: string;
|
||||
created_at: number;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export const getStripeWebhookTraceKey = (eventId: string) =>
|
||||
`stripe_webhook_trace:${eventId}`;
|
||||
|
||||
export const recordStripeWebhookTrace = async ({
|
||||
ctx,
|
||||
stage,
|
||||
data = {},
|
||||
}: {
|
||||
ctx: StripeWebhookContext;
|
||||
stage: string;
|
||||
data?: Record<string, unknown>;
|
||||
}) => {
|
||||
const event = ctx.stripeEvent;
|
||||
|
||||
try {
|
||||
const traceKv = ctx.workerEnv?.STRIPE_WEBHOOK_TRACE_KV;
|
||||
if (!traceKv) {
|
||||
ctx.logger.warn("stripe_webhook_trace_kv_missing", { stage });
|
||||
return;
|
||||
}
|
||||
|
||||
const dataEventId = data.event_id as string | undefined;
|
||||
const dataEventType = data.event_type as string | undefined;
|
||||
const dataObjectId = data.object_id as string | undefined;
|
||||
const dataMetadataId = data.metadata_id as string | undefined;
|
||||
|
||||
const eventId = event?.id ?? dataEventId;
|
||||
if (!eventId) {
|
||||
ctx.logger.warn("stripe_webhook_trace_event_id_missing", { stage });
|
||||
return;
|
||||
}
|
||||
|
||||
const entry: StripeWebhookTraceEntry = {
|
||||
stage,
|
||||
event_id: eventId,
|
||||
event_type: event?.type ?? dataEventType,
|
||||
object_id: event ? (event.data.object as { id?: string }).id : dataObjectId,
|
||||
metadata_id: event
|
||||
? (event.data.object as { metadata?: Record<string, string> }).metadata
|
||||
?.autumn_metadata_id
|
||||
: dataMetadataId,
|
||||
org_id: ctx.org?.id,
|
||||
org_slug: ctx.org?.slug,
|
||||
env: ctx.env,
|
||||
created_at: Date.now(),
|
||||
data,
|
||||
};
|
||||
|
||||
const key = getStripeWebhookTraceKey(eventId);
|
||||
const existing =
|
||||
(await traceKv.get<StripeWebhookTraceEntry[]>(key, "json")) ?? [];
|
||||
const entries = [...existing, entry].slice(-MAX_TRACE_ENTRIES);
|
||||
|
||||
await traceKv.put(key, JSON.stringify(entries), {
|
||||
expirationTtl: TRACE_TTL_SECONDS,
|
||||
});
|
||||
} catch (error) {
|
||||
ctx.logger.warn("stripe_webhook_trace_persist_failed", {
|
||||
stage,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -36,6 +36,17 @@ const redactSensitiveRequestBody = ({ body }: { body: unknown }): unknown => {
|
||||
);
|
||||
};
|
||||
|
||||
const shouldSkipRequestBodyParse = ({
|
||||
method,
|
||||
path,
|
||||
}: {
|
||||
method: string;
|
||||
path: string;
|
||||
}) => {
|
||||
if (method === "GET" || method === "HEAD") return true;
|
||||
return path.startsWith("/webhooks/stripe/") || path.startsWith("/webhooks/connect/");
|
||||
};
|
||||
|
||||
/**
|
||||
* Base middleware that sets up the request context
|
||||
* Sets up: db, logger, id, timestamp
|
||||
@@ -52,10 +63,12 @@ export const baseMiddleware = async (c: Context<HonoEnv>, next: Next) => {
|
||||
|
||||
const timestamp = Date.now();
|
||||
|
||||
const { data: body } =
|
||||
c.req.method !== "GET" && c.req.method !== "HEAD"
|
||||
? await tryCatch(c.req.json())
|
||||
: { data: undefined };
|
||||
const { data: body } = shouldSkipRequestBodyParse({
|
||||
method: c.req.method,
|
||||
path: c.req.path,
|
||||
})
|
||||
? { data: undefined }
|
||||
: await tryCatch(c.req.json());
|
||||
|
||||
const customerId = resolveCustomerId({
|
||||
method: c.req.method,
|
||||
|
||||
@@ -3,6 +3,10 @@ import { createRequestScopedDb } from "./db/initDrizzle.js";
|
||||
import { initTinybirdClient } from "./external/tinybird/initTinybird.js";
|
||||
import { initTinybirdConfig } from "./external/tinybird/tinybirdUtils.js";
|
||||
import { createCacheStore } from "./external/storage/cache/createCacheStore.js";
|
||||
import {
|
||||
getStripeWebhookTraceKey,
|
||||
type StripeWebhookTraceEntry,
|
||||
} from "./external/stripe/webhookTrace/recordStripeWebhookTrace.js";
|
||||
import { getRequestCacheStoreResolution } from "./external/storage/cache/resolveRequestCacheStore.js";
|
||||
import { setAllEdgeConfigEnvs } from "./internal/misc/edgeConfig/edgeConfigRegistry.js";
|
||||
import { createHonoApp } from "./initHono.js";
|
||||
@@ -22,6 +26,13 @@ const initWorkerRuntimeModules = (env: Env) => {
|
||||
|
||||
const SNAPSHOT_CACHE_DIAGNOSTIC_SUFFIX = "/snapshot-cache";
|
||||
const STARTUP_DIAGNOSTIC_SUFFIX = "/startup";
|
||||
const STRIPE_WEBHOOK_TRACE_KV_DIAGNOSTIC_SUFFIX = "/stripe-webhook-trace-kv";
|
||||
const STRIPE_WEBHOOK_INGRESS_DIAGNOSTIC_SUFFIX = "/stripe-webhook-ingress";
|
||||
const STRIPE_WEBHOOK_TRACE_TTL_SECONDS = 60 * 60;
|
||||
const STRIPE_WEBHOOK_TRACE_MAX_ENTRIES = 100;
|
||||
const STRIPE_WEBHOOK_LAST_INGRESS_TRACE_KEY =
|
||||
"stripe_webhook_trace:__last_ingress";
|
||||
const STRIPE_WEBHOOK_LAST_POST_TRACE_KEY = "stripe_webhook_trace:__last_post";
|
||||
|
||||
const constantTimeEqual = (left: string, right: string): boolean => {
|
||||
const maxLength = Math.max(left.length, right.length);
|
||||
@@ -48,6 +59,24 @@ const getStartupDiagnosticToken = (request: Request): string | null => {
|
||||
});
|
||||
};
|
||||
|
||||
const getStripeWebhookTraceKvDiagnosticToken = (
|
||||
request: Request,
|
||||
): string | null => {
|
||||
return getReadyDiagnosticToken({
|
||||
request,
|
||||
suffix: STRIPE_WEBHOOK_TRACE_KV_DIAGNOSTIC_SUFFIX,
|
||||
});
|
||||
};
|
||||
|
||||
const getStripeWebhookIngressDiagnosticToken = (
|
||||
request: Request,
|
||||
): string | null => {
|
||||
return getReadyDiagnosticToken({
|
||||
request,
|
||||
suffix: STRIPE_WEBHOOK_INGRESS_DIAGNOSTIC_SUFFIX,
|
||||
});
|
||||
};
|
||||
|
||||
const getReadyDiagnosticToken = ({
|
||||
request,
|
||||
suffix,
|
||||
@@ -79,6 +108,151 @@ const isAuthorizedReadyDiagnostic = ({
|
||||
return !!expectedToken && constantTimeEqual(expectedToken, providedToken);
|
||||
};
|
||||
|
||||
const isStripeWebhookIngressRequest = (request: Request): boolean => {
|
||||
if (request.method !== "POST") return false;
|
||||
const { pathname } = new URL(request.url);
|
||||
return (
|
||||
pathname.startsWith("/webhooks/stripe/") ||
|
||||
pathname.startsWith("/webhooks/connect/")
|
||||
);
|
||||
};
|
||||
|
||||
const getWebhookTraceObject = (event: unknown) => {
|
||||
if (!event || typeof event !== "object") return undefined;
|
||||
const data = (event as { data?: unknown }).data;
|
||||
if (!data || typeof data !== "object") return undefined;
|
||||
const object = (data as { object?: unknown }).object;
|
||||
return object && typeof object === "object"
|
||||
? (object as {
|
||||
id?: string;
|
||||
metadata?: Record<string, string>;
|
||||
})
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const appendStripeWebhookIngressTrace = async ({
|
||||
kv,
|
||||
key,
|
||||
entry,
|
||||
}: {
|
||||
kv: KVNamespace;
|
||||
key: string;
|
||||
entry: StripeWebhookTraceEntry;
|
||||
}) => {
|
||||
const existing = (await kv.get<StripeWebhookTraceEntry[]>(key, "json")) ?? [];
|
||||
const entries = [...existing, entry].slice(-STRIPE_WEBHOOK_TRACE_MAX_ENTRIES);
|
||||
await kv.put(key, JSON.stringify(entries), {
|
||||
expirationTtl: STRIPE_WEBHOOK_TRACE_TTL_SECONDS,
|
||||
});
|
||||
};
|
||||
|
||||
const recordStripeWebhookIngressTrace = async (
|
||||
request: Request,
|
||||
env: Env,
|
||||
) => {
|
||||
if (!isStripeWebhookIngressRequest(request)) return;
|
||||
|
||||
const traceKv = env.STRIPE_WEBHOOK_TRACE_KV;
|
||||
const url = new URL(request.url);
|
||||
const receivedAt = Date.now();
|
||||
const baseData = {
|
||||
method: request.method,
|
||||
pathname: url.pathname,
|
||||
has_signature: Boolean(request.headers.get("stripe-signature")),
|
||||
content_length: request.headers.get("content-length"),
|
||||
cf_ray: request.headers.get("cf-ray"),
|
||||
user_agent: request.headers.get("user-agent"),
|
||||
};
|
||||
|
||||
try {
|
||||
if (traceKv) {
|
||||
await appendStripeWebhookIngressTrace({
|
||||
kv: traceKv,
|
||||
key: STRIPE_WEBHOOK_LAST_INGRESS_TRACE_KEY,
|
||||
entry: {
|
||||
stage: "worker_ingress_matched",
|
||||
created_at: receivedAt,
|
||||
data: baseData,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const rawBody = await request.clone().text();
|
||||
const event = JSON.parse(rawBody) as {
|
||||
id?: string;
|
||||
type?: string;
|
||||
data?: unknown;
|
||||
};
|
||||
const object = getWebhookTraceObject(event);
|
||||
const eventId = event.id;
|
||||
const entry: StripeWebhookTraceEntry = {
|
||||
stage: "worker_received",
|
||||
event_id: eventId,
|
||||
event_type: event.type,
|
||||
object_id: object?.id,
|
||||
metadata_id: object?.metadata?.autumn_metadata_id,
|
||||
created_at: receivedAt,
|
||||
data: {
|
||||
...baseData,
|
||||
raw_body_length: rawBody.length,
|
||||
},
|
||||
};
|
||||
|
||||
console.log("stripe_webhook_ingress_trace", entry);
|
||||
|
||||
if (traceKv && eventId) {
|
||||
await appendStripeWebhookIngressTrace({
|
||||
kv: traceKv,
|
||||
key: getStripeWebhookTraceKey(eventId),
|
||||
entry,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
const entry: StripeWebhookTraceEntry = {
|
||||
stage: "worker_received_parse_failed",
|
||||
created_at: receivedAt,
|
||||
data: {
|
||||
...baseData,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
};
|
||||
|
||||
console.log("stripe_webhook_ingress_trace", entry);
|
||||
|
||||
if (traceKv) {
|
||||
await appendStripeWebhookIngressTrace({
|
||||
kv: traceKv,
|
||||
key: getStripeWebhookTraceKey(`parse_failed:${crypto.randomUUID()}`),
|
||||
entry,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const recordLastPostIngressDiagnostic = async (request: Request, env: Env) => {
|
||||
if (request.method !== "POST") return;
|
||||
const traceKv = env.STRIPE_WEBHOOK_TRACE_KV;
|
||||
if (!traceKv) return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
await appendStripeWebhookIngressTrace({
|
||||
kv: traceKv,
|
||||
key: STRIPE_WEBHOOK_LAST_POST_TRACE_KEY,
|
||||
entry: {
|
||||
stage: "worker_post_received",
|
||||
created_at: Date.now(),
|
||||
data: {
|
||||
method: request.method,
|
||||
pathname: url.pathname,
|
||||
has_signature: Boolean(request.headers.get("stripe-signature")),
|
||||
content_length: request.headers.get("content-length"),
|
||||
cf_ray: request.headers.get("cf-ray"),
|
||||
user_agent: request.headers.get("user-agent"),
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSnapshotCacheDiagnostic = async (
|
||||
request: Request,
|
||||
env: Env,
|
||||
@@ -130,6 +304,123 @@ const handleSnapshotCacheDiagnostic = async (
|
||||
);
|
||||
};
|
||||
|
||||
const handleStripeWebhookTraceKvDiagnostic = async (
|
||||
request: Request,
|
||||
env: Env,
|
||||
): Promise<Response | null> => {
|
||||
const providedToken = getStripeWebhookTraceKvDiagnosticToken(request);
|
||||
if (!providedToken) return null;
|
||||
|
||||
if (!isAuthorizedReadyDiagnostic({ providedToken, env })) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const traceKv = env.STRIPE_WEBHOOK_TRACE_KV;
|
||||
if (!traceKv) {
|
||||
return Response.json(
|
||||
{
|
||||
ok: false,
|
||||
checks: {
|
||||
stripeWebhookTraceKv: {
|
||||
configured: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
|
||||
const key = getStripeWebhookTraceKey(`diagnostic:${crypto.randomUUID()}`);
|
||||
const value: StripeWebhookTraceEntry[] = [
|
||||
{
|
||||
stage: "diagnostic_round_trip",
|
||||
created_at: Date.now(),
|
||||
data: {
|
||||
pathname: new URL(request.url).pathname,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
await traceKv.put(key, JSON.stringify(value), {
|
||||
expirationTtl: 60,
|
||||
});
|
||||
const readValue = await traceKv.get<StripeWebhookTraceEntry[]>(key, "json");
|
||||
await traceKv.delete(key);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
ok: readValue?.[0]?.stage === "diagnostic_round_trip",
|
||||
checks: {
|
||||
stripeWebhookTraceKv: {
|
||||
configured: true,
|
||||
roundTrip: readValue?.[0]?.stage === "diagnostic_round_trip",
|
||||
},
|
||||
},
|
||||
},
|
||||
{ status: readValue?.[0]?.stage === "diagnostic_round_trip" ? 200 : 503 },
|
||||
);
|
||||
};
|
||||
|
||||
const handleStripeWebhookIngressDiagnostic = async (
|
||||
request: Request,
|
||||
env: Env,
|
||||
): Promise<Response | null> => {
|
||||
const providedToken = getStripeWebhookIngressDiagnosticToken(request);
|
||||
if (!providedToken) return null;
|
||||
|
||||
if (!isAuthorizedReadyDiagnostic({ providedToken, env })) {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const eventId = `evt_diagnostic_${crypto.randomUUID()}`;
|
||||
const diagnosticRequest = new Request(
|
||||
new URL("/webhooks/stripe/diagnostic/sandbox", request.url),
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"stripe-signature": "t=1,v1=diagnostic",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
id: eventId,
|
||||
object: "event",
|
||||
type: "checkout.session.completed",
|
||||
data: {
|
||||
object: {
|
||||
id: "cs_diagnostic",
|
||||
object: "checkout.session",
|
||||
metadata: {
|
||||
autumn_metadata_id: "meta_diagnostic",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
await recordStripeWebhookIngressTrace(diagnosticRequest, env);
|
||||
const traceKv = env.STRIPE_WEBHOOK_TRACE_KV;
|
||||
const traces = traceKv
|
||||
? await traceKv.get<StripeWebhookTraceEntry[]>(
|
||||
getStripeWebhookTraceKey(eventId),
|
||||
"json",
|
||||
)
|
||||
: null;
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
ok: Boolean(traces?.some((trace) => trace.stage === "worker_received")),
|
||||
event_id: eventId,
|
||||
traces: traces ?? [],
|
||||
},
|
||||
{
|
||||
status: traces?.some((trace) => trace.stage === "worker_received")
|
||||
? 200
|
||||
: 503,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const serializeDiagnosticError = (error: unknown) => {
|
||||
if (!(error instanceof Error)) {
|
||||
return {
|
||||
@@ -213,6 +504,9 @@ const handleStartupDiagnostic = async (
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, executionCtx: ExecutionContext) {
|
||||
await recordLastPostIngressDiagnostic(request, env);
|
||||
await recordStripeWebhookIngressTrace(request, env);
|
||||
|
||||
const startupDiagnostic = await handleStartupDiagnostic(
|
||||
request,
|
||||
env,
|
||||
@@ -226,6 +520,14 @@ export default {
|
||||
);
|
||||
if (snapshotCacheDiagnostic) return snapshotCacheDiagnostic;
|
||||
|
||||
const stripeWebhookTraceKvDiagnostic =
|
||||
await handleStripeWebhookTraceKvDiagnostic(request, env);
|
||||
if (stripeWebhookTraceKvDiagnostic) return stripeWebhookTraceKvDiagnostic;
|
||||
|
||||
const stripeWebhookIngressDiagnostic =
|
||||
await handleStripeWebhookIngressDiagnostic(request, env);
|
||||
if (stripeWebhookIngressDiagnostic) return stripeWebhookIngressDiagnostic;
|
||||
|
||||
initWorkerRuntimeModules(env);
|
||||
return createHonoApp(env).fetch(request, env, executionCtx);
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Hono } from "hono";
|
||||
import type { HonoEnv } from "@/honoUtils/HonoEnv.js";
|
||||
import { handleDebugStripeCheckout } from "./handlers/handleDebugStripeCheckout";
|
||||
import { handleNukeOrganisationConfiguration } from "./handlers/handleNukeOrganisationConfiguration";
|
||||
import { handlePushOrganisationConfiguration } from "./handlers/handlePushOrganisationConfiguration";
|
||||
|
||||
@@ -7,3 +8,4 @@ export const configsRouter = new Hono<HonoEnv>();
|
||||
|
||||
configsRouter.post("/push", ...handlePushOrganisationConfiguration);
|
||||
configsRouter.delete("/nuke", ...handleNukeOrganisationConfiguration);
|
||||
configsRouter.post("/debug/stripe-checkout", ...handleDebugStripeCheckout);
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { customerProducts, metadata, Scopes } from "@autumn/shared";
|
||||
import { eq, sql } from "drizzle-orm";
|
||||
import { z } from "zod/v4";
|
||||
import {
|
||||
getStripeWebhookTraceKey,
|
||||
type StripeWebhookTraceEntry,
|
||||
} from "@/external/stripe/webhookTrace/recordStripeWebhookTrace";
|
||||
import { createRoute } from "@/honoMiddlewares/routeHandler";
|
||||
import { MetadataService } from "@/internal/metadata/MetadataService";
|
||||
|
||||
const DebugStripeCheckoutSchema = z.object({
|
||||
metadata_id: z.string().min(1),
|
||||
stripe_checkout_session_id: z.string().min(1),
|
||||
event_id: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const handleDebugStripeCheckout = createRoute({
|
||||
scopes: [Scopes.Plans.Read],
|
||||
body: DebugStripeCheckoutSchema,
|
||||
handler: async (c) => {
|
||||
const ctx = c.get("ctx");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const meta = await MetadataService.get({
|
||||
db: ctx.db,
|
||||
id: body.metadata_id,
|
||||
});
|
||||
|
||||
const linkedCustomerProducts = await ctx.db
|
||||
.select({
|
||||
id: customerProducts.id,
|
||||
product_id: customerProducts.product_id,
|
||||
status: customerProducts.status,
|
||||
subscription_ids: customerProducts.subscription_ids,
|
||||
stripe_checkout_session_id: customerProducts.stripe_checkout_session_id,
|
||||
})
|
||||
.from(customerProducts)
|
||||
.where(
|
||||
eq(
|
||||
customerProducts.stripe_checkout_session_id,
|
||||
body.stripe_checkout_session_id,
|
||||
),
|
||||
);
|
||||
|
||||
const metadataRowsForCheckout = await ctx.db
|
||||
.select({
|
||||
id: metadata.id,
|
||||
type: metadata.type,
|
||||
stripe_checkout_session_id: metadata.stripe_checkout_session_id,
|
||||
created_at: metadata.created_at,
|
||||
expires_at: metadata.expires_at,
|
||||
})
|
||||
.from(metadata)
|
||||
.where(
|
||||
sql`${metadata.data}->'billingPlan'->'stripe'->'stripeCheckoutSession'->>'id' = ${body.stripe_checkout_session_id}`,
|
||||
);
|
||||
|
||||
const traceKv = ctx.workerEnv?.STRIPE_WEBHOOK_TRACE_KV;
|
||||
const traces =
|
||||
body.event_id && traceKv
|
||||
? ((await traceKv.get<StripeWebhookTraceEntry[]>(
|
||||
getStripeWebhookTraceKey(body.event_id),
|
||||
"json",
|
||||
)) ?? [])
|
||||
: [];
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
trace_storage: {
|
||||
backend: "kv",
|
||||
configured: Boolean(traceKv),
|
||||
},
|
||||
metadata: meta
|
||||
? {
|
||||
id: meta.id,
|
||||
type: meta.type,
|
||||
stripe_checkout_session_id: meta.stripe_checkout_session_id,
|
||||
stripe_invoice_id: meta.stripe_invoice_id,
|
||||
created_at: meta.created_at,
|
||||
expires_at: meta.expires_at,
|
||||
}
|
||||
: null,
|
||||
linked_customer_products: linkedCustomerProducts,
|
||||
metadata_rows_for_checkout: metadataRowsForCheckout,
|
||||
traces,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -184,6 +184,10 @@ export const getStripeWebhookSecret = (
|
||||
});
|
||||
}
|
||||
|
||||
if (webhookSecret.startsWith("whsec_")) {
|
||||
return webhookSecret;
|
||||
}
|
||||
|
||||
return decryptData(webhookSecret, workerEnv);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const routerPath = join(
|
||||
import.meta.dir,
|
||||
"../../../src/external/stripe/stripeWebhookRouter.ts",
|
||||
);
|
||||
|
||||
describe("Stripe webhook router", () => {
|
||||
test("does not early-ack before request-scoped DB work completes", () => {
|
||||
const source = readFileSync(routerPath, "utf8");
|
||||
|
||||
expect(source).not.toContain("stripeWebhookEarlyAckMiddleware");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const baseMiddlewarePath = join(
|
||||
import.meta.dir,
|
||||
"../../../src/honoMiddlewares/baseMiddleware.ts",
|
||||
);
|
||||
|
||||
describe("Stripe webhook raw body handling", () => {
|
||||
test("base middleware does not consume Stripe webhook bodies before signature verification", () => {
|
||||
const source = readFileSync(baseMiddlewarePath, "utf8");
|
||||
|
||||
expect(source).toContain("shouldSkipRequestBodyParse");
|
||||
expect(source).toContain("/webhooks/stripe/");
|
||||
expect(source).toContain("/webhooks/connect/");
|
||||
});
|
||||
});
|
||||
30
server/tests/unit/cloudflare/stripe-webhook-trace-kv.test.ts
Normal file
30
server/tests/unit/cloudflare/stripe-webhook-trace-kv.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const tracePath = join(
|
||||
import.meta.dir,
|
||||
"../../../src/external/stripe/webhookTrace/recordStripeWebhookTrace.ts",
|
||||
);
|
||||
const debugPath = join(
|
||||
import.meta.dir,
|
||||
"../../../src/internal/misc/configs/handlers/handleDebugStripeCheckout.ts",
|
||||
);
|
||||
|
||||
describe("Stripe webhook KV trace persistence", () => {
|
||||
test("records webhook traces through KV instead of request-scoped Postgres", () => {
|
||||
const source = readFileSync(tracePath, "utf8");
|
||||
|
||||
expect(source).toContain("STRIPE_WEBHOOK_TRACE_KV");
|
||||
expect(source).toContain(".put(");
|
||||
expect(source).not.toContain("MetadataService.insert");
|
||||
expect(source).not.toContain("db: ctx.db");
|
||||
});
|
||||
|
||||
test("debug checkout endpoint reads webhook traces from KV", () => {
|
||||
const source = readFileSync(debugPath, "utf8");
|
||||
|
||||
expect(source).toContain("STRIPE_WEBHOOK_TRACE_KV");
|
||||
expect(source).toContain(".get<StripeWebhookTraceEntry[]>");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
|
||||
const workerEntryPath = join(import.meta.dir, "../../../src/index.ts");
|
||||
|
||||
describe("Stripe webhook Worker entry trace", () => {
|
||||
test("records Stripe webhook ingress before Hono middleware runs", () => {
|
||||
const source = readFileSync(workerEntryPath, "utf8");
|
||||
|
||||
expect(source).toContain("recordStripeWebhookIngressTrace");
|
||||
expect(source).toContain("isStripeWebhookIngressRequest");
|
||||
expect(source).toContain("request.clone()");
|
||||
expect(source).toContain("STRIPE_WEBHOOK_TRACE_KV");
|
||||
expect(source).toContain("worker_received");
|
||||
expect(source).toContain("stripe_webhook_trace:__last_ingress");
|
||||
expect(source).toContain("stripe_webhook_trace:__last_post");
|
||||
});
|
||||
|
||||
test("exposes a ready-token guarded KV round-trip diagnostic", () => {
|
||||
const source = readFileSync(workerEntryPath, "utf8");
|
||||
|
||||
expect(source).toContain("handleStripeWebhookTraceKvDiagnostic");
|
||||
expect(source).toContain("/stripe-webhook-trace-kv");
|
||||
expect(source).toContain("diagnostic_round_trip");
|
||||
expect(source).toContain("handleStripeWebhookIngressDiagnostic");
|
||||
expect(source).toContain("/stripe-webhook-ingress");
|
||||
});
|
||||
});
|
||||
36
server/tests/unit/webhooks/stripe-webhook-secret.test.ts
Normal file
36
server/tests/unit/webhooks/stripe-webhook-secret.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { AppEnv } from "@autumn/shared";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { getStripeWebhookSecret } from "@/internal/orgs/orgUtils.js";
|
||||
import { encryptData } from "@/utils/encryptUtils.js";
|
||||
|
||||
const workerEnv = {
|
||||
ENCRYPTION_PASSWORD: "test-password",
|
||||
} as Env;
|
||||
|
||||
const createOrg = (stripeConfig: Record<string, unknown>) =>
|
||||
({
|
||||
id: "org_test",
|
||||
stripe_config: stripeConfig,
|
||||
}) as never;
|
||||
|
||||
describe("getStripeWebhookSecret", () => {
|
||||
test("decrypts encrypted legacy Stripe webhook secrets", () => {
|
||||
const org = createOrg({
|
||||
test_webhook_secret: encryptData("whsec_encrypted", workerEnv),
|
||||
});
|
||||
|
||||
expect(getStripeWebhookSecret(org, AppEnv.Sandbox, workerEnv)).toBe(
|
||||
"whsec_encrypted",
|
||||
);
|
||||
});
|
||||
|
||||
test("uses plaintext whsec legacy Stripe webhook secrets without decrypting", () => {
|
||||
const org = createOrg({
|
||||
test_webhook_secret: "whsec_plaintext",
|
||||
});
|
||||
|
||||
expect(getStripeWebhookSecret(org, AppEnv.Sandbox, workerEnv)).toBe(
|
||||
"whsec_plaintext",
|
||||
);
|
||||
});
|
||||
});
|
||||
9
server/worker-configuration.d.ts
vendored
9
server/worker-configuration.d.ts
vendored
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable */
|
||||
// Generated by Wrangler by running `wrangler types --strict-vars=false` (hash: abde6c710d9140eb6b570f8c24efd178)
|
||||
// Generated by Wrangler by running `wrangler types --strict-vars=false` (hash: e70161a0c0764a8d9ed68acfaeb567f9)
|
||||
// Runtime types generated with workerd@1.20260611.1 2026-06-17 nodejs_compat
|
||||
interface __BaseEnv_Env {
|
||||
CACHE_KV?: KVNamespace;
|
||||
@@ -28,6 +28,9 @@ interface __BaseEnv_Env {
|
||||
RATE_LIMIT_DO: DurableObjectNamespace<import("./src/index").RateLimitDurableObject>;
|
||||
SCHEDULED_WORKFLOW_DO: DurableObjectNamespace<import("./src/index").ScheduledWorkflowDurableObject>;
|
||||
EVENTS_ARCHIVE_PIPELINE: import("cloudflare:pipelines").Pipeline<Cloudflare.AutumnEventsArchiveStagingRecord> | import("cloudflare:pipelines").Pipeline<Cloudflare.AutumnEventsArchiveRecord>;
|
||||
STRIPE_WEBHOOK_TRACE_KV?: KVNamespace;
|
||||
STRIPE_WEBHOOK_URL?: string;
|
||||
SERVER_URL?: string;
|
||||
ANTHROPIC_API_KEY?: string;
|
||||
ATMN_OAUTH_CLIENT_IDS?: string;
|
||||
AUTUMN_SECRET_KEY?: string;
|
||||
@@ -84,7 +87,6 @@ interface __BaseEnv_Env {
|
||||
REVENUECAT_OAUTH_CLIENT_SECRET?: string;
|
||||
SENTRY_DSN?: string;
|
||||
SERVER_PORT?: string;
|
||||
SERVER_URL?: string;
|
||||
SLACK_BOT_SCOPES?: string;
|
||||
SLACK_CLIENT_ID?: string;
|
||||
SLACK_REDIRECT_URI?: string;
|
||||
@@ -96,7 +98,6 @@ interface __BaseEnv_Env {
|
||||
STRIPE_SANDBOX_SECRET_KEY?: string;
|
||||
STRIPE_SANDBOX_WEBHOOK_SECRET?: string;
|
||||
STRIPE_WEBHOOK_SKIP_VERIFY?: string;
|
||||
STRIPE_WEBHOOK_URL?: string;
|
||||
SVIX_API_KEY?: string;
|
||||
TEST_FILE_CONCURRENCY?: string;
|
||||
TESTS_ORG_ID?: string;
|
||||
@@ -150,7 +151,7 @@ type StringifyValues<EnvType extends Record<string, unknown>> = {
|
||||
[Binding in keyof EnvType]: EnvType[Binding] extends string ? EnvType[Binding] : string;
|
||||
};
|
||||
declare namespace NodeJS {
|
||||
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "CLOUDFLARE_ANALYTICS_ARCHIVE_ENABLED" | "CLOUDFLARE_ANALYTICS_DATASET" | "CLOUDFLARE_ANALYTICS_HOT_READ_ENABLED" | "CLOUDFLARE_ANALYTICS_HOT_RETENTION_DAYS" | "CLOUDFLARE_ANALYTICS_WRITE_ENABLED" | "CLOUDFLARE_ACCOUNT_ID" | "CLOUDFLARE_API_TOKEN" | "EMAIL_DOMAIN" | "USE_KV_CACHE" | "USE_DO_IDEMPOTENCY" | "USE_DO_LOCK" | "USE_DO_RATE_LIMIT" | "ANTHROPIC_API_KEY" | "ATMN_OAUTH_CLIENT_IDS" | "AUTUMN_SECRET_KEY" | "AUTUMN_TEST_BASE_URL" | "AUTUMN_WEBHOOK_SECRET" | "AWS_REGION" | "AXIOM_ADMIN_TOKEN" | "AXIOM_METRICS_DATASET" | "AXIOM_ORG_ID" | "AXIOM_SUCCESS_REQUEST_LOG_SAMPLE_RATE" | "AXIOM_TOKEN" | "BETTER_AUTH_SECRET" | "BETTER_AUTH_URL" | "CHAT_SERVER_URL" | "CHAT_STATE_SECRET" | "CLIENT_URL" | "CRITICAL_DB_POOL_MAX" | "CRITICAL_DATABASE_URL" | "CRON" | "DATABASE_CRITICAL_URL" | "DATABASE_REPLICA_URL" | "DATABASE_URL" | "DISABLE_CORS_CHECK" | "DISABLE_CRON" | "DISCORD_FEEDBACK_WEBHOOK" | "ECS_CONTAINER_METADATA_URI_V4" | "EMULATE_GOOGLE_URL" | "ENCRYPTION_IV" | "ENCRYPTION_PASSWORD" | "ENV_FILE" | "FC_GIT_COMMIT_SHA" | "GENERAL_DB_POOL_MAX" | "GOOGLE_CLIENT_ID" | "GOOGLE_CLIENT_SECRET" | "HATCHET_CLIENT_TOKEN" | "IMAGE_TAG" | "INFISICAL_CLIENT_ID" | "INFISICAL_CLIENT_SECRET" | "INFISICAL_ENVIRONMENT" | "INFISICAL_PROJECT_ID" | "INTERNAL_MCP_OAUTH_CLIENT_ID" | "LOCALTUNNEL_RESERVED_KEY" | "LOOPS_API_KEY" | "MCP_RESOURCE_URLS" | "MCP_SERVER_URL" | "NGROK_URL" | "NODE_ENV" | "OTEL_SERVICE_NAME" | "POSTHOG_API_KEY" | "POSTHOG_HOST" | "READY_CHECK_TOKEN" | "REPLICA_DB_POOL_MAX" | "REVENUECAT_OAUTH_CLIENT_ID" | "REVENUECAT_OAUTH_CLIENT_SECRET" | "SENTRY_DSN" | "SERVER_PORT" | "SERVER_URL" | "SLACK_BOT_SCOPES" | "SLACK_CLIENT_ID" | "SLACK_REDIRECT_URI" | "SLACK_STATE_SECRET" | "STRIPE_LIVE_CLIENT_ID" | "STRIPE_LIVE_SECRET_KEY" | "STRIPE_LIVE_WEBHOOK_SECRET" | "STRIPE_SANDBOX_CLIENT_ID" | "STRIPE_SANDBOX_SECRET_KEY" | "STRIPE_SANDBOX_WEBHOOK_SECRET" | "STRIPE_WEBHOOK_SKIP_VERIFY" | "STRIPE_WEBHOOK_URL" | "SVIX_API_KEY" | "TEST_FILE_CONCURRENCY" | "TESTS_ORG_ID" | "TINYBIRD_API_URL" | "TINYBIRD_TOKEN" | "TINYBIRD_US_EAST_API_URL" | "TRIGGER_SERVER_SECRET_KEY" | "UNIT_TEST_AUTUMN_SECRET_KEY" | "VITE_FRONTEND_URL" | "WORKER">> {}
|
||||
interface ProcessEnv extends StringifyValues<Pick<Cloudflare.Env, "CLOUDFLARE_ANALYTICS_ARCHIVE_ENABLED" | "CLOUDFLARE_ANALYTICS_DATASET" | "CLOUDFLARE_ANALYTICS_HOT_READ_ENABLED" | "CLOUDFLARE_ANALYTICS_HOT_RETENTION_DAYS" | "CLOUDFLARE_ANALYTICS_WRITE_ENABLED" | "CLOUDFLARE_ACCOUNT_ID" | "CLOUDFLARE_API_TOKEN" | "EMAIL_DOMAIN" | "USE_KV_CACHE" | "USE_DO_IDEMPOTENCY" | "USE_DO_LOCK" | "USE_DO_RATE_LIMIT" | "STRIPE_WEBHOOK_URL" | "SERVER_URL" | "ANTHROPIC_API_KEY" | "ATMN_OAUTH_CLIENT_IDS" | "AUTUMN_SECRET_KEY" | "AUTUMN_TEST_BASE_URL" | "AUTUMN_WEBHOOK_SECRET" | "AWS_REGION" | "AXIOM_ADMIN_TOKEN" | "AXIOM_METRICS_DATASET" | "AXIOM_ORG_ID" | "AXIOM_SUCCESS_REQUEST_LOG_SAMPLE_RATE" | "AXIOM_TOKEN" | "BETTER_AUTH_SECRET" | "BETTER_AUTH_URL" | "CHAT_SERVER_URL" | "CHAT_STATE_SECRET" | "CLIENT_URL" | "CRITICAL_DB_POOL_MAX" | "CRITICAL_DATABASE_URL" | "CRON" | "DATABASE_CRITICAL_URL" | "DATABASE_REPLICA_URL" | "DATABASE_URL" | "DISABLE_CORS_CHECK" | "DISABLE_CRON" | "DISCORD_FEEDBACK_WEBHOOK" | "ECS_CONTAINER_METADATA_URI_V4" | "EMULATE_GOOGLE_URL" | "ENCRYPTION_IV" | "ENCRYPTION_PASSWORD" | "ENV_FILE" | "FC_GIT_COMMIT_SHA" | "GENERAL_DB_POOL_MAX" | "GOOGLE_CLIENT_ID" | "GOOGLE_CLIENT_SECRET" | "HATCHET_CLIENT_TOKEN" | "IMAGE_TAG" | "INFISICAL_CLIENT_ID" | "INFISICAL_CLIENT_SECRET" | "INFISICAL_ENVIRONMENT" | "INFISICAL_PROJECT_ID" | "INTERNAL_MCP_OAUTH_CLIENT_ID" | "LOCALTUNNEL_RESERVED_KEY" | "LOOPS_API_KEY" | "MCP_RESOURCE_URLS" | "MCP_SERVER_URL" | "NGROK_URL" | "NODE_ENV" | "OTEL_SERVICE_NAME" | "POSTHOG_API_KEY" | "POSTHOG_HOST" | "READY_CHECK_TOKEN" | "REPLICA_DB_POOL_MAX" | "REVENUECAT_OAUTH_CLIENT_ID" | "REVENUECAT_OAUTH_CLIENT_SECRET" | "SENTRY_DSN" | "SERVER_PORT" | "SLACK_BOT_SCOPES" | "SLACK_CLIENT_ID" | "SLACK_REDIRECT_URI" | "SLACK_STATE_SECRET" | "STRIPE_LIVE_CLIENT_ID" | "STRIPE_LIVE_SECRET_KEY" | "STRIPE_LIVE_WEBHOOK_SECRET" | "STRIPE_SANDBOX_CLIENT_ID" | "STRIPE_SANDBOX_SECRET_KEY" | "STRIPE_SANDBOX_WEBHOOK_SECRET" | "STRIPE_WEBHOOK_SKIP_VERIFY" | "SVIX_API_KEY" | "TEST_FILE_CONCURRENCY" | "TESTS_ORG_ID" | "TINYBIRD_API_URL" | "TINYBIRD_TOKEN" | "TINYBIRD_US_EAST_API_URL" | "TRIGGER_SERVER_SECRET_KEY" | "UNIT_TEST_AUTUMN_SECRET_KEY" | "VITE_FRONTEND_URL" | "WORKER">> {}
|
||||
}
|
||||
|
||||
// Begin runtime types
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
"enabled": true,
|
||||
"head_sampling_rate": 1
|
||||
},
|
||||
"routes": [
|
||||
{
|
||||
"pattern": "autumn-api.bowong.cc",
|
||||
"custom_domain": true
|
||||
}
|
||||
],
|
||||
"triggers": {
|
||||
"crons": [
|
||||
"* * * * *"
|
||||
@@ -23,6 +29,12 @@
|
||||
"id": "9f952eb0603044d4beb78e634fe503bc"
|
||||
}
|
||||
],
|
||||
"kv_namespaces": [
|
||||
{
|
||||
"binding": "STRIPE_WEBHOOK_TRACE_KV",
|
||||
"id": "bee161b0d50c4fa68bb66afe51a780b6"
|
||||
}
|
||||
],
|
||||
"r2_buckets": [
|
||||
{
|
||||
"binding": "ADMIN_R2_BUCKET",
|
||||
@@ -145,11 +157,11 @@
|
||||
"CLOUDFLARE_ACCOUNT_ID": "67720b647ff2b55cf37ba3ef9e677083",
|
||||
"CLOUDFLARE_API_TOKEN": "dlGquMNiAX-S7SV9pXne7YGfdH_fEgq3TfIGgNcQ",
|
||||
"CRITICAL_DB_POOL_MAX": "5",
|
||||
"CRITICAL_DATABASE_URL": "postgres://autumn:autumn@127.0.0.1:5432/autumn",
|
||||
"CRITICAL_DATABASE_URL": "postgresql://postgres:dJh0q4aaUb^PH3@34.21.187.198:5432/postgres",
|
||||
"CRON": "false",
|
||||
"DATABASE_CRITICAL_URL": "postgres://autumn:autumn@127.0.0.1:5432/autumn",
|
||||
"DATABASE_REPLICA_URL": "postgres://autumn:autumn@127.0.0.1:5432/autumn",
|
||||
"DATABASE_URL": "postgres://autumn:autumn@127.0.0.1:5432/autumn",
|
||||
"DATABASE_CRITICAL_URL": "postgresql://postgres:dJh0q4aaUb^PH3@34.21.187.198:5432/postgres",
|
||||
"DATABASE_REPLICA_URL": "postgresql://postgres:dJh0q4aaUb^PH3@34.21.187.198:5432/postgres",
|
||||
"DATABASE_URL": "postgresql://postgres:dJh0q4aaUb^PH3@34.21.187.198:5432/postgres",
|
||||
"DISABLE_CORS_CHECK": "true",
|
||||
"DISABLE_CRON": "false",
|
||||
"DISCORD_FEEDBACK_WEBHOOK": "http://localhost:8080/webhooks/discord-feedback",
|
||||
@@ -194,7 +206,7 @@
|
||||
"STRIPE_LIVE_WEBHOOK_SECRET": "whsec_lV2YzyjMm1WcxpJIxRstRFiIB4c3EZnV",
|
||||
"STRIPE_SANDBOX_CLIENT_ID": "ca_test_9546ff0bd6ee3d3a551b9f277ea3e1815b2db37d",
|
||||
"STRIPE_SANDBOX_SECRET_KEY": "sk_test_ZkqfCzHwunOTVUiLluRkAxcU3c5x9vzg",
|
||||
"STRIPE_SANDBOX_WEBHOOK_SECRET": "whsec_Gf0SU4UAK5PPJdp4no6e63IppLvcnAgy",
|
||||
"STRIPE_SANDBOX_WEBHOOK_SECRET": "whsec_Bp2p2BuYWZBmRfFxhmJGECB382p5TWFt",
|
||||
"STRIPE_WEBHOOK_SKIP_VERIFY": "false",
|
||||
"SVIX_API_KEY": "sk_svix_IMsy8zrwtFxEuJ5ukq6lnRrODlifyuBb",
|
||||
"TEST_FILE_CONCURRENCY": "1",
|
||||
@@ -336,4 +348,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ export enum MetadataType {
|
||||
CheckoutSessionV2Processing = "checkout_session_v2_processing",
|
||||
CheckoutSessionEnabledImmediately = "checkout_session_enabled_immediately",
|
||||
SetupPaymentV2 = "setup_payment_v2",
|
||||
StripeWebhookTrace = "stripe_webhook_trace",
|
||||
}
|
||||
|
||||
export const metadata = pgTable("metadata", {
|
||||
|
||||
Reference in New Issue
Block a user