@jskit-ai/users-core 0.1.4

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 (148) hide show
  1. package/package.descriptor.mjs +464 -0
  2. package/package.json +35 -0
  3. package/src/server/UsersCoreServiceProvider.js +74 -0
  4. package/src/server/accountNotifications/accountNotificationsActions.js +39 -0
  5. package/src/server/accountNotifications/accountNotificationsService.js +41 -0
  6. package/src/server/accountNotifications/bootAccountNotificationsRoutes.js +41 -0
  7. package/src/server/accountNotifications/registerAccountNotifications.js +39 -0
  8. package/src/server/accountPreferences/accountPreferencesActions.js +39 -0
  9. package/src/server/accountPreferences/accountPreferencesService.js +41 -0
  10. package/src/server/accountPreferences/bootAccountPreferencesRoutes.js +41 -0
  11. package/src/server/accountPreferences/registerAccountPreferences.js +39 -0
  12. package/src/server/accountProfile/accountProfileActions.js +137 -0
  13. package/src/server/accountProfile/accountProfileService.js +124 -0
  14. package/src/server/accountProfile/avatarService.js +141 -0
  15. package/src/server/accountProfile/avatarStorageService.js +132 -0
  16. package/src/server/accountProfile/bootAccountProfileRoutes.js +166 -0
  17. package/src/server/accountProfile/registerAccountProfile.js +62 -0
  18. package/src/server/accountProfile/registerAvatarMultipartSupport.js +43 -0
  19. package/src/server/accountSecurity/accountSecurityActions.js +144 -0
  20. package/src/server/accountSecurity/accountSecurityService.js +103 -0
  21. package/src/server/accountSecurity/bootAccountSecurityRoutes.js +183 -0
  22. package/src/server/accountSecurity/registerAccountSecurity.js +31 -0
  23. package/src/server/common/README.md +21 -0
  24. package/src/server/common/contributors/README.md +11 -0
  25. package/src/server/common/contributors/workspaceActionContextContributor.js +79 -0
  26. package/src/server/common/contributors/workspaceAuthPolicyContextResolver.js +34 -0
  27. package/src/server/common/contributors/workspaceRouteVisibilityResolver.js +79 -0
  28. package/src/server/common/diTokens.js +21 -0
  29. package/src/server/common/formatters/README.md +11 -0
  30. package/src/server/common/formatters/accountAvatarFormatter.js +42 -0
  31. package/src/server/common/formatters/accountSecurityStatusFormatter.js +71 -0
  32. package/src/server/common/formatters/accountSettingsResponseFormatter.js +62 -0
  33. package/src/server/common/formatters/workspaceFormatter.js +46 -0
  34. package/src/server/common/registerCommonRepositories.js +45 -0
  35. package/src/server/common/registerSharedApi.js +9 -0
  36. package/src/server/common/repositories/README.md +24 -0
  37. package/src/server/common/repositories/repositoryUtils.js +50 -0
  38. package/src/server/common/repositories/userProfilesRepository.js +251 -0
  39. package/src/server/common/repositories/userSettingsRepository.js +179 -0
  40. package/src/server/common/repositories/workspaceInvitesRepository.js +172 -0
  41. package/src/server/common/repositories/workspaceMembershipsRepository.js +157 -0
  42. package/src/server/common/repositories/workspacesRepository.js +183 -0
  43. package/src/server/common/routes/README.md +11 -0
  44. package/src/server/common/services/README.md +12 -0
  45. package/src/server/common/services/accountContextService.js +31 -0
  46. package/src/server/common/services/authProfileSyncService.js +128 -0
  47. package/src/server/common/services/workspaceContextService.js +270 -0
  48. package/src/server/common/support/deepFreeze.js +17 -0
  49. package/src/server/common/support/realtimeServiceEvents.js +94 -0
  50. package/src/server/common/support/resolveActionUser.js +11 -0
  51. package/src/server/common/support/workspaceRoutePaths.js +17 -0
  52. package/src/server/common/validators/README.md +11 -0
  53. package/src/server/common/validators/authenticatedUserValidator.js +42 -0
  54. package/src/server/common/validators/routeParamsValidator.js +62 -0
  55. package/src/server/consoleSettings/bootConsoleSettingsRoutes.js +64 -0
  56. package/src/server/consoleSettings/consoleService.js +36 -0
  57. package/src/server/consoleSettings/consoleSettingsActions.js +55 -0
  58. package/src/server/consoleSettings/consoleSettingsRepository.js +111 -0
  59. package/src/server/consoleSettings/consoleSettingsService.js +40 -0
  60. package/src/server/consoleSettings/registerConsoleSettings.js +57 -0
  61. package/src/server/registerWorkspaceBootstrap.js +36 -0
  62. package/src/server/registerWorkspaceCore.js +95 -0
  63. package/src/server/support/resolveWorkspace.js +16 -0
  64. package/src/server/support/workspaceActionSurfaces.js +135 -0
  65. package/src/server/support/workspaceInvitationsPolicy.js +45 -0
  66. package/src/server/support/workspaceRouteInput.js +22 -0
  67. package/src/server/workspaceBootstrapContributor.js +401 -0
  68. package/src/server/workspaceDirectory/bootWorkspaceDirectoryRoutes.js +73 -0
  69. package/src/server/workspaceDirectory/registerWorkspaceDirectory.js +19 -0
  70. package/src/server/workspaceDirectory/workspaceDirectoryActions.js +65 -0
  71. package/src/server/workspaceMembers/bootWorkspaceMembers.js +238 -0
  72. package/src/server/workspaceMembers/registerWorkspaceMembers.js +112 -0
  73. package/src/server/workspaceMembers/workspaceMembersActions.js +186 -0
  74. package/src/server/workspaceMembers/workspaceMembersService.js +210 -0
  75. package/src/server/workspacePendingInvitations/bootWorkspacePendingInvitations.js +63 -0
  76. package/src/server/workspacePendingInvitations/registerWorkspacePendingInvitations.js +128 -0
  77. package/src/server/workspacePendingInvitations/workspacePendingInvitationsActions.js +74 -0
  78. package/src/server/workspacePendingInvitations/workspacePendingInvitationsService.js +137 -0
  79. package/src/server/workspaceSettings/bootWorkspaceSettings.js +77 -0
  80. package/src/server/workspaceSettings/registerWorkspaceSettings.js +67 -0
  81. package/src/server/workspaceSettings/workspaceSettingsActions.js +72 -0
  82. package/src/server/workspaceSettings/workspaceSettingsRepository.js +135 -0
  83. package/src/server/workspaceSettings/workspaceSettingsService.js +65 -0
  84. package/src/shared/events/usersEvents.js +19 -0
  85. package/src/shared/index.js +91 -0
  86. package/src/shared/operationMessages.js +16 -0
  87. package/src/shared/resources/consoleSettingsFields.js +55 -0
  88. package/src/shared/resources/consoleSettingsResource.js +139 -0
  89. package/src/shared/resources/resolveGlobalArrayRegistry.js +6 -0
  90. package/src/shared/resources/userProfileResource.js +148 -0
  91. package/src/shared/resources/userSettingsFields.js +71 -0
  92. package/src/shared/resources/userSettingsResource.js +416 -0
  93. package/src/shared/resources/workspaceMembersResource.js +352 -0
  94. package/src/shared/resources/workspacePendingInvitationsResource.js +87 -0
  95. package/src/shared/resources/workspaceResource.js +149 -0
  96. package/src/shared/resources/workspaceSettingsFields.js +60 -0
  97. package/src/shared/resources/workspaceSettingsResource.js +178 -0
  98. package/src/shared/roles.js +136 -0
  99. package/src/shared/settings.js +31 -0
  100. package/src/shared/support/usersApiPaths.js +34 -0
  101. package/src/shared/support/usersVisibility.js +45 -0
  102. package/src/shared/support/workspacePathModel.js +145 -0
  103. package/src/shared/tenancyMode.js +35 -0
  104. package/src/shared/tenancyProfile.js +73 -0
  105. package/templates/config/workspaceRoles.js +30 -0
  106. package/templates/migrations/users_core_console_owner.cjs +39 -0
  107. package/templates/migrations/users_core_initial.cjs +118 -0
  108. package/templates/migrations/users_core_profile_username.cjs +98 -0
  109. package/templates/packages/main/src/shared/resources/consoleSettingsFields.js +11 -0
  110. package/templates/packages/main/src/shared/resources/userSettingsFields.js +138 -0
  111. package/templates/packages/main/src/shared/resources/workspaceSettingsFields.js +105 -0
  112. package/test/authProfileSyncService.test.js +119 -0
  113. package/test/avatarService.test.js +114 -0
  114. package/test/avatarStorageService.test.js +61 -0
  115. package/test/consoleService.test.js +57 -0
  116. package/test/consoleSettingsService.test.js +86 -0
  117. package/test/exportsContract.test.js +38 -0
  118. package/test/registerAvatarMultipartSupport.test.js +64 -0
  119. package/test/registerServiceRealtimeEvents.test.js +160 -0
  120. package/test/registerWorkspaceDirectory.test.js +26 -0
  121. package/test/registerWorkspaceSettings.test.js +44 -0
  122. package/test/resourcesCanonical.test.js +90 -0
  123. package/test/roles.test.js +74 -0
  124. package/test/settingsFieldRegistriesSingleton.test.js +24 -0
  125. package/test/tenancyProfile.test.js +67 -0
  126. package/test/userSettingsResource.test.js +31 -0
  127. package/test/usersApiPaths.test.js +31 -0
  128. package/test/usersRouteRequestInputValidator.test.js +556 -0
  129. package/test/usersRouteResources.test.js +113 -0
  130. package/test/usersRouteValidators.test.js +49 -0
  131. package/test/usersVisibility.test.js +22 -0
  132. package/test/workspaceActionContextContributor.test.js +251 -0
  133. package/test/workspaceActionSurfaces.test.js +105 -0
  134. package/test/workspaceAuthPolicyContextResolver.test.js +119 -0
  135. package/test/workspaceBootstrapContributor.test.js +466 -0
  136. package/test/workspaceInvitationsPolicy.test.js +71 -0
  137. package/test/workspaceInvitesRepository.test.js +111 -0
  138. package/test/workspaceMembersService.test.js +400 -0
  139. package/test/workspacePathModel.test.js +93 -0
  140. package/test/workspacePendingInvitationsResource.test.js +38 -0
  141. package/test/workspacePendingInvitationsService.test.js +151 -0
  142. package/test/workspaceRouteVisibilityResolver.test.js +83 -0
  143. package/test/workspaceService.test.js +480 -0
  144. package/test/workspaceSettingsActions.test.js +42 -0
  145. package/test/workspaceSettingsRepository.test.js +156 -0
  146. package/test/workspaceSettingsResource.test.js +156 -0
  147. package/test/workspaceSettingsService.test.js +120 -0
  148. package/test-support/registerDefaultSettingsFields.js +3 -0
