@oneuptime/common 12.0.3 → 12.0.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 (173) hide show
  1. package/Models/DatabaseModels/CephCluster.ts +14 -0
  2. package/Models/DatabaseModels/CodeRepository.ts +14 -6
  3. package/Models/DatabaseModels/DockerHost.ts +14 -0
  4. package/Models/DatabaseModels/GlobalOidcProject.ts +18 -0
  5. package/Models/DatabaseModels/GlobalSsoProject.ts +18 -0
  6. package/Models/DatabaseModels/IoTFleet.ts +14 -0
  7. package/Models/DatabaseModels/KubernetesCluster.ts +14 -0
  8. package/Models/DatabaseModels/NetworkDevice.ts +14 -0
  9. package/Models/DatabaseModels/NetworkInterface.ts +18 -0
  10. package/Models/DatabaseModels/Project.ts +16 -2
  11. package/Models/DatabaseModels/ProxmoxCluster.ts +14 -0
  12. package/Models/DatabaseModels/Service.ts +30 -0
  13. package/Models/DatabaseModels/StatusPagePrivateUser.ts +30 -0
  14. package/Models/DatabaseModels/User.ts +30 -0
  15. package/Server/API/AIAgentDataAPI.ts +31 -0
  16. package/Server/API/DashboardAPI.ts +419 -12
  17. package/Server/API/GitHubAPI.ts +119 -283
  18. package/Server/API/UserAPI.ts +263 -1
  19. package/Server/EnvironmentConfig.ts +7 -3
  20. package/Server/Infrastructure/GlobalCache.ts +37 -0
  21. package/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.ts +21 -0
  22. package/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.ts +68 -0
  23. package/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.ts +100 -0
  24. package/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.ts +214 -0
  25. package/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.ts +69 -0
  26. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +10 -0
  27. package/Server/Services/AnalyticsDatabaseService.ts +72 -1
  28. package/Server/Services/CodeRepositoryService.ts +105 -2
  29. package/Server/Services/DatabaseService.ts +185 -2
  30. package/Server/Services/OpenTelemetryIngestService.ts +230 -26
  31. package/Server/Services/UserService.ts +184 -0
  32. package/Server/Utils/AI/Toolbox/CodeTools.ts +45 -4
  33. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +13 -0
  34. package/Server/Utils/CodeRepository/GitHub/GitHub.ts +157 -4
  35. package/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.ts +130 -0
  36. package/Server/Utils/PasswordHash.ts +306 -0
  37. package/Tests/App/Dashboard/OverviewCustomFields.test.tsx +333 -0
  38. package/Tests/App/Dashboard/UserCustomFields.test.tsx +392 -0
  39. package/Tests/Server/API/AIAgentDataRepositoryToken.test.ts +381 -0
  40. package/Tests/Server/API/DashboardPublicAttributeValuesAPI.test.ts +943 -0
  41. package/Tests/Server/API/DashboardPublicMetricsAggregateAPI.test.ts +1296 -0
  42. package/Tests/Server/API/DashboardPublicTemplatePayloads.test.ts +598 -0
  43. package/Tests/Server/API/GitHubAppInstallationBindingAPI.test.ts +539 -0
  44. package/Tests/Server/API/Helpers.ts +6 -1
  45. package/Tests/Server/API/UserProjectsAPI.test.ts +852 -0
  46. package/Tests/Server/Infrastructure/GlobalCache.test.ts +128 -0
  47. package/Tests/Server/Infrastructure/SemaphoreMutex.test.ts +215 -0
  48. package/Tests/Server/Services/AddPerUserPasswordSaltMigration.test.ts +178 -0
  49. package/Tests/Server/Services/AnalyticsDatabasePaginationStability.test.ts +582 -0
  50. package/Tests/Server/Services/AnalyticsDatabaseService.test.ts +5 -0
  51. package/Tests/Server/Services/CodeRepositoryInstallationBinding.test.ts +305 -0
  52. package/Tests/Server/Services/CodeRepositoryResolutionBinding.test.ts +147 -0
  53. package/Tests/Server/Services/DatabaseServicePerUserPasswordSalt.test.ts +870 -0
  54. package/Tests/Server/Services/OpenTelemetryServiceResolutionCache.test.ts +568 -0
  55. package/Tests/Server/Services/RestoreDroppedUniqueIndexesMigration.test.ts +386 -0
  56. package/Tests/Server/Services/RestoreServiceLowerNameIndexMigration.test.ts +240 -0
  57. package/Tests/Server/Services/UserServiceFirstMasterAdminElection.test.ts +885 -0
  58. package/Tests/Server/Services/WidenHashedStringColumnsForScryptMigration.test.ts +220 -0
  59. package/Tests/Server/Utils/AI/CodeTools.test.ts +47 -1
  60. package/Tests/Server/Utils/AI/CodeWriteTools.test.ts +10 -0
  61. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +120 -0
  62. package/Tests/Server/Utils/CodeRepository/GitHubInstallationBinding.test.ts +292 -0
  63. package/Tests/Server/Utils/GitHubInstallationOwnershipVerification.test.ts +340 -0
  64. package/Tests/Server/Utils/GitHubWebhookAndTreeCacheIsolation.test.ts +250 -0
  65. package/Tests/Server/Utils/PasswordHash.test.ts +555 -0
  66. package/Tests/Types/Database/ColumnLength.test.ts +7 -1
  67. package/Tests/Types/HashedStringPerUserSalt.test.ts +476 -0
  68. package/Tests/Types/Monitor/DockerAlertTemplates.test.ts +293 -0
  69. package/Tests/Types/Monitor/HostAlertTemplates.test.ts +249 -0
  70. package/Tests/Types/Monitor/IotAlertTemplates.test.ts +320 -0
  71. package/Tests/Types/Monitor/PodmanAlertTemplates.test.ts +271 -0
  72. package/Tests/UI/Components/CustomFields/CustomFieldsDetail.test.tsx +696 -0
  73. package/Tests/UI/Utils/UserProjectsModelAPI.test.ts +714 -0
  74. package/Tests/Utils/TeamMembersByProject.test.ts +655 -0
  75. package/Types/Database/ColumnLength.ts +9 -1
  76. package/Types/Database/TableColumn.ts +11 -0
  77. package/Types/Database/UnsynchronizedIndex.ts +48 -0
  78. package/Types/HashedString.ts +139 -4
  79. package/UI/Components/CustomFields/CustomFieldsDetail.tsx +85 -11
  80. package/UI/Components/Navbar/NavBar.tsx +12 -2
  81. package/UI/Components/Navbar/NavBarMenuModal.tsx +19 -7
  82. package/UI/Utils/ModelAPI/UserProjectsModelAPI.ts +257 -0
  83. package/Utils/TeamMembersByProject.ts +237 -0
  84. package/build/dist/Models/DatabaseModels/CephCluster.js +16 -1
  85. package/build/dist/Models/DatabaseModels/CephCluster.js.map +1 -1
  86. package/build/dist/Models/DatabaseModels/CodeRepository.js +14 -6
  87. package/build/dist/Models/DatabaseModels/CodeRepository.js.map +1 -1
  88. package/build/dist/Models/DatabaseModels/DockerHost.js +16 -1
  89. package/build/dist/Models/DatabaseModels/DockerHost.js.map +1 -1
  90. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js +16 -1
  91. package/build/dist/Models/DatabaseModels/GlobalOidcProject.js.map +1 -1
  92. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js +16 -1
  93. package/build/dist/Models/DatabaseModels/GlobalSsoProject.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/IoTFleet.js +16 -1
  95. package/build/dist/Models/DatabaseModels/IoTFleet.js.map +1 -1
  96. package/build/dist/Models/DatabaseModels/KubernetesCluster.js +16 -1
  97. package/build/dist/Models/DatabaseModels/KubernetesCluster.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/NetworkDevice.js +16 -1
  99. package/build/dist/Models/DatabaseModels/NetworkDevice.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/NetworkInterface.js +16 -1
  101. package/build/dist/Models/DatabaseModels/NetworkInterface.js.map +1 -1
  102. package/build/dist/Models/DatabaseModels/Project.js +16 -2
  103. package/build/dist/Models/DatabaseModels/Project.js.map +1 -1
  104. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js +16 -1
  105. package/build/dist/Models/DatabaseModels/ProxmoxCluster.js.map +1 -1
  106. package/build/dist/Models/DatabaseModels/Service.js +28 -1
  107. package/build/dist/Models/DatabaseModels/Service.js.map +1 -1
  108. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js +32 -0
  109. package/build/dist/Models/DatabaseModels/StatusPagePrivateUser.js.map +1 -1
  110. package/build/dist/Models/DatabaseModels/User.js +32 -0
  111. package/build/dist/Models/DatabaseModels/User.js.map +1 -1
  112. package/build/dist/Server/API/AIAgentDataAPI.js +21 -2
  113. package/build/dist/Server/API/AIAgentDataAPI.js.map +1 -1
  114. package/build/dist/Server/API/DashboardAPI.js +304 -10
  115. package/build/dist/Server/API/DashboardAPI.js.map +1 -1
  116. package/build/dist/Server/API/GitHubAPI.js +94 -167
  117. package/build/dist/Server/API/GitHubAPI.js.map +1 -1
  118. package/build/dist/Server/API/UserAPI.js +187 -2
  119. package/build/dist/Server/API/UserAPI.js.map +1 -1
  120. package/build/dist/Server/EnvironmentConfig.js +7 -3
  121. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  122. package/build/dist/Server/Infrastructure/GlobalCache.js +29 -0
  123. package/build/dist/Server/Infrastructure/GlobalCache.js.map +1 -1
  124. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js +14 -0
  125. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786018109307-AddPerUserPasswordSalt.js.map +1 -0
  126. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js +59 -0
  127. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786023262402-WidenHashedStringColumnsForScrypt.js.map +1 -0
  128. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js +93 -0
  129. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786100000000-RestoreServiceLowerNameIndex.js.map +1 -0
  130. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js +101 -0
  131. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786200000000-RestoreDroppedUniqueIndexes.js.map +1 -0
  132. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js +66 -0
  133. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1786300000000-QuarantineUnboundGitHubInstallations.js.map +1 -0
  134. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +10 -0
  135. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  136. package/build/dist/Server/Services/AnalyticsDatabaseService.js +57 -1
  137. package/build/dist/Server/Services/AnalyticsDatabaseService.js.map +1 -1
  138. package/build/dist/Server/Services/CodeRepositoryService.js +90 -2
  139. package/build/dist/Server/Services/CodeRepositoryService.js.map +1 -1
  140. package/build/dist/Server/Services/DatabaseService.js +160 -2
  141. package/build/dist/Server/Services/DatabaseService.js.map +1 -1
  142. package/build/dist/Server/Services/OpenTelemetryIngestService.js +155 -25
  143. package/build/dist/Server/Services/OpenTelemetryIngestService.js.map +1 -1
  144. package/build/dist/Server/Services/UserService.js +165 -0
  145. package/build/dist/Server/Services/UserService.js.map +1 -1
  146. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js +31 -1
  147. package/build/dist/Server/Utils/AI/Toolbox/CodeTools.js.map +1 -1
  148. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +13 -0
  149. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  150. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js +126 -4
  151. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHub.js.map +1 -1
  152. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js +136 -0
  153. package/build/dist/Server/Utils/CodeRepository/GitHub/GitHubInstallationBinding.js.map +1 -0
  154. package/build/dist/Server/Utils/PasswordHash.js +237 -0
  155. package/build/dist/Server/Utils/PasswordHash.js.map +1 -0
  156. package/build/dist/Types/Database/ColumnLength.js +9 -1
  157. package/build/dist/Types/Database/ColumnLength.js.map +1 -1
  158. package/build/dist/Types/Database/TableColumn.js.map +1 -1
  159. package/build/dist/Types/Database/UnsynchronizedIndex.js +46 -0
  160. package/build/dist/Types/Database/UnsynchronizedIndex.js.map +1 -0
  161. package/build/dist/Types/HashedString.js +95 -5
  162. package/build/dist/Types/HashedString.js.map +1 -1
  163. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js +51 -10
  164. package/build/dist/UI/Components/CustomFields/CustomFieldsDetail.js.map +1 -1
  165. package/build/dist/UI/Components/Navbar/NavBar.js +3 -1
  166. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  167. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js +19 -7
  168. package/build/dist/UI/Components/Navbar/NavBarMenuModal.js.map +1 -1
  169. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js +156 -0
  170. package/build/dist/UI/Utils/ModelAPI/UserProjectsModelAPI.js.map +1 -0
  171. package/build/dist/Utils/TeamMembersByProject.js +145 -0
  172. package/build/dist/Utils/TeamMembersByProject.js.map +1 -0
  173. package/package.json +1 -1
