Skip to main content

max / makenotwork

Enforce the per-item license-key cap atomically under an item-row lock Risk remediation (fuzz 2026-07-06 doubledown, Performance axis, C6-1). The manual key-generation handler did count_keys_by_item then create_license_key with no lock between, so N concurrent generates for the same item could each read count = 999 and all insert, exceeding the 1000/item cap. Add create_manual_key_capped: begin a tx, SELECT the item row FOR UPDATE to serialize concurrent issuance, re-count under the lock, and insert only if under the cap (returning None otherwise, which the handler maps to a 400). Mirrors the lock-the-parent pattern the project-split cap already uses. sqlx offline cache regenerated for the three new queries; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-06 22:21 UTC
Commit: 7e06f064290ece38e2c3c32f0992097ad76fb292
Parent: caf9342
5 files changed, +230 insertions, -11 deletions
@@ -0,0 +1,22 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "SELECT id FROM items WHERE id = $1 FOR UPDATE",
4 + "describe": {
5 + "columns": [
6 + {
7 + "ordinal": 0,
8 + "name": "id",
9 + "type_info": "Uuid"
10 + }
11 + ],
12 + "parameters": {
13 + "Left": [
14 + "Uuid"
15 + ]
16 + },
17 + "nullable": [
18 + false
19 + ]
20 + },
21 + "hash": "1276f7a168ba0de05ec546b9e5544ab18f446c46b65fe715a55d537bfad14d3c"
22 + }
@@ -0,0 +1,70 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "\n SELECT\n transaction_id AS \"transaction_id!: TransactionId\",\n item_id AS \"item_id!: ItemId\",\n title AS \"title!\",\n creator AS \"creator!\",\n item_type AS \"item_type!: super::ItemType\",\n purchased_at AS \"purchased_at!: chrono::DateTime<chrono::Utc>\",\n is_free AS \"is_free!\",\n license_key_code AS \"license_key_code?: KeyCode\",\n has_new_version AS \"has_new_version!\"\n FROM (\n SELECT DISTINCT ON (p.item_id)\n p.transaction_id,\n p.item_id,\n i.title,\n u.username as creator,\n i.item_type,\n p.purchased_at,\n -- Badge from what the buyer actually paid, not the item's current\n -- price: a later re-price to $0 must not retroactively badge a paid\n -- purchase \"Free\" (nor vice-versa). The purchases view carries the\n -- transaction's own amount_cents.\n (p.amount_cents = 0) as is_free,\n lk.key_code as license_key_code,\n (vc.total_versions > 0 AND vc.total_versions > COALESCE(dc.downloaded_count, 0)) as has_new_version\n FROM purchases p\n JOIN items i ON p.item_id = i.id\n JOIN projects proj ON i.project_id = proj.id\n JOIN users u ON proj.user_id = u.id\n LEFT JOIN license_keys lk ON lk.item_id = p.item_id AND lk.owner_id = p.buyer_id AND lk.revoked_at IS NULL\n LEFT JOIN LATERAL (\n SELECT COUNT(*) AS total_versions\n FROM versions v\n WHERE v.item_id = i.id AND v.s3_key IS NOT NULL\n ) vc ON true\n LEFT JOIN LATERAL (\n SELECT COUNT(*) AS downloaded_count\n FROM user_downloads ud\n WHERE ud.user_id = p.buyer_id AND ud.item_id = i.id\n ) dc ON true\n WHERE p.buyer_id = $1\n ORDER BY p.item_id, p.purchased_at DESC\n ) deduped\n ORDER BY purchased_at DESC\n LIMIT 20\n ",
4 + "describe": {
5 + "columns": [
6 + {
7 + "ordinal": 0,
8 + "name": "transaction_id!: TransactionId",
9 + "type_info": "Uuid"
10 + },
11 + {
12 + "ordinal": 1,
13 + "name": "item_id!: ItemId",
14 + "type_info": "Uuid"
15 + },
16 + {
17 + "ordinal": 2,
18 + "name": "title!",
19 + "type_info": "Varchar"
20 + },
21 + {
22 + "ordinal": 3,
23 + "name": "creator!",
24 + "type_info": "Varchar"
25 + },
26 + {
27 + "ordinal": 4,
28 + "name": "item_type!: super::ItemType",
29 + "type_info": "Varchar"
30 + },
31 + {
32 + "ordinal": 5,
33 + "name": "purchased_at!: chrono::DateTime<chrono::Utc>",
34 + "type_info": "Timestamptz"
35 + },
36 + {
37 + "ordinal": 6,
38 + "name": "is_free!",
39 + "type_info": "Bool"
40 + },
41 + {
42 + "ordinal": 7,
43 + "name": "license_key_code?: KeyCode",
44 + "type_info": "Varchar"
45 + },
46 + {
47 + "ordinal": 8,
48 + "name": "has_new_version!",
49 + "type_info": "Bool"
50 + }
51 + ],
52 + "parameters": {
53 + "Left": [
54 + "Uuid"
55 + ]
56 + },
57 + "nullable": [
58 + true,
59 + true,
60 + false,
61 + false,
62 + false,
63 + true,
64 + null,
65 + false,
66 + null
67 + ]
68 + },
69 + "hash": "ae71cb5e4a10f885299bb2ffdb18f48f731c1442e2f728bba01094e29b729aa5"
70 + }
@@ -0,0 +1,73 @@
1 + {
2 + "db_name": "PostgreSQL",
3 + "query": "\n INSERT INTO license_keys (item_id, owner_id, transaction_id, key_code, max_activations)\n VALUES ($1, $2, NULL, $3, $4)\n RETURNING id AS \"id: LicenseKeyId\", item_id AS \"item_id: ItemId\",\n owner_id AS \"owner_id: UserId\", transaction_id AS \"transaction_id: TransactionId\",\n key_code AS \"key_code: KeyCode\", max_activations, activation_count,\n revoked_at AS \"revoked_at: chrono::DateTime<chrono::Utc>\",\n created_at AS \"created_at: chrono::DateTime<chrono::Utc>\"\n ",
4 + "describe": {
5 + "columns": [
6 + {
7 + "ordinal": 0,
8 + "name": "id: LicenseKeyId",
9 + "type_info": "Uuid"
10 + },
11 + {
12 + "ordinal": 1,
13 + "name": "item_id: ItemId",
14 + "type_info": "Uuid"
15 + },
16 + {
17 + "ordinal": 2,
18 + "name": "owner_id: UserId",
19 + "type_info": "Uuid"
20 + },
21 + {
22 + "ordinal": 3,
23 + "name": "transaction_id: TransactionId",
24 + "type_info": "Uuid"
25 + },
26 + {
27 + "ordinal": 4,
28 + "name": "key_code: KeyCode",
29 + "type_info": "Varchar"
30 + },
31 + {
32 + "ordinal": 5,
33 + "name": "max_activations",
34 + "type_info": "Int4"
35 + },
36 + {
37 + "ordinal": 6,
38 + "name": "activation_count",
39 + "type_info": "Int4"
40 + },
41 + {
42 + "ordinal": 7,
43 + "name": "revoked_at: chrono::DateTime<chrono::Utc>",
44 + "type_info": "Timestamptz"
45 + },
46 + {
47 + "ordinal": 8,
48 + "name": "created_at: chrono::DateTime<chrono::Utc>",
49 + "type_info": "Timestamptz"
50 + }
51 + ],
52 + "parameters": {
53 + "Left": [
54 + "Uuid",
55 + "Uuid",
56 + "Varchar",
57 + "Int4"
58 + ]
59 + },
60 + "nullable": [
61 + false,
62 + false,
63 + false,
64 + true,
65 + false,
66 + true,
67 + false,
68 + true,
69 + false
70 + ]
71 + },
72 + "hash": "fdbe0e6f055c5e2c3a08910d56f95c8f543d379e68af6c5f3bc2212a5e65db90"
73 + }
@@ -444,6 +444,58 @@ pub async fn deactivate_machine(
444 444 }
445 445 }
446 446
447 + /// Create a manually-generated key for an item, atomically enforcing a per-item
448 + /// cap. Locks the item row `FOR UPDATE` so concurrent manual issuance for the
449 + /// same item serializes — the prior count-then-insert let N concurrent generates
450 + /// each read `count = cap-1` and all insert, exceeding the cap (fuzz 2026-07-06
451 + /// C6-1). Returns `None` if the cap is already reached (caller maps to a 400).
452 + #[tracing::instrument(skip_all)]
453 + pub async fn create_manual_key_capped(
454 + pool: &PgPool,
455 + item_id: ItemId,
456 + owner_id: UserId,
457 + key_code: &KeyCode,
458 + max_activations: Option<i32>,
459 + cap: i64,
460 + ) -> Result<Option<DbLicenseKey>> {
461 + let mut tx = pool.begin().await?;
462 + // Serialize concurrent manual issuance for this item so the count below is
463 + // stable through the insert (mirrors lock_project_for_splits' cap pattern).
464 + sqlx::query!(r#"SELECT id FROM items WHERE id = $1 FOR UPDATE"#, item_id as ItemId)
465 + .fetch_one(&mut *tx)
466 + .await?;
467 + let count = sqlx::query_scalar!(
468 + r#"SELECT COUNT(*) AS "count!" FROM license_keys WHERE item_id = $1"#,
469 + item_id as ItemId,
470 + )
471 + .fetch_one(&mut *tx)
472 + .await?;
473 + if count >= cap {
474 + tx.rollback().await?;
475 + return Ok(None);
476 + }
477 + let key = sqlx::query_as!(
478 + DbLicenseKey,
479 + r#"
480 + INSERT INTO license_keys (item_id, owner_id, transaction_id, key_code, max_activations)
481 + VALUES ($1, $2, NULL, $3, $4)
482 + RETURNING id AS "id: LicenseKeyId", item_id AS "item_id: ItemId",
483 + owner_id AS "owner_id: UserId", transaction_id AS "transaction_id: TransactionId",
484 + key_code AS "key_code: KeyCode", max_activations, activation_count,
485 + revoked_at AS "revoked_at: chrono::DateTime<chrono::Utc>",
486 + created_at AS "created_at: chrono::DateTime<chrono::Utc>"
487 + "#,
488 + item_id as ItemId,
489 + owner_id as UserId,
490 + key_code as &KeyCode,
491 + max_activations,
492 + )
493 + .fetch_one(&mut *tx)
494 + .await?;
495 + tx.commit().await?;
496 + Ok(Some(key))
497 + }
498 +
447 499 /// Revoke a license key and deactivate all its activations.
448 500 ///
449 501 /// Wrapped in a transaction so the key revocation and activation
@@ -420,26 +420,28 @@ pub(super) async fn generate_key(
420 420 ));
421 421 }
422 422
423 - // Cap at 1000 manually-generated keys per item
424 - let existing_count = db::license_keys::count_keys_by_item(&state.db, item_id).await?;
425 - if existing_count >= 1000 {
426 - return Err(AppError::BadRequest(
427 - "Maximum of 1000 license keys per item reached".to_string(),
428 - ));
429 - }
430 -
431 423 let key_code = helpers::generate_key_code();
432 424 let max_activations = req.max_activations.or(item.default_max_activations);
433 425
434 - let _key = db::license_keys::create_license_key(
426 + // Cap at 1000 manually-generated keys per item, enforced atomically under an
427 + // item-row lock so concurrent generates can't race past it (fuzz 2026-07-06 C6-1).
428 + let _key = match db::license_keys::create_manual_key_capped(
435 429 &state.db,
436 430 item_id,
437 431 user.id,
438 - None,
439 432 &key_code,
440 433 max_activations,
434 + 1000,
441 435 )
442 - .await?;
436 + .await?
437 + {
438 + Some(key) => key,
439 + None => {
440 + return Err(AppError::BadRequest(
441 + "Maximum of 1000 license keys per item reached".to_string(),
442 + ));
443 + }
444 + };
443 445
444 446 if is_htmx_request(&headers) {
445 447 let keys = db::license_keys::get_license_keys_by_item(&state.db, item_id).await?;