From bb5cc8f7951cb162446968f21a8ade2074148964 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 8 Oct 2026 11:59:16 +0200 Subject: [PATCH] fix(db): one account per email address, whatever its case Login, password reset and the sign-up checks all match emails case-insensitively, but the only constraint was the column's own UNIQUE, which SQLite compares case-sensitively. Two accounts whose addresses differ only in case (legacy rows, two sign-ups at once) left a login to resolve to whichever row came first, and LOWER(email) lookups had no index to use. A migration adds a unique index on lower(email). An install that already holds such a pair must still start, so the migration then leaves the index out, logs how many addresses are shared and by which user ids (not the addresses), and records email_case_index_pending. Since a migration runs once, db/email-case-index.ts runs on every boot while that flag is set: it repeats the warning until the accounts are told apart, then creates the index and clears the flag. The app-level checks keep guarding meanwhile. A write that passes the up-front check and still loses the race is now refused by the database. The users repository turns that unique violation into UserIdentityTakenError, and sign-up, admin create and edit, and the profile update answer it with the same 409 their checks give, instead of a 500 or an unhandled rejection (ADMIN-SVC-094 pinned the old behaviour and now pins the 409). The entity generator could not see an expression index (PRAGMA index_info names its column NULL), so PARITY-009c found a db index with no entity declaration. Rule 14 carries such an index into the entity as a named entry with its own statement; Users.entity.ts is regenerated. --- server/CLAUDE.md | 2 +- server/scripts/generate-entities.ts | 74 +++++++++++++- server/src/db/email-case-index.ts | 38 +++++++ server/src/db/entities/Users.entity.ts | 4 + ...one_account_per_email_whatever_its_case.ts | 45 +++++++++ server/src/db/orm.ts | 3 + .../src/db/repositories/Users.repository.ts | 69 +++++++++---- server/src/nest/admin/admin.service.ts | 55 +++++++---- server/src/nest/auth/auth.service.ts | 9 +- server/src/nest/auth/user-profile.service.ts | 18 +++- server/tests/unit/db/email-case-index.test.ts | 99 +++++++++++++++++++ .../tests/unit/db/generate-entities.test.ts | 28 ++++++ server/tests/unit/db/legacy-baseline.test.ts | 1 + .../db/repositories/Users.repository.test.ts | 23 +++++ server/tests/unit/nest/admin.service.test.ts | 41 ++++++-- server/tests/unit/nest/auth.service.test.ts | 16 +++ .../unit/nest/user-profile.service.test.ts | 15 +++ wiki/Admin-Users-and-Invites.md | 4 + 18 files changed, 488 insertions(+), 56 deletions(-) create mode 100644 server/src/db/email-case-index.ts create mode 100644 server/src/db/migrations/Migration20200101042400_one_account_per_email_whatever_its_case.ts create mode 100644 server/tests/unit/db/email-case-index.test.ts diff --git a/server/CLAUDE.md b/server/CLAUDE.md index d985fca7c..3389809ef 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -33,7 +33,7 @@ Nest owns everything. Every domain is a DI module under `src/nest//` (`c - **Scheduling**: `src/nest/scheduling/` (`CronRegistrarService`) is the one way to schedule a cron. Jobs are `*.job.ts` providers in their owning domain. The registrar refuses to schedule under `NODE_ENV=test` (an integration test pins it). **Never use a `@Cron` decorator** — it bypasses that gate. - **Plugins** (`src/nest/plugins/`): the child-process runtime (`supervisor/`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts`) is **intentionally not Nest** — it is the security boundary. `host/rpc-host.ts` registers an RPC method at spawn only if the plugin holds the unlocking permission (registration = authorization). Install-time gates live in `install/`; the manifest's `trek` version range fails closed, and `TREK_PLUGINS_IGNORE_TREK_RANGE` downgrades that to a warning. The protocol tables in `protocol/envelope.ts` (+ `src/plugin-event-sink.ts`) are **generated** into `plugin-sdk/` and `shared/` — run `gen:plugin-facts` after touching them and commit the output. `runtime/egress-policy.ts` is a hand-kept mirror guarded by a parity test in `plugin-sdk/`; change both sides together. - **Storage**: `src/nest/storage/` is the upload driver abstraction (local / S3 / mirror behind one interface, with an admin surface and a usage job). Domain services never touch the filesystem or the S3 SDK directly. -- **DB** (`src/db/`): `better-sqlite3`, WAL, FK on. `database.ts` only opens the connection and applies pragmas — it does not build a schema. MikroORM's migrations (`db/migrations/`) are the only schema source, **including for tests**: the hand-kept `schema.ts`/`migrations.ts`/`seeds.ts` builders and the parity test that guarded them are gone. Seeding is the MikroORM seeders in `db/seeders/`, applied by `db/orm.ts` from `buildApp()` before anything reads. An install the retired positional runner migrated (a `schema_version` row, no `mikro_orm_migrations` rows) is baselined by `db/legacy-baseline.ts`: the migrations whose `Legacy migration step N` docstring is ≤ its version are recorded as executed (never replayed) in the same transaction as the migrator run, while the baseline migration itself still runs first, as the legacy runner's `createTables()` did on every boot; anything it cannot map refuses the boot. Before a boot applies pending migrations to a file database that holds anything, `db/pre-migrate-snapshot.ts` writes a `VACUUM INTO` copy next to it (`pre-migrate--