Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3f8b639
Harden Stripe webhook setup: same-URL cleanup, atomic credentials, sh…
stefan-burke Jul 14, 2026
770d703
Create webhook endpoint before deleting strays (review fix)
stefan-burke Jul 14, 2026
19d92be
Fix typecheck: rename recordedListing to recordedInListing in setupWi…
stefan-burke Jul 14, 2026
000d015
Record webhook-setup review follow-ups in TODO.md; drop unused export
stefan-burke Jul 14, 2026
39ba0e3
Merge origin/main; defer stray deletion until after DB save + endpoin…
stefan-burke Jul 14, 2026
fbb879b
Add coverage for stripe.ts new paths: delete catch, limit-error branc…
stefan-burke Jul 14, 2026
0b45fac
Make create-throws tests actually throw from fetch; remove untestable…
stefan-burke Jul 14, 2026
1e04758
Fix createThrowsMaximum: use 400 Response instead of throwing
stefan-burke Jul 14, 2026
6a77f71
Merge remote-tracking branch 'origin/main' into split/stripe-webhook-…
stefan-burke Jul 14, 2026
9b7cf87
Preserve strays in cap-recovery, delete old recorded endpoint by ID, …
stefan-burke Jul 14, 2026
1399189
Fix 4 review threads: stub cleanup in settings tests, remove dead exp…
stefan-burke Jul 14, 2026
929ecdb
Merge remote-tracking branch 'origin/main' into split/stripe-webhook-…
stefan-burke Jul 14, 2026
7072294
Fix typecheck: restore requireCreatedBody export and createThrowsNonE…
stefan-burke Jul 14, 2026
92b8829
Fix settings test failures: read snapshot instead of invalidating cache
stefan-burke Jul 14, 2026
709a666
Fix coverage gaps: remove concurrent-race guard, simplify isEndpointL…
stefan-burke Jul 14, 2026
54f8880
Fix remaining coverage: simplify isEndpointLimitError, add GET-withou…
stefan-burke Jul 14, 2026
1123749
Add POST-without-body test to cover init?.body ?? '' default branch
stefan-burke Jul 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -1030,6 +1030,39 @@ is next touched. Keep the copy plain per the Simple-Language rules.

---

---

## Stripe webhook setup hardening — deferred edges (from PR #1827)

*Origin: CodeRabbit and Codex review of PR #1827 (the same-URL cleanup +
atomic credentials + shared URL helper PR). The create-first refactor and
endpoint-limit fallback were applied in that PR; the one edge below was
judged out of scope and recorded here.*

`setupWebhookEndpointImpl` in `src/shared/stripe.ts` creates the new endpoint
only — old same-URL endpoints are deleted by a separate
`cleanupOldWebhookEndpoints` call that the settings route invokes AFTER
`settings.update.stripe.webhookConfig` saves the new endpoint ID + secret to
the DB. This ordering ensures a DB-save failure leaves the old endpoint
(whose secret matches the DB) in place. If Stripe rejects the create because
the account is at its webhook-endpoint cap, setup deletes same-URL strays
(keeping the recorded endpoint intact) and retries the create. One edge
remains:

- **Same-URL stray listing doesn't paginate.** `fetchWebhookEndpoints` calls
`client.webhookEndpoints.list({ limit: 100 })` once and returns `.data`
without following Stripe's `has_more` cursor. A site that has accumulated
more than 100 webhook endpoints (rare — would require many failed setups
or a long-running test environment) would leave strays beyond the first
page un-deleted. Impact is limited: the new endpoint is already live and
the DB points at it, so leftover strays are duplicate-delivery-only, not
a signing-secret mismatch. Fix direction: follow the `has_more`/cursor
loop in `fetchWebhookEndpoints` so the same-URL filter sees every
endpoint. Starting point: `src/shared/stripe.ts` (`fetchWebhookEndpoints`
and `listSameUrlEndpointIds`).

---

## Bunny subrequest budget follow-ups

