Skip to main content

max / makenotwork

payments: v2 webhook retry-queue parity + document get_transaction_by_id authz contract (ultra-fuzz NOTE tail) - webhook_v2 retry parity (Run 2 minor): on a v2 thin-event processing failure, persist to the local retry queue (source "stripe_v2") so the scheduler retries with backoff + dead-letter WAM, matching v1 -- instead of relying solely on Stripe's 3-day redelivery. Extracted process_v2_thin_event (shared by the live handler and the retry worker, which re-parses the stored payload and re-fetches the object). The worker gained a stripe_v2 branch. - get_transaction_by_id (Run 2 NOTE): documented that the lookup is intentionally unscoped and every caller MUST authorize against buyer_id/seller_id -- both current callers (receipt page, refund) already do; the doc makes the IDOR contract explicit for future callers. Already-safe NOTEs confirmed (no change): promo use_count is guarded by the atomic try_increment_use_count (documented), and webhook dedup carries its load-bearing check-then-act INVARIANT comment. stripe_webhooks suite (46) green; clippy clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-23 20:51 UTC
Commit: 7af8fb80b133b15485fffe563a6127e872774010
Parent: ccf8532
4 files changed, +64 insertions, -13 deletions
@@ -801,6 +801,16 @@ pub async fn remove_free_item_from_library(
801 801 }
802 802
803 803 /// Fetch a single transaction by ID.
804 + ///
805 + /// # Authorization
806 + ///
807 + /// This lookup is intentionally **unscoped** — it does not filter by buyer or
808 + /// seller, because the two callers need the row to *decide* authorization
809 + /// (a receipt page shown to buyer-or-seller; a refund restricted to the
810 + /// seller). Every caller MUST therefore check ownership against the returned
811 + /// `buyer_id`/`seller_id` before acting on it. A new caller that returns this
812 + /// row's contents without such a check would be an IDOR — there is no implicit
813 + /// scoping here to lean on.
804 814 #[tracing::instrument(skip_all)]
805 815 pub async fn get_transaction_by_id(
806 816 pool: &PgPool,
@@ -9,6 +9,7 @@ mod webhook_v2;
9 9
10 10 pub(crate) use checkout::grant_bundle_items;
11 11 pub(crate) use webhook::process_webhook_event;
12 + pub(crate) use webhook_v2::process_v2_thin_event;
12 13
13 14 use axum::routing::get;
14 15
@@ -65,26 +65,53 @@ pub(super) async fn webhook_v2(
65 65 Ok(false) => {} // first time — proceed
66 66 }
67 67
68 - // Route by event type
69 - if thin.event_type.starts_with("v2.core.account") {
70 - if let Err(e) = handle_account_thin_event(&state, stripe.as_ref(), &thin).await {
71 - // Not marked processed, so returning the error makes Stripe redeliver
72 - // (retries for up to 3 days) and the handler re-runs.
73 - tracing::warn!(event_id = %thin.id, error = ?e, "v2 event processing failed; will be redelivered");
74 - return Err(e);
75 - }
76 - // Succeeded — record it so a redelivery short-circuits.
77 - if let Err(e) = db::webhook_events::mark_event_processed(&state.db, &thin.id).await {
78 - tracing::error!(event_id = %thin.id, error = ?e, "failed to record processed v2 event; returning 503 for redelivery");
68 + if let Err(e) = process_v2_thin_event(&state, stripe.as_ref(), &thin).await {
69 + // Persist to the local retry queue (backoff + dead-letter WAM via the
70 + // scheduler), matching v1's failure path rather than relying solely on
71 + // Stripe's redelivery window. Not marked processed, so a Stripe
72 + // redelivery also still re-runs the idempotent handler.
73 + tracing::warn!(event_id = %thin.id, error = ?e, "v2 event processing failed; queueing for retry");
74 + if let Err(queue_err) = db::webhook_events::insert_failed_event(
75 + &state.db,
76 + "stripe_v2",
77 + &thin.event_type,
78 + payload,
79 + Some(signature),
80 + &format!("{e:?}"),
81 + )
82 + .await
83 + {
84 + tracing::error!(event_id = %thin.id, error = ?queue_err, "failed to queue v2 event for retry; returning 503 for Stripe redelivery");
79 85 return Ok(StatusCode::SERVICE_UNAVAILABLE);
80 86 }
81 - } else {
82 - tracing::debug!(event_type = %thin.event_type, "unhandled v2 event type");
87 + return Ok(StatusCode::OK);
88 + }
89 +
90 + // Succeeded — record it so a redelivery short-circuits.
91 + if let Err(e) = db::webhook_events::mark_event_processed(&state.db, &thin.id).await {
92 + tracing::error!(event_id = %thin.id, error = ?e, "failed to record processed v2 event; returning 503 for redelivery");
93 + return Ok(StatusCode::SERVICE_UNAVAILABLE);
83 94 }
84 95
85 96 Ok(StatusCode::OK)
86 97 }
87 98
99 + /// Route a verified v2 thin event to its handler. Shared by the live webhook and
100 + /// the scheduler retry worker (which re-parses the stored payload — the
101 + /// signature was already verified when the event was first received).
102 + pub(crate) async fn process_v2_thin_event(
103 + state: &AppState,
104 + stripe: &dyn payments::PaymentProvider,
105 + thin: &ThinEvent,
106 + ) -> Result<()> {
107 + if thin.event_type.starts_with("v2.core.account") {
108 + handle_account_thin_event(state, stripe, thin).await
109 + } else {
110 + tracing::debug!(event_type = %thin.event_type, "unhandled v2 event type");
111 + Ok(())
112 + }
113 + }
114 +
88 115 /// Fetch the full account object and delegate to the shared account-updated handler.
89 116 async fn handle_account_thin_event(
90 117 state: &AppState,
@@ -42,6 +42,19 @@ pub(super) async fn retry_failed_webhooks(state: &AppState) {
42 42 }
43 43 Err(e) => Err(e),
44 44 }
45 + } else if event.source == "stripe_v2" {
46 + // v2 thin events: re-parse the stored payload (signature was verified
47 + // at receive time) and re-route. The handler re-fetches the object
48 + // from Stripe and re-applies it idempotently.
49 + match serde_json::from_str::<crate::payments::ThinEvent>(&event.payload) {
50 + Ok(thin) => match state.stripe.as_ref() {
51 + Some(stripe) => {
52 + crate::routes::stripe::process_v2_thin_event(state, stripe.as_ref(), &thin).await
53 + }
54 + None => Err(crate::error::AppError::BadRequest("Stripe not configured".to_string())),
55 + },
56 + Err(e) => Err(crate::error::AppError::BadRequest(format!("failed to parse stored v2 event: {e}"))),
57 + }
45 58 } else {
46 59 Err(crate::error::AppError::BadRequest(format!("Unknown webhook source: {}", event.source)))
47 60 };