Skip to main content

max / makenotwork

payments: idempotent re-runnable webhook finalize (ultra-fuzz Run 4 M-Pay1, A+) Webhook secondary effects (license-key mint, revenue splits, mailing-list, bundle grants) ran after the DB commit but before the event was marked processed. The dedup is check-then-act, so a genuine duplicate is dropped early and the complete_transaction Ok(None) branch is reached only on crash-recovery redelivery (tx already completed, event unmarked). That branch only escalated, so a first attempt that crashed mid-finalize left the buyer with a completed purchase and no key/splits, never retried. - Consolidate the purchase/cart/guest effect blocks into one re-runnable finalize_purchase_transaction / finalize_guest_transaction so the paths cannot drift and a redelivery re-runs them. - Wire both branches of all three handlers: on Ok(None)/empty, re-fetch the session's completed rows and re-run the finalizer; escalate only if none exist (genuinely orphaned). - Make every effect idempotent: migration 151 adds a partial unique index on license_keys(transaction_id) and a unique index on revenue_splits(transaction_id, recipient_id); maybe_generate_license_key pre-checks for an existing key; create_transaction_splits uses ON CONFLICT DO NOTHING. Double-mint / double-split are now structurally impossible. - Integration test: a wiped-finalize redelivery backfills key + split and a second redelivery is a no-op; plus a direct unique-index rejection test. Also (gate-enabling, pre-existing): db/tips.rs string queries -> query_as! (A+ cold spot); clear two pre-existing clippy lints (lib.rs test-module ordering, synckit/rotation.rs type_complexity); refresh two stale slug tests to assert the deliberate Run 2 auto-suffix seal instead of the old reject behavior (they had been red since that seal landed).
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-23 22:32 UTC
Signed with PGP, not checked
Commit: c929c2b2cc25ff666b36ad8149ebeaa1eabf2c61
Parent: 83a4e68
22 files changed, +1257 insertions, -167 deletions
M server/src/lib.rs +29 -29
@@ -304,35 +304,6 @@
304 304 }
305 305 }
306 306
307 - #[cfg(test)]
308 - mod timeout_exempt_tests {
309 - use super::timeout_exempt;
310 -
311 - #[test]
312 - fn exempts_only_anchored_long_running_routes() {
313 - // Genuinely long / streaming routes stay exempt.
314 - for p in [
315 - "/git/foo/bar.git/info/refs",
316 - "/api/export/content",
317 - "/api/internal/creator/export/sales",
318 - "/api/sync/subscribe",
319 - "/api/v1/sync/subscribe",
320 - "/api/sync/ota/apps/x/releases",
321 - "/api/v1/sync/ota/slug/macos/arm64/1.0.0",
322 - ] {
323 - assert!(timeout_exempt(p), "{p} should be exempt");
324 - }
325 - // The substring-match hazard: these contain a token but must NOT be exempt.
326 - for p in [
327 - "/dashboard/export", // a normal page, not a long export
328 - "/u/somecreator/export-notes", // creator-controlled slug
329 - "/items/sync/ota-recap", // happens to contain the token mid-path
330 - ] {
331 - assert!(!timeout_exempt(p), "{p} must not be exempt");
332 - }
333 - }
334 - }
335 -
336 307 /// Middleware that sets security headers on all responses.
337 308 /// Embed routes (`/embed/`) get permissive frame headers for iframe embedding.
338 309 async fn security_headers_middleware(
@@ -429,3 +400,32 @@
429 400 );
430 401 response
431 402 }
403 +
404 + #[cfg(test)]
405 + mod timeout_exempt_tests {
406 + use super::timeout_exempt;
407 +
408 + #[test]
409 + fn exempts_only_anchored_long_running_routes() {
410 + // Genuinely long / streaming routes stay exempt.
411 + for p in [
412 + "/git/foo/bar.git/info/refs",
413 + "/api/export/content",
414 + "/api/internal/creator/export/sales",
415 + "/api/sync/subscribe",
416 + "/api/v1/sync/subscribe",
417 + "/api/sync/ota/apps/x/releases",
418 + "/api/v1/sync/ota/slug/macos/arm64/1.0.0",
419 + ] {
420 + assert!(timeout_exempt(p), "{p} should be exempt");
421 + }
422 + // The substring-match hazard: these contain a token but must NOT be exempt.
423 + for p in [
424 + "/dashboard/export", // a normal page, not a long export
425 + "/u/somecreator/export-notes", // creator-controlled slug
426 + "/items/sync/ota-recap", // happens to contain the token mid-path
427 + ] {
428 + assert!(!timeout_exempt(p), "{p} must not be exempt");
429 + }
430 + }
431 + }
@@ -93,6 +93,34 @@
93 93 Ok(key)
94 94 }
95 95
96 + /// Look up the auto-minted license key for a purchase transaction, if any.
97 + ///
98 + /// Used by the finalize pre-check so a crash-recovery redelivery does not mint
99 + /// a second key (the `license_keys_transaction_id_key` partial unique index is
100 + /// the structural backstop). At most one such key exists per transaction.
101 + #[tracing::instrument(skip_all)]
102 + pub async fn get_license_key_by_transaction_id(
103 + pool: &PgPool,
104 + transaction_id: TransactionId,
105 + ) -> Result<Option<DbLicenseKey>> {
106 + let key = sqlx::query_as!(
107 + DbLicenseKey,
108 + r#"
109 + SELECT id AS "id: LicenseKeyId", item_id AS "item_id: ItemId",
110 + owner_id AS "owner_id: UserId", transaction_id AS "transaction_id: TransactionId",
111 + key_code AS "key_code: KeyCode", max_activations, activation_count,
112 + revoked_at AS "revoked_at: chrono::DateTime<chrono::Utc>",
113 + created_at AS "created_at: chrono::DateTime<chrono::Utc>"
114 + FROM license_keys WHERE transaction_id = $1
115 + "#,
116 + transaction_id as TransactionId,
117 + )
118 + .fetch_optional(pool)
119 + .await?;
120 +
121 + Ok(key)
122 + }
123 +
96 124 /// Get a license key by ID.
97 125 #[tracing::instrument(skip_all)]
98 126 pub async fn get_license_key_by_id(pool: &PgPool, id: LicenseKeyId) -> Result<Option<DbLicenseKey>> {
@@ -248,10 +248,13 @@
248 248 let recipient_ids: Vec<UserId> = splits.iter().map(|(id, _, _)| *id).collect();
249 249 let amounts: Vec<i32> = splits.iter().map(|(_, a, _)| *a as i32).collect();
250 250 let percents: Vec<i16> = splits.iter().map(|(_, _, p)| *p).collect();
251 + // ON CONFLICT DO NOTHING (against revenue_splits_tx_recipient_key): a
252 + // crash-recovery finalize re-run records no duplicate splits.
251 253 sqlx::query(
252 254 r#"
253 255 INSERT INTO revenue_splits (transaction_id, recipient_id, amount_cents, split_percent, status)
254 256 SELECT $1, UNNEST($2::uuid[]), UNNEST($3::int[]), UNNEST($4::smallint[]), 'pending'
257 + ON CONFLICT (transaction_id, recipient_id) DO NOTHING
255 258 "#,
256 259 )
257 260 .bind(transaction_id)
@@ -4,6 +4,7 @@
4 4
5 5 use super::id_types::*;
6 6 use super::models::*;
7 + use super::validated_types::Cents;
7 8 use crate::error::Result;
8 9
9 10 /// Create a pending tip record before redirecting to Stripe Checkout.
@@ -17,19 +18,26 @@
17 18 message: Option<&str>,
18 19 stripe_checkout_session_id: &str,
19 20 ) -> Result<DbTip> {
20 - let tip = sqlx::query_as::<_, DbTip>(
21 + let tip = sqlx::query_as!(
22 + DbTip,
21 23 r#"
22 24 INSERT INTO tips (tipper_id, recipient_id, project_id, amount_cents, message, stripe_checkout_session_id)
23 25 VALUES ($1, $2, $3, $4, $5, $6)
24 - RETURNING *
26 + RETURNING
27 + id AS "id: TipId", tipper_id AS "tipper_id: UserId", recipient_id AS "recipient_id: UserId",
28 + project_id AS "project_id: ProjectId", amount_cents AS "amount_cents: Cents", message,
29 + status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
30 + stripe_transfer_group,
31 + created_at AS "created_at: chrono::DateTime<chrono::Utc>",
32 + completed_at AS "completed_at: chrono::DateTime<chrono::Utc>"
25 33 "#,
34 + tipper_id as UserId,
35 + recipient_id as UserId,
36 + project_id as Option<ProjectId>,
37 + amount_cents,
38 + message,
39 + stripe_checkout_session_id,
26 40 )
27 - .bind(tipper_id)
28 - .bind(recipient_id)
29 - .bind(project_id)
30 - .bind(amount_cents)
31 - .bind(message)
32 - .bind(stripe_checkout_session_id)
33 41 .fetch_one(pool)
34 42 .await?;
35 43
@@ -39,12 +47,13 @@
39 47 /// Mark a tip as completed after Stripe confirms payment.
40 48 /// Returns `Some(tip)` if updated, `None` if already completed (idempotent).
41 49 #[tracing::instrument(skip(executor))]
42 - pub async fn complete_tip(
43 - executor: impl sqlx::Executor<'_, Database = sqlx::Postgres>,
50 + pub async fn complete_tip<'e>(
51 + executor: impl sqlx::PgExecutor<'e>,
44 52 stripe_checkout_session_id: &str,
45 53 stripe_payment_intent_id: &str,
46 54 ) -> Result<Option<DbTip>> {
47 - let tip = sqlx::query_as::<_, DbTip>(
55 + let tip = sqlx::query_as!(
56 + DbTip,
48 57 r#"
49 58 UPDATE tips
50 59 SET status = 'completed',
@@ -52,11 +61,17 @@
52 61 completed_at = NOW()
53 62 WHERE stripe_checkout_session_id = $1
54 63 AND status = 'pending'
55 - RETURNING *
64 + RETURNING
65 + id AS "id: TipId", tipper_id AS "tipper_id: UserId", recipient_id AS "recipient_id: UserId",
66 + project_id AS "project_id: ProjectId", amount_cents AS "amount_cents: Cents", message,
67 + status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
68 + stripe_transfer_group,
69 + created_at AS "created_at: chrono::DateTime<chrono::Utc>",
70 + completed_at AS "completed_at: chrono::DateTime<chrono::Utc>"
56 71 "#,
72 + stripe_checkout_session_id,
73 + stripe_payment_intent_id,
57 74 )
58 - .bind(stripe_checkout_session_id)
59 - .bind(stripe_payment_intent_id)
60 75 .fetch_optional(executor)
61 76 .await?;
62 77
@@ -71,10 +86,14 @@
71 86 limit: i64,
72 87 offset: i64,
73 88 ) -> Result<Vec<DbTipWithUser>> {
74 - let tips = sqlx::query_as::<_, DbTipWithUser>(
89 + let tips = sqlx::query_as!(
90 + DbTipWithUser,
75 91 r#"
76 - SELECT t.id, t.tipper_id, t.recipient_id, t.project_id, t.amount_cents,
77 - t.message, t.status, t.created_at, t.completed_at,
92 + SELECT t.id AS "id: TipId", t.tipper_id AS "tipper_id: UserId", t.recipient_id AS "recipient_id: UserId",
93 + t.project_id AS "project_id: ProjectId", t.amount_cents AS "amount_cents: Cents",
94 + t.message, t.status AS "status: super::TransactionStatus",
95 + t.created_at AS "created_at: chrono::DateTime<chrono::Utc>",
96 + t.completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
78 97 u.username AS tipper_username, u.display_name AS tipper_display_name
79 98 FROM tips t
80 99 JOIN users u ON u.id = t.tipper_id
@@ -82,10 +101,10 @@
82 101 ORDER BY t.created_at DESC
83 102 LIMIT $2 OFFSET $3
84 103 "#,
104 + recipient_id as UserId,
105 + limit,
106 + offset,
85 107 )
86 - .bind(recipient_id)
87 - .bind(limit)
88 - .bind(offset)
89 108 .fetch_all(pool)
90 109 .await?;
91 110
@@ -95,41 +114,41 @@
95 114 /// Total tip revenue received by a creator (completed tips only).
96 115 #[tracing::instrument(skip(pool))]
97 116 pub async fn total_tips_received(pool: &PgPool, recipient_id: UserId) -> Result<i64> {
98 - let row: (Option<i64>,) = sqlx::query_as(
99 - "SELECT SUM(amount_cents)::BIGINT FROM tips WHERE recipient_id = $1 AND status = 'completed'",
117 + let total = sqlx::query_scalar!(
118 + r#"SELECT COALESCE(SUM(amount_cents), 0)::BIGINT AS "total!" FROM tips WHERE recipient_id = $1 AND status = 'completed'"#,
119 + recipient_id as UserId,
100 120 )
101 - .bind(recipient_id)
102 121 .fetch_one(pool)
103 122 .await?;
104 123
105 - Ok(row.0.unwrap_or(0))
124 + Ok(total)
106 125 }
107 126
108 127 /// Count of completed tips received.
109 128 #[tracing::instrument(skip(pool))]
110 129 pub async fn count_tips_received(pool: &PgPool, recipient_id: UserId) -> Result<i64> {
111 - let row: (i64,) = sqlx::query_as(
112 - "SELECT COUNT(*) FROM tips WHERE recipient_id = $1 AND status = 'completed'",
130 + let count = sqlx::query_scalar!(
131 + r#"SELECT COUNT(*) AS "count!" FROM tips WHERE recipient_id = $1 AND status = 'completed'"#,
132 + recipient_id as UserId,
113 133 )
114 - .bind(recipient_id)
115 134 .fetch_one(pool)
116 135 .await?;
117 136
118 - Ok(row.0)
137 + Ok(count)
119 138 }
120 139
121 140 /// Mark a tip as refunded by payment intent ID.
122 141 /// Returns true if a tip was refunded, false if not found (idempotent).
123 142 #[tracing::instrument(skip(pool))]
124 143 pub async fn refund_tip_by_payment_intent(pool: &PgPool, payment_intent_id: &str) -> Result<bool> {
125 - let result = sqlx::query(
144 + let result = sqlx::query!(
126 145 r#"
127 146 UPDATE tips
128 147 SET status = 'refunded'
129 148 WHERE stripe_payment_intent_id = $1 AND status = 'completed'
130 149 "#,
150 + payment_intent_id,
131 151 )
132 - .bind(payment_intent_id)
133 152 .execute(pool)
134 153 .await?;
135 154
@@ -145,17 +164,25 @@
145 164 limit: i64,
146 165 offset: i64,
147 166 ) -> Result<Vec<DbTip>> {
148 - let tips = sqlx::query_as::<_, DbTip>(
167 + let tips = sqlx::query_as!(
168 + DbTip,
149 169 r#"
150 - SELECT * FROM tips
170 + SELECT
171 + id AS "id: TipId", tipper_id AS "tipper_id: UserId", recipient_id AS "recipient_id: UserId",
172 + project_id AS "project_id: ProjectId", amount_cents AS "amount_cents: Cents", message,
173 + status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
174 + stripe_transfer_group,
175 + created_at AS "created_at: chrono::DateTime<chrono::Utc>",
176 + completed_at AS "completed_at: chrono::DateTime<chrono::Utc>"
177 + FROM tips
151 178 WHERE tipper_id = $1 AND status = 'completed'
152 179 ORDER BY created_at DESC
153 180 LIMIT $2 OFFSET $3
154 181 "#,
182 + tipper_id as UserId,
183 + limit,
184 + offset,
155 185 )
156 - .bind(tipper_id)
157 - .bind(limit)
158 - .bind(offset)
159 186 .fetch_all(pool)
160 187 .await?;
161 188
@@ -292,6 +292,41 @@
292 292 Ok(txs)
293 293 }
294 294
295 + /// Fetch all completed transactions for a checkout session.
296 + ///
297 + /// Used on the crash-recovery branch of the purchase/cart webhook handlers: when
298 + /// `complete_transaction` / `complete_cart_transactions` return nothing (the
299 + /// rows were already flipped to completed by a first attempt that crashed before
300 + /// running finalize), this re-reads those completed rows so finalize can re-run
301 + /// idempotently. Covers single and cart purchases since both key on the session.
302 + #[tracing::instrument(skip_all)]
303 + pub async fn get_completed_transactions_for_session<'e>(
304 + executor: impl sqlx::PgExecutor<'e>,
305 + stripe_checkout_session_id: &str,
306 + ) -> Result<Vec<DbTransaction>> {
307 + let txs = sqlx::query_as!(
308 + DbTransaction,
309 + r#"
310 + SELECT
311 + id AS "id: TransactionId", buyer_id AS "buyer_id: UserId", seller_id AS "seller_id: UserId",
312 + item_id AS "item_id: ItemId", amount_cents AS "amount_cents: Cents", platform_fee_cents AS "platform_fee_cents: Cents",
313 + currency, status AS "status: super::TransactionStatus", stripe_payment_intent_id, stripe_checkout_session_id,
314 + created_at AS "created_at: chrono::DateTime<chrono::Utc>", completed_at AS "completed_at: chrono::DateTime<chrono::Utc>",
315 + item_title, seller_username, share_contact, project_id AS "project_id: ProjectId",
316 + parent_transaction_id AS "parent_transaction_id: TransactionId", promo_code_id AS "promo_code_id: PromoCodeId",
317 + guest_email, claim_token AS "claim_token: ClaimToken", claimed_by AS "claimed_by: UserId",
318 + download_token AS "download_token: DownloadToken"
319 + FROM transactions
320 + WHERE stripe_checkout_session_id = $1 AND status = 'completed'
321 + "#,
322 + stripe_checkout_session_id,
323 + )
324 + .fetch_all(executor)
325 + .await?;
326 +
327 + Ok(txs)
328 + }
329 +
295 330 /// List transactions where the user is the buyer, newest first.
296 331 ///
297 332 /// Pass `limit: None` for all rows (exports), or `Some(n)` for dashboard display.
@@ -258,23 +258,36 @@
258 258 // Duplicate creation
259 259 // =============================================================================
260 260
261 - /// Vulnerability tested: Duplicate project slug creates confusion or overwrites.
262 - /// Second project with same slug should be rejected (unique constraint).
261 + /// Vulnerability tested: a duplicate project slug must never overwrite the
262 + /// existing project or surface a raw 500. The create path auto-suffixes the
263 + /// collision (`inputtest-proj` -> `inputtest-proj-2`) via `insert_with_unique_slug`
264 + /// and retries — the deliberate Run 2 UX seal. The second project gets a fresh
265 + /// id and a distinct slug, so no confusion or overwrite is possible.
263 266 #[tokio::test]
264 - async fn duplicate_project_slug_rejected() {
267 + async fn duplicate_project_slug_auto_suffixed() {
265 268 let mut h = TestHarness::new().await;
266 - let (_project_id, _item_id) = setup_creator_with_item(&mut h).await;
269 + let (project_id, _item_id) = setup_creator_with_item(&mut h).await;
267 270
268 - // Try creating another project with the same slug
271 + // Creating another project with the same slug succeeds with a suffixed slug.
269 272 let resp = h
270 273 .client
271 274 .post_form("/api/projects", "slug=inputtest-proj&title=Duplicate+Shop")
272 275 .await;
273 276 assert!(
274 - !resp.status.is_success(),
275 - "Duplicate slug must not succeed: {} {}",
277 + resp.status.is_success(),
278 + "Duplicate slug should auto-suffix, not fail: {} {}",
276 279 resp.status, resp.text
277 280 );
281 + let second: serde_json::Value = resp.json();
282 + assert_eq!(
283 + second["slug"], "inputtest-proj-2",
284 + "collision must auto-suffix to inputtest-proj-2: {}", second
285 + );
286 + assert_ne!(
287 + second["id"].as_str().unwrap(),
288 + project_id,
289 + "the duplicate must be a new project, never an overwrite"
290 + );
278 291 }
279 292
280 293 /// Vulnerability tested: Duplicate username on signup.
@@ -84,6 +84,7 @@
84 84 mod media_library;
85 85 mod synckit_sse;
86 86 mod mock_payment_flows;
87 + mod payment_crash_recovery;
87 88 mod revenue_splits;
88 89 mod synckit_selective;
89 90 mod guest_checkout;
@@ -168,7 +168,7 @@
168 168 }
169 169
170 170 #[tokio::test]
171 - async fn duplicate_slug_rejected() {
171 + async fn duplicate_slug_auto_suffixed() {
172 172 let mut h = TestHarness::new().await;
173 173 setup_creator(&mut h, "projslug").await;
174 174
@@ -177,17 +177,27 @@
177 177 .post_form("/api/projects", "slug=unique-slug&title=First")
178 178 .await;
179 179 assert!(resp.status.is_success());
180 + let first: serde_json::Value = resp.json();
181 + assert_eq!(first["slug"], "unique-slug");
180 182
181 - // Same slug should fail (may return 4xx or 5xx depending on error handling)
183 + // A second project with the same slug does NOT 500 or overwrite: the
184 + // create path routes through `insert_with_unique_slug`, which auto-suffixes
185 + // the collision (`unique-slug` -> `unique-slug-2`) and retries. This is the
186 + // deliberate Run 2 UX seal against slug-dedup drift.
182 187 let resp = h
183 188 .client
184 189 .post_form("/api/projects", "slug=unique-slug&title=Second")
185 190 .await;
186 191 assert!(
187 - !resp.status.is_success(),
188 - "Duplicate slug should be rejected: {} {}",
192 + resp.status.is_success(),
193 + "Duplicate slug should auto-suffix, not fail: {} {}",
189 194 resp.status, resp.text
190 195 );
196 + let second: serde_json::Value = resp.json();
197 + assert_eq!(
198 + second["slug"], "unique-slug-2",
199 + "collision must auto-suffix to unique-slug-2: {}", second
200 + );
191 201 }
192 202
193 203 #[tokio::test]
@@ -103,6 +103,9 @@
103 103 Ok(rotation)
104 104 }
105 105
106 + /// One sync-log entry awaiting re-encryption: (seq, table_name, row_id, data).
107 + pub type RotationEntry = (i64, String, String, Option<JsonValue>);
108 +
106 109 /// Pull sync log entries that need re-encryption (key_id != new_key_id).
107 110 /// Returns entries ordered by seq, paginated by after_seq.
108 111 #[tracing::instrument(skip_all)]
@@ -113,8 +116,8 @@
113 116 new_key_id: i32,
114 117 after_seq: i64,
115 118 limit: i64,
116 - ) -> Result<Vec<(i64, String, String, Option<JsonValue>)>> {
117 - let entries: Vec<(i64, String, String, Option<JsonValue>)> = sqlx::query_as(
119 + ) -> Result<Vec<RotationEntry>> {
120 + let entries: Vec<RotationEntry> = sqlx::query_as(
118 121 r#"
119 122 SELECT seq, table_name, row_id, data FROM sync_log
120 123 WHERE app_id = $1 AND user_id = $2