@solidxai/core 0.1.16-beta.1 → 0.1.16-beta.2

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 (46) hide show
  1. package/CHANGELOG.md +657 -0
  2. package/CLAUDE.md +26 -0
  3. package/dist/controllers/authentication.controller.d.ts.map +1 -1
  4. package/dist/controllers/authentication.controller.js +3 -2
  5. package/dist/controllers/authentication.controller.js.map +1 -1
  6. package/dist/enums/signup-intent.enum.d.ts +6 -0
  7. package/dist/enums/signup-intent.enum.d.ts.map +1 -0
  8. package/dist/enums/signup-intent.enum.js +10 -0
  9. package/dist/enums/signup-intent.enum.js.map +1 -0
  10. package/dist/index.d.ts +1 -0
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +1 -0
  13. package/dist/index.js.map +1 -1
  14. package/dist/seeders/module-metadata-seeder.service.d.ts.map +1 -1
  15. package/dist/seeders/module-metadata-seeder.service.js +4 -1
  16. package/dist/seeders/module-metadata-seeder.service.js.map +1 -1
  17. package/dist/seeders/module-test-data.service.d.ts.map +1 -1
  18. package/dist/seeders/module-test-data.service.js +3 -1
  19. package/dist/seeders/module-test-data.service.js.map +1 -1
  20. package/dist/services/authentication.service.d.ts +4 -2
  21. package/dist/services/authentication.service.d.ts.map +1 -1
  22. package/dist/services/authentication.service.js +73 -45
  23. package/dist/services/authentication.service.js.map +1 -1
  24. package/dist/services/crud.service.d.ts.map +1 -1
  25. package/dist/services/crud.service.js +2 -1
  26. package/dist/services/crud.service.js.map +1 -1
  27. package/dist/services/user.service.d.ts +4 -0
  28. package/dist/services/user.service.d.ts.map +1 -1
  29. package/dist/services/user.service.js +15 -0
  30. package/dist/services/user.service.js.map +1 -1
  31. package/dist/testing/contracts/testing-metadata.types.d.ts +1 -0
  32. package/dist/testing/contracts/testing-metadata.types.d.ts.map +1 -1
  33. package/dist/testing/contracts/testing-metadata.types.js.map +1 -1
  34. package/package.json +1 -1
  35. package/postman/signup-intent-verification.postman_collection.json +573 -0
  36. package/postman/signup-issues +2 -0
  37. package/src/controllers/authentication.controller.ts +5 -2
  38. package/src/enums/signup-intent.enum.ts +22 -0
  39. package/src/index.ts +1 -0
  40. package/src/seeders/module-metadata-seeder.service.ts +38 -5
  41. package/src/seeders/module-test-data.service.ts +9 -1
  42. package/src/services/authentication.service.ts +170 -59
  43. package/src/services/crud.service.ts +2 -1
  44. package/src/services/user.service.ts +34 -0
  45. package/src/testing/contracts/testing-metadata.types.ts +12 -0
  46. package/postman/instant-logout.postman_collection.json +0 -1493
@@ -41,6 +41,7 @@ import { User } from '../entities/user.entity';
41
41
  import { MENU_ROLE_JOIN_TABLE_NAME, MENU_ROLE_JOIN_TABLE_NAME_MENU_COL, MENU_ROLE_JOIN_TABLE_NAME_ROLE_COL } from '../dtos/create-menu-item-metadata.dto';
42
42
  import { DEFAULT_SA_PASSWORD } from '../dtos/create-user.dto';
43
43
  import { SignUpDto } from '../dtos/sign-up.dto';
