( kind: DeploymentKind, deploymentId: string, email: string, currentValue: string )
| 175 | * `'incremented'` otherwise. The DB path uses optimistic locking with retry. |
| 176 | */ |
| 177 | export async function incrementOTPAttempts( |
| 178 | kind: DeploymentKind, |
| 179 | deploymentId: string, |
| 180 | email: string, |
| 181 | currentValue: string |
| 182 | ): Promise<'locked' | 'incremented'> { |
| 183 | const keys = OTP_KEYS[kind] |
| 184 | const storageMethod = getStorageMethod() |
| 185 | |
| 186 | if (storageMethod === 'redis') { |
| 187 | const redis = getRedisClient() |
| 188 | if (!redis) throw new Error('Redis configured but client unavailable') |
| 189 | const key = keys.redisKey(email, deploymentId) |
| 190 | const result = await redis.eval(ATOMIC_INCREMENT_SCRIPT, 1, key, MAX_OTP_ATTEMPTS) |
| 191 | if (result === null || result === 'LOCKED') return 'locked' |
| 192 | return 'incremented' |
| 193 | } |
| 194 | |
| 195 | const identifier = keys.dbIdentifier(email, deploymentId) |
| 196 | const MAX_RETRIES = 3 |
| 197 | let value = currentValue |
| 198 | |
| 199 | for (let attempt = 0; attempt < MAX_RETRIES; attempt++) { |
| 200 | const { otp, attempts } = decodeOTPValue(value) |
| 201 | const newAttempts = attempts + 1 |
| 202 | |
| 203 | if (newAttempts >= MAX_OTP_ATTEMPTS) { |
| 204 | await db.delete(verification).where(eq(verification.identifier, identifier)) |
| 205 | return 'locked' |
| 206 | } |
| 207 | |
| 208 | const newValue = encodeOTPValue(otp, newAttempts) |
| 209 | const updated = await db |
| 210 | .update(verification) |
| 211 | .set({ value: newValue, updatedAt: new Date() }) |
| 212 | .where(and(eq(verification.identifier, identifier), eq(verification.value, value))) |
| 213 | .returning({ id: verification.id }) |
| 214 | |
| 215 | if (updated.length > 0) return 'incremented' |
| 216 | |
| 217 | const fresh = await getOTP(kind, deploymentId, email) |
| 218 | if (!fresh) return 'locked' |
| 219 | value = fresh |
| 220 | } |
| 221 | |
| 222 | /** |
| 223 | * Retry exhaustion under heavy DB-path contention: this request did not |
| 224 | * succeed in writing its own +1, so the stored count may not reflect it. |
| 225 | * Fail closed — invalidate the OTP rather than return `'incremented'` with |
| 226 | * a possibly-undercounted attempt total. |
| 227 | */ |
| 228 | await db.delete(verification).where(eq(verification.identifier, identifier)) |
| 229 | return 'locked' |
| 230 | } |
| 231 | |
| 232 | export async function deleteOTP( |
| 233 | kind: DeploymentKind, |
no test coverage detected