@jskit-ai/users-core 0.1.31 → 0.1.33

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 (55) hide show
  1. package/package.descriptor.mjs +21 -19
  2. package/package.json +6 -6
  3. package/src/server/UsersCoreServiceProvider.js +1 -3
  4. package/src/server/accountNotifications/accountNotificationsService.js +3 -3
  5. package/src/server/accountNotifications/registerAccountNotifications.js +1 -1
  6. package/src/server/accountPreferences/accountPreferencesService.js +3 -3
  7. package/src/server/accountPreferences/registerAccountPreferences.js +1 -1
  8. package/src/server/accountProfile/accountProfileActions.js +8 -2
  9. package/src/server/accountProfile/accountProfileService.js +10 -10
  10. package/src/server/accountProfile/avatarService.js +26 -67
  11. package/src/server/accountProfile/avatarStorageService.js +14 -95
  12. package/src/server/accountProfile/bootAccountProfileRoutes.js +13 -15
  13. package/src/server/accountProfile/registerAccountProfile.js +2 -2
  14. package/src/server/accountSecurity/accountSecurityService.js +3 -3
  15. package/src/server/accountSecurity/registerAccountSecurity.js +1 -1
  16. package/src/server/common/contributors/workspaceActionContextContributor.js +24 -17
  17. package/src/server/common/formatters/workspaceFormatter.js +2 -2
  18. package/src/server/common/registerCommonRepositories.js +3 -3
  19. package/src/server/common/repositories/{userProfilesRepository.js → usersRepository.js} +7 -7
  20. package/src/server/common/repositories/workspaceInvitesRepository.js +2 -2
  21. package/src/server/common/repositories/workspaceMembershipsRepository.js +9 -9
  22. package/src/server/common/repositories/workspacesRepository.js +2 -2
  23. package/src/server/common/services/accountContextService.js +4 -4
  24. package/src/server/common/services/authProfileSyncService.js +15 -15
  25. package/src/server/common/services/workspaceContextService.js +3 -3
  26. package/src/server/common/validators/authenticatedUserValidator.js +2 -2
  27. package/src/server/registerWorkspaceBootstrap.js +1 -1
  28. package/src/server/registerWorkspaceCore.js +5 -2
  29. package/src/server/workspaceBootstrapContributor.js +6 -6
  30. package/src/server/workspaceMembers/bootWorkspaceMembers.js +2 -2
  31. package/src/server/workspaceMembers/workspaceMembersActions.js +2 -2
  32. package/src/server/workspaceMembers/workspaceMembersService.js +11 -11
  33. package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +1 -1
  34. package/src/shared/resources/workspaceMembersResource.js +11 -11
  35. package/src/shared/resources/workspacePendingInvitationsResource.js +2 -2
  36. package/src/shared/resources/workspaceResource.js +2 -2
  37. package/src/shared/roles.js +37 -12
  38. package/templates/config/roles.js +27 -0
  39. package/templates/migrations/users_core_initial.cjs +5 -5
  40. package/test/authProfileSyncService.test.js +8 -8
  41. package/test/avatarService.test.js +6 -6
  42. package/test/roles.test.js +90 -5
  43. package/test/usersRouteRequestInputValidator.test.js +4 -4
  44. package/test/workspaceActionContextContributor.test.js +107 -14
  45. package/test/workspaceAuthPolicyContextResolver.test.js +2 -2
  46. package/test/workspaceBootstrapContributor.test.js +8 -8
  47. package/test/workspaceInvitesRepository.test.js +3 -3
  48. package/test/workspaceMembersService.test.js +14 -12
  49. package/test/workspacePendingInvitationsResource.test.js +2 -2
  50. package/test/workspacePendingInvitationsService.test.js +3 -3
  51. package/test/workspaceService.test.js +22 -18
  52. package/test/workspaceSettingsResource.test.js +4 -2
  53. package/src/server/accountProfile/registerAvatarMultipartSupport.js +0 -40
  54. package/templates/config/workspaceRoles.js +0 -30
  55. package/test/registerAvatarMultipartSupport.test.js +0 -63
