Skip to main content

max / makenotwork

Security audit: fix 23 flaws from adversarial code fuzz, harden test suite Round 1 fixes (from prior audit, unstaged): - S3 key prefix validation on all confirm endpoints - Unicode homograph prevention (is_ascii_alphanumeric) - Bundle refund child transaction revocation (migration 061) - Tip amount overflow guard ($1-$10K bounds) - Project member split TOCTOU (SELECT FOR UPDATE) - SSE connection limit with drop guard - Backup code transaction wrapping - Storage increment ordering (before DB writes) - Constant-time compare via SHA-256 pre-hash - Login timing equalization (dummy Argon2 hash) - Atomic lockout increment (single SQL UPDATE) - Tip refund webhook handling - OTA semver ordering - SUM ::BIGINT casts in analytics - N+1 query batching (UNNEST, ANY) - Admin query LIMIT caps - DB ownership checks on mutation functions Round 2 fixes (fuzz audit, this session): - Partial refund handling: extract amount_refunded from Charge, skip revocation on partial refunds (previously any refund revoked all access) - Propagate complete_transaction errors so Stripe retries webhooks - Unique public project slug index (migration 062) + deterministic ORDER BY on get_public_project_by_slug - Move file type rejection before try_increment_storage to prevent storage counter leak on rejected Download/Insertion/Media types - Reject upload confirm when S3 object_size returns None instead of defaulting to 0 bytes (6 confirm handlers) - PWYW $10K max cap (matching tip ceiling) - Revenue split rounding with remainder distribution - Fee display clamped to 0 for sub-31-cent items - Password 128-char cap on login and SyncKit auth paths - Session cycle before storing pending_2fa_user_id - slugify() restricted to ASCII alphanumeric - SyncKit app is_active check in JWT extractor - SSE sync_notify and sse_connections pruning on disconnect - Image content-type: reject unrecognized magic bytes for Cover/MediaImage Test suite: - Template database for integration tests (CREATE DATABASE ... TEMPLATE) - SSE streaming tests marked #[ignore] to prevent binary hang - New integration tests for auth, blog, content, license keys, payments Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-25 07:14 UTC
Commit: 514ead900b1068762c70e1f978c5507c70cf854e
Parent: b3f80f6
66 files changed, +2554 insertions, -654 deletions
@@ -0,0 +1,290 @@
1 + # Server Code Flaws (Fuzz Audit 2026-04-24)
2 +
3 + Automated adversarial code review of the MNW server. Each flaw confirmed by reading the source.
4 +
5 + All flaws fixed (except #6, false positive). 17 of 18 addressed.
6 +
7 + ---
8 +
9 + ## Critical
10 +
11 + ### 1. confirm_upload accepts arbitrary S3 key -- FIXED
12 +
13 + **Location:** `src/routes/storage/uploads.rs`, `versions.rs`, `media.rs`, `content_insertions.rs`
14 +
15 + `confirm_upload` takes an `s3_key` from the client and only checks that the object exists in S3 and that the user owns the `item_id`. It never validates the key matches what `presign_upload` generated. A user can pass another user's S3 key (discoverable from public CDN URLs) to attach someone else's file to their own item.
16 +
17 + **Fix:** Added `user_id/item_id` prefix validation to all four confirm endpoints.
18 +
19 + ---
20 +
21 + ## Serious
22 +
23 + ### 2. Unicode homograph attack on usernames and slugs -- FIXED
24 +
25 + **Location:** `src/validation/users.rs:88`, `src/validation/mod.rs:84,133`, `src/validation/items.rs:72`, `src/routes/auth.rs:316`
26 +
27 + `char::is_alphanumeric()` accepts Unicode letters (Cyrillic, Greek, etc.), not just ASCII. A user can register `alicе` (Cyrillic е) to impersonate `alice`. Same for project slugs — visually identical URLs.
28 +
29 + **Fix:** Changed to `is_ascii_alphanumeric()` in all 5 call sites.
30 +
31 + ### 3. Bundle refund does not revoke child item access -- FIXED
32 +
33 + **Location:** `src/routes/stripe/webhook/billing.rs`, `src/routes/stripe/checkout/item.rs`, `src/db/transactions.rs`
34 +
35 + When a bundle is refunded, the parent transaction is marked `refunded`, but child items were granted as separate `$0 completed` transactions via `claim_free_item`. These have no link to the parent and remain `completed`.
36 +
37 + **Fix:** Added `parent_transaction_id` column (migration 061), passed it through `ClaimParams`/`grant_bundle_items`, and added `revoke_child_transactions` call in the refund webhook handler.
38 +
39 + ### 4. i32 overflow in tip amount calculation -- FIXED
40 +
41 + **Location:** `src/routes/stripe/checkout/tips.rs:51`
42 +
43 + `amount_dollars * 100` can overflow `i32` in release builds, creating a checkout with a wrong amount. No maximum tip amount enforced server-side.
44 +
45 + **Fix:** Added $1 min / $10,000 max bounds check before multiplication.
46 +
47 + ### 5. Project member split race condition (TOCTOU) -- FIXED
48 +
49 + **Location:** `src/db/project_members.rs:25-31, 110-143`
50 +
51 + `add_project_member` reads the current total split then inserts. Two concurrent requests can both pass the `<= 100%` check and commit, totaling >100%.
52 +
53 + **Fix:** Wrapped both functions in transactions with `SELECT ... FOR UPDATE` locking.
54 +
55 + ---
56 +
57 + ## Moderate
58 +
59 + ### 6. 30-second session revocation window -- FALSE POSITIVE
60 +
61 + **Location:** `src/auth.rs:109-147`
62 +
63 + Session validation is cached for 30 seconds. However, all revocation paths (revoke_session, revoke_other_sessions, logout, password change) already evict from `session_cache`. No code path deletes sessions without clearing the cache.
64 +
65 + ### 7. SSE connection exhaustion -- FIXED
66 +
67 + **Location:** `src/routes/synckit/subscribe.rs`
68 +
69 + Rate limiting throttles new connection attempts but not concurrent open connections. A malicious user can hold many SSE connections open.
70 +
71 + **Fix:** Added per-user atomic connection counter in `AppState.sse_connections` with `SseConnectionGuard` drop guard. Max 10 concurrent SSE connections per user.
72 +
73 + ### 8. Backup codes not created in a transaction -- FIXED
74 +
75 + **Location:** `src/db/totp.rs:73-96`
76 +
77 + DELETE of old backup codes and INSERT loop for new ones are not in a transaction. A crash between them leaves the user with zero backup codes.
78 +
79 + **Fix:** Wrapped in `pool.begin()` / `tx.commit()`.
80 +
81 + ### 9. Storage increment runs after DB writes -- FIXED
82 +
83 + **Location:** `src/routes/storage/uploads.rs`, `versions.rs`, `media.rs`, `content_insertions.rs`
84 +
85 + `try_increment_storage` runs after the item's `s3_key` is written. If the increment fails (quota exceeded), the item references a file that doesn't count toward quota.
86 +
87 + **Fix:** Moved `try_increment_storage` before DB writes in all four confirm endpoints.
88 +
89 + ---
90 +
91 + ## Minor
92 +
93 + ### 10. constant_time_compare leaks string length -- FIXED
94 +
95 + **Location:** `src/helpers.rs:56-58`
96 +
97 + Early return on length mismatch. Low impact since token lengths are fixed, but technically not constant-time.
98 +
99 + **Fix:** Hash both inputs with SHA-256 before XOR comparison, eliminating the length leak.
100 +
101 + ### 11. Login timing leaks user existence -- FIXED
102 +
103 + **Location:** `src/routes/auth.rs:82-104`
104 +
105 + "User not found" returns immediately without Argon2 work (~100ms faster). Rate limiter partially masks this.
106 +
107 + **Fix:** Added dummy Argon2 verification (against a pre-computed hash) when user is not found.
108 +
109 + ### 12. Lockout race allows ~9 attempts instead of 5 -- FIXED
110 +
111 + **Location:** `src/routes/auth.rs`, `src/routes/oauth.rs`, `src/routes/synckit/auth.rs`, `src/db/auth.rs`
112 +
113 + Concurrent burst requests could each pass the lockout check before either incremented.
114 +
115 + **Fix:** Combined increment + conditional lock into a single atomic SQL UPDATE. The `locked_until` is set in the same statement that increments `failed_login_attempts`, so PostgreSQL row-level locking serializes concurrent callers. Removed the separate `lock_account` function. Updated all three call sites (login, OAuth, SyncKit).
116 +
117 + ### 13. Tip refunds not handled -- FIXED
118 +
119 + **Location:** `src/routes/stripe/webhook/billing.rs`, `src/db/tips.rs`
120 +
121 + `charge.refunded` webhook looks up `transactions` only; tips in their separate table are not updated.
122 +
123 + **Fix:** Added `refund_tip_by_payment_intent` in `db/tips.rs` and fallback lookup in the refund webhook handler.
124 +
125 + ### 14. OTA version ordering uses pub_date not semver -- FIXED
126 +
127 + **Location:** `src/db/ota.rs:84`
128 +
129 + Publishing a backport (lower version, newer date) makes it the "latest," hiding the actual newest version.
130 +
131 + **Fix:** Changed ORDER BY to use `(string_to_array(split_part(version, '-', 1), '.'))::int[] DESC` for semver sorting, with pub_date as tiebreaker.
132 +
133 + ### 15. Missing ::BIGINT casts on SUM in analytics.rs -- FIXED
134 +
135 + **Location:** `src/db/analytics.rs`, `src/db/transactions.rs`
136 +
137 + Works now due to `SUM(INT)->BIGINT` promotion, but inconsistent with casts used elsewhere.
138 +
139 + **Fix:** Added `::BIGINT` casts to all 19 SUM queries across both files.
140 +
141 + ### 16. N+1 query loops -- FIXED
142 +
143 + - `src/db/totp.rs` — 10 INSERTs for backup codes
144 + - `src/db/project_members.rs` — split INSERTs
145 + - `src/db/license_keys.rs` — deactivation loop
146 +
147 + **Fix:** Batched with UNNEST (totp, splits) and ANY (license deactivations).
148 +
149 + ### 17. Unbounded admin queries -- PARTIALLY FIXED
150 +
151 + - `src/db/users.rs:363-376` — `get_pending_appeals`
152 + - `src/db/projects.rs:252-259` — `get_projects_without_mt_community`
153 + - `src/db/users.rs:449-457` — `get_all_user_emails` (intentionally unbounded for bulk notifications)
154 +
155 + **Fix:** Added LIMIT 500 to get_pending_appeals and get_projects_without_mt_community. Left get_all_user_emails unbounded per its documented purpose (shutdown notices).
156 +
157 + ### 18. DB functions lack ownership checks -- FIXED
158 +
159 + `db/projects.rs` and `db/items.rs` mutation functions took only an entity ID with no ownership filter.
160 +
161 + **Fix:** Added `user_id` parameter to all high-risk mutation functions:
162 + - **Projects (5 functions):** `update_project`, `set_project_category`, `delete_project`, `update_project_image_url`, `update_project_pricing` — added `AND user_id = $N` to WHERE.
163 + - **Items (10 functions):** `update_item`, `delete_item`, `update_item_text`, `update_item_license_settings`, `update_item_license_text`, `move_item`, `bulk_publish`, `bulk_unpublish`, `bulk_delete`, `duplicate_item` — added `AND project_id IN (SELECT id FROM projects WHERE user_id = $N)` to WHERE.
164 + - Updated ~30 call sites across routes/api, routes/pages/dashboard/wizards, and routes/api/internal.
165 + - Skipped benign operations: `bump_cache_generation` (24+ callers, no data exposure), `increment/decrement_sales_count` (webhook-only), `increment_play_count`/`increment_download_count` (public counters), scheduler operations.
166 +
167 + ---
168 +
169 + # Fuzz Audit Round 2 (2026-04-24)
170 +
171 + Adversarial code fuzzing of the MNW server. Five parallel agents attacked payment processing, auth/sessions, file uploads, SyncKit/custom domains, and validation/DB queries.
172 +
173 + ## Critical
174 +
175 + ### 19. Partial refunds treated as full refunds
176 + **Location:** `src/routes/stripe/webhook/billing.rs:212-263`, `src/payments/webhooks.rs:119-129`
177 +
178 + `extract_charge_refunded` discards the `Charge` object, losing `amount_refunded`. The handler unconditionally marks the transaction as `refunded`, decrements `sales_count`, and revokes all license keys and bundle children. Stripe fires `charge.refunded` for both partial and full refunds. A $2 partial refund on a $10 purchase fully revokes access.
179 +
180 + ### 20. Webhook complete_transaction failure silently dropped
181 + **Location:** `src/routes/stripe/webhook/checkout.rs:93-96`
182 +
183 + When `complete_transaction` fails (e.g. transient DB error), the error is logged as a warning but `Ok(())` is returned. The outer webhook handler believes the event succeeded and does not queue a retry. The buyer paid but never gets access.
184 +
185 + ## Serious
186 +
187 + ### 21. Project slug collision on public routes
188 + **Location:** `src/db/projects.rs:209-221`
189 +
190 + `get_public_project_by_slug()` queries `WHERE slug = $1 AND is_public = true LIMIT 1` without user scoping. The UNIQUE constraint is `(user_id, slug)`, not global. Two users with the same project slug produce a nondeterministic result on `/p/{slug}` routes.
191 +
192 + ### 22. Storage counter leaked on rejected file type confirm
193 + **Location:** `src/routes/storage/uploads.rs:164-198`
194 +
195 + `try_increment_storage` runs before the `match file_type` block that rejects Download/Insertion types. Storage counter is incremented, then the request errors out. Never decremented. Orphaned S3 objects accumulate.
196 +
197 + ### 23. Unlimited storage via cover/media image uploads
198 + **Location:** `src/db/creator_tiers.rs:517-518`
199 +
200 + `check_upload_allowed` returns `i64::MAX` for Cover/MediaImage. The `try_increment_storage` WHERE clause always passes. No quota enforcement on these file types.
201 +
202 + ### 24. File replacement doesn't decrement old file storage
203 + **Location:** `src/routes/storage/uploads.rs:164-174`
204 +
205 + Uploading a new audio/cover/video increments the storage counter but never decrements the old file's size or deletes the old S3 object. Counter drifts upward on every replacement.
206 +
207 + ### 25. object_size None treated as 0 bytes
208 + **Location:** `src/routes/storage/uploads.rs:136`, `src/routes/storage/versions.rs:130`, `src/routes/storage/media.rs:210`
209 +
210 + `s3.object_size(&key).await?.unwrap_or(0)` means S3 eventual consistency or transient errors record the file as 0 bytes. File passes size checks and consumes no quota but is served at full size from CDN.
211 +
212 + ### 26. SyncKit auth timing oracle for user enumeration
213 + **Location:** `src/routes/synckit/auth.rs:42-44`
214 +
215 + Unlike the web login (which uses `DUMMY_HASH`), the SyncKit auth endpoint returns immediately on user-not-found. Response time difference reveals whether an email is registered.
216 +
217 + ### 27. 2FA has no per-account attempt limit
218 + **Location:** `src/routes/pages/public/two_factor.rs:57-101`
219 +
220 + Per-IP rate limiting exists, but no account-level counter. `pending_2fa_user_id` persists for the full session lifetime. Distributed attackers bypass IP limits. 6-digit TOTP codes have only 1M possibilities.
221 +
222 + ### 28. Blob confirm TOCTOU — duplicate insert causes 500
223 + **Location:** `src/routes/synckit/blobs.rs:110-128`
224 +
225 + `get_sync_blob_by_hash` then `create_sync_blob` is not atomic. Two concurrent confirms for the same hash both pass the check; second insert hits UNIQUE constraint.
226 +
227 + ### 29. Blob confirm missing size_bytes validation
228 + **Location:** `src/routes/synckit/blobs.rs:88-131`
229 +
230 + Upload endpoint validates `size_bytes`, but confirm endpoint stores whatever the client sends (including 0, negative, or i64::MAX).
231 +
232 + ### 30. No device registration limit in SyncKit
233 + **Location:** `src/routes/synckit/sync.rs:197-218`
234 +
235 + Unlimited devices per (app, user) with distinct names. `get_sync_devices` has `LIMIT 100`, making excess devices invisible but still functional.
236 +
237 + ## Minor
238 +
239 + ### 31. PWYW has no maximum amount
240 + **Location:** `src/pricing.rs:186-195`
241 +
242 + Items/projects accept `amount_cents` up to i32::MAX ($21.4M). Tips correctly cap at $10K.
243 +
244 + ### 32. Revenue split truncation loses cents
245 + **Location:** `src/routes/stripe/webhook/checkout.rs:536-542`
246 +
247 + Integer division `amount * percent / 100` truncates. With 3 members at 33%, 12 cents per $9.99 sale disappear from accounting records.
248 +
249 + ### 33. estimate_stripe_fee negative for sub-31-cent items
250 + **Location:** `src/helpers.rs:269-276`
251 +
252 + Fee of 30 cents exceeds price; `creator_receives` goes negative. Display-only.
253 +
254 + ### 34. No password length cap on login paths
255 + **Location:** `src/routes/auth.rs:128`, `src/routes/synckit/auth.rs:65`
256 +
257 + Signup caps at 128 chars, login doesn't. Multi-MB passwords cause unnecessary Argon2 work.
258 +
259 + ### 35. Session not cycled before storing pending_2fa_user_id
260 + **Location:** `src/routes/auth.rs:172-189`
261 +
262 + Session fixation window during 2FA flow (pending state alone doesn't grant access).
263 +
264 + ### 36. slugify() allows non-ASCII that validate_slug() rejects
265 + **Location:** `src/helpers.rs:79`
266 +
267 + `is_alphanumeric()` accepts Unicode, `validate_slug()` requires ASCII. `Slug::from_trusted()` bypasses validation.
268 +
269 + ### 37. Slug::from_trusted on untrusted URL path params
270 + 23 occurrences across 12 files. Not exploitable (parameterized queries) but violates type invariant.
271 +
272 + ### 38. Deactivated SyncKit app usable for JWT lifetime
273 + **Location:** `src/routes/synckit/sync.rs:29-98`
274 +
275 + `is_active` checked at token issuance, not on each request. 7-day window.
276 +
277 + ### 39. SSE sync_notify entries never cleaned up
278 + **Location:** `src/routes/synckit/subscribe.rs:106-111`
279 +
280 + Slow memory leak proportional to distinct users who have ever used SSE.
281 +
282 + ### 40. ON CONFLICT DO NOTHING without conflict target
283 + **Location:** `src/routes/stripe/checkout/project.rs:89-104`
284 +
285 + Any unique violation silently swallowed, not just duplicate purchases.
286 +
287 + ### 41. Content-type check passes on unrecognized magic bytes
288 + **Location:** `src/scanning/content_type.rs:212-243`
289 +
290 + Small HTML files could bypass infer detection. Potential stored XSS depending on CDN config.
@@ -1,89 +1,39 @@
1 1 # Money Transmitter Licenses
2 2
3 - What MTLs are, why they matter, and where we stand.
3 + If you receive funds from one party and send them to another, most US states require a money transmitter license (MTL). Internationally, the equivalent goes by different names — payment services license, e-money license — but the idea is the same: governments regulate who can move money.
4 4
5 - ## What Is a Money Transmitter License?
5 + Operating without one when you need one is a federal crime. Regulators can fine you, shut you down, or both. Banks won't touch you.
6 6
7 - A money transmitter license (MTL) is a state-level license required in the US to engage in the business of transmitting money. If you receive funds from one party and send them to another, most states require you to be licensed.
7 + ## We Don't Need One Today
8 8
9 - Similar regulations exist internationally under different names—payment services licenses, e-money licenses, etc.
9 + Stripe processes all our payments. Funds flow directly from fans to creators through Stripe Connect. We never receive, hold, or route creator earnings. We're a software platform that uses Stripe's licensed infrastructure — not a payment processor.
10 10
11 - ## Why MTLs Matter
11 + ## When That Would Change
12 12
13 - Without proper licensing, a company handling money transmission can face:
13 + We'd need MTLs if we:
14 14
15 - - **Regulatory action:** Fines, cease-and-desist orders
16 - - **Criminal liability:** Unlicensed money transmission is a federal crime
17 - - **Operational shutdown:** Regulators can force you to stop operating
18 - - **Banking problems:** Banks won't work with unlicensed transmitters
15 + - Processed payments directly instead of through Stripe
16 + - Held creator funds before payout (escrow, delayed disbursement)
17 + - Operated our own payment rails
18 + - Facilitated direct peer-to-peer transfers
19 19
20 - These aren't theoretical risks. Companies have been shut down for operating without proper licenses.
20 + Any of these would require licensing *before* launch, not after.
21 21
22 - ## Our Current Status
22 + ## What Licensing Looks Like
23 23
24 - **We don't currently need MTLs.** Here's why:
24 + **US:** FinCEN registration as a Money Services Business at the federal level, plus individual licenses in 47+ state jurisdictions. The process takes 1-2 years and carries ongoing compliance costs (audits, surety bonds, reporting).
25 25
26 - - **Stripe processes payments:** We don't receive or transmit funds
27 - - **Direct creator payouts:** Money flows from fans to creators via Stripe Connect
28 - - **No fund holding:** We never hold creator earnings
26 + **EU:** Payment Services Directive (PSD2) authorization, or E-Money Directive licensing depending on the model.
29 27
30 - Stripe is the payment processor. We're a software platform that uses Stripe's licensed infrastructure.
28 + **UK:** FCA authorization for payment services.
31 29
32 - ## When We Would Need MTLs
30 + **Canada:** FINTRAC registration plus provincial requirements.
33 31
34 - If we ever:
32 + Every other country has its own framework. There's no shortcut.
35 33
36 - - Process payments directly instead of through Stripe
37 - - Hold creator funds before payout
38 - - Facilitate direct peer-to-peer payments
39 - - Operate our own payment rails
34 + ## Our Position
40 35
41 - ...we would need to obtain appropriate licenses before doing so.
42 -
43 - ## The Licensing Landscape
44 -
45 - ### United States
46 -
47 - Money transmission is regulated at both federal and state levels:
48 -
49 - - **Federal:** FinCEN registration as a Money Services Business (MSB)
50 - - **State:** Individual licenses required in most states (47+ jurisdictions)
51 -
52 - Getting licensed in all states takes 1-2 years and significant ongoing compliance costs.
53 -
54 - ### International
55 -
56 - Every country has its own framework:
57 -
58 - - **EU:** Payment Services Directive (PSD2), E-Money Directive
59 - - **UK:** FCA authorization for payment services
60 - - **Canada:** FINTRAC registration, provincial requirements
61 - - **Others:** Varying requirements by jurisdiction
62 -
63 - ## Our Approach
64 -
65 - We're not rushing to handle payments directly. The current Stripe-based model:
66 -
67 - - Works reliably
68 - - Serves creators well
69 - - Doesn't require us to maintain complex licenses
70 - - Lets us focus on the platform, not payment infrastructure
71 -
72 - If we eventually pursue direct payment handling (see [Payment Independence](./payment-independence.md)), we'll obtain proper licenses first. We won't cut corners on compliance to save money or move faster.
73 -
74 - ## Common Questions
75 -
76 - **"Will you ever handle payments directly?"**
77 -
78 - Possibly, for specific use cases where it benefits creators (lower fees on certain transaction types, for example). Not soon, and not without proper licensing.
79 -
80 - **"Does this limit what you can offer?"**
81 -
82 - Somewhat. Some features (like holding escrow for disputes, or instant creator-to-creator payments) would require licensing. We prioritize features we can offer compliantly.
83 -
84 - **"What if Stripe changes terms or raises prices?"**
85 -
86 - We'd evaluate alternatives—other licensed processors, or potentially obtaining our own licenses. Having options matters, which is why we avoid lock-in.
36 + We're not pursuing MTLs now. The Stripe-based model works, serves creators well, and lets us focus on the platform. If we eventually pursue direct payment handling for specific use cases (micro-transactions, creator-to-creator splits — see [Payment Independence](./payment-independence.md)), we'll get licensed first.
87 37
88 38 ## See Also
89 39
@@ -1,127 +1,50 @@
1 1 # Partnerships
2 2
3 - Our approach to partnerships: we work with organizations that share our values.
3 + We'll work with organizations that align with what we're building. We won't work with ones that don't.
4 4
5 - ## Our Position
5 + ## What This Means in Practice
6 6
7 - We're open to working with organizations that genuinely align with what we're building.
7 + A good partner is an organization we'd recommend to a creator even if we had no business relationship with them. They don't monetize user data, they don't use dark patterns, and they don't treat creators as a resource to extract from.
8 8
9 - ## What We Look For in Partners
9 + We're interested in:
10 10
11 - ### Shared Values
11 + - **Non-profits** in creator advocacy, digital rights, or open source
12 + - **Creator collectives and studios** doing real work for their members
13 + - **Complementary tools** that don't exploit their users
14 + - **Open source projects** we depend on or integrate with
15 + - **Educational programs** teaching creative skills
12 16
13 - Partners must share our commitment to:
17 + We're not interested in organizations that lock users in, surveil them, or have a track record of mistreating creators or workers. If we find out a partner does these things after we've started working together, we stop working with them.
14 18
15 - - **Creator ownership:** Creators own their work, their audience, and their data
16 - - **Transparent economics:** No hidden fees, no extractive practices
17 - - **Privacy:** No surveillance capitalism, no data monetization
18 - - **Sustainability over growth:** Long-term thinking, not growth-at-all-costs
19 + ## Sponsorship
19 20
20 - We don't expect partners to be identical to us. We do expect them to operate ethically and transparently.
21 + We may accept in-kind sponsorship for infrastructure costs — servers, storage, bandwidth. Sponsors get their name on a clearly marked sponsorship page. That's it.
21 22
22 - ### Types of Organizations We Work With
23 + Sponsors don't get placement in the product, access to user data, roadmap influence, or policy exceptions. The sponsorship page is acknowledgment, not advertising.
23 24
24 - - **Non-profits:** Organizations supporting creators, open source, digital rights, or related causes
25 - - **Creator spaces:** Collectives, studios, and communities that help creators thrive
26 - - **Creator tools:** Software and services that complement what we do without exploiting creators
27 - - **Open source projects:** Tools and infrastructure we depend on or integrate with
28 - - **Educational institutions:** Schools, programs, and initiatives teaching creative skills
25 + ## Integration
29 26
30 - ### What We Won't Accept
27 + For organizations doing work we respect, we're open to technical integrations, sharing audiences where it helps creators, or collaborating on shared problems. All partnerships are publicly disclosed — creators always know who we work with.
31 28
32 - We will not partner with organizations that:
29 + ## We Own Our Complexity
33 30
34 - - Monetize user data or enable surveillance
35 - - Use exploitative pricing or lock-in tactics
36 - - Have a pattern of mistreating creators or workers
37 - - Operate in bad faith with their users or partners
38 - - Fail to meet basic ethical standards in their industry
31 + We have a deliberate bias toward doing things ourselves. We run our own infrastructure, build our own integrations, and avoid vendor lock-in even when the managed alternative would be easier. Every external dependency is a cost we pass to creators and a decision we hand to someone else.
39 32
40 - If a partner violates these principles after we've begun working together, we end the relationship.
33 + When we do use external services, we pick them for transparent pricing, open standards, and business practices we're comfortable with. See [economics.md](./economics.md) for what this looks like in practice.
41 34
42 - ## What We Offer
35 + ## Streaming Platform Distribution
43 36
44 - ### Sponsorship Recognition
37 + We plan to offer distribution to Spotify, Apple Music, and other streaming platforms. Creators want their work available where audiences listen, so we'll make that possible.
45 38
46 - We may accept in-kind sponsorship for infrastructure costs—data, hardware, and eventually personnel. In return, sponsors receive recognition on a clearly designated sponsorship page.
39 + To be direct: we don't endorse the economics of most streaming platforms. Their incentive structures don't align with ours, and the per-stream rates are bad for most independent creators. We offer this integration because creators should decide where their art lives. If you want your music on Spotify, we'll get it there. If you don't, that's your call.
47 40
48 - This is not advertising. Sponsors don't get:
49 -
50 - - Placement in the product
51 - - Access to user data
52 - - Influence over our roadmap
53 - - Preferential treatment in our policies
54 -
55 - They get acknowledgment that they helped make this possible. That's it.
56 -
57 - ### Integration and Collaboration
58 -
59 - For aligned organizations, we're open to:
60 -
61 - - **Technical integrations:** Connecting our platform with complementary tools
62 - - **Co-marketing:** Sharing audiences when it benefits creators
63 - - **Knowledge sharing:** Collaborating on problems we both face
64 - - **Open source contributions:** Working together on shared infrastructure
65 -
66 - ### Transparency About the Relationship
67 -
68 - Any partnership will be publicly disclosed. Creators and users will always know who we work with and why.
69 -
70 - ## What We Expect from Partners
71 -
72 - ### Alignment, Not Just Agreement
73 -
74 - We expect partners to genuinely share our values, not just sign a document saying they do. This means:
75 -
76 - - Operating transparently with their own users
77 - - Treating creators and workers fairly
78 - - Avoiding dark patterns and manipulative design
79 - - Being honest about their business model
80 -
81 - ### Reliability
82 -
83 - If you commit to something, follow through. If circumstances change, communicate early. We extend the same courtesy.
84 -
85 - ### No Pressure to Compromise
86 -
87 - Partners must respect that we won't change our principles to accommodate a deal. If our values are incompatible with what you need, we're not the right fit.
88 -
89 - ## Doing Things the Hard Way
90 -
91 - We have a deliberate bias toward taking on complexity ourselves rather than overpaying for managed services. This means:
92 -
93 - - We run our own infrastructure where practical
94 - - We build integrations ourselves when possible
95 - - We avoid vendor lock-in even when it's convenient
96 - - We choose open source over proprietary when the option exists
97 -
98 - Every dependency is a potential point of compromise. Every managed service is a cost we pass to creators.
99 -
100 - When we do use external services, we choose carefully. We prefer providers with transparent pricing, open standards, and ethical business practices.
101 -
102 - ## Community
103 -
104 - Bug reports, documentation suggestions, and feature ideas from the community help shape this platform.
105 -
106 - If you've reported a bug, suggested an improvement, or shared an idea, you're part of this. Thank you.
107 -
108 - ## A Note on Streaming Platforms
109 -
110 - We plan to offer distribution to major streaming platforms (Spotify, Apple Music, etc.) because creators want their work available where audiences listen.
111 -
112 - To be direct: we do not endorse the business models of most streaming platforms. The economics of streaming are often unfavorable to creators, and the platforms' incentive structures don't align with ours.
113 -
114 - We offer this integration because we believe creators should decide where their art lives. If you want your music on Spotify, we'll help you get it there. If you don't, we respect that choice too.
115 -
116 - This is not a partnership. It's infrastructure we provide so creators can reach audiences wherever they are—within limits we're comfortable with.
41 + This isn't a partnership. It's plumbing.
117 42
118 43 ## Contact
119 44
120 - If you represent an organization that aligns with these principles and want to explore working together:
121 -
122 45 [partnerships@makenot.work](mailto:partnerships@makenot.work)
123 46
124 - We read everything. We respond to things that make sense. We don't respond to pitches that clearly didn't read this page.
47 + We read everything. We don't respond to pitches that clearly didn't read this page.
125 48
126 49 ## See Also
127 50
@@ -1,162 +1,65 @@
1 1 # Payment Independence
2 2
3 - Our approach to payment processing: start simple, stay transparent, and work toward giving creators the best possible options.
3 + We use Stripe for everything today. That's fine for now, but long-term we want creators to have options.
4 4
5 5 ## Current State: Stripe
6 6
7 - We use Stripe for all payment processing today. This includes:
7 + All payments run through Stripe:
8 8
9 - - **Fan payments:** Stripe processes purchases and subscriptions
10 - - **Creator payouts:** Stripe Connect routes funds directly to creators
11 - - **Platform billing:** Stripe handles creator subscription fees
9 + - **Fan purchases and subscriptions** go through Stripe Checkout
10 + - **Creator payouts** go through Stripe Connect — funds land in the creator's own Stripe account, not ours
11 + - **Platform billing** (creator subscriptions to us) is also Stripe
12 12
13 - ### Why Stripe First
14 -
15 - Stripe is the pragmatic choice for a new platform:
16 -
17 - - **Fast to implement:** We can focus on building the platform, not payment infrastructure
18 - - **Global reach:** Supports 40+ countries out of the box
19 - - **Creator-friendly:** Funds go directly to creator accounts, not through us
20 - - **Trusted:** Creators and fans already know Stripe
21 -
22 - Building our own payment infrastructure from day one would have delayed everything else by months or years.
23 -
24 - ### What This Means for Creators
25 -
26 - When you receive payments through Makenot.work:
27 -
28 - 1. Fan pays via Stripe (~2.9% + $0.30 per transaction)
29 - 2. Funds go directly to your connected Stripe account
30 - 3. You control payout timing (instant, daily, weekly, monthly)
31 - 4. We never hold your money
32 -
33 - The ~3% you see quoted as "payment processing" is Stripe's fee, not ours. We don't add anything on top.
34 -
35 - ## The Transparency Principle
36 -
37 - Even while using Stripe, we're committed to transparency:
38 -
39 - - **Clear fee breakdown:** You always see exactly what Stripe charges
40 - - **No hidden costs:** We don't mark up payment processing fees
41 - - **Direct relationship:** Your Stripe account is yours, not a sub-account we control
13 + Fan pays ~2.9% + $0.30 per transaction. That fee is Stripe's — we don't add anything on top. Creators control their own payout timing. We never hold funds.
42 14
43 - If Stripe's fees change, you'll know. If we change processors, you'll know. No surprises.
44 -
45 - ## Our Payment Roadmap
46 -
47 - We're not satisfied with "good enough." Our long-term goal is to help creators keep as much of their revenue as possible. The roadmap focuses on **payouts first**—that's where creators feel the cost most directly.
48 -
49 - ### Phase 1: Stripe (Current)
50 -
51 - - Stripe Connect for all payments and payouts
52 - - ~2.9% + $0.30 per transaction (Stripe's standard rate)
53 - - 40+ countries supported
54 - - Creators control their own payout schedules
55 -
56 - This works. It's reliable. But the fees add up, especially for smaller transactions.
57 -
58 - ### Phase 2: Payout Alternatives
59 -
60 - Our first focus is giving creators more payout options:
15 + ### Why Stripe First
61 16
62 - - **ACH direct deposit (US):** Lower fees for US creators who don't need instant access
63 - - **SEPA transfers (EU):** Lower-cost European payouts
64 - - **Crypto options:** For creators who want them, with lower conversion costs
65 - - **Local payment rails:** Country-specific options where they're cheaper
17 + Building payment infrastructure from scratch would have delayed the platform by months. Stripe gave us global reach (40+ countries), direct-to-creator payouts, and a processor fans already trust. The tradeoff is that we depend on a single vendor and eat their fee structure.
66 18
67 - The goal: let creators choose the payout method that works best for them, with full transparency about costs and timing tradeoffs.
19 + ## Where We Want to Go
68 20
69 - ### Phase 3: Payment Processing Options
21 + The goal is to give creators cheaper ways to get paid, starting with payouts (where the fees hurt most) and working toward intake.
70 22
71 - Longer-term, we want to offer alternatives to Stripe for incoming payments too:
23 + **Payout alternatives (next priority):**
72 24
73 - - **Lower-cost processors:** Some alternatives charge less for certain transaction types
74 - - **Regional options:** Local processors may be cheaper in specific markets
75 - - **Direct bank payments:** ACH/SEPA for fans who prefer it
25 + - ACH direct deposit for US creators who don't need instant access
26 + - SEPA transfers for EU creators
27 + - Crypto for creators who want it — with honest disclosure about volatility and tax complexity
28 + - Country-specific payment rails where they're cheaper than Stripe
76 29
77 - This is more complex than payouts—we need to maintain the seamless experience fans expect while offering creators real savings.
30 + **Intake alternatives (longer term):**
78 31
79 - ### Phase 4: Our Own Infrastructure
32 + - Lower-cost processors for specific transaction types
33 + - Regional processors where they beat Stripe on price
34 + - Direct bank payments (ACH/SEPA) for fans who prefer them
80 35
81 - Eventually, we may build or operate our own payment infrastructure for specific use cases:
36 + **Own infrastructure (eventually, maybe):**
82 37
83 - - **Micro-transactions:** Small payments where percentage fees hurt most
84 - - **Creator-to-creator payments:** Revenue splits without double fees
85 - - **Bulk operations:** Batch processing for high-volume creators
38 + - Micro-transactions where percentage fees eat the payment
39 + - Creator-to-creator splits without double-dipping on fees
40 + - Batch processing for high-volume creators
86 41
87 - This requires significant investment and regulatory compliance. We won't rush it—a bad payment experience is worse than a slightly more expensive good one.
42 + This last step requires money transmitter licenses in 47+ US jurisdictions and 1-2 years of regulatory work. We're not doing it until the math justifies it. See [mtl.md](./mtl.md) for details.
88 43
89 44 ## What We Won't Do
90 45
91 - ### Hold Creator Funds
92 -
93 - We don't accumulate creator earnings and pay out later. Funds go directly to creator accounts. This is a fundamental principle, not a temporary convenience.
94 -
95 - ### Obscure Fees
46 + **Hold creator funds.** Money goes directly to creator accounts. This is structural, not a courtesy — we never want to be in a position where we're sitting on someone else's earnings.
96 47
97 - You'll always know what you're paying for payment processing and why. If we negotiate volume discounts with processors, those savings go to creators.
48 + **Obscure fees.** You see what Stripe charges. If we negotiate volume discounts, those savings go to creators.
98 49
99 - ### Lock You In
50 + **Lock you in.** Your Stripe account is yours. Your customer relationships are yours. If you leave, you take them with you.
100 51
101 - If you want to use a different payment method we support, you can switch. If you want to leave the platform, your payment relationships are yours—we don't own your Stripe account or customer relationships.
52 + ## Unsupported Countries
102 53
103 - ### Compromise Security
104 -
105 - Lower costs aren't worth fraud risk. Any payment option we offer will meet the same security standards as Stripe.
106 -
107 - ## Regulatory Reality
108 -
109 - Payment processing is heavily regulated. Some of what we'd like to do requires:
110 -
111 - - **Money transmitter licenses:** Required in most US states to handle funds
112 - - **PCI compliance:** Required for handling card data directly
113 - - **International banking relationships:** Required for cross-border payouts
114 - - **Know Your Customer (KYC):** Required for anti-money-laundering compliance
115 -
116 - We're not going to cut corners on compliance. This means some improvements will take time—but they'll be done right.
117 -
118 - ## For Creators in Unsupported Countries
119 -
120 - Stripe doesn't operate everywhere. If you're in a country Stripe doesn't support:
121 -
122 - - Contact us—we track demand by region
123 - - We're evaluating alternative processors for underserved markets
124 - - Some payout alternatives may be available sooner than full Stripe support
125 -
126 - We want to serve creators globally. Geography shouldn't determine whether you can get paid.
127 -
128 - ## Questions We Get
129 -
130 - **"Why not build your own payment system now?"**
131 -
132 - Because it would take years and delay everything else. Stripe lets us ship a working platform while we plan for the future. Perfect is the enemy of good.
133 -
134 - **"Will you ever charge more than Stripe?"**
135 -
136 - No. We might offer options that cost more (instant payouts, for example), but we won't mark up the base processing fees. Our revenue comes from subscriptions, not payment processing.
137 -
138 - **"What about crypto?"**
139 -
140 - We're interested, but carefully. Crypto can offer lower fees for certain use cases, but it also introduces volatility, tax complexity, and user experience challenges. We'll offer it when we can do it well, not as a gimmick.
141 -
142 - **"Can I use my own payment processor?"**
143 -
144 - Not today. Integrating arbitrary processors would be a significant engineering effort. If there's demand for a specific processor, let us know—we prioritize based on creator needs.
54 + Stripe doesn't operate everywhere. If you're in a country without Stripe support, contact us — we track demand by region and it influences which alternative processors we evaluate first.
145 55
146 56 ## Timeline
147 57
148 - We don't give timeline predictions. Payment infrastructure is complex, and regulatory requirements vary by jurisdiction.
149 -
150 - What we can say:
151 -
152 - - Stripe is working now and will continue working
153 - - Payout alternatives are our next priority
154 - - We'll announce options as they become available
155 - - We won't rush something that handles your money
58 + We don't give timeline predictions for payment infrastructure. The regulatory surface is large and the consequences of getting it wrong are serious. Stripe works now and will keep working. We'll announce alternatives as they're ready.
156 59
157 60 ## See Also
158 61
159 - - [Receiving Payouts](../creator/payouts.md) — how payouts work today
160 62 - [Economics](./economics.md) — where payment costs fit in our model
161 - - [Roadmap](../../public/about/roadmap.md) — broader platform roadmap including payments
63 + - [Money Transmitter Licenses](./mtl.md) — regulatory requirements for direct payment handling
162 64 - [Partnerships](./partnerships.md) — how we choose vendors
65 + - [Roadmap](../../public/about/roadmap.md) — broader platform roadmap
@@ -0,0 +1,4 @@
1 + -- Link child transactions (from bundle grants) to the parent bundle transaction.
2 + -- Enables revoking child item access when a bundle purchase is refunded.
3 + ALTER TABLE transactions ADD COLUMN parent_transaction_id UUID REFERENCES transactions(id) ON DELETE SET NULL;
4 + CREATE INDEX idx_transactions_parent_id ON transactions(parent_transaction_id) WHERE parent_transaction_id IS NOT NULL;
@@ -0,0 +1,6 @@
1 + -- Prevent two public projects from claiming the same slug.
2 + -- The /p/{slug} routes resolve by slug alone, so at most one
3 + -- public project may own a given slug at a time.
4 + CREATE UNIQUE INDEX idx_projects_public_slug
5 + ON projects (slug)
6 + WHERE is_public = true;
@@ -41,6 +41,7 @@ pub const SYNCKIT_API_KEY_LENGTH: usize = 32; // 32 bytes = 64 hex chars
41 41 pub const SYNC_LOG_RETAIN_DAYS: i64 = 90;
42 42 pub const SYNCKIT_MAX_BLOB_SIZE_BYTES: i64 = 500 * 1024 * 1024; // 500 MB
43 43 pub const SYNCKIT_BLOB_PRESIGN_EXPIRY_SECS: u64 = 3600; // 1 hour
44 + pub const SYNCKIT_MAX_SSE_CONNECTIONS_PER_USER: usize = 10;
44 45
45 46 // -- Subscriptions --
46 47 pub const MIN_SUBSCRIPTION_PRICE_CENTS: i32 = 100; // $1.00 minimum
@@ -150,7 +150,7 @@ pub async fn get_revenue_timeseries(
150 150 r#"
151 151 SELECT
152 152 date_trunc('{bucket}', completed_at) AS bucket,
153 - COALESCE(SUM(amount_cents), 0),
153 + COALESCE(SUM(amount_cents), 0)::BIGINT,
154 154 COUNT(*)
155 155 FROM transactions
156 156 WHERE seller_id = $1
@@ -173,7 +173,7 @@ pub async fn get_revenue_timeseries(
173 173 r#"
174 174 SELECT
175 175 date_trunc('{bucket}', completed_at) AS bucket,
176 - COALESCE(SUM(amount_cents), 0),
176 + COALESCE(SUM(amount_cents), 0)::BIGINT,
177 177 COUNT(*)
178 178 FROM transactions
179 179 WHERE seller_id = $1
@@ -199,7 +199,7 @@ pub async fn get_revenue_timeseries(
199 199 r#"
200 200 SELECT
201 201 date_trunc('{bucket}', t.completed_at) AS bucket,
202 - COALESCE(SUM(t.amount_cents), 0),
202 + COALESCE(SUM(t.amount_cents), 0)::BIGINT,
203 203 COUNT(*)
204 204 FROM transactions t
205 205 WHERE t.seller_id = $1
@@ -222,7 +222,7 @@ pub async fn get_revenue_timeseries(
222 222 r#"
223 223 SELECT
224 224 date_trunc('{bucket}', t.completed_at) AS bucket,
225 - COALESCE(SUM(t.amount_cents), 0),
225 + COALESCE(SUM(t.amount_cents), 0)::BIGINT,
226 226 COUNT(*)
227 227 FROM transactions t
228 228 WHERE t.seller_id = $1
@@ -248,7 +248,7 @@ pub async fn get_revenue_timeseries(
248 248 r#"
249 249 SELECT
250 250 date_trunc('{bucket}', completed_at) AS bucket,
251 - COALESCE(SUM(amount_cents), 0),
251 + COALESCE(SUM(amount_cents), 0)::BIGINT,
252 252 COUNT(*)
253 253 FROM transactions
254 254 WHERE seller_id = $1
@@ -269,7 +269,7 @@ pub async fn get_revenue_timeseries(
269 269 r#"
270 270 SELECT
271 271 date_trunc('{bucket}', completed_at) AS bucket,
272 - COALESCE(SUM(amount_cents), 0),
272 + COALESCE(SUM(amount_cents), 0)::BIGINT,
273 273 COUNT(*)
274 274 FROM transactions
275 275 WHERE seller_id = $1
@@ -342,7 +342,7 @@ async fn get_transaction_comparison(
342 342 sqlx::query_as(
343 343 r#"
344 344 SELECT
345 - COALESCE(SUM(amount_cents), 0),
345 + COALESCE(SUM(amount_cents), 0)::BIGINT,
346 346 COUNT(*)
347 347 FROM transactions
348 348 WHERE seller_id = $1 AND item_id = $2 AND status = 'completed'
@@ -357,7 +357,7 @@ async fn get_transaction_comparison(
357 357 sqlx::query_as(
358 358 r#"
359 359 SELECT
360 - COALESCE(SUM(t.amount_cents), 0),
360 + COALESCE(SUM(t.amount_cents), 0)::BIGINT,
361 361 COUNT(*)
362 362 FROM transactions t
363 363 WHERE t.seller_id = $1
@@ -374,7 +374,7 @@ async fn get_transaction_comparison(
374 374 sqlx::query_as(
375 375 r#"
376 376 SELECT
377 - COALESCE(SUM(amount_cents), 0),
377 + COALESCE(SUM(amount_cents), 0)::BIGINT,
378 378 COUNT(*)
379 379 FROM transactions
380 380 WHERE seller_id = $1 AND status = 'completed'
@@ -395,9 +395,9 @@ async fn get_transaction_comparison(
395 395 &format!(
396 396 r#"
397 397 SELECT
398 - COALESCE(SUM(amount_cents) FILTER (WHERE completed_at >= NOW() - INTERVAL '{interval}'), 0),
398 + COALESCE(SUM(amount_cents) FILTER (WHERE completed_at >= NOW() - INTERVAL '{interval}'), 0)::BIGINT,
399 399 COUNT(*) FILTER (WHERE completed_at >= NOW() - INTERVAL '{interval}'),
400 - COALESCE(SUM(amount_cents) FILTER (WHERE completed_at < NOW() - INTERVAL '{interval}'), 0),
400 + COALESCE(SUM(amount_cents) FILTER (WHERE completed_at < NOW() - INTERVAL '{interval}'), 0)::BIGINT,
401 401 COUNT(*) FILTER (WHERE completed_at < NOW() - INTERVAL '{interval}')
402 402 FROM transactions
403 403 WHERE seller_id = $1
@@ -417,9 +417,9 @@ async fn get_transaction_comparison(
417 417 &format!(
418 418 r#"
419 419 SELECT
420 - COALESCE(SUM(t.amount_cents) FILTER (WHERE t.completed_at >= NOW() - INTERVAL '{interval}'), 0),
420 + COALESCE(SUM(t.amount_cents) FILTER (WHERE t.completed_at >= NOW() - INTERVAL '{interval}'), 0)::BIGINT,
421 421 COUNT(*) FILTER (WHERE t.completed_at >= NOW() - INTERVAL '{interval}'),
422 - COALESCE(SUM(t.amount_cents) FILTER (WHERE t.completed_at < NOW() - INTERVAL '{interval}'), 0),
422 + COALESCE(SUM(t.amount_cents) FILTER (WHERE t.completed_at < NOW() - INTERVAL '{interval}'), 0)::BIGINT,
423 423 COUNT(*) FILTER (WHERE t.completed_at < NOW() - INTERVAL '{interval}')
424 424 FROM transactions t
425 425 WHERE t.seller_id = $1
@@ -439,9 +439,9 @@ async fn get_transaction_comparison(
439 439 &format!(
440 440 r#"
441 441 SELECT
442 - COALESCE(SUM(amount_cents) FILTER (WHERE completed_at >= NOW() - INTERVAL '{interval}'), 0),
442 + COALESCE(SUM(amount_cents) FILTER (WHERE completed_at >= NOW() - INTERVAL '{interval}'), 0)::BIGINT,
443 443 COUNT(*) FILTER (WHERE completed_at >= NOW() - INTERVAL '{interval}'),
444 - COALESCE(SUM(amount_cents) FILTER (WHERE completed_at < NOW() - INTERVAL '{interval}'), 0),
444 + COALESCE(SUM(amount_cents) FILTER (WHERE completed_at < NOW() - INTERVAL '{interval}'), 0)::BIGINT,
445 445 COUNT(*) FILTER (WHERE completed_at < NOW() - INTERVAL '{interval}')
446 446 FROM transactions
447 447 WHERE seller_id = $1
@@ -7,23 +7,50 @@ use super::models::*;
7 7 use super::UserId;
8 8 use crate::error::Result;
9 9
10 - /// Increment failed login attempts for a user
10 + /// Result of an atomic failed-login increment.
11 + pub struct FailedLoginResult {
12 + /// New failed_login_attempts count after increment.
13 + pub attempts: i32,
14 + /// Whether the account was just locked by this increment.
15 + pub just_locked: bool,
16 + }
17 +
18 + /// Atomically increment failed login attempts and lock the account if the
19 + /// threshold is reached. This prevents race conditions where concurrent
20 + /// requests could each pass the lockout check before either increments.
21 + ///
22 + /// The UPDATE uses a single SQL statement with conditional locked_until
23 + /// assignment, so PostgreSQL's row-level locking serializes concurrent callers.
11 24 #[tracing::instrument(skip_all)]
12 - pub async fn increment_failed_login(pool: &PgPool, user_id: UserId) -> Result<i32> {
13 - let attempts: i32 = sqlx::query_scalar(
25 + pub async fn increment_failed_login(
26 + pool: &PgPool,
27 + user_id: UserId,
28 + max_attempts: i32,
29 + lockout_minutes: i64,
30 + ) -> Result<FailedLoginResult> {
31 + let row: (i32, bool) = sqlx::query_as(
14 32 r#"
15 33 UPDATE users
16 34 SET failed_login_attempts = failed_login_attempts + 1,
17 - last_failed_login_at = NOW()
35 + last_failed_login_at = NOW(),
36 + locked_until = CASE
37 + WHEN failed_login_attempts + 1 >= $2 THEN NOW() + ($3 || ' minutes')::interval
38 + ELSE locked_until
39 + END
18 40 WHERE id = $1
19 - RETURNING failed_login_attempts
41 + RETURNING failed_login_attempts, (failed_login_attempts >= $2) AS just_locked
20 42 "#,
21 43 )
22 44 .bind(user_id)
45 + .bind(max_attempts)
46 + .bind(lockout_minutes.to_string())
23 47 .fetch_one(pool)
24 48 .await?;
25 49
26 - Ok(attempts)
50 + Ok(FailedLoginResult {
51 + attempts: row.0,
52 + just_locked: row.1,
53 + })
27 54 }
28 55
29 56 /// Reset failed login attempts (on successful login)
@@ -39,18 +66,6 @@ pub async fn reset_failed_login(pool: &PgPool, user_id: UserId) -> Result<()> {
39 66 Ok(())
40 67 }
41 68
42 - /// Lock a user account until a specific time
43 - #[tracing::instrument(skip_all)]
44 - pub async fn lock_account(pool: &PgPool, user_id: UserId, until: DateTime<Utc>) -> Result<()> {
45 - sqlx::query("UPDATE users SET locked_until = $2 WHERE id = $1")
46 - .bind(user_id)
47 - .bind(until)
48 - .execute(pool)
49 - .await?;
50 -
51 - Ok(())
52 - }
53 -
54 69 /// Create a one-time login token
55 70 #[tracing::instrument(skip_all)]
56 71 pub async fn create_login_token(
@@ -200,6 +200,7 @@ pub async fn get_public_items_by_project(pool: &PgPool, project_id: ProjectId) -
200 200 pub async fn update_item(
201 201 pool: &PgPool,
202 202 id: ItemId,
203 + user_id: UserId,
203 204 title: Option<&str>,
204 205 description: Option<&str>,
205 206 price_cents: Option<i32>,
@@ -211,27 +212,29 @@ pub async fn update_item(
211 212 web_only: Option<bool>,
212 213 ) -> Result<DbItem> {
213 214 // Flatten the double-Option: if outer is None, pass current DB value (via SQL CASE).
214 - // $9 = whether to update publish_at, $10 = the new value (NULL to clear).
215 + // $10 = whether to update publish_at, $11 = the new value (NULL to clear).
215 216 let update_publish_at = publish_at.is_some();
216 217 let publish_at_value = publish_at.flatten();
217 218
218 219 let item = sqlx::query_as::<_, DbItem>(
219 220 r#"
220 221 UPDATE items
221 - SET title = COALESCE($2, title),
222 - description = COALESCE($3, description),
223 - price_cents = COALESCE($4, price_cents),
224 - item_type = COALESCE($5, item_type),
225 - is_public = COALESCE($6, is_public),
226 - pwyw_enabled = COALESCE($7, pwyw_enabled),
227 - pwyw_min_cents = COALESCE($8, pwyw_min_cents),
228 - publish_at = CASE WHEN $9 THEN $10 ELSE publish_at END,
229 - web_only = COALESCE($11, web_only)
222 + SET title = COALESCE($3, title),
223 + description = COALESCE($4, description),
224 + price_cents = COALESCE($5, price_cents),
225 + item_type = COALESCE($6, item_type),
226 + is_public = COALESCE($7, is_public),
227 + pwyw_enabled = COALESCE($8, pwyw_enabled),
228 + pwyw_min_cents = COALESCE($9, pwyw_min_cents),
229 + publish_at = CASE WHEN $10 THEN $11 ELSE publish_at END,
230 + web_only = COALESCE($12, web_only)
230 231 WHERE id = $1
232 + AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
231 233 RETURNING *
232 234 "#,
233 235 )
234 236 .bind(id)
237 + .bind(user_id)
235 238 .bind(title)
236 239 .bind(description)
237 240 .bind(price_cents)
@@ -270,11 +273,14 @@ pub async fn publish_scheduled_items(pool: &PgPool) -> Result<Vec<DbItem>> {
270 273
271 274 /// Permanently delete an item by ID.
272 275 #[tracing::instrument(skip_all)]
273 - pub async fn delete_item(pool: &PgPool, id: ItemId) -> Result<()> {
274 - sqlx::query("DELETE FROM items WHERE id = $1")
275 - .bind(id)
276 - .execute(pool)
277 - .await?;
276 + pub async fn delete_item(pool: &PgPool, id: ItemId, user_id: UserId) -> Result<()> {
277 + sqlx::query(
278 + "DELETE FROM items WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $2)",
279 + )
280 + .bind(id)
281 + .bind(user_id)
282 + .execute(pool)
283 + .await?;
278 284
279 285 Ok(())
280 286 }
@@ -371,19 +377,21 @@ pub async fn get_item_owner(pool: &PgPool, item_id: ItemId) -> Result<Option<Use
371 377 /// Reading time uses ~200 wpm (average adult reading speed) with floor
372 378 /// division, clamped to a minimum of 1 minute so the UI never shows "0 min".
373 379 #[tracing::instrument(skip_all)]
374 - pub async fn update_item_text(pool: &PgPool, id: ItemId, body: &str) -> Result<DbItem> {
380 + pub async fn update_item_text(pool: &PgPool, id: ItemId, user_id: UserId, body: &str) -> Result<DbItem> {
375 381 let word_count = body.split_whitespace().count() as i32;
376 382 let reading_time = (word_count / 200).max(1);
377 383
378 384 let item = sqlx::query_as::<_, DbItem>(
379 385 r#"
380 386 UPDATE items
381 - SET body = $2, word_count = $3, reading_time_minutes = $4, updated_at = NOW()
387 + SET body = $3, word_count = $4, reading_time_minutes = $5, updated_at = NOW()
382 388 WHERE id = $1
389 + AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
383 390 RETURNING *
384 391 "#,
385 392 )
386 393 .bind(id)
394 + .bind(user_id)
387 395 .bind(body)
388 396 .bind(word_count)
389 397 .bind(reading_time)
@@ -398,17 +406,20 @@ pub async fn update_item_text(pool: &PgPool, id: ItemId, body: &str) -> Result<D
398 406 pub async fn update_item_license_settings(
399 407 pool: &PgPool,
400 408 item_id: ItemId,
409 + user_id: UserId,
401 410 enable_license_keys: bool,
402 411 default_max_activations: Option<i32>,
403 412 ) -> Result<()> {
404 413 sqlx::query(
405 414 r#"
406 415 UPDATE items
407 - SET enable_license_keys = $2, default_max_activations = $3, updated_at = NOW()
416 + SET enable_license_keys = $3, default_max_activations = $4, updated_at = NOW()
408 417 WHERE id = $1
418 + AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
409 419 "#,
410 420 )
411 421 .bind(item_id)
422 + .bind(user_id)
412 423 .bind(enable_license_keys)
413 424 .bind(default_max_activations)
414 425 .execute(pool)
@@ -422,17 +433,20 @@ pub async fn update_item_license_settings(
422 433 pub async fn update_item_license_text(
423 434 pool: &PgPool,
424 435 item_id: ItemId,
436 + user_id: UserId,
425 437 license_preset: Option<&str>,
426 438 custom_license_text: Option<&str>,
427 439 ) -> Result<()> {
428 440 sqlx::query(
429 441 r#"
430 442 UPDATE items
431 - SET license_preset = $2, custom_license_text = $3, updated_at = NOW()
443 + SET license_preset = $3, custom_license_text = $4, updated_at = NOW()
432 444 WHERE id = $1
445 + AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
433 446 "#,
434 447 )
435 448 .bind(item_id)
449 + .bind(user_id)
436 450 .bind(license_preset)
437 451 .bind(custom_license_text)
438 452 .execute(pool)
@@ -573,16 +587,23 @@ pub async fn increment_item_download_count(pool: &PgPool, item_id: ItemId) -> Re
573 587 pub async fn move_item(
574 588 pool: &PgPool,
575 589 project_id: ProjectId,
590 + user_id: UserId,
576 591 item_id: ItemId,
577 592 direction: &str,
578 593 ) -> Result<()> {
579 594 let mut tx = pool.begin().await?;
580 595
581 - // Lock and fetch item IDs in display order
596 + // Lock and fetch item IDs in display order (scoped to projects owned by user)
582 597 let item_ids: Vec<ItemId> = sqlx::query_scalar(
583 - "SELECT id FROM items WHERE project_id = $1 ORDER BY sort_order, created_at DESC LIMIT 500 FOR UPDATE",
598 + r#"
599 + SELECT id FROM items
600 + WHERE project_id = $1
601 + AND project_id IN (SELECT id FROM projects WHERE user_id = $2)
602 + ORDER BY sort_order, created_at DESC LIMIT 500 FOR UPDATE
603 + "#,
584 604 )
585 605 .bind(project_id)
606 + .bind(user_id)
586 607 .fetch_all(&mut *tx)
587 608 .await?;
588 609
@@ -624,16 +645,19 @@ pub async fn bulk_publish(
624 645 pool: &PgPool,
625 646 item_ids: &[ItemId],
626 647 project_id: ProjectId,
648 + user_id: UserId,
627 649 ) -> Result<u64> {
628 650 let result = sqlx::query(
629 651 r#"
630 652 UPDATE items
631 653 SET is_public = true, publish_at = NULL, updated_at = NOW()
632 654 WHERE id = ANY($1) AND project_id = $2
655 + AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
633 656 "#,
634 657 )
635 658 .bind(item_ids)
636 659 .bind(project_id)
660 + .bind(user_id)
637 661 .execute(pool)
638 662 .await?;
639 663
@@ -648,16 +672,19 @@ pub async fn bulk_unpublish(
648 672 pool: &PgPool,
649 673 item_ids: &[ItemId],
650 674 project_id: ProjectId,
675 + user_id: UserId,
651 676 ) -> Result<u64> {
652 677 let result = sqlx::query(
653 678 r#"
654 679 UPDATE items
655 680 SET is_public = false, updated_at = NOW()
656 681 WHERE id = ANY($1) AND project_id = $2
682 + AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
657 683 "#,
658 684 )
659 685 .bind(item_ids)
660 686 .bind(project_id)
687 + .bind(user_id)
661 688 .execute(pool)
662 689 .await?;
663 690
@@ -672,12 +699,17 @@ pub async fn bulk_delete(
672 699 pool: &PgPool,
673 700 item_ids: &[ItemId],
674 701 project_id: ProjectId,
702 + user_id: UserId,
675 703 ) -> Result<u64> {
676 704 let result = sqlx::query(
677 - "DELETE FROM items WHERE id = ANY($1) AND project_id = $2",
705 + r#"
706 + DELETE FROM items WHERE id = ANY($1) AND project_id = $2
707 + AND project_id IN (SELECT id FROM projects WHERE user_id = $3)
708 + "#,
678 709 )
679 710 .bind(item_ids)
680 711 .bind(project_id)
712 + .bind(user_id)
681 713 .execute(pool)
682 714 .await?;
683 715
@@ -689,14 +721,17 @@ pub async fn bulk_delete(
689 721 /// Creates a draft copy with "Copy of …" title. Does not copy versions (S3 files),
690 722 /// license keys, download codes, or discount codes.
691 723 #[tracing::instrument(skip_all)]
692 - pub async fn duplicate_item(pool: &PgPool, source_id: ItemId) -> Result<DbItem> {
724 + pub async fn duplicate_item(pool: &PgPool, source_id: ItemId, user_id: UserId) -> Result<DbItem> {
693 725 let mut tx = pool.begin().await?;
694 726
695 - // Generate a unique slug for the copy
696 - let source = sqlx::query_as::<_, DbItem>("SELECT * FROM items WHERE id = $1")
697 - .bind(source_id)
698 - .fetch_one(&mut *tx)
699 - .await?;
727 + // Generate a unique slug for the copy (verify ownership via project)
728 + let source = sqlx::query_as::<_, DbItem>(
729 + "SELECT * FROM items WHERE id = $1 AND project_id IN (SELECT id FROM projects WHERE user_id = $2)",
730 + )
731 + .bind(source_id)
732 + .bind(user_id)
733 + .fetch_one(&mut *tx)
734 + .await?;
700 735 let copy_title = format!("Copy of {}", &source.title);
701 736 let copy_title: String = copy_title.chars().take(200).collect();
702 737 let mut slug = crate::helpers::slugify(&copy_title);
@@ -329,12 +329,12 @@ pub async fn revoke_keys_by_transaction(
329 329 .execute(&mut *conn)
330 330 .await?;
331 331
332 - // Deactivate all activations for those keys
333 - for key_id in &key_ids {
332 + // Deactivate all activations for those keys in a single query
333 + if !key_ids.is_empty() {
334 334 sqlx::query(
335 - "UPDATE license_activations SET is_active = false WHERE license_key_id = $1",
335 + "UPDATE license_activations SET is_active = false WHERE license_key_id = ANY($1)",
336 336 )
337 - .bind(key_id)
337 + .bind(&key_ids)
338 338 .execute(&mut *conn)
339 339 .await?;
340 340 }
@@ -58,6 +58,8 @@ pub struct DbTransaction {
58 58 pub share_contact: bool,
59 59 /// Purchased project ID (for project-level purchases). Nullable.
60 60 pub project_id: Option<ProjectId>,
61 + /// Parent bundle transaction that granted this child item. Nullable.
62 + pub parent_transaction_id: Option<TransactionId>,
61 63 }
62 64
63 65 impl DbTransaction {
@@ -148,6 +150,7 @@ mod tests {
148 150 seller_username: None,
149 151 share_contact: false,
150 152 project_id: None,
153 + parent_transaction_id: None,
151 154 }
152 155 }
153 156
@@ -74,14 +74,22 @@ pub async fn list_releases(pool: &PgPool, app_id: SyncAppId) -> Result<Vec<DbOta
74 74 Ok(releases)
75 75 }
76 76
77 - /// Get the latest release for an app (by pub_date).
77 + /// Get the latest release for an app by semantic version (highest version wins).
78 + ///
79 + /// Falls back to pub_date ordering if version parts aren't numeric.
78 80 #[tracing::instrument(skip_all)]
79 81 pub async fn get_latest_release(
80 82 pool: &PgPool,
81 83 app_id: SyncAppId,
82 84 ) -> Result<Option<DbOtaRelease>> {
83 85 let release = sqlx::query_as::<_, DbOtaRelease>(
84 - "SELECT * FROM ota_releases WHERE app_id = $1 ORDER BY pub_date DESC LIMIT 1",
86 + r#"
87 + SELECT * FROM ota_releases WHERE app_id = $1
88 + ORDER BY
89 + (string_to_array(split_part(version, '-', 1), '.'))::int[] DESC,
90 + pub_date DESC
91 + LIMIT 1
92 + "#,
85 93 )
86 94 .bind(app_id)
87 95 .fetch_optional(pool)
@@ -22,11 +22,26 @@ pub async fn add_project_member(
22 22 split_percent: i16,
23 23 added_by: UserId,
24 24 ) -> Result<DbProjectMember> {
25 - let current_total = get_total_split_percent(pool, project_id).await?;
26 - if current_total + split_percent as i64 > 100 {
25 + let mut tx = pool.begin().await?;
26 +
27 + // Lock all existing members to prevent concurrent split modifications
28 + let current_total: (Option<i64>,) = sqlx::query_as(
29 + r#"
30 + SELECT SUM(split_percent)::BIGINT
31 + FROM project_members
32 + WHERE project_id = $1
33 + FOR UPDATE
34 + "#,
35 + )
36 + .bind(project_id)
37 + .fetch_one(&mut *tx)
38 + .await?;
39 +
40 + let total = current_total.0.unwrap_or(0);
41 + if total + split_percent as i64 > 100 {
27 42 return Err(AppError::BadRequest(format!(
28 43 "Total split would be {}%, exceeding 100%",
29 - current_total + split_percent as i64
44 + total + split_percent as i64
30 45 )));
31 46 }
32 47
@@ -45,9 +60,10 @@ pub async fn add_project_member(
45 60 .bind(role)
46 61 .bind(split_percent)
47 62 .bind(added_by)
48 - .fetch_one(pool)
63 + .fetch_one(&mut *tx)
49 64 .await?;
50 65
66 + tx.commit().await?;
51 67 Ok(member)
52 68 }
53 69
@@ -113,16 +129,32 @@ pub async fn update_member_split(
113 129 user_id: UserId,
114 130 new_split_percent: i16,
115 131 ) -> Result<()> {
132 + let mut tx = pool.begin().await?;
133 +
134 + // Lock all members for this project to prevent concurrent split modifications
116 135 let current: Option<DbProjectMember> = sqlx::query_as(
117 - "SELECT * FROM project_members WHERE project_id = $1 AND user_id = $2",
136 + "SELECT * FROM project_members WHERE project_id = $1 AND user_id = $2 FOR UPDATE",
118 137 )
119 138 .bind(project_id)
120 139 .bind(user_id)
121 - .fetch_optional(pool)
140 + .fetch_optional(&mut *tx)
122 141 .await?;
123 142
124 143 let current_member_split = current.map(|m| m.split_percent as i64).unwrap_or(0);
125 - let total = get_total_split_percent(pool, project_id).await?;
144 +
145 + let total_row: (Option<i64>,) = sqlx::query_as(
146 + r#"
147 + SELECT SUM(split_percent)::BIGINT
148 + FROM project_members
149 + WHERE project_id = $1
150 + FOR UPDATE
151 + "#,
152 + )
153 + .bind(project_id)
154 + .fetch_one(&mut *tx)
155 + .await?;
156 +
157 + let total = total_row.0.unwrap_or(0);
126 158 let new_total = total - current_member_split + new_split_percent as i64;
127 159 if new_total > 100 {
128 160 return Err(AppError::BadRequest(format!(
@@ -137,9 +169,10 @@ pub async fn update_member_split(
137 169 .bind(project_id)
138 170 .bind(user_id)
139 171 .bind(new_split_percent)
140 - .execute(pool)
172 + .execute(&mut *tx)
141 173 .await?;
142 174
175 + tx.commit().await?;
143 176 Ok(())
144 177 }
145 178
@@ -152,20 +185,22 @@ pub async fn create_tip_splits(
152 185 tip_id: TipId,
153 186 splits: &[(UserId, i32, i16)], // (recipient_id, amount_cents, split_percent)
154 187 ) -> Result<()> {
155 - for (recipient_id, amount_cents, split_percent) in splits {
156 - sqlx::query(
157 - r#"
158 - INSERT INTO revenue_splits (tip_id, recipient_id, amount_cents, split_percent, status)
159 - VALUES ($1, $2, $3, $4, 'pending')
160 - "#,
161 - )
162 - .bind(tip_id)
163 - .bind(recipient_id)
164 - .bind(amount_cents)
165 - .bind(split_percent)
166 - .execute(pool)
167 - .await?;
168 - }
188 + if splits.is_empty() { return Ok(()); }
189 + let recipient_ids: Vec<UserId> = splits.iter().map(|(id, _, _)| *id).collect();
190 + let amounts: Vec<i32> = splits.iter().map(|(_, a, _)| *a).collect();
191 + let percents: Vec<i16> = splits.iter().map(|(_, _, p)| *p).collect();
192 + sqlx::query(
193 + r#"
194 + INSERT INTO revenue_splits (tip_id, recipient_id, amount_cents, split_percent, status)
195 + SELECT $1, UNNEST($2::uuid[]), UNNEST($3::int[]), UNNEST($4::smallint[]), 'pending'
196 + "#,
197 + )
198 + .bind(tip_id)
199 + .bind(&recipient_ids)
200 + .bind(&amounts)
201 + .bind(&percents)
202 + .execute(pool)
203 + .await?;
169 204 Ok(())
170 205 }
171 206
@@ -176,20 +211,22 @@ pub async fn create_transaction_splits(
176 211 transaction_id: TransactionId,
177 212 splits: &[(UserId, i32, i16)], // (recipient_id, amount_cents, split_percent)
178 213 ) -> Result<()> {
179 - for (recipient_id, amount_cents, split_percent) in splits {
180 - sqlx::query(
181 - r#"
182 - INSERT INTO revenue_splits (transaction_id, recipient_id, amount_cents, split_percent, status)
183 - VALUES ($1, $2, $3, $4, 'pending')
184 - "#,
185 - )
186 - .bind(transaction_id)
187 - .bind(recipient_id)
188 - .bind(amount_cents)
189 - .bind(split_percent)
190 - .execute(pool)
191 - .await?;
192 - }
214 + if splits.is_empty() { return Ok(()); }
215 + let recipient_ids: Vec<UserId> = splits.iter().map(|(id, _, _)| *id).collect();
216 + let amounts: Vec<i32> = splits.iter().map(|(_, a, _)| *a).collect();
217 + let percents: Vec<i16> = splits.iter().map(|(_, _, p)| *p).collect();
218 + sqlx::query(
219 + r#"
220 + INSERT INTO revenue_splits (transaction_id, recipient_id, amount_cents, split_percent, status)
221 + SELECT $1, UNNEST($2::uuid[]), UNNEST($3::int[]), UNNEST($4::smallint[]), 'pending'
222 + "#,
223 + )
224 + .bind(transaction_id)
225 + .bind(&recipient_ids)
226 + .bind(&amounts)
227 + .bind(&percents)
228 + .execute(pool)
229 + .await?;
193 230 Ok(())
194 231 }
195 232
@@ -108,6 +108,7 @@ pub async fn get_projects_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<
108 108 pub async fn update_project(
109 109 pool: &PgPool,
110 110 id: ProjectId,
111 + user_id: UserId,
111 112 title: Option<&str>,
112 113 description: Option<&str>,
113 114 features: Option<&[String]>,
@@ -117,16 +118,17 @@ pub async fn update_project(
117 118 let project = sqlx::query_as::<_, DbProject>(
118 119 r#"
119 120 UPDATE projects
120 - SET title = COALESCE($2, title),
121 - description = COALESCE($3, description),
122 - project_type = COALESCE($4, project_type),
123 - is_public = COALESCE($5, is_public),
124 - features = COALESCE($6, features)
125 - WHERE id = $1
121 + SET title = COALESCE($3, title),
122 + description = COALESCE($4, description),
123 + project_type = COALESCE($5, project_type),
124 + is_public = COALESCE($6, is_public),
125 + features = COALESCE($7, features)
126 + WHERE id = $1 AND user_id = $2
126 127 RETURNING *
127 128 "#,
128 129 )
129 130 .bind(id)
131 + .bind(user_id)
130 132 .bind(title)
131 133 .bind(description)
132 134 .bind(project_type)
@@ -143,10 +145,12 @@ pub async fn update_project(
143 145 pub async fn set_project_category(
144 146 pool: &PgPool,
145 147 id: ProjectId,
148 + user_id: UserId,
146 149 category_id: Option<super::CategoryId>,
147 150 ) -> Result<()> {
148 - sqlx::query("UPDATE projects SET category_id = $2 WHERE id = $1")
151 + sqlx::query("UPDATE projects SET category_id = $3 WHERE id = $1 AND user_id = $2")
149 152 .bind(id)
153 + .bind(user_id)
150 154 .bind(category_id)
151 155 .execute(pool)
152 156 .await?;
@@ -156,9 +160,10 @@ pub async fn set_project_category(
156 160
157 161 /// Permanently delete a project by ID (cascades to items).
158 162 #[tracing::instrument(skip_all)]
159 - pub async fn delete_project(pool: &PgPool, id: ProjectId) -> Result<()> {
160 - sqlx::query("DELETE FROM projects WHERE id = $1")
163 + pub async fn delete_project(pool: &PgPool, id: ProjectId, user_id: UserId) -> Result<()> {
164 + sqlx::query("DELETE FROM projects WHERE id = $1 AND user_id = $2")
161 165 .bind(id)
166 + .bind(user_id)
162 167 .execute(pool)
163 168 .await?;
164 169
@@ -206,7 +211,7 @@ pub async fn get_public_project_by_slug(
206 211 slug: &Slug,
207 212 ) -> Result<Option<DbProject>> {
208 213 let project = sqlx::query_as::<_, DbProject>(
209 - "SELECT * FROM projects WHERE slug = $1 AND is_public = true LIMIT 1",
214 + "SELECT * FROM projects WHERE slug = $1 AND is_public = true ORDER BY created_at ASC LIMIT 1",
210 215 )
211 216 .bind(slug)
212 217 .fetch_optional(pool)
@@ -223,7 +228,7 @@ pub async fn get_public_project_by_slug_str(
223 228 slug: &str,
224 229 ) -> Result<Option<DbProject>> {
225 230 let project = sqlx::query_as::<_, DbProject>(
226 - "SELECT * FROM projects WHERE slug = $1 AND is_public = true LIMIT 1",
231 + "SELECT * FROM projects WHERE slug = $1 AND is_public = true ORDER BY created_at ASC LIMIT 1",
227 232 )
228 233 .bind(slug)
229 234 .fetch_optional(pool)
@@ -251,7 +256,7 @@ pub async fn set_mt_community_id(
251 256 #[tracing::instrument(skip_all)]
252 257 pub async fn get_projects_without_mt_community(pool: &PgPool) -> Result<Vec<DbProject>> {
253 258 let projects = sqlx::query_as::<_, DbProject>(
254 - "SELECT * FROM projects WHERE mt_community_id IS NULL ORDER BY created_at",
259 + "SELECT * FROM projects WHERE mt_community_id IS NULL ORDER BY created_at LIMIT 500",
255 260 )
256 261 .fetch_all(pool)
257 262 .await?;
@@ -263,11 +268,13 @@ pub async fn get_projects_without_mt_community(pool: &PgPool) -> Result<Vec<DbPr
263 268 pub async fn update_project_image_url(
264 269 pool: &PgPool,
265 270 id: ProjectId,
271 + user_id: UserId,
266 272 url: &str,
267 273 ) -> Result<()> {
268 - sqlx::query("UPDATE projects SET cover_image_url = $1, updated_at = NOW() WHERE id = $2")
274 + sqlx::query("UPDATE projects SET cover_image_url = $1, updated_at = NOW() WHERE id = $2 AND user_id = $3")
269 275 .bind(url)
270 276 .bind(id)
277 + .bind(user_id)
271 278 .execute(pool)
272 279 .await?;
273 280
@@ -279,6 +286,7 @@ pub async fn update_project_image_url(
279 286 pub async fn update_project_pricing(
280 287 pool: &PgPool,
281 288 id: ProjectId,
289 + user_id: UserId,
282 290 pricing_model: super::PricingKind,
283 291 price_cents: i32,
284 292 pwyw_min_cents: Option<i32>,
@@ -286,11 +294,12 @@ pub async fn update_project_pricing(
286 294 sqlx::query(
287 295 r#"
288 296 UPDATE projects
289 - SET pricing_model = $2, price_cents = $3, pwyw_min_cents = $4, updated_at = NOW()
290 - WHERE id = $1
297 + SET pricing_model = $3, price_cents = $4, pwyw_min_cents = $5, updated_at = NOW()
298 + WHERE id = $1 AND user_id = $2
291 299 "#,
292 300 )
293 301 .bind(id)
302 + .bind(user_id)
294 303 .bind(pricing_model)
295 304 .bind(price_cents)
296 305 .bind(pwyw_min_cents)
@@ -118,6 +118,24 @@ pub async fn count_tips_received(pool: &PgPool, recipient_id: UserId) -> Result<
118 118 Ok(row.0)
119 119 }
120 120
121 + /// Mark a tip as refunded by payment intent ID.
122 + /// Returns true if a tip was refunded, false if not found (idempotent).
123 + #[tracing::instrument(skip(pool))]
124 + pub async fn refund_tip_by_payment_intent(pool: &PgPool, payment_intent_id: &str) -> Result<bool> {
125 + let result = sqlx::query(
126 + r#"
127 + UPDATE tips
128 + SET status = 'refunded'
129 + WHERE stripe_payment_intent_id = $1 AND status = 'completed'
130 + "#,
131 + )
132 + .bind(payment_intent_id)
133 + .execute(pool)
134 + .await?;
135 +
136 + Ok(result.rows_affected() > 0)
137 + }
138 +
121 139 /// Get tips sent by a user, most recent first.
122 140 #[tracing::instrument(skip(pool))]
123 141 pub async fn get_tips_sent(
@@ -75,23 +75,24 @@ pub async fn create_backup_codes(
75 75 user_id: UserId,
76 76 code_hashes: &[String],
77 77 ) -> Result<()> {
78 + let mut tx = pool.begin().await?;
79 +
78 80 // Delete any existing codes
79 81 sqlx::query("DELETE FROM backup_codes WHERE user_id = $1")
80 82 .bind(user_id)
81 - .execute(pool)
83 + .execute(&mut *tx)
82 84 .await?;
83 85
84 - // Insert new codes
85 - for hash in code_hashes {
86 - sqlx::query(
87 - "INSERT INTO backup_codes (user_id, code_hash) VALUES ($1, $2)",
88 - )
89 - .bind(user_id)
90 - .bind(hash)
91 - .execute(pool)
92 - .await?;
93 - }
86 + // Batch insert all codes in a single query
87 + sqlx::query(
88 + "INSERT INTO backup_codes (user_id, code_hash) SELECT $1, UNNEST($2::text[])",
89 + )
90 + .bind(user_id)
91 + .bind(code_hashes)
92 + .execute(&mut *tx)
93 + .await?;
94 94
95 + tx.commit().await?;
95 96 Ok(())
96 97 }
97 98
@@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
4 4 use sqlx::PgPool;
5 5
6 6 use super::models::*;
7 - use super::{ItemId, ProjectId, PromoCodeId, UserId};
7 + use super::{ItemId, ProjectId, PromoCodeId, TransactionId, UserId};
8 8 use crate::error::Result;
9 9
10 10 /// Parameters for creating a pending Stripe checkout transaction.
@@ -30,6 +30,8 @@ pub struct ClaimParams<'a> {
30 30 pub item_title: &'a str,
31 31 pub seller_username: &'a str,
32 32 pub share_contact: bool,
33 + /// If this claim was granted via a bundle purchase, the parent transaction ID.
34 + pub parent_transaction_id: Option<TransactionId>,
33 35 }
34 36
35 37 /// Record a new pending transaction for a Stripe checkout session.
@@ -173,8 +175,8 @@ pub async fn claim_free_item<'e>(
173 175 let claim_id = format!("free-claim-{}-{}", params.buyer_id, params.item_id);
174 176 let result = sqlx::query(
175 177 r#"
176 - INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, status, completed_at, item_title, seller_username, share_contact)
177 - VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7)
178 + INSERT INTO transactions (buyer_id, seller_id, item_id, amount_cents, platform_fee_cents, stripe_checkout_session_id, status, completed_at, item_title, seller_username, share_contact, parent_transaction_id)
179 + VALUES ($1, $2, $3, 0, 0, $4, 'completed', NOW(), $5, $6, $7, $8)
178 180 ON CONFLICT (buyer_id, item_id) WHERE status = 'completed' DO NOTHING
179 181 "#,
180 182 )
@@ -185,6 +187,7 @@ pub async fn claim_free_item<'e>(
185 187 .bind(params.item_title)
186 188 .bind(params.seller_username)
187 189 .bind(params.share_contact)
190 + .bind(params.parent_transaction_id)
188 191 .execute(executor)
189 192 .await?;
190 193
@@ -343,7 +346,7 @@ pub async fn get_revenue_by_project(pool: &PgPool, project_id: ProjectId) -> Res
343 346 let row: (Option<i64>, Option<i64>) = sqlx::query_as(
344 347 r#"
345 348 SELECT
346 - COALESCE(SUM(t.amount_cents), 0),
349 + COALESCE(SUM(t.amount_cents), 0)::BIGINT,
347 350 COUNT(*)
348 351 FROM transactions t
349 352 JOIN items i ON t.item_id = i.id
@@ -431,6 +434,29 @@ pub async fn refund_transaction_by_payment_intent<'e>(
431 434 Ok(row)
432 435 }
433 436
437 + /// Revoke all child transactions linked to a parent (bundle) transaction.
438 + ///
439 + /// Returns the item IDs of revoked children so callers can decrement sales counts.
440 + #[tracing::instrument(skip_all)]
441 + pub async fn revoke_child_transactions<'e>(
442 + executor: impl sqlx::PgExecutor<'e>,
443 + parent_transaction_id: TransactionId,
444 + ) -> Result<Vec<ItemId>> {
445 + let item_ids: Vec<(Option<ItemId>,)> = sqlx::query_as(
446 + r#"
447 + UPDATE transactions
448 + SET status = 'refunded'
449 + WHERE parent_transaction_id = $1 AND status = 'completed'
450 + RETURNING item_id
451 + "#,
452 + )
453 + .bind(parent_transaction_id)
454 + .fetch_all(executor)
455 + .await?;
456 +
457 + Ok(item_ids.into_iter().filter_map(|(id,)| id).collect())
458 + }
459 +
434 460 /// A contact row: a buyer who opted to share their email with the seller.
435 461 #[derive(sqlx::FromRow)]
436 462 pub struct DbContactRow {
@@ -465,7 +491,7 @@ pub async fn get_seller_contacts(
465 491 u.username,
466 492 u.email,
467 493 COUNT(*) as total_purchases,
468 - COALESCE(SUM(t.amount_cents), 0) as total_spent_cents,
494 + COALESCE(SUM(t.amount_cents), 0)::BIGINT as total_spent_cents,
469 495 MAX(t.completed_at) as last_purchase_at
470 496 FROM transactions t
471 497 JOIN users u ON u.id = t.buyer_id
@@ -529,9 +555,9 @@ pub async fn get_platform_revenue_stats(pool: &PgPool) -> Result<(i64, i64, i64)
529 555 let row: (Option<i64>, Option<i64>, Option<i64>) = sqlx::query_as(
530 556 r#"
531 557 SELECT
532 - COALESCE(SUM(CASE WHEN status = 'completed' THEN amount_cents ELSE 0 END), 0),
533 - COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0),
534 - COALESCE(SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END), 0)
558 + COALESCE(SUM(CASE WHEN status = 'completed' THEN amount_cents ELSE 0 END), 0)::BIGINT,
559 + COALESCE(SUM(CASE WHEN status = 'completed' THEN 1 ELSE 0 END), 0)::BIGINT,
560 + COALESCE(SUM(CASE WHEN status = 'refunded' THEN 1 ELSE 0 END), 0)::BIGINT
535 561 FROM transactions
536 562 "#,
537 563 )
@@ -367,6 +367,7 @@ pub async fn get_pending_appeals(pool: &PgPool) -> Result<Vec<DbUser>> {
367 367 WHERE appeal_submitted_at IS NOT NULL
368 368 AND appeal_decided_at IS NULL
369 369 ORDER BY appeal_submitted_at ASC
370 + LIMIT 500
370 371 "#,
371 372 )
372 373 .fetch_all(pool)
@@ -130,13 +130,18 @@ define_pg_string_newtype!(
130 130 pub struct PriceCents(i32);
131 131
132 132 impl PriceCents {
133 - /// Validate and construct. Rejects negative values.
133 + /// Validate and construct. Rejects negative values and values exceeding the cap.
134 134 pub fn new(cents: i32) -> std::result::Result<Self, crate::error::AppError> {
135 135 if cents < 0 {
136 136 return Err(crate::error::AppError::Validation(
137 137 "Price cannot be negative".to_string(),
138 138 ));
139 139 }
140 + if cents > crate::constants::MAX_PRICE_CENTS {
141 + return Err(crate::error::AppError::Validation(
142 + "Price cannot exceed $10,000".to_string(),
143 + ));
144 + }
140 145 Ok(Self(cents))
141 146 }
142 147
@@ -257,7 +262,7 @@ mod tests {
257 262 fn price_cents_valid() {
258 263 assert!(PriceCents::new(0).is_ok());
259 264 assert!(PriceCents::new(999).is_ok());
260 - assert!(PriceCents::new(999_999_99).is_ok());
265 + assert!(PriceCents::new(1_000_000).is_ok()); // $10,000 — exactly at cap
261 266 }
262 267
263 268 #[test]
@@ -267,6 +272,12 @@ mod tests {
267 272 }
268 273
269 274 #[test]
275 + fn price_cents_rejects_overflow() {
276 + assert!(PriceCents::new(1_000_001).is_err()); // over $10,000 cap
277 + assert!(PriceCents::new(99_999_999).is_err());
278 + }
279 +
280 + #[test]
270 281 fn price_cents_deref() {
271 282 let p = PriceCents::new(1500).unwrap();
272 283 assert_eq!(*p, 1500);