Without application-layer transition guards, any code path — including a customer-facing API route — can write an arbitrary status value to any order. An attacker who discovers the order update endpoint can skip 'confirmed' and 'shipped' to mark their own order 'delivered', triggering fulfillment or refund logic without a real purchase completing the intervening steps. Even without malice, a race condition or a bug in an admin tool can jump an order from 'pending' to 'delivered' in one call, corrupting history and triggering incorrect emails. A database enum constraint alone (CWE-841) does not prevent logically invalid sequences — application-layer guards must enforce the allowed graph.
Critical because unguarded transitions allow both malicious status manipulation by authenticated users and accidental logical corruption that invalidates order history and triggers incorrect downstream actions.
Implement an explicit transition map in lib/orders/state-machine.ts and route every status update through it. All direct db.orders.update() calls that change status must be replaced with transitionOrder().
// lib/orders/state-machine.ts
const ALLOWED_TRANSITIONS: Record<string, string[]> = {
pending: ['confirmed', 'cancelled'],
confirmed: ['shipped', 'cancelled'],
shipped: ['delivered'],
delivered: [],
cancelled: [],
}
export async function transitionOrder(orderId: string, newStatus: string, actorId: string) {
const order = await db.orders.findUniqueOrThrow({ where: { id: orderId } })
const allowed = ALLOWED_TRANSITIONS[order.status] ?? []
if (!allowed.includes(newStatus)) {
throw new Error(`Invalid transition: ${order.status} → ${newStatus}`)
}
return db.orders.update({ where: { id: orderId }, data: { status: newStatus } })
}
ID: ecommerce-order-management.order-lifecycle.state-transitions
Severity: critical
What to look for: Count every code path that updates an order's status field. For each path found, enumerate what transition it performs and whether a guard clause validates the current status before allowing the update. A valid state machine for orders typically follows: pending -> confirmed -> shipped -> delivered, with cancelled reachable from pending and confirmed (and sometimes shipped with special handling). Look for guard clauses such as if (order.status !== 'confirmed') throw new Error(...), a dedicated transitionOrder function that maps at least 3 allowed transitions, or a state machine library (xstate, robot, etc.). Also check for negative cases — whether status can be set to arbitrary values through an update endpoint. Quote the exact guard clause or transition map if one exists.
Pass criteria: A transition validator, guard clause, or state machine definition explicitly restricts which transitions are allowed, covering at least 3 distinct transitions. Invalid transitions are rejected at the application layer (not just at the DB schema layer). The allowed transitions match a sensible business flow — an order cannot go from pending to delivered without passing through confirmed and shipped. A database enum constraint alone does not count as pass — application-layer validation must exist. Report the count of validated transitions even on pass: "Found N transitions with guards."
Fail criteria: No validation exists on state transitions — any status value can be written to any order at any time. Or the validation is present but incomplete (covering fewer than 3 transitions), allowing logically invalid jumps (e.g., pending -> delivered, shipped -> pending, cancelled -> confirmed).
Skip (N/A) when: The project has no order status update logic because it delegates all order state management to an external platform (Shopify, Medusa, etc.) with its own state machine. The codebase contains 0 status-update handlers.
Detail on fail: Describe which invalid transitions are permitted. Example: "No transition validation found. The PATCH /api/orders/[id] endpoint accepts any status value. An order can be set from 'pending' to 'delivered' in a single call with no intermediate states required. 0 of 4 status-update paths have guards."
Cross-reference: If transitions are unguarded, the history-log check is also likely failing — unguarded writes rarely include history entries. Also check whether cancellation-history is affected.
Remediation: Implement an explicit transition map in lib/orders/state-machine.ts and validate every status update against it:
// lib/orders/state-machine.ts
const ALLOWED_TRANSITIONS: Record<string, string[]> = {
pending: ['confirmed', 'cancelled'],
confirmed: ['shipped', 'cancelled'],
shipped: ['delivered'],
delivered: [],
cancelled: [],
}
export async function transitionOrder(
orderId: string,
newStatus: string,
actorId: string
) {
const order = await db.orders.findUniqueOrThrow({ where: { id: orderId } })
const allowed = ALLOWED_TRANSITIONS[order.status] ?? []
if (!allowed.includes(newStatus)) {
throw new Error(
`Invalid transition: ${order.status} → ${newStatus}. ` +
`Allowed: ${allowed.join(', ') || 'none'}`
)
}
return db.orders.update({
where: { id: orderId },
data: { status: newStatus, updatedAt: new Date() },
})
}
All status updates throughout the codebase should go through transitionOrder() rather than calling db.orders.update() directly.