@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.21

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