From e37b1a151c7c55c784187c3bb9010115528d182c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Thu, 20 Aug 2026 20:03:18 +0000 Subject: [PATCH] usernames are required, and unique case-insensitively MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit two platform fixes that make a plugin's job smaller, both from the owner: downstream services identify a person by name, so the platform has to be the one holding that name to a single meaning. `getUserByUsername` compared with `eq`, so `diogo` and `DIOGO` were two accounts. create-user.ts asks "is this taken?" through that function, which means the check could not see its own collision. it compares on `lower()` now. and the constraint is on `lower(username)`, not the column — a plain unique index is what allowed both to exist. it sits in the schema rather than only in create-user.ts so a second code path, a migration, or someone at a psql prompt cannot get around it. the column is NOT NULL. `validateUsername` has always demanded one, but the column allowed null, so anything skipping validation could write a user nobody could name — and a Linux account or a Jellyfin login then has nothing to work with. both databases were checked before the change: zero null usernames, zero case-collisions on either, so no migration was needed. proved rather than assumed, inside a transaction that was rolled back on the live database: `PROBEUSER` refused against `probeuser` by uq_users_username_lower, and a null username refused by the not-null. no rows written. this also removes a case the jellyfin reconcile had to carry: an officer account with no username could not be matched or created there, because jellyfin authenticates by name and has no email login. --- src/databases/officer_db/src/auth/queries.ts | 17 ++++++++++++++++- src/databases/officer_db/src/auth/schema.ts | 18 +++++++++++++++++- 2 files changed, 33 insertions(+), 2 deletions(-) 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.