Where
`apps/web/app/(app)/me/cameras/new/actions.ts` lines 44–85
Problem
The contributor cap check is a classic check-then-act:
```ts
const { count } = await supabase
.from("cameras")
.select("id", { count: "exact", head: true })
.eq("source", "contributor");
if ((count ?? 0) >= limit) {
return { ok: false, message: "waitlist: contributor onboarding is at capacity" };
}
// ... then later: INSERT into cameras ...
```
Two simultaneous `createCamera()` calls both see `count = limit - 1`, both pass the gate, both insert, total = `limit + 1`. As contributor signups grow this gets more likely (and is trivially exploitable by anyone holding two browser tabs).
Suggested fix
Move the cap into the database, where atomicity is free. Two options:
- Postgres function: `create_contributor_camera(...)` that runs the count + insert inside a single transaction with `SELECT ... FOR UPDATE` on a counter row.
- Partial unique constraint or trigger: a `BEFORE INSERT` trigger that raises if `COUNT(*) WHERE source = 'contributor' >= ?`. Keep the limit in a settings table so it's runtime-tunable.
Option 1 is more code; option 2 is more declarative. Either is correct.
Severity
Med — small blast radius today, but the bug grows with the user base.
Where
`apps/web/app/(app)/me/cameras/new/actions.ts` lines 44–85
Problem
The contributor cap check is a classic check-then-act:
```ts
const { count } = await supabase
.from("cameras")
.select("id", { count: "exact", head: true })
.eq("source", "contributor");
if ((count ?? 0) >= limit) {
return { ok: false, message: "waitlist: contributor onboarding is at capacity" };
}
// ... then later: INSERT into cameras ...
```
Two simultaneous `createCamera()` calls both see `count = limit - 1`, both pass the gate, both insert, total = `limit + 1`. As contributor signups grow this gets more likely (and is trivially exploitable by anyone holding two browser tabs).
Suggested fix
Move the cap into the database, where atomicity is free. Two options:
Option 1 is more code; option 2 is more declarative. Either is correct.
Severity
Med — small blast radius today, but the bug grows with the user base.