fix(audit-wave-10): concurrency hardening (concurrency-auditor)

Close the CRITICAL + HIGH-tractable race conditions the
concurrency-auditor flagged. The wide-impact items (BullMQ jobId
plumbing — C-2; webhook outbound retry idempotency keys; etc.) span too
many call sites for a single contained wave and stay deferred.

**C-1 — handleDocumentCompleted concurrent-retry orphan-blob**
Wave 1 fixed the compensating-delete on single-process failure but the
idempotency gate at line 1110 reads `doc.status` outside any row lock.
Two webhook deliveries arriving in parallel both pass the gate, both
storage.put + db.insert(files), and the losing files row orphans its
blob since documents.signed_file_id only points at one. Now the
transaction at line 1176 SELECTs the document `FOR UPDATE` and
re-checks the gate; if a concurrent worker already completed, throws a
sentinel `DocumentAlreadyCompletedError` which the outer catch
recognizes and runs the compensating storage.delete at info level
(not error). Net effect: at-most-once signed-PDF persistence even
under Documenso 5xx-then-retry storms.

**H-1 — moveFolder cycle check race**
Two concurrent folder moves (A → B and B → A) in READ COMMITTED can
each pass the cycle check against pre-state and both commit, leaving
A↔B in the tree. Add a per-port `pg_advisory_xact_lock` at the top of
the move transaction so the walk-and-write is atomic per port.
Lock auto-releases on tx end; no impact on cross-port folder ops.

**H-3 — upsertInterestBerth 23505 → generic 500**
Two concurrent `setPrimaryBerth` calls hit `idx_interest_berths_one_primary`
and the loser surfaced as a generic 500. Catch the 23505 + constraint
name and remap to ConflictError so the UI gets a "Another rep changed
the primary berth at the same time. Refresh and try again." toast.

**M-2 — username uniqueness 23505 → generic 500**
Same TOCTOU shape: pre-check at me/route.ts:132 says "available", the
UPDATE then fails at the partial unique index. Catch 23505 +
`idx_user_profiles_username_unique` and remap to ConflictError.

Tests 1315/1315.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-13 12:34:23 +02:00
parent 0ea8d94d26
commit ecf49be18c
4 changed files with 107 additions and 14 deletions

View File

@@ -162,11 +162,24 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
updates.preferences = merged;
}
const [updated] = await db
.update(userProfiles)
.set(updates)
.where(eq(userProfiles.userId, ctx.userId))
.returning();
// concurrency-auditor M-2: pre-check at line 132-139 is TOCTOU
// against `idx_user_profiles_username_unique`. Two concurrent claims
// on the same username will see "available" in their own pre-check
// and the loser's UPDATE fails with 23505 — surface that as
// ConflictError rather than letting it bubble as a generic 500.
let updated;
try {
[updated] = await db
.update(userProfiles)
.set(updates)
.where(eq(userProfiles.userId, ctx.userId))
.returning();
} catch (err) {
if (isUsernameUniqueConflict(err)) {
throw new ConflictError('That username is already taken.');
}
throw err;
}
return NextResponse.json({
data: {
@@ -184,3 +197,9 @@ export const PATCH = withAuth(async (req, ctx: AuthContext) => {
return errorResponse(error);
}
});
function isUsernameUniqueConflict(err: unknown): boolean {
if (typeof err !== 'object' || err === null) return false;
const e = err as { code?: string; constraint_name?: string };
return e.code === '23505' && e.constraint_name === 'idx_user_profiles_username_unique';
}