diff --git a/src/databases/officer_db/src/auth/queries.ts b/src/databases/officer_db/src/auth/queries.ts index c4a2576d..2bc620b8 100644 --- a/src/databases/officer_db/src/auth/queries.ts +++ b/src/databases/officer_db/src/auth/queries.ts @@ -56,8 +56,23 @@ export async function getUserByEmail(email: string): Promise { - const [user] = await db.select().from(users).where(eq(users.username, username)); + const [user] = await db + .select() + .from(users) + .where(sql`lower(${users.username}) = lower(${username})`); return user; } diff --git a/src/databases/officer_db/src/auth/schema.ts b/src/databases/officer_db/src/auth/schema.ts index 93a5b76a..b9ab85a4 100644 --- a/src/databases/officer_db/src/auth/schema.ts +++ b/src/databases/officer_db/src/auth/schema.ts @@ -34,7 +34,14 @@ export const users = pgTable( // authorisation cannot accidentally mint an admin. role: text('role', { enum: USER_ROLES }).notNull().default('Member'), name: text('name'), - username: text('username').unique(), + /** + * Required, and unique case-INSENSITIVELY — see the index below. + * + * `validateUsername` has always demanded one, but the column allowed null, so a code path that + * skipped validation could write a user nobody could name. Anything downstream that identifies a + * person by username — a Linux account, a Jellyfin login — then has nothing to work with. + */ + username: text('username').notNull(), /** * The Linux account this platform account runs as, when per-user OS accounts are enabled. * @@ -69,6 +76,15 @@ export const users = pgTable( // Two accounts must not share one Linux user — that would make "whose uid is this" ambiguous for a // shell. An index rather than a constraint: see the note on the column. uniqueIndex('uq_users_os_user').on(table.osUser), + // Usernames are unique CASE-INSENSITIVELY. `diogo` and `DIOGO` are one name. + // + // On `lower(username)` rather than the column, because a plain unique index would let both exist — + // which was the state until 2026-08-20. Two consequences, and the second is why it is a constraint + // rather than a check in `create-user.ts`: a person cannot accidentally register a shadow of their + // own account, and anything identifying them downstream by name gets one answer. Jellyfin enforces + // uniqueness on an uppercased `NormalizedUsername`, so two Officer accounts Officer thought were + // distinct collide there, and the second is unprovisionable with no way to see why from this side. + uniqueIndex('uq_users_username_lower').on(sql`lower(${table.username})`), // The owner cannot be demoted. Enforced here rather than in application code because the whole // point is that it holds "whatever happens" — a stray UPDATE, a migration script, someone at a psql // prompt. Postgres rejects the write; there is no path around it.