@@ -1,13 +1,14 @@
1
1
  export default Object.freeze({
2
2
  packageVersion: 1,
3
3
  packageId: "@jskit-ai/users-core",
4
- version: "0.1.31",
4
+ version: "0.1.33",
5
5
  kind: "runtime",
6
6
  description: "Users/workspace domain runtime plus HTTP routes for workspace, account, and console features.",
7
7
  dependsOn: [
8
8
  "@jskit-ai/auth-core",
9
9
  "@jskit-ai/database-runtime",
10
10
  "@jskit-ai/http-runtime",
11
+ "@jskit-ai/uploads-runtime",
11
12
  "@jskit-ai/storage-runtime"
12
13
  ],
13
14
  capabilities: {
@@ -19,6 +20,7 @@ export default Object.freeze({
19
20
  "runtime.actions",
20
21
  "runtime.database",
21
22
  "runtime.storage",
23
+ "runtime.uploads",
22
24
  "auth.provider",
23
25
  "auth.policy"
24
26
  ]
@@ -93,7 +95,7 @@ export default Object.freeze({
93
95
  {
94
96
  method: "GET",
95
97
  path: "/api/w/:workspaceSlug/roles",
96
- summary: "Get workspace role catalog by workspace slug."
98
+ summary: "Get role catalog by workspace slug."
97
99
  },
98
100
  {
99
101
  method: "GET",
@@ -196,11 +198,11 @@ export default Object.freeze({
196
198
  mutations: {
197
199
  dependencies: {
198
200
  runtime: {
199
- "@jskit-ai/auth-core": "0.1.21",
200
- "@jskit-ai/database-runtime": "0.1.22",
201
- "@jskit-ai/http-runtime": "0.1.21",
202
- "@jskit-ai/kernel": "0.1.22",
203
- "@fastify/multipart": "^9.4.0",
201
+ "@jskit-ai/auth-core": "0.1.23",
202
+ "@jskit-ai/database-runtime": "0.1.24",
203
+ "@jskit-ai/http-runtime": "0.1.23",
204
+ "@jskit-ai/kernel": "0.1.24",
205
+ "@jskit-ai/uploads-runtime": "0.1.2",
204
206
  "@fastify/type-provider-typebox": "^6.1.0",
205
207
  "typebox": "^1.0.81"
206
208
  },
@@ -288,12 +290,12 @@ export default Object.freeze({
288
290
  id: "users-core-app-owned-user-settings-fields"
289
291
  },
290
292
  {
291
- from: "templates/config/workspaceRoles.js",
292
- to: "config/workspaceRoles.js",
293
+ from: "templates/config/roles.js",
294
+ to: "config/roles.js",
293
295
  preserveOnRemove: true,
294
- reason: "Install app-owned workspace role catalog in a dedicated config file.",
296
+ reason: "Install app-owned role catalog in a dedicated config file.",
295
297
  category: "users-core",
296
- id: "users-core-app-owned-workspace-roles-config"
298
+ id: "users-core-app-owned-role-catalog-config"
297
299
  }
298
300
  ],
299
301
  text: [
@@ -360,11 +362,11 @@ export default Object.freeze({
360
362
  op: "append-text",
361
363
  file: "config/public.js",
362
364
  position: "top",
363
- skipIfContains: "import { workspaceRoles } from \"./workspaceRoles.js\";",
364
- value: "import { workspaceRoles } from \"./workspaceRoles.js\";\n",
365
- reason: "Load app-owned workspace role catalog from dedicated config file.",
365
+ skipIfContains: "import { roleCatalog } from \"./roles.js\";",
366
+ value: "import { roleCatalog } from \"./roles.js\";\n",
367
+ reason: "Load app-owned role catalog from dedicated config file.",
366
368
  category: "users-core",
367
- id: "users-core-workspace-roles-public-import"
369
+ id: "users-core-role-catalog-public-import"
368
370
  },
369
371
  {
370
372
  op: "append-text",
@@ -426,11 +428,11 @@ export default Object.freeze({
426
428
  op: "append-text",
427
429
  file: "config/public.js",
428
430
  position: "bottom",
429
- skipIfContains: "config.workspaceRoles = workspaceRoles;",
430
- value: "\nconfig.workspaceRoles = workspaceRoles;\n",
431
- reason: "Bind app-owned workspace role catalog onto public config.",
431
+ skipIfContains: "config.roleCatalog = roleCatalog;",
432
+ value: "\nconfig.roleCatalog = roleCatalog;\n",
433
+ reason: "Bind app-owned role catalog onto public config.",
432
434
  category: "users-core",
433
- id: "users-core-workspace-roles-public-config"
435
+ id: "users-core-role-catalog-public-config"
434
436
  },
435
437
  {
436
438
  op: "append-text",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/users-core",
3
- "version": "0.1.31",
3
+ "version": "0.1.33",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -24,11 +24,11 @@
24
24
  "./shared/resources/consoleSettingsFields": "./src/shared/resources/consoleSettingsFields.js"
25
25
  },
26
26
  "dependencies": {
27
- "@jskit-ai/auth-core": "0.1.21",
28
- "@jskit-ai/database-runtime": "0.1.22",
29
- "@jskit-ai/http-runtime": "0.1.21",
30
- "@jskit-ai/kernel": "0.1.22",
31
- "@fastify/multipart": "^9.4.0",
27
+ "@jskit-ai/auth-core": "0.1.23",
28
+ "@jskit-ai/database-runtime": "0.1.24",
29
+ "@jskit-ai/http-runtime": "0.1.23",
30
+ "@jskit-ai/kernel": "0.1.24",
31
+ "@jskit-ai/uploads-runtime": "0.1.2",
32
32
  "@fastify/type-provider-typebox": "^6.1.0",
33
33
  "typebox": "^1.0.81"
34
34
  }
@@ -23,13 +23,12 @@ import { registerAccountNotifications } from "./accountNotifications/registerAcc
23
23
  import { registerAccountProfile } from "./accountProfile/registerAccountProfile.js";
24
24
  import { registerAccountSecurity } from "./accountSecurity/registerAccountSecurity.js";
25
25
  import { registerConsoleSettings } from "./consoleSettings/registerConsoleSettings.js";
26
- import { registerAvatarMultipartSupport } from "./accountProfile/registerAvatarMultipartSupport.js";
27
26
  import { registerUsersCoreActionSurfaceSources } from "./support/workspaceActionSurfaces.js";
28
27
 
29
28
  class UsersCoreServiceProvider {
30
29
  static id = "users.core";
31
30
 
32
- static dependsOn = ["runtime.server", "runtime.actions", "runtime.database", "runtime.storage", "auth.provider"];
31
+ static dependsOn = ["runtime.server", "runtime.actions", "runtime.database", "runtime.storage", "auth.provider", "runtime.uploads"];
33
32
 
34
33
  register(app) {
35
34
  registerUsersCoreActionSurfaceSources(app);
@@ -58,7 +57,6 @@ class UsersCoreServiceProvider {
58
57
  bootWorkspaceSettings(app);
59
58
  bootWorkspaceMembers(app);
60
59
  }
61
- await registerAvatarMultipartSupport(app);
62
60
  bootAccountProfileRoutes(app);
63
61
  bootAccountPreferencesRoutes(app);
64
62
  bootAccountNotificationsRoutes(app);
@@ -9,15 +9,15 @@ import {
9
9
 
10
10
  function createService({
11
11
  userSettingsRepository,
12
- userProfilesRepository,
12
+ usersRepository,
13
13
  authService
14
14
  } = {}) {
15
- if (!userSettingsRepository || !userProfilesRepository) {
15
+ if (!userSettingsRepository || !usersRepository) {
16
16
  throw new Error("accountNotificationsService requires repositories.");
17
17
  }
18
18
 
19
19
  async function updateNotifications(request, user, payload = {}, options = {}) {
20
- const profile = await resolveUserProfile(userProfilesRepository, user);
20
+ const profile = await resolveUserProfile(usersRepository, user);
21
21
  if (!profile) {
22
22
  throw new AppError(404, "User profile was not found.");
23
23
  }
@@ -14,7 +14,7 @@ function registerAccountNotifications(app) {
14
14
  (scope) =>
15
15
  createAccountNotificationsService({
16
16
  userSettingsRepository: scope.make("userSettingsRepository"),
17
- userProfilesRepository: scope.make("userProfilesRepository"),
17
+ usersRepository: scope.make("usersRepository"),
18
18
  authService: scope.make("authService")
19
19
  }),
20
20
  {
@@ -9,15 +9,15 @@ import {
9
9
 
10
10
  function createService({
11
11
  userSettingsRepository,
12
- userProfilesRepository,
12
+ usersRepository,
13
13
  authService
14
14
  } = {}) {
15
- if (!userSettingsRepository || !userProfilesRepository) {
15
+ if (!userSettingsRepository || !usersRepository) {
16
16
  throw new Error("accountPreferencesService requires repositories.");
17
17
  }
18
18
 
19
19
  async function updatePreferences(request, user, payload = {}, options = {}) {
20
- const profile = await resolveUserProfile(userProfilesRepository, user);
20
+ const profile = await resolveUserProfile(usersRepository, user);
21
21
  if (!profile) {
22
22
  throw new AppError(404, "User profile was not found.");
23
23
  }
@@ -14,7 +14,7 @@ function registerAccountPreferences(app) {
14
14
  (scope) =>
15
15
  createAccountPreferencesService({
16
16
  userSettingsRepository: scope.make("userSettingsRepository"),
17
- userProfilesRepository: scope.make("userProfilesRepository"),
17
+ usersRepository: scope.make("usersRepository"),
18
18
  authService: scope.make("authService")
19
19
  }),
20
20
  {
@@ -95,10 +95,17 @@ const accountProfileActions = Object.freeze([
95
95
  },
96
96
  observability: {},
97
97
  async execute(input, context, deps) {
98
+ const avatarUpload = {
99
+ stream: input.stream,
100
+ mimeType: input.mimeType,
101
+ fileName: input.fileName,
102
+ uploadDimension: input.uploadDimension
103
+ };
104
+
98
105
  return deps.accountProfileService.uploadAvatar(
99
106
  resolveRequest(context),
100
107
  resolveActionUser(context, input),
101
- input,
108
+ avatarUpload,
102
109
  {
103
110
  context
104
111
  }
@@ -125,7 +132,6 @@ const accountProfileActions = Object.freeze([
125
132
  return deps.accountProfileService.deleteAvatar(
126
133
  resolveRequest(context),
127
134
  resolveActionUser(context, input),
128
- input,
129
135
  {
130
136
  context
131
137
  }
@@ -9,16 +9,16 @@ import {
9
9
 
10
10
  function createService({
11
11
  userSettingsRepository,
12
- userProfilesRepository,
12
+ usersRepository,
13
13
  authService,
14
14
  avatarService
15
15
  } = {}) {
16
- if (!userSettingsRepository || !userProfilesRepository || !avatarService) {
16
+ if (!userSettingsRepository || !usersRepository || !avatarService) {
17
17
  throw new Error("accountProfileService requires repositories and avatarService.");
18
18
  }
19
19
 
20
20
  async function getForUser(request, user, options = {}) {
21
- const profile = await resolveUserProfile(userProfilesRepository, user);
21
+ const profile = await resolveUserProfile(usersRepository, user);
22
22
  if (!profile) {
23
23
  throw new AppError(404, "User profile was not found.");
24
24
  }
@@ -35,7 +35,7 @@ function createService({
35
35
  }
36
36
 
37
37
  async function updateProfile(request, user, payload = {}, options = {}) {
38
- const profile = await resolveUserProfile(userProfilesRepository, user);
38
+ const profile = await resolveUserProfile(usersRepository, user);
39
39
  if (!profile) {
40
40
  throw new AppError(404, "User profile was not found.");
41
41
  }
@@ -49,7 +49,7 @@ function createService({
49
49
  }
50
50
 
51
51
  if (!updatedProfile) {
52
- updatedProfile = await userProfilesRepository.updateDisplayNameById(profile.id, payload.displayName);
52
+ updatedProfile = await usersRepository.updateDisplayNameById(profile.id, payload.displayName);
53
53
  }
54
54
 
55
55
  const settings = await userSettingsRepository.ensureForUserId(updatedProfile.id);
@@ -66,11 +66,11 @@ function createService({
66
66
  };
67
67
  }
68
68
 
69
- async function uploadAvatar(request, user, payload = {}, options = {}) {
69
+ async function uploadAvatar(request, user, avatarUpload = {}, options = {}) {
70
70
  void options;
71
71
 
72
- const avatarUpload = await avatarService.uploadForUser(user, payload);
73
- const profile = avatarUpload?.profile || null;
72
+ const result = await avatarService.uploadForUser(user, avatarUpload);
73
+ const profile = result?.profile || null;
74
74
  if (!profile) {
75
75
  throw new AppError(500, "Avatar upload completed without a profile result.");
76
76
  }
@@ -86,7 +86,7 @@ function createService({
86
86
  });
87
87
  }
88
88
 
89
- async function deleteAvatar(request, user, _payload = {}, options = {}) {
89
+ async function deleteAvatar(request, user, options = {}) {
90
90
  void options;
91
91
 
92
92
  const profile = await avatarService.clearForUser(user);
@@ -101,7 +101,7 @@ function createService({
101
101
  });
102
102
  }
103
103
 
104
- async function readAvatar(_request, user, _payload = {}, options = {}) {
104
+ async function readAvatar(_request, user, options = {}) {
105
105
  void options;
106
106
 
107
107
  const avatar = await avatarService.readForUser(user);
@@ -1,68 +1,29 @@
1
- import { AppError, createValidationError } from "@jskit-ai/kernel/server/runtime/errors";
1
+ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
+ import { DEFAULT_IMAGE_UPLOAD_POLICY } from "@jskit-ai/uploads-runtime/shared";
3
+ import {
4
+ normalizeUploadPolicy,
5
+ readUploadBuffer,
6
+ validateUploadMimeType
7
+ } from "@jskit-ai/uploads-runtime/server/policy/uploadPolicy";
2
8
  import { resolveUserProfile } from "../common/services/accountContextService.js";
3
9
 
4
- const DEFAULT_AVATAR_POLICY = Object.freeze({
5
- allowedMimeTypes: Object.freeze(["image/jpeg", "image/png", "image/webp"]),
6
- maxUploadBytes: 5 * 1024 * 1024
7
- });
10
+ const DEFAULT_AVATAR_POLICY = DEFAULT_IMAGE_UPLOAD_POLICY;
8
11
 
9
12
  function resolveAvatarPolicy(policy = {}) {
10
- const source = policy && typeof policy === "object" ? policy : {};
11
- const allowedMimeTypes =
12
- Array.isArray(source.allowedMimeTypes) && source.allowedMimeTypes.length > 0
13
- ? source.allowedMimeTypes
14
- .map((value) => String(value || "").trim().toLowerCase())
15
- .filter((value) => value.length > 0)
16
- : [...DEFAULT_AVATAR_POLICY.allowedMimeTypes];
17
- const normalizedMaxUploadBytes = Number(source.maxUploadBytes);
18
- const maxUploadBytes =
19
- Number.isInteger(normalizedMaxUploadBytes) && normalizedMaxUploadBytes > 0
20
- ? normalizedMaxUploadBytes
21
- : DEFAULT_AVATAR_POLICY.maxUploadBytes;
22
-
23
- return Object.freeze({
24
- allowedMimeTypes: Object.freeze(allowedMimeTypes),
25
- maxUploadBytes
26
- });
13
+ return normalizeUploadPolicy(policy, DEFAULT_AVATAR_POLICY);
27
14
  }
28
15
 
29
16
  async function readAvatarBuffer(stream, { maxBytes = DEFAULT_AVATAR_POLICY.maxUploadBytes } = {}) {
30
- if (!stream || typeof stream.on !== "function") {
31
- throw new TypeError("Avatar upload stream is required.");
32
- }
33
-
34
- const chunks = [];
35
- let total = 0;
36
-
37
- for await (const chunk of stream) {
38
- const bufferChunk = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
39
- total += bufferChunk.length;
40
-
41
- if (total > maxBytes) {
42
- throw createValidationError({
43
- avatar: `Avatar file is too large. Maximum allowed size is ${Math.floor(maxBytes / (1024 * 1024))}MB.`
44
- });
45
- }
46
-
47
- chunks.push(bufferChunk);
48
- }
49
-
50
- if (chunks.length === 0) {
51
- throw createValidationError({
52
- avatar: "Avatar file is empty."
53
- });
54
- }
55
-
56
- return Buffer.concat(chunks);
57
- }
58
-
59
- function normalizeMimeType(value) {
60
- return String(value || "").trim().toLowerCase();
17
+ return readUploadBuffer(stream, {
18
+ maxBytes,
19
+ fieldName: "avatar",
20
+ label: "Avatar"
21
+ });
61
22
  }
62
23
 
63
- function createService({ userProfilesRepository, avatarStorageService, avatarPolicy } = {}) {
64
- if (!userProfilesRepository) {
65
- throw new TypeError("avatarService requires userProfilesRepository.");
24
+ function createService({ usersRepository, avatarStorageService, avatarPolicy } = {}) {
25
+ if (!usersRepository) {
26
+ throw new TypeError("avatarService requires usersRepository.");
66
27
  }
67
28
  if (!avatarStorageService) {
68
29
  throw new TypeError("avatarService requires avatarStorageService.");
@@ -71,23 +32,21 @@ function createService({ userProfilesRepository, avatarStorageService, avatarPol
71
32
  const resolvedAvatarPolicy = resolveAvatarPolicy(avatarPolicy);
72
33
 
73
34
  async function resolveProfile(user) {
74
- const profile = await resolveUserProfile(userProfilesRepository, user);
35
+ const profile = await resolveUserProfile(usersRepository, user);
75
36
  if (!profile) {
76
37
  throw new AppError(404, "User profile was not found.");
77
38
  }
78
39
  return profile;
79
40
  }
80
41
 
81
- async function uploadForUser(user, payload = {}) {
42
+ async function uploadForUser(user, avatarUpload = {}) {
82
43
  const profile = await resolveProfile(user);
83
- const mimeType = normalizeMimeType(payload?.mimeType);
84
- if (!resolvedAvatarPolicy.allowedMimeTypes.includes(mimeType)) {
85
- throw createValidationError({
86
- avatar: `Avatar must be one of: ${resolvedAvatarPolicy.allowedMimeTypes.join(", ")}.`
87
- });
88
- }
44
+ validateUploadMimeType(avatarUpload?.mimeType, resolvedAvatarPolicy, {
45
+ fieldName: "avatar",
46
+ label: "Avatar"
47
+ });
89
48
 
90
- const buffer = await readAvatarBuffer(payload.stream, {
49
+ const buffer = await readAvatarBuffer(avatarUpload?.stream, {
91
50
  maxBytes: resolvedAvatarPolicy.maxUploadBytes
92
51
  });
93
52
 
@@ -98,7 +57,7 @@ function createService({ userProfilesRepository, avatarStorageService, avatarPol
98
57
  buffer
99
58
  });
100
59
 
101
- const updatedProfile = await userProfilesRepository.updateAvatarById(profile.id, {
60
+ const updatedProfile = await usersRepository.updateAvatarById(profile.id, {
102
61
  avatarStorageKey: savedAvatar.storageKey,
103
62
  avatarVersion,
104
63
  avatarUpdatedAt: new Date(avatarVersionMs)
@@ -114,7 +73,7 @@ function createService({ userProfilesRepository, avatarStorageService, avatarPol
114
73
  if (profile.avatarStorageKey) {
115
74
  await avatarStorageService.deleteAvatar(profile.avatarStorageKey);
116
75
  }
117
- return userProfilesRepository.clearAvatarById(profile.id);
76
+ return usersRepository.clearAvatarById(profile.id);
118
77
  }
119
78
 
120
79
  async function readForUser(user) {
@@ -1,10 +1,10 @@
1
1
  import { parsePositiveInteger } from "@jskit-ai/kernel/server/runtime";
2
+ import {
3
+ createUploadStorageService,
4
+ detectCommonMimeTypeFromBuffer
5
+ } from "@jskit-ai/uploads-runtime/server/storage/createUploadStorageService";
2
6
 
3
7
  const AVATAR_STORAGE_PREFIX = "users/avatars";
4
- const AVATAR_MIME_TYPE_JPEG = "image/jpeg";
5
- const AVATAR_MIME_TYPE_PNG = "image/png";
6
- const AVATAR_MIME_TYPE_WEBP = "image/webp";
7
- const AVATAR_MIME_TYPE_FALLBACK = "application/octet-stream";
8
8
 
9
9
  function buildAvatarStorageKey(userId) {
10
10
  const normalizedUserId = parsePositiveInteger(userId);
@@ -15,106 +15,25 @@ function buildAvatarStorageKey(userId) {
15
15
  return `${AVATAR_STORAGE_PREFIX}/${normalizedUserId}/avatar`;
16
16
  }
17
17
 
18
- function normalizeStorageKey(value) {
19
- const normalized = String(value || "").trim();
20
- if (!normalized) {
21
- return "";
22
- }
23
- if (normalized.startsWith("/") || normalized.includes("..")) {
24
- return "";
25
- }
26
- return normalized;
27
- }
28
-
29
- function detectAvatarMimeTypeFromBuffer(buffer) {
30
- if (!Buffer.isBuffer(buffer) || buffer.length < 4) {
31
- return AVATAR_MIME_TYPE_FALLBACK;
32
- }
33
-
34
- if (
35
- buffer.length >= 3 &&
36
- buffer[0] === 0xff &&
37
- buffer[1] === 0xd8 &&
38
- buffer[2] === 0xff
39
- ) {
40
- return AVATAR_MIME_TYPE_JPEG;
41
- }
42
-
43
- if (
44
- buffer.length >= 8 &&
45
- buffer[0] === 0x89 &&
46
- buffer[1] === 0x50 &&
47
- buffer[2] === 0x4e &&
48
- buffer[3] === 0x47 &&
49
- buffer[4] === 0x0d &&
50
- buffer[5] === 0x0a &&
51
- buffer[6] === 0x1a &&
52
- buffer[7] === 0x0a
53
- ) {
54
- return AVATAR_MIME_TYPE_PNG;
55
- }
56
-
57
- if (
58
- buffer.length >= 12 &&
59
- buffer[0] === 0x52 &&
60
- buffer[1] === 0x49 &&
61
- buffer[2] === 0x46 &&
62
- buffer[3] === 0x46 &&
63
- buffer[8] === 0x57 &&
64
- buffer[9] === 0x45 &&
65
- buffer[10] === 0x42 &&
66
- buffer[11] === 0x50
67
- ) {
68
- return AVATAR_MIME_TYPE_WEBP;
69
- }
70
-
71
- return AVATAR_MIME_TYPE_FALLBACK;
72
- }
73
-
74
18
  function createService({ storage } = {}) {
75
- if (!storage || typeof storage.getItemRaw !== "function" || typeof storage.setItemRaw !== "function") {
76
- throw new TypeError("avatarStorageService requires a storage binding with getItemRaw()/setItemRaw().");
77
- }
19
+ const uploadStorageService = createUploadStorageService({
20
+ storage,
21
+ mimeTypeDetector: detectCommonMimeTypeFromBuffer
22
+ });
78
23
 
79
24
  async function saveAvatar({ userId, buffer }) {
80
- if (!Buffer.isBuffer(buffer)) {
81
- throw new TypeError("Avatar buffer must be a Buffer instance.");
82
- }
83
-
84
- const storageKey = buildAvatarStorageKey(userId);
85
- await storage.setItemRaw(storageKey, buffer);
86
-
87
- return Object.freeze({
88
- storageKey
25
+ return uploadStorageService.saveFile({
26
+ storageKey: buildAvatarStorageKey(userId),
27
+ buffer
89
28
  });
90
29
  }
91
30
 
92
31
  async function readAvatar(storageKey) {
93
- const normalizedStorageKey = normalizeStorageKey(storageKey);
94
- if (!normalizedStorageKey) {
95
- return null;
96
- }
97
-
98
- const value = await storage.getItemRaw(normalizedStorageKey);
99
- if (value == null) {
100
- return null;
101
- }
102
-
103
- const buffer = Buffer.isBuffer(value) ? value : Buffer.from(value);
104
- return Object.freeze({
105
- storageKey: normalizedStorageKey,
106
- buffer,
107
- mimeType: detectAvatarMimeTypeFromBuffer(buffer)
108
- });
32
+ return uploadStorageService.readFile(storageKey);
109
33
  }
110
34
 
111
35
  async function deleteAvatar(storageKey) {
112
- const normalizedStorageKey = normalizeStorageKey(storageKey);
113
- if (!normalizedStorageKey || typeof storage.removeItem !== "function") {
114
- return;
115
- }
116
-
117
- await storage.removeItem(normalizedStorageKey);
36
+ await uploadStorageService.deleteFile(storageKey);
118
37
  }
119
38
 
120
39
  return Object.freeze({
@@ -126,7 +45,7 @@ function createService({ storage } = {}) {
126
45
 
127
46
  const __testables = Object.freeze({
128
47
  buildAvatarStorageKey,
129
- detectAvatarMimeTypeFromBuffer
48
+ detectAvatarMimeTypeFromBuffer: detectCommonMimeTypeFromBuffer
130
49
  });
131
50
 
132
51
  export { createService, __testables };
@@ -1,5 +1,6 @@
1
- import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
2
1
  import { withStandardErrorResponses } from "@jskit-ai/http-runtime/shared/validators/errorResponses";
2
+ import { DEFAULT_IMAGE_UPLOAD_MAX_BYTES } from "@jskit-ai/uploads-runtime/shared";
3
+ import { readSingleMultipartFile } from "@jskit-ai/uploads-runtime/server/multipart/readSingleMultipartFile";
3
4
  import { userSettingsResource } from "../../shared/resources/userSettingsResource.js";
4
5
  import { userProfileResource } from "../../shared/resources/userProfileResource.js";
5
6
 
@@ -77,7 +78,7 @@ function bootAccountProfileRoutes(app) {
77
78
  }
78
79
  },
79
80
  async function (request, reply) {
80
- const avatar = await accountProfileService.readAvatar(request, request.user, {}, {
81
+ const avatar = await accountProfileService.readAvatar(request, request.user, {
81
82
  context: {
82
83
  actor: request.user
83
84
  }
@@ -113,24 +114,21 @@ function bootAccountProfileRoutes(app) {
113
114
  )
114
115
  },
115
116
  async function (request, reply) {
116
- const filePart = await request.file();
117
- if (!filePart) {
118
- throw new AppError(400, "Validation failed.", {
119
- details: {
120
- fieldErrors: {
121
- avatar: "Avatar file is required."
122
- }
123
- }
124
- });
125
- }
117
+ const filePart = await readSingleMultipartFile(request, {
118
+ fieldName: "avatar",
119
+ required: true,
120
+ fieldErrorKey: "avatar",
121
+ label: "Avatar",
122
+ maxBytes: DEFAULT_IMAGE_UPLOAD_MAX_BYTES
123
+ });
126
124
 
127
125
  const uploadDimension = filePart.fields?.uploadDimension?.value;
128
126
  const response = await request.executeAction({
129
127
  actionId: "settings.profile.avatar.upload",
130
128
  input: {
131
- stream: filePart.file,
132
- mimeType: filePart.mimetype,
133
- fileName: filePart.filename,
129
+ stream: filePart.stream,
130
+ mimeType: filePart.mimeType,
131
+ fileName: filePart.fileName,
134
132
  uploadDimension
135
133
  }
136
134
  });
@@ -20,7 +20,7 @@ function registerAccountProfile(app) {
20
20
 
21
21
  app.singleton("users.avatar.service", (scope) =>
22
22
  createAvatarService({
23
- userProfilesRepository: scope.make("userProfilesRepository"),
23
+ usersRepository: scope.make("usersRepository"),
24
24
  avatarStorageService: scope.make("users.avatar.storage.service")
25
25
  })
26
26
  );
@@ -30,7 +30,7 @@ function registerAccountProfile(app) {
30
30
  (scope) =>
31
31
  createAccountProfileService({
32
32
  userSettingsRepository: scope.make("userSettingsRepository"),
33
- userProfilesRepository: scope.make("userProfilesRepository"),
33
+ usersRepository: scope.make("usersRepository"),
34
34
  authService: scope.make("authService"),
35
35
  avatarService: scope.make("users.avatar.service")
36
36
  }),
@@ -10,10 +10,10 @@ import {
10
10
 
11
11
  function createService({
12
12
  userSettingsRepository,
13
- userProfilesRepository,
13
+ usersRepository,
14
14
  authService
15
15
  } = {}) {
16
- if (!userSettingsRepository || !userProfilesRepository) {
16
+ if (!userSettingsRepository || !usersRepository) {
17
17
  throw new Error("accountSecurityService requires repositories.");
18
18
  }
19
19
 
@@ -40,7 +40,7 @@ function createService({
40
40
  throw new AppError(501, "Password method toggle is not available.");
41
41
  }
42
42
 
43
- const profile = await resolveUserProfile(userProfilesRepository, user);
43
+ const profile = await resolveUserProfile(usersRepository, user);
44
44
  if (!profile) {
45
45
  throw new AppError(404, "User profile was not found.");
46
46
  }