@spfn/auth 0.3.0-beta.3 → 0.3.0-beta.5

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.
package/dist/server.js CHANGED
@@ -4521,6 +4521,19 @@ var init_schema3 = __esm({
4521
4521
  }
4522
4522
  });
4523
4523
 
4524
+ // src/server/helpers/email.ts
4525
+ function normalizeEmail(email) {
4526
+ return email.trim().toLowerCase();
4527
+ }
4528
+ function normalizeOptionalEmail(email) {
4529
+ return typeof email === "string" ? normalizeEmail(email) : email;
4530
+ }
4531
+ var init_email = __esm({
4532
+ "src/server/helpers/email.ts"() {
4533
+ "use strict";
4534
+ }
4535
+ });
4536
+
4524
4537
  // src/server/entities/schema.ts
4525
4538
  import { createSchema } from "@spfn/core/db";
4526
4539
  var authSchema;
@@ -5486,7 +5499,7 @@ var init_entities = __esm({
5486
5499
  });
5487
5500
 
5488
5501
  // src/server/repositories/users.repository.ts
5489
- import { eq, and } from "drizzle-orm";
5502
+ import { eq, and, sql as sql4 } from "drizzle-orm";
5490
5503
  import { BaseRepository } from "@spfn/core/db";
5491
5504
  import { EntityNotFoundError, NotFoundError } from "@spfn/core/errors";
5492
5505
  var UsersRepository, usersRepository;
