@preventive/triage 1.0.0-alpha.3 → 1.0.0-alpha.30

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 (183) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/api/reap.ts +17 -0
  3. package/cli.js +6 -0
  4. package/client/finding-link.js +305 -0
  5. package/client/linked-findings.d.ts +1 -0
  6. package/client/linked-findings.js +111 -0
  7. package/common/bundle-commit.d.ts +1 -0
  8. package/common/bundle-commit.js +4 -0
  9. package/common/bundle-metadata.d.ts +10 -0
  10. package/common/bundle-metadata.js +182 -0
  11. package/common/bundle-reasons.d.ts +2 -0
  12. package/common/bundle-reasons.js +21 -0
  13. package/common/bundle-source-links.d.ts +5 -0
  14. package/common/bundle-source-links.js +82 -0
  15. package/common/bundle-sources.d.ts +3 -0
  16. package/common/bundle-sources.js +284 -0
  17. package/common/bundle-stats.js +41 -0
  18. package/common/bundle-tabs.js +1 -0
  19. package/common/code-language.js +51 -0
  20. package/common/default-scan-models.ts +30 -0
  21. package/common/finding-id.js +47 -0
  22. package/common/github-issue-labels.d.ts +1 -0
  23. package/common/github-issue-labels.js +11 -0
  24. package/common/github-pr.ts +56 -0
  25. package/common/managed/comments.ts +34 -0
  26. package/common/managed/permissions.ts +35 -0
  27. package/common/managed/report-content.ts +42 -0
  28. package/common/managed/report-filter.ts +108 -0
  29. package/common/managed/roles.ts +28 -0
  30. package/common/managed/routes.d.ts +2 -0
  31. package/common/managed/routes.js +124 -0
  32. package/common/managed/scan-models.ts +6 -0
  33. package/common/managed/triage.ts +83 -0
  34. package/common/save-error-reason.ts +20 -7
  35. package/common/scan-server.ts +13 -0
  36. package/common/server-info.ts +35 -0
  37. package/common/utf8.d.ts +3 -0
  38. package/common/utf8.js +45 -0
  39. package/out/brotli-fallback.js +3 -3
  40. package/out/client-managed-import.js +81 -0
  41. package/out/client-managed.js +167 -0
  42. package/out/client-sync.js +16 -13
  43. package/out/github-issue-authorized.html +3 -0
  44. package/out/github-issue-authorized.js +1 -0
  45. package/out/graph.js +30 -4
  46. package/out/index.html +55 -8
  47. package/out/prism.js +2 -2
  48. package/out/stasis.svg +20 -0
  49. package/out/terminal.js +273 -39
  50. package/out/view.css +1 -1
  51. package/out/view.js +259 -63
  52. package/package.json +205 -57
  53. package/server-common/database-config.ts +16 -0
  54. package/server-common/initialize.ts +18 -0
  55. package/server-common/npm-advisories.ts +101 -0
  56. package/{server → server-common}/origin.ts +5 -5
  57. package/server-common/reap.ts +48 -0
  58. package/server-common/scan-config.ts +19 -0
  59. package/server-common/standalone.ts +29 -0
  60. package/server-common/storage-crypto.ts +182 -0
  61. package/server-common/storage-log.ts +38 -0
  62. package/server-common/vercel-blob.ts +102 -0
  63. package/server-e2e/app.ts +486 -0
  64. package/{server → server-e2e}/auth.ts +5 -1
  65. package/{server → server-e2e}/bus-receiver.ts +2 -1
  66. package/{server → server-e2e}/cli.js +9 -4
  67. package/{server → server-e2e}/config.ts +56 -35
  68. package/{server → server-e2e}/db-neon.ts +2 -2
  69. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  70. package/{server → server-e2e}/db-stmt.ts +2 -2
  71. package/{server → server-e2e}/db.ts +7 -7
  72. package/{server → server-e2e}/http.ts +110 -12
  73. package/{server → server-e2e}/hub.ts +40 -9
  74. package/server-e2e/index.ts +17 -0
  75. package/server-e2e/lifecycle.ts +95 -0
  76. package/{server → server-e2e}/neon-driver.ts +2 -2
  77. package/{server → server-e2e}/npm-proxy.ts +11 -144
  78. package/{server → server-e2e}/objstore/blob-fs.ts +2 -2
  79. package/{server → server-e2e}/objstore/blob-vercel.ts +13 -117
  80. package/{server → server-e2e}/objstore/blob.ts +9 -2
  81. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  82. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  83. package/{server → server-e2e}/objstore/init.ts +35 -18
  84. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  85. package/server-e2e/objstore/rest-deny.ts +28 -0
  86. package/server-e2e/objstore/rest-mint.ts +224 -0
  87. package/{server → server-e2e}/objstore/rest.ts +58 -33
  88. package/{server → server-e2e}/objstore/sign.ts +105 -0
  89. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  90. package/{server → server-e2e}/objstore/store.ts +7 -7
  91. package/{server → server-e2e}/objstore/tokens.ts +1 -1
  92. package/{server → server-e2e}/pubsub.ts +12 -9
  93. package/{server → server-e2e}/sign.ts +4 -4
  94. package/{server → server-e2e}/sse-server.ts +102 -68
  95. package/{server → server-e2e}/sse-session.ts +28 -8
  96. package/{server → server-e2e}/static.ts +31 -21
  97. package/server-e2e/sync-handlers.ts +408 -0
  98. package/{server → server-e2e}/util.ts +9 -0
  99. package/{server → server-e2e}/ws-server.ts +20 -9
  100. package/server-managed/activity.ts +231 -0
  101. package/server-managed/avatar-store.ts +12 -0
  102. package/server-managed/blob-store.ts +17 -0
  103. package/server-managed/brotli.ts +10 -0
  104. package/server-managed/bundle-advisories.ts +53 -0
  105. package/server-managed/bundle-advisory-inventory.ts +75 -0
  106. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  107. package/server-managed/bundle-build-diagnostics.js +23 -0
  108. package/server-managed/bundle-build-leases.ts +41 -0
  109. package/server-managed/bundle-build-worker.js +54 -0
  110. package/server-managed/bundle-build.ts +168 -0
  111. package/server-managed/bundle-cache.ts +166 -0
  112. package/server-managed/bundle-catalog.ts +29 -0
  113. package/server-managed/bundle-store.ts +24 -0
  114. package/server-managed/bundle-summary-cache.ts +116 -0
  115. package/server-managed/bundle.ts +104 -0
  116. package/server-managed/cache-storage.ts +19 -0
  117. package/server-managed/cli.js +21 -0
  118. package/server-managed/combined.ts +46 -0
  119. package/server-managed/comments.ts +151 -0
  120. package/server-managed/config.ts +149 -0
  121. package/server-managed/content-access.ts +15 -0
  122. package/server-managed/crypto.ts +25 -0
  123. package/server-managed/db-methods.ts +1517 -0
  124. package/server-managed/db-neon.ts +277 -0
  125. package/server-managed/db-schema.ts +206 -0
  126. package/server-managed/db-table-names.ts +22 -0
  127. package/server-managed/db.ts +120 -0
  128. package/server-managed/github-app.ts +385 -0
  129. package/server-managed/github-errors.ts +49 -0
  130. package/server-managed/github-issue-oauth.ts +48 -0
  131. package/server-managed/github-issues.ts +152 -0
  132. package/server-managed/github-metadata.ts +98 -0
  133. package/server-managed/github-oauth.ts +199 -0
  134. package/server-managed/github-pulls.ts +227 -0
  135. package/server-managed/http-response.ts +18 -0
  136. package/server-managed/http.ts +2350 -0
  137. package/server-managed/import-triage.ts +51 -0
  138. package/server-managed/index.ts +175 -0
  139. package/server-managed/managed-issues.ts +47 -0
  140. package/server-managed/management-catalog.ts +45 -0
  141. package/server-managed/management.ts +86 -0
  142. package/server-managed/object-storage-disk.ts +144 -0
  143. package/server-managed/object-storage-vercel.ts +60 -0
  144. package/server-managed/object-storage.ts +71 -0
  145. package/server-managed/package-entry-points.ts +51 -0
  146. package/server-managed/public-workspace.ts +146 -0
  147. package/server-managed/repo-path.ts +21 -0
  148. package/server-managed/report-migration.ts +35 -0
  149. package/server-managed/report-query.ts +10 -0
  150. package/server-managed/report-response.ts +16 -0
  151. package/server-managed/report-sources.ts +166 -0
  152. package/server-managed/repository-browser.ts +139 -0
  153. package/server-managed/repository-discovery.ts +82 -0
  154. package/server-managed/repository-policy.ts +27 -0
  155. package/server-managed/revisions.ts +35 -0
  156. package/server-managed/session.ts +78 -0
  157. package/server-managed/slugs.ts +38 -0
  158. package/server-managed/solidity-entry-points.ts +69 -0
  159. package/server-managed/sql-postgres.ts +31 -0
  160. package/server-managed/sql.ts +72 -0
  161. package/server-managed/static.ts +28 -0
  162. package/server-managed/storage-command.ts +16 -0
  163. package/server-managed/storage-db.ts +175 -0
  164. package/server-managed/storage-encryption.ts +211 -0
  165. package/server-managed/storage-maintenance.ts +223 -0
  166. package/server-managed/storage-payload.ts +72 -0
  167. package/server-managed/storage-stores.ts +81 -0
  168. package/server-managed/storage.ts +58 -0
  169. package/server-managed/team-catalog.ts +7 -0
  170. package/server-managed/team-feed.ts +173 -0
  171. package/server-managed/team-reports.ts +219 -0
  172. package/server-managed/triage-response.ts +16 -0
  173. package/server-managed/uploads.ts +65 -0
  174. package/server-managed/workspace-shares.ts +162 -0
  175. package/server.ts +50 -0
  176. package/server/index.ts +0 -477
  177. package/server/lifecycle.ts +0 -204
  178. package/server/sync-handlers.ts +0 -320
  179. /package/{LICENSE → LICENSE-MIT} +0 -0
  180. /package/{server → server-e2e}/config.example.json +0 -0
  181. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  182. /package/{server → server-e2e}/peer.ts +0 -0
  183. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,1517 @@