@@ -0,0 +1,210 @@
1
+ import { buildInviteToken, hashInviteToken } from "@jskit-ai/auth-core/server/inviteTokens";
2
+ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
3
+ import { OWNER_ROLE_ID, createWorkspaceRoleCatalog, cloneWorkspaceRoleCatalog } from "../../shared/roles.js";
4
+
5
+ function createService({
6
+ workspaceMembershipsRepository,
7
+ workspaceInvitesRepository,
8
+ inviteExpiresInMs,
9
+ roleCatalog = null,
10
+ workspaceInvitationsEnabled = true
11
+ } = {}) {
12
+ if (!workspaceMembershipsRepository || !workspaceInvitesRepository) {
13
+ throw new Error("workspaceMembersService requires membership and invite repositories.");
14
+ }
15
+ const resolvedInviteExpiresInMs = Number(inviteExpiresInMs);
16
+ if (!Number.isInteger(resolvedInviteExpiresInMs) || resolvedInviteExpiresInMs < 1) {
17
+ throw new Error("workspaceMembersService requires inviteExpiresInMs.");
18
+ }
19
+
20
+ const resolvedRoleCatalog = roleCatalog && typeof roleCatalog === "object" ? roleCatalog : createWorkspaceRoleCatalog();
21
+ const assignableRoleIds = Array.isArray(resolvedRoleCatalog.assignableRoleIds)
22
+ ? [...resolvedRoleCatalog.assignableRoleIds]
23
+ : [];
24
+ const resolvedWorkspaceInvitationsEnabled = workspaceInvitationsEnabled === true;
25
+
26
+ function ensureWorkspaceInvitationsEnabled() {
27
+ if (resolvedWorkspaceInvitationsEnabled) {
28
+ return;
29
+ }
30
+ throw new AppError(403, "Workspace invitations are disabled.");
31
+ }
32
+
33
+ function withRoleCatalog(payload = {}) {
34
+ return {
35
+ ...payload,
36
+ roleCatalog: cloneWorkspaceRoleCatalog({
37
+ ...resolvedRoleCatalog,
38
+ assignableRoleIds
39
+ })
40
+ };
41
+ }
42
+
43
+ async function listRoles(options = {}) {
44
+ return cloneWorkspaceRoleCatalog({
45
+ ...resolvedRoleCatalog,
46
+ assignableRoleIds
47
+ });
48
+ }
49
+
50
+ async function listMembersPayload(workspace, options = {}) {
51
+ const members = await workspaceMembershipsRepository.listActiveByWorkspaceId(workspace.id, options);
52
+
53
+ return withRoleCatalog({
54
+ workspace,
55
+ members
56
+ });
57
+ }
58
+
59
+ async function listMembers(workspace, options = {}) {
60
+ return listMembersPayload(workspace, options);
61
+ }
62
+
63
+ async function updateMemberRole(workspace, payload = {}, options = {}) {
64
+ const memberUserId = payload.memberUserId;
65
+ const roleId = payload.roleId;
66
+ if (!assignableRoleIds.includes(roleId)) {
67
+ throw new AppError(400, "Validation failed.", {
68
+ details: {
69
+ fieldErrors: {
70
+ roleId: "Role is not assignable."
71
+ }
72
+ }
73
+ });
74
+ }
75
+
76
+ const existingMembership = await workspaceMembershipsRepository.findByWorkspaceIdAndUserId(workspace.id, memberUserId, options);
77
+ if (!existingMembership || existingMembership.status !== "active") {
78
+ throw new AppError(404, "Member not found.");
79
+ }
80
+ if (Number(memberUserId) === Number(workspace.ownerUserId) || existingMembership.roleId === OWNER_ROLE_ID) {
81
+ throw new AppError(409, "Cannot change workspace owner role.");
82
+ }
83
+
84
+ await workspaceMembershipsRepository.upsertMembership(
85
+ workspace.id,
86
+ memberUserId,
87
+ {
88
+ roleId,
89
+ status: "active"
90
+ },
91
+ options
92
+ );
93
+
94
+ return listMembersPayload(workspace, options);
95
+ }
96
+
97
+ async function removeMember(workspace, payload = {}, options = {}) {
98
+ const memberUserId = payload.memberUserId;
99
+
100
+ const existingMembership = await workspaceMembershipsRepository.findByWorkspaceIdAndUserId(workspace.id, memberUserId, options);
101
+ if (!existingMembership || existingMembership.status !== "active") {
102
+ throw new AppError(404, "Member not found.");
103
+ }
104
+ if (Number(memberUserId) === Number(workspace.ownerUserId) || existingMembership.roleId === OWNER_ROLE_ID) {
105
+ throw new AppError(409, "Cannot remove workspace owner.");
106
+ }
107
+
108
+ await workspaceMembershipsRepository.upsertMembership(
109
+ workspace.id,
110
+ memberUserId,
111
+ {
112
+ roleId: existingMembership.roleId,
113
+ status: "revoked"
114
+ },
115
+ options
116
+ );
117
+
118
+ return listMembersPayload(workspace, options);
119
+ }
120
+
121
+ async function listInvitesPayload(workspace, options = {}) {
122
+ ensureWorkspaceInvitationsEnabled();
123
+ const invites = await workspaceInvitesRepository.listPendingByWorkspaceIdWithWorkspace(workspace.id, options);
124
+
125
+ return withRoleCatalog({
126
+ workspace,
127
+ invites
128
+ });
129
+ }
130
+
131
+ async function listInvites(workspace, options = {}) {
132
+ return listInvitesPayload(workspace, options);
133
+ }
134
+
135
+ async function createInvite(workspace, user, payload = {}, options = {}) {
136
+ const email = payload.email;
137
+ const roleId = payload.roleId;
138
+ if (!assignableRoleIds.includes(roleId)) {
139
+ throw new AppError(400, "Validation failed.", {
140
+ details: {
141
+ fieldErrors: {
142
+ roleId: "Role is not assignable."
143
+ }
144
+ }
145
+ });
146
+ }
147
+
148
+ const token = buildInviteToken();
149
+ const tokenHash = hashInviteToken(token);
150
+ await workspaceInvitesRepository.expirePendingByWorkspaceIdAndEmail(workspace.id, email, options);
151
+ const createdInvite = await workspaceInvitesRepository.insert(
152
+ {
153
+ workspaceId: workspace.id,
154
+ email,
155
+ roleId,
156
+ status: "pending",
157
+ tokenHash,
158
+ invitedByUserId: Number(user?.id || 0) || null,
159
+ expiresAt: new Date(Date.now() + resolvedInviteExpiresInMs).toISOString()
160
+ },
161
+ options
162
+ );
163
+ const createdInviteId = Number(createdInvite?.id);
164
+ if (!Number.isInteger(createdInviteId) || createdInviteId < 1) {
165
+ throw new Error("workspaceMembersService.createInvite expected repository to return created invite id.");
166
+ }
167
+
168
+ const response = await listInvitesPayload(workspace, options);
169
+ return {
170
+ ...response,
171
+ inviteTokenPreview: token,
172
+ createdInviteId
173
+ };
174
+ }
175
+
176
+ async function revokeInvite(workspace, inviteId, options = {}) {
177
+ const invite = await workspaceInvitesRepository.findPendingByIdForWorkspace(
178
+ inviteId,
179
+ workspace.id,
180
+ options
181
+ );
182
+ if (!invite) {
183
+ throw new AppError(404, "Invite not found.");
184
+ }
185
+
186
+ await workspaceInvitesRepository.revokeById(inviteId, options);
187
+ const revokedInviteId = Number(invite?.id);
188
+ if (!Number.isInteger(revokedInviteId) || revokedInviteId < 1) {
189
+ throw new Error("workspaceMembersService.revokeInvite expected repository to return pending invite id.");
190
+ }
191
+
192
+ const response = await listInvitesPayload(workspace, options);
193
+ return {
194
+ ...response,
195
+ revokedInviteId
196
+ };
197
+ }
198
+
199
+ return Object.freeze({
200
+ listRoles,
201
+ listMembers,
202
+ updateMemberRole,
203
+ removeMember,
204
+ listInvites,
205
+ createInvite,
206
+ revokeInvite
207
+ });
208
+ }
209
+
210
+ export { createService };
@@ -0,0 +1,63 @@
1
+ import { withStandardErrorResponses } from "@jskit-ai/http-runtime/shared/validators/errorResponses";
2
+ import { KERNEL_TOKENS } from "@jskit-ai/kernel/shared/support/tokens";
3
+ import { workspaceMembersResource } from "../../shared/resources/workspaceMembersResource.js";
4
+ import { workspacePendingInvitationsResource } from "../../shared/resources/workspacePendingInvitationsResource.js";
5
+
6
+ function bootWorkspacePendingInvitations(app) {
7
+ if (!app || typeof app.make !== "function") {
8
+ throw new Error("bootWorkspacePendingInvitations requires application make().");
9
+ }
10
+
11
+ const router = app.make(KERNEL_TOKENS.HttpRouter);
12
+
13
+ router.register(
14
+ "GET",
15
+ "/api/workspace/invitations/pending",
16
+ {
17
+ auth: "required",
18
+ meta: {
19
+ tags: ["workspace"],
20
+ summary: "List pending workspace invitations for authenticated user"
21
+ },
22
+ responseValidators: withStandardErrorResponses({
23
+ 200: workspacePendingInvitationsResource.operations.list.outputValidator
24
+ })
25
+ },
26
+ async function (request, reply) {
27
+ const response = await request.executeAction({
28
+ actionId: "workspace.invitations.pending.list"
29
+ });
30
+ reply.code(200).send(response);
31
+ }
32
+ );
33
+
34
+ router.register(
35
+ "POST",
36
+ "/api/workspace/invitations/redeem",
37
+ {
38
+ auth: "required",
39
+ meta: {
40
+ tags: ["workspace"],
41
+ summary: "Accept or refuse a workspace invitation using an invite token"
42
+ },
43
+ bodyValidator: workspaceMembersResource.operations.redeemInvite.bodyValidator,
44
+ responseValidators: withStandardErrorResponses(
45
+ {
46
+ 200: workspaceMembersResource.operations.redeemInvite.outputValidator
47
+ },
48
+ { includeValidation400: true }
49
+ )
50
+ },
51
+ async function (request, reply) {
52
+ const response = await request.executeAction({
53
+ actionId: "workspace.invite.redeem",
54
+ input: {
55
+ payload: request.input.body
56
+ }
57
+ });
58
+ reply.code(200).send(response);
59
+ }
60
+ );
61
+ }
62
+
63
+ export { bootWorkspacePendingInvitations };
@@ -0,0 +1,128 @@
1
+ import { withActionDefaults } from "@jskit-ai/kernel/shared/actions";
2
+ import { createService } from "./workspacePendingInvitationsService.js";
3
+ import { workspacePendingInvitationsActions } from "./workspacePendingInvitationsActions.js";
4
+ import {
5
+ USERS_BOOTSTRAP_CHANGED_EVENT,
6
+ WORKSPACE_INVITES_CHANGED_EVENT,
7
+ WORKSPACE_MEMBERS_CHANGED_EVENT,
8
+ WORKSPACES_CHANGED_EVENT,
9
+ WORKSPACE_PENDING_INVITATIONS_CHANGED_EVENT
10
+ } from "../../shared/events/usersEvents.js";
11
+ import { deepFreeze } from "../common/support/deepFreeze.js";
12
+ import {
13
+ USERS_WORKSPACE_PENDING_INVITATIONS_SERVICE_TOKEN
14
+ } from "../common/diTokens.js";
15
+
16
+ function workspaceAudienceFromEntityId({ event } = {}) {
17
+ const workspaceId = Number(event?.entityId);
18
+ if (!Number.isInteger(workspaceId) || workspaceId < 1) {
19
+ return "none";
20
+ }
21
+ return {
22
+ workspaceId
23
+ };
24
+ }
25
+
26
+ function actorUserEntityId({ options } = {}) {
27
+ return Number(options?.context?.actor?.id || 0);
28
+ }
29
+
30
+ function createActorUserEvent({ source, entity, realtimeEvent }) {
31
+ return {
32
+ type: "entity.changed",
33
+ source,
34
+ entity,
35
+ operation: "updated",
36
+ entityId: actorUserEntityId,
37
+ realtime: {
38
+ event: realtimeEvent,
39
+ audience: "actor_user"
40
+ }
41
+ };
42
+ }
43
+
44
+ function createWorkspaceAudienceEvent({ entity, realtimeEvent }) {
45
+ return {
46
+ type: "entity.changed",
47
+ source: "workspace",
48
+ entity,
49
+ operation: "updated",
50
+ entityId: ({ result }) => result?.workspaceId,
51
+ realtime: {
52
+ event: realtimeEvent,
53
+ audience: workspaceAudienceFromEntityId
54
+ }
55
+ };
56
+ }
57
+
58
+ function createInviteDecisionEvents({ includeDirectoryAndMembers = false } = {}) {
59
+ const events = [
60
+ createActorUserEvent({
61
+ source: "workspace",
62
+ entity: "invitation",
63
+ realtimeEvent: WORKSPACE_PENDING_INVITATIONS_CHANGED_EVENT
64
+ }),
65
+ createActorUserEvent({
66
+ source: "users",
67
+ entity: "bootstrap",
68
+ realtimeEvent: USERS_BOOTSTRAP_CHANGED_EVENT
69
+ })
70
+ ];
71
+
72
+ if (includeDirectoryAndMembers) {
73
+ events.push(
74
+ createActorUserEvent({
75
+ source: "workspace",
76
+ entity: "directory",
77
+ realtimeEvent: WORKSPACES_CHANGED_EVENT
78
+ }),
79
+ createWorkspaceAudienceEvent({
80
+ entity: "member",
81
+ realtimeEvent: WORKSPACE_MEMBERS_CHANGED_EVENT
82
+ })
83
+ );
84
+ }
85
+
86
+ events.push(
87
+ createWorkspaceAudienceEvent({
88
+ entity: "invite",
89
+ realtimeEvent: WORKSPACE_INVITES_CHANGED_EVENT
90
+ })
91
+ );
92
+
93
+ return events;
94
+ }
95
+
96
+ function registerWorkspacePendingInvitations(app) {
97
+ if (!app || typeof app.singleton !== "function" || typeof app.service !== "function" || typeof app.actions !== "function") {
98
+ throw new Error("registerWorkspacePendingInvitations requires application singleton()/service()/actions().");
99
+ }
100
+
101
+ app.service(
102
+ USERS_WORKSPACE_PENDING_INVITATIONS_SERVICE_TOKEN,
103
+ (scope) =>
104
+ createService({
105
+ workspaceInvitesRepository: scope.make("workspaceInvitesRepository"),
106
+ workspaceMembershipsRepository: scope.make("workspaceMembershipsRepository")
107
+ }),
108
+ {
109
+ events: deepFreeze({
110
+ acceptInviteByToken: createInviteDecisionEvents({
111
+ includeDirectoryAndMembers: true
112
+ }),
113
+ refuseInviteByToken: createInviteDecisionEvents()
114
+ })
115
+ }
116
+ );
117
+
118
+ app.actions(
119
+ withActionDefaults(workspacePendingInvitationsActions, {
120
+ domain: "workspace",
121
+ dependencies: {
122
+ workspacePendingInvitationsService: USERS_WORKSPACE_PENDING_INVITATIONS_SERVICE_TOKEN
123
+ }
124
+ })
125
+ );
126
+ }
127
+
128
+ export { registerWorkspacePendingInvitations };
@@ -0,0 +1,74 @@
1
+ import {
2
+ EMPTY_INPUT_VALIDATOR
3
+ } from "@jskit-ai/kernel/shared/actions/actionContributorHelpers";
4
+ import { workspaceMembersResource } from "../../shared/resources/workspaceMembersResource.js";
5
+ import { workspacePendingInvitationsResource } from "../../shared/resources/workspacePendingInvitationsResource.js";
6
+ import { resolveActionUser } from "../common/support/resolveActionUser.js";
7
+
8
+ const workspacePendingInvitationsActions = Object.freeze([
9
+ {
10
+ id: "workspace.invitations.pending.list",
11
+ version: 1,
12
+ kind: "query",
13
+ channels: ["api", "automation", "internal"],
14
+ surfacesFrom: "enabled",
15
+ permission: {
16
+ require: "authenticated"
17
+ },
18
+ inputValidator: EMPTY_INPUT_VALIDATOR,
19
+ outputValidator: workspacePendingInvitationsResource.operations.list.outputValidator,
20
+ idempotency: "none",
21
+ audit: {
22
+ actionName: "workspace.invitations.pending.list"
23
+ },
24
+ observability: {},
25
+ async execute(input, context, deps) {
26
+ return {
27
+ pendingInvites: await deps.workspacePendingInvitationsService.listPendingInvitesForUser(resolveActionUser(context, input), {
28
+ context
29
+ })
30
+ };
31
+ }
32
+ },
33
+ {
34
+ id: "workspace.invite.redeem",
35
+ version: 1,
36
+ kind: "command",
37
+ channels: ["api", "automation", "internal"],
38
+ surfacesFrom: "enabled",
39
+ permission: {
40
+ require: "authenticated"
41
+ },
42
+ inputValidator: {
43
+ payload: workspaceMembersResource.operations.redeemInvite.bodyValidator
44
+ },
45
+ outputValidator: workspaceMembersResource.operations.redeemInvite.outputValidator,
46
+ idempotency: "optional",
47
+ audit: {
48
+ actionName: "workspace.invite.redeem"
49
+ },
50
+ observability: {},
51
+ async execute(input, context, deps) {
52
+ const payload = input.payload || {};
53
+ const user = resolveActionUser(context, input);
54
+
55
+ if (payload.decision === "accept") {
56
+ return deps.workspacePendingInvitationsService.acceptInviteByToken({
57
+ user,
58
+ token: payload.token
59
+ }, {
60
+ context
61
+ });
62
+ }
63
+
64
+ return deps.workspacePendingInvitationsService.refuseInviteByToken({
65
+ user,
66
+ token: payload.token
67
+ }, {
68
+ context
69
+ });
70
+ }
71
+ }
72
+ ]);
73
+
74
+ export { workspacePendingInvitationsActions };
@@ -0,0 +1,137 @@
1
+ import { resolveInviteTokenHash } from "@jskit-ai/auth-core/server/inviteTokens";
2
+ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
3
+ import { normalizeLowerText, normalizeText } from "@jskit-ai/kernel/shared/actions/textNormalization";
4
+ import { authenticatedUserValidator } from "../common/validators/authenticatedUserValidator.js";
5
+
6
+ function createService({
7
+ workspaceInvitesRepository,
8
+ workspaceMembershipsRepository
9
+ } = {}) {
10
+ if (!workspaceInvitesRepository || !workspaceMembershipsRepository) {
11
+ throw new Error("workspacePendingInvitationsService requires invite and membership repositories.");
12
+ }
13
+
14
+ function requireAuthenticatedInviteUser(user) {
15
+ const normalizedUser = authenticatedUserValidator.normalize(user);
16
+ if (!normalizedUser) {
17
+ throw new AppError(401, "Authentication required.");
18
+ }
19
+
20
+ return normalizedUser;
21
+ }
22
+
23
+ function requireInviteTokenHash(token) {
24
+ const normalizedToken = normalizeText(token);
25
+ if (!normalizedToken) {
26
+ throw new AppError(400, "Invite token is required.");
27
+ }
28
+
29
+ const tokenHash = resolveInviteTokenHash(normalizedToken);
30
+ if (!tokenHash) {
31
+ throw new AppError(400, "Invite token is invalid.");
32
+ }
33
+
34
+ return tokenHash;
35
+ }
36
+
37
+ async function requirePendingInviteForUserByToken(user, token, options = {}) {
38
+ const normalizedUser = requireAuthenticatedInviteUser(user);
39
+ const tokenHash = requireInviteTokenHash(token);
40
+
41
+ const invite = await workspaceInvitesRepository.findPendingByTokenHash(tokenHash, options);
42
+ if (!invite) {
43
+ throw new AppError(404, "Invitation not found or already handled.");
44
+ }
45
+
46
+ if (normalizeLowerText(invite.email) !== normalizedUser.email) {
47
+ throw new AppError(403, "Invitation email does not match authenticated user.");
48
+ }
49
+
50
+ return {
51
+ user: normalizedUser,
52
+ invite
53
+ };
54
+ }
55
+
56
+ async function revokeExpiredInviteAndThrow(invite, options = {}) {
57
+ if (invite.expiresAt && new Date(invite.expiresAt).getTime() < Date.now()) {
58
+ await workspaceInvitesRepository.revokeById(invite.id, options);
59
+ throw new AppError(409, "Invitation has expired.");
60
+ }
61
+ }
62
+
63
+ async function listPendingInvitesForUser(user, options = {}) {
64
+ const normalizedUser = requireAuthenticatedInviteUser(user);
65
+ if (!normalizedUser.email) {
66
+ return [];
67
+ }
68
+
69
+ return workspaceInvitesRepository.listPendingByEmail(normalizedUser.email, options);
70
+ }
71
+
72
+ function requireWorkspaceIdFromInvite(invite, methodName = "workspacePendingInvitationsService") {
73
+ const workspaceId = Number(invite?.workspaceId);
74
+ if (!Number.isInteger(workspaceId) || workspaceId < 1) {
75
+ throw new Error(`${methodName} expected invite workspace id.`);
76
+ }
77
+ return workspaceId;
78
+ }
79
+
80
+ async function resolveInviteActionInput(user, token, options = {}, methodName = "workspacePendingInvitationsService") {
81
+ const resolvedInvite = await requirePendingInviteForUserByToken(user, token, options);
82
+ await revokeExpiredInviteAndThrow(resolvedInvite.invite, options);
83
+
84
+ return {
85
+ resolvedInvite,
86
+ workspaceId: requireWorkspaceIdFromInvite(resolvedInvite.invite, methodName)
87
+ };
88
+ }
89
+
90
+ async function acceptInviteByToken({ user, token } = {}, options = {}) {
91
+ const { resolvedInvite, workspaceId } = await resolveInviteActionInput(
92
+ user,
93
+ token,
94
+ options,
95
+ "workspacePendingInvitationsService.acceptInviteByToken"
96
+ );
97
+
98
+ await workspaceMembershipsRepository.upsertMembership(
99
+ workspaceId,
100
+ resolvedInvite.user.id,
101
+ {
102
+ roleId: resolvedInvite.invite.roleId,
103
+ status: "active"
104
+ },
105
+ options
106
+ );
107
+ await workspaceInvitesRepository.markAcceptedById(resolvedInvite.invite.id, options);
108
+
109
+ return {
110
+ decision: "accepted",
111
+ workspaceId
112
+ };
113
+ }
114
+
115
+ async function refuseInviteByToken({ user, token } = {}, options = {}) {
116
+ const { resolvedInvite, workspaceId } = await resolveInviteActionInput(
117
+ user,
118
+ token,
119
+ options,
120
+ "workspacePendingInvitationsService.refuseInviteByToken"
121
+ );
122
+ await workspaceInvitesRepository.revokeById(resolvedInvite.invite.id, options);
123
+
124
+ return {
125
+ decision: "refused",
126
+ workspaceId
127
+ };
128
+ }
129
+
130
+ return Object.freeze({
131
+ listPendingInvitesForUser,
132
+ acceptInviteByToken,
133
+ refuseInviteByToken
134
+ });
135
+ }
136
+
137
+ export { createService };
@@ -0,0 +1,77 @@
1
+ import { withStandardErrorResponses } from "@jskit-ai/http-runtime/shared/validators/errorResponses";
2
+ import { KERNEL_TOKENS } from "@jskit-ai/kernel/shared/support/tokens";
3
+ import { workspaceSettingsResource } from "../../shared/resources/workspaceSettingsResource.js";
4
+ import { resolveWorkspaceRoutePath } from "../common/support/workspaceRoutePaths.js";
5
+ import { workspaceSlugParamsValidator } from "../common/validators/routeParamsValidator.js";
6
+ import { resolveDefaultWorkspaceRouteSurfaceIdFromAppConfig } from "../support/workspaceActionSurfaces.js";
7
+
8
+ function bootWorkspaceSettings(app) {
9
+ if (!app || typeof app.make !== "function") {
10
+ throw new Error("bootWorkspaceSettings requires application make().");
11
+ }
12
+
13
+ const router = app.make(KERNEL_TOKENS.HttpRouter);
14
+ const appConfig = typeof app.has === "function" && app.has("appConfig") ? app.make("appConfig") : {};
15
+ const workspaceRouteSurfaceId = resolveDefaultWorkspaceRouteSurfaceIdFromAppConfig(appConfig);
16
+
17
+ router.register(
18
+ "GET",
19
+ resolveWorkspaceRoutePath("/settings"),
20
+ {
21
+ auth: "required",
22
+ surface: workspaceRouteSurfaceId,
23
+ visibility: "workspace",
24
+ meta: {
25
+ tags: ["workspace"],
26
+ summary: "Get workspace settings and role catalog by workspace slug"
27
+ },
28
+ paramsValidator: workspaceSlugParamsValidator,
29
+ responseValidators: withStandardErrorResponses({
30
+ 200: workspaceSettingsResource.operations.view.outputValidator
31
+ })
32
+ },
33
+ async function (request, reply) {
34
+ const response = await request.executeAction({
35
+ actionId: "workspace.settings.read",
36
+ input: {
37
+ workspaceSlug: request.input.params.workspaceSlug
38
+ }
39
+ });
40
+ reply.code(200).send(response);
41
+ }
42
+ );
43
+
44
+ router.register(
45
+ "PATCH",
46
+ resolveWorkspaceRoutePath("/settings"),
47
+ {
48
+ auth: "required",
49
+ surface: workspaceRouteSurfaceId,
50
+ visibility: "workspace",
51
+ meta: {
52
+ tags: ["workspace"],
53
+ summary: "Update workspace settings by workspace slug"
54
+ },
55
+ paramsValidator: workspaceSlugParamsValidator,
56
+ bodyValidator: workspaceSettingsResource.operations.patch.bodyValidator,
57
+ responseValidators: withStandardErrorResponses(
58
+ {
59
+ 200: workspaceSettingsResource.operations.patch.outputValidator
60
+ },
61
+ { includeValidation400: true }
62
+ )
63
+ },
64
+ async function (request, reply) {
65
+ const response = await request.executeAction({
66
+ actionId: "workspace.settings.update",
67
+ input: {
68
+ workspaceSlug: request.input.params.workspaceSlug,
69
+ patch: request.input.body
70
+ }
71
+ });
72
+ reply.code(200).send(response);
73
+ }
74
+ );
75
+ }
76
+
77
+ export { bootWorkspaceSettings };