*Origin: request-fan-out audit for PR #1820.*
Expand Down
7 changes: 0 additions & 7 deletions src/features/admin/settings-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import {
withAuth,
} from "#routes/auth.ts";
import { errorRedirect, jsonResponse, redirect } from "#routes/response.ts";
import { getEffectiveDomain } from "#shared/config.ts";
import { logActivity } from "#shared/db/activityLog.ts";
import { isMaskSentinel } from "#shared/db/settings/mask.ts";
import { settings } from "#shared/db/settings.ts";
Expand Down Expand Up @@ -83,11 +82,6 @@ const advancedSettingsRoute = wrapRoute(ADVANCED_PATH);
const testRoute = (testFn: () => Promise<unknown>) =>
gatedPost(OWNER_FORM)(async () => jsonResponse(await testFn()));

/** Build the payment webhook URL from the configured domain.
* Shared by the settings page (display) and the Stripe handler (setup). */
const getWebhookUrl = (): string =>
`https://${getEffectiveDomain()}/payment/webhook`;

/** Run an optional async validator; return error response or null */
const runValidate = <T>(
validate: ValidateFn<T> | undefined,
Expand Down Expand Up @@ -411,7 +405,6 @@ export {
clearableFieldHandler,
createSettingsHandler,
defineProviderCredentialsRoute,
getWebhookUrl,
processSecretField,
saveSecret,
secretFieldHandler,
Expand Down
4 changes: 2 additions & 2 deletions src/features/admin/settings-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
*/

/* jscpd:ignore-start */
import { getWebhookUrl } from "#routes/admin/settings-helpers.ts";
import { type AuthSession, ownerPage } from "#routes/auth.ts";
import type { TypedRouteHandler } from "#routes/router.ts";
import { getCdnHostname } from "#shared/bunny-cdn.ts";
Expand All @@ -17,6 +16,7 @@ import {
import { settings } from "#shared/db/settings.ts";
import { EMAIL_PROVIDER_LABELS, getHostEmailConfig } from "#shared/email.ts";
import { getFlash } from "#shared/flash-context.ts";
import { getPaymentWebhookUrl } from "#shared/payment-webhook-url.ts";
import { isStorageEnabled } from "#shared/storage.ts";
import { getSuperuserState } from "#shared/superuser.ts";
import { adminSettingsPage } from "#templates/admin/settings.tsx";
Expand Down Expand Up @@ -51,7 +51,7 @@ const getSettingsPageState = async () => {
termsAndConditions: settings.terms,
theme: settings.theme,
underlineLinks: settings.underlineLinks,
webhookUrl: getWebhookUrl(),
webhookUrl: getPaymentWebhookUrl(),
};
};

Expand Down
27 changes: 21 additions & 6 deletions src/features/admin/settings-stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,12 @@
*/

import { t } from "#i18n";
import {
defineProviderCredentialsRoute,
getWebhookUrl,
} from "#routes/admin/settings-helpers.ts";
import { defineProviderCredentialsRoute } from "#routes/admin/settings-helpers.ts";
import { settings } from "#shared/db/settings.ts";
import { isDemoMode } from "#shared/demo/mode.ts";
import { getPaymentWebhookUrl } from "#shared/payment-webhook-url.ts";
import {
cleanupOldWebhookEndpoints,
detectStripeKeyMode,
setupWebhookEndpoint,
testStripeConnection,
Expand All @@ -20,15 +19,31 @@ export const stripeRoutes = defineProviderCredentialsRoute<undefined>({
// Provision the Stripe webhook before the key is persisted, so a setup
// failure aborts the save leaving nothing configured.
afterSave: async (value) => {
const webhookUrl = getPaymentWebhookUrl();
const previousEndpointId = settings.stripe.webhookEndpointId;
const result = await setupWebhookEndpoint(
value,
getWebhookUrl(),
settings.stripe.webhookEndpointId,
webhookUrl,
previousEndpointId,
);
if (!result.success) {
return `Failed to set up Stripe webhook: ${result.error}`;
}
// Save the new endpoint ID + secret to the DB FIRST. If this fails, the
// old endpoint (whose secret matches the DB) stays in place — webhooks
// keep delivering with the old config instead of losing the only signed
// endpoint mid-replacement.
await settings.update.stripe.webhookConfig(result);
// Now that the new credentials are saved, delete old endpoints. Pass the
// previous endpoint ID explicitly — after a domain change it points at
// the old URL, so the same-URL listing won't find it. Cleanup is
// best-effort: a failure here doesn't unwind the save.
await cleanupOldWebhookEndpoints(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stub cleanup in the masking helper too

Besides the Stripe settings helper already called out, test/lib/server-settings/sensitive-field-masking.test.ts also stubs only stripeApi.setupWebhookEndpoint before posting this route. With this new cleanup call, that masking test now falls through to the real Stripe client using fake keys such as sk_test_real_secret; the list call is swallowed but still performs live network work and can hang or vary by environment, so this helper also needs to stub stripeApi.cleanupOldWebhookEndpoints.

Useful? React with 👍 / 👎.

value,
webhookUrl,
result.endpointId,
previousEndpointId ? [previousEndpointId] : [],
);
return null;
},
formId: "settings-stripe",
Expand Down
4 changes: 2 additions & 2 deletions src/features/api/webhooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,13 @@ import {
verifyTokensWithRealLine,
} from "#routes/tickets/token-utils.ts";
import { getSearchParam } from "#routes/url.ts";
import { getEffectiveDomain } from "#shared/config.ts";
/* jscpd:ignore-end */
import { getHiddenPackageMemberIds } from "#shared/db/groups.ts";
import { getListingWithCount } from "#shared/db/listings/records.ts";
import { clearSessionTokens } from "#shared/db/processed-payments.ts";
import { ErrorCode, logDebug, logError } from "#shared/logger.ts";
import { WEBHOOK_SIGNATURE_HEADERS } from "#shared/payment-providers.ts";
import { getPaymentWebhookUrl } from "#shared/payment-webhook-url.ts";
import {
getActivePaymentProvider,
type ValidatedPaymentSession,
Expand Down Expand Up @@ -354,7 +354,7 @@ const authenticateWebhook = async (
// webhook using the exact notification URL from the subscription, which is the
// public https:// URL. Deriving from request.url fails behind CDNs that
// terminate TLS (the edge runtime sees http:// instead of https://).
const webhookUrl = `https://${getEffectiveDomain()}/payment/webhook`;
const webhookUrl = getPaymentWebhookUrl();
const verification = await provider.verifyWebhookSignature(
payload,
signature,
Expand Down
15 changes: 7 additions & 8 deletions src/shared/db/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
writeEncrypted,
writeOrDelete,
writeRaw,
writeRawBatch,
} from "#shared/db/settings/raw-writes.ts";
import {
clearSetupCompleteCache,
Expand Down Expand Up @@ -460,14 +461,12 @@ const settingsBase = {
secret: string;
endpointId: string;
}): Promise<void> => {
await writeRaw(
CONFIG_KEYS.STRIPE_WEBHOOK_SECRET,
await encrypt(config.secret),
);
await writeRaw(
CONFIG_KEYS.STRIPE_WEBHOOK_ENDPOINT_ID,
config.endpointId,
);
// Save both values in one atomic batch so a partial write can never
// leave a new secret paired with an old endpoint ID (or vice versa).
await writeRawBatch([
[CONFIG_KEYS.STRIPE_WEBHOOK_SECRET, await encrypt(config.secret)],
[CONFIG_KEYS.STRIPE_WEBHOOK_ENDPOINT_ID, config.endpointId],
]);
data.stripe_webhook_secret = config.secret;
data.stripe_webhook_endpoint_id = config.endpointId;
},
Expand Down
23 changes: 23 additions & 0 deletions src/shared/payment-webhook-url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* One home for the payment webhook URL: the route Stripe/Square/SumUp deliver
* signed payment callbacks to, and the page a customer's browser lands on
* after a hosted checkout. Replacing a provider's endpoint (or building the URL
* a webhook signature is verified against) goes through here so every caller
* reads the same host.
*
* The constructor is pure (taking the domain in), leaving the domain lookup to a
* thin wrapper so the URL shape can be unit-tested without seeding a domain.
*/

import { getEffectiveDomain } from "#shared/config.ts";

/** The fixed path under the effective domain that receives payment callbacks. */
const PAYMENT_WEBHOOK_PATH = "/payment/webhook";

/** Build the public payment webhook URL for the given domain. */
const paymentWebhookUrl = (domain: string): string =>
`https://${domain}${PAYMENT_WEBHOOK_PATH}`;

/** Build the payment webhook URL from the current effective domain. */
export const getPaymentWebhookUrl = (): string =>
paymentWebhookUrl(getEffectiveDomain());
Loading