@vex-chat/spire 3.0.1 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/README.md +9 -8
  2. package/dist/Database.d.ts +12 -11
  3. package/dist/Database.js +161 -118
  4. package/dist/Database.js.map +1 -1
  5. package/dist/Spire.d.ts +4 -2
  6. package/dist/Spire.js +154 -71
  7. package/dist/Spire.js.map +1 -1
  8. package/dist/db/schema.d.ts +0 -2
  9. package/dist/migrations/2026-04-06_initial-schema.js +4 -5
  10. package/dist/migrations/2026-04-06_initial-schema.js.map +1 -1
  11. package/dist/migrations/{2026-04-14_argon2id-password-hashing.d.ts → 2026-07-14_query-indexes.d.ts} +1 -1
  12. package/dist/migrations/2026-07-14_query-indexes.js +31 -0
  13. package/dist/migrations/2026-07-14_query-indexes.js.map +1 -0
  14. package/dist/server/avatar.js +30 -4
  15. package/dist/server/avatar.js.map +1 -1
  16. package/dist/server/errors.js +8 -0
  17. package/dist/server/errors.js.map +1 -1
  18. package/dist/server/file.js +35 -12
  19. package/dist/server/file.js.map +1 -1
  20. package/dist/server/index.d.ts +8 -0
  21. package/dist/server/index.js +157 -41
  22. package/dist/server/index.js.map +1 -1
  23. package/dist/server/passkey.js +41 -26
  24. package/dist/server/passkey.js.map +1 -1
  25. package/dist/server/passkeyDevices.js +1 -0
  26. package/dist/server/passkeyDevices.js.map +1 -1
  27. package/dist/server/password.d.ts +7 -0
  28. package/dist/server/password.js +58 -0
  29. package/dist/server/password.js.map +1 -0
  30. package/dist/server/permissions.d.ts +1 -0
  31. package/dist/server/permissions.js +11 -2
  32. package/dist/server/permissions.js.map +1 -1
  33. package/dist/server/rateLimit.d.ts +11 -0
  34. package/dist/server/rateLimit.js +43 -4
  35. package/dist/server/rateLimit.js.map +1 -1
  36. package/dist/server/user.d.ts +1 -1
  37. package/dist/server/user.js +40 -9
  38. package/dist/server/user.js.map +1 -1
  39. package/dist/types/express.d.ts +3 -4
  40. package/dist/types/express.js.map +1 -1
  41. package/dist/utils/authJwt.d.ts +8 -0
  42. package/dist/utils/authJwt.js +25 -0
  43. package/dist/utils/authJwt.js.map +1 -0
  44. package/dist/utils/loadEnv.js +4 -0
  45. package/dist/utils/loadEnv.js.map +1 -1
  46. package/dist/utils/preKeySignature.d.ts +1 -1
  47. package/dist/utils/preKeySignature.js +7 -11
  48. package/dist/utils/preKeySignature.js.map +1 -1
  49. package/package.json +7 -7
  50. package/src/Database.ts +211 -152
  51. package/src/Spire.ts +411 -284
  52. package/src/__tests__/Database.spec.ts +126 -3
  53. package/src/__tests__/connectAuth.spec.ts +146 -4
  54. package/src/__tests__/deviceTokenRevalidation.spec.ts +57 -3
  55. package/src/__tests__/passkeyDevices.spec.ts +94 -0
  56. package/src/__tests__/passkeys.spec.ts +7 -2
  57. package/src/__tests__/password.spec.ts +223 -0
  58. package/src/__tests__/permissions.spec.ts +50 -0
  59. package/src/__tests__/preKeySignature.spec.ts +20 -3
  60. package/src/db/schema.ts +0 -2
  61. package/src/migrations/2026-04-06_initial-schema.ts +4 -5
  62. package/src/migrations/2026-07-14_query-indexes.ts +34 -0
  63. package/src/server/avatar.ts +29 -4
  64. package/src/server/errors.ts +7 -0
  65. package/src/server/file.ts +34 -13
  66. package/src/server/index.ts +286 -111
  67. package/src/server/passkey.ts +51 -28
  68. package/src/server/passkeyDevices.ts +1 -0
  69. package/src/server/password.ts +96 -0
  70. package/src/server/permissions.ts +20 -2
  71. package/src/server/rateLimit.ts +47 -4
  72. package/src/server/user.ts +44 -9
  73. package/src/types/express.ts +3 -4
  74. package/src/utils/authJwt.ts +34 -0
  75. package/src/utils/loadEnv.ts +4 -0
  76. package/src/utils/preKeySignature.ts +12 -15
  77. package/dist/migrations/2026-04-14_argon2id-password-hashing.js +0 -17
  78. package/dist/migrations/2026-04-14_argon2id-password-hashing.js.map +0 -1
  79. package/src/migrations/2026-04-14_argon2id-password-hashing.ts +0 -24
