@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
@@ -0,0 +1,214 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ /*
4
+ * Restores nine UNIQUE indexes that autogenerated migrations deleted, and that
5
+ * no migration ever put back.
6
+ *
7
+ * HOW THEY WENT MISSING
8
+ *
9
+ * TypeORM's schema builder matches database indexes to entity metadata BY NAME
10
+ * (RdbmsSchemaBuilder.dropOldIndices) and drops every index it cannot find a
11
+ * match for. A hand-named index created only in a migration is invisible to
12
+ * entity metadata, so the next `migration:generate` emits a DROP for it, mixed
13
+ * in with whatever change was actually intended.
14
+ *
15
+ * Found by auditing every hand-named index ever created in a migration up()
16
+ * against a database with all 504 registered migrations applied. 308 such
17
+ * indexes exist; 302 are absent from the migrated schema. Almost all of those
18
+ * are benign — TypeORM re-created an equivalent plain index under its own
19
+ * canonical IDX_<hash> name. The ten that were NOT benign carried a UNIQUE,
20
+ * partial, or expression definition that TypeORM cannot express and therefore
21
+ * never re-created. One of the ten (the Service LOWER(name) index) was restored
22
+ * separately in migration 1786100000000. This handles the other nine.
23
+ *
24
+ * Each one is a lost integrity guarantee, not just a lost index:
25
+ *
26
+ * slug uniqueness CephCluster, DockerHost, IoTFleet, KubernetesCluster,
27
+ * NetworkDevice, ProxmoxCluster — the slug is the public URL
28
+ * identifier for the resource. These six tables currently
29
+ * have NO index on slug at all, so slug lookups are also
30
+ * sequential scans.
31
+ * SSO/OIDC binding GlobalOIDCProject, GlobalSSOProject — one mapping row per
32
+ * (provider, project). Duplicates make a project's identity
33
+ * binding ambiguous.
34
+ * interface ids NetworkInterface — a device cannot have two live
35
+ * interfaces at the same SNMP ifIndex. App-level
36
+ * @UniqueColumnBy does a find-then-insert, which races under
37
+ * concurrency exactly as it did for Service before migration
38
+ * 1778100000000 added a DB constraint.
39
+ *
40
+ * PARTIAL, WHERE THE ORIGINALS MOSTLY WERE NOT
41
+ *
42
+ * Only IDX_network_interface_device_ifindex was originally partial. The other
43
+ * eight were unqualified UNIQUE indexes, and they are restored here as partial
44
+ * on `deletedAt IS NULL`. This is a deliberate change, for three reasons:
45
+ *
46
+ * 1. It matches how the data is actually read. Every query the app issues
47
+ * carries `deletedAt IS NULL` (DatabaseService adds it to every read that
48
+ * is not explicitly withDeleted), so nothing loses index coverage.
49
+ * 2. An unqualified unique index lets a soft-deleted row hold a slug
50
+ * hostage forever — delete a cluster and you can never create another one
51
+ * that slugifies the same way. That is a bug, and restoring it verbatim
52
+ * would restore the bug with it.
53
+ * 3. It makes the repair below non-destructive: a duplicate can be taken out
54
+ * of the index by soft-deleting it rather than by hard-deleting a row.
55
+ *
56
+ * REPAIR BEFORE CREATE
57
+ *
58
+ * A UNIQUE index cannot be created over existing duplicates, and a migration
59
+ * that can fail on real data is a migration that blocks a deploy. Each index is
60
+ * therefore preceded by a repair step that is deterministic (oldest row always
61
+ * wins: createdAt, then _id) and non-destructive:
62
+ *
63
+ * - slugs are RENAMED, not deleted. The losing row keeps all its data and
64
+ * gets a discriminator appended. Slugs already end in a random 10-digit
65
+ * suffix (see Common/Utils/Slug.ts), so collisions should not exist at all;
66
+ * this is a guard, not an expected path.
67
+ * - duplicate mapping / interface rows are SOFT-deleted, which removes them
68
+ * from a partial index without destroying anything. Nothing is hard-deleted
69
+ * and no foreign key is touched, so no cascade can fire.
70
+ *
71
+ * Every statement is idempotent: the repairs are no-ops when there are no
72
+ * duplicates, and each CREATE uses IF NOT EXISTS so an index built by hand
73
+ * ahead of the deploy does not fail the migration.
74
+ *
75
+ * NOT CONCURRENT
76
+ *
77
+ * DataSourceOptions sets no `migrationsTransactionMode`, so TypeORM defaults to
78
+ * "all" and runs every migration inside one transaction. CREATE INDEX
79
+ * CONCURRENTLY cannot run inside a transaction block and would abort the run.
80
+ * These tables are small (infrastructure inventory, not telemetry), so the
81
+ * brief SHARE lock is not a concern.
82
+ *
83
+ * HAND-WRITTEN
84
+ *
85
+ * AGENTS.md requires migrations to come from generate-postgres-migration. It
86
+ * cannot produce this one: @Index() can express neither a partial index nor the
87
+ * repair steps. The entities declare each index with `synchronize: false` so
88
+ * the generator leaves them alone from here on.
89
+ */
90
+
91
+ interface SlugTable {
92
+ table: string;
93
+ indexName: string;
94
+ }
95
+
96
+ const SLUG_TABLES: Array<SlugTable> = [
97
+ { table: "CephCluster", indexName: "IDX_ceph_cluster_slug" },
98
+ { table: "DockerHost", indexName: "IDX_docker_host_slug" },
99
+ { table: "IoTFleet", indexName: "IDX_iot_fleet_slug" },
100
+ { table: "KubernetesCluster", indexName: "IDX_kubernetes_cluster_slug" },
101
+ { table: "NetworkDevice", indexName: "IDX_network_device_slug" },
102
+ { table: "ProxmoxCluster", indexName: "IDX_proxmox_cluster_slug" },
103
+ ];
104
+
105
+ interface PairIndex {
106
+ table: string;
107
+ indexName: string;
108
+ columns: Array<string>;
109
+ }
110
+
111
+ const PAIR_INDEXES: Array<PairIndex> = [
112
+ {
113
+ table: "GlobalOIDCProject",
114
+ indexName: "IDX_GlobalOIDCProject_globalOidcId_projectId",
115
+ columns: ["globalOidcId", "projectId"],
116
+ },
117
+ {
118
+ table: "GlobalSSOProject",
119
+ indexName: "IDX_GlobalSSOProject_globalSsoId_projectId",
120
+ columns: ["globalSsoId", "projectId"],
121
+ },
122
+ {
123
+ table: "NetworkInterface",
124
+ indexName: "IDX_network_interface_device_ifindex",
125
+ columns: ["networkDeviceId", "interfaceIndex"],
126
+ },
127
+ ];
128
+
129
+ export class RestoreDroppedUniqueIndexes1786200000000
130
+ implements MigrationInterface
131
+ {
132
+ public name: string = "RestoreDroppedUniqueIndexes1786200000000";
133
+
134
+ public async up(queryRunner: QueryRunner): Promise<void> {
135
+ /*
136
+ * Slug tables: rename the losers rather than removing them. Oldest row
137
+ * keeps the slug; every other live row with the same slug gets the first
138
+ * eight characters of its own _id appended, which is unique by
139
+ * construction and keeps the slug URL-safe.
140
+ */
141
+ for (const { table, indexName } of SLUG_TABLES) {
142
+ await queryRunner.query(`
143
+ WITH ranked AS (
144
+ SELECT _id,
145
+ ROW_NUMBER() OVER (
146
+ PARTITION BY "slug"
147
+ ORDER BY "createdAt" ASC, _id ASC
148
+ ) AS rn
149
+ FROM "${table}"
150
+ WHERE "deletedAt" IS NULL AND "slug" IS NOT NULL
151
+ )
152
+ UPDATE "${table}" t
153
+ SET "slug" = t."slug" || '-' || LEFT(t._id::text, 8)
154
+ FROM ranked r
155
+ WHERE t._id = r._id AND r.rn > 1;
156
+ `);
157
+
158
+ await queryRunner.query(
159
+ `CREATE UNIQUE INDEX IF NOT EXISTS "${indexName}" ON "${table}" ("slug") WHERE "deletedAt" IS NULL`,
160
+ );
161
+ }
162
+
163
+ /*
164
+ * Composite tables: soft-delete the losers. The index is partial on
165
+ * `deletedAt IS NULL`, so stamping deletedAt removes the row from the
166
+ * index while leaving every column intact and every foreign key valid.
167
+ */
168
+ for (const { table, indexName, columns } of PAIR_INDEXES) {
169
+ const partitionBy: string = columns
170
+ .map((column: string): string => {
171
+ return `"${column}"`;
172
+ })
173
+ .join(", ");
174
+
175
+ const notNull: string = columns
176
+ .map((column: string): string => {
177
+ return `"${column}" IS NOT NULL`;
178
+ })
179
+ .join(" AND ");
180
+
181
+ await queryRunner.query(`
182
+ WITH ranked AS (
183
+ SELECT _id,
184
+ ROW_NUMBER() OVER (
185
+ PARTITION BY ${partitionBy}
186
+ ORDER BY "createdAt" ASC, _id ASC
187
+ ) AS rn
188
+ FROM "${table}"
189
+ WHERE "deletedAt" IS NULL AND ${notNull}
190
+ )
191
+ UPDATE "${table}" t
192
+ SET "deletedAt" = CURRENT_TIMESTAMP
193
+ FROM ranked r
194
+ WHERE t._id = r._id AND r.rn > 1;
195
+ `);
196
+
197
+ await queryRunner.query(
198
+ `CREATE UNIQUE INDEX IF NOT EXISTS "${indexName}" ON "${table}" (${partitionBy}) WHERE "deletedAt" IS NULL`,
199
+ );
200
+ }
201
+ }
202
+
203
+ public async down(queryRunner: QueryRunner): Promise<void> {
204
+ /*
205
+ * Only the indexes are reversible. The repair is not: a renamed slug and a
206
+ * soft-deleted duplicate cannot be distinguished afterwards from one the
207
+ * application produced, and guessing would corrupt live data. Dropping the
208
+ * indexes is enough to restore the previous schema shape.
209
+ */
210
+ for (const { indexName } of [...SLUG_TABLES, ...PAIR_INDEXES]) {
211
+ await queryRunner.query(`DROP INDEX IF EXISTS "public"."${indexName}"`);
212
+ }
213
+ }
214
+ }
@@ -0,0 +1,69 @@
1
+ import { MigrationInterface, QueryRunner } from "typeorm";
2
+
3
+ /*
4
+ * GHSA-xx95-gmcf-7q86 — data-side remediation.
5
+ *
6
+ * The code fix closes every way to write an installation ID that a project
7
+ * does not own, and re-derives the binding from Project.gitHubAppInstallationId
8
+ * before minting a token. Neither reaches rows that are ALREADY in the
9
+ * database. Before the fix a caller could put any installation ID on a
10
+ * CodeRepository row in a project they owned, and several code paths (the AI
11
+ * chat read/write tools, the pull-request sync worker) mint from that column
12
+ * directly. So any such row left in place stays a live cross-tenant token.
13
+ *
14
+ * This migration NULLs the installation ID on every repository row that
15
+ * contradicts its own project. That is decidable and safe: the project's own
16
+ * binding is the authority, so a row disagreeing with it was either poisoned
17
+ * or is stale after a reinstall. Nothing is deleted — the repository row keeps
18
+ * its name, commands and history, and reconnecting the GitHub App repopulates
19
+ * the ID through the verified callback.
20
+ *
21
+ * Hand-written on purpose: this changes no schema, so the entity-diff
22
+ * generator (npm run generate-postgres-migration) produces nothing for it, and
23
+ * the schema-drift check is unaffected.
24
+ *
25
+ * WHAT THIS MIGRATION DELIBERATELY DOES NOT DO
26
+ *
27
+ * It does not clear Project.gitHubAppInstallationId. Every one of those was
28
+ * written by the old, unverified callback, but they are overwhelmingly
29
+ * legitimate and nulling them would disconnect every existing GitHub
30
+ * integration. There is also no way in SQL to tell a forged binding from a
31
+ * real one.
32
+ *
33
+ * Operators who want to audit for the callback-forgery path should look for
34
+ * one installation bound to several projects, which is the fingerprint (it is
35
+ * legal — an org can connect one installation to several projects — so review
36
+ * the results, do not delete them):
37
+ *
38
+ * SELECT "gitHubAppInstallationId", count(*), array_agg("_id")
39
+ * FROM "Project"
40
+ * WHERE "gitHubAppInstallationId" IS NOT NULL AND "deletedAt" IS NULL
41
+ * GROUP BY "gitHubAppInstallationId" HAVING count(*) > 1;
42
+ */
43
+ export class QuarantineUnboundGitHubInstallations1786300000000
44
+ implements MigrationInterface
45
+ {
46
+ public name = "QuarantineUnboundGitHubInstallations1786300000000";
47
+
48
+ public async up(queryRunner: QueryRunner): Promise<void> {
49
+ await queryRunner.query(`
50
+ UPDATE "CodeRepository" AS cr
51
+ SET "gitHubAppInstallationId" = NULL
52
+ FROM "Project" AS p
53
+ WHERE cr."projectId" = p."_id"
54
+ AND cr."gitHubAppInstallationId" IS NOT NULL
55
+ AND (
56
+ p."gitHubAppInstallationId" IS NULL
57
+ OR p."gitHubAppInstallationId" <> cr."gitHubAppInstallationId"
58
+ )
59
+ `);
60
+ }
61
+
62
+ public async down(): Promise<void> {
63
+ /*
64
+ * Not reversible, and must not be. The quarantined values are exactly the
65
+ * bindings that could not be justified; restoring them would restore the
66
+ * vulnerability, and the rows carry no record of what was cleared.
67
+ */
68
+ }
69
+ }
@@ -499,6 +499,11 @@ import { AddStatusPageReportPeriod1785930709405 } from "./1785930709405-AddStatu
499
499
  import { AddIncidentInvestigationGating1785790000000 } from "./1785790000000-AddIncidentInvestigationGating";
