najm-auth 6.3.1 → 6.4.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.
@@ -1,4 +1,4 @@
1
- import { R as RetryConfig, a as RequestOptions, A as AuthUser, b as AuthClientConfig, L as LoginCredentials, c as LoginResult, O as OAuthProvider, d as OAuthLoginOptions, e as AuthState, f as AuthEvent, g as AuthEventHandler } from './types-CI2t8wpJ.js';
1
+ import { R as RetryConfig, a as RequestOptions, A as AuthUser, b as AuthClientConfig, L as LoginCredentials, c as LoginResult, O as OAuthProvider, d as OAuthLoginOptions, e as AuthState, f as AuthEvent, g as AuthEventHandler } from './types-axhOL6D1.js';
2
2
 
3
3
  interface FetchClientConfig {
4
4
  baseURL: string;
@@ -139,7 +139,7 @@ function isRecord(value) {
139
139
  }
140
140
  __name(isRecord, "isRecord");
141
141
  function isValidUser(value) {
142
- return isRecord(value) && typeof value.id === "string" && value.id.length > 0 && typeof value.email === "string" && value.email.length > 0 && (value.role === void 0 || value.role === null || typeof value.role === "string");
142
+ return isRecord(value) && typeof value.id === "string" && value.id.length > 0 && (value.email === null || typeof value.email === "string" && value.email.length > 0) && (value.role === void 0 || value.role === null || typeof value.role === "string");
143
143
  }
144
144
  __name(isValidUser, "isValidUser");
145
145
  function isStringArray(value) {
@@ -1,6 +1,6 @@
1
- export { F as FetchClient, H as HydrateSession, N as NajmAuthClient, c as createAuthClient } from '../NajmAuthClient-BygfBCpF.js';
2
- import { D as DecodedToken, T as TabSyncMessage, S as SyncPayload } from '../types-CI2t8wpJ.js';
3
- export { b as AuthClientConfig, h as AuthError, f as AuthEvent, g as AuthEventHandler, i as AuthEventMap, e as AuthState, A as AuthUser, j as AuthenticatedLogin, C as CredentialSetupPending, L as LoginCredentials, c as LoginResult, d as OAuthLoginOptions, O as OAuthProvider, a as RequestOptions, R as RetryConfig, k as ServerResponse, l as TokenPair } from '../types-CI2t8wpJ.js';
1
+ export { F as FetchClient, H as HydrateSession, N as NajmAuthClient, c as createAuthClient } from '../NajmAuthClient-B1Gv8PIw.js';
2
+ import { D as DecodedToken, T as TabSyncMessage, S as SyncPayload } from '../types-axhOL6D1.js';
3
+ export { b as AuthClientConfig, h as AuthError, f as AuthEvent, g as AuthEventHandler, i as AuthEventMap, e as AuthState, A as AuthUser, j as AuthenticatedLogin, C as CredentialSetupPending, L as LoginCredentials, c as LoginResult, d as OAuthLoginOptions, O as OAuthProvider, a as RequestOptions, R as RetryConfig, k as ServerResponse, l as TokenPair } from '../types-axhOL6D1.js';
4
4
 
5
5
  /**
6
6
  * Decode a JWT token payload without verification.
@@ -1,7 +1,7 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode, CSSProperties, ReactElement } from 'react';
3
- import { N as NajmAuthClient, H as HydrateSession } from '../../NajmAuthClient-BygfBCpF.js';
4
- import { e as AuthState, A as AuthUser, c as LoginResult, h as AuthError, L as LoginCredentials, d as OAuthLoginOptions, f as AuthEvent, i as AuthEventMap } from '../../types-CI2t8wpJ.js';
3
+ import { N as NajmAuthClient, H as HydrateSession } from '../../NajmAuthClient-B1Gv8PIw.js';
4
+ import { e as AuthState, A as AuthUser, c as LoginResult, h as AuthError, L as LoginCredentials, d as OAuthLoginOptions, f as AuthEvent, i as AuthEventMap } from '../../types-axhOL6D1.js';
5
5
 
6
6
  interface AuthProviderProps {
7
7
  client: NajmAuthClient;
@@ -1,9 +1,9 @@
1
- import { A as AuthUser, R as RetryConfig } from '../../types-CI2t8wpJ.js';
2
- import { F as FetchClient, N as NajmAuthClient } from '../../NajmAuthClient-BygfBCpF.js';
1
+ import { A as AuthUser, R as RetryConfig } from '../../types-axhOL6D1.js';
2
+ import { F as FetchClient, N as NajmAuthClient } from '../../NajmAuthClient-B1Gv8PIw.js';
3
3
  import { AuthProxyOptions, ProxySessionMode } from '../edge.js';
4
4
  export { withAuthMiddleware } from '../edge.js';
5
- import { G as GetSessionConfig, S as ServerSession$1 } from '../../getSession-DBX8XpsR.js';
6
- export { A as AuthConfigError, a as AuthTransportError, N as NoSessionError, g as getSession } from '../../getSession-DBX8XpsR.js';
5
+ import { G as GetSessionConfig, S as ServerSession$1 } from '../../getSession-DAQCguP3.js';
6
+ export { A as AuthConfigError, a as AuthTransportError, N as NoSessionError, g as getSession } from '../../getSession-DAQCguP3.js';
7
7
  import { S as SessionRecoveryFailure } from '../../sessionRecovery-D5Fa0yZ1.js';
8
8
  export { a as SessionRecoveryErrorDetails, b as SessionRecoveryFailureReason } from '../../sessionRecovery-D5Fa0yZ1.js';
9
9
  import 'next/server';
@@ -134,7 +134,7 @@ function isRecord(value) {
134
134
  return typeof value === "object" && value !== null && !Array.isArray(value);
135
135
  }
136
136
  function isValidUser(value) {
137
- return isRecord(value) && typeof value.id === "string" && value.id.length > 0 && typeof value.email === "string" && value.email.length > 0 && (value.role === void 0 || value.role === null || typeof value.role === "string");
137
+ return isRecord(value) && typeof value.id === "string" && value.id.length > 0 && (value.email === null || typeof value.email === "string" && value.email.length > 0) && (value.role === void 0 || value.role === null || typeof value.role === "string");
138
138
  }
139
139
  function isStringArray(value) {
140
140
  return Array.isArray(value) && value.every((entry) => typeof entry === "string");
@@ -1,5 +1,5 @@
1
- import { S as ServerSession } from '../../getSession-DBX8XpsR.js';
2
- import '../../types-CI2t8wpJ.js';
1
+ import { S as ServerSession } from '../../getSession-DAQCguP3.js';
2
+ import '../../types-axhOL6D1.js';
3
3
  import '../../sessionRecovery-D5Fa0yZ1.js';
4
4
 
5
5
  /**
@@ -1,4 +1,4 @@
1
- import { A as AuthUser } from './types-CI2t8wpJ.js';
1
+ import { A as AuthUser } from './types-axhOL6D1.js';
2
2
  import { S as SessionRecoveryFailure } from './sessionRecovery-D5Fa0yZ1.js';
3
3
 
4
4
  interface ServerSession {
package/dist/index.d.ts CHANGED
@@ -379,7 +379,8 @@ interface TokenPair {
379
379
  */
380
380
  interface AuthUser {
381
381
  id: string;
382
- email: string;
382
+ /** Null for an account reached by phone only. */
383
+ email: string | null;
383
384
  name?: string | null;
384
385
  role?: string;
385
386
  status?: string;
@@ -462,6 +463,7 @@ var users = {
462
463
  notFound: "User not found",
463
464
  idExists: "User ID already exists",
464
465
  emailRequired: "Email is required",
466
+ emailOrPhoneRequired: "An email address or a phone number is required",
465
467
  phoneExists: "Phone number already registered",
466
468
  passwordRequired: "Password is required",
467
469
  invalidEmail: "Invalid email format",
@@ -592,6 +594,7 @@ declare const AUTH_LOCALES: {
592
594
  notFound: string;
593
595
  idExists: string;
594
596
  emailRequired: string;
597
+ emailOrPhoneRequired: string;
595
598
  phoneExists: string;
596
599
  passwordRequired: string;
597
600
  invalidEmail: string;
@@ -667,7 +670,7 @@ interface SessionCookieData {
667
670
  /** Must satisfy the AuthUser contract — see AuthUser in ../types. */
668
671
  user: {
669
672
  id: string;
670
- email: string;
673
+ email: string | null;
671
674
  name?: string | null;
672
675
  role?: string;
673
676
  status?: string;
@@ -744,6 +747,10 @@ declare class UserRepository {
744
747
  getByEmailInsensitive(email: string): Promise<(User & {
745
748
  role?: string | null;
746
749
  }) | undefined>;
750
+ /** Login by phone reads the row it matched; the account may have no email. */
751
+ getByPhoneWithCredentials(phone: string): Promise<(User & {
752
+ role?: string | null;
753
+ }) | undefined>;
747
754
  create(data: NewUser): Promise<User>;
748
755
  /**
749
756
  * Ids of everyone holding a role. Used to end sessions when the role's
@@ -779,11 +786,17 @@ declare class UserValidator {
779
786
  /**
780
787
  * Check if email already exists in database
781
788
  */
782
- checkEmailUnique(email: string, excludeId?: string): Promise<void>;
789
+ checkEmailUnique(email: string | null | undefined, excludeId?: string): Promise<void>;
783
790
  /**
784
791
  * Check that a normalized phone number is not already taken.
785
792
  */
786
793
  checkPhoneUnique(phone: string, excludeId?: string): Promise<void>;
794
+ /**
795
+ * An account is reached by its email, its phone, or both. Refused here with
796
+ * a message the caller can show; the `users_email_or_phone` check is the
797
+ * database's own copy of the rule.
798
+ */
799
+ checkHasIdentity(email: string | null | undefined, phone: string | null | undefined): void;
787
800
  /** The database remains authoritative when two validation reads both pass. */
788
801
  writeUnique<T>(write: () => Promise<T>): Promise<T>;
789
802
  checkCredentialUnchanged(updated: unknown): void;
@@ -1219,6 +1232,11 @@ declare class UserService {
1219
1232
  * so, and a second call is harmless.
1220
1233
  */
1221
1234
  private invalidateSessions;
1235
+ /**
1236
+ * Stored in the same normalized form login lookup resolves to, or phone
1237
+ * login would never find the row. Blank means no phone.
1238
+ */
1239
+ private normalizePhone;
1222
1240
  private sanitizeUser;
1223
1241
  private sanitizeUsers;
1224
1242
  private requireUser;
@@ -1240,6 +1258,10 @@ declare class UserService {
1240
1258
  role?: string | null;
1241
1259
  }) | undefined>;
1242
1260
  findByPhone(phone: string): Promise<UserWithPermissions>;
1261
+ /** The credential row behind a phone login, for timing-safe authentication. */
1262
+ findCredentialsByPhone(phone: string): Promise<(User & {
1263
+ role?: string | null;
1264
+ }) | undefined>;
1243
1265
  getAuthRecordById(id: string): Promise<User | undefined>;
1244
1266
  create(data: Record<string, any>, options?: {
1245
1267
  validatePasswordStrength?: boolean;
@@ -1599,7 +1621,8 @@ declare class TokenService {
1599
1621
 
1600
1622
  declare const createUserDto: z.ZodObject<{
1601
1623
  name: z.ZodOptional<z.ZodString>;
1602
- email: z.ZodString;
1624
+ email: z.ZodOptional<z.ZodNullable<z.ZodString>>;
1625
+ phone: z.ZodOptional<z.ZodNullable<z.ZodString>>;
1603
1626
  password: z.ZodString;
1604
1627
  roleId: z.ZodOptional<z.ZodString>;
1605
1628
  image: z.ZodOptional<z.ZodNullable<z.ZodString>>;
@@ -1612,7 +1635,8 @@ declare const createUserDto: z.ZodObject<{
1612
1635
  }, z.core.$strip>;
1613
1636
  declare const updateUserDto: z.ZodObject<{
1614
1637
  name: z.ZodOptional<z.ZodOptional<z.ZodString>>;
1615
- email: z.ZodOptional<z.ZodString>;
1638
+ email: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
1639
+ phone: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
1616
1640
  password: z.ZodOptional<z.ZodString>;
1617
1641
  roleId: z.ZodOptional<z.ZodOptional<z.ZodString>>;
1618
1642
  image: z.ZodOptional<z.ZodOptional<z.ZodNullable<z.ZodString>>>;
@@ -1837,9 +1861,13 @@ declare class PasswordSetupService {
1837
1861
  type ProvisionUserInput = {
1838
1862
  id?: string;
1839
1863
  name?: string;
1840
- email: string;
1864
+ /**
1865
+ * At least one of `email` and `phone` is required. Blank counts as absent;
1866
+ * an account with no email is created without an invitation mail.
1867
+ */
1868
+ email?: string | null;
1841
1869
  /** Normalized through the configured identity preset before it is stored. */
1842
- phone?: string;
1870
+ phone?: string | null;
1843
1871
  role?: string;
1844
1872
  roleId?: string;
1845
1873
  image?: string | null;
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ var AUTH_PERMISSIONS = Symbol.for("najm:auth:permissions");
18
18
  var AUTH_ENCRYPTION_KEY = Symbol.for("najm:auth:encryption-key");
19
19
 
20
20
  // src/schema/pg.ts
21
- import { pgTable, text, boolean, timestamp, pgEnum, primaryKey, integer, index, uniqueIndex } from "drizzle-orm/pg-core";
21
+ import { pgTable, text, boolean, timestamp, pgEnum, primaryKey, integer, index, uniqueIndex, check } from "drizzle-orm/pg-core";
22
22
  import { sql } from "drizzle-orm";
23
23
  import { nanoid } from "nanoid";
24
24
 
@@ -49,7 +49,10 @@ var rolesTable = pgTable("roles", {
49
49
  var usersTable = pgTable("users", {
50
50
  ...baseFields(8),
51
51
  name: text("name"),
52
- email: text("email").notNull().unique(),
52
+ // An account is reached by its email, its phone, or both — never neither.
53
+ // Email is nullable so several phone-only accounts can coexist: a NOT NULL
54
+ // column forced them all to "" and the second one broke the unique index.
55
+ email: text("email").unique(),
53
56
  emailVerified: boolean("email_verified").default(false),
54
57
  phone: text("phone").unique(),
55
58
  phoneVerified: boolean("phone_verified").default(false),
@@ -61,7 +64,8 @@ var usersTable = pgTable("users", {
61
64
  failedLoginAttempts: integer("failed_login_attempts").default(0),
62
65
  lockoutUntil: timestamp("lockout_until", { mode: "string" })
63
66
  }, (table) => ({
64
- roleIdx: index("users_role_id_idx").on(table.roleId)
67
+ roleIdx: index("users_role_id_idx").on(table.roleId),
68
+ emailOrPhone: check("users_email_or_phone", sql`${table.email} is not null or ${table.phone} is not null`)
65
69
  }));
66
70
  var oauthAccountsTable = pgTable("oauth_accounts", {
67
71
  ...baseFields(10),
@@ -137,7 +141,7 @@ var authSchema = {
137
141
  };
138
142
 
139
143
  // src/schema/sqlite.ts
140
- import { sqliteTable, text as text2, integer as integer2, primaryKey as primaryKey2, uniqueIndex as uniqueIndex2, index as index2 } from "drizzle-orm/sqlite-core";
144
+ import { sqliteTable, text as text2, integer as integer2, primaryKey as primaryKey2, uniqueIndex as uniqueIndex2, index as index2, check as check2 } from "drizzle-orm/sqlite-core";
141
145
  import { sql as sql2 } from "drizzle-orm";
142
146
  import { nanoid as nanoid2 } from "nanoid";
143
147
  var baseFields2 = /* @__PURE__ */ __name((idLength = 5) => ({
@@ -156,7 +160,8 @@ var rolesTable2 = sqliteTable("roles", {
156
160
  var usersTable2 = sqliteTable("users", {
157
161
  ...baseFields2(8),
158
162
  name: text2("name"),
159
- email: text2("email").notNull().unique(),
163
+ // Nullable, with at least one of email or phone; see schema/pg.ts.
164
+ email: text2("email").unique(),
160
165
  emailVerified: integer2("email_verified", { mode: "boolean" }).default(false),
161
166
  phone: text2("phone").unique(),
162
167
  phoneVerified: integer2("phone_verified", { mode: "boolean" }).default(false),
@@ -168,7 +173,8 @@ var usersTable2 = sqliteTable("users", {
168
173
  failedLoginAttempts: integer2("failed_login_attempts").default(0),
169
174
  lockoutUntil: text2("lockout_until")
170
175
  }, (table) => ({
171
- roleIdx: index2("users_role_id_idx").on(table.roleId)
176
+ roleIdx: index2("users_role_id_idx").on(table.roleId),
177
+ emailOrPhone: check2("users_email_or_phone", sql2`${table.email} is not null or ${table.phone} is not null`)
172
178
  }));
173
179
  var oauthAccountsTable2 = sqliteTable("oauth_accounts", {
174
180
  ...baseFields2(10),
@@ -385,7 +391,7 @@ function isRecord(value) {
385
391
  }
386
392
  __name(isRecord, "isRecord");
387
393
  function isValidUser(value) {
388
- return isRecord(value) && typeof value.id === "string" && value.id.length > 0 && typeof value.email === "string" && value.email.length > 0 && (value.role === void 0 || value.role === null || typeof value.role === "string");
394
+ return isRecord(value) && typeof value.id === "string" && value.id.length > 0 && (value.email === null || typeof value.email === "string" && value.email.length > 0) && (value.role === void 0 || value.role === null || typeof value.role === "string");
389
395
  }
390
396
  __name(isValidUser, "isValidUser");
391
397
  function isStringArray(value) {
@@ -682,6 +688,16 @@ var UserRepository = class UserRepository2 {
682
688
  }).from(this.users).leftJoin(this.roles, eq2(this.users.roleId, this.roles.id)).where(sql3`lower(${this.users.email}) = ${email2.trim().toLowerCase()}`).limit(1);
683
689
  return existingUser;
684
690
  }
691
+ /** Login by phone reads the row it matched; the account may have no email. */
692
+ async getByPhoneWithCredentials(phone) {
693
+ const [existingUser] = await this.db.select({
694
+ ...this.q.userSelection(),
695
+ password: this.users.password,
696
+ failedLoginAttempts: this.users.failedLoginAttempts,
697
+ lockoutUntil: this.users.lockoutUntil
698
+ }).from(this.users).leftJoin(this.roles, eq2(this.users.roleId, this.roles.id)).where(eq2(this.users.phone, phone)).limit(1);
699
+ return existingUser;
700
+ }
685
701
  async create(data) {
686
702
  const [newUser] = await this.db.insert(this.users).values(data).returning();
687
703
  return newUser;
@@ -701,7 +717,7 @@ var UserRepository = class UserRepository2 {
701
717
  }
702
718
  /** The credential that authorized a delayed write must still own the account. */
703
719
  async updateWithCredential(id, data, expected) {
704
- const [updated] = await this.db.update(this.users).set(data).where(and(eq2(this.users.id, id), eq2(this.users.password, expected.password), eq2(this.users.email, expected.email), expected.status == null ? isNull(this.users.status) : eq2(this.users.status, expected.status), expected.emailVerified == null ? isNull(this.users.emailVerified) : eq2(this.users.emailVerified, expected.emailVerified))).returning();
720
+ const [updated] = await this.db.update(this.users).set(data).where(and(eq2(this.users.id, id), eq2(this.users.password, expected.password), expected.email == null ? isNull(this.users.email) : eq2(this.users.email, expected.email), expected.status == null ? isNull(this.users.status) : eq2(this.users.status, expected.status), expected.emailVerified == null ? isNull(this.users.emailVerified) : eq2(this.users.emailVerified, expected.emailVerified))).returning();
705
721
  return updated;
706
722
  }
707
723
  async updateLastLogin(id) {
@@ -779,6 +795,21 @@ function isUniqueViolation(error) {
779
795
  return false;
780
796
  }
781
797
  __name(isUniqueViolation, "isUniqueViolation");
798
+ function isMissingIdentityViolation(error) {
799
+ const visited = /* @__PURE__ */ new Set();
800
+ while (error && typeof error === "object" && !visited.has(error)) {
801
+ visited.add(error);
802
+ const candidate = error;
803
+ const constraint = candidate.constraint ?? candidate.constraint_name;
804
+ if (constraint === "users_email_or_phone")
805
+ return true;
806
+ if (typeof candidate.message === "string" && candidate.message.includes("users_email_or_phone"))
807
+ return true;
808
+ error = candidate.cause;
809
+ }
810
+ return false;
811
+ }
812
+ __name(isMissingIdentityViolation, "isMissingIdentityViolation");
782
813
 
783
814
  // src/users/UserValidator.ts
784
815
  var __decorate4 = function(decorators, target, key, desc) {
@@ -825,6 +856,16 @@ var UserValidator = class UserValidator2 {
825
856
  Err(this.t("errors.phoneExists"), 409);
826
857
  }
827
858
  }
859
+ /**
860
+ * An account is reached by its email, its phone, or both. Refused here with
861
+ * a message the caller can show; the `users_email_or_phone` check is the
862
+ * database's own copy of the rule.
863
+ */
864
+ checkHasIdentity(email2, phone) {
865
+ if (!email2 && !phone) {
866
+ Err(this.t("errors.emailOrPhoneRequired"), 400);
867
+ }
868
+ }
828
869
  /** The database remains authoritative when two validation reads both pass. */
829
870
  async writeUnique(write) {
830
871
  try {
@@ -832,6 +873,8 @@ var UserValidator = class UserValidator2 {
832
873
  } catch (error) {
833
874
  if (isUniqueViolation(error))
834
875
  Err("User identity already exists", 409);
876
+ if (isMissingIdentityViolation(error))
877
+ Err(this.t("errors.emailOrPhoneRequired"), 400);
835
878
  throw error;
836
879
  }
837
880
  }
@@ -1862,6 +1905,10 @@ var _g;
1862
1905
  var _h;
1863
1906
  var _j;
1864
1907
  var E164_PHONE = /^\+[1-9]\d{7,14}$/;
1908
+ function normalizeEmail(value) {
1909
+ return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : null;
1910
+ }
1911
+ __name(normalizeEmail, "normalizeEmail");
1865
1912
  var UserService = class UserService2 {
1866
1913
  static {
1867
1914
  __name(this, "UserService");
@@ -1897,6 +1944,18 @@ var UserService = class UserService2 {
1897
1944
  async invalidateSessions(userId) {
1898
1945
  await this.sessionInvalidation?.invalidateUser(userId);
1899
1946
  }
1947
+ /**
1948
+ * Stored in the same normalized form login lookup resolves to, or phone
1949
+ * login would never find the row. Blank means no phone.
1950
+ */
1951
+ normalizePhone(phone) {
1952
+ if (phone == null || typeof phone === "string" && !phone.trim())
1953
+ return null;
1954
+ const normalized = this.authConfig.identity?.resolve(phone) ?? normalizeAuthIdentifier(phone);
1955
+ if (!normalized || !E164_PHONE.test(normalized))
1956
+ Err5("A valid phone number is required", 400);
1957
+ return normalized;
1958
+ }
1900
1959
  sanitizeUser(user) {
1901
1960
  if (!user)
1902
1961
  return user;
@@ -1960,6 +2019,10 @@ var UserService = class UserService2 {
1960
2019
  async findByPhone(phone) {
1961
2020
  return await this.userRepository.findByPhone(phone);
1962
2021
  }
2022
+ /** The credential row behind a phone login, for timing-safe authentication. */
2023
+ async findCredentialsByPhone(phone) {
2024
+ return await this.userRepository.getByPhoneWithCredentials(phone);
2025
+ }
1963
2026
  async getAuthRecordById(id) {
1964
2027
  return await this.userRepository.getRawById(id);
1965
2028
  }
@@ -1973,12 +2036,11 @@ var UserService = class UserService2 {
1973
2036
  this.userValidator.validatePasswordStrength(password);
1974
2037
  }
1975
2038
  let userId = id || nanoid3(10);
1976
- await this.userValidator.checkEmailUnique(data.email);
2039
+ const normalizedEmail = normalizeEmail(email2);
2040
+ const normalizedPhone = this.normalizePhone(phone);
2041
+ this.userValidator.checkHasIdentity(normalizedEmail, normalizedPhone);
2042
+ await this.userValidator.checkEmailUnique(normalizedEmail);
1977
2043
  await this.userValidator.checkUserIdIsUnique(userId);
1978
- const normalizedPhone = phone == null ? null : this.authConfig.identity?.resolve(phone) ?? normalizeAuthIdentifier(phone);
1979
- if (phone != null && (!normalizedPhone || !E164_PHONE.test(normalizedPhone))) {
1980
- Err5("A valid phone number is required", 400);
1981
- }
1982
2044
  if (normalizedPhone) {
1983
2045
  await this.userValidator.checkPhoneUnique(normalizedPhone, userId);
1984
2046
  }
@@ -1987,7 +2049,7 @@ var UserService = class UserService2 {
1987
2049
  const userDetails = {
1988
2050
  id: userId,
1989
2051
  name: name || null,
1990
- email: email2.trim().toLowerCase(),
2052
+ email: normalizedEmail,
1991
2053
  image,
1992
2054
  password: hashedPassword,
1993
2055
  roleId: resolvedRoleId,
@@ -2006,7 +2068,17 @@ var UserService = class UserService2 {
2006
2068
  */
2007
2069
  async update(id, data, options = {}) {
2008
2070
  const { password, image } = data;
2009
- await this.userValidator.checkEmailUnique(data.email, id);
2071
+ const touchesEmail = data.email !== void 0;
2072
+ const touchesPhone = data.phone !== void 0;
2073
+ const email2 = touchesEmail ? normalizeEmail(data.email) : void 0;
2074
+ const phone = touchesPhone ? this.normalizePhone(data.phone) : void 0;
2075
+ if (touchesEmail && !email2 || touchesPhone && !phone) {
2076
+ const current = this.requireUser(await this.userRepository.getRawById(id));
2077
+ this.userValidator.checkHasIdentity(touchesEmail ? email2 : current.email, touchesPhone ? phone : current.phone);
2078
+ }
2079
+ await this.userValidator.checkEmailUnique(email2, id);
2080
+ if (phone)
2081
+ await this.userValidator.checkPhoneUnique(phone, id);
2010
2082
  let hashedPassword;
2011
2083
  if (password) {
2012
2084
  if (options.validatePasswordStrength === false) {
@@ -2021,15 +2093,10 @@ var UserService = class UserService2 {
2021
2093
  image,
2022
2094
  ...hashedPassword && { password: hashedPassword }
2023
2095
  };
2024
- if (typeof data.email === "string")
2025
- updateData.email = data.email.trim().toLowerCase();
2026
- if (data.phone !== void 0 && data.phone !== null) {
2027
- const phone = this.authConfig.identity?.resolve(data.phone) ?? normalizeAuthIdentifier(data.phone);
2028
- if (!phone || !E164_PHONE.test(phone))
2029
- Err5("A valid phone number is required", 400);
2030
- await this.userValidator.checkPhoneUnique(phone, id);
2096
+ if (touchesEmail)
2097
+ updateData.email = email2;
2098
+ if (touchesPhone)
2031
2099
  updateData.phone = phone;
2032
- }
2033
2100
  const cleanedUpdateData = clean(updateData);
2034
2101
  const updatedUser = await this.userValidator.writeUnique(() => options.expectedCredential ? this.userRepository.updateWithCredential(id, cleanedUpdateData, options.expectedCredential) : this.userRepository.update(id, cleanedUpdateData));
2035
2102
  if (options.expectedCredential)
@@ -2891,7 +2958,7 @@ var TokenService = class TokenService2 {
2891
2958
  */
2892
2959
  async generateSetPasswordToken(userId, type, expiresIn, expectedEmail) {
2893
2960
  const user = await this.tokenRepository.getCredentialState(userId);
2894
- if (!user || expectedEmail !== void 0 && user.email.trim().toLowerCase() !== expectedEmail.trim().toLowerCase()) {
2961
+ if (!user || expectedEmail !== void 0 && (user.email ?? "").trim().toLowerCase() !== expectedEmail.trim().toLowerCase()) {
2895
2962
  Err7(this.t("errors.invalidResetToken"), 401);
2896
2963
  }
2897
2964
  const credentialState = credentialFingerprint(this.config.jwt.refreshSecret, user);
@@ -3960,8 +4027,7 @@ var AuthService = class AuthService2 {
3960
4027
  if (identifier && isEmailIdentifier(identifier)) {
3961
4028
  user = await this.userService.findByEmailInsensitive(identifier);
3962
4029
  } else if (identifier) {
3963
- const phoneUser = await this.userService.findByPhone(identifier);
3964
- user = phoneUser ? await this.userService.findByEmail(phoneUser.email) : void 0;
4030
+ user = await this.userService.findCredentialsByPhone(identifier);
3965
4031
  }
3966
4032
  if (user?.lockoutUntil && !this.isLockoutActive(user.lockoutUntil)) {
3967
4033
  await this.userService.resetFailedAttempts(user.id);
@@ -4542,7 +4608,8 @@ var passwordField = z.string().min(8, "Password must be at least 8 characters").
4542
4608
  var optionalDateField = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").nullable().optional();
4543
4609
  var createUserDto = z.object({
4544
4610
  name: z.string().max(100).optional(),
4545
- email: emailField,
4611
+ email: emailField.nullish(),
4612
+ phone: z.string().trim().max(32).nullish(),
4546
4613
  password: passwordField,
4547
4614
  roleId: z.string().min(1).optional(),
4548
4615
  image: z.string().nullish(),
@@ -7042,6 +7109,7 @@ var en_default = {
7042
7109
  notFound: "User not found",
7043
7110
  idExists: "User ID already exists",
7044
7111
  emailRequired: "Email is required",
7112
+ emailOrPhoneRequired: "An email address or a phone number is required",
7045
7113
  phoneExists: "Phone number already registered",
7046
7114
  passwordRequired: "Password is required",
7047
7115
  invalidEmail: "Invalid email format",
@@ -8374,7 +8442,7 @@ var authSeed = /* @__PURE__ */ __name((config) => ({
8374
8442
  users: {
8375
8443
  by: ["email"],
8376
8444
  rows: /* @__PURE__ */ __name(async (seeded) => {
8377
- createUserDto.pick({ email: true, password: true }).parse({
8445
+ registerDto.pick({ email: true, password: true }).parse({
8378
8446
  email: config.adminEmail,
8379
8447
  password: config.adminPass
8380
8448
  });
@@ -8392,7 +8460,7 @@ var authSeed = /* @__PURE__ */ __name((config) => ({
8392
8460
  ];
8393
8461
  if (config.additionalUsers) {
8394
8462
  for (const user of config.additionalUsers) {
8395
- createUserDto.pick({ email: true, password: true }).parse({
8463
+ registerDto.pick({ email: true, password: true }).parse({
8396
8464
  email: user.email,
8397
8465
  password: user.password
8398
8466
  });
@@ -135,7 +135,7 @@ declare const usersTable: drizzle_orm_pg_core.PgTableWithColumns<{
135
135
  columnType: "PgText";
136
136
  data: string;
137
137
  driverParam: string;
138
- notNull: true;
138
+ notNull: false;
139
139
  hasDefault: false;
140
140
  isPrimaryKey: false;
141
141
  isAutoincrement: false;
@@ -1208,7 +1208,7 @@ declare const authSchema: {
1208
1208
  columnType: "PgText";
1209
1209
  data: string;
1210
1210
  driverParam: string;
1211
- notNull: true;
1211
+ notNull: false;
1212
1212
  hasDefault: false;
1213
1213
  isPrimaryKey: false;
1214
1214
  isAutoincrement: false;
package/dist/schema/pg.js CHANGED
@@ -2,7 +2,7 @@ var __defProp = Object.defineProperty;
2
2
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
3
 
4
4
  // src/schema/pg.ts
5
- import { pgTable, text, boolean, timestamp, pgEnum, primaryKey, integer, index, uniqueIndex } from "drizzle-orm/pg-core";
5
+ import { pgTable, text, boolean, timestamp, pgEnum, primaryKey, integer, index, uniqueIndex, check } from "drizzle-orm/pg-core";
6
6
  import { sql } from "drizzle-orm";
7
7
  import { nanoid } from "nanoid";
8
8
 
@@ -33,7 +33,10 @@ var rolesTable = pgTable("roles", {
33
33
  var usersTable = pgTable("users", {
34
34
  ...baseFields(8),
35
35
  name: text("name"),
36
- email: text("email").notNull().unique(),
36
+ // An account is reached by its email, its phone, or both — never neither.
37
+ // Email is nullable so several phone-only accounts can coexist: a NOT NULL
38
+ // column forced them all to "" and the second one broke the unique index.
39
+ email: text("email").unique(),
37
40
  emailVerified: boolean("email_verified").default(false),
38
41
  phone: text("phone").unique(),
39
42
  phoneVerified: boolean("phone_verified").default(false),
@@ -45,7 +48,8 @@ var usersTable = pgTable("users", {
45
48
  failedLoginAttempts: integer("failed_login_attempts").default(0),
46
49
  lockoutUntil: timestamp("lockout_until", { mode: "string" })
47
50
  }, (table) => ({
48
- roleIdx: index("users_role_id_idx").on(table.roleId)
51
+ roleIdx: index("users_role_id_idx").on(table.roleId),
52
+ emailOrPhone: check("users_email_or_phone", sql`${table.email} is not null or ${table.phone} is not null`)
49
53
  }));
50
54
  var oauthAccountsTable = pgTable("oauth_accounts", {
51
55
  ...baseFields(10),
@@ -144,7 +144,7 @@ declare const usersTable: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{
144
144
  columnType: "SQLiteText";
145
145
  data: string;
146
146
  driverParam: string;
147
- notNull: true;
147
+ notNull: false;
148
148
  hasDefault: false;
149
149
  isPrimaryKey: false;
150
150
  isAutoincrement: false;
@@ -1368,7 +1368,7 @@ declare const authSchema: {
1368
1368
  columnType: "SQLiteText";
1369
1369
  data: string;
1370
1370
  driverParam: string;
1371
- notNull: true;
1371
+ notNull: false;
1372
1372
  hasDefault: false;
1373
1373
  isPrimaryKey: false;
1374
1374
  isAutoincrement: false;
@@ -2,7 +2,7 @@ var __defProp = Object.defineProperty;
2
2
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
3
 
4
4
  // src/schema/sqlite.ts
5
- import { sqliteTable, text, integer, primaryKey, uniqueIndex, index } from "drizzle-orm/sqlite-core";
5
+ import { sqliteTable, text, integer, primaryKey, uniqueIndex, index, check } from "drizzle-orm/sqlite-core";
6
6
  import { sql } from "drizzle-orm";
7
7
  import { nanoid } from "nanoid";
8
8
  var baseFields = /* @__PURE__ */ __name((idLength = 5) => ({
@@ -21,7 +21,8 @@ var rolesTable = sqliteTable("roles", {
21
21
  var usersTable = sqliteTable("users", {
22
22
  ...baseFields(8),
23
23
  name: text("name"),
24
- email: text("email").notNull().unique(),
24
+ // Nullable, with at least one of email or phone; see schema/pg.ts.
25
+ email: text("email").unique(),
25
26
  emailVerified: integer("email_verified", { mode: "boolean" }).default(false),
26
27
  phone: text("phone").unique(),
27
28
  phoneVerified: integer("phone_verified", { mode: "boolean" }).default(false),
@@ -33,7 +34,8 @@ var usersTable = sqliteTable("users", {
33
34
  failedLoginAttempts: integer("failed_login_attempts").default(0),
34
35
  lockoutUntil: text("lockout_until")
35
36
  }, (table) => ({
36
- roleIdx: index("users_role_id_idx").on(table.roleId)
37
+ roleIdx: index("users_role_id_idx").on(table.roleId),
38
+ emailOrPhone: check("users_email_or_phone", sql`${table.email} is not null or ${table.phone} is not null`)
37
39
  }));
38
40
  var oauthAccountsTable = sqliteTable("oauth_accounts", {
39
41
  ...baseFields(10),
@@ -3,7 +3,8 @@
3
3
  */
4
4
  interface AuthUser {
5
5
  id: string;
6
- email: string;
6
+ /** Null for an account reached by phone only. */
7
+ email: string | null;
7
8
  name?: string;
8
9
  role?: string | null;
9
10
  permissions?: string[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "najm-auth",
3
- "version": "6.3.1",
3
+ "version": "6.4.0",
4
4
  "description": "Authentication and authorization library for najm framework",
5
5
  "type": "module",
6
6
  "files": [