@solidxai/core 0.1.17-beta.0 → 0.1.17-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 (36) hide show
  1. package/CHANGELOG.md +1330 -0
  2. package/CLAUDE.md +26 -0
  3. package/dist/constants/media-file-types.d.ts +2 -2
  4. package/dist/constants/media-file-types.d.ts.map +1 -1
  5. package/dist/constants/media-file-types.js +24 -10
  6. package/dist/constants/media-file-types.js.map +1 -1
  7. package/dist/controllers/media.controller.d.ts.map +1 -1
  8. package/dist/controllers/media.controller.js +7 -2
  9. package/dist/controllers/media.controller.js.map +1 -1
  10. package/dist/controllers/user.controller.d.ts +1 -0
  11. package/dist/controllers/user.controller.d.ts.map +1 -1
  12. package/dist/controllers/user.controller.js +18 -2
  13. package/dist/controllers/user.controller.js.map +1 -1
  14. package/dist/services/authentication.service.d.ts +2 -4
  15. package/dist/services/authentication.service.d.ts.map +1 -1
  16. package/dist/services/authentication.service.js +8 -16
  17. package/dist/services/authentication.service.js.map +1 -1
  18. package/dist/services/settings/default-settings-provider.service.d.ts +20 -0
  19. package/dist/services/settings/default-settings-provider.service.d.ts.map +1 -1
  20. package/dist/services/settings/default-settings-provider.service.js +12 -0
  21. package/dist/services/settings/default-settings-provider.service.js.map +1 -1
  22. package/dist/services/user.service.d.ts +2 -0
  23. package/dist/services/user.service.d.ts.map +1 -1
  24. package/dist/services/user.service.js +58 -44
  25. package/dist/services/user.service.js.map +1 -1
  26. package/dist/solid-core.module.d.ts.map +1 -1
  27. package/dist/solid-core.module.js +5 -1
  28. package/dist/solid-core.module.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/constants/media-file-types.ts +76 -24
  31. package/src/controllers/media.controller.ts +18 -3
  32. package/src/controllers/user.controller.ts +37 -3
  33. package/src/services/authentication.service.ts +21 -19
  34. package/src/services/settings/default-settings-provider.service.ts +12 -0
  35. package/src/services/user.service.ts +101 -47
  36. package/src/solid-core.module.ts +12 -2
@@ -1,6 +1,6 @@
1
- import { Controller, Post, Body, Param, UploadedFiles, UseInterceptors, Put, Get, Query, Delete, Patch } from '@nestjs/common';
1
+ import { Controller, Post, Body, Param, UploadedFiles, UseInterceptors, Put, Get, Query, Delete, Patch, Logger } from '@nestjs/common';
2
2
  import { AnyFilesInterceptor } from "@nestjs/platform-express";
3
- import { ApiBearerAuth, ApiForbiddenResponse, ApiQuery, ApiTags } from '@nestjs/swagger';
3
+ import { ApiBearerAuth, ApiForbiddenResponse, ApiOperation, ApiQuery, ApiTags } from '@nestjs/swagger';
4
4
  import { UserService } from '../services/user.service';
5
5
  import { CreateUserDto } from '../dtos/create-user.dto';
6
6
  import { UpdateUserDto } from '../dtos/update-user.dto';
@@ -13,21 +13,55 @@ import { SolidRequestContextDto } from 'src/dtos/solid-request-context.dto';
13
13
  import { UpdateUserProfileDto } from 'src/dtos/update-user-profile.dto';
14
14
 
15
15
  @ApiTags('Solid Core')