500
500
  import { AddRemediationVerification1785768089408 } from "./1785768089408-AddRemediationVerification";
501
501
  import { AddStatusPageMonitorRule1786005052769 } from "./1786005052769-AddStatusPageMonitorRule";
502
+ import { AddPerUserPasswordSalt1786018109307 } from "./1786018109307-AddPerUserPasswordSalt";
503
+ import { WidenHashedStringColumnsForScrypt1786023262402 } from "./1786023262402-WidenHashedStringColumnsForScrypt";
504
+ import { RestoreServiceLowerNameIndex1786100000000 } from "./1786100000000-RestoreServiceLowerNameIndex";
505
+ import { RestoreDroppedUniqueIndexes1786200000000 } from "./1786200000000-RestoreDroppedUniqueIndexes";
506
+ import { QuarantineUnboundGitHubInstallations1786300000000 } from "./1786300000000-QuarantineUnboundGitHubInstallations";
502
507
 
503
508
  export default [
504
509
  InitialMigration,
@@ -1002,4 +1007,9 @@ export default [
1002
1007
  WidenInventoryResourceNameColumns1785930000000,
1003
1008
  AddStatusPageReportPeriod1785930709405,
1004
1009
  AddStatusPageMonitorRule1786005052769,
1010
+ AddPerUserPasswordSalt1786018109307,
1011
+ WidenHashedStringColumnsForScrypt1786023262402,
1012
+ RestoreServiceLowerNameIndex1786100000000,
1013
+ RestoreDroppedUniqueIndexes1786200000000,
1014
+ QuarantineUnboundGitHubInstallations1786300000000,
1005
1015
  ];
@@ -1440,6 +1440,75 @@ export default class AnalyticsDatabaseService<
1440
1440
  return { statement, columns: select.columns };
1441
1441
  }
