najm-auth 6.3.1 → 6.4.1

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;
@@ -2970,6 +2998,8 @@ declare class AuthQueries {
2970
2998
  name: any;
2971
2999
  email: any;
2972
3000
  emailVerified: any;
3001
+ phone: any;
3002
+ phoneVerified: any;
2973
3003
  image: any;
2974
3004
  status: any;
2975
3005
  roleId: any;
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) {
@@ -537,6 +543,10 @@ var AuthQueries = class {
537
543
  name: this.schema.users.name,
538
544
  email: this.schema.users.email,
539
545
  emailVerified: this.schema.users.emailVerified,
546
+ // An account may be reached by phone only; without this the user list
547
+ // and profile showed no way to reach it at all.
548
+ phone: this.schema.users.phone,
549
+ phoneVerified: this.schema.users.phoneVerified,
540
550
  image: this.schema.users.image,
541
551
  status: this.schema.users.status,
542
552
  roleId: this.schema.users.roleId,
@@ -682,6 +692,16 @@ var UserRepository = class UserRepository2 {
682
692
  }).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
693
  return existingUser;
684
694
  }
695
+ /** Login by phone reads the row it matched; the account may have no email. */
696
+ async getByPhoneWithCredentials(phone) {
697
+ const [existingUser] = await this.db.select({
698
+ ...this.q.userSelection(),
699
+ password: this.users.password,
700
+ failedLoginAttempts: this.users.failedLoginAttempts,
701
+ lockoutUntil: this.users.lockoutUntil
702
+ }).from(this.users).leftJoin(this.roles, eq2(this.users.roleId, this.roles.id)).where(eq2(this.users.phone, phone)).limit(1);
703
+ return existingUser;
704
+ }
685
705
  async create(data) {
686
706
  const [newUser] = await this.db.insert(this.users).values(data).returning();
687
707
  return newUser;
@@ -701,7 +721,7 @@ var UserRepository = class UserRepository2 {
701
721
  }
702
722
  /** The credential that authorized a delayed write must still own the account. */
703
723
  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();
724
+ 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
725
  return updated;
706
726
  }
