Skip to content

Commit 1888fca

Browse files
authored
fix(stack): close codex follow-up — auth races, CORS gaps, missing email-link routes (#77)
* fix(stack): close codex follow-up — auth races, CORS gaps, missing email-link routes Second Codex pass uncovered six gaps. This PR ships fixes for all six, each with its own guardrail or sibling test. ## Atomicity in single-use token flows * Email verification: token lookup moves INSIDE the transaction as an atomic `DELETE … RETURNING`. The user row is then locked `FOR UPDATE`. Two parallel verify calls used to both pass a pre-tx existence check and both call `provisionAfterVerification` (which itself does select-then-insert against no unique constraint) — the user ended up with two personal accounts. The atomic claim collapses the race to one winner. * Password reset: same shape — atomic `DELETE … RETURNING` inside the tx. The bcrypt hash still runs outside the tx so it doesn't pin a connection, but only the call that wins the DELETE proceeds to UPDATE the password. * Ownership transfer decline: now transactional with the same `FOR UPDATE` lock + repeated `acceptedAt/declinedAt IS NULL` predicates on the UPDATE that accept() uses. Without it, an accept/decline race could leave BOTH timestamps set. ## Billing customer creation * Stripe `customers.create` now passes `idempotencyKey: account-customer:{id}` so a double-click collapses to one customer record. The DB write-back is conditional (`stripeCustomerId IS NULL OR ''`) so a concurrent caller that beat us to the API doesn't get overwritten. Stripe holds idempotency keys for 24h — well beyond any plausible double-submit window. ## CORS for cross-origin Sentry tracing * Added `sentry-trace`, `baggage`, `traceparent` to `CORS_ALLOWED_HEADERS`. The Sentry browser SDK writes these on every outbound /api/* fetch when `browserTracingIntegration` is loaded; without the allowlist the cross- origin preflight rejects every traced call. * Added `exposeHeaders: ['x-request-id']` so the SPA's error toasts can still surface the request id when the API runs on a different host. * Sibling test in `security.constants.test.ts` locks both behaviours. ## Three missing email-link landing routes * `/invitations/accept` — auto-fires the accept call (mirrors VerifyEmailPage shape). ProtectedRoute-wrapped so anonymous clicks land on /login first and come back with the `?token=` preserved. * `/account/ownership-transfer/accept` — renders Accept and Decline buttons, NEVER auto-fires. Accepting transfers a whole account — a stray click on the email shouldn't perform it. The API also enforces recipient-JWT match. * `/account/requests` — reviewer-side inbox for domain-join requests with approve/deny actions. Lives inside AppShell. The API enforces role on every mutation; we don't add a second gate. Each new page ships with `.hooks.ts`, `.utils.ts`, `.types.ts`, `.constants.ts`, `.stories.tsx`, `.test.tsx`, `.utils.test.ts`, plus matching sibling `.hooks.test.tsx`. i18n keys added to both en and de common.json bundles. Log event names registered in `logger.events.ts`. ## Out of scope (P2/P3 from the Codex pass) * P2: OpenAPI fidelity — `success: boolean` → `t.Literal(true)`, dropping the multipart/text-plain default. Touches every route in the API and is pure schema hygiene; deferred to its own PR. * P3: UI/API authorization rule duplication — would need a generated rule matrix or parity test, neither of which fits a follow-up. ## Gates * API: 1033/1035 (2 DB-only skipped — local pg unavailable) * UI: 572/572 * Both apps: lint + lint-meta + typecheck + knip clean * fix(auth): keep "Email already verified" branch after atomic claim Codex follow-up tightened email verify to use atomic DELETE...RETURNING on the token row, which collapsed the duplicate-account race. But it also removed the explicit "already verified" check — a second click on an older verification link (still a valid token row from a prior resend) would now silently re-run the provisioning idempotency path instead of telling the user the email is already verified. Reinstates the check inside the transaction, after the user row is locked FOR UPDATE. Both contracts hold: races are atomic, and a stale verification email surfaces the user-friendly error string the integration test still asserts.
1 parent b2eb426 commit 1888fca

49 files changed

Lines changed: 2497 additions & 83 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/api/src/api/accounts/ownership-transfers.service.ts‎

Lines changed: 52 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -261,47 +261,65 @@ export class OwnershipTransfersService {
261261
): Promise<IOwnershipTransfer> {
262262
const tokenHash = hashOpaqueToken(token);
263263

264-
const [transfer] = await db
265-
.select()
266-
.from(accountOwnershipTransfers)
267-
.where(
268-
and(
269-
eq(accountOwnershipTransfers.tokenHash, tokenHash),
270-
isNull(accountOwnershipTransfers.acceptedAt),
271-
isNull(accountOwnershipTransfers.declinedAt),
272-
isNull(accountOwnershipTransfers.cancelledAt)
264+
return db.transaction(async (tx) => {
265+
/*
266+
* Mirrors `accept()`'s `FOR UPDATE`. Without the row lock, an
267+
* accept running in parallel commits its UPDATE between this
268+
* SELECT and a naive UPDATE-by-id, and both `acceptedAt` and
269+
* `declinedAt` end up set. The row lock + a WHERE that repeats
270+
* the `acceptedAt IS NULL` predicates on the UPDATE forces the
271+
* loser to fall through to a clean 404.
272+
*/
273+
const [transfer] = await tx
274+
.select()
275+
.from(accountOwnershipTransfers)
276+
.where(
277+
and(
278+
eq(accountOwnershipTransfers.tokenHash, tokenHash),
279+
isNull(accountOwnershipTransfers.acceptedAt),
280+
isNull(accountOwnershipTransfers.declinedAt),
281+
isNull(accountOwnershipTransfers.cancelledAt)
282+
)
273283
)
274-
)
275-
.limit(1);
284+
.limit(1)
285+
.for("update");
276286

277-
if (!transfer) {
278-
throw ApiErrors.notFound("Ownership transfer");
279-
}
287+
if (!transfer) {
288+
throw ApiErrors.notFound("Ownership transfer");
289+
}
280290

281-
if (transfer.toUserId !== decliningUserId) {
282-
throw ApiErrors.forbidden(
283-
"Only the named recipient can decline this transfer"
284-
);
285-
}
291+
if (transfer.toUserId !== decliningUserId) {
292+
throw ApiErrors.forbidden(
293+
"Only the named recipient can decline this transfer"
294+
);
295+
}
286296

287-
const [declined] = await db
288-
.update(accountOwnershipTransfers)
289-
.set({ declinedAt: now(), updatedAt: now() })
290-
.where(eq(accountOwnershipTransfers.id, transfer.id))
291-
.returning();
297+
const [declined] = await tx
298+
.update(accountOwnershipTransfers)
299+
.set({ declinedAt: now(), updatedAt: now() })
300+
.where(
301+
and(
302+
eq(accountOwnershipTransfers.id, transfer.id),
303+
isNull(accountOwnershipTransfers.acceptedAt),
304+
isNull(accountOwnershipTransfers.declinedAt),
305+
isNull(accountOwnershipTransfers.cancelledAt)
306+
)
307+
)
308+
.returning();
292309

293-
if (!declined) {
294-
throw ApiErrors.database("Failed to mark transfer declined");
295-
}
310+
if (!declined) {
311+
throw ApiErrors.notFound("Ownership transfer");
312+
}
296313

297-
void auditLogService.record({
298-
userId: decliningUserId,
299-
action: AUDIT_ACTIONS.ACCOUNT_OWNERSHIP_TRANSFER_DECLINED,
300-
resource: `account:${transfer.accountId}`,
301-
metadata: { transferId: transfer.id },
302-
});
314+
void auditLogService.record({
315+
userId: decliningUserId,
316+
action: AUDIT_ACTIONS.ACCOUNT_OWNERSHIP_TRANSFER_DECLINED,
317+
resource: `account:${transfer.accountId}`,
318+
metadata: { transferId: transfer.id },
319+
});
303320

304-
return toOwnershipTransfer(declined);
321+
return toOwnershipTransfer(declined);
322+
});
305323
}
306324

307325
async cancel(

‎apps/api/src/api/auth/services/email-verification.service.ts‎

Lines changed: 50 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -37,44 +37,67 @@ export class EmailVerificationService {
3737
*/
3838
async verify(token: string): Promise<IAuthenticatedResult> {
3939
const tokenHash = hashOpaqueToken(token);
40-
const record = await db.query.emailVerificationTokens.findFirst({
41-
where: and(
42-
eq(emailVerificationTokens.tokenHash, tokenHash),
43-
gt(emailVerificationTokens.expiresAt, now())
44-
),
45-
});
46-
47-
if (!record) {
48-
throw ApiErrors.invalidInput("Invalid or expired verification token");
49-
}
40+
const verifiedAt = now();
5041

51-
const user = await db.query.users.findFirst({
52-
where: eq(users.id, record.userId),
53-
});
42+
const { user, provisioned } = await db.transaction(async (tx) => {
43+
/*
44+
* Atomic token claim. Postgres serializes concurrent DELETEs on
45+
* the same row: the first call returns the deleted row, every
46+
* later call sees an empty RETURNING and falls through to the
47+
* "invalid or expired" branch. Without this, two parallel verify
48+
* calls both pass a pre-tx existence check, both call
49+
* `provisionAfterVerification`, and the user ends up with two
50+
* personal accounts because that path also does select-then-
51+
* insert under no unique constraint.
52+
*/
53+
const [claimed] = await tx
54+
.delete(emailVerificationTokens)
55+
.where(
56+
and(
57+
eq(emailVerificationTokens.tokenHash, tokenHash),
58+
gt(emailVerificationTokens.expiresAt, now())
59+
)
60+
)
61+
.returning();
62+
63+
if (!claimed) {
64+
throw ApiErrors.invalidInput("Invalid or expired verification token");
65+
}
5466

55-
if (!user) {
56-
throw ApiErrors.notFound("User");
57-
}
67+
const [claimedUser] = await tx
68+
.select()
69+
.from(users)
70+
.where(eq(users.id, claimed.userId))
71+
.limit(1)
72+
.for("update");
5873

59-
if (user.emailVerifiedAt !== null) {
60-
throw ApiErrors.invalidInput("Email already verified");
61-
}
74+
if (!claimedUser) {
75+
throw ApiErrors.notFound("User");
76+
}
6277

63-
const verifiedAt = now();
78+
/*
79+
* Resend can leave a fresh token on a user that's already been
80+
* verified through a different link. Reject explicitly instead of
81+
* silently re-running the provisioning path — the user-facing
82+
* message ("Email already verified") is more informative than
83+
* "Invalid or expired" for the legitimate "I clicked the older
84+
* email" case.
85+
*/
86+
if (claimedUser.emailVerifiedAt !== null) {
87+
throw ApiErrors.invalidInput("Email already verified");
88+
}
6489

65-
const provisioned = await db.transaction(async (tx) => {
6690
await tx
6791
.update(users)
6892
.set({ emailVerifiedAt: verifiedAt, updatedAt: verifiedAt })
69-
.where(eq(users.id, user.id));
70-
await tx
71-
.delete(emailVerificationTokens)
72-
.where(eq(emailVerificationTokens.tokenHash, tokenHash));
93+
.where(eq(users.id, claimedUser.id));
7394

74-
return accountsService.provisionAfterVerification(
75-
{ userId: user.id },
95+
const account = await accountsService.provisionAfterVerification(
96+
{ userId: claimedUser.id },
7697
tx
7798
);
99+
100+
return { user: claimedUser, provisioned: account };
78101
});
79102

80103
/*

‎apps/api/src/api/auth/services/password-reset.service.ts‎

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -136,26 +136,38 @@ export class PasswordResetService {
136136

137137
async complete(token: string, newPassword: string): Promise<IMessageResult> {
138138
const tokenHash = hashOpaqueToken(token);
139-
const record = await db.query.passwordResetTokens.findFirst({
140-
where: and(
141-
eq(passwordResetTokens.tokenHash, tokenHash),
142-
gt(passwordResetTokens.expiresAt, now())
143-
),
144-
});
139+
const passwordHash = await passwordService.hash(newPassword);
145140

146-
if (!record) {
147-
throw ApiErrors.invalidInput("Invalid or expired reset token");
148-
}
141+
const record = await db.transaction(async (tx) => {
142+
/*
143+
* Atomic token claim — see email-verification.service.ts for the
144+
* full rationale. The pre-hash bcrypt cost runs outside the tx so
145+
* a duplicate submission still pays it, but only one call gets
146+
* past the DELETE...RETURNING. The loser sees an empty result
147+
* and surfaces "Invalid or expired" — the user already-completed
148+
* state stays consistent because nothing past the claim runs
149+
* twice.
150+
*/
151+
const [claimed] = await tx
152+
.delete(passwordResetTokens)
153+
.where(
154+
and(
155+
eq(passwordResetTokens.tokenHash, tokenHash),
156+
gt(passwordResetTokens.expiresAt, now())
157+
)
158+
)
159+
.returning();
149160

150-
const passwordHash = await passwordService.hash(newPassword);
161+
if (!claimed) {
162+
throw ApiErrors.invalidInput("Invalid or expired reset token");
163+
}
151164

152-
await db.transaction(async (tx) => {
153165
const updatedProviders = await tx
154166
.update(userAuthProviders)
155167
.set({ passwordHash })
156168
.where(
157169
and(
158-
eq(userAuthProviders.userId, record.userId),
170+
eq(userAuthProviders.userId, claimed.userId),
159171
eq(userAuthProviders.provider, EMAIL_PROVIDER_KEY)
160172
)
161173
)
@@ -165,9 +177,7 @@ export class PasswordResetService {
165177
throw ApiErrors.invalidInput("Password login is not enabled");
166178
}
167179

168-
await tx
169-
.delete(passwordResetTokens)
170-
.where(eq(passwordResetTokens.userId, record.userId));
180+
return claimed;
171181
});
172182

173183
/*

‎apps/api/src/api/billing/billing.service.ts‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { and, eq, isNull } from "drizzle-orm";
1+
import { and, eq, isNull, or } from "drizzle-orm";
22
import Stripe from "stripe";
33

44
import { db } from "../../clients/postgres";
@@ -169,17 +169,46 @@ export class BillingService {
169169
let stripeCustomerId = account.stripeCustomerId;
170170

171171
if (stripeCustomerId === null || stripeCustomerId === "") {
172-
const customer = await this.stripe.customers.create({
173-
name: account.name,
174-
metadata: { accountId: account.id },
175-
});
172+
/*
173+
* Idempotency key keyed on the account id makes Stripe collapse a
174+
* double-click into a single customer record. Without it, two
175+
* parallel checkout requests each see `stripeCustomerId === null`,
176+
* each `customers.create()` returns a *different* id, and the
177+
* second DB write wins — the orphaned customer's future webhook
178+
* deliveries don't resolve the account and the subscription
179+
* silently lands on the wrong tenant. Stripe holds the key for
180+
* 24h, which covers any plausible double-submit window.
181+
*/
182+
const customer = await this.stripe.customers.create(
183+
{
184+
name: account.name,
185+
metadata: { accountId: account.id },
186+
},
187+
{ idempotencyKey: `account-customer:${account.id}` }
188+
);
176189

177190
stripeCustomerId = customer.id;
178191

192+
/*
193+
* Conditional update — only write the customer id when the column
194+
* is still empty. A concurrent request that beat us to the Stripe
195+
* API may have already filled it with the same value (idempotency
196+
* key collapse) or, in theory, raced past our row in a different
197+
* order; either way, leaving the existing value alone keeps the
198+
* write idempotent.
199+
*/
179200
await db
180201
.update(accounts)
181202
.set({ stripeCustomerId })
182-
.where(eq(accounts.id, accountId));
203+
.where(
204+
and(
205+
eq(accounts.id, accountId),
206+
or(
207+
isNull(accounts.stripeCustomerId),
208+
eq(accounts.stripeCustomerId, "")
209+
)
210+
)
211+
);
183212
}
184213

185214
const session = await this.stripe.checkout.sessions.create({

‎apps/api/src/config/security/security.constants.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,26 @@ export const CORS_ALLOWED_HEADERS = [
2121
"Content-Type",
2222
"Authorization",
2323
"X-Requested-With",
24+
/*
25+
* Sentry browser SDK writes both the Sentry-native and W3C trace
26+
* headers on outbound fetches when `browserTracingIntegration` is on
27+
* (see apps/ui/src/app/main.tsx). Without these in the allowlist the
28+
* cross-origin preflight rejects the request before it ever reaches
29+
* the API, and the SPA degrades to untraced calls.
30+
*/
31+
"sentry-trace",
32+
"baggage",
33+
"traceparent",
2434
];
2535

36+
/**
37+
* Response headers the browser is allowed to expose to JS via
38+
* `response.headers.get(...)`. Same-origin reads them unconditionally;
39+
* cross-origin needs an explicit allowlist. `x-request-id` is the
40+
* forensic id our error toasts surface — without it, "ask support for
41+
* request id X" breaks the moment the API runs on a different host.
42+
*/
43+
export const CORS_EXPOSED_HEADERS = ["x-request-id"];
44+
2645
/** Browser preflight cache TTL in seconds (24h). */
2746
export const CORS_MAX_AGE_SECONDS = 86_400;

‎apps/api/src/config/security/security.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { env } from "../env";
44
import { ValkeyRateLimitContext } from "../../lib/rate-limit/valkey-context";
55
import {
66
CORS_ALLOWED_HEADERS,
7+
CORS_EXPOSED_HEADERS,
78
CORS_MAX_AGE_SECONDS,
89
CORS_METHODS,
910
} from "./security.constants";
@@ -25,6 +26,7 @@ export const buildCors = () => {
2526
credentials: true,
2627
methods: CORS_METHODS,
2728
allowedHeaders: CORS_ALLOWED_HEADERS,
29+
exposeHeaders: CORS_EXPOSED_HEADERS,
2830
maxAge: CORS_MAX_AGE_SECONDS,
2931
aot: true,
3032
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, it } from "bun:test";
2+
3+
import {
4+
CORS_ALLOWED_HEADERS,
5+
CORS_EXPOSED_HEADERS,
6+
} from "../../../src/config/security/security.constants";
7+
8+
describe("CORS allowlist", () => {
9+
it("allows the Sentry browser SDK trace propagation headers", () => {
10+
/*
11+
* `browserTracingIntegration` writes both the Sentry-native
12+
* `sentry-trace` + `baggage` headers and the W3C `traceparent`
13+
* header on outbound /api/* fetches. Cross-origin preflight has
14+
* to allow them all or the API never sees the call.
15+
*/
16+
expect(CORS_ALLOWED_HEADERS).toContain("sentry-trace");
17+
expect(CORS_ALLOWED_HEADERS).toContain("baggage");
18+
expect(CORS_ALLOWED_HEADERS).toContain("traceparent");
19+
});
20+
21+
it("exposes x-request-id to the browser", () => {
22+
/*
23+
* Error toasts read `response.headers.get("x-request-id")` and
24+
* show it for support. Cross-origin reads need an explicit
25+
* exposed-headers allowlist.
26+
*/
27+
expect(CORS_EXPOSED_HEADERS).toContain("x-request-id");
28+
});
29+
});

0 commit comments

Comments
 (0)