1442
1442
 
1443
+ /**
1444
+ * Append the unique `_id` as a final sort key so the ORDER BY is a TOTAL
1445
+ * order.
1446
+ *
1447
+ * `ORDER BY time DESC` alone is not: rows tying on `time` may come back
1448
+ * in any order, and ClickHouse is free to order them differently between
1449
+ * two executions of the same query (part order, thread scheduling and
1450
+ * `optimize_read_in_order` all feed into it). Paging with LIMIT/OFFSET on
1451
+ * top of a non-total order silently drops rows at page boundaries and
1452
+ * repeats others: a row that sat at offset 49 on page 1 can shift to
1453
+ * offset 50 before page 2 is fetched, so nobody ever sees it.
1454
+ *
1455
+ * Ties are the normal case, not an edge case — a service emitting a burst
1456
+ * of logs writes many rows with an identical timestamp.
1457
+ *
1458
+ * `_id` is stamped per row with `ObjectID.generateTimeOrdered()`, so it is
1459
+ * unique and its ordering agrees with time order.
1460
+ *
1461
+ * Two cases must NOT get the tiebreaker:
1462
+ * - GROUP BY queries, where `_id` is neither a grouping key nor an
1463
+ * aggregate, so referencing it is an error.
1464
+ * - Models with no `_id` column: derived aggregate targets deliberately
1465
+ * omit it because the aggregation key is the row identity (see _findBy).
1466
+ */
1467
+ private toPaginationStableSort(
1468
+ sort: Sort<TBaseModel>,
1469
+ options: { hasGroupBy: boolean },
1470
+ ): Sort<TBaseModel> {
1471
+ if (options.hasGroupBy) {
1472
+ return sort;
1473
+ }
1474
+
1475
+ const sortKeys: Array<string> = Object.keys(sort || {});
1476
+
1477
+ // Already a total order; adding `_id` again would be a no-op term.
1478
+ if (sortKeys.includes("_id")) {
1479
+ return sort;
1480
+ }
1481
+
1482
+ const hasIdColumn: boolean = this.model.tableColumns.some(
1483
+ (column: AnalyticsTableColumn): boolean => {
1484
+ return column.key === "_id";
1485
+ },
1486
+ );
1487
+
1488
+ if (!hasIdColumn) {
1489
+ return sort;
1490
+ }
1491
+
1492
+ /*
1493
+ * Tie-break in the same direction as the last declared sort key, so a
1494
+ * newest-first list stays newest-first within a single timestamp.
1495
+ */
1496
+ const lastSortKey: string | undefined = sortKeys[sortKeys.length - 1];
1497
+
1498
+ const direction: SortOrder =
1499
+ (lastSortKey
1500
+ ? ((sort as Record<string, SortOrder | undefined>)[lastSortKey] as
1501
+ | SortOrder
1502
+ | undefined)
1503
+ : undefined) ?? SortOrder.Descending;
1504
+
1505
+ /*
1506
+ * `_id` is absent from `sort` (checked above), so spreading appends it
1507
+ * last and the caller's own keys keep their relative order.
1508
+ */
1509
+ return { ...sort, _id: direction } as Sort<TBaseModel>;
1510
+ }
1511
+
1443
1512
  public toFindStatement(findBy: FindBy<TBaseModel>): {
1444
1513
  statement: Statement;
1445
1514
  columns: Array<string>;
@@ -1470,7 +1539,9 @@ export default class AnalyticsDatabaseService<
1470
1539
  );
