Skip to content

Bug: Non-atomic card delete sequence allows concurrent requests to remove all user cards #328

@anshul23102

Description

@anshul23102

Summary

DELETE /api/cards/:id performs three separate database operations in sequence without wrapping them in a transaction:

  1. prisma.card.count() to enforce the minimum-one-card invariant
  2. prisma.card.update() to promote the next oldest card as default
  3. prisma.card.delete() to remove the target card

Because these are three independent Prisma calls with no transaction boundary, concurrent DELETE requests can race past the count guard and violate the invariant.


Affected File

apps/backend/src/routes/cards.ts - DELETE /:id handler (lines 195-237)

// Step 1 - count check (no lock held)
const userCardCount = await app.prisma.card.count({ where: { userId } });
if (userCardCount <= 1) {
  reply.status(400).send({ error: 'Cannot delete the last remaining card...' });
  return;
}

// Step 2 - conditional default promotion (separate round-trip)
if (existing.isDefault) {
  const oldestRemainingCard = await app.prisma.card.findFirst({ ... });
  if (oldestRemainingCard) {
    await app.prisma.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } });
  }
}

// Step 3 - delete (another separate round-trip)
await app.prisma.card.delete({ where: { id } });

Race Condition Scenario

Suppose a user has exactly two cards: Card A (default) and Card B.

  1. Request 1 deletes Card A: count() returns 2, guard passes.
  2. Request 2 deletes Card B: count() also returns 2 (Card A not deleted yet), guard passes.
  3. Request 1 promotes Card B as default and deletes Card A. User has Card B.
  4. Request 2 proceeds to delete Card B. User now has zero cards.

The same window exists between step 2 (promotion) and step 3 (delete) for the default-card path: another request can see the partially-promoted state before the delete commits.


Impact

  • Data integrity violation: a user can end up with zero cards, breaking the minimum-one-card invariant the guard is meant to enforce.
  • Corrupted default state: a card can be promoted to default and then immediately deleted within the same window.

Suggested Fix

Wrap the entire count check, promotion, and delete inside a prisma.$transaction:

await app.prisma.$transaction(async (tx) => {
  const userCardCount = await tx.card.count({ where: { userId } });
  if (userCardCount <= 1) {
    return reply.status(400).send({ error: 'Cannot delete the last remaining card. A user must have at least one card.' });
  }

  if (existing.isDefault) {
    const oldestRemainingCard = await tx.card.findFirst({
      where: { userId, id: { not: id } },
      orderBy: { createdAt: 'asc' },
    });
    if (oldestRemainingCard) {
      await tx.card.update({ where: { id: oldestRemainingCard.id }, data: { isDefault: true } });
    }
  }

  await tx.card.delete({ where: { id } });
});

This makes the entire sequence atomic: the count check, the promotion, and the delete either all succeed together or all roll back, with no window for concurrent interleaving.


Environment

  • apps/backend/src/routes/cards.ts
  • Prisma ORM with PostgreSQL
  • Fastify backend

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions