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>
2 files changed,
+26 insertions,
-2 deletions
| 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 |
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 |
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 |
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 |
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 |
|
}))
|