1471
1540
 
1472
1541
  const sortStatement: Statement = this.statementGenerator.toSortStatement(
1473
- findBy.sort!,
1542
+ this.toPaginationStableSort(findBy.sort!, {
1543
+ hasGroupBy: Boolean(groupByStatement),
1544
+ }),
1474
1545
  );
1475
1546
 
1476
1547
  const statement: Statement = SQL``;
@@ -14,6 +14,11 @@ import {
14
14
  import GitHubUtil, {
15
15
  GitHubRepository,
16
16
  } from "../Utils/CodeRepository/GitHub/GitHub";
17
+ import GitHubInstallationBinding from "../Utils/CodeRepository/GitHub/GitHubInstallationBinding";
18
+ import BadDataException from "../../Types/Exception/BadDataException";
19
+ import CreateBy from "../Types/Database/CreateBy";
20
+ import UpdateBy from "../Types/Database/UpdateBy";
21
+ import { OnCreate, OnUpdate } from "../Types/Database/Hooks";
17
22
 
18
23
  export interface ImportReposFromInstallationResult {
19
24
  imported: number;
@@ -25,6 +30,93 @@ export class Service extends DatabaseService<Model> {
25
30
  super(Model);
26
31
  }
27
32
 
33
+ /*
34
+ * Last line of defence for the installation-to-project binding.
35
+ *
36
+ * gitHubAppInstallationId is already closed to API callers at the column
37
+ * level, but that only stops requests that go through permission checks —
38
+ * every write here is made with `props: { isRoot: true }`, which skips them
39
+ * entirely. This hook runs regardless of isRoot, so any route (including one
40
+ * added later) that writes a row carrying another tenant's installation ID
41
+ * is refused at the service boundary rather than at the token endpoint.
42
+ *
43
+ * The legitimate writer is importReposFromInstallation, which the install
44
+ * callback and the installation webhooks only ever call for a project the
45
+ * installation is already bound to — so this check passes for them.
46
+ */
47
+ @CaptureSpan()
48
+ protected override async onBeforeCreate(
49
+ createBy: CreateBy<Model>,
50
+ ): Promise<OnCreate<Model>> {
51
+ const installationId: string | undefined =
52
+ createBy.data.gitHubAppInstallationId;
53
+
54
+ if (installationId) {
55
+ const projectId: ObjectID | undefined =
56
+ createBy.data.projectId || createBy.props.tenantId;
57
+
58
+ if (!projectId) {
59
+ throw new BadDataException(
60
+ "A project is required to connect a GitHub App installation to a repository.",
61
+ );
62
+ }
63
+
64
+ await GitHubInstallationBinding.assertInstallationBoundToProject({
65
+ projectId: projectId,
66
+ installationId: installationId,
67
+ });
68
+ }
69
+
70
+ return { createBy, carryForward: null };
71
+ }
72
+
73
+ /*
74
+ * Same rule on the update path. Clearing the installation ID is always
75
+ * allowed — that is how the `installation.deleted` webhook tears a
76
+ * disconnected installation out of the database.
77
+ */
78
+ @CaptureSpan()
79
+ protected override async onBeforeUpdate(
80
+ updateBy: UpdateBy<Model>,
81
+ ): Promise<OnUpdate<Model>> {
82
+ const installationId: string | undefined = updateBy.data
83
+ .gitHubAppInstallationId as string | undefined;
84
+
85
+ if (installationId) {
86
+ /*
87
+ * updateBy matches by query rather than by id, so check every row the
88
+ * write would touch: one unbound row is enough to leak a token.
89
+ */
90
+ const repositories: Array<Model> = await this.findBy({
91
+ query: updateBy.query,
92
+ select: {
93
+ _id: true,
94
+ projectId: true,
95
+ },
96
+ limit: LIMIT_MAX,
97
+ skip: 0,
98
+ props: {
99
+ isRoot: true,
100
+ },
101
+ });
102
+
103
+ for (const repository of repositories) {
104
+ if (!repository.projectId) {
105
+ throw new BadDataException(
106
+ "A project is required to connect a GitHub App installation to a repository.",
107
+ );
108
+ }
109
+
110
+ await GitHubInstallationBinding.assertInstallationBoundToProject({
111
+ projectId: repository.projectId,
112
+ installationId: installationId,
113
+ });
114
+ }
115
+ }
116
+
117
+ return { updateBy, carryForward: null };
118
+ }
119
+
28
120
  /*
29
121
  * Imports all repositories accessible to a GitHub App installation into a
30
122
  * project. Repositories that already exist for the project (matched on
@@ -174,13 +266,24 @@ export class Service extends DatabaseService<Model> {
174
266
  },
175
267
  });
176
268
 
177
- // Only GitHub-App-connected repos can be cloned/pushed by the agent.
269
+ /*
270
+ * Only GitHub-App-connected repos can be cloned/pushed by the agent — and
271
+ * only ones whose installation this project actually owns. Resolution
272
+ * probes EVERY candidate's file tree, so an unbound row here would have
273
+ * the server read another tenant's private repository on its own
274
+ * initiative (GHSA-xx95-gmcf-7q86).
275
+ */
276
+ const boundInstallationId: string | null =
277
+ await GitHubInstallationBinding.getBoundInstallationId(data.projectId);
278
+
178
279
  const resolvable: Array<ResolvableRepository> = repositories
179
280
  .filter((repository: Model) => {
180
281
  return (
181
282
  repository.repositoryHostedAt === CodeRepositoryType.GitHub &&
182
283
  Boolean(repository.gitHubAppInstallationId) &&
183
- Boolean(repository.id)
284
+ Boolean(repository.id) &&
285
+ Boolean(boundInstallationId) &&
286
+ repository.gitHubAppInstallationId === boundInstallationId
184
287
  );
185
288
  })
186
289
  .map((repository: Model) => {