@unchainedshop/core-users 4.6.2 → 4.8.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 (33) hide show
  1. package/lib/domain/model/Email.d.ts +17 -0
  2. package/lib/domain/model/Email.js +21 -0
  3. package/lib/domain/model/PasswordHash.d.ts +16 -0
  4. package/lib/domain/model/PasswordHash.js +31 -0
  5. package/lib/domain/model/PushSubscription.d.ts +23 -0
  6. package/lib/domain/model/PushSubscription.js +25 -0
  7. package/lib/domain/model/User.d.ts +116 -0
  8. package/lib/domain/model/User.js +386 -0
  9. package/lib/domain/model/UserProfile.d.ts +14 -0
  10. package/lib/domain/model/UserProfile.js +29 -0
  11. package/lib/domain/model/Web3Address.d.ts +16 -0
  12. package/lib/domain/model/Web3Address.js +32 -0
  13. package/lib/domain/model/WebAuthnCredential.d.ts +22 -0
  14. package/lib/domain/model/WebAuthnCredential.js +28 -0
  15. package/lib/domain/model/index.d.ts +14 -0
  16. package/lib/domain/model/index.js +7 -0
  17. package/lib/domain/repository/UserRepository.d.ts +48 -0
  18. package/lib/domain/repository/UserRepository.js +1 -0
  19. package/lib/domain/services/PasswordService.d.ts +5 -0
  20. package/lib/domain/services/PasswordService.js +1 -0
  21. package/lib/domain/services/TokenService.d.ts +8 -0
  22. package/lib/domain/services/TokenService.js +1 -0
  23. package/lib/domain/types.d.ts +28 -0
  24. package/lib/domain/types.js +1 -0
  25. package/lib/infrastructure/persistence/MongoUserRepository.d.ts +4 -0
  26. package/lib/infrastructure/persistence/MongoUserRepository.js +159 -0
  27. package/lib/infrastructure/persistence/buildUserSelector.d.ts +4 -0
  28. package/lib/infrastructure/persistence/buildUserSelector.js +54 -0
  29. package/lib/infrastructure/services/CryptoTokenService.d.ts +2 -0
  30. package/lib/infrastructure/services/CryptoTokenService.js +16 -0
  31. package/lib/infrastructure/services/Pbkdf2PasswordService.d.ts +2 -0
  32. package/lib/infrastructure/services/Pbkdf2PasswordService.js +23 -0
  33. package/package.json +1 -1
