Relationships
#1812 Identifier lookups are exact-match and case-sensitive across username, email and slug
Opened by keeb · 8/24/2026
Found by code scan. Hardening finding — the app currently stores these fields lowercased at write time, so nothing is observably broken today. The exposure is on the input side and on any future writer that does not normalise.
The sites
lib/infrastructure/better-auth-operative-queries.ts:42
const doc = await this.db.collection("user").findOne({ username });Backs the invite-by-username branch. The value comes straight from a free-text form field with no normalisation beyond .trim(). A username typed with different capitalisation than stored yields Operative not found.
lib/infrastructure/better-auth-operative-queries.ts:94
const user = await this.db.collection("user").findOne({ email });isEmailVerified — a miss here reports an operative as unverified, which is a fail-closed path that silently withholds whatever it gates.
lib/infrastructure/better-auth-collective-queries.ts:780
const doc = await this.db.collection("organization").findOne({ slug });Collective lookup by slug. A mixed-case slug in a URL or an API call 404s.
lib/infrastructure/username-resolution.ts:62
await users.findOne({ username: name }) ?? await orgs.findOne({ slug: name });Both halves exact.
lib/infrastructure/mongo-platform-invite-repository.ts:63
return this.invites.findOne({ email })...No .toLowerCase() on the lookup, while lib/app/claim-platform-invites.ts:68 lowercases before comparing. The two ends of the same flow disagree about normalisation.
The asymmetry that makes this fragile
Write paths already normalise deliberately — lib/app/collective-commands.ts:226 lowercases the invited email before storing, with a comment explaining that a mixed-case invite would otherwise stay pending forever. claim-pending-invitations.ts:141 and accept-invite.tsx:98-99 lowercase too.
So the invariant "these fields are stored lowercase" is real but enforced only by convention at each writer, and the readers depend on it silently. One writer that skips the step — a new OAuth provider that hands back a capitalised address, an admin tool, a migration, a seed script — and these reads start missing with no error, just an empty result that every caller interprets as "does not exist."
Suggested fix
Either:
- Normalise at the read edge — lowercase in each of these lookups, or add
$options: "i"where an index-backed exact match is not required — or - Make the invariant enforceable rather than conventional: normalise on write in one place at the repository boundary, and add a case-insensitive collation or a stored normalised field with a unique index, so a non-conforming write cannot land.
Option 2 is the durable one. Option 1 alone leaves each new read site free to reintroduce the same bug.
Note that username is also matched exactly in findUserIdsByUsernames (:139) and in the $in shape at :145, which have the same property.
Open
No activity in this phase yet.
Sign in to post a ripple.