16
- @Controller('user') //FIXME: Change this to the model plural name
16
+ @Controller('user') //FIXME: Change this to the model plural name
17
17
  export class UserController {
18
+ private readonly logger = new Logger(UserController.name);
19
+
18
20
  constructor(private readonly service: UserService) { }
19
21
 
22
+ /**
23
+ * @deprecated Bypasses `AuthenticationService.signUp`, so it never runs
24
+ * `initializeRolesForNewUser` - a user created here gets no role at all, not even
25
+ * the "Internal User" baseline, and none of the SignupIntent dispatch introduced
26
+ * alongside the extension-user provider work applies. Use `POST /iam/register-private`
27
+ * (an authenticated core user, with roles you name) or the extension model's own
28
+ * generated create endpoint instead.
29
+ *
30
+ * A workspace-wide search (frontends and backend callers across every consuming app
31
+ * in this monorepo) found no caller as of 2026-09-11. Kept for one deprecation cycle
32
+ * in case an external caller exists outside this workspace - the warning below is
33
+ * how that would surface - before being removed in a future major version.
34
+ */
35
+ @ApiOperation({
36
+ deprecated: true,
37
+ description: 'Deprecated: creates a user with no role assignment, not even the default. Use POST /iam/register-private, or the extension model\'s own create endpoint, instead. Scheduled for removal in a future major version.',
38
+ })
20
39
  @ApiBearerAuth("jwt")
21
40
  @Post()
22
41
  @UseInterceptors(AnyFilesInterceptor())
23
42
  create(@Body() createDto: CreateUserDto, @UploadedFiles() files: Array<Express.Multer.File>, @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) {
43
+ this.logger.warn(
44
+ `Deprecated endpoint POST /user was called (username="${createDto?.username}"). ` +
45
+ 'This bypasses role assignment entirely and is scheduled for removal. ' +
46
+ 'Use POST /iam/register-private or the extension model\'s own create endpoint.',
47
+ );
24
48
  return this.service.create(createDto, files, solidRequestContext);
25
49
  }
26
50
 
51
+ /** @deprecated See {@link create} - same gap, applied per row. */
52
+ @ApiOperation({
53
+ deprecated: true,
54
+ description: 'Deprecated: creates users with no role assignment, not even the default. Use the extension model\'s own bulk-create endpoint, or repeated calls to POST /iam/register-private, instead. Scheduled for removal in a future major version.',
55
+ })
27
56
  @ApiBearerAuth("jwt")
28
57
  @Post('/bulk')
29
58
  @UseInterceptors(AnyFilesInterceptor())
30
59
  insertMany(@Body() createDtos: CreateUserDto[], @UploadedFiles() filesArray: Express.Multer.File[][] = [], @SolidRequestContextDecorator() solidRequestContext: SolidRequestContextDto) {
60
+ this.logger.warn(
61
+ `Deprecated endpoint POST /user/bulk was called (count=${createDtos?.length ?? 0}, ` +
62
+ `usernames=${createDtos?.map(dto => dto.username).join(', ')}). ` +
63
+ 'This bypasses role assignment entirely and is scheduled for removal.',
64
+ );
31
65
  return this.service.insertMany(createDtos, filesArray, solidRequestContext);
32
66
  }
33
67
 
@@ -55,7 +55,6 @@ import { MetadataValidationService } from "./metadata-validation.service";
55
55
  import { UserService } from "./user.service";
56
56
  import { SmsFactory } from "src/factories/sms.factory";
57
57
  import { WhatsAppFactory } from "src/factories/whatsapp.factory";
58
- import { SolidRegistry } from "src/helpers/solid-registry";
59
58
 
60
59
  enum LoginProvider {
61
60
  LOCAL = "local",
@@ -150,7 +149,6 @@ export class AuthenticationService {
150
149
 
151
150
  @InjectDataSource()
152
151
  private readonly dataSource: DataSource,
153
- private readonly solidRegistry: SolidRegistry,
154
152
  ) {
155
153
  // this.mailService = this.mailServiceFactory.getMailService();
156
154
  }
@@ -281,11 +279,12 @@ export class AuthenticationService {
281
279
  // roles(), which is where a provider validates its discriminator, and
282
280
  // CreateUserDto types roles as UpdateRoleMetadataDto[] where performSignUp
283
281
  // expects role-name strings. [] here falls through to `defaultRole`.
284
- return (
285
- this.solidRegistry
286
- .getExtensionUserCreationProvider()
287
- ?.roles(dto as any) ?? []
288
- );
282
+ //
283
+ // Delegated to UserService.resolveProviderRoles - the one place that calls
284
+ // provider.roles() - rather than looking up the registry here too, which is
285
+ // what UserService.resolveSelfRegistrationRoles (the OAuth callers' equivalent
286
+ // of this switch) also needs and previously duplicated.
287
+ return this.userService.resolveProviderRoles(dto);
289
288
 
290
289
  case RolesSource.Caller:
291
290
  return dto.roles ?? [];
@@ -730,7 +729,7 @@ export class AuthenticationService {
730
729
  }
731
730
 
732
731
  try {
733
- const user = await this.upsertUserWithRegistrationVerificationTokens(
732
+ const user = await this.resolveUserForOtpRegistration(
734
733
  existingUser,
735
734
  signUpDto,
736
735
  validationSource,
@@ -787,7 +786,7 @@ export class AuthenticationService {
787
786
  );
788
787
  }
789
788
 
790
- private async upsertUserWithRegistrationVerificationTokens(
789
+ private async resolveUserForOtpRegistration(
791
790
  existingUser: User,
792
791
  signUpDto: OTPSignUpDto,
793
792
  validationSource: string,
@@ -808,16 +807,19 @@ export class AuthenticationService {
808
807
  await this.assignRegistrationOtp(validationSource, user);
809
808
  await repo.save(user);
810
809
 
811
- if (roles.length) {
812
- await this.userService.addRolesToUser(user.username, roles);
813
- } else {
814
- // The provider named none, or there is no provider: fall back to the configured
815
- // default, matching what performSignUp does on the password paths.
816
- await this.userService.addRoleToUser(
817
- user.username,
818
- this.settingService.getConfigValue<SolidCoreSetting>("defaultRole"),
819
- );
820
- }
810
+ // The provider named none, or there is no provider: fall back to the configured
811
+ // default, matching what performSignUp does on the password paths. Built as a
812
+ // fresh array rather than mutating `roles` - RolesSource.Provider can return
813
+ // whatever reference the provider's roles() handed back.
814
+ const defaultRole = this.settingService.getConfigValue<SolidCoreSetting>("defaultRole");
815
+ const effectiveRoles = roles.length ? roles : [defaultRole].filter(Boolean);
816
+
817
+ // initializeRolesForNewUser always grants "Internal User" - the baseline every
818
+ // other signup path gets via handlePostSignup - which this branch previously
819
+ // skipped entirely by calling addRolesToUser/addRoleToUser directly. Without it
820
+ // an OTP-registered user could not even read their own User record: the
821
+ // "Internal User" role is what carries that security rule.
822
+ await this.userService.initializeRolesForNewUser(effectiveRoles, user);
821
823
  return user;
822
824
  }
823
825
 
@@ -7,6 +7,7 @@ import {
7
7
  SettingLevel,
8
8
  } from "src/interfaces";
9
9
  import { getDefaultThemeKey, getThemesByMode } from "src/theme/theme-registry";
10
+ import { DANGEROUS_EXTENSIONS } from "src/constants/media-file-types";
10
11
 
11
12
  export const DEFAULT_MEDIA_UPLOAD_DIR = "media-uploads";
12
13
  export const DEFAULT_MEDIA_FILE_STORAGE_DIR = "media-files-storage";
@@ -546,6 +547,17 @@ const getSolidCoreSettings = (isProd: boolean) =>
546
547
  controlType: "numeric",
547
548
  helpText: "Global upload size limit enforced by Multer under every per-field mediaMaxSizeKb restriction. Read-only here: it's applied once at process start, so changing AB_MEDIA_MAX_FILE_SIZE_MB requires an app restart to take effect - editing this in the admin UI would not do that.",
548
549
  },
550
+ {
551
+ moduleName: "solid-core",
552
+ key: "dangerousExtensions",
553
+ value: [...DANGEROUS_EXTENSIONS].join(','),
554
+ level: SettingLevel.SystemAdminReadonly,
555
+ label: "Blocked Upload Extensions",
556
+ group: "storage-settings",
557
+ sortOrder: 46,
558
+ controlType: "shortText",
559
+ helpText: "File extensions rejected on every upload path, regardless of field configuration. Read-only here: it's applied once at process start. To change it, copy this value into AB_DANGEROUS_EXTENSIONS, edit it, and restart - note that variable REPLACES this list rather than adding to it, so any extension you leave out becomes an accepted upload type.",
560
+ },
549
561
 
550
562
  // aws-s3-settings-provider.service.ts
551
563
  {
@@ -145,6 +145,49 @@ export class UserService extends CRUDService<User> {
145
145
  };
146
146
  }
147
147
 
148
+ /**
149
+ * Asks the registered extension-user provider for role names, treating "no provider
150
+ * registered" and "provider named none for this DTO" identically as [].
151
+ *
152
+ * The one place that actually calls `IExtensionUserCreationProvider.roles()`.
153
+ * `resolveSelfRegistrationRoles` below and `AuthenticationService.resolveSignupRoles`'s
154
+ * `Provider` branch both delegate here rather than each re-deriving "look up the
155
+ * registry, call roles(), default to []". Lives on `UserService` - not on
156
+ * `SolidRegistry`, which stays a pure lookup/storage layer for every provider kind it
157
+ * holds - because `AuthenticationService` already injects `UserService` directly
158
+ * (mirrors `buildSignupTarget`, and the DI cycle runs the other way: `UserService`
159
+ * cannot inject `AuthenticationService` back).
160
+ */
161
+ resolveProviderRoles(dto: Record<string, any>): string[] {
162
+ return (
163
+ this.moduleRef
164
+ .get(SolidRegistry, { strict: false })
165
+ ?.getExtensionUserCreationProvider()
166
+ ?.roles(dto as any) ?? []
167
+ );
168
+ }
169
+
170
+ /**
171
+ * Role names for a self-provisioning user, mirroring SignupIntent.SelfRegistration's
172
+ * policy on the password/OTP paths: ask the extension-user provider first, and fall
173
+ * back to the configured `defaultRole` when it names none - either because there is
174
+ * no provider, or the provider declines to name any for this DTO.
175
+ *
176
+ * Safe to call for OAuth even though `OauthUserDto` carries no discriminator field:
177
+ * a provider whose `roles()` requires one already needs to default it for public
178
+ * register/OTP to work at all (see the extending-users docs), and once it does,
179
+ * this resolves the same way automatically - there is no separate obligation OAuth
180
+ * places on providers beyond what self-registration already requires.
181
+ */
182
+ private resolveSelfRegistrationRoles(dto: Record<string, any>): string[] {
183
+ const roles = this.resolveProviderRoles(dto);
184
+ if (roles.length) {
185
+ return roles;
186
+ }
187
+ const defaultRole = this.settingService.getConfigValue<SolidCoreSetting>("defaultRole");
188
+ return defaultRole ? [defaultRole] : [];
189
+ }
190
+
148
191
  async findOneByEmail(email: string): Promise<User> {
149
192
  return await this.repo.findOne({
150
193
  where: {
@@ -315,21 +358,26 @@ export class UserService extends CRUDService<User> {
315
358
 
316
359
  // if we are unable to find a user then we need to create one.
317
360
  if (!user) {
318
- const user = new User();
319
- user.username = oauthUserDto.email;
320
- user.email = oauthUserDto.email;
321
- user.fullName = oauthUserDto.name;
322
- user.lastLoginProvider = oauthUserDto.provider;
323
- user.accessCode = oauthUserDto.accessCode;
324
- user.googleAccessToken = oauthUserDto.accessToken;
325
- user.googleId = oauthUserDto.providerId;
326
- user.googleProfilePicture = oauthUserDto.picture;
327
-
328
- const savedUser = await this.repo.save(user);
361
+ // Social sign-in provisions an app user, so it is built and roled the same way
362
+ // public signup and OTP registration are: entity through the registered
363
+ // extension-user provider when there is one (see buildSignupTarget), roles
364
+ // through resolveSelfRegistrationRoles below, which asks that same provider
365
+ // first and falls back to `defaultRole`.
366
+ const { entity, repo } = await this.buildSignupTarget(oauthUserDto, true);
367
+ entity.username = oauthUserDto.email;
368
+ entity.email = oauthUserDto.email;
369
+ entity.fullName = oauthUserDto.name;
370
+ entity.lastLoginProvider = oauthUserDto.provider;
371
+ entity.accessCode = oauthUserDto.accessCode;
372
+ entity.googleAccessToken = oauthUserDto.accessToken;
373
+ entity.googleId = oauthUserDto.providerId;
374
+ entity.googleProfilePicture = oauthUserDto.picture;
375
+
376
+ const savedUser = await repo.save(entity);
329
377
 
330
378
  // Initialize the user roles
331
379
  await this.initializeRolesForNewUser(
332
- [this.settingService.getConfigValue<SolidCoreSetting>("defaultRole")],
380
+ this.resolveSelfRegistrationRoles(oauthUserDto),
333
381
  savedUser,
334
382
  );
335
383
  }
@@ -390,20 +438,22 @@ export class UserService extends CRUDService<User> {
390
438
  // facebookProviderFallback,
391
439
  );
392
440
 
393
- const newUser = new User();
394
- newUser.username = username;
395
- newUser.email = email;
396
- newUser.fullName = oauthUserDto.name;
397
- newUser.lastLoginProvider = oauthUserDto.provider;
398
- newUser.accessCode = oauthUserDto.accessCode;
399
- newUser.facebookAccessToken = oauthUserDto.accessToken;
400
- newUser.facebookId = oauthUserDto.providerId;
401
- newUser.facebookProfilePicture = oauthUserDto.picture;
441
+ // See resolveUserOnOauthGoogle for why entity and roles both go through the
442
+ // extension-user provider first, falling back to a plain User / `defaultRole`.
443
+ const { entity, repo } = await this.buildSignupTarget(oauthUserDto, true);
444
+ entity.username = username;
445
+ entity.email = email;
446
+ entity.fullName = oauthUserDto.name;
447
+ entity.lastLoginProvider = oauthUserDto.provider;
448
+ entity.accessCode = oauthUserDto.accessCode;
449
+ entity.facebookAccessToken = oauthUserDto.accessToken;
450
+ entity.facebookId = oauthUserDto.providerId;
451
+ entity.facebookProfilePicture = oauthUserDto.picture;
402
452
 
403
- const savedUser = await this.repo.save(newUser);
453
+ const savedUser = await repo.save(entity);
404
454
 
405
455
  await this.initializeRolesForNewUser(
406
- [this.settingService.getConfigValue<SolidCoreSetting>("defaultRole")],
456
+ this.resolveSelfRegistrationRoles(oauthUserDto),
407
457
  savedUser,
408
458
  );
409
459
  return savedUser;
@@ -432,20 +482,22 @@ export class UserService extends CRUDService<User> {
432
482
  });
433
483
 
434
484
  if (!user) {
435
- const newUser = new User();
436
- newUser.username = oauthUserDto.email;
437
- newUser.email = oauthUserDto.email;
438
- newUser.fullName = oauthUserDto.name;
439
- newUser.lastLoginProvider = oauthUserDto.provider;
440
- newUser.accessCode = oauthUserDto.accessCode;
441
- newUser.microsoftAccessToken = oauthUserDto.accessToken;
442
- newUser.microsoftId = oauthUserDto.providerId;
443
- newUser.microsoftProfilePicture = oauthUserDto.picture;
444
-
445
- const savedUser = await this.repo.save(newUser);
485
+ // See resolveUserOnOauthGoogle for why entity and roles both go through the
486
+ // extension-user provider first, falling back to a plain User / `defaultRole`.
487
+ const { entity, repo } = await this.buildSignupTarget(oauthUserDto, true);
488
+ entity.username = oauthUserDto.email;
489
+ entity.email = oauthUserDto.email;
490
+ entity.fullName = oauthUserDto.name;
491
+ entity.lastLoginProvider = oauthUserDto.provider;
492
+ entity.accessCode = oauthUserDto.accessCode;
493
+ entity.microsoftAccessToken = oauthUserDto.accessToken;
494
+ entity.microsoftId = oauthUserDto.providerId;
495
+ entity.microsoftProfilePicture = oauthUserDto.picture;
496
+
497
+ const savedUser = await repo.save(entity);
446
498
 
447
499
  await this.initializeRolesForNewUser(
448
- [this.settingService.getConfigValue<SolidCoreSetting>("defaultRole")],
500
+ this.resolveSelfRegistrationRoles(oauthUserDto),
449
501
  savedUser,
450
502
  );
451
503
  } else {
@@ -476,20 +528,22 @@ export class UserService extends CRUDService<User> {
476
528
  });
477
529
 
478
530
  if (!user) {
479
- const newUser = new User();
480
- newUser.username = oauthUserDto.email;
481
- newUser.email = oauthUserDto.email;
482
- newUser.fullName = oauthUserDto.name;
483
- newUser.lastLoginProvider = oauthUserDto.provider;
484
- newUser.accessCode = oauthUserDto.accessCode;
485
- newUser.microsoftActiveDirectoryAccessToken = oauthUserDto.accessToken;
486
- newUser.microsoftActiveDirectoryId = oauthUserDto.providerId;
487
- newUser.microsoftActiveDirectoryProfilePicture = oauthUserDto.picture;
488
-
489
- const savedUser = await this.repo.save(newUser);
531
+ // See resolveUserOnOauthGoogle for why entity and roles both go through the
532
+ // extension-user provider first, falling back to a plain User / `defaultRole`.
533
+ const { entity, repo } = await this.buildSignupTarget(oauthUserDto, true);
534
+ entity.username = oauthUserDto.email;
535
+ entity.email = oauthUserDto.email;
536
+ entity.fullName = oauthUserDto.name;
537
+ entity.lastLoginProvider = oauthUserDto.provider;
538
+ entity.accessCode = oauthUserDto.accessCode;
539
+ entity.microsoftActiveDirectoryAccessToken = oauthUserDto.accessToken;
540
+ entity.microsoftActiveDirectoryId = oauthUserDto.providerId;
541
+ entity.microsoftActiveDirectoryProfilePicture = oauthUserDto.picture;
542
+
543
+ const savedUser = await repo.save(entity);
490
544
 
491
545
  await this.initializeRolesForNewUser(
492
- [this.settingService.getConfigValue<SolidCoreSetting>("defaultRole")],
546
+ this.resolveSelfRegistrationRoles(oauthUserDto),
493
547
  savedUser,
494
548
  );
495
549
  } else {
@@ -541,17 +541,27 @@ import { SwitchNode } from './services/workflow/nodes/switch.node';
541
541
  res.setHeader("X-Content-Type-Options", "nosniff");
542
542
 
543
543
  // Only a small allowlist of media types is ever safe to render inline. Everything
544
- // else (including svg, html, and any other uploaded file) is forced to download
544
+ // else (including html and any other uploaded file) is forced to download
545
545
  // rather than be displayed/executed by the browser, regardless of what mimetype or
546
546
  // extension it was uploaded with.
547
547
  // basename first: getLowercaseFileExtension takes a file name, not a path, so a
548
548
  // directory containing a dot must not be mistaken for the extension. An
549
549
  // extensionless file yields undefined, which is not inline-safe - so it correctly
550
550
  // falls through to the forced-download branch.
551
- if (!INLINE_SAFE_EXTENSIONS.has(getLowercaseFileExtension(basename(path)) ?? '')) {
551
+ const ext = getLowercaseFileExtension(basename(path)) ?? '';
552
+ if (!INLINE_SAFE_EXTENSIONS.has(ext)) {
552
553
  res.setHeader("Content-Type", "application/octet-stream");
553
554
  res.setHeader("Content-Disposition", "attachment");
554
555
  }
556
+
557
+ // svg is only ever inline-safe (see INLINE_SAFE_EXTENSIONS) once it can also render
558
+ // as an active document (<script>, event handlers). <img>/CSS never execute it, but
559
+ // a direct navigation to this URL would - so pin it to a locked-down document: no
560
+ // script, no network, and `sandbox` gives it an opaque origin so even a parser bypass
561
+ // can't reach app cookies or storage.
562
+ if (ext === 'svg') {
563
+ res.setHeader("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; img-src data:; sandbox");
564
+ }
555
565
  },
556
566
  },
557
567
  }),