@@ -5494,6 +5507,7 @@ var init_users_repository = __esm({
5494
5507
  "src/server/repositories/users.repository.ts"() {
5495
5508
  "use strict";
5496
5509
  init_entities();
5510
+ init_email();
5497
5511
  UsersRepository = class extends BaseRepository {
5498
5512
  /**
5499
5513
  * ID로 사용자 조회
@@ -5519,7 +5533,25 @@ var init_users_repository = __esm({
5519
5533
  * Read replica 사용
5520
5534
  */
5521
5535
  async findByEmail(email) {
5522
- const result = await this.readDb.select().from(users).where(eq(users.email, email)).limit(1);
5536
+ const result = await this.readDb.select().from(users).where(eq(users.email, normalizeEmail(email))).limit(1);
5537
+ return result[0] ?? null;
5538
+ }
5539
+ /**
5540
+ * 이메일로 사용자 조회 — 저장된 형태와 무관하게 찾는다.
5541
+ *
5542
+ * `findByEmail` asks whether a row holds this exact address; this asks
5543
+ * whether any row *is* this address, whatever form it was written in. The
5544
+ * difference matters to a caller that answers "no" by creating an account:
5545
+ * a lookup that misses a row stored in another form would make a second
5546
+ * account for a person who already has one, and the unique constraint
5547
+ * cannot object because the two stored strings differ.
5548
+ *
5549
+ * Folding in the predicate means no index on `email` applies, so this is for
5550
+ * the few addresses a caller decides about — admin seeding — not for a
5551
+ * request path. Write primary: the answer decides whether to insert.
5552
+ */
5553
+ async findByEmailInAnyStoredForm(email) {
5554
+ const result = await this.db.select().from(users).where(sql4`lower(btrim(${users.email})) = ${normalizeEmail(email)}`).limit(1);
5523
5555
  return result[0] ?? null;
5524
5556
  }
5525
5557
  /**
@@ -5585,14 +5617,69 @@ var init_users_repository = __esm({
5585
5617
  * Write primary 사용
5586
5618
  */
5587
5619
  async create(data) {
5588
- return await this._create(users, data);
5620
+ return await this._create(users, { ...data, email: normalizeOptionalEmail(data.email) });
5621
+ }
5622
+ /**
5623
+ * User ids grouped by an address two or more rows share once folded.
5624
+ *
5625
+ * The whole comparison happens in the database and only the colliding groups
5626
+ * come back, so the size of the answer is the size of the problem rather
5627
+ * than the size of the table. `users.email` is unique, so a group of more
5628
+ * than one can only be rows that differ by capitalization or padding —
5629
+ * exactly the ones a rewrite cannot decide between.
5630
+ *
5631
+ * Every member id is returned, including a row already holding the
5632
+ * canonical form, because the operator has to compare the accounts against
5633
+ * each other to settle which is real.
5634
+ *
5635
+ * Write primary: the caller is about to rewrite rows and a replica could
5636
+ * still be showing the pre-fix state.
5637
+ */
5638
+ async findEmailConflictGroups() {
5639
+ const rows = await this.db.select({ ids: sql4`array_agg(${users.id} ORDER BY ${users.id})` }).from(users).where(sql4`${users.email} IS NOT NULL`).groupBy(sql4`lower(btrim(${users.email}))`).having(sql4`count(*) > 1`);
5640
+ return rows.map((row) => row.ids.map(Number));
5641
+ }
5642
+ /**
5643
+ * Fold every stored address to canonical form, leaving the given ids alone.
5644
+ *
5645
+ * One statement rather than a row at a time: the rewrite is the same
5646
+ * expression the detection uses, so the database can do it in place. A
5647
+ * legacy install with a large users table therefore pays one update instead
5648
+ * of a round trip per row on the boot path, and no list of addresses is ever
5649
+ * carried through the application.
5650
+ *
5651
+ * The excluded ids travel as a single array parameter, so the count of
5652
+ * conflicts cannot run into the protocol's limit on bind parameters.
5653
+ *
5654
+ * One statement also means all or nothing. `users.email` is unique, so if an
5655
+ * instance still running the old code registers a canonical address in the
5656
+ * moment between the conflict query and this update, the update aborts and
5657
+ * nothing is folded on this boot. The next boot sees that pair as a conflict
5658
+ * and folds everything else, so the repair is deferred rather than lost.
5659
+ *
5660
+ * `lower(btrim(...))` is the SQL spelling of `normalizeEmail`. The two agree
5661
+ * on every address this package's validation accepts (ASCII, no interior
5662
+ * whitespace); an address outside that set — reachable only by an app
5663
+ * writing to the repository directly — may fold differently in a database
5664
+ * whose collation lower-cases non-ASCII letters.
5665
+ *
5666
+ * @param excludedIds - Rows to leave untouched, normally the conflict groups
5667
+ * @returns How many rows were rewritten
5668
+ */
5669
+ async normalizeEmailsExcept(excludedIds) {
5670
+ const keepConflicts = excludedIds.length > 0 ? sql4` AND NOT (${users.id} = ANY(string_to_array(${excludedIds.join(",")}, ',')::bigint[]))` : sql4``;
5671
+ const pending = sql4`${users.email} IS NOT NULL AND ${users.email} <> lower(btrim(${users.email}))${keepConflicts}`;
5672
+ const [counted] = await this.db.select({ rows: sql4`count(*)` }).from(users).where(pending);
5673
+ await this.db.update(users).set({ email: sql4`lower(btrim(${users.email}))` }).where(pending);
5674
+ return Number(counted?.rows ?? 0);
5589
5675
  }
5590
5676
  /**
5591
5677
  * 사용자 정보 업데이트
5592
5678
  * Write primary 사용
5593
5679
  */
5594
5680
  async updateById(id13, data) {
5595
- const result = await this.db.update(users).set(data).where(eq(users.id, id13)).returning();
5681
+ const patch = "email" in data ? { ...data, email: normalizeOptionalEmail(data.email) } : data;
5682
+ const result = await this.db.update(users).set(patch).where(eq(users.id, id13)).returning();
5596
5683
  return result[0] ?? null;
5597
5684
  }
5598
5685
  /**
@@ -5759,7 +5846,7 @@ var init_users_repository = __esm({
5759
5846
 
5760
5847
  // src/server/repositories/keys.repository.ts
5761
5848
  import { BaseRepository as BaseRepository2 } from "@spfn/core/db";
5762
- import { eq as eq2, and as and2, or, isNull, lt, ne, desc, sql as sql4 } from "drizzle-orm";
5849
+ import { eq as eq2, and as and2, or, isNull, lt, ne, desc, sql as sql5 } from "drizzle-orm";
5763
5850
  var LAST_USED_THROTTLE_MS, KeysRepository, keysRepository;
5764
5851
  var init_keys_repository = __esm({
5765
5852
  "src/server/repositories/keys.repository.ts"() {
@@ -6000,19 +6087,19 @@ var init_keys_repository = __esm({
6000
6087
  await this.db.update(userPublicKeys).set({ lastUsedAt: /* @__PURE__ */ new Date() }).where(and2(eq2(userPublicKeys.id, id13), lastUsedIsStale));
6001
6088
  return;
6002
6089
  }
6003
- const identityChanged = sql4`(
6090
+ const identityChanged = sql5`(
6004
6091
  ${userPublicKeys.clientKind} IS DISTINCT FROM ${identity.kind}
6005
6092
  OR ${userPublicKeys.clientVersion} IS DISTINCT FROM ${identity.version}
6006
6093
  OR ${userPublicKeys.clientContractVersion} IS DISTINCT FROM ${identity.contractVersion}
6007
6094
  )`;
6008
6095
  const now = /* @__PURE__ */ new Date();
6009
- const nowParam = sql4`${now.toISOString()}::timestamptz`;
6096
+ const nowParam = sql5`${now.toISOString()}::timestamptz`;
6010
6097
  await this.db.update(userPublicKeys).set({
6011
6098
  lastUsedAt: now,
6012
6099
  clientKind: identity.kind,
6013
6100
  clientVersion: identity.version,
6014
6101
  clientContractVersion: identity.contractVersion,
6015
- clientSeenAt: sql4`CASE WHEN ${identityChanged} THEN ${nowParam} ELSE ${userPublicKeys.clientSeenAt} END`
6102
+ clientSeenAt: sql5`CASE WHEN ${identityChanged} THEN ${nowParam} ELSE ${userPublicKeys.clientSeenAt} END`
6016
6103
  }).where(and2(
6017
6104
  eq2(userPublicKeys.id, id13),
6018
6105
  or(lastUsedIsStale, identityChanged)
@@ -6561,7 +6648,7 @@ var init_user_profiles_repository = __esm({
6561
6648
  });
6562
6649
 
6563
6650
  // src/server/repositories/invitations.repository.ts
6564
- import { eq as eq9, and as and6, lt as lt4, desc as desc2, sql as sql5 } from "drizzle-orm";
6651
+ import { eq as eq9, and as and6, lt as lt4, desc as desc2, sql as sql6 } from "drizzle-orm";
6565
6652
  import { BaseRepository as BaseRepository9 } from "@spfn/core/db";
6566
6653
  var InvitationsRepository, invitationsRepository;
6567
6654
  var init_invitations_repository = __esm({
@@ -6570,6 +6657,7 @@ var init_invitations_repository = __esm({
6570
6657
  init_users();
6571
6658
  init_roles();
6572
6659
  init_user_invitations();
6660
+ init_email();
6573
6661
  InvitationsRepository = class extends BaseRepository9 {
6574
6662
  /**
6575
6663
  * ID로 초대 조회
@@ -6591,7 +6679,7 @@ var init_invitations_repository = __esm({
6591
6679
  async findPendingByEmail(email) {
6592
6680
  const result = await this.readDb.select().from(userInvitations).where(
6593
6681
  and6(
6594
- eq9(userInvitations.email, email),
6682
+ eq9(userInvitations.email, normalizeEmail(email)),
6595
6683
  eq9(userInvitations.status, "pending")
6596
6684
  )
6597
6685
  ).limit(1);
@@ -6613,7 +6701,7 @@ var init_invitations_repository = __esm({
6613
6701
  * 초대 생성
6614
6702
  */
6615
6703
  async create(data) {
6616
- return await this._create(userInvitations, data);
6704
+ return await this._create(userInvitations, { ...data, email: normalizeEmail(data.email) });
6617
6705
  }
6618
6706
  /**
6619
6707
  * 초대 상태 업데이트
@@ -6695,7 +6783,7 @@ var init_invitations_repository = __esm({
6695
6783
  conditions.push(eq9(userInvitations.invitedBy, invitedBy));
6696
6784
  }
6697
6785
  const whereClause = conditions.length > 0 ? and6(...conditions) : void 0;
6698
- const countResult = await this.readDb.select({ count: sql5`count(*)` }).from(userInvitations).where(whereClause);
6786
+ const countResult = await this.readDb.select({ count: sql6`count(*)` }).from(userInvitations).where(whereClause);
6699
6787
  const total = Number(countResult[0]?.count || 0);
6700
6788
  const results = await this.readDb.select({
6701
6789
  id: userInvitations.id,
@@ -6732,7 +6820,8 @@ var init_invitations_repository = __esm({
6732
6820
  * 초대 업데이트 (일반 업데이트 - 모든 필드 가능)
6733
6821
  */
6734
6822
  async updateById(id13, data) {
6735
- const result = await this.db.update(userInvitations).set(data).where(eq9(userInvitations.id, id13)).returning();
6823
+ const patch = "email" in data && typeof data.email === "string" ? { ...data, email: normalizeEmail(data.email) } : data;
6824
+ const result = await this.db.update(userInvitations).set(patch).where(eq9(userInvitations.id, id13)).returning();
6736
6825
  return result[0] ?? null;
6737
6826
  }
6738
6827
  /**
@@ -7879,6 +7968,9 @@ import { defineRouter as defineRouter6 } from "@spfn/core/route";
7879
7968
  // src/server/routes/auth/index.ts
7880
7969
  init_schema3();
7881
7970
 
7971
+ // src/server/helpers/index.ts
7972
+ init_email();
7973
+
7882
7974
  // src/server/helpers/password.ts
7883
7975
  import * as bcrypt from "@node-rs/bcrypt";
7884
7976
  import { env } from "@spfn/auth/config";
@@ -8070,6 +8162,7 @@ import {
8070
8162
  } from "@spfn/auth/errors";
8071
8163
 
8072
8164
  // src/server/lib/config.ts
8165
+ init_email();
8073
8166
  import { env as env4 } from "@spfn/auth/config";
8074
8167
  function getCookieSuffix() {
8075
8168
  const port = process.env.SPFN_PORT;
@@ -8135,7 +8228,7 @@ function getAuthConfig() {
8135
8228
  async function runBeforeRegister(context) {
8136
8229
  const { beforeRegister } = globalConfig;
8137
8230
  if (beforeRegister) {
8138
- await beforeRegister(context);
8231
+ await beforeRegister({ ...context, email: normalizeOptionalEmail(context.email) });
8139
8232
  }
8140
8233
  }
8141
8234
  function getSessionTtl(override) {
@@ -8178,11 +8271,15 @@ var authLogger = {
8178
8271
  };
8179
8272
 
8180
8273
  // src/server/services/verification.service.ts
8274
+ init_email();
8181
8275
  init_repositories();
8182
8276
  var ACCOUNT_EXISTS_NOTICE_DEDUPE_MINUTES = 60;
8183
8277
  var VERIFICATION_TOKEN_EXPIRY = "15m";
8184
8278
  var VERIFICATION_CODE_EXPIRY_MINUTES = 5;
8185
8279
  var MAX_VERIFICATION_ATTEMPTS = 5;
8280
+ function normalizeVerificationTarget(target, targetType) {
8281
+ return targetType === "email" ? normalizeEmail(target) : target.trim();
8282
+ }
8186
8283
  function generateVerificationCode() {
8187
8284
  return crypto4.randomInt(0, 1e6).toString().padStart(6, "0");
8188
8285
  }
@@ -8291,7 +8388,8 @@ async function sendAccountExistsNotice(target, targetType) {
8291
8388
  }
8292
8389
  }
8293
8390
  async function sendVerificationCodeService(params) {
8294
- const { target, targetType, purpose } = params;
8391
+ const { targetType, purpose } = params;
8392
+ const target = normalizeVerificationTarget(params.target, targetType);
8295
8393
  if (purpose === "registration" && await accountExistsForTarget(target, targetType)) {
8296
8394
  const recentNotice = await verificationCodesRepository.findValidByTargetAndPurpose(target, purpose);
8297
8395
  if (!recentNotice) {
@@ -8325,7 +8423,8 @@ async function sendVerificationCodeService(params) {
8325
8423
  };
8326
8424
  }
8327
8425
  async function verifyCodeService(params) {
8328
- const { target, targetType, code, purpose } = params;
8426
+ const { targetType, code, purpose } = params;
8427
+ const target = normalizeVerificationTarget(params.target, targetType);
8329
8428
  const validation = await validateVerificationCode(target, code, purpose);
8330
8429
  if (!validation.valid) {
8331
8430
  throw new InvalidVerificationCodeError({ message: validation.error || "Invalid verification code" });
@@ -8923,7 +9022,8 @@ async function sweepDuePurges(now = /* @__PURE__ */ new Date()) {
8923
9022
 
8924
9023
  // src/server/services/auth.service.ts
8925
9024
  async function registerService(params) {
8926
- const { email, phone, verificationToken, password, publicKey, keyId, fingerprint, algorithm, metadata } = params;
9025
+ const { email, verificationToken, password, publicKey, keyId, fingerprint, algorithm, metadata } = params;
9026
+ const phone = params.phone?.trim();
8927
9027
  const tokenPayload = validateVerificationToken(verificationToken);
8928
9028
  if (!tokenPayload) {
8929
9029
  throw new InvalidVerificationTokenError2();
@@ -8931,7 +9031,7 @@ async function registerService(params) {
8931
9031
  if (tokenPayload.purpose !== "registration") {
8932
9032
  throw new VerificationTokenPurposeMismatchError2({ expected: "registration", actual: tokenPayload.purpose });
8933
9033
  }
8934
- const providedTarget = email || phone;
9034
+ const providedTarget = email ? normalizeEmail(email) : phone;
8935
9035
  if (tokenPayload.target !== providedTarget) {
8936
9036
  throw new VerificationTokenTargetMismatchError2();
8937
9037
  }
@@ -9210,6 +9310,29 @@ async function syncMappings(allMappings, rolesByName, permsByName) {
9210
9310
  }
9211
9311
  }
9212
9312
 
9313
+ // src/server/services/email-normalization.service.ts
9314
+ init_repositories();
9315
+ var BACKFILL_KEY = "auth:email_normalization";
9316
+ async function normalizeStoredEmails() {
9317
+ if (await authMetadataRepository.get(BACKFILL_KEY)) {
9318
+ return { normalized: 0, conflicts: [] };
9319
+ }
9320
+ const conflicts = await usersRepository.findEmailConflictGroups();
9321
+ const normalized = await usersRepository.normalizeEmailsExcept(conflicts.flat());
9322
+ if (normalized > 0) {
9323
+ authLogger.service.info(`\u2709\uFE0F Normalized ${normalized} stored email address(es)`);
9324
+ }
9325
+ if (conflicts.length > 0) {
9326
+ authLogger.service.error(
9327
+ `${conflicts.length} email group(s) differ only by capitalization and cannot be normalized automatically. The accounts are untouched and the ones stored in mixed case cannot sign in until this is resolved.`,
9328
+ { conflictingUserIds: conflicts }
9329
+ );
9330
+ return { normalized, conflicts };
9331
+ }
9332
+ await authMetadataRepository.set(BACKFILL_KEY, "done");
9333
+ return { normalized, conflicts };
9334
+ }
9335
+
9213
9336
  // src/server/services/permission.service.ts
9214
9337
  init_repositories();
9215
9338
  import { ForbiddenError } from "@spfn/core/errors";
@@ -10932,6 +11055,7 @@ import { Transactional } from "@spfn/core/db";
10932
11055
  import { rateLimitPolicy } from "@spfn/core/middleware";
10933
11056
 
10934
11057
  // src/server/lib/rate-limit-keys.ts
11058
+ init_email();
10935
11059
  import { createHash as createHash5 } from "crypto";
10936
11060
  import { getClientIp } from "@spfn/core/middleware";
10937
11061
  async function readJsonBody(c) {
@@ -10943,7 +11067,7 @@ async function readJsonBody(c) {
10943
11067
  }
10944
11068
  function accountKey(body) {
10945
11069
  if (typeof body.email === "string" && body.email.trim()) {
10946
- return `email:${body.email.trim().toLowerCase()}`;
11070
+ return `email:${normalizeEmail(body.email)}`;
10947
11071
  }
10948
11072
  if (typeof body.phone === "string" && body.phone.trim()) {
10949
11073
  return `phone:${body.phone.trim()}`;
@@ -10955,7 +11079,7 @@ function targetKey(body) {
10955
11079
  return void 0;
10956
11080
  }
10957
11081
  const type = typeof body.targetType === "string" ? body.targetType : "target";
10958
- const value = type === "email" ? body.target.trim().toLowerCase() : body.target.trim();
11082
+ const value = type === "email" ? normalizeEmail(body.target) : body.target.trim();
10959
11083
  return `${type}:${value}`;
10960
11084
  }
10961
11085
  function byIpAndAccount(options = {}) {
@@ -11675,6 +11799,10 @@ function contractViolation(message) {
11675
11799
 
11676
11800
  // src/server/client-proof/contract-bundle.ts
11677
11801
  import { createHash as createHash7 } from "crypto";
11802
+ import {
11803
+ CORE_TIME_OPERATION_ID as CORE_TIME_OPERATION_ID2,
11804
+ ServerTimeResponseSchema
11805
+ } from "@spfn/core/server";
11678
11806
  init_types();
11679
11807
 
11680
11808
  // src/server/client-proof/proof.ts
@@ -11805,17 +11933,68 @@ function isRequestContentType(value) {
11805
11933
  return value.split(";")[0].trim().toLowerCase() === CLIENT_PROOF_CONTENT_TYPE;
11806
11934
  }
11807
11935
 
11936
+ // src/server/client-proof/contract-types.ts
11937
+ import {
11938
+ CORE_TIME_OPERATION_ID,
11939
+ CORE_TIME_ROUTE
11940
+ } from "@spfn/core/server";
11941
+ function importCoreTimeContract() {
11942
+ const { method, path, contract } = CORE_TIME_ROUTE;
11943
+ if (method !== "GET" || typeof path !== "string" || contract?.auth !== "none" || contract.requiresSession !== false || typeof contract.since !== "string") {
11944
+ throw new Error("core.time does not match the clientProofV1 synchronization prerequisite");
11945
+ }
11946
+ return {
11947
+ id: CORE_TIME_OPERATION_ID,
11948
+ method,
11949
+ path,
11950
+ authProfile: contract.auth,
11951
+ requiresSession: contract.requiresSession,
11952
+ sourceSince: contract.since
11953
+ };
11954
+ }
11955
+ var IMPORTED_CORE_TIME_CONTRACT = importCoreTimeContract();
11956
+ var CORE_PREREQUISITE_OPERATIONS = [
11957
+ {
11958
+ id: IMPORTED_CORE_TIME_CONTRACT.id,
11959
+ method: IMPORTED_CORE_TIME_CONTRACT.method,
11960
+ path: IMPORTED_CORE_TIME_CONTRACT.path,
11961
+ authProfile: IMPORTED_CORE_TIME_CONTRACT.authProfile,
11962
+ requiresSession: IMPORTED_CORE_TIME_CONTRACT.requiresSession,
11963
+ responseType: "ServerTimeResponse",
11964
+ summary: "Returns the server epoch used to timestamp clientProofV1 proofs.",
11965
+ since: "0.9.0"
11966
+ }
11967
+ ];
11968
+
11808
11969
  // src/server/client-proof/contract-bundle.ts
11809
11970
  init_wire_headers();
11810
- var CONTRACT_VERSION = "0.8.0";
11811
- var CONTRACT_SUPPORTED_RANGE = ">=0.8.0 <0.9.0";
11971
+ var CONTRACT_VERSION = "0.9.0";
11972
+ var CONTRACT_SUPPORTED_RANGE = ">=0.9.0 <0.10.0";
11812
11973
  function required(name, type) {
11813
11974
  return { name, type, optional: false };
11814
11975
  }
11815
11976
  function optional(name, type) {
11816
11977
  return { name, type, optional: true };
11817
11978
  }
11979
+ function coreTimeResponseDeclaration() {
11980
+ if (ServerTimeResponseSchema.type !== "object" || ServerTimeResponseSchema.additionalProperties !== false) {
11981
+ throw new Error("core.time response must remain a closed object");
11982
+ }
11983
+ const requiredFields = new Set(ServerTimeResponseSchema.required);
11984
+ const fields = Object.entries(ServerTimeResponseSchema.properties).map(([name, schema]) => {
11985
+ if (schema.type !== "integer") {
11986
+ throw new Error(`core.time response field ${name} is outside the mobile type grammar`);
11987
+ }
11988
+ return {
11989
+ name,
11990
+ type: "integer",
11991
+ optional: !requiredFields.has(name)
11992
+ };
11993
+ });
11994
+ return { name: "ServerTimeResponse", fields };
11995
+ }
11818
11996
  var CONTRACT_TYPES = [
11997
+ coreTimeResponseDeclaration(),
11819
11998
  {
11820
11999
  name: "HandshakeRequest",
11821
12000
  fields: [
@@ -13961,7 +14140,7 @@ async function ensureAdminExists() {
13961
14140
  for (const account of accounts) {
13962
14141
  authLogger.setup.info(`Creating ${account.email} admin account(s)...`);
13963
14142
  try {
13964
- const existing = await usersRepository.findByEmail(account.email);
14143
+ const existing = await usersRepository.findByEmailInAnyStoredForm(account.email);
13965
14144
  if (existing) {
13966
14145
  authLogger.setup.info(`\u26A0\uFE0F Account already exists: ${account.email} (skipped)`);
13967
14146
  skipped++;
@@ -14013,6 +14192,14 @@ function createAuthLifecycle(options = {}) {
14013
14192
  */
14014
14193
  afterInfrastructure: async () => {
14015
14194
  await initializeAuth(options);
14195
+ try {
14196
+ await normalizeStoredEmails();
14197
+ } catch (error) {
14198
+ authLogger.service.error(
14199
+ "Stored email normalization did not complete. Addresses stay as they are, so an account stored in mixed case cannot sign in until a later boot succeeds.",
14200
+ { error }
14201
+ );
14202
+ }
14016
14203
  await ensureAdminExists();
14017
14204
  initOneTimeTokenManager(options.oneTimeToken);
14018
14205
  }
@@ -14184,6 +14371,9 @@ export {
14184
14371
  logoutService,
14185
14372
  matchOAuthCsrfCookies,
14186
14373
  naverProvider,
14374
+ normalizeEmail,
14375
+ normalizeOptionalEmail,
14376
+ normalizeStoredEmails,
14187
14377
  oauthCallbackService,
14188
14378
  oauthNativeService,
14189
14379
  oauthStartService,