package/src/Database.ts CHANGED
@@ -32,10 +32,10 @@ import type {
32
32
  Server,
33
33
  UserRecord,
34
34
  } from "@vex-chat/types";
35
- import type { Migration, MigrationProvider } from "kysely";
35
+ import type { Migration, MigrationProvider, Transaction } from "kysely";
36
36
 
37
37
  import { EventEmitter } from "events";
38
- import { createHash, pbkdf2Sync } from "node:crypto";
38
+ import { createHash } from "node:crypto";
39
39
  import { statSync } from "node:fs";
40
40
  import * as fs from "node:fs/promises";
41
41
  import path from "node:path";
@@ -47,6 +47,8 @@ import {
47
47
  XUtils,
48
48
  } from "@vex-chat/crypto";
49
49
  import {
50
+ ACCOUNT_PASSWORD_MAX_LENGTH,
51
+ ACCOUNT_PASSWORD_MIN_LENGTH,
50
52
  AccountEntitlementSourceSchema,
51
53
  AccountTierSchema,
52
54
  BillingEnvironmentSchema,
@@ -100,6 +102,8 @@ import BetterSqlite3 from "better-sqlite3";
100
102
  import { Kysely, Migrator, sql, SqliteDialect } from "kysely";
101
103
  import { stringify as uuidStringify, validate as uuidValidate } from "uuid";
102
104
 
105
+ export const MAX_ACTIVE_DEVICES_PER_USER = 20;
106
+
103
107
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
104
108
  const migrationFolder = path.join(__dirname, "migrations");
105
109
 
@@ -155,20 +159,31 @@ function isMigration(mod: unknown): mod is Migration {
155
159
  );
156
160
  }
157
161
 
158
- const pubkeyRegex = /[0-9a-f]{64}/;
159
-
160
- /** Legacy iteration count kept only for verifying old PBKDF2 hashes. */
161
- const PBKDF2_ITERATIONS = 1000;
162
+ const pubkeyRegex = /^(?:[0-9a-fA-F]{2}){32,4096}$/;
163
+ const DUMMY_PASSWORD_HASH =
164
+ "$argon2id$v=19$m=65536,t=3,p=1$SZlCYiWwt450ZWt2zRvDIw$QZdY/EtG81hEAXYRLVDvqtpJbajXL1/QRM91ZT9DQPk";
165
+ const ARGON2_OPTIONS = {
166
+ memoryCost: 65_536,
167
+ parallelism: 1,
168
+ timeCost: 3,
169
+ type: argon2.argon2id,
170
+ } as const;
171
+
172
+ const COMMON_ACCOUNT_PASSWORDS = new Set([
173
+ "123456789012345",
174
+ "adminadminadminadmin",
175
+ "letmeinletmeinletmein",
176
+ "passwordpassword",
177
+ "passwordpasswordpassword",
178
+ "qwertyuiopasdfgh",
179
+ ]);
180
+
181
+ export type InternalUserRecord = UserRecord;
162
182
 
163
183
  // ── Row-to-interface converters ─────────────────────────────────────────
164
184
  // SQLite stores booleans as integers and dates as strings, but the
165
185
  // @vex-chat/types interfaces expect boolean / Date.
166
186
 