44
+ import { SignupIntent } from '../enums/signup-intent.enum';
44
45
  import {
45
46
  ADMIN_ROLE_NAME,
46
47
  ALLOWED_TO_EXPORT_ROLE_NAME,
@@ -86,6 +87,33 @@ import { EventDetails, EventType, ModuleMetadataSeederEventPayload } from 'src/i
86
87
  * - View/action preload + in-memory no-op detection so unchanged rows skip save() entirely.
87
88
  * - Menu entity no-op detection, though menu relation lookups are still mostly serial and remain a future candidate.
88
89
  */
90
+
91
+ /**
92
+ * A user entry in seed metadata.
93
+ *
94
+ * The array is genuinely mixed: core seeds `sa` (a technical user), while an app's
95
+ * module metadata can seed its own users - an "Operations User" and the like, which
96
+ * are app users and belong on the extension entity. One intent for the whole list
97
+ * cannot express that, so each entry declares its own.
98
+ *
99
+ * Extends SignUpDto with an index signature because a flagged entry carries the
100
+ * extension model's own fields (`userType`, relation keys) straight through to the
101
+ * provider.
102
+ */
103
+ type SeedUserSpec = SignUpDto & {
104
+ /**
105
+ * Seed through the registered IExtensionUserCreationProvider, producing the app's
106
+ * extension entity instead of a base `User`. Absent means a base `User`, so
107
+ * existing metadata is unaffected.
108
+ *
109
+ * `roles` is ignored on a flagged entry - the provider derives them from the
110
+ * extension fields, as it does on the model's own CRUD form.
111
+ */
112
+ isExtensionUser?: boolean;
113
+ isAllowedToGenerateApiKeys?: boolean;
114
+ [key: string]: any;
115
+ };
116
+
89
117
  @Injectable()
90
118
  export class ModuleMetadataSeederService {
91
119
  private readonly adminPermissionExclusionPrefixes = [
@@ -758,7 +786,7 @@ export class ModuleMetadataSeederService {
758
786
 
759
787
  // Ok
760
788
  private async seedUsers(overallMetadata: any): Promise<{ pruned: number; upserted: number }> {
761
- const users = this.getSeedArray<SignUpDto>(overallMetadata?.users);
789
+ const users = this.getSeedArray<SeedUserSpec>(overallMetadata?.users);
762
790
  // usersDetail = users;
763
791
  await this.timeOperation('handle-users', () => this.handleSeedUsers(users), {
764
792
  moduleName: overallMetadata?.moduleMetadata?.name,
@@ -1543,13 +1571,13 @@ export class ModuleMetadataSeederService {
1543
1571
  }
1544
1572
 
1545
1573
  // OK
1546
- private async handleSeedUsers(users: SignUpDto[]) {
1574
+ private async handleSeedUsers(users: SeedUserSpec[]) {
1547
1575
  if (!users) {
1548
1576
  return;
1549
1577
  }
1550
1578
 
1551
1579
  for (let l = 0; l < users.length; l++) {
1552
- const user: SignUpDto = users[l];
1580
+ const user: SeedUserSpec = users[l];
1553
1581
  const isSystemAdminUser = user.username === 'sa';
1554
1582
  let exisitingUser = await this.timeOperation('user-find-by-username', () => this.userService.findOneByUsername(user.username), {
1555
1583
  component: 'users',
@@ -1561,10 +1589,15 @@ export class ModuleMetadataSeederService {
1561
1589
  user.password = DEFAULT_SA_PASSWORD;
1562
1590
  }
1563
1591
  if (isSystemAdminUser) {
1564
- (user as SignUpDto & { isAllowedToGenerateApiKeys?: boolean }).isAllowedToGenerateApiKeys = true;
1592
+ user.isAllowedToGenerateApiKeys = true;
1565
1593
  }
1566
1594
 
1567
- exisitingUser = await this.timeOperation('user-sign-up', () => this.authenticationService.signUp(user), {
1595
+ // The flag is seeder metadata, not user data - keep it out of the DTO
1596
+ // the provider and performSignUp see.
1597
+ const { isExtensionUser, ...signUpDto } = user;
1598
+ const intent = isExtensionUser ? SignupIntent.ExtensionModel : SignupIntent.CoreUser;
1599
+
1600
+ exisitingUser = await this.timeOperation('user-sign-up', () => this.authenticationService.signUp(signUpDto, null, intent), {
1568
1601
  component: 'users',
1569
1602
  serviceCall: 'authenticationService.signUp',
1570
1603
  details: `username=${user.username}`,
@@ -10,6 +10,7 @@ import solidCoreMetadata from './seed-data/solid-core-metadata.json';
10
10
  import { CreateModuleMetadataDto } from 'src/dtos/create-module-metadata.dto';
11
11
  import { CreateModelMetadataDto } from 'src/dtos/create-model-metadata.dto';
12
12
  import { MediaStorageProviderType } from 'src/dtos/create-media-storage-provider-metadata.dto';
13
+ import { SignupIntent } from 'src/enums/signup-intent.enum';
13
14
  import { getDynamicModuleNamesBasedOnMetadata } from 'src/helpers/module.helper';
14
15
  import { SolidRegistry } from 'src/helpers/solid-registry';
15
16
  import { InternationalisationHelperService } from 'src/services/internationalisation-helper.service';
@@ -475,7 +476,14 @@ export class ModuleTestDataService {
475
476
  continue;
476
477
  }
477
478
 
478
- await authService.signUp({ ...user });
479
+ // The flag is seeder metadata, not user data - keep it out of the DTO the
480
+ // provider and performSignUp see.
481
+ const { isExtensionUser, ...signUpDto } = user;
482
+ await authService.signUp(
483
+ signUpDto,
484
+ null,
485
+ isExtensionUser ? SignupIntent.ExtensionModel : SignupIntent.CoreUser,
486
+ );
479
487
  this.logger.log(`Created test user "${user.username}"${user.roles?.length ? ` with roles [${user.roles.join(', ')}]` : ''}`);
480
488
  }
481
489
  }
@@ -36,6 +36,7 @@ import { OTPSignUpDto } from "../dtos/otp-sign-up.dto";
36
36
  import { RefreshTokenDto } from "../dtos/refresh-token.dto";
37
37
  import { SignInDto } from "../dtos/sign-in.dto";
38
38
  import { SignUpDto } from "../dtos/sign-up.dto";
39
+ import { SignupIntent } from "../enums/signup-intent.enum";
39
40
  import { User } from "../entities/user.entity";
40
41
  import { EventDetails, EventType } from "../interfaces";
41
42
  import { ActiveUserData } from "../interfaces/active-user-data.interface";
@@ -67,6 +68,43 @@ interface otp {
67
68
  expiresAt: Date;
68
69
  }
69
70
 
71
+ /** Where a signup's role names come from. */
72
+ enum RolesSource {
73
+ /** None from the caller: performSignUp applies the configured `defaultRole`. */
74
+ Default,
75
+ /** `provider.roles(dto)`, unconditionally. */
76
+ Provider,
77
+ /** The caller's own `dto.roles`. */
78
+ Caller,
79
+ }
80
+
81
+ /**
82
+ * The single place that decides, per intent, which entity a signup builds and where
83
+ * its roles come from.
84
+ *
85
+ * `useProvider` is currently computable from `rolesSource` (`Caller` implies false),
86
+ * but the two answer different questions - "which entity?" and "which roles?" - that
87
+ * merely coincide across these three intents. Keep both, and change behaviour here
88
+ * rather than at a call site.
89
+ */
90
+ const SIGNUP_POLICY: Record<
91
+ SignupIntent,
92
+ { useProvider: boolean; rolesSource: RolesSource }
93
+ > = {
94
+ [SignupIntent.SelfRegistration]: {
95
+ useProvider: true,
96
+ rolesSource: RolesSource.Default,
97
+ },
98
+ [SignupIntent.ExtensionModel]: {
99
+ useProvider: true,
100
+ rolesSource: RolesSource.Provider,
101
+ },
102
+ [SignupIntent.CoreUser]: {
103
+ useProvider: false,
104
+ rolesSource: RolesSource.Caller,
105
+ },
106
+ };
107
+
70
108
  @Injectable()
71
109
  export class AuthenticationService {
72
110
  private readonly logger = new Logger(AuthenticationService.name);
@@ -184,52 +222,65 @@ export class AuthenticationService {
184
222
  }
185
223
  }
186
224
 
187
- private static readonly SIGNUP_DTO_KEYS = new Set([
188
- "username",
189
- "email",
190
- "password",
191
- "fullName",
192
- "mobile",
193
- "roles",
194
- "forcePasswordChange",
195
- "isAllowedToGenerateApiKeys",
196
- "failedLoginAttempts",
197
- ]);
198
-
199
225
  async signUp(
200
226
  signUpDto: SignUpDto & Record<string, any>,
201
227
  activeUser: ActiveUserData = null,
228
+ intent: SignupIntent = SignupIntent.SelfRegistration,
202
229
  ): Promise<User> {
203
- const hasExtensionFields = Object.keys(signUpDto).some(
204
- (k) => !AuthenticationService.SIGNUP_DTO_KEYS.has(k),
230
+ const { useProvider, rolesSource } = SIGNUP_POLICY[intent];
231
+
232
+ if (intent === SignupIntent.SelfRegistration) {
233
+ this.assertPublicRegistrationEnabled();
234
+ }
235
+
236
+ const { entity, repo } = await this.userService.buildSignupTarget(
237
+ signUpDto,
238
+ useProvider,
205
239
  );
206
- if (hasExtensionFields) {
207
- const provider = this.solidRegistry.getExtensionUserCreationProvider();
208
- if (!provider) {
209
- throw new InternalServerErrorException(
210
- "No ExtensionUserCreationProvider registered. Register one to handle extension user creation.",
240
+ const roles = this.resolveSignupRoles(signUpDto, rolesSource);
241
+
242
+ return this.performSignUp({ ...signUpDto, roles }, entity, repo);
243
+ }
244
+
245
+ private resolveSignupRoles(
246
+ dto: Record<string, any>,
247
+ source: RolesSource,
248
+ ): string[] {
249
+ switch (source) {
250
+ case RolesSource.Provider:
251
+ // Sole authority. `dto.roles` is deliberately not read - not as a preference,
252
+ // not as a fallback. Reading it would skip roles(), which is where a provider
253
+ // validates its discriminator, and CreateUserDto types roles as
254
+ // UpdateRoleMetadataDto[] where performSignUp expects role-name strings.
255
+ return (
256
+ this.solidRegistry
257
+ .getExtensionUserCreationProvider()
258
+ ?.roles(dto as any) ?? []
211
259
  );
212
- }
213
- const entity = await provider.buildExtensionEntity(signUpDto);
214
- const effectiveDto = { ...signUpDto, roles: provider.roles(signUpDto) };
215
- return this.performSignUp(
216
- effectiveDto,
217
- entity,
218
- provider.repo as Repository<User>,
219
- true,
220
- );
260
+
261
+ case RolesSource.Caller:
262
+ return dto.roles ?? [];
263
+
264
+ case RolesSource.Default:
265
+ // Public signup: the form has no business naming roles. Returning empty lets
266
+ // performSignUp apply the configured `defaultRole`, rather than adding a
267
+ // second mechanism for the same thing.
268
+ if (dto.roles?.length) {
269
+ this.logger.warn(
270
+ `Ignoring caller-supplied roles on public registration for "${dto.username}"`,
271
+ );
272
+ }
273
+ return [];
221
274
  }
222
- return this.performSignUp(signUpDto, new User(), this.userRepository);
223
275
  }
224
276
 
225
277
  private async performSignUp<T extends User>(
226
278
  signUpDto: SignUpDto,
227
279
  entity: T,
228
280
  repo: Repository<T>,
229
- preferEntityApiKeyFlag: boolean = false,
230
281
  ): Promise<T> {
231
282
  try {
232
- await this.assertUniqueSignupIdentifiers(signUpDto, repo);
283
+ await this.assertUniqueSignupIdentifiers(signUpDto);
233
284
  await this.metadataValidationService.validateCreateDto("user", signUpDto);
234
285
 
235
286
  const onForcePasswordChange =
@@ -249,11 +300,11 @@ export class AuthenticationService {
249
300
  activateUserOnRegistration,
250
301
  onForcePasswordChange,
251
302
  );
303
+ // An explicitly supplied value wins over whatever the entity carries. The
304
+ // entity's own flag cannot be trusted as a signal: User initialises it to
305
+ // false, so "the provider set it" is indistinguishable from "nobody set it".
252
306
  const privateDto = signUpDto as { isAllowedToGenerateApiKeys?: boolean };
253
- if (
254
- !preferEntityApiKeyFlag &&
255
- privateDto.isAllowedToGenerateApiKeys !== undefined
256
- ) {
307
+ if (privateDto.isAllowedToGenerateApiKeys !== undefined) {
257
308
  user.isAllowedToGenerateApiKeys = privateDto.isAllowedToGenerateApiKeys;
258
309
  }
259
310
  const savedUser = await repo.save(user);
@@ -283,33 +334,41 @@ export class AuthenticationService {
283
334
  }
284
335
  }
285
336
 
286
- private async assertUniqueSignupIdentifiers<T extends User>(
337
+ /**
338
+ * Always queries the base `User` repository, never the caller's repository.
339
+ *
340
+ * `User` is a `@TableInheritance` root, so a child repository scopes every query
341
+ * to its own discriminator. Checking through one would only compare against users
342
+ * of the same subtype, and `email`/`mobile` carry non-unique `@Index()` - there is
343
+ * no database constraint behind them to catch what the query misses. A duplicate
344
+ * against a base `User` or a sibling subtype would be silently accepted.
345
+ */
346
+ private async assertUniqueSignupIdentifiers(
287
347
  signUpDto: SignUpDto,
288
- repo: Repository<T>,
289
348
  ): Promise<void> {
290
349
  const username = signUpDto.username?.trim();
291
350
  const email = signUpDto.email?.trim();
292
351
  const mobile = signUpDto.mobile?.trim();
293
352
 
294
- const where: FindOptionsWhere<T>[] = [];
353
+ const where: FindOptionsWhere<User>[] = [];
295
354
 
296
355
  if (username) {
297
- where.push({ username } as FindOptionsWhere<T>);
356
+ where.push({ username });
298
357
  }
299
358
 
300
359
  if (email) {
301
- where.push({ email } as FindOptionsWhere<T>);
360
+ where.push({ email });
302
361
  }
303
362
 
304
363
  if (mobile) {
305
- where.push({ mobile } as FindOptionsWhere<T>);
364
+ where.push({ mobile });
306
365
  }
307
366
 
308
367
  if (where.length === 0) {
309
368
  return;
310
369
  }
311
370
 
312
- const existingUser = await repo.findOne({ where });
371
+ const existingUser = await this.userRepository.findOne({ where });
313
372
 
314
373
  if (!existingUser) {
315
374
  return;
@@ -401,7 +460,12 @@ export class AuthenticationService {
401
460
  }
402
461
  user.username = signUpDto.username;
403
462
  user.email = signUpDto.email;
404
- user.fullName = signUpDto.fullName;
463
+ // `fullName` is optional on SignUpDto and the stock signup screen only sends it when
464
+ // showNameFieldsForRegistration is on, so assigning it unconditionally left every
465
+ // self-registered user with a null display name. `username` is always present -
466
+ // non-nullable on the entity and @IsNotEmpty() on the DTO - so it is a safe fallback.
467
+ // `||` rather than `??`: a form posting an empty string means "not supplied" too.
468
+ user.fullName = signUpDto.fullName?.trim() || signUpDto.username;
405
469
  user.forcePasswordChange = onForcePasswordChange;
406
470
  if (signUpDto.mobile) {
407
471
  user.mobile = signUpDto.mobile;
@@ -602,7 +666,32 @@ export class AuthenticationService {
602
666
  }
603
667
  }
604
668
 
669
+ /**
670
+ * Gates *self-service* account creation only - the public register endpoints, where
671
+ * an anonymous visitor creates their own account. Callers that create a user on
672
+ * someone else's behalf (the admin console, an extension model's CRUD form, the
673
+ * seeders) are deliberately unaffected, so turning this off does not disable user
674
+ * creation across the system.
675
+ *
676
+ * Compared against both representations because `getConfigValue` returns the raw
677
+ * cached value, which is a boolean when it comes from the provider default and a
678
+ * string once persisted or edited through the Settings screen - see the same
679
+ * defensive comparison for `mcpEnabled` in setting.service.ts. A plain falsy check
680
+ * would read `'false'` as truthy and never fire.
681
+ */
682
+ private assertPublicRegistrationEnabled(): void {
683
+ const allowPublicRegistration =
684
+ this.settingService.getConfigValue<SolidCoreSetting>(
685
+ "allowPublicRegistration",
686
+ );
687
+ if (allowPublicRegistration === false || allowPublicRegistration === "false") {
688
+ throw new ForbiddenException(ERROR_MESSAGES.PUBLIC_REGISTRATION_DISABLED);
689
+ }
690
+ }
691
+
605
692
  async otpInitiateRegistration(signUpDto: OTPSignUpDto) {
693
+ this.assertPublicRegistrationEnabled();
694
+
606
695
  const isPasswordlessRegistrationEnabled =
607
696
  await this.isPasswordlessRegistrationEnabled();
608
697
  if (!isPasswordlessRegistrationEnabled) {
@@ -682,32 +771,54 @@ export class AuthenticationService {
682
771
  signUpDto: OTPSignUpDto,
683
772
  validationSource: string,
684
773
  ): Promise<User> {
685
- let user = existingUser;
686
- if (isEmpty(user)) {
687
- user = this.createUser(signUpDto);
774
+ if (isEmpty(existingUser)) {
775
+ // A new registration is saved through whichever repository createUser resolved,
776
+ // which is the provider's when the app registers one.
777
+ const { entity: user, repo } = await this.createUser(signUpDto);
688
778
  user.active = false; // User will be activated only after OTP verification, hence setting active to false for new user.
689
779
  await this.assignRegistrationOtp(validationSource, user);
690
- await this.userRepository.save(user);
780
+ await repo.save(user);
691
781
  await this.userService.addRoleToUser(
692
782
  user.username,
693
783
  this.settingService.getConfigValue<SolidCoreSetting>("defaultRole"),
694
784
  );
695
- } else {
696
- await this.assignRegistrationOtp(validationSource, user);
697
- await this.userRepository.save(user);
785
+ return user;
698
786
  }
787
+
788
+ // An existing row is saved back through the base repository: `User` is the
789
+ // inheritance root, so TypeORM hydrated it as its own subclass on the way in and
790
+ // round-trips the discriminator on the way out.
791
+ const user = existingUser;
792
+ await this.assignRegistrationOtp(validationSource, user);
793
+ await this.userRepository.save(user);
699
794
  return user;
700
795
  }
701
796
 
702
- // Create a new user entity.
703
- private createUser(signUpDto: OTPSignUpDto) {
704
- const user = new User();
705
- user.username = signUpDto.username;
706
- user.email = signUpDto.email;
707
- user.mobile = signUpDto.mobile;
708
- user.customPayload = signUpDto.customPayload;
709
- user.lastLoginProvider = LoginProvider.OTP;
710
- return user;
797
+ /**
798
+ * Creates a new user entity for OTP registration - of whichever type this app
799
+ * registers its users as, since passwordless signup is self-registration like any
800
+ * other. Returns the repository alongside it, because a provider-built entity has
801
+ * to be saved through the provider's own repository.
802
+ */
803
+ private async createUser(
804
+ signUpDto: OTPSignUpDto,
805
+ ): Promise<{ entity: User; repo: Repository<User> }> {
806
+ const { useProvider } = SIGNUP_POLICY[SignupIntent.SelfRegistration];
807
+ const { entity, repo } = await this.userService.buildSignupTarget(
808
+ signUpDto,
809
+ useProvider,
810
+ );
811
+
812
+ entity.username = signUpDto.username;
813
+ entity.email = signUpDto.email;
814
+ entity.mobile = signUpDto.mobile;
815
+ // OTPSignUpDto declares no `fullName`, so username is the only source here. Mirrors
816
+ // the fallback in populateForSignup, which this path does not go through.
817
+ entity.fullName = signUpDto.username;
818
+ entity.customPayload = signUpDto.customPayload;
819
+ entity.lastLoginProvider = LoginProvider.OTP;
820
+
821
+ return { entity, repo };
711
822
  }
712
823
 
713
824
  // Generate the validation tokens for the user i.e (system configured + user provided)
@@ -3,6 +3,7 @@ import { DiscoveryService, ModuleRef } from "@nestjs/core";
3
3
  import { isArray } from "class-validator";
4
4
  import { CommonEntity } from "../entities/common.entity";
5
5
  import { User } from "../entities/user.entity";
6
+ import { SignupIntent } from "../enums/signup-intent.enum";
6
7
  import { SolidBaseRepository } from "../repository/solid-base.repository";
7
8
  import { SettingService } from "./setting.service";
8
9
  import { ERROR_MESSAGES } from "src/constants/error-messages";
@@ -113,7 +114,7 @@ export class CRUDService<T extends CommonEntity> { // Add two generic value i.e
113
114
  }
114
115
  const { AuthenticationService } = await import('./authentication.service');
115
116
  const authService = this.moduleRef.get(AuthenticationService, { strict: false });
116
- return authService.signUp(createDto) as unknown as T;
117
+ return authService.signUp(createDto, null, SignupIntent.ExtensionModel) as unknown as T;
117
118
  }
118
119
 
119
120
  async create(createDto: any, files: Express.Multer.File[] = [], solidRequestContext: any = {}): Promise<T> {
@@ -15,6 +15,7 @@ import { RoleMetadata } from "../entities/role-metadata.entity";
15
15
  import { User } from "../entities/user.entity";
16
16
  import { ActiveUserData } from "../interfaces/active-user-data.interface";
17
17
  import { ERROR_MESSAGES } from "src/constants/error-messages";
18
+ import { SolidRegistry } from "src/helpers/solid-registry";
18
19
  import { UserRepository } from "src/repository/user.repository";
19
20
  import { RoleMetadataRepository } from "src/repository/role-metadata.repository";
20
21
  import { HashingService } from "./hashing.service";
@@ -111,6 +112,39 @@ export class UserService extends CRUDService<User> {
111
112
  return super.deleteMany(ids, solidRequestContext);
112
113
  }
113
114
 
115
+ /**
116
+ * Resolves which entity a signup should build and which repository it should be
117
+ * saved through.
118
+ *
119
+ * When `useProvider` is set and the app registered an `IExtensionUserCreationProvider`,
120
+ * the provider builds the entity and owns the repository; otherwise a plain `User`
121
+ * is saved through the base repository. The caller decides `useProvider` from its
122
+ * `SignupIntent` - nothing here inspects the DTO.
123
+ *
124
+ * The registry is resolved lazily through `moduleRef` rather than injected, mirroring
125
+ * `CRUDService.tryCreateAsExtensionUser`, to keep this off the DI graph that
126
+ * AuthenticationService and CRUDService already form a cycle around.
127
+ */
128
+ async buildSignupTarget(
129
+ dto: Record<string, any>,
130
+ useProvider: boolean,
131
+ ): Promise<{ entity: User; repo: Repository<User> }> {
132
+ const provider = useProvider
133
+ ? this.moduleRef
134
+ .get(SolidRegistry, { strict: false })
135
+ ?.getExtensionUserCreationProvider()
136
+ : null;
137
+
138
+ if (!provider) {
139
+ return { entity: new User(), repo: this.repo };
140
+ }
141
+
142
+ return {
143
+ entity: await provider.buildExtensionEntity(dto as any),
144
+ repo: provider.repo as Repository<User>,
145
+ };
146
+ }
147
+
114
148
  async findOneByEmail(email: string): Promise<User> {
115
149
  return await this.repo.findOne({
116
150
  where: {
@@ -18,6 +18,18 @@ export interface TestingUserSpec {
18
18
  fullName?: string;
19
19
  mobile?: string;
20
20
  roles?: string[];
21
+ /**
22
+ * Seed this user through the registered IExtensionUserCreationProvider, making it
23
+ * an instance of the app's extension entity rather than a base `User`.
24
+ *
25
+ * Absent means a base `User`, so existing specs are unaffected. Set it on users
26
+ * that carry extension fields (`userType` and the like) - without it those fields
27
+ * are ignored and a plain `User` is created.
28
+ *
29
+ * `roles` is ignored on a flagged user: the provider derives them from the
30
+ * extension fields, exactly as it does on the model's own CRUD form.
31
+ */
32
+ isExtensionUser?: boolean;
21
33
  [key: string]: any;
22
34
  }
23
35