1
+ import { createHash, randomUUID } from 'node:crypto'
2
+ import { teamCatalogRevision } from './team-catalog.ts'
3
+ import { type Role, roleAtLeast } from '../common/managed/roles.ts'
4
+ import type { TeamUserPermissions } from '../common/managed/permissions.ts'
5
+ import type { TriageEntryPatch } from '../common/managed/triage.ts'
6
+ import { preferredSlug } from './slugs.ts'
7
+ import { type CommentStore, commentMethods } from './comments.ts'
8
+ import { type ActivityStore, activityMethods } from './activity.ts'
9
+ import { type GithubMetadataStore, githubMetadataMethods } from './github-metadata.ts'
10
+ import { type ManagedIssueStore, managedIssueMethods } from './managed-issues.ts'
11
+ import { type BundleBuildLeaseStore, bundleBuildLeaseMethods } from './bundle-build-leases.ts'
12
+ import { type ImportTriageStore, importTriageMethods } from './import-triage.ts'
13
+ import type { ManagedSql } from './sql.ts'
14
+ import { type WorkspaceShareStore, workspaceShareMethods } from './workspace-shares.ts'
15
+ import { ManagedMutationError, type ManagementStore, managementMethods } from './management.ts'
16
+ import { type ManagementCatalogStore, managementCatalogMethods } from './management-catalog.ts'
17
+ import { type StorageKey } from '../server-common/storage-crypto.ts'
18
+ import { type StorageDb, type StoredTokens, decodeStorageTokens, encodeStorageTokens, storageMethods, storageState, validateStorageUpload } from './storage-db.ts'
19
+
20
+ // A managed user identity (the subset of GitHub's `GET /user` we keep). Input
21
+ // to the upsert; `githubUserId` is the provider lookup key, never exposed to
22
+ // clients.
23
+ export interface ManagedUser {
24
+ githubUserId: number
25
+ login: string
26
+ name: string | null
27
+ avatarUrl: string | null
28
+ }
29
+
30
+ // A persisted user as read back from a session — identified by the opaque `id`;
31
+ // the GitHub id stays server-internal.
32
+ export interface StoredUser {
33
+ id: string
34
+ login: string
35
+ name: string | null
36
+ avatarUrl: string | null
37
+ role: Role
38
+ }
39
+
40
+ // A user row for the admin users list.
41
+ export interface AdminUser {
42
+ id: string
43
+ login: string
44
+ name: string | null
45
+ role: Role
46
+ createdAt: number
47
+ lastSeenAt: number | null
48
+ lastActivityAt: number | null
49
+ }
50
+
51
+ // A user's persisted GitHub user-to-server token. `refreshToken` / `expiresAt`
52
+ // are null when the App issues non-expiring tokens.
53
+ export interface UserTokens {
54
+ accessToken: string
55
+ refreshToken: string | null
56
+ expiresAt: number | null
57
+ }
58
+
59
+ export interface ManagedSession {
60
+ id: string
61
+ userId: string
62
+ csrfToken: string
63
+ expiresAt: number
64
+ }
65
+
66
+ // A repository selected for the workspace to operate on, with the context to
67
+ // read its contents: `installationId` mints an App installation token (Contents:
68
+ // Read) for a PRIVATE repo — null for a PUBLIC repo readable without the App —
69
+ // and `fullName` + `defaultBranch` locate the contents
70
+ // (GET /repos/{fullName}/contents?ref={defaultBranch}).
71
+ export interface SelectedRepo {
72
+ repoId: number
73
+ fullName: string
74
+ private: boolean
75
+ installationId: number | null
76
+ defaultBranch: string
77
+ // Last live default observed by the repository browser, independent of the
78
+ // selection/discovery snapshot above. NULL means unknown or unavailable.
79
+ cachedDefaultBranch: string | null
80
+ htmlUrl: string
81
+ addedBy: string | null
82
+ addedAt: number
83
+ }
84
+
85
+ // The admin repository directory includes inactive connections as well. Their
86
+ // metadata remains available for existing reports/bundles, while only active
87
+ // rows are returned by listSelectedRepos for new scans and attachments.
88
+ export interface ManagedRepo extends SelectedRepo {
89
+ active: boolean
90
+ }
91
+
92
+ // What a caller supplies to select (upsert) a repo; the store stamps the
93
+ // timestamps.
94
+ export type SelectedRepoInput = Omit<SelectedRepo, 'addedAt' | 'cachedDefaultBranch'>
95
+
96
+ // A stored report's metadata. The bytes live in the blob-store keyed by `id`;
97
+ // `contentType` + `filename` ride here so a download can label them, `sha256`
98
+ // (base64url) is the content hash for integrity, `uploadedBy` is the opaque
99
+ // uploader id (null once that user is removed).
100
+ export interface ReportRecord {
101
+ id: string
102
+ slug: string
103
+ filename: string
104
+ contentType: string
105
+ byteSize: number
106
+ sha256: string
107
+ uploadedBy: string | null
108
+ uploadedAt: number
109
+ repoId: number | null
110
+ repoDirectory: string
111
+ repoEmbedded: boolean
112
+ analyzer: string | null
113
+ visible: boolean
114
+ bundleId: string | null
115
+ }
116
+
117
+ // One transaction snapshots all metadata needed to authorize, filter and
118
+ // revalidate a report batch. Unauthorized/missing reports are omitted.
119
+ export interface ReportAccessRecord {
120
+ id: string
121
+ filename: string
122
+ byteSize: number
123
+ sha256: string
124
+ repo: { github: string | null; directory: string }
125
+ permissions: TeamUserPermissions
126
+ }
127
+ export interface TeamReportAccessSnapshot extends ReportAccessSnapshot {
128
+ teamId: string | null
129
+ repositories: { repoId: number; github: string; path: string | null }[]
130
+ }
131
+ export interface ReportAccessSnapshot {
132
+ user: StoredUser
133
+ reports: ReportAccessRecord[]
134
+ }
135
+
136
+ // What the upload handler supplies to record a report; the store stamps
137
+ // uploaded_at. `repoId` / `bundleId` are the (nullable) repo + auto-resolved
138
+ // bundle links; `bundleIntegrity` is the report's declared primary bundle (kept
139
+ // so a later bundle upload of that integrity re-links it).
140
+ export interface ReportRecordInput {
141
+ dataKey?: string | null
142
+ id: string
143
+ filename: string
144
+ contentType: string
145
+ byteSize: number
146
+ sha256: string
147
+ uploadedBy: string | null
148
+ uploadedByLogin: string | null
149
+ repoId: number | null
150
+ repoDirectory: string
151
+ repoEmbedded?: boolean
152
+ analyzer: string | null
153
+ visible: boolean
154
+ bundleId: string | null
155
+ bundleIntegrity: string | null
156
+ }
157
+
158
+ // A report row for the "Manage reports" list — adds display joins: uploader
159
+ // login, repo full name, and the linked bundle's filename (each null when
160
+ // absent / since removed).
161
+ export interface AdminReport {
162
+ id: string
163
+ slug: string
164
+ filename: string
165
+ contentType: string
166
+ byteSize: number
167
+ sha256: string
168
+ uploadedByLogin: string | null
169
+ repoId: number | null
170
+ repoFullName: string | null
171
+ repoDirectory: string
172
+ repoEmbedded: boolean
173
+ analyzer: string | null
174
+ visible: boolean
175
+ bundleId: string | null
176
+ bundleFilename: string | null
177
+ bundleIntegrity: string | null
178
+ uploadedAt: number
179
+ }
180
+
181
+ export interface RepoDataItem {
182
+ id: string
183
+ filename: string
184
+ }
185
+
186
+ export type RepoReportItem = Pick<ReportRecord, 'id' | 'filename' | 'sha256' | 'bundleId'>
187
+
188
+ // A stored per-finding triage row, with the last writer's login resolved like
189
+ // listReports (live login, falling back to the durable snapshot). `flagged`
190
+ // maps the tri-state column: null unset, true/false set. Every field null =
191
+ // the tombstone of a cleared entry.
192
+ export interface TriageRow {
193
+ findingId: string
194
+ color: string | null
195
+ triage: string | null
196
+ comment: string | null
197
+ fix: string | null
198
+ flagged: boolean | null
199
+ updatedByLogin: string | null
200
+ updatedAt: number
201
+ }
202
+
203
+ // One row of a finding's triage trail: the entry as written by that event
204
+ // (every field null = a clear), the actor's login resolved like TriageRow's
205
+ // writer, and the request it came in with.
206
+ export interface TriageEventRow {
207
+ seq: number
208
+ findingId: string
209
+ batchId: string
210
+ color: string | null
211
+ triage: string | null
212
+ comment: string | null
213
+ fix: string | null
214
+ flagged: boolean | null
215
+ actorId: string | null
216
+ actorLogin: string | null
217
+ actorName: string | null
218
+ at: number
219
+ }
220
+
221
+ // A stored bundle's metadata. Bytes live in the blob-store keyed by `id`;
222
+ // `integrity` (sha512-<base64>) is the content-addressed identity (UNIQUE),
223
+ // matched against a report's bundleHashes to auto-link.
224
+ export interface ManagedBundle {
225
+ id: string
226
+ slug: string
227
+ integrity: string
228
+ filename: string
229
+ kind: string | null
230
+ byteSize: number
231
+ uploadedBy: string | null
232
+ repoDirectory: string
233
+ repoId: number | null
234
+ uploadedAt: number
235
+ }
236
+
237
+ // What the upload handler supplies to record a bundle; the store stamps
238
+ // uploaded_at. `uploadedByLogin` is the durable uploader-login snapshot.
239
+ export type BundleInput = Omit<ManagedBundle, 'uploadedAt' | 'slug' | 'repoDirectory'> & { uploadedByLogin: string | null; repoDirectory?: string; dataKey?: string | null }
240
+
241
+ // A bundle row for the "Manage bundles" list — adds the uploader login + repo
242
+ // full name display joins (null when absent / since removed).
243
+ export interface AdminBundle {
244
+ id: string
245
+ slug: string
246
+ integrity: string
247
+ filename: string
248
+ kind: string | null
249
+ byteSize: number
250
+ uploadedByLogin: string | null
251
+ repoDirectory: string
252
+ repoId: number | null
253
+ repoFullName: string | null
254
+ uploadedAt: number
255
+ }
256
+
257
+ // A team's repo link (with its optional subpath) and member (with resolved
258
+ // login + visibility permissions), as carried in the AdminTeam detail.
259
+ export interface TeamRepoLink {
260
+ repoId: number
261
+ fullName: string
262
+ path: string | null
263
+ }
264
+ export interface TeamMember extends TeamUserPermissions {
265
+ userId: string
266
+ login: string
267
+ }
268
+
269
+ // A team with its links inlined, for the "Manage teams" page.
270
+ export interface AdminTeam {
271
+ id: string
272
+ slug: string
273
+ name: string
274
+ repos: TeamRepoLink[]
275
+ members: TeamMember[]
276
+ }
277
+
278
+ // A user id + login, for the team-member picker (lighter than the admin users
279
+ // list, and usable by manage — not just admin). A `type` (not interface) so a
280
+ // SQLite row casts straight to it.
281
+ export type UserOption = {
282
+ id: string
283
+ login: string
284
+ name: string | null
285
+ role: Role
286
+ }
287
+
288
+ // A team as shown in a member's own sidebar: the team name plus the reports and
289
+ // bundles attached to the team's repos (id + filename, newest first). An item
290
+ // shows under a team when its repo is one of the team's linked repos.
291
+ export interface UserTeamReport {
292
+ id: string
293
+ slug: string
294
+ filename: string
295
+ analyzer: string | null
296
+ repoFullName: string | null
297
+ repoDirectory: string
298
+ cacheKey: string
299
+ }
300
+ export interface UserTeamBundle {
301
+ id: string
302
+ slug: string
303
+ integrity: string
304
+ filename: string
305
+ kind: string | null
306
+ byteSize: number
307
+ repoDirectory: string
308
+ repoId: number
309
+ repoFullName: string
310
+ }
311
+ export interface UserTeam {
312
+ id: string
313
+ slug: string
314
+ name: string
315
+ cacheKey?: string
316
+ reports: UserTeamReport[]
317
+ bundles: UserTeamBundle[]
318
+ }
319
+
320
+ // Backend-agnostic store surface (SQLite + PostgreSQL implementations).
321
+ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, BundleBuildLeaseStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, ManagementCatalogStore, StorageDb {
322
+ claimMaintenanceLease(owner: string, now: number, until: number, migration?: boolean): Promise<boolean>
323
+ finishMaintenanceLease(owner: string, until: number): Promise<void>
324
+ getFeedState(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; catalog: number; annotations: number } | null>
325
+ // Reuse connections within a finite request or one feed polling iteration.
326
+ withRequest?<T>(work: () => Promise<T>): Promise<T>
327
+ // Upsert the identity; returns the user's opaque id (stable across logins).
328
+ // Initial-admin approval comes only from trusted login configuration. It
329
+ // promotes a matching No access identity only while it is the sole user.
330
+ upsertUser(user: ManagedUser, now: number, initialAdminGithubId?: number | null): Promise<string>
331
+ createSession(session: ManagedSession, now: number): Promise<void>
332
+ sessionWithUser(id: string, now: number): Promise<{ session: ManagedSession; user: StoredUser } | null>
333
+ deleteSession(id: string): Promise<void>
334
+ deleteExpiredSessions(now: number): Promise<number>
335
+ listUsers(): Promise<AdminUser[]>
336
+ // Set a user's role; resolves true iff a matching user row was updated.
337
+ setUserRole(id: string, role: Role): Promise<boolean>
338
+ // Persist / read a user's GitHub token (for on-demand repo listing).
339
+ setUserTokens(id: string, tokens: UserTokens): Promise<void>
340
+ getUserTokens(id: string): Promise<UserTokens | null>
341
+ getUserGithubId(id: string): Promise<number | null>
342
+ // Repo selection ("operate on"). selectRepo upserts by repo id, refreshing the
343
+ // mutable context while keeping the original added_by/added_at; deselectRepo
344
+ // resolves true iff a row was removed.
345
+ selectRepo(repo: SelectedRepoInput, now: number): Promise<void>
346
+ cacheRepoDefaultBranch(repo: SelectedRepo, branch: string | null): Promise<boolean>
347
+ connectRepoInstallation(repo: SelectedRepo, installationId: number, sessionId: string, now: number): Promise<boolean>
348
+ deselectRepo(repoId: number): Promise<boolean>
349
+ listSelectedRepos(): Promise<SelectedRepo[]>
350
+ listAllRepos(): Promise<ManagedRepo[]>
351
+ deactivateRepo(repoId: number): Promise<boolean>
352
+ reactivateRepo(repoId: number): Promise<boolean>
353
+ deleteRepo(repoId: number): Promise<boolean>
354
+ listReportsForRepo(repoId: number): Promise<RepoReportItem[]>
355
+ listBundlesForRepo(repoId: number): Promise<RepoDataItem[]>
356
+ deleteReportsForRepo(repoId: number): Promise<number>
357
+ deleteBundlesForRepo(repoId: number): Promise<number>
358
+ // Permanently delete current annotations AND their history atomically.
359
+ // Returns the number of current rows removed (not the number of events).
360
+ deleteTriage(findingIds: readonly string[]): Promise<number>
361
+ // Reports ("Manage reports"). insertReport records an uploaded report's
362
+ // metadata (bytes are written to the blob-store separately); listReports joins
363
+ // the uploader login + repo + linked-bundle filename for the admin list;
364
+ // getReport reads one row (for download); deleteReport resolves true iff a row
365
+ // was removed.
366
+ insertReport(report: ReportRecordInput, now: number): Promise<void>
367
+ // Content reuse is atomic with insertion, including across server instances.
368
+ // Existing copies are kept; the oldest matching row is the stable identity.
369
+ // HTTP uploads pass sessionId to recheck role/destination in that transaction.
370
+ insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord>
371
+ getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null>
372
+ listReports(userId?: string): Promise<AdminReport[]>
373
+ listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]>
374
+ getReport(id: string): Promise<ReportRecord | null>
375
+ // Writer-locked reconciliation waits for an uncertain earlier insert.
376
+ resolveReportUpload(id: string): Promise<ReportRecord | null>
377
+ getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null>
378
+ getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null>
379
+ listReportFilenamesWithBundleHash(bundleId: string, sha256: string): Promise<string[]>
380
+ deleteReport(id: string): Promise<boolean>
381
+ // Attach / detach a report's repo + directory link (repoId null = detach);
382
+ // resolves true iff the report exists. The caller validates repoId and the
383
+ // directory path.
384
+ setReportRepo(id: string, repoId: number | null, repoDirectory?: string): Promise<boolean>
385
+ setReportVisible(id: string, visible: boolean): Promise<boolean>
386
+ // Per-finding triage annotations, keyed by finding id alone (shared by every
387
+ // report carrying the finding). listTriage reads the rows for a set of ids —
388
+ // the endpoint passes a viewer's visible findings of one report; a cleared
389
+ // entry comes back as a row with every field null (its tombstone).
390
+ // setTriage replaces one row wholesale, stamping the writer — a null/empty
391
+ // entry writes the tombstone rather than deleting; setTriageEntries does the
392
+ // same for a batch in one transaction: it lands whole or not at all. A write
393
+ // that leaves the entry as it is changes nothing — neither the row's writer
394
+ // stamp nor the trail. Every change also appends to managed_finding_triage_event;
395
+ // listTriageHistory walks one finding's trail, newest first.
396
+ listTriage(findingIds: readonly string[]): Promise<TriageRow[]>
397
+ getAnnotations(findingIds: readonly string[]): Promise<{ triage: TriageRow[]; comments: Awaited<ReturnType<CommentStore['listComments']>> }>
398
+ // A content-free fingerprint of current annotations, read in one snapshot.
399
+ getAnnotationRevision(findingIds: readonly string[]): Promise<string>
400
+ setTriage(findingId: string, entry: TriageEntryPatch | null, updatedBy: string | null, updatedByLogin: string | null, now: number): Promise<void>
401
+ setTriageEntries(entries: readonly (readonly [string, TriageEntryPatch | null])[], updatedBy: string | null, updatedByLogin: string | null, now: number, reportId?: string): Promise<void>
402
+ listTriageHistory(findingId: string, limit: number): Promise<TriageEventRow[]>
403
+ // Bundles ("Manage bundles"). insertBundle records an uploaded bundle (bytes
404
+ // in the blob-store); getBundleByIntegrity dedupes uploads + resolves a
405
+ // report's bundleHashes; getBundle reads one row (download); listBundles joins
406
+ // uploader login + repo; deleteBundle resolves true iff a row was removed
407
+ // (referencing reports' bundle_id null out via the FK). linkReportsToBundle
408
+ // attaches a freshly-stored bundle to the (still-unlinked) reports that
409
+ // declared its integrity.
410
+ insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void>
411
+ getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null>
412
+ resolveBundleUpload(integrity: string): Promise<ManagedBundle | null>
413
+ getBundle(id: string): Promise<ManagedBundle | null>
414
+ // One read snapshot of the live session, bundle and grants. Passing a team
415
+ // (or null for any team) also checks advisory security access; omission skips it.
416
+ getBundleAccessSnapshot(sessionId: string, now: number, id: string, advisoriesTeamId?: string | null): Promise<{
417
+ bundle: ManagedBundle | null; canReadAdvisories: boolean
418
+ } | null>
419
+ listBundles(userId?: string): Promise<AdminBundle[]>
420
+ listReadableBundleIds(userId: string, ids: readonly string[]): Promise<string[]>
421
+ userCanReadBundle(userId: string, id: string): Promise<boolean>
422
+ userCanReadRepo(userId: string, repoId: number): Promise<boolean>
423
+ userCanReadBundleAdvisories(userId: string, bundleId: string, teamId: string | null): Promise<boolean>
424
+ userCanReadRepoPath(userId: string, repoId: number, directory: string): Promise<boolean>
425
+ deleteBundle(id: string): Promise<boolean>
426
+ // Set a bundle's repository and normalized directory (repoId null detaches
427
+ // and clears the directory). The caller validates the location and access.
428
+ setBundleRepo(id: string, repoId: number | null, directory?: string): Promise<boolean>
429
+ linkReportsToBundle(integrity: string, bundleId: string, userId?: string): Promise<void>
430
+ // Teams ("Manage teams"). createTeam inserts a team (false iff the name is
431
+ // taken); renameTeam changes a team's name ('name-taken' iff another team
432
+ // already has it, 'not-found' iff no such team, 'ok' otherwise — same name is
433
+ // idempotent); deleteTeam drops it (cascading its links); listTeams returns
434
+ // every team with its repos + members inlined; listUserOptions is the id+login+name
435
+ // set for the member picker. The set*/remove* pairs maintain the link tables:
436
+ // setTeamRepo adds a path (whole repo replaces paths); setTeamMember upserts a
437
+ // membership + its visibility permissions (each resolves true iff a row was
438
+ // written / removed; the caller validates the team/repo/user exist first).
439
+ createTeam(id: string, name: string, now: number): Promise<boolean>
440
+ renameTeam(id: string, name: string, now: number): Promise<'ok' | 'name-taken' | 'not-found'>
441
+ deleteTeam(id: string): Promise<boolean>
442
+ getTeam(id: string): Promise<{ id: string; slug: string; name: string } | null>
443
+ listTeams(): Promise<AdminTeam[]>
444
+ listUserOptions(): Promise<UserOption[]>
445
+ // Current grants for manager content reads, writes, and repository pickers.
446
+ listRepoScopesForUser(userId: string): Promise<{ repoId: number; path: string | null }[]>
447
+ // The teams a given user belongs to (name-sorted), each with reports and
448
+ // bundles attached to that team's repos — for that user's own sidebar Teams section.
449
+ // Any user; only their own memberships.
450
+ listTeamsForUser(userId: string): Promise<UserTeam[]>
451
+ getUserTeamFeedSnapshot(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; revision: string; teams: UserTeam[]; catalog: number } | null>
452
+ // Whether `userId` may read `reportId`: true iff the report's repo belongs to
453
+ // a team the user is a member of. Backs the team-scoped report view endpoint.
454
+ userCanReadReport(userId: string, reportId: string): Promise<boolean>
455
+ // The viewer's effective visibility permissions for a report, OR'd across the
456
+ // teams (holding the report's repo) the user belongs to — drives the content
457
+ // filter (a viewer without a permission has those findings stripped).
458
+ reportPermissionsFor(userId: string, reportId: string): Promise<TeamUserPermissions>
459
+ setTeamRepo(teamId: string, repoId: number, path: string | null): Promise<void>
460
+ removeTeamRepo(teamId: string, repoId: number, path?: string | null): Promise<boolean>
461
+ setTeamMember(teamId: string, userId: string, perms: TeamUserPermissions): Promise<void>
462
+ removeTeamMember(teamId: string, userId: string): Promise<boolean>
463
+ close(): Promise<void>
464
+ }
465
+
466
+ type SessionRow = {
467
+ id: string; csrf: string; exp: number
468
+ uid: string; login: string; name: string | null; avatar: string | null; role: Role
469
+ }
470
+
471
+ type UserRow = { id: string; login: string; name: string | null; role: Role; created: number; lastSeen: number | null; lastActivity: number | null }
472
+
473
+ // Shared by listing, access checks, and permission aggregation. A team sees
474
+ // reports rooted at its path or below it. Literal substring comparison keeps
475
+ // separators, case, and SQL wildcard characters significant.
476
+ const REPORT_IN_TEAM_PATH_SQL = `(tr.path IS NULL OR tr.path = ''
477
+ OR r.repo_directory = tr.path
478
+ OR substr(r.repo_directory, 1, length(tr.path) + 1) = tr.path || '/')`
479
+
480
+ const BUNDLE_IN_TEAM_PATH_SQL = REPORT_IN_TEAM_PATH_SQL.replaceAll('r.repo_directory', 'b.repo_directory')
481
+
482
+ // Prepare every statement the store uses, returned as a bag the factory
483
+ // destructures — keeps openSqliteManagedDb itself small (one place per query).
484
+ function prepareStatements(db: ManagedSql) {
485
+ return {
486
+ // Identity refreshes preserve roles. Login can separately bootstrap the
487
+ // configured sole No access user within this same writer transaction.
488
+ upsertUserStmt: db.prepare(
489
+ `INSERT INTO managed_user (id, github_user_id, login, name, avatar_url, role, created_at, updated_at)
490
+ VALUES (?, ?, ?, ?, ?, 'none', ?, ?)
491
+ ON CONFLICT(github_user_id) DO UPDATE SET
492
+ login = excluded.login, name = excluded.name,
493
+ avatar_url = excluded.avatar_url, updated_at = excluded.updated_at`,
494
+ ),
495
+ selectUserIdStmt: db.prepare(`SELECT id, role FROM managed_user WHERE github_user_id = ?`),
496
+ promoteInitialAdminStmt: db.prepare(
497
+ `UPDATE managed_user SET role = 'admin' WHERE id = ? AND role = 'none'
498
+ AND NOT EXISTS(SELECT 1 FROM managed_user WHERE id <> ?)`,
499
+ ),
500
+ selectGithubIdStmt: db.prepare(`SELECT github_user_id AS githubId FROM managed_user WHERE id = ?`),
501
+ insertSessionStmt: db.prepare(
502
+ `INSERT INTO managed_session (id, user_id, csrf_token, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
503
+ ),
504
+ selectSessionStmt: db.prepare(
505
+ `SELECT s.id AS id, s.csrf_token AS csrf, s.expires_at AS exp,
506
+ u.id AS uid, u.login AS login, u.name AS name, u.avatar_url AS avatar, u.role AS role
507
+ FROM managed_session s
508
+ JOIN managed_user u ON u.id = s.user_id
509
+ WHERE s.id = ? AND s.expires_at > ?`,
510
+ ),
511
+ selectUsersStmt: db.prepare(
512
+ `SELECT u.id, u.login, u.name, u.role, u.created_at AS created, u.last_seen_at AS lastSeen,
513
+ (SELECT MAX(at) FROM (
514
+ SELECT MAX(e.at) AS at FROM managed_finding_triage_event e WHERE e.actor_id = u.id
515
+ UNION ALL SELECT MAX(a.at) AS at FROM managed_activity a WHERE a.actor_id = u.id
516
+ UNION ALL SELECT MAX(c.at) AS at FROM managed_finding_comment_event c WHERE c.actor_id = u.id
517
+ ) AS events) AS lastActivity
518
+ FROM managed_user u ORDER BY u.created_at ASC, u.login ASC`,
519
+ ),
520
+ touchUserSeenStmt: db.prepare(
521
+ `UPDATE managed_user SET last_seen_at = ? WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)`,
522
+ ),
523
+ updateRoleStmt: db.prepare(`UPDATE managed_user SET role = ?, updated_at = ? WHERE id = ?`),
524
+ updateTokensStmt: db.prepare(
525
+ `UPDATE managed_user
526
+ SET gh_access_token = ?, gh_refresh_token = ?, gh_token_expires_at = ?, gh_tokens_encrypted = ?, updated_at = ?
527
+ WHERE id = ?`,
528
+ ),
529
+ selectTokensStmt: db.prepare(
530
+ `SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp, gh_tokens_encrypted AS encrypted
531
+ FROM managed_user WHERE id = ?`,
532
+ ),
533
+ deleteSessionStmt: db.prepare(`DELETE FROM managed_session WHERE id = ?`),
534
+ deleteExpiredStmt: db.prepare(`DELETE FROM managed_session WHERE expires_at <= ?`),
535
+ upsertRepoStmt: db.prepare(
536
+ `INSERT INTO managed_selected_repo (repo_id, full_name, is_private, installation_id, default_branch, html_url, added_by, added_at, updated_at, active)
537
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
538
+ ON CONFLICT(repo_id) DO UPDATE SET
539
+ full_name = excluded.full_name, is_private = excluded.is_private,
540
+ installation_id = excluded.installation_id, default_branch = excluded.default_branch,
541
+ html_url = excluded.html_url, updated_at = excluded.updated_at, active = 1`,
542
+ ),
543
+ // Never reactivate or recreate a repo, overwrite another connection, or
544
+ // commit after the administrator's session/role was revoked during GitHub I/O.
545
+ connectRepoInstallationStmt: db.prepare(`UPDATE managed_selected_repo SET installation_id = ?, updated_at = ?
546
+ WHERE repo_id = ? AND full_name = ? AND added_at = ? AND installation_id IS NULL
547
+ AND EXISTS (SELECT 1 FROM managed_session s JOIN managed_user u ON u.id = s.user_id
548
+ WHERE s.id = ? AND s.expires_at > ? AND u.role = 'admin')`),
549
+ cacheRepoDefaultBranchStmt: db.prepare(`UPDATE managed_selected_repo SET cached_default_branch = ?
550
+ WHERE repo_id = ? AND full_name = ? AND added_at = ? AND active = 1`),
551
+ deleteRepoStmt: db.prepare(`DELETE FROM managed_selected_repo WHERE repo_id = ?`),
552
+ deactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 0 WHERE repo_id = ? AND active = 1`),
553
+ reactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 1 WHERE repo_id = ? AND active = 0`),
554
+ selectReportsForRepoStmt: db.prepare(`SELECT id, filename, sha256, bundle_id AS bundleId FROM managed_report WHERE repo_id = ? ORDER BY filename ASC`),
555
+ selectBundlesForRepoStmt: db.prepare(`SELECT id, filename FROM managed_bundle WHERE repo_id = ? ORDER BY filename ASC`),
556
+ deleteReportsForRepoStmt: db.prepare(`DELETE FROM managed_report WHERE repo_id = ?`),
557
+ deleteBundlesForRepoStmt: db.prepare(`DELETE FROM managed_bundle WHERE repo_id = ?`),
558
+ deleteTriageStmt: db.prepare(`DELETE FROM managed_finding_triage WHERE finding_id IN (SELECT value FROM json_each(?))`),
559
+ deleteTriageHistoryStmt: db.prepare(`DELETE FROM managed_finding_triage_event WHERE finding_id IN (SELECT value FROM json_each(?))`),
560
+ deleteCommentsStmt: db.prepare(`DELETE FROM managed_finding_comment WHERE finding_id IN (SELECT value FROM json_each(?))`),
561
+ deleteCommentHistoryStmt: db.prepare(`DELETE FROM managed_finding_comment_event WHERE finding_id IN (SELECT value FROM json_each(?))`),
562
+ countAnnotationsStmt: db.prepare(`SELECT count(*) AS total FROM (
563
+ SELECT finding_id FROM managed_finding_triage WHERE finding_id IN (SELECT value FROM json_each(?))
564
+ UNION SELECT finding_id FROM managed_finding_comment WHERE finding_id IN (SELECT value FROM json_each(?))
565
+ UNION SELECT finding_id FROM managed_finding_comment_event WHERE finding_id IN (SELECT value FROM json_each(?))) AS annotations`),
566
+ selectReposStmt: db.prepare(
567
+ `SELECT repo_id AS repoId, full_name AS fullName, is_private AS priv,
568
+ installation_id AS installId, default_branch AS branch, cached_default_branch AS cachedDefaultBranch, html_url AS htmlUrl,
569
+ added_by AS addedBy, added_at AS addedAt, active AS active
570
+ FROM managed_selected_repo WHERE active = 1 ORDER BY full_name ASC`,
571
+ ),
572
+ selectAllReposStmt: db.prepare(
573
+ `SELECT repo_id AS repoId, full_name AS fullName, is_private AS priv,
574
+ installation_id AS installId, default_branch AS branch, cached_default_branch AS cachedDefaultBranch, html_url AS htmlUrl,
575
+ added_by AS addedBy, added_at AS addedAt, active AS active
576
+ FROM managed_selected_repo ORDER BY full_name ASC`,
577
+ ),
578
+ insertReportStmt: db.prepare(
579
+ `WITH candidate(id, slug) AS (VALUES (?, ?))
580
+ INSERT INTO managed_report (id, slug, filename, content_type, byte_size, sha256, uploaded_by, uploaded_by_login, repo_id, repo_directory, repo_embedded, analyzer, visible, bundle_id, bundle_integrity, uploaded_at, data_key, storage_encrypted)
581
+ SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_report WHERE slug = candidate.slug) THEN id ELSE slug END,
582
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
583
+ ),
584
+ // LEFT JOINs so a report whose uploader / repo / bundle was removed (the FK
585
+ // nulled) still lists, with null display fields.
586
+ selectReportsStmt: db.prepare(
587
+ `SELECT r.id AS id, r.slug AS slug, r.filename AS filename, r.content_type AS contentType,
588
+ r.byte_size AS byteSize, r.sha256 AS sha256, COALESCE(u.login, r.uploaded_by_login) AS uploadedByLogin,
589
+ r.repo_id AS repoId, sr.full_name AS repoFullName,
590
+ r.repo_directory AS repoDirectory, r.repo_embedded AS repoEmbedded,
591
+ r.analyzer AS analyzer, r.visible AS visible,
592
+ r.bundle_id AS bundleId, b.filename AS bundleFilename, r.bundle_integrity AS bundleIntegrity,
593
+ r.uploaded_at AS uploadedAt
594
+ FROM managed_report r
595
+ LEFT JOIN managed_user u ON u.id = r.uploaded_by
596
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
597
+ LEFT JOIN managed_bundle b ON b.id = r.bundle_id
598
+ WHERE (? IS NULL OR r.uploaded_by = ? OR EXISTS (
599
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
600
+ WHERE tr.repo_id = r.repo_id AND tu.user_id = ? AND ${REPORT_IN_TEAM_PATH_SQL}))
601
+ ORDER BY r.uploaded_at DESC, r.filename ASC`,
602
+ ),
603
+ findingCatalogReportsStmt: db.prepare(`SELECT id, byte_size AS byteSize FROM managed_report WHERE id > ? ORDER BY id LIMIT ?`),
604
+ selectReportStmt: db.prepare(
605
+ `SELECT id, slug, filename, content_type AS contentType, byte_size AS byteSize,
606
+ sha256, uploaded_by AS uploadedBy, uploaded_at AS uploadedAt,
607
+ repo_id AS repoId, repo_directory AS repoDirectory, repo_embedded AS repoEmbedded,
608
+ analyzer AS analyzer, visible AS visible, bundle_id AS bundleId
609
+ FROM managed_report WHERE id = ?`,
610
+ ),
611
+ selectReportByHashStmt: db.prepare(
612
+ `SELECT id FROM managed_report WHERE sha256 = ? AND COALESCE(analyzer, '') = COALESCE(?, '')
613
+ ORDER BY uploaded_at, id LIMIT 1`,
614
+ ),
615
+ selectReportAccessStmt: db.prepare(
616
+ `WITH requested AS (SELECT value AS id FROM json_each(?)), report_grants AS (
617
+ SELECT r.id AS id, MAX(tu.view_dependencies) AS dependencies, MAX(tu.view_security) AS security
618
+ FROM managed_report r JOIN requested q ON q.id = r.id
619
+ JOIN managed_team_repo tr ON tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
620
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id AND tu.user_id = ?
621
+ GROUP BY r.id
622
+ )
623
+ SELECT r.id AS id, r.filename AS filename, r.byte_size AS byteSize, r.sha256 AS sha256,
624
+ r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
625
+ COALESCE(g.dependencies, 0) AS dependencies, COALESCE(g.security, 0) AS security
626
+ FROM managed_report r JOIN requested q ON q.id = r.id
627
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
628
+ LEFT JOIN report_grants g ON g.id = r.id
629
+ WHERE ? = 1 OR (? = 1 AND r.uploaded_by = ?)
630
+ OR (g.id IS NOT NULL AND (? = 1 OR r.visible = 1))`,
631
+ ),
632
+ selectTeamAccessStmt: db.prepare(`SELECT 1 FROM managed_team_user WHERE user_id = ? AND team_id = ?`),
633
+ selectTeamRepositoriesStmt: db.prepare(
634
+ `SELECT tr.repo_id AS repoId, sr.full_name AS github, tr.path AS path
635
+ FROM managed_team_repo tr JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
636
+ WHERE tr.team_id = ? ORDER BY tr.repo_id, tr.path`,
637
+ ),
638
+ selectTeamReportAccessStmt: db.prepare(
639
+ `SELECT DISTINCT r.id AS id, r.filename AS filename, r.byte_size AS byteSize, r.sha256 AS sha256,
640
+ r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
641
+ tu.view_dependencies AS dependencies, tu.view_security AS security
642
+ FROM managed_team_user tu
643
+ JOIN managed_team_repo tr ON tr.team_id = tu.team_id
644
+ JOIN managed_report r ON r.repo_id = tr.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
645
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
646
+ WHERE tu.user_id = ? AND tu.team_id = ? AND (r.visible = 1 OR ? = 1)
647
+ ORDER BY r.id`,
648
+ ),
649
+ reportFilenamesWithBundleHashStmt: db.prepare(`SELECT DISTINCT filename FROM managed_report WHERE bundle_id = ? AND sha256 = ?`),
650
+ deleteReportStmt: db.prepare(`DELETE FROM managed_report WHERE id = ?`),
651
+ setReportRepoStmt: db.prepare(`UPDATE managed_report SET repo_id = ?, repo_directory = ? WHERE id = ?`),
652
+ setReportVisibleStmt: db.prepare(`UPDATE managed_report SET visible = ? WHERE id = ?`),
653
+ upsertTriageStmt: db.prepare(
654
+ `INSERT INTO managed_finding_triage (finding_id, color, triage, comment, fix, flagged, updated_by, updated_by_login, updated_at)
655
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
656
+ ON CONFLICT(finding_id) DO UPDATE SET
657
+ color = excluded.color, triage = excluded.triage, comment = excluded.comment,
658
+ fix = excluded.fix, flagged = excluded.flagged, updated_by = excluded.updated_by,
659
+ updated_by_login = excluded.updated_by_login, updated_at = excluded.updated_at`,
660
+ ),
661
+ selectTriageStateStmt: db.prepare(
662
+ `SELECT color, triage, comment, fix, flagged FROM managed_finding_triage WHERE finding_id = ?`,
663
+ ),
664
+ insertTriageEventStmt: db.prepare(
665
+ `INSERT INTO managed_finding_triage_event (finding_id, batch_id, color, triage, comment, fix, flagged, actor_id, actor_login, at, report_id, report, repo)
666
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
667
+ (SELECT filename FROM managed_report WHERE id = ?),
668
+ (SELECT p.full_name FROM managed_report r JOIN managed_selected_repo p ON p.repo_id = r.repo_id WHERE r.id = ?))`,
669
+ ),
670
+ // With a retention limit set: keep the newest N events of a finding.
671
+ trimTriageEventsStmt: db.prepare(
672
+ `DELETE FROM managed_finding_triage_event
673
+ WHERE finding_id = ?
674
+ AND seq NOT IN (SELECT seq FROM managed_finding_triage_event WHERE finding_id = ? ORDER BY seq DESC LIMIT ?)`,
675
+ ),
676
+ selectTriageHistoryStmt: db.prepare(
677
+ `SELECT e.seq AS seq, e.finding_id AS findingId, e.batch_id AS batchId, e.color AS color, e.triage AS triage,
678
+ e.comment AS comment, e.fix AS fix, e.flagged AS flagged,
679
+ u.id AS actorId, COALESCE(u.login, e.actor_login) AS actorLogin, u.name AS actorName, e.at AS at
680
+ FROM managed_finding_triage_event e
681
+ LEFT JOIN managed_user u ON u.id = e.actor_id
682
+ WHERE e.finding_id = ?
683
+ ORDER BY e.seq DESC
684
+ LIMIT ?`,
685
+ ),
686
+ // The ids arrive as one JSON array (json_each), so a report's worth of
687
+ // them is one statement, not a chunked IN list. COALESCE the live login
688
+ // over the durable snapshot, like selectReportsStmt.
689
+ selectTriageStmt: db.prepare(
690
+ `SELECT t.finding_id AS findingId, t.color AS color, t.triage AS triage,
691
+ t.comment AS comment, t.fix AS fix, t.flagged AS flagged,
692
+ COALESCE(u.login, t.updated_by_login) AS updatedByLogin, t.updated_at AS updatedAt
693
+ FROM managed_finding_triage t
694
+ LEFT JOIN managed_user u ON u.id = t.updated_by
695
+ WHERE t.finding_id IN (SELECT value FROM json_each(?))
696
+ ORDER BY t.finding_id ASC`,
697
+ ),
698
+ annotationTriageRevisionStmt: db.prepare(`
699
+ SELECT t.finding_id AS id,
700
+ (SELECT e.batch_id FROM managed_finding_triage_event e
701
+ WHERE e.finding_id = t.finding_id ORDER BY e.seq DESC LIMIT 1) AS revision
702
+ FROM managed_finding_triage t WHERE t.finding_id IN (SELECT value FROM json_each(?))
703
+ ORDER BY t.finding_id`),
704
+ annotationCommentRevisionStmt: db.prepare(`
705
+ SELECT id, version FROM managed_finding_comment
706
+ WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY id`),
707
+ insertBundleStmt: db.prepare(
708
+ `WITH candidate(id, slug) AS (VALUES (?, ?))
709
+ INSERT INTO managed_bundle (id, slug, integrity, filename, kind, byte_size, uploaded_by, uploaded_by_login, repo_id, repo_directory, uploaded_at, data_key, storage_encrypted)
710
+ SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_bundle WHERE slug = candidate.slug) THEN id ELSE slug END,
711
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
712
+ ),
713
+ selectBundleByIntegrityStmt: db.prepare(
714
+ `SELECT id, slug, integrity, filename, kind, byte_size AS byteSize,
715
+ uploaded_by AS uploadedBy, repo_id AS repoId, repo_directory AS repoDirectory, uploaded_at AS uploadedAt
716
+ FROM managed_bundle WHERE integrity = ?`,
717
+ ),
718
+ selectBundleStmt: db.prepare(
719
+ `SELECT id, slug, integrity, filename, kind, byte_size AS byteSize,
720
+ uploaded_by AS uploadedBy, repo_id AS repoId, repo_directory AS repoDirectory, uploaded_at AS uploadedAt
721
+ FROM managed_bundle WHERE id = ?`,
722
+ ),
723
+ selectBundlesStmt: db.prepare(
724
+ `SELECT b.id AS id, b.slug AS slug, b.integrity AS integrity, b.filename AS filename, b.kind AS kind,
725
+ b.byte_size AS byteSize, COALESCE(u.login, b.uploaded_by_login) AS uploadedByLogin,
726
+ b.repo_id AS repoId, b.repo_directory AS repoDirectory, sr.full_name AS repoFullName, b.uploaded_at AS uploadedAt
727
+ FROM managed_bundle b
728
+ LEFT JOIN managed_user u ON u.id = b.uploaded_by
729
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = b.repo_id
730
+ WHERE (? IS NULL OR (b.uploaded_by = ? OR EXISTS (
731
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
732
+ WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?)))
733
+ ORDER BY b.uploaded_at DESC, b.filename ASC`,
734
+ ),
735
+ selectBundleReadableStmt: db.prepare(
736
+ `SELECT 1 FROM managed_bundle b WHERE b.id = ? AND (b.uploaded_by = ? OR EXISTS (
737
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
738
+ WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?))`,
739
+ ),
740
+ selectReadableBundleIdsStmt: db.prepare(
741
+ `SELECT b.id FROM managed_bundle b WHERE b.id IN (SELECT value FROM json_each(?)) AND (b.uploaded_by = ? OR EXISTS (
742
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
743
+ WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?))`,
744
+ ),
745
+ selectRepoReadableStmt: db.prepare(
746
+ `SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
747
+ WHERE tr.repo_id = ? AND tu.user_id = ? LIMIT 1`,
748
+ ),
749
+ selectRepoPathReadableStmt: db.prepare(
750
+ `WITH r(repo_directory) AS (VALUES (?))
751
+ SELECT 1 FROM r JOIN managed_team_repo tr ON tr.repo_id = ? AND ${REPORT_IN_TEAM_PATH_SQL}
752
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id WHERE tu.user_id = ? LIMIT 1`,
753
+ ),
754
+ deleteBundleStmt: db.prepare(`DELETE FROM managed_bundle WHERE id = ?`),
755
+ setBundleRepoStmt: db.prepare(`UPDATE managed_bundle SET repo_id = ?, repo_directory = ? WHERE id = ?`),
756
+ // Attach a freshly-stored bundle to the reports that declared its integrity
757
+ // but haven't been linked yet (bundle uploaded after the report).
758
+ linkReportsToBundleStmt: db.prepare(
759
+ `UPDATE managed_report AS r SET bundle_id = ? WHERE bundle_integrity = ? AND bundle_id IS NULL
760
+ AND (? IS NULL OR r.uploaded_by = ? OR EXISTS (SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
761
+ WHERE tu.user_id = ? AND tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}))`,
762
+ ),
763
+ // OR IGNORE: a duplicate name (UNIQUE) is the "taken" signal (0 changes); the
764
+ // uuid PK never collides.
765
+ insertTeamStmt: db.prepare(`WITH candidate(id, slug) AS (VALUES (?, ?))
766
+ INSERT OR IGNORE INTO managed_team (id, slug, name, created_at, updated_at)
767
+ SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_team WHERE slug = candidate.slug) THEN id ELSE slug END,
768
+ ?, ?, ? FROM candidate`),
769
+ deleteTeamStmt: db.prepare(`DELETE FROM managed_team WHERE id = ?`),
770
+ selectTeamStmt: db.prepare(`SELECT id, slug, name FROM managed_team WHERE id = ?`),
771
+ selectTeamByNameStmt: db.prepare(`SELECT id FROM managed_team WHERE name = ?`),
772
+ renameTeamStmt: db.prepare(`UPDATE managed_team SET name = ?, updated_at = ? WHERE id = ?`),
773
+ selectTeamsStmt: db.prepare(`SELECT id, slug, name FROM managed_team ORDER BY name ASC`),
774
+ selectTeamsForUserStmt: db.prepare(
775
+ `SELECT t.id AS id, t.slug AS slug, t.name AS name,
776
+ tu.view_dependencies AS dependencies, tu.view_security AS security
777
+ FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id
778
+ WHERE tu.user_id = ? ORDER BY t.name ASC, t.id ASC`,
779
+ ),
780
+ selectUserTeamScopesStmt: db.prepare(
781
+ `SELECT tr.team_id AS teamId, tr.repo_id AS repoId, tr.path AS path, sr.full_name AS fullName
782
+ FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
783
+ JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
784
+ WHERE tu.user_id = ? ORDER BY tr.team_id, tr.repo_id, tr.path`,
785
+ ),
786
+ // Reports attached to the repos of the user's teams, tagged by team (a report
787
+ // shows under every team whose repo it's attached to). Newest first.
788
+ selectUserTeamReportsStmt: db.prepare(
789
+ `SELECT DISTINCT tr.team_id AS teamId, r.id AS id, r.slug AS slug, r.filename AS filename, r.analyzer, r.uploaded_at,
790
+ r.sha256 AS sha256, r.bundle_id AS bundleId, r.repo_id AS repoId, r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
791
+ tu.view_dependencies AS dependencies, tu.view_security AS security,
792
+ (SELECT role FROM managed_user WHERE id = tu.user_id) AS role
793
+ FROM managed_team_user tu
794
+ JOIN managed_team_repo tr ON tr.team_id = tu.team_id
795
+ JOIN managed_report r ON r.repo_id = tr.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
796
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
797
+ WHERE tu.user_id = ? AND (r.visible = 1 OR (SELECT role FROM managed_user WHERE id = tu.user_id) IN ('admin', 'manage'))
798
+ ORDER BY r.uploaded_at DESC, r.filename ASC, r.id ASC`,
799
+ ),
800
+ // Bundles are scoped through the same team -> repository links as reports.
801
+ // They have no visibility flag: the bundle directory must fall inside a
802
+ // team repository scope to appear in that team's sidebar.
803
+ selectUserTeamBundlesStmt: db.prepare(
804
+ `SELECT DISTINCT tr.team_id AS teamId, b.id AS id, b.slug AS slug, b.integrity AS integrity,
805
+ b.filename AS filename, b.kind, b.byte_size AS byteSize, b.repo_id AS repoId, b.repo_directory AS repoDirectory, sr.full_name AS repoFullName, b.uploaded_at
806
+ FROM managed_team_user tu
807
+ JOIN managed_team_repo tr ON tr.team_id = tu.team_id
808
+ JOIN managed_bundle b ON b.repo_id = tr.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL}
809
+ JOIN managed_selected_repo sr ON sr.repo_id = b.repo_id
810
+ WHERE tu.user_id = ?
811
+ ORDER BY b.uploaded_at DESC, b.filename ASC, b.id ASC`,
812
+ ),
813
+ selectUserRepoScopesStmt: db.prepare(
814
+ `SELECT DISTINCT tr.repo_id AS repoId, NULLIF(tr.path, '') AS path
815
+ FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id WHERE tu.user_id = ?`,
816
+ ),
817
+ // A report is readable iff one of the user's team scopes contains it.
818
+ selectReportReadableStmt: db.prepare(
819
+ `SELECT 1 FROM managed_report r
820
+ JOIN managed_team_repo tr ON tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
821
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id
822
+ WHERE r.id = ? AND tu.user_id = ? LIMIT 1`,
823
+ ),
824
+ selectBundleSecurityStmt: db.prepare(
825
+ `SELECT 1 FROM managed_bundle b
826
+ JOIN managed_team_repo tr ON tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL}
827
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id
828
+ WHERE b.id = ? AND tu.user_id = ? AND tu.view_security = 1
829
+ AND (? IS NULL OR tr.team_id = ?) LIMIT 1`,
830
+ ),
831
+ // The viewer's effective visibility permissions for a report: OR'd (MAX over
832
+ // 0/1) across memberships whose repository path contains the report. NULLs
833
+ // (no such membership) read as 0 = no permission.
834
+ selectReportPermsStmt: db.prepare(
835
+ `SELECT MAX(tu.view_dependencies) AS dependencies, MAX(tu.view_security) AS security
836
+ FROM managed_report r
837
+ JOIN managed_team_repo tr ON tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
838
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id AND tu.user_id = ?
839
+ WHERE r.id = ?`,
840
+ ),
841
+ selectUserOptionsStmt: db.prepare(`SELECT id, login, name, role FROM managed_user ORDER BY login ASC`),
842
+ selectTeamReposStmt: db.prepare(
843
+ `SELECT tr.team_id AS teamId, tr.repo_id AS repoId, sr.full_name AS fullName, NULLIF(tr.path, '') AS path
844
+ FROM managed_team_repo tr JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
845
+ ORDER BY sr.full_name ASC, tr.path ASC`,
846
+ ),
847
+ selectTeamMembersStmt: db.prepare(
848
+ `SELECT tu.team_id AS teamId, tu.user_id AS userId, u.login AS login,
849
+ tu.view_dependencies AS viewDependencies, tu.view_security AS viewSecurity
850
+ FROM managed_team_user tu JOIN managed_user u ON u.id = tu.user_id
851
+ ORDER BY u.login ASC`,
852
+ ),
853
+ upsertTeamRepoStmt: db.prepare(
854
+ `INSERT OR IGNORE INTO managed_team_repo (team_id, repo_id, path)
855
+ SELECT ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM managed_team_repo WHERE team_id = ? AND repo_id = ? AND path = '')`,
856
+ ),
857
+ deleteTeamRepoStmt: db.prepare(`DELETE FROM managed_team_repo WHERE team_id = ? AND repo_id = ?`),
858
+ deleteTeamRepoPathStmt: db.prepare(`DELETE FROM managed_team_repo WHERE team_id = ? AND repo_id = ? AND path = ?`),
859
+ upsertTeamMemberStmt: db.prepare(
860
+ `INSERT INTO managed_team_user (team_id, user_id, view_dependencies, view_security) VALUES (?, ?, ?, ?)
861
+ ON CONFLICT(team_id, user_id) DO UPDATE SET
862
+ view_dependencies = excluded.view_dependencies, view_security = excluded.view_security`,
863
+ ),
864
+ deleteTeamMemberStmt: db.prepare(`DELETE FROM managed_team_user WHERE team_id = ? AND user_id = ?`),
865
+ }
866
+ }
867
+
868
+ type RepoRow = {
869
+ repoId: number; fullName: string; priv: number; installId: number | null
870
+ branch: string; cachedDefaultBranch: string | null; htmlUrl: string; addedBy: string | null; addedAt: number; active: number
871
+ }
872
+
873
+ // The repo-selection slice of ManagedDb, split out to keep openSqliteManagedDb
874
+ // within the per-function line budget. Closes over its prepared statements.
875
+ function selectedRepoMethods(stmts: ReturnType<typeof prepareStatements>) {
876
+ const { upsertRepoStmt, deleteRepoStmt, deactivateRepoStmt, reactivateRepoStmt, selectReposStmt, selectAllReposStmt,
877
+ selectReportsForRepoStmt, selectBundlesForRepoStmt, deleteReportsForRepoStmt, deleteBundlesForRepoStmt } = stmts
878
+ const readRepo = (r: RepoRow): SelectedRepo => ({
879
+ repoId: r.repoId, fullName: r.fullName, private: r.priv === 1,
880
+ installationId: r.installId, defaultBranch: r.branch, cachedDefaultBranch: r.cachedDefaultBranch, htmlUrl: r.htmlUrl,
881
+ addedBy: r.addedBy, addedAt: r.addedAt,
882
+ })
883
+ const readManagedRepo = (r: RepoRow): ManagedRepo => ({ ...readRepo(r), active: r.active === 1 })
884
+ return {
885
+ async selectRepo(repo: SelectedRepoInput, now: number): Promise<void> {
886
+ await upsertRepoStmt.run(
887
+ repo.repoId, repo.fullName, repo.private ? 1 : 0, repo.installationId,
888
+ repo.defaultBranch, repo.htmlUrl, repo.addedBy, now, now,
889
+ )
890
+ },
891
+ async cacheRepoDefaultBranch(repo: SelectedRepo, branch: string | null): Promise<boolean> {
892
+ return Number((await stmts.cacheRepoDefaultBranchStmt.run(branch, repo.repoId, repo.fullName, repo.addedAt)).changes) > 0
893
+ },
894
+ async deselectRepo(repoId: number): Promise<boolean> {
895
+ return Number((await deleteRepoStmt.run(repoId)).changes) > 0
896
+ },
897
+ async connectRepoInstallation(repo: SelectedRepo, installationId: number, sessionId: string, now: number): Promise<boolean> {
898
+ return Number((await stmts.connectRepoInstallationStmt.run(installationId, now, repo.repoId, repo.fullName, repo.addedAt, sessionId, now)).changes) > 0
899
+ },
900
+ async listSelectedRepos(): Promise<SelectedRepo[]> {
901
+ const rows = (await selectReposStmt.all()) as RepoRow[]
902
+ return rows.map(readRepo)
903
+ },
904
+ async listAllRepos(): Promise<ManagedRepo[]> {
905
+ const rows = (await selectAllReposStmt.all()) as RepoRow[]
906
+ return rows.map(readManagedRepo)
907
+ },
908
+ async deactivateRepo(repoId: number): Promise<boolean> {
909
+ return Number((await deactivateRepoStmt.run(repoId)).changes) > 0
910
+ },
911
+ async reactivateRepo(repoId: number): Promise<boolean> {
912
+ return Number((await reactivateRepoStmt.run(repoId)).changes) > 0
913
+ },
914
+ async deleteRepo(repoId: number): Promise<boolean> {
915
+ return Number((await deleteRepoStmt.run(repoId)).changes) > 0
916
+ },
917
+ async listReportsForRepo(repoId: number): Promise<RepoReportItem[]> {
918
+ return (await selectReportsForRepoStmt.all(repoId)) as unknown as RepoReportItem[]
919
+ },
920
+ async listBundlesForRepo(repoId: number): Promise<RepoDataItem[]> {
921
+ return (await selectBundlesForRepoStmt.all(repoId)) as unknown as RepoDataItem[]
922
+ },
923
+ async deleteReportsForRepo(repoId: number): Promise<number> {
924
+ return Number((await deleteReportsForRepoStmt.run(repoId)).changes)
925
+ },
926
+ async deleteBundlesForRepo(repoId: number): Promise<number> {
927
+ return Number((await deleteBundlesForRepoStmt.run(repoId)).changes)
928
+ },
929
+ }
930
+ }
931
+
932
+ type ReportListRow = {
933
+ id: string; slug: string; filename: string; contentType: string; byteSize: number
934
+ sha256: string; uploadedByLogin: string | null
935
+ repoId: number | null; repoFullName: string | null
936
+ repoDirectory: string; repoEmbedded: number; analyzer: string | null; visible: number
937
+ bundleId: string | null; bundleFilename: string | null; bundleIntegrity: string | null
938
+ uploadedAt: number
939
+ }
940
+ type ReportRow = {
941
+ id: string; slug: string; filename: string; contentType: string; byteSize: number
942
+ sha256: string; uploadedBy: string | null; uploadedAt: number
943
+ repoId: number | null; repoDirectory: string; repoEmbedded: number; analyzer: string | null; visible: number
944
+ bundleId: string | null
945
+ }
946
+
947
+ // Byte storage may outlast a logout or grant change. HTTP uploads reauthorize
948
+ // inside the metadata writer transaction after their bytes have been stored.
949
+ async function authorizeUpload(stmts: ReturnType<typeof prepareStatements>, sessionId: string | undefined,
950
+ item: { uploadedBy: string | null; repoId: number | null; repoDirectory?: string }) {
951
+ if (sessionId === undefined) return // Trusted imports and fixtures use the raw store API.
952
+ const user = await stmts.selectSessionStmt.get(sessionId, Date.now()) as SessionRow | undefined
953
+ if (!user) throw new ManagedMutationError(401, 'unauthenticated')
954
+ if (user.uid !== item.uploadedBy || !roleAtLeast(user.role, 'manage')) throw new ManagedMutationError(403, 'forbidden')
955
+ if (item.repoId === null) return
956
+ if (!(await stmts.selectReposStmt.all() as RepoRow[]).some(repo => repo.repoId === item.repoId)) throw new ManagedMutationError(400, 'repo-not-selected')
957
+ if (user.role !== 'admin' && !await stmts.selectRepoPathReadableStmt.get(item.repoDirectory ?? '', item.repoId, user.uid)) throw new ManagedMutationError(403, 'repo-forbidden')
958
+ }
959
+
960
+ // The report slice of ManagedDb closes over its prepared statements.
961
+ function reportMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql, key: StorageKey | null) {
962
+ const { insertReportStmt, selectReportsStmt, selectReportStmt, deleteReportStmt, setReportRepoStmt, setReportVisibleStmt } = stmts
963
+ async function getReport(id: string): Promise<ReportRecord | null> {
964
+ const row = (await selectReportStmt.get(id)) as ReportRow | undefined
965
+ if (row == null) return null
966
+ return { ...row, repoEmbedded: row.repoEmbedded === 1, visible: row.visible === 1 }
967
+ }
968
+ async function getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null> {
969
+ const row = await stmts.selectReportByHashStmt.get(sha256, analyzer) as { id: string } | undefined
970
+ return row ? getReport(row.id) : null
971
+ }
972
+ async function insertReport(report: ReportRecordInput, now: number): Promise<void> {
973
+ await validateStorageUpload(db, key, 'report', report.id, report.dataKey)
974
+ await insertReportStmt.run(
975
+ report.id, preferredSlug(report.id), report.filename, report.contentType, report.byteSize,
976
+ report.sha256, report.uploadedBy, report.uploadedByLogin ?? null, report.repoId,
977
+ report.repoDirectory ?? '', report.repoEmbedded ? 1 : 0, report.analyzer ?? null, report.visible == null ? 0 : report.visible ? 1 : 0, report.bundleId ?? null,
978
+ report.bundleIntegrity ?? null, now, report.dataKey ?? null, report.dataKey ? 1 : 0,
979
+ )
980
+ }
981
+ return {
982
+ insertReport,
983
+ getReport,
984
+ resolveReportUpload: getReport,
985
+ getReportByHash,
986
+ async insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord> {
987
+ await authorizeUpload(stmts, sessionId, report)
988
+ // The driver holds its writer lock for this whole operation. Checking
989
+ // again here closes the race between the HTTP lookup and blob upload.
990
+ const existing = await getReportByHash(report.sha256, report.analyzer ?? null)
991
+ if (existing) return existing
992
+ await insertReport(report, now)
993
+ return (await getReport(report.id))!
994
+ },
995
+ async listReports(userId?: string): Promise<AdminReport[]> {
996
+ const rows = (await selectReportsStmt.all(userId ?? null, userId ?? null, userId ?? null)) as ReportListRow[]
997
+ return rows.map((r) => ({
998
+ id: r.id, slug: r.slug, filename: r.filename, contentType: r.contentType, byteSize: r.byteSize,
999
+ sha256: r.sha256, uploadedByLogin: r.uploadedByLogin,
1000
+ repoId: r.repoId, repoFullName: r.repoFullName,
1001
+ repoDirectory: r.repoDirectory, repoEmbedded: r.repoEmbedded === 1, analyzer: r.analyzer, visible: r.visible === 1,
1002
+ bundleId: r.bundleId, bundleFilename: r.bundleFilename, bundleIntegrity: r.bundleIntegrity,
1003
+ uploadedAt: r.uploadedAt,
1004
+ }))
1005
+ },
1006
+ async listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]> {
1007
+ return await stmts.findingCatalogReportsStmt.all(after, limit) as Pick<ReportRecord, 'id' | 'byteSize'>[]
1008
+ },
1009
+ async getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null> {
1010
+ const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1011
+ if (!session) return null
1012
+ const user: StoredUser = { id: session.uid, login: session.login, name: session.name, avatarUrl: session.avatar, role: session.role }
1013
+ if (user.role === 'none' || !await stmts.selectTeamAccessStmt.get(user.id, teamId)) return { user, teamId: null, reports: [], repositories: [] }
1014
+ const whole = user.role === 'admin' || user.role === 'manage'
1015
+ const rows = await stmts.selectTeamReportAccessStmt.all(user.id, teamId, whole ? 1 : 0) as {
1016
+ id: string; filename: string; byteSize: number; sha256: string; repoDirectory: string
1017
+ repoFullName: string | null; dependencies: number; security: number
1018
+ }[]
1019
+ const repositories = await stmts.selectTeamRepositoriesStmt.all(teamId) as TeamReportAccessSnapshot['repositories']
1020
+ return { user, teamId, repositories, reports: rows.map(row => ({
1021
+ id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
1022
+ repo: { github: row.repoFullName, directory: row.repoDirectory },
1023
+ permissions: { dependencies: whole || row.dependencies === 1, security: whole || row.security === 1 },
1024
+ })) }
1025
+ },
1026
+ async getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null> {
1027
+ const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1028
+ if (!session) return null
1029
+ const user: StoredUser = { id: session.uid, login: session.login, name: session.name, avatarUrl: session.avatar, role: session.role }
1030
+ if (user.role === 'none' || ids.length === 0) return { user, reports: [] }
1031
+ const admin = user.role === 'admin', manage = user.role === 'manage'
1032
+ const rows = await stmts.selectReportAccessStmt.all(JSON.stringify([...new Set(ids)]), user.id,
1033
+ admin ? 1 : 0, manage ? 1 : 0, user.id, manage ? 1 : 0) as {
1034
+ id: string; filename: string; byteSize: number; sha256: string; repoDirectory: string
1035
+ repoFullName: string | null; dependencies: number; security: number
1036
+ }[]
1037
+ return { user, reports: rows.map(row => ({
1038
+ id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
1039
+ repo: { github: row.repoFullName, directory: row.repoDirectory },
1040
+ permissions: { dependencies: admin || manage || row.dependencies === 1, security: admin || manage || row.security === 1 },
1041
+ })) }
1042
+ },
1043
+ async listReportFilenamesWithBundleHash(bundleId: string, sha256: string): Promise<string[]> {
1044
+ const rows = (await stmts.reportFilenamesWithBundleHashStmt.all(bundleId, sha256)) as { filename: string }[]
1045
+ return rows.map(row => row.filename)
1046
+ },
1047
+ async deleteReport(id: string): Promise<boolean> {
1048
+ return Number((await deleteReportStmt.run(id)).changes) > 0
1049
+ },
1050
+ async setReportRepo(id: string, repoId: number | null, repoDirectory = ''): Promise<boolean> {
1051
+ return Number((await setReportRepoStmt.run(repoId, repoId == null ? '' : repoDirectory, id)).changes) > 0
1052
+ },
1053
+ async setReportVisible(id: string, visible: boolean): Promise<boolean> {
1054
+ return Number((await setReportVisibleStmt.run(visible ? 1 : 0, id)).changes) > 0
1055
+ },
1056
+ }
1057
+ }
1058
+
1059
+ type TriageDbRow = {
1060
+ findingId: string; color: string | null; triage: string | null
1061
+ comment: string | null; fix: string | null; flagged: number | null
1062
+ updatedByLogin: string | null; updatedAt: number
1063
+ }
1064
+ type TriageStateDbRow = { color: string | null; triage: string | null; comment: string | null; fix: string | null; flagged: number | null }
1065
+ type TriageEventDbRow = TriageStateDbRow & {
1066
+ seq: number; findingId: string; batchId: string; actorId: string | null; actorLogin: string | null; actorName: string | null; at: number
1067
+ }
1068
+
1069
+ // The per-finding triage slice of ManagedDb. Closes over its prepared
1070
+ // statements (and the handle, for the batch write's transaction). A
1071
+ // null/empty entry writes the tombstone — every field NULL, writer and time
1072
+ // stamped — so a later reader learns the entry was cleared rather than never
1073
+ // set. Every change is also appended to the trail; a write equal to the
1074
+ // current row is skipped altogether, so a client re-pushing what already
1075
+ // stands neither re-stamps the writer nor echoes into the trail. `historyLimit`
1076
+ // > 0 keeps only that many events per finding; 0 keeps everything.
1077
+ function triageMethods(stmts: ReturnType<typeof prepareStatements>, historyLimit: number, db: ManagedSql) {
1078
+ const { upsertTriageStmt, selectTriageStmt, selectTriageStateStmt, insertTriageEventStmt, trimTriageEventsStmt, selectTriageHistoryStmt,
1079
+ deleteTriageStmt, deleteTriageHistoryStmt, deleteCommentsStmt, deleteCommentHistoryStmt, countAnnotationsStmt } = stmts
1080
+ async function writeEntry(findingId: string, entry: TriageEntryPatch | null, batchId: string, updatedBy: string | null, updatedByLogin: string | null, now: number, reportId: string | null = null): Promise<void> {
1081
+ const e = entry ?? {}
1082
+ // `flagged: false` is a real value (the explicit un-flag tombstone), so it
1083
+ // is 0 here and only null/absent maps to NULL.
1084
+ const next: TriageStateDbRow = {
1085
+ color: e.color ?? null, triage: e.triage ?? null, comment: e.comment ?? null,
1086
+ fix: e.fix ?? null, flagged: e.flagged == null ? null : (e.flagged ? 1 : 0),
1087
+ }
1088
+ const cur = (await selectTriageStateStmt.get(findingId)) as TriageStateDbRow | undefined
1089
+ if (cur != null && cur.color === next.color && cur.triage === next.triage && cur.comment === next.comment
1090
+ && cur.fix === next.fix && cur.flagged === next.flagged) return
1091
+ await upsertTriageStmt.run(findingId, next.color, next.triage, next.comment, next.fix, next.flagged, updatedBy, updatedByLogin, now)
1092
+ await insertTriageEventStmt.run(findingId, batchId, next.color, next.triage, next.comment, next.fix, next.flagged, updatedBy, updatedByLogin, now, reportId, reportId, reportId)
1093
+ if (historyLimit > 0) await trimTriageEventsStmt.run(findingId, findingId, historyLimit)
1094
+ }
1095
+ return {
1096
+ async getAnnotationRevision(findingIds: readonly string[]): Promise<string> {
1097
+ if (findingIds.length === 0) return '[]'
1098
+ const ids = JSON.stringify(findingIds)
1099
+ // Batch UUIDs survive history trimming and distinguish same-timestamp
1100
+ // writes, even if SQLite reuses a sequence after an annotation purge.
1101
+ return JSON.stringify([
1102
+ await stmts.annotationTriageRevisionStmt.all(ids),
1103
+ await stmts.annotationCommentRevisionStmt.all(ids),
1104
+ ])
1105
+ },
1106
+ async deleteTriage(findingIds: readonly string[]): Promise<number> {
1107
+ if (findingIds.length === 0) return 0
1108
+ const ids = JSON.stringify(findingIds)
1109
+ const { total: deleted } = (await countAnnotationsStmt.get(ids, ids, ids)) as { total: number }
1110
+ await deleteTriageStmt.run(ids)
1111
+ await deleteTriageHistoryStmt.run(ids)
1112
+ await deleteCommentsStmt.run(ids)
1113
+ await deleteCommentHistoryStmt.run(ids)
1114
+ return deleted
1115
+ },
1116
+ async listTriageHistory(findingId: string, limit: number): Promise<TriageEventRow[]> {
1117
+ const rows = (await selectTriageHistoryStmt.all(findingId, limit)) as TriageEventDbRow[]
1118
+ return rows.map((r) => ({
1119
+ seq: r.seq, findingId: r.findingId, batchId: r.batchId,
1120
+ color: r.color, triage: r.triage, comment: r.comment, fix: r.fix,
1121
+ flagged: r.flagged == null ? null : r.flagged === 1,
1122
+ actorId: r.actorId, actorLogin: r.actorLogin, actorName: r.actorName, at: r.at,
1123
+ }))
1124
+ },
1125
+ async listTriage(findingIds: readonly string[]): Promise<TriageRow[]> {
1126
+ if (findingIds.length === 0) return []
1127
+ const rows = (await selectTriageStmt.all(JSON.stringify(findingIds))) as TriageDbRow[]
1128
+ return rows.map((r) => ({
1129
+ findingId: r.findingId, color: r.color, triage: r.triage, comment: r.comment, fix: r.fix,
1130
+ flagged: r.flagged == null ? null : r.flagged === 1,
1131
+ updatedByLogin: r.updatedByLogin, updatedAt: r.updatedAt,
1132
+ }))
1133
+ },
1134
+ async setTriage(findingId: string, entry: TriageEntryPatch | null, updatedBy: string | null, updatedByLogin: string | null, now: number): Promise<void> {
1135
+ // A single write is its own batch. The row and its event go together.
1136
+ await writeEntry(findingId, entry, randomUUID(), updatedBy, updatedByLogin, now)
1137
+ },
1138
+ async setTriageEntries(entries: readonly (readonly [string, TriageEntryPatch | null])[], updatedBy: string | null, updatedByLogin: string | null, now: number, reportId?: string): Promise<void> {
1139
+ // One transaction, so a batch is never half-applied by a mid-loop error
1140
+ // (and costs one fsync under synchronous = FULL, not one per row); one
1141
+ // batch id groups its rows in the trail.
1142
+ const batchId = randomUUID()
1143
+ if (new Set(entries.map(([id]) => id)).size !== entries.length) {
1144
+ // Preserve the ordered history of callers that supply repeated IDs.
1145
+ for (const [id, entry] of entries) await writeEntry(id, entry, batchId, updatedBy, updatedByLogin, now, reportId)
1146
+ return
1147
+ }
1148
+ const current = new Map((await selectTriageStmt.all(JSON.stringify(entries.map(([id]) => id))) as TriageDbRow[])
1149
+ .map(row => [row.findingId, row]))
1150
+ const changed = entries.flatMap(([id, entry]) => {
1151
+ const e = entry ?? {}, previous = current.get(id)
1152
+ const next = { color: e.color ?? null, triage: e.triage ?? null, comment: e.comment ?? null,
1153
+ fix: e.fix ?? null, flagged: e.flagged == null ? null : e.flagged ? 1 : 0 }
1154
+ if (previous && Object.entries(next).every(([key, value]) => previous[key as keyof TriageStateDbRow] === value)) return []
1155
+ return [[id, next.color, next.triage, next.comment, next.fix, next.flagged]]
1156
+ })
1157
+ // Bound SQL parameters while keeping the entire batch/history atomic.
1158
+ for (let offset = 0; offset < changed.length; offset += 200) {
1159
+ const rows = changed.slice(offset, offset + 200), values = rows.flat()
1160
+ const input = `WITH changes(finding_id, color, triage, comment, fix, flagged, ordinal) AS
1161
+ (VALUES ${rows.map((_, index) => `(?, ?, ?, ?, ?, CAST(? AS INTEGER), ${index})`).join(',')})`
1162
+ await db.prepare(`${input}
1163
+ INSERT INTO managed_finding_triage (finding_id, color, triage, comment, fix, flagged, updated_by, updated_by_login, updated_at)
1164
+ SELECT finding_id, color, triage, comment, fix, flagged, ?, ?, CAST(? AS BIGINT) FROM changes WHERE TRUE
1165
+ ON CONFLICT(finding_id) DO UPDATE SET color = excluded.color, triage = excluded.triage,
1166
+ comment = excluded.comment, fix = excluded.fix, flagged = excluded.flagged, updated_by = excluded.updated_by,
1167
+ updated_by_login = excluded.updated_by_login, updated_at = excluded.updated_at`).run(...values, updatedBy, updatedByLogin, now)
1168
+ await db.prepare(`${input}
1169
+ INSERT INTO managed_finding_triage_event (finding_id, batch_id, color, triage, comment, fix, flagged, actor_id, actor_login, at, report_id, report, repo)
1170
+ SELECT c.finding_id, ?, c.color, c.triage, c.comment, c.fix, c.flagged, ?, ?, CAST(? AS BIGINT), ?, r.filename, p.full_name
1171
+ FROM changes c LEFT JOIN managed_report r ON r.id = ? LEFT JOIN managed_selected_repo p ON p.repo_id = r.repo_id ORDER BY c.ordinal`)
1172
+ .run(...values, batchId, updatedBy, updatedByLogin, now, reportId ?? null, reportId ?? null)
1173
+ if (historyLimit > 0) {
1174
+ await db.prepare(`DELETE FROM managed_finding_triage_event WHERE seq IN (
1175
+ SELECT seq FROM (SELECT seq, ROW_NUMBER() OVER (PARTITION BY finding_id ORDER BY seq DESC) AS position
1176
+ FROM managed_finding_triage_event WHERE finding_id IN (SELECT value FROM json_each(?))) ranked WHERE position > ?)`)
1177
+ .run(JSON.stringify(rows.map(row => row[0])), historyLimit)
1178
+ }
1179
+ }
1180
+ },
1181
+ }
1182
+ }
1183
+
1184
+ type BundleRow = {
1185
+ id: string; slug: string; integrity: string; filename: string; kind: string | null; byteSize: number
1186
+ uploadedBy: string | null; repoId: number | null; repoDirectory: string; uploadedAt: number
1187
+ }
1188
+ type BundleListRow = {
1189
+ id: string; slug: string; integrity: string; filename: string; kind: string | null; byteSize: number
1190
+ uploadedByLogin: string | null; repoId: number | null; repoFullName: string | null; repoDirectory: string; uploadedAt: number
1191
+ }
1192
+
1193
+ function mapBundle(r: BundleRow): ManagedBundle {
1194
+ return {
1195
+ id: r.id, slug: r.slug, integrity: r.integrity, filename: r.filename, kind: r.kind,
1196
+ byteSize: r.byteSize, uploadedBy: r.uploadedBy, repoId: r.repoId, repoDirectory: r.repoDirectory, uploadedAt: r.uploadedAt,
1197
+ }
1198
+ }
1199
+
1200
+ // The bundle slice of ManagedDb. Closes over its prepared statements.
1201
+ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql, key: StorageKey | null) {
1202
+ const {
1203
+ insertBundleStmt, selectBundleByIntegrityStmt, selectBundleStmt,
1204
+ selectBundlesStmt, deleteBundleStmt, setBundleRepoStmt, linkReportsToBundleStmt, selectBundleReadableStmt, selectRepoReadableStmt, selectRepoPathReadableStmt,
1205
+ } = stmts
1206
+ async function getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null> {
1207
+ const row = (await selectBundleByIntegrityStmt.get(integrity)) as BundleRow | undefined
1208
+ return row == null ? null : mapBundle(row)
1209
+ }
1210
+ return {
1211
+ async insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void> {
1212
+ await authorizeUpload(stmts, sessionId, bundle)
1213
+ await validateStorageUpload(db, key, 'bundle', bundle.id, bundle.dataKey)
1214
+ await insertBundleStmt.run(
1215
+ bundle.id, preferredSlug(bundle.id), bundle.integrity, bundle.filename, bundle.kind,
1216
+ bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', now, bundle.dataKey ?? null, bundle.dataKey ? 1 : 0,
1217
+ )
1218
+ },
1219
+ getBundleByIntegrity,
1220
+ // The distinct method name selects a writer-locked reconciliation scope.
1221
+ resolveBundleUpload: getBundleByIntegrity,
1222
+ async getBundle(id: string): Promise<ManagedBundle | null> {
1223
+ const row = (await selectBundleStmt.get(id)) as BundleRow | undefined
1224
+ return row == null ? null : mapBundle(row)
1225
+ },
1226
+ async getBundleAccessSnapshot(sessionId: string, now: number, id: string, advisoriesTeamId?: string | null) {
1227
+ const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1228
+ if (!session) return null
1229
+ const denied = { bundle: null, canReadAdvisories: false }
1230
+ if (!roleAtLeast(session.role, 'view')) return denied
1231
+ const row = await selectBundleStmt.get(id) as BundleRow | undefined
1232
+ if (!row) return denied
1233
+ const manager = roleAtLeast(session.role, 'manage')
1234
+ if (session.role !== 'admin') {
1235
+ const allowed = manager ? await selectBundleReadableStmt.get(id, session.uid, session.uid)
1236
+ : row.repoId !== null && await selectRepoPathReadableStmt.get(row.repoDirectory, row.repoId, session.uid)
1237
+ if (!allowed) return denied
1238
+ }
1239
+ const canReadAdvisories = manager || (advisoriesTeamId !== undefined
1240
+ && await stmts.selectBundleSecurityStmt.get(id, session.uid, advisoriesTeamId, advisoriesTeamId) != null)
1241
+ return { bundle: mapBundle(row), canReadAdvisories }
1242
+ },
1243
+ async listBundles(userId?: string): Promise<AdminBundle[]> {
1244
+ const rows = (await selectBundlesStmt.all(userId ?? null, userId ?? null, userId ?? null)) as BundleListRow[]
1245
+ return rows.map((r) => ({
1246
+ id: r.id, slug: r.slug, integrity: r.integrity, filename: r.filename, kind: r.kind, byteSize: r.byteSize,
1247
+ uploadedByLogin: r.uploadedByLogin, repoId: r.repoId, repoFullName: r.repoFullName, repoDirectory: r.repoDirectory,
1248
+ uploadedAt: r.uploadedAt,
1249
+ }))
1250
+ },
1251
+ async userCanReadBundle(userId: string, id: string): Promise<boolean> {
1252
+ return (await selectBundleReadableStmt.get(id, userId, userId)) != null
1253
+ },
1254
+ async listReadableBundleIds(userId: string, ids: readonly string[]): Promise<string[]> {
1255
+ if (ids.length === 0) return []
1256
+ const rows = await stmts.selectReadableBundleIdsStmt.all(JSON.stringify([...new Set(ids)]), userId, userId) as { id: string }[]
1257
+ return rows.map(row => row.id)
1258
+ },
1259
+ async userCanReadRepo(userId: string, repoId: number): Promise<boolean> {
1260
+ return (await selectRepoReadableStmt.get(repoId, userId)) != null
1261
+ },
1262
+ async userCanReadRepoPath(userId: string, repoId: number, directory: string): Promise<boolean> {
1263
+ return (await selectRepoPathReadableStmt.get(directory, repoId, userId)) != null
1264
+ },
1265
+ async deleteBundle(id: string): Promise<boolean> {
1266
+ return Number((await deleteBundleStmt.run(id)).changes) > 0
1267
+ },
1268
+ async setBundleRepo(id: string, repoId: number | null, directory?: string): Promise<boolean> {
1269
+ return Number((await setBundleRepoStmt.run(repoId, repoId == null ? '' : directory ?? '', id)).changes) > 0
1270
+ },
1271
+ async linkReportsToBundle(integrity: string, bundleId: string, userId?: string): Promise<void> {
1272
+ await linkReportsToBundleStmt.run(bundleId, integrity, userId ?? null, userId ?? null, userId ?? null)
1273
+ },
1274
+ }
1275
+ }
1276
+
1277
+ type TeamRow = { id: string; slug: string; name: string }
1278
+ type TeamRepoRow = { teamId: string; repoId: number; fullName: string; path: string | null }
1279
+ type TeamMemberRow = { teamId: string; userId: string; login: string; viewDependencies: number; viewSecurity: number }
1280
+
1281
+ // The team slice of ManagedDb. listTeams reads the three tables in full and
1282
+ // groups in JS (3 queries, not N+1) — fine for the handful of teams a managed
1283
+ // workspace has.
1284
+ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql) {
1285
+ const {
1286
+ insertTeamStmt, selectTeamByNameStmt, renameTeamStmt, deleteTeamStmt, selectTeamStmt,
1287
+ selectUserRepoScopesStmt, selectTeamsStmt, selectTeamsForUserStmt, selectUserTeamReportsStmt, selectUserTeamBundlesStmt, selectReportReadableStmt,
1288
+ selectReportPermsStmt, selectUserOptionsStmt, selectTeamReposStmt, selectTeamMembersStmt,
1289
+ upsertTeamRepoStmt, deleteTeamRepoStmt, deleteTeamRepoPathStmt, upsertTeamMemberStmt, deleteTeamMemberStmt,
1290
+ } = stmts
1291
+ const methods = {
1292
+ async createTeam(id: string, name: string, now: number): Promise<boolean> {
1293
+ return Number((await insertTeamStmt.run(id, preferredSlug(id), name, now, now)).changes) > 0
1294
+ },
1295
+ async renameTeam(id: string, name: string, now: number): Promise<'ok' | 'name-taken' | 'not-found'> {
1296
+ // The operation transaction serializes the existence/name checks and rename.
1297
+ if ((await selectTeamStmt.get(id)) == null) return 'not-found'
1298
+ const clash = (await selectTeamByNameStmt.get(name)) as { id: string } | undefined
1299
+ if (clash != null && clash.id !== id) return 'name-taken'
1300
+ await renameTeamStmt.run(name, now, id)
1301
+ return 'ok'
1302
+ },
1303
+ async deleteTeam(id: string): Promise<boolean> {
1304
+ return Number((await deleteTeamStmt.run(id)).changes) > 0
1305
+ },
1306
+ async getTeam(id: string): Promise<{ id: string; slug: string; name: string } | null> {
1307
+ const row = (await selectTeamStmt.get(id)) as TeamRow | undefined
1308
+ return row == null ? null : { id: row.id, slug: row.slug, name: row.name }
1309
+ },
1310
+ async listUserOptions(): Promise<UserOption[]> {
1311
+ return ((await selectUserOptionsStmt.all()) as UserOption[]).map((u) => ({ id: u.id, login: u.login, name: u.name, role: u.role }))
1312
+ },
1313
+ async listRepoScopesForUser(userId: string): Promise<{ repoId: number; path: string | null }[]> {
1314
+ return (await selectUserRepoScopesStmt.all(userId)) as { repoId: number; path: string | null }[]
1315
+ },
1316
+ async listTeamsForUser(userId: string): Promise<UserTeam[]> {
1317
+ const teams = (await selectTeamsForUserStmt.all(userId)) as { id: string; slug: string; name: string; dependencies: number; security: number }[]
1318
+ const scopes = await stmts.selectUserTeamScopesStmt.all(userId) as { teamId: string; repoId: number; path: string; fullName: string }[]
1319
+ const reportsByTeam = new Map<string, UserTeamReport[]>()
1320
+ for (const r of (await selectUserTeamReportsStmt.all(userId)) as {
1321
+ teamId: string; id: string; slug: string; filename: string; analyzer: string | null; sha256: string; bundleId: string | null; repoId: number
1322
+ repoDirectory: string; repoFullName: string | null; dependencies: number; security: number; role: string
1323
+ }[]) {
1324
+ const list = reportsByTeam.get(r.teamId) ?? []
1325
+ const cacheKey = createHash('sha256').update(JSON.stringify([
1326
+ r.sha256, r.bundleId, r.filename, r.repoId, r.repoDirectory, r.repoFullName, r.dependencies, r.security, r.role,
1327
+ ])).digest('base64url')
1328
+ list.push({ id: r.id, slug: r.slug, filename: r.filename, analyzer: r.analyzer, repoFullName: r.repoFullName, repoDirectory: r.repoDirectory, cacheKey })
1329
+ reportsByTeam.set(r.teamId, list)
1330
+ }
1331
+ const bundlesByTeam = new Map<string, UserTeamBundle[]>()
1332
+ for (const b of (await selectUserTeamBundlesStmt.all(userId)) as (UserTeamBundle & { teamId: string })[]) {
1333
+ const list = bundlesByTeam.get(b.teamId) ?? []
1334
+ list.push({ id: b.id, slug: b.slug, integrity: b.integrity, filename: b.filename,
1335
+ kind: b.kind, byteSize: b.byteSize, repoId: b.repoId, repoDirectory: b.repoDirectory, repoFullName: b.repoFullName })
1336
+ bundlesByTeam.set(b.teamId, list)
1337
+ }
1338
+ return teams.map((t) => ({
1339
+ id: t.id, slug: t.slug, name: t.name,
1340
+ cacheKey: createHash('sha256').update(JSON.stringify([
1341
+ t.dependencies, t.security, scopes.filter(scope => scope.teamId === t.id),
1342
+ ])).digest('base64url'),
1343
+ reports: reportsByTeam.get(t.id) ?? [],
1344
+ bundles: bundlesByTeam.get(t.id) ?? [],
1345
+ }))
1346
+ },
1347
+ async getFeedState(sessionId: string, now: number) {
1348
+ const row = await db.prepare(`SELECT u.id, u.role, r.catalog, r.annotations FROM managed_session s
1349
+ JOIN managed_user u ON u.id = s.user_id JOIN managed_change_revision r ON r.id = 1
1350
+ WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, now) as { id: string; role: Role; catalog: number; annotations: number } | undefined
1351
+ return row ? { user: { id: row.id, role: row.role }, catalog: row.catalog, annotations: row.annotations } : null
1352
+ },
1353
+ async getUserTeamFeedSnapshot(sessionId: string, now: number) {
1354
+ // One short read-only transaction, shared by SQLite and Postgres. Never
1355
+ // touch last-seen timestamps or include other users' memberships.
1356
+ const state = await methods.getFeedState(sessionId, now)
1357
+ if (!state) return null
1358
+ const teams = state.user.role === 'none' ? [] : await methods.listTeamsForUser(state.user.id)
1359
+ return { user: state.user, catalog: state.catalog, teams, revision: teamCatalogRevision(teams) }
1360
+ },
1361
+ async userCanReadReport(userId: string, reportId: string): Promise<boolean> {
1362
+ return (await selectReportReadableStmt.get(reportId, userId)) != null
1363
+ },
1364
+ async userCanReadBundleAdvisories(userId: string, bundleId: string, teamId: string | null): Promise<boolean> {
1365
+ return (await stmts.selectBundleSecurityStmt.get(bundleId, userId, teamId, teamId)) != null
1366
+ },
1367
+ async reportPermissionsFor(userId: string, reportId: string): Promise<TeamUserPermissions> {
1368
+ const row = (await selectReportPermsStmt.get(userId, reportId)) as { dependencies: number | null; security: number | null } | undefined
1369
+ return { dependencies: row?.dependencies === 1, security: row?.security === 1 }
1370
+ },
1371
+ async listTeams(): Promise<AdminTeam[]> {
1372
+ const teams = (await selectTeamsStmt.all()) as TeamRow[]
1373
+ const reposByTeam = new Map<string, TeamRepoLink[]>()
1374
+ for (const r of (await selectTeamReposStmt.all()) as TeamRepoRow[]) {
1375
+ const list = reposByTeam.get(r.teamId) ?? []
1376
+ list.push({ repoId: r.repoId, fullName: r.fullName, path: r.path })
1377
+ reposByTeam.set(r.teamId, list)
1378
+ }
1379
+ const membersByTeam = new Map<string, TeamMember[]>()
1380
+ for (const m of (await selectTeamMembersStmt.all()) as TeamMemberRow[]) {
1381
+ const list = membersByTeam.get(m.teamId) ?? []
1382
+ list.push({ userId: m.userId, login: m.login, dependencies: m.viewDependencies === 1, security: m.viewSecurity === 1 })
1383
+ membersByTeam.set(m.teamId, list)
1384
+ }
1385
+ return teams.map((t) => ({
1386
+ id: t.id, slug: t.slug, name: t.name,
1387
+ repos: reposByTeam.get(t.id) ?? [],
1388
+ members: membersByTeam.get(t.id) ?? [],
1389
+ }))
1390
+ },
1391
+ async setTeamRepo(teamId: string, repoId: number, path: string | null): Promise<void> {
1392
+ // A whole-repository grant supersedes all its path grants atomically.
1393
+ // Adding a redundant path to an existing whole-repo grant is a no-op.
1394
+ if (path == null || path === '') await deleteTeamRepoStmt.run(teamId, repoId)
1395
+ await upsertTeamRepoStmt.run(teamId, repoId, path ?? '', teamId, repoId)
1396
+ },
1397
+ async removeTeamRepo(teamId: string, repoId: number, path?: string | null): Promise<boolean> {
1398
+ const result = path === undefined ? (await deleteTeamRepoStmt.run(teamId, repoId)) : (await deleteTeamRepoPathStmt.run(teamId, repoId, path ?? ''))
1399
+ return Number(result.changes) > 0
1400
+ },
1401
+ async setTeamMember(teamId: string, userId: string, perms: TeamUserPermissions): Promise<void> {
1402
+ await upsertTeamMemberStmt.run(teamId, userId, perms.dependencies ? 1 : 0, perms.security ? 1 : 0)
1403
+ },
1404
+ async removeTeamMember(teamId: string, userId: string): Promise<boolean> {
1405
+ return Number((await deleteTeamMemberStmt.run(teamId, userId)).changes) > 0
1406
+ },
1407
+ }
1408
+ return methods
1409
+ }
1410
+
1411
+ // `triageHistoryLimit`: events kept per finding in the triage trail; 0 (the
1412
+ // default) keeps everything. See ManagedConfig.
1413
+ export interface ManagedDbOptions {
1414
+ storageEncryptionKey?: StorageKey | null
1415
+ triageHistoryLimit?: number
1416
+ }
1417
+
1418
+ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions = {}): ManagedDb {
1419
+ const key = options.storageEncryptionKey ?? null
1420
+ const comments = commentMethods(db)
1421
+ const activity = activityMethods(db)
1422
+ const stmts = prepareStatements(db)
1423
+ const reports = reportMethods(stmts, db, key)
1424
+ const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0, db)
1425
+ const {
1426
+ upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectSessionStmt, selectUsersStmt,
1427
+ touchUserSeenStmt, updateRoleStmt, updateTokensStmt, selectTokensStmt, deleteSessionStmt, deleteExpiredStmt,
1428
+ } = stmts
1429
+
1430
+ const methods: Omit<ManagedDb, keyof ManagementStore | keyof ManagementCatalogStore> = {
1431
+ ...storageMethods(db, key),
1432
+ ...bundleBuildLeaseMethods(db),
1433
+ async claimMaintenanceLease(owner, now, until, migration = false) {
1434
+ return !!await db.prepare(`INSERT INTO managed_maintenance_lease (id, owner, expires_at) VALUES (?, ?, ?)
1435
+ ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at
1436
+ WHERE managed_maintenance_lease.expires_at <= ? RETURNING id`).get(migration ? 2 : 1, owner, until, now)
1437
+ },
1438
+ async finishMaintenanceLease(owner, until) {
1439
+ await db.prepare('UPDATE managed_maintenance_lease SET expires_at = ? WHERE owner = ?').run(until, owner)
1440
+ },
1441
+ async upsertUser(user, now, initialAdminGithubId = null) {
1442
+ // New row → a fresh id; ON CONFLICT(github_user_id) keeps an existing
1443
+ // user's id (DO UPDATE leaves it untouched), so re-read to return it.
1444
+ await upsertUserStmt.run(randomUUID(), user.githubUserId, user.login, user.name, user.avatarUrl, now, now)
1445
+ const row = (await selectUserIdStmt.get(user.githubUserId)) as { id: string; role: Role }
1446
+ // Avoid the population check unless this login matches the configured
1447
+ // identity and still needs approval. NOT EXISTS stops at any other user.
1448
+ if (initialAdminGithubId != null && Number.isSafeInteger(initialAdminGithubId) && initialAdminGithubId > 0
1449
+ && user.githubUserId === initialAdminGithubId && row.role === 'none') {
1450
+ await promoteInitialAdminStmt.run(row.id, row.id)
1451
+ }
1452
+ return row.id
1453
+ },
1454
+ async createSession(session, now) {
1455
+ await insertSessionStmt.run(session.id, session.userId, session.csrfToken, now, session.expiresAt)
1456
+ await touchUserSeenStmt.run(now, session.userId, now)
1457
+ },
1458
+ async sessionWithUser(id, now) {
1459
+ const row = (await selectSessionStmt.get(id, now)) as SessionRow | undefined
1460
+ if (row == null) return null
1461
+ await touchUserSeenStmt.run(now, row.uid, now)
1462
+ return {
1463
+ session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp },
1464
+ user: { id: row.uid, login: row.login, name: row.name, avatarUrl: row.avatar, role: row.role },
1465
+ }
1466
+ },
1467
+ async deleteSession(id) {
1468
+ await deleteSessionStmt.run(id)
1469
+ },
1470
+ async deleteExpiredSessions(now) {
1471
+ return Number((await deleteExpiredStmt.run(now)).changes)
1472
+ },
1473
+ async listUsers() {
1474
+ const rows = (await selectUsersStmt.all()) as UserRow[]
1475
+ return rows.map((r) => ({ id: r.id, login: r.login, name: r.name, role: r.role, createdAt: r.created, lastSeenAt: r.lastSeen, lastActivityAt: r.lastActivity }))
1476
+ },
1477
+ async setUserRole(id, role) {
1478
+ return Number((await updateRoleStmt.run(role, Date.now(), id)).changes) > 0
1479
+ },
1480
+ async setUserTokens(id, tokens) {
1481
+ const state = await storageState(db, key)
1482
+ const value = state ? encodeStorageTokens(key!, id, tokens.accessToken, tokens.refreshToken) : { access: tokens.accessToken, refresh: tokens.refreshToken }
1483
+ await updateTokensStmt.run(value.access, value.refresh, tokens.expiresAt, state ? 1 : 0, Date.now(), id)
1484
+ },
1485
+ async getUserTokens(id) {
1486
+ const state = await storageState(db, key)
1487
+ const row = (await selectTokensStmt.get(id)) as StoredTokens | undefined
1488
+ if (row == null || row.access == null) return null
1489
+ if (row.encrypted && !state) throw new Error('Encrypted tokens without storage encryption state')
1490
+ if (!row.encrypted && state?.complete) throw new Error('Unexpected plaintext GitHub tokens')
1491
+ const value = row.encrypted ? decodeStorageTokens(key!, id, row) : row
1492
+ return { accessToken: value.access!, refreshToken: value.refresh, expiresAt: row.exp }
1493
+ },
1494
+ async getUserGithubId(id) {
1495
+ const row = (await selectGithubIdStmt.get(id)) as { githubId: number } | undefined
1496
+ return row?.githubId ?? null
1497
+ },
1498
+ ...githubMetadataMethods(db),
1499
+ ...managedIssueMethods(db),
1500
+ ...selectedRepoMethods(stmts),
1501
+ ...activity,
1502
+ ...comments,
1503
+ async getAnnotations(ids) {
1504
+ return { triage: await triage.listTriage(ids), comments: await comments.listComments(ids) }
1505
+ },
1506
+ ...workspaceShareMethods(db),
1507
+ ...reports,
1508
+ ...triage,
1509
+ ...importTriageMethods({ getReport: reports.getReport, ...triage, ...comments }),
1510
+ ...bundleMethods(stmts, db, key),
1511
+ ...teamMethods(stmts, db),
1512
+ async close() {
1513
+ await db.close()
1514
+ },
1515
+ }
1516
+ return { ...methods, ...managementMethods(methods), ...managementCatalogMethods(methods) }
1517
+ }