167
- /** Internal record that includes the hash algorithm discriminator. */
168
- export interface InternalUserRecord extends UserRecord {
169
- hashAlgo: string;
170
- }
171
-
172
187
  export interface NotificationSubscription {
173
188
  channel: "expo";
174
189
  createdAt: string;
@@ -272,55 +287,11 @@ export class Database extends EventEmitter {
272
287
  payload: DevicePayload,
273
288
  passkeyApproval?: DevicePasskeyApprovalInput,
274
289
  ): Promise<Device> {
275
- const now = new Date().toISOString();
276
- const device = {
277
- deleted: 0,
278
- deviceID: crypto.randomUUID(),
279
- lastLogin: now,
280
- name: payload.deviceName,
281
- owner,
282
- signKey: payload.signKey,
283
- };
284
-
285
- const medPreKeys = {
286
- deviceID: device.deviceID,
287
- index: payload.preKeyIndex,
288
- keyID: crypto.randomUUID(),
289
- publicKey: payload.preKey,
290
- signature: payload.preKeySignature,
291
- userID: owner,
292
- };
293
-
294
- await this.db.transaction().execute(async (trx) => {
295
- await trx.insertInto("devices").values(device).execute();
296
- await trx.insertInto("preKeys").values(medPreKeys).execute();
297
- if (passkeyApproval) {
298
- await trx
299
- .insertInto("device_passkey_approvals")
300
- .values({
301
- approvedAt: now,
302
- approvedByDeviceID:
303
- passkeyApproval.approvedByDeviceID ?? null,
304
- approvedByPasskeyID:
305
- passkeyApproval.approvedByPasskeyID,
306
- deviceID: device.deviceID,
307
- userID: owner,
308
- })
309
- .onConflict((oc) =>
310
- oc.column("deviceID").doUpdateSet({
311
- approvedAt: now,
312
- approvedByDeviceID:
313
- passkeyApproval.approvedByDeviceID ?? null,
314
- approvedByPasskeyID:
315
- passkeyApproval.approvedByPasskeyID,
316
- userID: owner,
317
- }),
318
- )
319
- .execute();
320
- }
321
- });
322
-
323
- return toDevice(device);
290
+ return this.db
291
+ .transaction()
292
+ .execute(async (trx) =>
293
+ this.insertDevice(trx, owner, payload, passkeyApproval),
294
+ );
324
295
  }
325
296
 
326
297
  public async createEmoji(emoji: Emoji): Promise<void> {
@@ -453,10 +424,7 @@ export class Database extends EventEmitter {
453
424
  ): Promise<[null | UserRecord, Error | null]> {
454
425
  try {
455
426
  const userID = uuidStringify(regKey);
456
- const username = normalizeRegistrationUsername(
457
- regPayload.username,
458
- userID,
459
- );
427
+ const username = normalizeRegistrationUsername(regPayload.username);
460
428
  if (
461
429
  typeof regPayload.password !== "string" ||
462
430
  regPayload.password.trim().length === 0
@@ -465,25 +433,32 @@ export class Database extends EventEmitter {
465
433
  "Password is required to register a new account.",
466
434
  );
467
435
  }
436
+ const passwordError = validateAccountPassword(
437
+ regPayload.password,
438
+ username,
439
+ );
440
+ if (passwordError) {
441
+ throw new Error(passwordError);
442
+ }
468
443
  const passwordHash = await hashPasswordArgon2(regPayload.password);
469
444
 
470
445
  const user: UserRecord = {
471
446
  lastSeen: new Date().toISOString(),
472
447
  passwordHash,
473
- passwordSalt: "",
474
448
  userID,
475
449
  username,
476
450
  };
477
451
 
478
- await this.db
479
- .insertInto("users")
480
- .values({
481
- ...user,
482
- hashAlgo: "argon2id",
483
- lastSeen: user.lastSeen,
484
- })
485
- .execute();
486
- await this.createDevice(user.userID, regPayload);
452
+ await this.db.transaction().execute(async (trx) => {
453
+ await trx
454
+ .insertInto("users")
455
+ .values({
456
+ ...user,
457
+ lastSeen: user.lastSeen,
458
+ })
459
+ .execute();
460
+ await this.insertDevice(trx, user.userID, regPayload);
461
+ });
487
462
 
488
463
  return [user, null];
489
464
  } catch (err: unknown) {
@@ -504,31 +479,33 @@ export class Database extends EventEmitter {
504
479
  }
505
480
 
506
481
  public async deleteDevice(deviceID: string): Promise<void> {
507
- await this.db
508
- .deleteFrom("preKeys")
509
- .where("deviceID", "=", deviceID)
510
- .execute();
482
+ await this.db.transaction().execute(async (trx) => {
483
+ await trx
484
+ .deleteFrom("preKeys")
485
+ .where("deviceID", "=", deviceID)
486
+ .execute();
511
487
 
512
- await this.db
513
- .deleteFrom("oneTimeKeys")
514
- .where("deviceID", "=", deviceID)
515
- .execute();
488
+ await trx
489
+ .deleteFrom("oneTimeKeys")
490
+ .where("deviceID", "=", deviceID)
491
+ .execute();
516
492
 
517
- await this.db
518
- .deleteFrom("notification_subscriptions")
519
- .where("deviceID", "=", deviceID)
520
- .execute();
493
+ await trx
494
+ .deleteFrom("notification_subscriptions")
495
+ .where("deviceID", "=", deviceID)
496
+ .execute();
521
497
 
522
- await this.db
523
- .deleteFrom("device_passkey_approvals")
524
- .where("deviceID", "=", deviceID)
525
- .execute();
498
+ await trx
499
+ .deleteFrom("device_passkey_approvals")
500
+ .where("deviceID", "=", deviceID)
501
+ .execute();
526
502
 
527
- await this.db
528
- .updateTable("devices")
529
- .set({ deleted: 1 })
530
- .where("deviceID", "=", deviceID)
531
- .execute();
503
+ await trx
504
+ .updateTable("devices")
505
+ .set({ deleted: 1 })
506
+ .where("deviceID", "=", deviceID)
507
+ .execute();
508
+ });
532
509
  }
533
510
 
534
511
  public async deleteEmoji(emojiID: string): Promise<void> {
@@ -815,16 +792,19 @@ export class Database extends EventEmitter {
815
792
  */
816
793
  public async markPasskeyUsed(
817
794
  passkeyID: string,
795
+ expectedSignCount: number,
818
796
  signCount: number,
819
- ): Promise<void> {
820
- await this.db
797
+ ): Promise<boolean> {
798
+ const result = await this.db
821
799
  .updateTable("passkeys")
822
800
  .set({
823
801
  lastUsedAt: new Date().toISOString(),
824
802
  signCount,
825
803
  })
826
804
  .where("passkeyID", "=", passkeyID)
827
- .execute();
805
+ .where("signCount", "=", expectedSignCount)
806
+ .executeTakeFirst();
807
+ return Number(result.numUpdatedRows) > 0;
828
808
  }
829
809
 
830
810
  public async markUserSeen(user: UserRecord): Promise<void> {
@@ -1013,9 +993,7 @@ export class Database extends EventEmitter {
1013
993
  await this.db
1014
994
  .updateTable("users")
1015
995
  .set({
1016
- hashAlgo: "argon2id",
1017
996
  passwordHash: newHash,
1018
- passwordSalt: "",
1019
997
  })
1020
998
  .where("userID", "=", userID)
1021
999
  .execute();
@@ -1526,13 +1504,8 @@ export class Database extends EventEmitter {
1526
1504
  return row?.userID ?? null;
1527
1505
  }
1528
1506
 
1529
- // The identifier is matched as either a userID (UUID branch) or a
1530
- // username (string branch). Username comparison is case-folded so
1531
- // `User` and `user` resolve to the same row regardless of how the
1532
- // caller typed it — the canonical form on disk is lowercase
1533
- // (`normalizeRegistrationUsername`), but legacy mixed-case rows
1534
- // from before the canonicalization landed still resolve via the
1535
- // `lower(username) = lower(?)` predicate below.
1507
+ // The identifier is matched as either a userID or the canonical lowercase
1508
+ // username stored by `normalizeRegistrationUsername`.
1536
1509
  public async retrieveUser(
1537
1510
  userIdentifier: string,
1538
1511
  ): Promise<InternalUserRecord | null> {
@@ -1545,11 +1518,11 @@ export class Database extends EventEmitter {
1545
1518
  .limit(1)
1546
1519
  .execute();
1547
1520
  } else {
1548
- const normalized = userIdentifier.toLowerCase();
1521
+ const normalized = userIdentifier.trim().toLowerCase();
1549
1522
  rows = await this.db
1550
1523
  .selectFrom("users")
1551
1524
  .selectAll()
1552
- .where(sql<string>`lower(username)`, "=", normalized)
1525
+ .where("username", "=", normalized)
1553
1526
  .limit(1)
1554
1527
  .execute();
1555
1528
  }
@@ -1842,6 +1815,74 @@ export class Database extends EventEmitter {
1842
1815
  this.emit("ready");
1843
1816
  }
1844
1817
 
1818
+ private async insertDevice(
1819
+ trx: Transaction<ServerDatabase>,
1820
+ owner: string,
1821
+ payload: DevicePayload,
1822
+ passkeyApproval?: DevicePasskeyApprovalInput,
1823
+ ): Promise<Device> {
1824
+ const activeDeviceCount = await trx
1825
+ .selectFrom("devices")
1826
+ .select((eb) => eb.fn.countAll().as("count"))
1827
+ .where("owner", "=", owner)
1828
+ .where("deleted", "=", 0)
1829
+ .executeTakeFirst();
1830
+ if (
1831
+ Number(activeDeviceCount?.count ?? 0) >= MAX_ACTIVE_DEVICES_PER_USER
1832
+ ) {
1833
+ throw new Error(
1834
+ `Each account is limited to ${String(MAX_ACTIVE_DEVICES_PER_USER)} active devices.`,
1835
+ );
1836
+ }
1837
+
1838
+ const now = new Date().toISOString();
1839
+ const device = {
1840
+ deleted: 0,
1841
+ deviceID: crypto.randomUUID(),
1842
+ lastLogin: now,
1843
+ name: payload.deviceName,
1844
+ owner,
1845
+ signKey: payload.signKey,
1846
+ };
1847
+
1848
+ const medPreKeys = {
1849
+ deviceID: device.deviceID,
1850
+ index: payload.preKeyIndex,
1851
+ keyID: crypto.randomUUID(),
1852
+ publicKey: payload.preKey,
1853
+ signature: payload.preKeySignature,
1854
+ userID: owner,
1855
+ };
1856
+
1857
+ await trx.insertInto("devices").values(device).execute();
1858
+ await trx.insertInto("preKeys").values(medPreKeys).execute();
1859
+ if (passkeyApproval) {
1860
+ await trx
1861
+ .insertInto("device_passkey_approvals")
1862
+ .values({
1863
+ approvedAt: now,
1864
+ approvedByDeviceID:
1865
+ passkeyApproval.approvedByDeviceID ?? null,
1866
+ approvedByPasskeyID: passkeyApproval.approvedByPasskeyID,
1867
+ deviceID: device.deviceID,
1868
+ userID: owner,
1869
+ })
1870
+ .onConflict((oc) =>
1871
+ oc.column("deviceID").doUpdateSet({
1872
+ approvedAt: now,
1873
+ approvedByDeviceID:
1874
+ passkeyApproval.approvedByDeviceID ?? null,
1875
+ approvedByPasskeyID:
1876
+ passkeyApproval.approvedByPasskeyID,
1877
+ userID: owner,
1878
+ }),
1879
+ )
1880
+ .execute();
1881
+ }
1882
+
1883
+ return toDevice(device);
1884
+ }
1885
+
1845
1886
  private mailSqlEntry(
1846
1887
  mail: MailWS,
1847
1888
  header: Uint8Array,
@@ -1873,49 +1914,77 @@ export class Database extends EventEmitter {
1873
1914
  * Returns the encoded hash string which embeds salt, params, and digest.
1874
1915
  */
1875
1916
  export async function hashPasswordArgon2(password: string): Promise<string> {
1876
- return argon2.hash(password, {
1877
- memoryCost: 65536,
1878
- parallelism: 4,
1879
- timeCost: 3,
1880
- type: argon2.argon2id,
1881
- });
1917
+ if (
1918
+ password.length === 0 ||
1919
+ password.length > ACCOUNT_PASSWORD_MAX_LENGTH
1920
+ ) {
1921
+ throw new Error("Password length is outside the supported range.");
1922
+ }
1923
+ return argon2.hash(password, ARGON2_OPTIONS);
1924
+ }
1925
+
1926
+ /**
1927
+ * Validate new account passwords without imposing composition rules. Passwords
1928
+ * are hashed exactly as supplied; normalization is used only for comparisons
1929
+ * against account-specific and common-password deny lists.
1930
+ */
1931
+ export function validateAccountPassword(
1932
+ password: string,
1933
+ username?: string,
1934
+ ): null | string {
1935
+ if (
1936
+ password.trim().length === 0 ||
1937
+ password.length < ACCOUNT_PASSWORD_MIN_LENGTH
1938
+ ) {
1939
+ return `Password must be at least ${String(ACCOUNT_PASSWORD_MIN_LENGTH)} characters.`;
1940
+ }
1941
+ if (password.length > ACCOUNT_PASSWORD_MAX_LENGTH) {
1942
+ return `Password must be at most ${String(ACCOUNT_PASSWORD_MAX_LENGTH)} characters.`;
1943
+ }
1944
+
1945
+ const comparable = password.normalize("NFKC").toLowerCase();
1946
+ const comparableUsername = username?.trim().normalize("NFKC").toLowerCase();
1947
+ const characters = Array.from(comparable);
1948
+ const repeatedSingleCharacter = characters.every(
1949
+ (character) => character === characters[0],
1950
+ );
1951
+ if (
1952
+ COMMON_ACCOUNT_PASSWORDS.has(comparable) ||
1953
+ repeatedSingleCharacter ||
1954
+ (comparableUsername !== undefined && comparable === comparableUsername)
1955
+ ) {
1956
+ return "Choose a less common password.";
1957
+ }
1958
+
1959
+ return null;
1882
1960
  }
1883
1961
 
1884
1962
  /**
1885
- * Verify a password against either Argon2id or legacy PBKDF2 storage.
1886
- * Returns `{ valid, needsRehash }` — callers should rehash on success
1887
- * when `needsRehash` is true.
1963
+ * Verify an Argon2id password hash. Unknown hash formats fail closed.
1888
1964
  */
1889
1965
  export async function verifyPassword(
1890
1966
  password: string,
1891
- stored: { hashAlgo: string; passwordHash: string; passwordSalt: string },
1967
+ stored: null | {
1968
+ passwordHash: string;
1969
+ },
1892
1970
  ): Promise<{ needsRehash: boolean; valid: boolean }> {
1893
- if (stored.hashAlgo === "keycluster") {
1971
+ if (stored === null) {
1972
+ await argon2.verify(DUMMY_PASSWORD_HASH, password);
1894
1973
  return { needsRehash: false, valid: false };
1895
1974
  }
1896
- if (stored.hashAlgo === "argon2id") {
1975
+ try {
1897
1976
  const valid = await argon2.verify(stored.passwordHash, password);
1898
- return { needsRehash: false, valid };
1899
- }
1900
-
1901
- // Legacy PBKDF2 path
1902
- const salt = XUtils.decodeHex(stored.passwordSalt);
1903
- const computed = pbkdf2Sync(
1904
- password,
1905
- salt,
1906
- PBKDF2_ITERATIONS,
1907
- 32,
1908
- "sha512",
1909
- );
1910
- const storedBuf = XUtils.decodeHex(stored.passwordHash);
1911
-
1912
- if (computed.length !== storedBuf.length) {
1977
+ return {
1978
+ needsRehash:
1979
+ valid &&
1980
+ argon2.needsRehash(stored.passwordHash, ARGON2_OPTIONS),
1981
+ valid,
1982
+ };
1983
+ } catch {
1984
+ // Keep malformed rows on the same expensive path as a missing user.
1985
+ await argon2.verify(DUMMY_PASSWORD_HASH, password).catch(() => false);
1913
1986
  return { needsRehash: false, valid: false };
1914
1987
  }
1915
-
1916
- const { timingSafeEqual } = await import("node:crypto");
1917
- const valid = timingSafeEqual(computed, storedBuf);
1918
- return { needsRehash: valid, valid };
1919
1988
  }
1920
1989
 
1921
1990
  function accountTierRank(tier: AccountTier): number {
@@ -2001,16 +2070,8 @@ function expiryRank(expiresAt: null | string): number {
2001
2070
  // flows) gets the same lowercase canonicalization the public
2002
2071
  // `POST /register` route applies. Usernames are case-insensitive at
2003
2072
  // the protocol level.
2004
- function normalizeRegistrationUsername(
2005
- providedUsername: string | undefined,
2006
- userID: string,
2007
- ): string {
2008
- const trimmed = providedUsername?.trim().toLowerCase();
2009
- if (trimmed && trimmed.length > 0) {
2010
- return trimmed;
2011
- }
2012
- const seed = userID.replaceAll("-", "").slice(0, 12);
2013
- return `key_${seed}`;
2073
+ function normalizeRegistrationUsername(providedUsername: string): string {
2074
+ return providedUsername.trim().toLowerCase();
2014
2075
  }
2015
2076
 
2016
2077
  function parseAccountEntitlementSource(
@@ -2131,10 +2192,8 @@ function toServer(row: {
2131
2192
  }
2132
2193
 
2133
2194
  function toUserRecord(row: {
2134
- hashAlgo: string;
2135
2195
  lastSeen: string;
2136
2196
  passwordHash: string;
2137
- passwordSalt: string;
2138
2197
  userID: string;
2139
2198
  username: string;
2140
2199
  }): InternalUserRecord {