@@ -1,10 +1,29 @@
1
+ import Team from "../../Models/DatabaseModels/Team";
2
+ import TeamMember from "../../Models/DatabaseModels/TeamMember";
1
3
  import User from "../../Models/DatabaseModels/User";
4
+ import BaseModel from "../../Models/DatabaseModels/DatabaseBaseModel/DatabaseBaseModel";
5
+ import Query from "../../Types/BaseDatabase/Query";
6
+ import LIMIT_MAX, { DEFAULT_LIMIT } from "../../Types/Database/LimitMax";
7
+ import BadDataException from "../../Types/Exception/BadDataException";
2
8
  import NotFoundException from "../../Types/Exception/NotFoundException";
9
+ import { JSONArray, JSONObject } from "../../Types/JSON";
3
10
  import ObjectID from "../../Types/ObjectID";
11
+ import PositiveNumber from "../../Types/PositiveNumber";
12
+ import TeamMembersByProject, {
13
+ UserProjectMembership,
14
+ } from "../../Utils/TeamMembersByProject";
15
+ import MasterAdminAuthorization from "../Middleware/MasterAdminAuthorization";
16
+ import TeamMemberService from "../Services/TeamMemberService";
4
17
  import UserService, {
5
18
  Service as UserServiceType,
6
19
  } from "../Services/UserService";
