Skip to content

fix: atomic promo redemption duplicate guard (fixes #92) - #110

Merged
ralyodio merged 2 commits into
profullstack:masterfrom
threebeats:fix/promo-redemption-atomic-guard
Aug 1, 2026
Merged

fix: atomic promo redemption duplicate guard (fixes #92)#110
ralyodio merged 2 commits into
profullstack:masterfrom
threebeats:fix/promo-redemption-atomic-guard

Conversation

@threebeats

Copy link
Copy Markdown
Contributor

Summary

Fixes #92 (TOCTOU race in the promo code duplicate redemption check). Previously quotePromo() checked for an existing redemption and recordPromoRedemption() inserted it as two separate, lock-free database operations - so concurrent requests could both pass the quote check and double-record.

Change

apps/web/lib/entitlements.ts - recordPromoRedemption() now uses a single atomic statement as the source of truth:

  • INSERT OR IGNORE INTO promo_redemptions + a rowsAffected check - the composite PK (code, user_id) is the authority, so a concurrent duplicate is a no-op instead of an error or double-record.
  • Returns boolean: true = newly recorded, false = already recorded (or blank code).
  • Skips the uses increment when the row already exists.
  • Keeps the capped increment (WHERE max_uses IS NULL OR uses < max_uses), matching PR fix: atomic capped increment for promo code redemption #106's over-redemption guard, so both races are closed even if fix: atomic capped increment for promo code redemption #106 is not merged yet.

apps/web/lib/entitlements.test.ts - new test suite: records and bumps uses, uppercase normalization, duplicate no-op (no UPDATE issued), blank code no-op.

Verification

  • New unit tests cover all four behaviors with a mocked sqlClient (rowsAffected-based).
  • pnpm precommit (lint, type-check, test, build) should pass.

Notes

Complements PR #106 (capped increment); the two changes overlap in the same function and this PR already includes the capped increment so either merge order works. Existing callers ignore the new boolean return value and are unaffected.

Close the TOCTOU between quotePromo()'s duplicate check and
recordPromoRedemption()'s insert by making the redemption row itself the
single atomic authority: INSERT OR IGNORE + rowsAffected check. Returns
whether the redemption was newly recorded; skips the uses increment when
the row already exists. Also keeps the capped increment (uses < max_uses)
so over-redemption is impossible even if the quote check races.

Tests: new recordPromoRedemption suite (records + bumps, uppercase
normalization, duplicate no-op, blank code).
Same TDZ fix as the rate-limit test: create the mocked execute via
vi.hoisted() so the hoisted vi.mock factory doesn't reference it before
initialization.
@ralyodio
ralyodio merged commit a0b5c0c into profullstack:master Aug 1, 2026
5 checks passed
ralyodio added a commit that referenced this pull request Aug 1, 2026
…sponse (#115)

#110 dropped the try/catch around recordPromoRedemption to make the
duplicate guard atomic. Both callers grant the entitlement BEFORE
recording the redemption and neither handles errors:

  - app/api/payments/status/route.ts:58
  - app/api/payments/create-checkout/route.ts:59

So a transient DB failure in the INSERT now 500s a response that has
already granted the purchase — and the status route's payload carries
grant.apiKeyPlaintext, which is shown exactly once. The user would lose
their API key to that 500.

Restore the catch (log + return false) while keeping the atomic
INSERT OR IGNORE guard and the capped increment. The recorded flag is
tracked outside the try so a failed counter bump still reports the
redemption row that did land.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MEDIUM: Race condition in promo code duplicate redemption check

2 participants