Skip to main content

max / makenotwork

server: report authoritative license activation_count (ultra-fuzz Run #1 Payments MINOR) validate_key returned a pre-lock read on the already-activated fast path and a manual `+ 1` guess on the new-activation path, both of which drift under concurrent activations. try_create_activation already recomputes the denormalized count under the row lock; add get_activation_count and report that from both response branches. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-19 21:30 UTC
Commit: 0acbd0aab6d753acbf6fd4e45575ac0e186ad5ff
Parent: 7e3dcca
2 files changed, +26 insertions, -2 deletions
@@ -163,6 +163,23 @@ pub async fn touch_activation(pool: &PgPool, activation_id: LicenseActivationId)
163 163 Ok(())
164 164 }
165 165
166 + /// Read the denormalized active-activation count for a key.
167 + ///
168 + /// `try_create_activation` keeps `license_keys.activation_count` authoritative
169 + /// (it recomputes it under the row lock), so this is the value to report back to
170 + /// the client rather than a pre-lock read or a manual `+ 1` guess that drifts
171 + /// under concurrent activations (ultra-fuzz Run #1 Payments MINOR).
172 + #[tracing::instrument(skip_all)]
173 + pub async fn get_activation_count(pool: &PgPool, license_key_id: LicenseKeyId) -> Result<i32> {
174 + let count: i32 = sqlx::query_scalar(
175 + "SELECT activation_count FROM license_keys WHERE id = $1",
176 + )
177 + .bind(license_key_id)
178 + .fetch_one(pool)
179 + .await?;
180 + Ok(count)
181 + }
182 +
166 183 /// Activate a license key on a machine, atomically enforcing max_activations.
167 184 ///
168 185 /// Uses a transaction with `FOR UPDATE` to serialize concurrent activations
@@ -147,6 +147,9 @@ pub(super) async fn validate_key(
147 147 && activation.is_active
148 148 {
149 149 db::license_keys::touch_activation(&state.db, activation.id).await?;
150 + // Report the authoritative denormalized count, not the pre-lookup read,
151 + // so a concurrent activation on another machine isn't reflected stale.
152 + let activation_count = db::license_keys::get_activation_count(&state.db, key.id).await?;
150 153 return Ok(Json(ValidateKeyResponse {
151 154 valid: true,
152 155 activated: None,
@@ -154,7 +157,7 @@ pub(super) async fn validate_key(
154 157 license: Some(ValidateKeyLicense {
155 158 item_id: key.item_id,
156 159 max_activations: key.max_activations,
157 - activation_count: key.activation_count,
160 + activation_count,
158 161 created_at: key.created_at,
159 162 }),
160 163 }));
@@ -178,6 +181,10 @@ pub(super) async fn validate_key(
178 181 }));
179 182 }
180 183
184 + // try_create_activation recomputed the denormalized count under the row
185 + // lock; read it back rather than guessing `+ 1` (which is wrong if another
186 + // machine activated concurrently).
187 + let activation_count = db::license_keys::get_activation_count(&state.db, key.id).await?;
181 188 Ok(Json(ValidateKeyResponse {
182 189 valid: true,
183 190 activated: Some(true),
@@ -185,7 +192,7 @@ pub(super) async fn validate_key(
185 192 license: Some(ValidateKeyLicense {
186 193 item_id: key.item_id,
187 194 max_activations: key.max_activations,
188 - activation_count: key.activation_count + 1,
195 + activation_count,
189 196 created_at: key.created_at,
190 197 }),
191 198 }))