7
- import { ExpressRequest, ExpressResponse } from "../Utils/Express";
20
+ import CommonAPI from "./CommonAPI";
21
+ import DatabaseCommonInteractionProps from "../../Types/BaseDatabase/DatabaseCommonInteractionProps";
22
+ import {
23
+ ExpressRequest,
24
+ ExpressResponse,
25
+ NextFunction,
26
+ } from "../Utils/Express";
8
27
  import logger, { getLogAttributesFromRequest } from "../Utils/Logger";
9
28
  import Response from "../Utils/Response";
10
29
  import BaseAPI from "./BaseAPI";
@@ -16,6 +35,162 @@ export default class UserAPI extends BaseAPI<User, UserServiceType> {
16
35
  public constructor() {
17
36
  super(User, UserService);
18
37
 
38
+ /*
39
+ * Every project one user belongs to, one row per project, for the Admin
40
+ * Dashboard's User > Projects page.
41
+ *
42
+ * There is no CRUD list that answers this. `GET /team-member` scoped to a
43
+ * user returns MEMBERSHIPS - a (user, team) pair - so a user on three teams
44
+ * of one project comes back as three rows and reads as three projects. This
45
+ * endpoint folds those into one row per project carrying every team the
46
+ * user is on there, which is both what the page renders and what any
47
+ * caller asking "which projects is this person in?" actually wants.
48
+ *
49
+ * Master-admin only: it deliberately reads across every tenant, which is
50
+ * exactly what no project-scoped caller is allowed to do. The middleware is
51
+ * the gate; the read below runs as root because a master admin is not a
52
+ * member of the projects being listed and so has no tenant permissions to
53
+ * read them with.
54
+ */
55
+ this.router.post(
56
+ `${new this.entityType().getCrudApiPath()?.toString()}/:userId/projects`,
57
+ MasterAdminAuthorization.isAuthorizedMasterAdminMiddleware,
58
+ async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
59
+ try {
60
+ const userId: ObjectID = UserAPI.getUserIdFromParams(req);
61
+
62
+ const memberships: Array<TeamMember> = await TeamMemberService.findBy(
63
+ {
64
+ query: {
65
+ userId: userId,
66
+ },
67
+ select: {
68
+ _id: true,
69
+ projectId: true,
70
+ teamId: true,
71
+ hasAcceptedInvitation: true,
72
+ createdAt: true,
73
+ project: {
74
+ _id: true,
75
+ name: true,
76
+ slug: true,
77
+ },
78
+ team: {
79
+ _id: true,
80
+ name: true,
81
+ },
82
+ },
83
+ limit: LIMIT_MAX,
84
+ skip: 0,
85
+ sort: {},
86
+ props: {
87
+ isRoot: true,
88
+ },
89
+ },
90
+ );
91
+
92
+ const rows: Array<UserProjectMembership> =
93
+ TeamMembersByProject.sortByProjectName(
94
+ TeamMembersByProject.groupByProject(memberships),
95
+ );
96
+
97
+ const skip: number = UserAPI.getPositiveIntegerParam(
98
+ req.query["skip"],
99
+ 0,
100
+ );
101
+ const limit: number = UserAPI.getPositiveIntegerParam(
102
+ req.query["limit"],
103
+ DEFAULT_LIMIT,
104
+ );
105
+
106
+ const page: Array<UserProjectMembership> = rows.slice(
107
+ skip,
108
+ skip + limit,
109
+ );
110
+
111
+ return Response.sendJsonArrayResponse(
112
+ req,
113
+ res,
114
+ page.map((row: UserProjectMembership) => {
115
+ return UserAPI.serializeUserProjectMembership(userId, row);
116
+ }),
117
+ new PositiveNumber(rows.length),
118
+ );
119
+ } catch (err) {
120
+ return next(err);
121
+ }
122
+ },
123
+ );
124
+
125
+ /*
126
+ * Removes one user from one project - every team they belong to in it - in
127
+ * a single call.
128
+ *
129
+ * A row on the User > Projects page is a project, so "remove" there means
130
+ * "remove from the project". Doing that as N separate DELETEs from the
131
+ * browser is not equivalent: TeamMemberService.onBeforeDelete refuses to
132
+ * remove the last accepted member of a team that shouldHaveAtLeastOneMember
133
+ * (the Owners team), and it refuses per request - so a client-side loop
134
+ * deletes every other membership first and only then reports the failure,
135
+ * leaving the user stripped of the teams the admin was just told they had
136
+ * not lost. One deleteBy over the whole set runs that guard against all of
137
+ * the user's memberships in the project before anything is deleted.
138
+ *
139
+ * This is the master-admin twin of
140
+ * POST /team-member/remove-user-from-project, which takes its project from
141
+ * the request's tenant. A master admin is not a member of the project and
142
+ * sends no tenant, so the project is named in the body here - the
143
+ * master-admin middleware, not a tenant, is what authorizes the call.
144
+ *
145
+ * The delete runs with the CALLER'S props rather than as root, so the
146
+ * Owners-team and SCIM Push Groups guards still apply: a master admin can
147
+ * remove people, but not leave a project ownerless or fight the customer's
148
+ * identity provider behind its back.
149
+ */
150
+ this.router.post(
151
+ `${new this.entityType().getCrudApiPath()?.toString()}/:userId/remove-from-project`,
152
+ MasterAdminAuthorization.isAuthorizedMasterAdminMiddleware,
153
+ async (req: ExpressRequest, res: ExpressResponse, next: NextFunction) => {
154
+ try {
155
+ const userId: ObjectID = UserAPI.getUserIdFromParams(req);
156
+
157
+ const projectIdParam: string = (
158
+ (req.body?.["projectId"] as string) || ""
159
+ ).trim();
160
+
161
+ if (!projectIdParam) {
162
+ return Response.sendErrorResponse(
163
+ req,
164
+ res,
165
+ new BadDataException("Project ID is required"),
166
+ );
167
+ }
168
+
169
+ ObjectID.validateUUID(projectIdParam);
170
+
171
+ const props: DatabaseCommonInteractionProps =
172
+ await CommonAPI.getDatabaseCommonInteractionProps(req);
173
+
174
+ const numberOfMembershipsDeleted: number =
175
+ await TeamMemberService.deleteBy({
176
+ query: {
177
+ userId: userId,
178
+ projectId: new ObjectID(projectIdParam),
179
+ } as Query<TeamMember>,
180
+ limit: LIMIT_MAX,
181
+ skip: 0,
182
+ props: props,
183
+ });
184
+
185
+ return Response.sendJsonObjectResponse(req, res, {
186
+ numberOfMembershipsDeleted: numberOfMembershipsDeleted,
187
+ });
188
+ } catch (err) {
189
+ return next(err);
190
+ }
191
+ },
192
+ );
193
+
19
194
  this.router.get(
20
195
  `${new this.entityType().getCrudApiPath()?.toString()}/profile-picture/:userId`,
21
196
  async (req: ExpressRequest, res: ExpressResponse) => {
@@ -78,6 +253,93 @@ export default class UserAPI extends BaseAPI<User, UserServiceType> {
78
253
  );
79
254
  }
80
255
 
256
+ /**
257
+ * The `:userId` in the path, rejected loudly if it is not a uuid.
258
+ *
259
+ * Validating rather than trusting matters even behind the master-admin gate:
260
+ * the value flows into a query, and a malformed id should fail as bad input
261
+ * rather than reach the database layer.
262
+ */
263
+ private static getUserIdFromParams(req: ExpressRequest): ObjectID {
264
+ const userIdParam: string = ((req.params["userId"] as string) || "").trim();
265
+
266
+ if (!userIdParam) {
267
+ throw new BadDataException("User ID is required");
268
+ }
269
+
270
+ ObjectID.validateUUID(userIdParam);
271
+
272
+ return new ObjectID(userIdParam);
273
+ }
274
+
275
+ /**
276
+ * A non-negative integer from a query string, or the fallback.
277
+ *
278
+ * Anything unparseable (missing, "abc", "-5", "1e9999") falls back rather
279
+ * than becoming NaN - a NaN skip/limit turns Array.slice into "return
280
+ * everything from 0", which would silently ignore paging instead of failing.
281
+ */
282
+ private static getPositiveIntegerParam(
283
+ value: unknown,
284
+ fallback: number,
285
+ ): number {
286
+ if (value === undefined || value === null || value === "") {
287
+ return fallback;
288
+ }
289
+
290
+ const parsed: number = Number(value.toString());
291
+
292
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed) || parsed < 0) {
293
+ return fallback;
294
+ }
295
+
296
+ return parsed;
297
+ }
298
+
299
+ /**
300
+ * One grouped row as JSON.
301
+ *
302
+ * The row is sent as a TeamMember - the model the row is built from, and the
303
+ * one the Admin Dashboard's table is typed against - carrying the project it
304
+ * stands for. The aggregate fields (`teams`, the counts) are not columns of
305
+ * TeamMember, so they are attached after serialization; the client reads them
306
+ * off the raw JSON before hydrating the model.
307
+ */
308
+ private static serializeUserProjectMembership(
309
+ userId: ObjectID,
310
+ row: UserProjectMembership,
311
+ ): JSONObject {
312
+ const teamMember: TeamMember = new TeamMember();
313
+
314
+ if (row.id) {
315
+ teamMember._id = row.id;
316
+ }
317
+
318
+ teamMember.userId = userId;
319
+ teamMember.hasAcceptedInvitation = row.hasAcceptedInvitation;
320
+
321
+ if (row.projectId) {
322
+ teamMember.projectId = row.projectId;
323
+ }
324
+
325
+ if (row.project) {
326
+ teamMember.project = row.project;
327
+ }
328
+
329
+ if (row.joinedAt) {
330
+ teamMember.createdAt = row.joinedAt;
331
+ }
332
+
333
+ const json: JSONObject = BaseModel.toJSON(teamMember, TeamMember);
334
+
335
+ json["teams"] = BaseModel.toJSONArray(row.teams, Team) as JSONArray;
336
+ json["teamCount"] = row.teams.length;
337
+ json["pendingTeamCount"] = row.pendingTeamCount;
338
+ json["teamMemberIds"] = [...row.teamMemberIds];
339
+
340
+ return json;
341
+ }
342
+
81
343
  private sendBlankProfile(req: ExpressRequest, res: ExpressResponse): void {
82
344
  Response.setNoCacheHeaders(res);
83
345
 
@@ -477,10 +477,14 @@ export const DisableQueueWorkers: boolean =
477
477
  * When "false", this process does NOT run schema or data migrations on boot.
478
478
  * Set on runtime pods (app/worker/nginx) when a dedicated one-shot migrate Job
479
479
  * (App/Migrate.ts) owns migrations instead, so the fleet's many replicas never
480
- * run them — which is what makes PgBouncer transaction-mode pooling safe (the
481
- * data-migration session advisory lock then only ever runs in the single Job).
480
+ * run them — which keeps boot DDL off pooled connections and, since the data
481
+ * migration runner no longer takes an advisory lock, is also what keeps two
482
+ * replicas from running the same migration concurrently.
483
+ *
482
484
  * Default true preserves the original self-migrating-on-boot behavior used by
483
- * docker-compose and any deploy that does not run the migrate Job.
485
+ * docker-compose and any deploy that does not run the migrate Job. Those
486
+ * deployments DO run several unserialized runners, so data migrations must be
487
+ * written to tolerate it (see Workers/Utils/DataMigration.ts).
484
488
  */
485
489
  export const RunDatabaseMigrationsOnBoot: boolean =
486
490
  process.env["RUN_DATABASE_MIGRATIONS_ON_BOOT"] !== "false";
@@ -291,6 +291,43 @@ export default abstract class GlobalCache {
291
291
  return Math.ceil(expiresInSeconds * (1 + Math.random() * 0.25));
292
292
  }
293
293
 
294
+ /*
295
+ * Atomic compare-and-delete: drop the key only if it still holds `value`.
296
+ * Returns true only when this caller's value was the one removed.
297
+ *
298
+ * This is the release half of a lease taken with `setStringIfNotExists`, and
299
+ * the comparison is what makes the release safe. A plain `deleteKey` is a
300
+ * check-then-act race in disguise: if the holder overruns its TTL the key
301
+ * expires, a second worker legitimately acquires the lease, and the first
302
+ * worker's release then deletes the SECOND worker's lease — handing the same
303
+ * lease to a third. Comparing the holder token collapses that into one
304
+ * atomic evaluation, so a late release is a no-op instead of a double-grant.
305
+ *
306
+ * The key is passed as KEYS[1] rather than inlined into the script body so
307
+ * the script stays correct on Redis Cluster, which routes by declared keys.
308
+ */
309
+ @CaptureSpan()
310
+ public static async deleteKeyIfValue(
311
+ namespace: string,
312
+ key: string,
313
+ value: string,
314
+ ): Promise<boolean> {
315
+ const client: ClientType | null = Redis.getClient();
316
+
317
+ if (!client || !Redis.isConnected()) {
318
+ throw new DatabaseNotConnectedException("Cache is not connected");
319
+ }
320
+
321
+ const result: unknown = await client.eval(
322
+ "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end",
323
+ 1,
324
+ `${namespace}-${key}`,
325
+ value,
326
+ );
327
+
328
+ return result === 1;
329
+ }
330
+
294
331
  @CaptureSpan()
295
332
  public static async deleteKey(namespace: string, key: string): Promise<void> {
296
333
  const client: ClientType | null = Redis.getClient();
@@ -0,0 +1,21 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ export class AddPerUserPasswordSalt1786018109307 implements MigrationInterface {
4
+ public name = "AddPerUserPasswordSalt1786018109307";
5
+
6
+ public async up(queryRunner: QueryRunner): Promise<void> {
7
+ await queryRunner.query(
8
+ `ALTER TABLE "User" ADD "passwordSalt" character varying(100)`,
9
+ );
10
+ await queryRunner.query(
11
+ `ALTER TABLE "StatusPagePrivateUser" ADD "passwordSalt" character varying(100)`,
12
+ );
13
+ }
14
+
15
+ public async down(queryRunner: QueryRunner): Promise<void> {
16
+ await queryRunner.query(
17
+ `ALTER TABLE "StatusPagePrivateUser" DROP COLUMN "passwordSalt"`,
18
+ );
19
+ await queryRunner.query(`ALTER TABLE "User" DROP COLUMN "passwordSalt"`);
20
+ }
21
+ }
@@ -0,0 +1,68 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ /*
4
+ * Widen every HashedString column from varchar(64) to varchar(255).
5
+ *
6
+ * A bare SHA-256 digest is 64 hex characters, which is exactly what the old
7
+ * column held. User passwords are now scrypt hashes that carry the cost
8
+ * parameters they were produced with — `scrypt$N=16384,r=8,p=1$<64 hex>`,
9
+ * about 90 characters — so that raising the cost later needs no migration and
10
+ * no password reset. The other HashedString columns (session refresh tokens,
11
+ * master passwords) still store a bare digest and simply do not use the room;
12
+ * they are widened only because they share ColumnLength.HashedString.
13
+ *
14
+ * WHY THIS FILE IS NOT WHAT `migration:generate` PRODUCED.
15
+ *
16
+ * TypeORM does not express a varchar length change as ALTER COLUMN TYPE. It
17
+ * emits DROP COLUMN followed by ADD COLUMN, which for these six columns means
18
+ * deleting every password, every status page and dashboard master password,
19
+ * and every active session in the instance. The generated `up()` also had to
20
+ * drop and recreate the refresh-token unique constraints and indexes, purely
21
+ * to work around its own DROP.
22
+ *
23
+ * ALTER COLUMN TYPE to a WIDER varchar is the correct statement and is
24
+ * cheaper besides: Postgres skips the table rewrite entirely when a varchar
25
+ * is only being lengthened (no rewrite means the unique indexes survive
26
+ * untouched too), so this is a catalog update behind a brief ACCESS EXCLUSIVE
27
+ * lock rather than a full copy of six tables.
28
+ *
29
+ * The resulting schema is identical to what the generated migration would
30
+ * have produced, which is what `npm run check-postgres-schema-drift` verifies.
31
+ */
32
+
33
+ const WIDENED_COLUMNS: ReadonlyArray<{ table: string; column: string }> = [
34
+ { table: "User", column: "password" },
35
+ { table: "StatusPagePrivateUser", column: "password" },
36
+ { table: "StatusPage", column: "masterPassword" },
37
+ { table: "Dashboard", column: "masterPassword" },
38
+ { table: "UserSession", column: "refreshToken" },
39
+ { table: "StatusPagePrivateUserSession", column: "refreshToken" },
40
+ ];
41
+
42
+ export class WidenHashedStringColumnsForScrypt1786023262402
43
+ implements MigrationInterface
44
+ {
45
+ public name = "WidenHashedStringColumnsForScrypt1786023262402";
46
+
47
+ public async up(queryRunner: QueryRunner): Promise<void> {
48
+ for (const { table, column } of WIDENED_COLUMNS) {
49
+ await queryRunner.query(
50
+ `ALTER TABLE "${table}" ALTER COLUMN "${column}" TYPE character varying(255)`,
51
+ );
52
+ }
53
+ }
54
+
55
+ /*
56
+ * Narrowing back to 64 fails loudly if any scrypt hash has been written,
57
+ * because those do not fit. That is the intended behaviour: a down
58
+ * migration that truncated password hashes would lock every user out
59
+ * silently instead of refusing.
60
+ */
61
+ public async down(queryRunner: QueryRunner): Promise<void> {
62
+ for (const { table, column } of [...WIDENED_COLUMNS].reverse()) {
63
+ await queryRunner.query(
64
+ `ALTER TABLE "${table}" ALTER COLUMN "${column}" TYPE character varying(64)`,
65
+ );
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,100 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ /*
4
+ * Restores the expression index that serves the telemetry ingest path's
5
+ * `service.name` lookup, and that an autogenerated migration deleted.
6
+ *
7
+ * WHAT BROKE
8
+ *
9
+ * `OTelIngestService.findOrCreateTelemetryService` resolves an OTLP resource's
10
+ * `service.name` to a Service row through `QueryHelper.findWithSameText`, which
11
+ * emits a case-insensitive predicate:
12
+ *
13
+ * WHERE "projectId" = $1 AND LOWER("name") = $2 AND "deletedAt" IS NULL
14
+ * ORDER BY "createdAt" ASC LIMIT 1
15
+ *
16
+ * Every live index on Service is over the RAW `name` column. Postgres cannot
17
+ * invert `lower()` (it is not order-preserving under any non-C collation) and
18
+ * PG16 has no index-condition pushdown, so `LOWER("name") = $2` degrades to a
19
+ * post-heap-fetch Filter. Only the `projectId` prefix acts as an index qual,
20
+ * which makes each lookup cost O(services in that project) heap fetches plus
21
+ * that many collation-aware lower() calls — not O(log n).
22
+ *
23
+ * That query runs once per OTLP *resource* (hundreds per batch for the shipped
24
+ * host-metrics collector config), again per signal type, from every ingest pod.
25
+ * Production sat at 100% CPU against a 100% buffer cache hit ratio and ~110 B/s
26
+ * of reads: entirely in memory, entirely CPU.
27
+ *
28
+ * WHY IT BROKE
29
+ *
30
+ * The correct index existed. Migration 1774559064921 created:
31
+ *
32
+ * CREATE UNIQUE INDEX "IDX_SERVICE_PROJECT_NAME_UNIQUE"
33
+ * ON "Service" ("projectId", LOWER("name")) WHERE "deletedAt" IS NULL
34
+ *
35
+ * Migration 1775735059360 — otherwise two unrelated column-default changes —
36
+ * dropped it. TypeORM's schema builder matches database indexes to entity
37
+ * metadata BY NAME and drops any it cannot find, and a hand-named expression
38
+ * index created only in a migration is invisible to entity metadata. Its down()
39
+ * "restores" the index as ("projectId") alone, silently losing the expression,
40
+ * which is the signature of a machine-generated statement rather than a
41
+ * deliberate one.
42
+ *
43
+ * The Service entity now declares this index with `synchronize: false`, which
44
+ * makes the schema builder skip it in both directions. Without that decorator
45
+ * this migration would be undone again by the next autogenerated one.
46
+ *
47
+ * WHY THIS IS HAND-WRITTEN
48
+ *
49
+ * AGENTS.md requires migrations to come from `npm run generate-postgres-migration`.
50
+ * That cannot produce this one: `@Index()` has no way to express `LOWER(...)`,
51
+ * so TypeORM can neither generate nor round-trip an expression index. The
52
+ * generator is authoritative for column and table drift; expression indexes are
53
+ * outside what it can model.
54
+ *
55
+ * NOT UNIQUE, DELIBERATELY
56
+ *
57
+ * The dropped original was UNIQUE. Recreating it that way would make this
58
+ * migration fail on any project that already holds two services whose names
59
+ * differ only by case — which the surviving raw-name unique index permits, so
60
+ * such rows can legitimately exist today. Failing a deploy to enforce an
61
+ * invariant nobody is currently relying on is the wrong trade: this index
62
+ * exists to make the lookup fast. `ORDER BY "createdAt" ASC LIMIT 1` in the
63
+ * query already resolves duplicates deterministically (oldest wins), and the
64
+ * raw-name unique index still blocks exact-duplicate races. Restoring
65
+ * case-insensitive uniqueness would need a dedupe pass first, as migration
66
+ * 1778100000000 did, and belongs in its own change.
67
+ *
68
+ * `createdAt` is the third key column so the ORDER BY is satisfied by the index
69
+ * itself and the plan carries no Sort node even when duplicates exist.
70
+ *
71
+ * NOT CONCURRENT, DELIBERATELY
72
+ *
73
+ * DataSourceOptions sets no `migrationsTransactionMode`, so TypeORM defaults to
74
+ * "all" and runs every migration inside ONE transaction. `CREATE INDEX
75
+ * CONCURRENTLY` cannot run inside a transaction block and would abort the whole
76
+ * run. A plain CREATE INDEX takes a SHARE lock that blocks writes to Service
77
+ * for the duration, which is milliseconds at this table's size (thousands of
78
+ * live rows).
79
+ *
80
+ * IF NOT EXISTS makes this a no-op when an operator has already created the
81
+ * index by hand — the recommended incident mitigation is to build it
82
+ * CONCURRENTLY on the live database under this exact name, ahead of the deploy.
83
+ */
84
+ export class RestoreServiceLowerNameIndex1786100000000
85
+ implements MigrationInterface
86
+ {
87
+ public name: string = "RestoreServiceLowerNameIndex1786100000000";
88
+
89
+ public async up(queryRunner: QueryRunner): Promise<void> {
90
+ await queryRunner.query(
91
+ `CREATE INDEX IF NOT EXISTS "IDX_SERVICE_PROJECT_LOWER_NAME" ON "Service" ("projectId", LOWER("name"), "createdAt") WHERE "deletedAt" IS NULL`,
92
+ );
93
+ }
94
+
95
+ public async down(queryRunner: QueryRunner): Promise<void> {
96
+ await queryRunner.query(
97
+ `DROP INDEX IF EXISTS "public"."IDX_SERVICE_PROJECT_LOWER_NAME"`,
98
+ );
99
+ }
100
+ }