@@ -0,0 +1,17 @@
1
+ export interface Email {
2
+ readonly address: string;
3
+ verified: boolean;
4
+ }
5
+ export declare const Email: {
6
+ create(address: string, verified?: boolean): Email;
7
+ fromData(data: {
8
+ address: string;
9
+ verified: boolean;
10
+ }): Email;
11
+ isValid(address: string): boolean;
12
+ equals(a: Email, b: Email): boolean;
13
+ toData(email: Email): {
14
+ address: string;
15
+ verified: boolean;
16
+ };
17
+ };
@@ -0,0 +1,21 @@
1
+ export const Email = {
2
+ create(address, verified = false) {
3
+ const normalized = address.trim().toLowerCase();
4
+ if (!Email.isValid(normalized)) {
5
+ throw new Error(`Invalid email address: ${address}`);
6
+ }
7
+ return { address: normalized, verified };
8
+ },
9
+ fromData(data) {
10
+ return { address: data.address, verified: data.verified };
11
+ },
12
+ isValid(address) {
13
+ return typeof address === 'string' && address.includes('@') && address.length >= 3;
14
+ },
15
+ equals(a, b) {
16
+ return a.address.toLowerCase() === b.address.toLowerCase();
17
+ },
18
+ toData(email) {
19
+ return { address: email.address, verified: email.verified };
20
+ },
21
+ };
@@ -0,0 +1,16 @@
1
+ export interface PasswordHash {
2
+ pbkdf2?: string;
3
+ bcrypt?: string;
4
+ }
5
+ export declare const PasswordHash: {
6
+ create(pbkdf2Hash: string): PasswordHash;
7
+ fromLegacyBcrypt(bcryptHash: string): PasswordHash;
8
+ fromData(data: {
9
+ pbkdf2?: string;
10
+ bcrypt?: string;
11
+ }): PasswordHash;
12
+ isEmpty(hash: PasswordHash | undefined): boolean;
13
+ isLegacyBcrypt(hash: PasswordHash): boolean;
14
+ getSalt(hash: PasswordHash): string | undefined;
15
+ getHash(hash: PasswordHash): string | undefined;
16
+ };
@@ -0,0 +1,31 @@
1
+ export const PasswordHash = {
2
+ create(pbkdf2Hash) {
3
+ return { pbkdf2: pbkdf2Hash };
4
+ },
5
+ fromLegacyBcrypt(bcryptHash) {
6
+ return { bcrypt: bcryptHash };
7
+ },
8
+ fromData(data) {
9
+ return { ...data };
10
+ },
11
+ isEmpty(hash) {
12
+ if (!hash)
13
+ return true;
14
+ return !hash.pbkdf2 && !hash.bcrypt;
15
+ },
16
+ isLegacyBcrypt(hash) {
17
+ return !!hash.bcrypt && !hash.pbkdf2;
18
+ },
19
+ getSalt(hash) {
20
+ if (!hash.pbkdf2)
21
+ return undefined;
22
+ const [salt] = hash.pbkdf2.split(':');
23
+ return salt;
24
+ },
25
+ getHash(hash) {
26
+ if (!hash.pbkdf2)
27
+ return undefined;
28
+ const [, hashValue] = hash.pbkdf2.split(':');
29
+ return hashValue;
30
+ },
31
+ };
@@ -0,0 +1,23 @@
1
+ export interface PushSubscription {
2
+ userAgent: string;
3
+ endpoint: string;
4
+ expirationTime: number;
5
+ keys: {
6
+ auth: string;
7
+ p256dh: string;
8
+ };
9
+ }
10
+ export declare const PushSubscription: {
11
+ create(data: {
12
+ userAgent: string;
13
+ endpoint: string;
14
+ expirationTime?: number;
15
+ keys: {
16
+ auth: string;
17
+ p256dh: string;
18
+ };
19
+ }): PushSubscription;
20
+ fromData(data: PushSubscription): PushSubscription;
21
+ equals(a: PushSubscription, b: PushSubscription): boolean;
22
+ matchesKey(subscription: PushSubscription, p256dh: string): boolean;
23
+ };
@@ -0,0 +1,25 @@
1
+ export const PushSubscription = {
2
+ create(data) {
3
+ if (!data.endpoint || !data.keys?.p256dh || !data.keys?.auth) {
4
+ throw new Error('Push subscription requires endpoint and keys');
5
+ }
6
+ return {
7
+ userAgent: data.userAgent,
8
+ endpoint: data.endpoint,
9
+ expirationTime: data.expirationTime ?? 0,
10
+ keys: {
11
+ auth: data.keys.auth,
12
+ p256dh: data.keys.p256dh,
13
+ },
14
+ };
15
+ },
16
+ fromData(data) {
17
+ return { ...data };
18
+ },
19
+ equals(a, b) {
20
+ return a.keys.p256dh === b.keys.p256dh;
21
+ },
22
+ matchesKey(subscription, p256dh) {
23
+ return subscription.keys.p256dh === p256dh;
24
+ },
25
+ };
@@ -0,0 +1,116 @@
1
+ import { type Email } from './Email.ts';
2
+ import { type UserProfile } from './UserProfile.ts';
3
+ import { type Web3Address } from './Web3Address.ts';
4
+ import { type WebAuthnCredential } from './WebAuthnCredential.ts';
5
+ import { type PasswordHash } from './PasswordHash.ts';
6
+ import { type PushSubscription } from './PushSubscription.ts';
7
+ import type { Address, Contact, UserLastLogin, TokenRecord } from '../types.ts';
8
+ export interface UserData {
9
+ _id: string;
10
+ emails: Email[];
11
+ username?: string;
12
+ profile?: UserProfile;
13
+ roles: string[];
14
+ tags: string[];
15
+ guest: boolean;
16
+ initialPassword: boolean;
17
+ avatarId?: string;
18
+ lastBillingAddress?: Address;
19
+ lastContact?: Contact;
20
+ lastLogin?: UserLastLogin;
21
+ pushSubscriptions: PushSubscription[];
22
+ meta?: Record<string, unknown>;
23
+ created: Date;
24
+ updated?: Date;
25
+ deleted?: Date | null;
26
+ services: {
27
+ password?: PasswordHash;
28
+ web3?: Web3Address[];
29
+ webAuthn?: WebAuthnCredential[];
30
+ token?: {
31
+ secret: string;
32
+ };
33
+ email?: {
34
+ verificationTokens: TokenRecord[];
35
+ };
36
+ };
37
+ }
38
+ export interface CreateUserParams {
39
+ _id?: string;
40
+ email?: string;
41
+ username?: string;
42
+ password?: PasswordHash;
43
+ webAuthnCredential?: WebAuthnCredential;
44
+ roles?: string[];
45
+ tags?: string[];
46
+ guest?: boolean;
47
+ initialPassword?: boolean;
48
+ profile?: Partial<UserProfile>;
49
+ meta?: Record<string, unknown>;
50
+ pushSubscriptions?: PushSubscription[];
51
+ }
52
+ export interface User {
53
+ readonly id: string;
54
+ readonly _id: string;
55
+ readonly emails: Email[];
56
+ readonly username: string | undefined;
57
+ readonly profile: UserProfile | undefined;
58
+ readonly roles: string[];
59
+ readonly tags: string[];
60
+ readonly guest: boolean;
61
+ readonly initialPassword: boolean;
62
+ readonly avatarId: string | undefined;
63
+ readonly lastBillingAddress: Address | undefined;
64
+ readonly lastContact: Contact | undefined;
65
+ readonly lastLogin: UserLastLogin | undefined;
66
+ readonly pushSubscriptions: PushSubscription[];
67
+ readonly meta: Record<string, unknown> | undefined;
68
+ readonly created: Date;
69
+ readonly updated: Date | undefined;
70
+ readonly deleted: Date | null | undefined;
71
+ readonly services: UserData['services'];
72
+ readonly isDeleted: boolean;
73
+ primaryEmail(): Email | undefined;
74
+ hasVerifiedEmail(): boolean;
75
+ hasPassword(): boolean;
76
+ getPasswordHash(): PasswordHash | undefined;
77
+ findWeb3Address(address: string): Web3Address | undefined;
78
+ getWebAuthnCredentials(): WebAuthnCredential[];
79
+ findEmailByAddress(address: string): Email | undefined;
80
+ addEmail(email: Email): void;
81
+ removeEmail(address: string): void;
82
+ markEmailVerified(address: string): boolean;
83
+ setUsername(username: string): void;
84
+ setProfile(profile: Partial<UserProfile>): void;
85
+ setMeta(meta: Record<string, unknown>): void;
86
+ setAvatar(fileId: string): void;
87
+ setPassword(passwordHash: PasswordHash): void;
88
+ setRoles(roles: string[]): void;
89
+ addRoles(roles: string[]): void;
90
+ setTags(tags: string[]): void;
91
+ setGuest(isGuest: boolean): void;
92
+ updateLastLogin(loginInfo: Omit<UserLastLogin, 'timestamp'>): void;
93
+ updateLastBillingAddress(address: Address, updateDisplayName?: boolean): void;
94
+ updateLastContact(contact: Contact, updatePhoneMobile?: boolean): void;
95
+ addWeb3Address(address: string): string;
96
+ removeWeb3Address(address: string): boolean;
97
+ verifyWeb3Address(address: string): boolean;
98
+ addWebAuthnCredential(credential: WebAuthnCredential): void;
99
+ removeWebAuthnCredential(credentialId: string): boolean;
100
+ updateWebAuthnCounter(credentialId: string, counter: number): boolean;
101
+ addPushSubscription(subscription: PushSubscription): boolean;
102
+ removePushSubscription(p256dh: string): boolean;
103
+ setAccessToken(hashedToken: string): void;
104
+ addVerificationToken(token: TokenRecord): void;
105
+ addResetToken(token: TokenRecord): void;
106
+ removeResetToken(hashedToken: string): void;
107
+ clearVerificationTokens(address: string): void;
108
+ markAsDeleted(): void;
109
+ readonly isDirty: boolean;
110
+ readonly changes: readonly string[];
111
+ clearChanges(): void;
112
+ toData(): UserData;
113
+ toPublicData(): Omit<UserData, 'services'>;
114
+ }
115
+ export declare const createUser: (params: CreateUserParams) => User;
116
+ export declare const reconstituteUser: (data: UserData) => User;
@@ -0,0 +1,386 @@
1
+ import { Email as EmailVO } from "./Email.js";
2
+ import { UserProfile as UserProfileVO } from "./UserProfile.js";
3
+ import { Web3Address as Web3AddressVO } from "./Web3Address.js";
4
+ import { WebAuthnCredential as WebAuthnCredentialVO, } from "./WebAuthnCredential.js";
5
+ import { PasswordHash as PasswordHashVO } from "./PasswordHash.js";
6
+ import { PushSubscription as PushSubscriptionVO } from "./PushSubscription.js";
7
+ export const createUser = (params) => {
8
+ const now = new Date();
9
+ const data = {
10
+ _id: params._id || crypto.randomUUID(),
11
+ emails: params.email ? [EmailVO.create(params.email)] : [],
12
+ username: params.username?.trim().toLowerCase(),
13
+ profile: params.profile ? UserProfileVO.create(params.profile) : undefined,
14
+ roles: params.roles || [],
15
+ tags: params.tags || [],
16
+ guest: params.guest ?? false,
17
+ initialPassword: params.initialPassword ?? false,
18
+ pushSubscriptions: params.pushSubscriptions || [],
19
+ meta: params.meta,
20
+ created: now,
21
+ updated: undefined,
22
+ deleted: null,
23
+ services: {},
24
+ };
25
+ if (params.password) {
26
+ data.services.password = params.password;
27
+ }
28
+ if (params.webAuthnCredential) {
29
+ data.services.webAuthn = [params.webAuthnCredential];
30
+ }
31
+ const isDirty = true;
32
+ const changes = ['created'];
33
+ return createUserFromData(data, isDirty, changes);
34
+ };
35
+ export const reconstituteUser = (data) => {
36
+ const reconstituted = {
37
+ ...data,
38
+ emails: (data.emails || []).map((e) => EmailVO.fromData(e)),
39
+ profile: data.profile ? UserProfileVO.fromData(data.profile) : undefined,
40
+ roles: [...(data.roles || [])],
41
+ tags: [...(data.tags || [])],
42
+ pushSubscriptions: (data.pushSubscriptions || []).map((p) => PushSubscriptionVO.fromData(p)),
43
+ services: {
44
+ ...data.services,
45
+ password: data.services?.password ? PasswordHashVO.fromData(data.services.password) : undefined,
46
+ web3: data.services?.web3?.map((w) => Web3AddressVO.fromData(w)),
47
+ webAuthn: data.services?.webAuthn?.map((w) => WebAuthnCredentialVO.fromData(w)),
48
+ },
49
+ };
50
+ return createUserFromData(reconstituted, false, []);
51
+ };
52
+ const createUserFromData = (data, initialDirty, initialChanges) => {
53
+ let isDirty = initialDirty;
54
+ const changes = [...initialChanges];
55
+ const markDirty = (field) => {
56
+ isDirty = true;
57
+ data.updated = new Date();
58
+ if (!changes.includes(field)) {
59
+ changes.push(field);
60
+ }
61
+ };
62
+ const user = {
63
+ get id() {
64
+ return data._id;
65
+ },
66
+ get _id() {
67
+ return data._id;
68
+ },
69
+ get emails() {
70
+ return [...data.emails];
71
+ },
72
+ get username() {
73
+ return data.username;
74
+ },
75
+ get profile() {
76
+ return data.profile ? { ...data.profile } : undefined;
77
+ },
78
+ get roles() {
79
+ return [...data.roles];
80
+ },
81
+ get tags() {
82
+ return [...data.tags];
83
+ },
84
+ get guest() {
85
+ return data.guest;
86
+ },
87
+ get initialPassword() {
88
+ return data.initialPassword;
89
+ },
90
+ get avatarId() {
91
+ return data.avatarId;
92
+ },
93
+ get lastBillingAddress() {
94
+ return data.lastBillingAddress ? { ...data.lastBillingAddress } : undefined;
95
+ },
96
+ get lastContact() {
97
+ return data.lastContact ? { ...data.lastContact } : undefined;
98
+ },
99
+ get lastLogin() {
100
+ return data.lastLogin ? { ...data.lastLogin } : undefined;
101
+ },
102
+ get pushSubscriptions() {
103
+ return [...data.pushSubscriptions];
104
+ },
105
+ get meta() {
106
+ return data.meta ? { ...data.meta } : undefined;
107
+ },
108
+ get created() {
109
+ return data.created;
110
+ },
111
+ get updated() {
112
+ return data.updated;
113
+ },
114
+ get deleted() {
115
+ return data.deleted;
116
+ },
117
+ get services() {
118
+ return data.services;
119
+ },
120
+ get isDeleted() {
121
+ return data.deleted != null;
122
+ },
123
+ primaryEmail() {
124
+ return [...data.emails].sort((a, b) => Number(b.verified) - Number(a.verified))[0];
125
+ },
126
+ hasVerifiedEmail() {
127
+ return data.emails.some((e) => e.verified);
128
+ },
129
+ hasPassword() {
130
+ return !PasswordHashVO.isEmpty(data.services.password);
131
+ },
132
+ getPasswordHash() {
133
+ return data.services.password;
134
+ },
135
+ findWeb3Address(address) {
136
+ return data.services.web3?.find((w) => w.address.toLowerCase() === address.toLowerCase());
137
+ },
138
+ getWebAuthnCredentials() {
139
+ return data.services.webAuthn || [];
140
+ },
141
+ findEmailByAddress(address) {
142
+ return data.emails.find((e) => e.address.toLowerCase() === address.toLowerCase());
143
+ },
144
+ addEmail(email) {
145
+ const exists = data.emails.some((e) => EmailVO.equals(e, email));
146
+ if (!exists) {
147
+ data.emails.push(email);
148
+ markDirty('emails');
149
+ }
150
+ },
151
+ removeEmail(address) {
152
+ const index = data.emails.findIndex((e) => e.address.toLowerCase() === address.toLowerCase());
153
+ if (index !== -1) {
154
+ data.emails.splice(index, 1);
155
+ markDirty('emails');
156
+ }
157
+ },
158
+ markEmailVerified(address) {
159
+ const email = data.emails.find((e) => e.address.toLowerCase() === address.toLowerCase());
160
+ if (email && !email.verified) {
161
+ email.verified = true;
162
+ if (data.services.email?.verificationTokens) {
163
+ data.services.email.verificationTokens = data.services.email.verificationTokens.filter((t) => t.address.toLowerCase() !== address.toLowerCase());
164
+ }
165
+ markDirty('emails');
166
+ return true;
167
+ }
168
+ return false;
169
+ },
170
+ setUsername(username) {
171
+ data.username = username.trim().toLowerCase();
172
+ markDirty('username');
173
+ },
174
+ setProfile(profile) {
175
+ data.profile = UserProfileVO.merge(data.profile, profile);
176
+ markDirty('profile');
177
+ },
178
+ setMeta(meta) {
179
+ data.meta = meta;
180
+ markDirty('meta');
181
+ },
182
+ setAvatar(fileId) {
183
+ data.avatarId = fileId;
184
+ markDirty('avatar');
185
+ },
186
+ setPassword(passwordHash) {
187
+ data.services.password = passwordHash;
188
+ data.initialPassword = false;
189
+ markDirty('password');
190
+ },
191
+ setRoles(roles) {
192
+ data.roles = [...roles];
193
+ markDirty('roles');
194
+ },
195
+ addRoles(roles) {
196
+ const newRoles = roles.filter((r) => !data.roles.includes(r));
197
+ if (newRoles.length > 0) {
198
+ data.roles.push(...newRoles);
199
+ markDirty('roles');
200
+ }
201
+ },
202
+ setTags(tags) {
203
+ data.tags = [...tags];
204
+ markDirty('tags');
205
+ },
206
+ setGuest(isGuest) {
207
+ data.guest = isGuest;
208
+ markDirty('guest');
209
+ },
210
+ updateLastLogin(loginInfo) {
211
+ data.lastLogin = {
212
+ timestamp: new Date(),
213
+ ...loginInfo,
214
+ };
215
+ markDirty('lastLogin');
216
+ },
217
+ updateLastBillingAddress(address, updateDisplayName = false) {
218
+ data.lastBillingAddress = address;
219
+ if (updateDisplayName || !data.profile?.displayName || data.guest) {
220
+ const displayName = [address.firstName, address.lastName].filter(Boolean).join(' ');
221
+ if (displayName) {
222
+ data.profile = UserProfileVO.merge(data.profile, { displayName });
223
+ }
224
+ }
225
+ markDirty('lastBillingAddress');
226
+ },
227
+ updateLastContact(contact, updatePhoneMobile = false) {
228
+ data.lastContact = contact;
229
+ if ((updatePhoneMobile || !data.profile?.phoneMobile || data.guest) && contact.telNumber) {
230
+ data.profile = UserProfileVO.merge(data.profile, { phoneMobile: contact.telNumber });
231
+ }
232
+ markDirty('lastContact');
233
+ },
234
+ addWeb3Address(address) {
235
+ if (!data.services.web3) {
236
+ data.services.web3 = [];
237
+ }
238
+ const existing = data.services.web3.find((w) => w.address.toLowerCase() === address.toLowerCase());
239
+ if (existing) {
240
+ return existing.nonce || '';
241
+ }
242
+ const nonce = crypto.randomUUID();
243
+ data.services.web3.push(Web3AddressVO.create(address, nonce));
244
+ markDirty('web3');
245
+ return nonce;
246
+ },
247
+ removeWeb3Address(address) {
248
+ if (!data.services.web3)
249
+ return false;
250
+ const index = data.services.web3.findIndex((w) => w.address.toLowerCase() === address.toLowerCase());
251
+ if (index !== -1) {
252
+ data.services.web3.splice(index, 1);
253
+ markDirty('web3');
254
+ return true;
255
+ }
256
+ return false;
257
+ },
258
+ verifyWeb3Address(address) {
259
+ if (!data.services.web3)
260
+ return false;
261
+ const web3Address = data.services.web3.find((w) => w.address.toLowerCase() === address.toLowerCase());
262
+ if (web3Address && !web3Address.verified) {
263
+ web3Address.verified = true;
264
+ web3Address.nonce = undefined;
265
+ markDirty('web3');
266
+ return true;
267
+ }
268
+ return false;
269
+ },
270
+ addWebAuthnCredential(credential) {
271
+ if (!data.services.webAuthn) {
272
+ data.services.webAuthn = [];
273
+ }
274
+ data.services.webAuthn.push(credential);
275
+ markDirty('webAuthn');
276
+ },
277
+ removeWebAuthnCredential(credentialId) {
278
+ if (!data.services.webAuthn)
279
+ return false;
280
+ const index = data.services.webAuthn.findIndex((c) => c.id === credentialId);
281
+ if (index !== -1) {
282
+ data.services.webAuthn.splice(index, 1);
283
+ markDirty('webAuthn');
284
+ return true;
285
+ }
286
+ return false;
287
+ },
288
+ updateWebAuthnCounter(credentialId, counter) {
289
+ if (!data.services.webAuthn)
290
+ return false;
291
+ const credential = data.services.webAuthn.find((c) => c.id === credentialId);
292
+ if (credential) {
293
+ credential.counter = counter;
294
+ markDirty('webAuthn');
295
+ return true;
296
+ }
297
+ return false;
298
+ },
299
+ addPushSubscription(subscription) {
300
+ const exists = data.pushSubscriptions.some((p) => PushSubscriptionVO.equals(p, subscription));
301
+ if (!exists) {
302
+ data.pushSubscriptions.push(subscription);
303
+ markDirty('pushSubscriptions');
304
+ return true;
305
+ }
306
+ return false;
307
+ },
308
+ removePushSubscription(p256dh) {
309
+ const index = data.pushSubscriptions.findIndex((p) => PushSubscriptionVO.matchesKey(p, p256dh));
310
+ if (index !== -1) {
311
+ data.pushSubscriptions.splice(index, 1);
312
+ markDirty('pushSubscriptions');
313
+ return true;
314
+ }
315
+ return false;
316
+ },
317
+ setAccessToken(hashedToken) {
318
+ data.services.token = { secret: hashedToken };
319
+ markDirty('token');
320
+ },
321
+ addVerificationToken(token) {
322
+ if (!data.services.email) {
323
+ data.services.email = { verificationTokens: [] };
324
+ }
325
+ data.services.email.verificationTokens.push(token);
326
+ markDirty('verificationTokens');
327
+ },
328
+ addResetToken(token) {
329
+ if (!data.services.password) {
330
+ data.services.password = {};
331
+ }
332
+ const passwordWithReset = data.services.password;
333
+ if (!passwordWithReset.reset) {
334
+ passwordWithReset.reset = [];
335
+ }
336
+ passwordWithReset.reset.push(token);
337
+ markDirty('resetTokens');
338
+ },
339
+ removeResetToken(hashedToken) {
340
+ const passwordWithReset = data.services.password;
341
+ if (passwordWithReset?.reset) {
342
+ passwordWithReset.reset = passwordWithReset.reset.filter((t) => t.token !== hashedToken);
343
+ markDirty('resetTokens');
344
+ }
345
+ },
346
+ clearVerificationTokens(address) {
347
+ if (data.services.email?.verificationTokens) {
348
+ data.services.email.verificationTokens = data.services.email.verificationTokens.filter((t) => t.address.toLowerCase() !== address.toLowerCase());
349
+ markDirty('verificationTokens');
350
+ }
351
+ },
352
+ markAsDeleted() {
353
+ data.deleted = new Date();
354
+ data.username = `deleted-${Date.now()}`;
355
+ data.emails = [];
356
+ data.roles = [];
357
+ data.services = {};
358
+ data.pushSubscriptions = [];
359
+ data.initialPassword = false;
360
+ data.profile = undefined;
361
+ data.lastBillingAddress = undefined;
362
+ data.lastContact = undefined;
363
+ data.lastLogin = undefined;
364
+ data.avatarId = undefined;
365
+ markDirty('deleted');
366
+ },
367
+ get isDirty() {
368
+ return isDirty;
369
+ },
370
+ get changes() {
371
+ return [...changes];
372
+ },
373
+ clearChanges() {
374
+ isDirty = false;
375
+ changes.length = 0;
376
+ },
377
+ toData() {
378
+ return JSON.parse(JSON.stringify(data));
379
+ },
380
+ toPublicData() {
381
+ const { services, ...publicData } = data;
382
+ return JSON.parse(JSON.stringify(publicData));
383
+ },
384
+ };
385
+ return user;
386
+ };
@@ -0,0 +1,14 @@
1
+ import type { Address } from '../types.ts';
2
+ export interface UserProfile {
3
+ displayName?: string;
4
+ birthday?: Date;
5
+ phoneMobile?: string;
6
+ gender?: string;
7
+ address?: Address;
8
+ }
9
+ export declare const UserProfile: {
10
+ create(data?: Partial<UserProfile>): UserProfile;
11
+ fromData(data: UserProfile): UserProfile;
12
+ merge(existing: UserProfile | undefined, updates: Partial<UserProfile>): UserProfile;
13
+ isEmpty(profile: UserProfile | undefined): boolean;
14
+ };
@@ -0,0 +1,29 @@
1
+ export const UserProfile = {
2
+ create(data = {}) {
3
+ return {
4
+ displayName: data.displayName?.trim(),
5
+ birthday: data.birthday,
6
+ phoneMobile: data.phoneMobile?.trim(),
7
+ gender: data.gender?.trim(),
8
+ address: data.address,
9
+ };
10
+ },
11
+ fromData(data) {
12
+ return { ...data };
13
+ },
14
+ merge(existing, updates) {
15
+ return {
16
+ ...existing,
17
+ ...updates,
18
+ };
19
+ },
20
+ isEmpty(profile) {
21
+ if (!profile)
22
+ return true;
23
+ return (!profile.displayName &&
24
+ !profile.birthday &&
25
+ !profile.phoneMobile &&
26
+ !profile.gender &&
27
+ !profile.address);
28
+ },
29
+ };
@@ -0,0 +1,16 @@
1
+ export interface Web3Address {
2
+ readonly address: string;
3
+ nonce?: string;
4
+ verified: boolean;
5
+ }
6
+ export declare const Web3Address: {
7
+ create(address: string, nonce?: string): Web3Address;
8
+ fromData(data: {
9
+ address: string;
10
+ nonce?: string;
11
+ verified?: boolean;
12
+ }): Web3Address;
13
+ isValidAddress(address: string): boolean;
14
+ equals(a: Web3Address, b: Web3Address): boolean;
15
+ markVerified(web3Address: Web3Address): Web3Address;
16
+ };
@@ -0,0 +1,32 @@
1
+ export const Web3Address = {
2
+ create(address, nonce) {
3
+ if (!Web3Address.isValidAddress(address)) {
4
+ throw new Error(`Invalid Web3 address: ${address}`);
5
+ }
6
+ return {
7
+ address,
8
+ nonce,
9
+ verified: false,
10
+ };
11
+ },
12
+ fromData(data) {
13
+ return {
14
+ address: data.address,
15
+ nonce: data.nonce,
16
+ verified: data.verified ?? false,
17
+ };
18
+ },
19
+ isValidAddress(address) {
20
+ return /^0x[a-fA-F0-9]{40}$/.test(address);
21
+ },
22
+ equals(a, b) {
23
+ return a.address.toLowerCase() === b.address.toLowerCase();
24
+ },
25
+ markVerified(web3Address) {
26
+ return {
27
+ ...web3Address,
28
+ nonce: undefined,
29
+ verified: true,
30
+ };
31
+ },
32
+ };
@@ -0,0 +1,22 @@
1
+ export type NamedAlgo = 'RS256' | 'ES256';
2
+ export interface WebAuthnCredential {
3
+ readonly id: string;
4
+ readonly publicKey: string;
5
+ readonly algorithm: NamedAlgo;
6
+ readonly aaguid: string;
7
+ readonly type: 'public-key';
8
+ counter: number;
9
+ readonly created: Date;
10
+ }
11
+ export declare const WebAuthnCredential: {
12
+ create(data: {
13
+ id: string;
14
+ publicKey: string;
15
+ algorithm?: NamedAlgo;
16
+ aaguid?: string;
17
+ counter?: number;
18
+ }): WebAuthnCredential;
19
+ fromData(data: WebAuthnCredential): WebAuthnCredential;
20
+ equals(a: WebAuthnCredential, b: WebAuthnCredential): boolean;
21
+ updateCounter(credential: WebAuthnCredential, newCounter: number): WebAuthnCredential;
22
+ };
@@ -0,0 +1,28 @@
1
+ export const WebAuthnCredential = {
2
+ create(data) {
3
+ if (!data.id || !data.publicKey) {
4
+ throw new Error('WebAuthn credential requires id and publicKey');
5
+ }
6
+ return {
7
+ id: data.id,
8
+ publicKey: data.publicKey,
9
+ algorithm: data.algorithm ?? 'ES256',
10
+ aaguid: data.aaguid ?? '',
11
+ type: 'public-key',
12
+ counter: data.counter ?? 0,
13
+ created: new Date(),
14
+ };
15
+ },
16
+ fromData(data) {
17
+ return { ...data, type: 'public-key' };
18
+ },
19
+ equals(a, b) {
20
+ return a.id === b.id;
21
+ },
22
+ updateCounter(credential, newCounter) {
23
+ return {
24
+ ...credential,
25
+ counter: newCounter,
26
+ };
27
+ },
28
+ };
@@ -0,0 +1,14 @@
1
+ export type { Email } from './Email.ts';
2
+ export { Email as EmailFactory } from './Email.ts';
3
+ export type { UserProfile } from './UserProfile.ts';
4
+ export { UserProfile as UserProfileFactory } from './UserProfile.ts';
5
+ export type { Web3Address } from './Web3Address.ts';
6
+ export { Web3Address as Web3AddressFactory } from './Web3Address.ts';
7
+ export type { WebAuthnCredential, NamedAlgo } from './WebAuthnCredential.ts';
8
+ export { WebAuthnCredential as WebAuthnCredentialFactory } from './WebAuthnCredential.ts';
9
+ export type { PasswordHash } from './PasswordHash.ts';
10
+ export { PasswordHash as PasswordHashFactory } from './PasswordHash.ts';
11
+ export type { PushSubscription } from './PushSubscription.ts';
12
+ export { PushSubscription as PushSubscriptionFactory } from './PushSubscription.ts';
13
+ export type { User, UserData, CreateUserParams } from './User.ts';
14
+ export { createUser, reconstituteUser } from './User.ts';
@@ -0,0 +1,7 @@
1
+ export { Email as EmailFactory } from "./Email.js";
2
+ export { UserProfile as UserProfileFactory } from "./UserProfile.js";
3
+ export { Web3Address as Web3AddressFactory } from "./Web3Address.js";
4
+ export { WebAuthnCredential as WebAuthnCredentialFactory } from "./WebAuthnCredential.js";
5
+ export { PasswordHash as PasswordHashFactory } from "./PasswordHash.js";
6
+ export { PushSubscription as PushSubscriptionFactory } from "./PushSubscription.js";
7
+ export { createUser, reconstituteUser } from "./User.js";
@@ -0,0 +1,48 @@
1
+ import type { User } from '../model/User.ts';
2
+ export interface UserQuery {
3
+ includeGuests?: boolean;
4
+ includeDeleted?: boolean;
5
+ queryString?: string;
6
+ emailVerified?: boolean;
7
+ lastLogin?: {
8
+ start?: string;
9
+ end?: string;
10
+ };
11
+ tags?: string[];
12
+ userIds?: string[];
13
+ username?: string;
14
+ usernames?: string[];
15
+ emails?: string[];
16
+ web3Verified?: boolean;
17
+ }
18
+ export interface FindOptions {
19
+ sort?: {
20
+ key: string;
21
+ value: 'ASC' | 'DESC';
22
+ }[];
23
+ limit?: number;
24
+ offset?: number;
25
+ }
26
+ export interface TokenResult {
27
+ userId: string;
28
+ address: string;
29
+ when: Date;
30
+ token: string;
31
+ }
32
+ export interface UserRepository {
33
+ findById(id: string): Promise<User | null>;
34
+ findByUsername(username: string): Promise<User | null>;
35
+ findByEmail(email: string): Promise<User | null>;
36
+ findByToken(hashedToken: string): Promise<User | null>;
37
+ findOne(query: UserQuery, options?: FindOptions): Promise<User | null>;
38
+ findMany(query: UserQuery, options?: FindOptions): Promise<User[]>;
39
+ count(query: UserQuery): Promise<number>;
40
+ exists(userId: string): Promise<boolean>;
41
+ findByVerificationToken(hashedToken: string, validAfter: Date): Promise<TokenResult | null>;
42
+ findByResetToken(hashedToken: string, validAfter: Date): Promise<TokenResult | null>;
43
+ save(user: User): Promise<void>;
44
+ insert(user: User): Promise<string>;
45
+ delete(userId: string): Promise<boolean>;
46
+ deleteUserSessions(userId: string): Promise<void>;
47
+ distinctTags(): Promise<string[]>;
48
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,5 @@
1
+ import type { PasswordHash } from '../model/PasswordHash.ts';
2
+ export interface PasswordService {
3
+ hash(plainPassword: string): Promise<PasswordHash>;
4
+ verify(hash: PasswordHash, plainPassword: string): Promise<boolean>;
5
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,8 @@
1
+ export interface TokenService {
2
+ generateToken(): string;
3
+ hashToken(plainToken: string): Promise<string>;
4
+ generateHashedToken(): Promise<{
5
+ plain: string;
6
+ hashed: string;
7
+ }>;
8
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,28 @@
1
+ export interface Address {
2
+ firstName?: string;
3
+ lastName?: string;
4
+ company?: string;
5
+ addressLine?: string;
6
+ addressLine2?: string;
7
+ postalCode?: string;
8
+ regionCode?: string;
9
+ city?: string;
10
+ countryCode?: string;
11
+ }
12
+ export interface Contact {
13
+ telNumber?: string;
14
+ emailAddress?: string;
15
+ }
16
+ export interface UserLastLogin {
17
+ timestamp?: Date;
18
+ locale?: string;
19
+ countryCode?: string;
20
+ remoteAddress?: string;
21
+ remotePort?: number;
22
+ userAgent?: string;
23
+ }
24
+ export interface TokenRecord {
25
+ token: string;
26
+ address: string;
27
+ when: Date;
28
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,4 @@
1
+ import { type mongodb } from '@unchainedshop/mongodb';
2
+ import type { UserRepository } from '../../domain/repository/UserRepository.ts';
3
+ import { type UserData } from '../../domain/model/User.ts';
4
+ export declare const createMongoUserRepository: (collection: mongodb.Collection<UserData>, db: mongodb.Db) => UserRepository;
@@ -0,0 +1,159 @@
1
+ import { generateDbFilterById, buildSortOptions, insensitiveTrimmedRegexOperator, } from '@unchainedshop/mongodb';
2
+ import { SortDirection } from '@unchainedshop/utils';
3
+ import { reconstituteUser } from "../../domain/model/User.js";
4
+ import { buildUserSelector } from "./buildUserSelector.js";
5
+ export const createMongoUserRepository = (collection, db) => {
6
+ const buildFindOptions = (options) => {
7
+ const findOptions = {};
8
+ if (options?.offset) {
9
+ findOptions.skip = options.offset;
10
+ }
11
+ if (options?.limit) {
12
+ findOptions.limit = options.limit;
13
+ }
14
+ if (options?.sort) {
15
+ findOptions.sort = buildSortOptions(options.sort);
16
+ }
17
+ return findOptions;
18
+ };
19
+ return {
20
+ async findById(id) {
21
+ if (!id)
22
+ return null;
23
+ const data = await collection.findOne(generateDbFilterById(id));
24
+ return data ? reconstituteUser(data) : null;
25
+ },
26
+ async findByUsername(username) {
27
+ if (!username)
28
+ return null;
29
+ const data = await collection.findOne({
30
+ username: insensitiveTrimmedRegexOperator(username),
31
+ });
32
+ return data ? reconstituteUser(data) : null;
33
+ },
34
+ async findByEmail(email) {
35
+ if (!email)
36
+ return null;
37
+ const data = await collection.findOne({
38
+ 'emails.address': insensitiveTrimmedRegexOperator(email),
39
+ });
40
+ return data ? reconstituteUser(data) : null;
41
+ },
42
+ async findByToken(hashedToken) {
43
+ if (!hashedToken)
44
+ return null;
45
+ const data = await collection.findOne({
46
+ 'services.token.secret': hashedToken,
47
+ });
48
+ return data ? reconstituteUser(data) : null;
49
+ },
50
+ async findOne(query, options) {
51
+ const selector = buildUserSelector(query);
52
+ const findOptions = buildFindOptions(options);
53
+ const data = await collection.findOne(selector, findOptions);
54
+ return data ? reconstituteUser(data) : null;
55
+ },
56
+ async findMany(query, options) {
57
+ const selector = buildUserSelector(query);
58
+ const defaultSort = [{ key: 'created', value: SortDirection.ASC }];
59
+ const findOptions = buildFindOptions(options);
60
+ if (query.queryString) {
61
+ const docs = await collection
62
+ .find(selector, {
63
+ ...findOptions,
64
+ projection: { score: { $meta: 'textScore' } },
65
+ sort: { score: { $meta: 'textScore' } },
66
+ })
67
+ .toArray();
68
+ return docs.map(reconstituteUser);
69
+ }
70
+ if (!findOptions.sort) {
71
+ findOptions.sort = buildSortOptions(defaultSort);
72
+ }
73
+ const docs = await collection.find(selector, findOptions).toArray();
74
+ return docs.map(reconstituteUser);
75
+ },
76
+ async count(query) {
77
+ const selector = buildUserSelector(query);
78
+ return collection.countDocuments(selector);
79
+ },
80
+ async exists(userId) {
81
+ const count = await collection.countDocuments({ _id: userId, deleted: null }, { limit: 1 });
82
+ return count === 1;
83
+ },
84
+ async findByVerificationToken(hashedToken, validAfter) {
85
+ if (!hashedToken)
86
+ return null;
87
+ const data = await collection.findOne({
88
+ 'services.email.verificationTokens': {
89
+ $elemMatch: {
90
+ token: hashedToken,
91
+ when: { $gt: validAfter },
92
+ },
93
+ },
94
+ });
95
+ if (!data)
96
+ return null;
97
+ const verificationToken = data.services?.email?.verificationTokens?.find((v) => v.token === hashedToken);
98
+ if (!verificationToken)
99
+ return null;
100
+ return {
101
+ userId: data._id,
102
+ address: verificationToken.address,
103
+ when: verificationToken.when,
104
+ token: verificationToken.token,
105
+ };
106
+ },
107
+ async findByResetToken(hashedToken, validAfter) {
108
+ if (!hashedToken)
109
+ return null;
110
+ const data = await collection.findOne({
111
+ 'services.password.reset': {
112
+ $elemMatch: {
113
+ token: hashedToken,
114
+ when: { $gt: validAfter },
115
+ },
116
+ },
117
+ });
118
+ if (!data)
119
+ return null;
120
+ const passwordService = data.services?.password;
121
+ const resetToken = passwordService?.reset?.find((v) => v.token === hashedToken);
122
+ if (!resetToken)
123
+ return null;
124
+ return {
125
+ userId: data._id,
126
+ address: resetToken.address,
127
+ when: resetToken.when,
128
+ token: resetToken.token,
129
+ };
130
+ },
131
+ async save(user) {
132
+ const data = user.toData();
133
+ await collection.replaceOne({ _id: data._id }, data, { upsert: true });
134
+ user.clearChanges();
135
+ },
136
+ async insert(user) {
137
+ const data = user.toData();
138
+ await collection.insertOne(data);
139
+ user.clearChanges();
140
+ return data._id;
141
+ },
142
+ async delete(userId) {
143
+ const result = await collection.deleteOne({ _id: userId });
144
+ return result.deletedCount > 0;
145
+ },
146
+ async deleteUserSessions(userId) {
147
+ await db.collection('sessions').deleteMany({
148
+ session: insensitiveTrimmedRegexOperator(`"user":"${userId}"`),
149
+ });
150
+ },
151
+ async distinctTags() {
152
+ const tags = (await collection.distinct('tags', {
153
+ tags: { $exists: true },
154
+ deleted: null,
155
+ }));
156
+ return tags.filter(Boolean).sort();
157
+ },
158
+ };
159
+ };
@@ -0,0 +1,4 @@
1
+ import { type mongodb } from '@unchainedshop/mongodb';
2
+ import type { UserQuery } from '../../domain/repository/UserRepository.ts';
3
+ import type { UserData } from '../../domain/model/User.ts';
4
+ export declare const buildUserSelector: ({ includeGuests, includeDeleted, queryString, emailVerified, lastLogin, tags, userIds, username, usernames, emails, web3Verified, }: UserQuery) => mongodb.Filter<UserData>;
@@ -0,0 +1,54 @@
1
+ import { insensitiveTrimmedRegexOperator, assertDocumentDBCompatMode, } from '@unchainedshop/mongodb';
2
+ export const buildUserSelector = ({ includeGuests, includeDeleted, queryString, emailVerified, lastLogin, tags, userIds, username, usernames, emails, web3Verified, }) => {
3
+ const selector = {};
4
+ if (!includeDeleted) {
5
+ selector.deleted = null;
6
+ }
7
+ if (!includeGuests) {
8
+ selector.guest = { $ne: true };
9
+ }
10
+ if (userIds) {
11
+ selector._id = { $in: userIds };
12
+ }
13
+ if (username) {
14
+ selector.username = insensitiveTrimmedRegexOperator(username);
15
+ }
16
+ if (usernames?.length) {
17
+ selector.username = {
18
+ $in: usernames.map((u) => insensitiveTrimmedRegexOperator(u)),
19
+ };
20
+ }
21
+ if (emails?.length) {
22
+ selector['emails.address'] = {
23
+ $in: emails.map((e) => insensitiveTrimmedRegexOperator(e)),
24
+ };
25
+ }
26
+ if (emailVerified === true) {
27
+ selector['emails.verified'] = true;
28
+ }
29
+ if (emailVerified === false) {
30
+ selector['emails.verified'] = { $ne: true };
31
+ }
32
+ if (Array.isArray(tags) && tags.length) {
33
+ selector.tags = { $in: tags };
34
+ }
35
+ if (web3Verified === true) {
36
+ selector['services.web3.verified'] = true;
37
+ }
38
+ if (lastLogin?.start || lastLogin?.end) {
39
+ const timestampFilter = { $exists: true };
40
+ if (lastLogin.end) {
41
+ timestampFilter.$lte = typeof lastLogin.end === 'string' ? new Date(lastLogin.end) : lastLogin.end;
42
+ }
43
+ if (lastLogin.start) {
44
+ timestampFilter.$gte =
45
+ typeof lastLogin.start === 'string' ? new Date(lastLogin.start) : lastLogin.start;
46
+ }
47
+ selector['lastLogin.timestamp'] = timestampFilter;
48
+ }
49
+ if (queryString) {
50
+ assertDocumentDBCompatMode();
51
+ selector.$text = { $search: queryString };
52
+ }
53
+ return selector;
54
+ };
@@ -0,0 +1,2 @@
1
+ import type { TokenService } from '../../domain/services/TokenService.ts';
2
+ export declare const createCryptoTokenService: () => TokenService;
@@ -0,0 +1,16 @@
1
+ import { sha256 } from '@unchainedshop/utils';
2
+ export const createCryptoTokenService = () => {
3
+ return {
4
+ generateToken() {
5
+ return crypto.randomUUID();
6
+ },
7
+ async hashToken(plainToken) {
8
+ return sha256(plainToken);
9
+ },
10
+ async generateHashedToken() {
11
+ const plain = crypto.randomUUID();
12
+ const hashed = await sha256(plain);
13
+ return { plain, hashed };
14
+ },
15
+ };
16
+ };
@@ -0,0 +1,2 @@
1
+ import type { PasswordService } from '../../domain/services/PasswordService.ts';
2
+ export declare const createPbkdf2PasswordService: () => PasswordService;
@@ -0,0 +1,23 @@
1
+ import * as bcrypt from 'bcryptjs';
2
+ import { sha256 } from '@unchainedshop/utils';
3
+ import * as pbkdf2 from "../../module/pbkdf2.js";
4
+ export const createPbkdf2PasswordService = () => {
5
+ return {
6
+ async hash(plainPassword) {
7
+ const salt = pbkdf2.generateSalt();
8
+ const hashedPassword = await pbkdf2.getDerivedKey(salt, plainPassword);
9
+ return { pbkdf2: `${salt}:${hashedPassword}` };
10
+ },
11
+ async verify(hash, plainPassword) {
12
+ if (hash.pbkdf2) {
13
+ const [pbkdf2Salt, pbkdf2Hash] = hash.pbkdf2.split(':');
14
+ return pbkdf2.compare(plainPassword, pbkdf2Hash, pbkdf2Salt);
15
+ }
16
+ if (hash.bcrypt) {
17
+ const password = await sha256(plainPassword);
18
+ return bcrypt.compare(password, hash.bcrypt);
19
+ }
20
+ return false;
21
+ },
22
+ };
23
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@unchainedshop/core-users",
3
3
  "description": "User management module for the Unchained Engine with authentication support",
4
- "version": "4.6.2",
4
+ "version": "4.8.0",
5
5
  "main": "lib/users-index.js",
6
6
  "types": "lib/users-index.d.ts",
7
7
  "type": "module",