707
727
  async updateLastLogin(id) {
@@ -779,6 +799,21 @@ function isUniqueViolation(error) {
779
799
  return false;
780
800
  }
781
801
  __name(isUniqueViolation, "isUniqueViolation");
802
+ function isMissingIdentityViolation(error) {
803
+ const visited = /* @__PURE__ */ new Set();
804
+ while (error && typeof error === "object" && !visited.has(error)) {
805
+ visited.add(error);
806
+ const candidate = error;
807
+ const constraint = candidate.constraint ?? candidate.constraint_name;
808
+ if (constraint === "users_email_or_phone")
809
+ return true;
810
+ if (typeof candidate.message === "string" && candidate.message.includes("users_email_or_phone"))
811
+ return true;
812
+ error = candidate.cause;
813
+ }
814
+ return false;
815
+ }
816
+ __name(isMissingIdentityViolation, "isMissingIdentityViolation");
782
817
 
783
818
  // src/users/UserValidator.ts
784
819
  var __decorate4 = function(decorators, target, key, desc) {
@@ -825,6 +860,16 @@ var UserValidator = class UserValidator2 {
825
860
  Err(this.t("errors.phoneExists"), 409);
826
861
  }
827
862
  }
863
+ /**
864
+ * An account is reached by its email, its phone, or both. Refused here with
865
+ * a message the caller can show; the `users_email_or_phone` check is the
866
+ * database's own copy of the rule.
867
+ */
868
+ checkHasIdentity(email2, phone) {
869
+ if (!email2 && !phone) {
870
+ Err(this.t("errors.emailOrPhoneRequired"), 400);
871
+ }
872
+ }
828
873
  /** The database remains authoritative when two validation reads both pass. */
829
874
  async writeUnique(write) {
830
875
  try {
@@ -832,6 +877,8 @@ var UserValidator = class UserValidator2 {
832
877
  } catch (error) {
833
878
  if (isUniqueViolation(error))
834
879
  Err("User identity already exists", 409);
880
+ if (isMissingIdentityViolation(error))
881
+ Err(this.t("errors.emailOrPhoneRequired"), 400);
835
882
  throw error;
836
883
  }
837
884
  }
@@ -1862,6 +1909,10 @@ var _g;
1862
1909
  var _h;
1863
1910
  var _j;
1864
1911
  var E164_PHONE = /^\+[1-9]\d{7,14}$/;
1912
+ function normalizeEmail(value) {
1913
+ return typeof value === "string" && value.trim() ? value.trim().toLowerCase() : null;
1914
+ }
1915
+ __name(normalizeEmail, "normalizeEmail");
1865
1916
  var UserService = class UserService2 {
1866
1917
  static {
1867
1918
  __name(this, "UserService");
@@ -1897,6 +1948,18 @@ var UserService = class UserService2 {
1897
1948
  async invalidateSessions(userId) {
1898
1949
  await this.sessionInvalidation?.invalidateUser(userId);
1899
1950
  }
1951
+ /**
1952
+ * Stored in the same normalized form login lookup resolves to, or phone
1953
+ * login would never find the row. Blank means no phone.
1954
+ */
1955
+ normalizePhone(phone) {
1956
+ if (phone == null || typeof phone === "string" && !phone.trim())
1957
+ return null;
1958
+ const normalized = this.authConfig.identity?.resolve(phone) ?? normalizeAuthIdentifier(phone);
1959
+ if (!normalized || !E164_PHONE.test(normalized))
1960
+ Err5("A valid phone number is required", 400);
1961
+ return normalized;
1962
+ }
1900
1963
  sanitizeUser(user) {
1901
1964
  if (!user)
1902
1965
  return user;
@@ -1960,6 +2023,10 @@ var UserService = class UserService2 {
1960
2023
  async findByPhone(phone) {
1961
2024
  return await this.userRepository.findByPhone(phone);
1962
2025
  }
2026
+ /** The credential row behind a phone login, for timing-safe authentication. */
2027
+ async findCredentialsByPhone(phone) {
2028
+ return await this.userRepository.getByPhoneWithCredentials(phone);
2029
+ }
1963
2030
  async getAuthRecordById(id) {
1964
2031
  return await this.userRepository.getRawById(id);
1965
2032
  }
@@ -1973,12 +2040,11 @@ var UserService = class UserService2 {
1973
2040
  this.userValidator.validatePasswordStrength(password);
1974
2041
  }
1975
2042
  let userId = id || nanoid3(10);
1976
- await this.userValidator.checkEmailUnique(data.email);
2043
+ const normalizedEmail = normalizeEmail(email2);
2044
+ const normalizedPhone = this.normalizePhone(phone);
2045
+ this.userValidator.checkHasIdentity(normalizedEmail, normalizedPhone);
2046
+ await this.userValidator.checkEmailUnique(normalizedEmail);
1977
2047
  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
2048
  if (normalizedPhone) {
1983
2049
  await this.userValidator.checkPhoneUnique(normalizedPhone, userId);
1984
2050
  }
@@ -1987,7 +2053,7 @@ var UserService = class UserService2 {
1987
2053
  const userDetails = {
1988
2054
  id: userId,
1989
2055
  name: name || null,
1990
- email: email2.trim().toLowerCase(),
2056
+ email: normalizedEmail,
1991
2057
  image,
1992
2058
  password: hashedPassword,
1993
2059
  roleId: resolvedRoleId,
@@ -2006,7 +2072,17 @@ var UserService = class UserService2 {
2006
2072
  */
2007
2073
  async update(id, data, options = {}) {
2008
2074
  const { password, image } = data;
2009
- await this.userValidator.checkEmailUnique(data.email, id);
2075
+ const touchesEmail = data.email !== void 0;
2076
+ const touchesPhone = data.phone !== void 0;
2077
+ const email2 = touchesEmail ? normalizeEmail(data.email) : void 0;
2078
+ const phone = touchesPhone ? this.normalizePhone(data.phone) : void 0;
2079
+ if (touchesEmail && !email2 || touchesPhone && !phone) {
2080
+ const current = this.requireUser(await this.userRepository.getRawById(id));
2081
+ this.userValidator.checkHasIdentity(touchesEmail ? email2 : current.email, touchesPhone ? phone : current.phone);
2082
+ }
2083
+ await this.userValidator.checkEmailUnique(email2, id);
2084
+ if (phone)
2085
+ await this.userValidator.checkPhoneUnique(phone, id);
2010
2086
  let hashedPassword;
2011
2087
  if (password) {
2012
2088
  if (options.validatePasswordStrength === false) {
@@ -2021,15 +2097,10 @@ var UserService = class UserService2 {
2021
2097
  image,
2022
2098
  ...hashedPassword && { password: hashedPassword }
2023
2099
  };
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);
2100
+ if (touchesEmail)
2101
+ updateData.email = email2;
2102
+ if (touchesPhone)
2031
2103
  updateData.phone = phone;
2032
- }
2033
2104
  const cleanedUpdateData = clean(updateData);
2034
2105
  const updatedUser = await this.userValidator.writeUnique(() => options.expectedCredential ? this.userRepository.updateWithCredential(id, cleanedUpdateData, options.expectedCredential) : this.userRepository.update(id, cleanedUpdateData));
2035
2106
  if (options.expectedCredential)
@@ -2891,7 +2962,7 @@ var TokenService = class TokenService2 {
2891
2962
  */
2892
2963
  async generateSetPasswordToken(userId, type, expiresIn, expectedEmail) {
2893
2964
  const user = await this.tokenRepository.getCredentialState(userId);
2894
- if (!user || expectedEmail !== void 0 && user.email.trim().toLowerCase() !== expectedEmail.trim().toLowerCase()) {
2965
+ if (!user || expectedEmail !== void 0 && (user.email ?? "").trim().toLowerCase() !== expectedEmail.trim().toLowerCase()) {
2895
2966
  Err7(this.t("errors.invalidResetToken"), 401);
2896
2967
  }
2897
2968
  const credentialState = credentialFingerprint(this.config.jwt.refreshSecret, user);
@@ -3960,8 +4031,7 @@ var AuthService = class AuthService2 {
3960
4031
  if (identifier && isEmailIdentifier(identifier)) {
3961
4032
  user = await this.userService.findByEmailInsensitive(identifier);
3962
4033
  } else if (identifier) {
3963
- const phoneUser = await this.userService.findByPhone(identifier);
3964
- user = phoneUser ? await this.userService.findByEmail(phoneUser.email) : void 0;
4034
+ user = await this.userService.findCredentialsByPhone(identifier);
3965
4035
  }
3966
4036
  if (user?.lockoutUntil && !this.isLockoutActive(user.lockoutUntil)) {
3967
4037
  await this.userService.resetFailedAttempts(user.id);
@@ -4542,7 +4612,8 @@ var passwordField = z.string().min(8, "Password must be at least 8 characters").
4542
4612
  var optionalDateField = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Date must be in YYYY-MM-DD format").nullable().optional();
4543
4613
  var createUserDto = z.object({
4544
4614
  name: z.string().max(100).optional(),
4545
- email: emailField,
4615
+ email: emailField.nullish(),
4616
+ phone: z.string().trim().max(32).nullish(),
4546
4617
  password: passwordField,
4547
4618
  roleId: z.string().min(1).optional(),
4548
4619
  image: z.string().nullish(),
@@ -7042,6 +7113,7 @@ var en_default = {
7042
7113
  notFound: "User not found",
7043
7114
  idExists: "User ID already exists",
7044
7115
  emailRequired: "Email is required",
7116
+ emailOrPhoneRequired: "An email address or a phone number is required",
7045
7117
  phoneExists: "Phone number already registered",
7046
7118
  passwordRequired: "Password is required",
7047
7119
  invalidEmail: "Invalid email format",
@@ -8374,7 +8446,7 @@ var authSeed = /* @__PURE__ */ __name((config) => ({
8374
8446
  users: {
8375
8447
  by: ["email"],
8376
8448
  rows: /* @__PURE__ */ __name(async (seeded) => {
8377
- createUserDto.pick({ email: true, password: true }).parse({
8449
+ registerDto.pick({ email: true, password: true }).parse({
8378
8450
  email: config.adminEmail,
8379
8451
  password: config.adminPass
8380
8452
  });
@@ -8392,7 +8464,7 @@ var authSeed = /* @__PURE__ */ __name((config) => ({
8392
8464
  ];
8393
8465
  if (config.additionalUsers) {
8394
8466
  for (const user of config.additionalUsers) {
8395
- createUserDto.pick({ email: true, password: true }).parse({
8467
+ registerDto.pick({ email: true, password: true }).parse({
8396
8468
  email: user.email,
8397
8469
  password: user.password
8398
8470
  });
@@ -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.1",
4
4
  "description": "Authentication and authorization library for najm framework",
5
5
  "type": "module",
6
6
  "files": [