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

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 +1330 -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 +25 -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,1330 @@
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 server configuration and
311
+ // applies exclusively to the first insertion into an empty user table.
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
+ // Only the operator-preapproved identity may bootstrap the empty DB.
451
+ // The emptiness check and insert share the store's writer transaction,
452
+ // so concurrent first logins cannot both become admins. Re-login never
453
+ // overwrites approval or revocation through the ON CONFLICT update.
454
+ upsertUserStmt: db.prepare(
455
+ `INSERT INTO managed_user (id, github_user_id, login, name, avatar_url, role, created_at, updated_at)
456
+ VALUES (?, ?, ?, ?, ?, CASE WHEN NOT EXISTS(SELECT 1 FROM managed_user) THEN ? ELSE 'none' END, ?, ?)
457
+ ON CONFLICT(github_user_id) DO UPDATE SET
458
+ login = excluded.login, name = excluded.name,
459
+ avatar_url = excluded.avatar_url, updated_at = excluded.updated_at`,
460
+ ),
461
+ selectUserIdStmt: db.prepare(`SELECT id FROM managed_user WHERE github_user_id = ?`),
462
+ selectGithubIdStmt: db.prepare(`SELECT github_user_id AS githubId FROM managed_user WHERE id = ?`),
463
+ insertSessionStmt: db.prepare(
464
+ `INSERT INTO managed_session (id, user_id, csrf_token, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
465
+ ),
466
+ selectSessionStmt: db.prepare(
467
+ `SELECT s.id AS id, s.csrf_token AS csrf, s.expires_at AS exp,
468
+ u.id AS uid, u.login AS login, u.name AS name, u.avatar_url AS avatar, u.role AS role
469
+ FROM managed_session s
470
+ JOIN managed_user u ON u.id = s.user_id
471
+ WHERE s.id = ? AND s.expires_at > ?`,
472
+ ),
473
+ selectUsersStmt: db.prepare(
474
+ `SELECT u.id, u.login, u.name, u.role, u.created_at AS created, u.last_seen_at AS lastSeen,
475
+ (SELECT MAX(at) FROM (
476
+ SELECT MAX(e.at) AS at FROM managed_finding_triage_event e WHERE e.actor_id = u.id
477
+ UNION ALL SELECT MAX(a.at) AS at FROM managed_activity a WHERE a.actor_id = u.id
478
+ UNION ALL SELECT MAX(c.at) AS at FROM managed_finding_comment_event c WHERE c.actor_id = u.id
479
+ ) AS events) AS lastActivity
480
+ FROM managed_user u ORDER BY u.created_at ASC, u.login ASC`,
481
+ ),
482
+ touchUserSeenStmt: db.prepare(
483
+ `UPDATE managed_user SET last_seen_at = ? WHERE id = ? AND (last_seen_at IS NULL OR last_seen_at < ?)`,
484
+ ),
485
+ updateRoleStmt: db.prepare(`UPDATE managed_user SET role = ?, updated_at = ? WHERE id = ?`),
486
+ updateTokensStmt: db.prepare(
487
+ `UPDATE managed_user
488
+ SET gh_access_token = ?, gh_refresh_token = ?, gh_token_expires_at = ?, updated_at = ?
489
+ WHERE id = ?`,
490
+ ),
491
+ selectTokensStmt: db.prepare(
492
+ `SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp
493
+ FROM managed_user WHERE id = ?`,
494
+ ),
495
+ deleteSessionStmt: db.prepare(`DELETE FROM managed_session WHERE id = ?`),
496
+ deleteExpiredStmt: db.prepare(`DELETE FROM managed_session WHERE expires_at <= ?`),
497
+ upsertRepoStmt: db.prepare(
498
+ `INSERT INTO managed_selected_repo (repo_id, full_name, is_private, installation_id, default_branch, html_url, added_by, added_at, updated_at, active)
499
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)
500
+ ON CONFLICT(repo_id) DO UPDATE SET
501
+ full_name = excluded.full_name, is_private = excluded.is_private,
502
+ installation_id = excluded.installation_id, default_branch = excluded.default_branch,
503
+ html_url = excluded.html_url, updated_at = excluded.updated_at, active = 1`,
504
+ ),
505
+ deleteRepoStmt: db.prepare(`DELETE FROM managed_selected_repo WHERE repo_id = ?`),
506
+ deactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 0 WHERE repo_id = ? AND active = 1`),
507
+ reactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 1 WHERE repo_id = ? AND active = 0`),
508
+ selectReportsForRepoStmt: db.prepare(`SELECT id, filename, sha256, bundle_id AS bundleId FROM managed_report WHERE repo_id = ? ORDER BY filename ASC`),
509
+ selectBundlesForRepoStmt: db.prepare(`SELECT id, filename FROM managed_bundle WHERE repo_id = ? ORDER BY filename ASC`),
510
+ deleteReportsForRepoStmt: db.prepare(`DELETE FROM managed_report WHERE repo_id = ?`),
511
+ deleteBundlesForRepoStmt: db.prepare(`DELETE FROM managed_bundle WHERE repo_id = ?`),
512
+ deleteTriageStmt: db.prepare(`DELETE FROM managed_finding_triage WHERE finding_id IN (SELECT value FROM json_each(?))`),
513
+ deleteTriageHistoryStmt: db.prepare(`DELETE FROM managed_finding_triage_event WHERE finding_id IN (SELECT value FROM json_each(?))`),
514
+ deleteCommentsStmt: db.prepare(`DELETE FROM managed_finding_comment WHERE finding_id IN (SELECT value FROM json_each(?))`),
515
+ deleteCommentHistoryStmt: db.prepare(`DELETE FROM managed_finding_comment_event WHERE finding_id IN (SELECT value FROM json_each(?))`),
516
+ countAnnotationsStmt: db.prepare(`SELECT count(*) AS total FROM (
517
+ SELECT finding_id FROM managed_finding_triage WHERE finding_id IN (SELECT value FROM json_each(?))
518
+ UNION SELECT finding_id FROM managed_finding_comment WHERE finding_id IN (SELECT value FROM json_each(?))
519
+ UNION SELECT finding_id FROM managed_finding_comment_event WHERE finding_id IN (SELECT value FROM json_each(?))) AS annotations`),
520
+ selectReposStmt: db.prepare(
521
+ `SELECT repo_id AS repoId, full_name AS fullName, is_private AS priv,
522
+ installation_id AS installId, default_branch AS branch, html_url AS htmlUrl,
523
+ added_by AS addedBy, added_at AS addedAt, active AS active
524
+ FROM managed_selected_repo WHERE active = 1 ORDER BY full_name ASC`,
525
+ ),
526
+ selectAllReposStmt: db.prepare(
527
+ `SELECT repo_id AS repoId, full_name AS fullName, is_private AS priv,
528
+ installation_id AS installId, default_branch AS branch, html_url AS htmlUrl,
529
+ added_by AS addedBy, added_at AS addedAt, active AS active
530
+ FROM managed_selected_repo ORDER BY full_name ASC`,
531
+ ),
532
+ insertReportStmt: db.prepare(
533
+ `WITH candidate(id, slug) AS (VALUES (?, ?))
534
+ 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)
535
+ SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_report WHERE slug = candidate.slug) THEN id ELSE slug END,
536
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
537
+ ),
538
+ // LEFT JOINs so a report whose uploader / repo / bundle was removed (the FK
539
+ // nulled) still lists, with null display fields.
540
+ selectReportsStmt: db.prepare(
541
+ `SELECT r.id AS id, r.slug AS slug, r.filename AS filename, r.content_type AS contentType,
542
+ r.byte_size AS byteSize, r.sha256 AS sha256, COALESCE(u.login, r.uploaded_by_login) AS uploadedByLogin,
543
+ r.repo_id AS repoId, sr.full_name AS repoFullName,
544
+ r.repo_directory AS repoDirectory, r.repo_embedded AS repoEmbedded,
545
+ r.analyzer AS analyzer, r.visible AS visible,
546
+ r.bundle_id AS bundleId, b.filename AS bundleFilename, r.bundle_integrity AS bundleIntegrity,
547
+ r.uploaded_at AS uploadedAt
548
+ FROM managed_report r
549
+ LEFT JOIN managed_user u ON u.id = r.uploaded_by
550
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
551
+ LEFT JOIN managed_bundle b ON b.id = r.bundle_id
552
+ WHERE (? IS NULL OR r.uploaded_by = ? OR EXISTS (
553
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
554
+ WHERE tr.repo_id = r.repo_id AND tu.user_id = ? AND ${REPORT_IN_TEAM_PATH_SQL}))
555
+ ORDER BY r.uploaded_at DESC, r.filename ASC`,
556
+ ),
557
+ selectReportStmt: db.prepare(
558
+ `SELECT id, slug, filename, content_type AS contentType, byte_size AS byteSize,
559
+ sha256, uploaded_by AS uploadedBy, uploaded_at AS uploadedAt,
560
+ repo_id AS repoId, repo_directory AS repoDirectory, repo_embedded AS repoEmbedded,
561
+ analyzer AS analyzer, visible AS visible, bundle_id AS bundleId
562
+ FROM managed_report WHERE id = ?`,
563
+ ),
564
+ selectReportAccessStmt: db.prepare(
565
+ `WITH requested AS (SELECT value AS id FROM json_each(?)), report_grants AS (
566
+ SELECT r.id AS id, MAX(tu.view_dependencies) AS dependencies, MAX(tu.view_security) AS security
567
+ FROM managed_report r JOIN requested q ON q.id = r.id
568
+ JOIN managed_team_repo tr ON tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
569
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id AND tu.user_id = ?
570
+ GROUP BY r.id
571
+ )
572
+ SELECT r.id AS id, r.filename AS filename, r.byte_size AS byteSize, r.sha256 AS sha256,
573
+ r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
574
+ COALESCE(g.dependencies, 0) AS dependencies, COALESCE(g.security, 0) AS security
575
+ FROM managed_report r JOIN requested q ON q.id = r.id
576
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
577
+ LEFT JOIN report_grants g ON g.id = r.id
578
+ WHERE ? = 1 OR (? = 1 AND r.uploaded_by = ?)
579
+ OR (g.id IS NOT NULL AND (? = 1 OR r.visible = 1))`,
580
+ ),
581
+ selectTeamAccessStmt: db.prepare(`SELECT 1 FROM managed_team_user WHERE user_id = ? AND team_id = ?`),
582
+ selectTeamRepositoriesStmt: db.prepare(
583
+ `SELECT tr.repo_id AS repoId, sr.full_name AS github, tr.path AS path
584
+ FROM managed_team_repo tr JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
585
+ WHERE tr.team_id = ? ORDER BY tr.repo_id, tr.path`,
586
+ ),
587
+ selectTeamReportAccessStmt: db.prepare(
588
+ `SELECT DISTINCT r.id AS id, r.filename AS filename, r.byte_size AS byteSize, r.sha256 AS sha256,
589
+ r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
590
+ tu.view_dependencies AS dependencies, tu.view_security AS security
591
+ FROM managed_team_user tu
592
+ JOIN managed_team_repo tr ON tr.team_id = tu.team_id
593
+ JOIN managed_report r ON r.repo_id = tr.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
594
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
595
+ WHERE tu.user_id = ? AND tu.team_id = ? AND (r.visible = 1 OR ? = 1)
596
+ ORDER BY r.id`,
597
+ ),
598
+ reportFilenamesWithBundleHashStmt: db.prepare(`SELECT DISTINCT filename FROM managed_report WHERE bundle_id = ? AND sha256 = ?`),
599
+ deleteReportStmt: db.prepare(`DELETE FROM managed_report WHERE id = ?`),
600
+ setReportRepoStmt: db.prepare(`UPDATE managed_report SET repo_id = ?, repo_directory = ? WHERE id = ?`),
601
+ setReportVisibleStmt: db.prepare(`UPDATE managed_report SET visible = ? WHERE id = ?`),
602
+ upsertTriageStmt: db.prepare(
603
+ `INSERT INTO managed_finding_triage (finding_id, color, triage, comment, fix, flagged, updated_by, updated_by_login, updated_at)
604
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
605
+ ON CONFLICT(finding_id) DO UPDATE SET
606
+ color = excluded.color, triage = excluded.triage, comment = excluded.comment,
607
+ fix = excluded.fix, flagged = excluded.flagged, updated_by = excluded.updated_by,
608
+ updated_by_login = excluded.updated_by_login, updated_at = excluded.updated_at`,
609
+ ),
610
+ selectTriageStateStmt: db.prepare(
611
+ `SELECT color, triage, comment, fix, flagged FROM managed_finding_triage WHERE finding_id = ?`,
612
+ ),
613
+ insertTriageEventStmt: db.prepare(
614
+ `INSERT INTO managed_finding_triage_event (finding_id, batch_id, color, triage, comment, fix, flagged, actor_id, actor_login, at, report_id, report, repo)
615
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
616
+ (SELECT filename FROM managed_report WHERE id = ?),
617
+ (SELECT p.full_name FROM managed_report r JOIN managed_selected_repo p ON p.repo_id = r.repo_id WHERE r.id = ?))`,
618
+ ),
619
+ // With a retention limit set: keep the newest N events of a finding.
620
+ trimTriageEventsStmt: db.prepare(
621
+ `DELETE FROM managed_finding_triage_event
622
+ WHERE finding_id = ?
623
+ AND seq NOT IN (SELECT seq FROM managed_finding_triage_event WHERE finding_id = ? ORDER BY seq DESC LIMIT ?)`,
624
+ ),
625
+ selectTriageHistoryStmt: db.prepare(
626
+ `SELECT e.seq AS seq, e.finding_id AS findingId, e.batch_id AS batchId, e.color AS color, e.triage AS triage,
627
+ e.comment AS comment, e.fix AS fix, e.flagged AS flagged,
628
+ COALESCE(u.login, e.actor_login) AS actorLogin, e.at AS at
629
+ FROM managed_finding_triage_event e
630
+ LEFT JOIN managed_user u ON u.id = e.actor_id
631
+ WHERE e.finding_id = ?
632
+ ORDER BY e.seq DESC
633
+ LIMIT ?`,
634
+ ),
635
+ // The ids arrive as one JSON array (json_each), so a report's worth of
636
+ // them is one statement, not a chunked IN list. COALESCE the live login
637
+ // over the durable snapshot, like selectReportsStmt.
638
+ selectTriageStmt: db.prepare(
639
+ `SELECT t.finding_id AS findingId, t.color AS color, t.triage AS triage,
640
+ t.comment AS comment, t.fix AS fix, t.flagged AS flagged,
641
+ COALESCE(u.login, t.updated_by_login) AS updatedByLogin, t.updated_at AS updatedAt
642
+ FROM managed_finding_triage t
643
+ LEFT JOIN managed_user u ON u.id = t.updated_by
644
+ WHERE t.finding_id IN (SELECT value FROM json_each(?))
645
+ ORDER BY t.finding_id ASC`,
646
+ ),
647
+ annotationTriageRevisionStmt: db.prepare(`
648
+ SELECT t.finding_id AS id,
649
+ (SELECT e.batch_id FROM managed_finding_triage_event e
650
+ WHERE e.finding_id = t.finding_id ORDER BY e.seq DESC LIMIT 1) AS revision
651
+ FROM managed_finding_triage t WHERE t.finding_id IN (SELECT value FROM json_each(?))
652
+ ORDER BY t.finding_id`),
653
+ annotationCommentRevisionStmt: db.prepare(`
654
+ SELECT id, version FROM managed_finding_comment
655
+ WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY id`),
656
+ insertBundleStmt: db.prepare(
657
+ `WITH candidate(id, slug) AS (VALUES (?, ?))
658
+ INSERT INTO managed_bundle (id, slug, integrity, filename, kind, byte_size, uploaded_by, uploaded_by_login, repo_id, repo_directory, uploaded_at)
659
+ SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_bundle WHERE slug = candidate.slug) THEN id ELSE slug END,
660
+ ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
661
+ ),
662
+ selectBundleByIntegrityStmt: db.prepare(
663
+ `SELECT id, slug, integrity, filename, kind, byte_size AS byteSize,
664
+ uploaded_by AS uploadedBy, repo_id AS repoId, repo_directory AS repoDirectory, uploaded_at AS uploadedAt
665
+ FROM managed_bundle WHERE integrity = ?`,
666
+ ),
667
+ selectBundleStmt: db.prepare(
668
+ `SELECT id, slug, integrity, filename, kind, byte_size AS byteSize,
669
+ uploaded_by AS uploadedBy, repo_id AS repoId, repo_directory AS repoDirectory, uploaded_at AS uploadedAt
670
+ FROM managed_bundle WHERE id = ?`,
671
+ ),
672
+ selectBundlesStmt: db.prepare(
673
+ `SELECT b.id AS id, b.slug AS slug, b.integrity AS integrity, b.filename AS filename, b.kind AS kind,
674
+ b.byte_size AS byteSize, COALESCE(u.login, b.uploaded_by_login) AS uploadedByLogin,
675
+ b.repo_id AS repoId, b.repo_directory AS repoDirectory, sr.full_name AS repoFullName, b.uploaded_at AS uploadedAt
676
+ FROM managed_bundle b
677
+ LEFT JOIN managed_user u ON u.id = b.uploaded_by
678
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = b.repo_id
679
+ WHERE (? IS NULL OR (b.uploaded_by = ? OR EXISTS (
680
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
681
+ WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?)))
682
+ ORDER BY b.uploaded_at DESC, b.filename ASC`,
683
+ ),
684
+ selectBundleReadableStmt: db.prepare(
685
+ `SELECT 1 FROM managed_bundle b WHERE b.id = ? AND (b.uploaded_by = ? OR EXISTS (
686
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
687
+ WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?))`,
688
+ ),
689
+ selectRepoReadableStmt: db.prepare(
690
+ `SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
691
+ WHERE tr.repo_id = ? AND tu.user_id = ? LIMIT 1`,
692
+ ),
693
+ selectRepoPathReadableStmt: db.prepare(
694
+ `WITH r(repo_directory) AS (VALUES (?))
695
+ SELECT 1 FROM r JOIN managed_team_repo tr ON tr.repo_id = ? AND ${REPORT_IN_TEAM_PATH_SQL}
696
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id WHERE tu.user_id = ? LIMIT 1`,
697
+ ),
698
+ deleteBundleStmt: db.prepare(`DELETE FROM managed_bundle WHERE id = ?`),
699
+ setBundleRepoStmt: db.prepare(`UPDATE managed_bundle SET repo_id = ?, repo_directory = ? WHERE id = ?`),
700
+ // Attach a freshly-stored bundle to the reports that declared its integrity
701
+ // but haven't been linked yet (bundle uploaded after the report).
702
+ linkReportsToBundleStmt: db.prepare(
703
+ `UPDATE managed_report AS r SET bundle_id = ? WHERE bundle_integrity = ? AND bundle_id IS NULL
704
+ 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
705
+ WHERE tu.user_id = ? AND tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}))`,
706
+ ),
707
+ // OR IGNORE: a duplicate name (UNIQUE) is the "taken" signal (0 changes); the
708
+ // uuid PK never collides.
709
+ insertTeamStmt: db.prepare(`WITH candidate(id, slug) AS (VALUES (?, ?))
710
+ INSERT OR IGNORE INTO managed_team (id, slug, name, created_at, updated_at)
711
+ SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_team WHERE slug = candidate.slug) THEN id ELSE slug END,
712
+ ?, ?, ? FROM candidate`),
713
+ deleteTeamStmt: db.prepare(`DELETE FROM managed_team WHERE id = ?`),
714
+ selectTeamStmt: db.prepare(`SELECT id, slug, name FROM managed_team WHERE id = ?`),
715
+ selectTeamByNameStmt: db.prepare(`SELECT id FROM managed_team WHERE name = ?`),
716
+ renameTeamStmt: db.prepare(`UPDATE managed_team SET name = ?, updated_at = ? WHERE id = ?`),
717
+ selectTeamsStmt: db.prepare(`SELECT id, slug, name FROM managed_team ORDER BY name ASC`),
718
+ selectTeamsForUserStmt: db.prepare(
719
+ `SELECT t.id AS id, t.slug AS slug, t.name AS name,
720
+ tu.view_dependencies AS dependencies, tu.view_security AS security
721
+ FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id
722
+ WHERE tu.user_id = ? ORDER BY t.name ASC, t.id ASC`,
723
+ ),
724
+ selectUserTeamScopesStmt: db.prepare(
725
+ `SELECT tr.team_id AS teamId, tr.repo_id AS repoId, tr.path AS path, sr.full_name AS fullName
726
+ FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
727
+ JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
728
+ WHERE tu.user_id = ? ORDER BY tr.team_id, tr.repo_id, tr.path`,
729
+ ),
730
+ // Reports attached to the repos of the user's teams, tagged by team (a report
731
+ // shows under every team whose repo it's attached to). Newest first.
732
+ selectUserTeamReportsStmt: db.prepare(
733
+ `SELECT DISTINCT tr.team_id AS teamId, r.id AS id, r.slug AS slug, r.filename AS filename, r.uploaded_at,
734
+ r.sha256 AS sha256, r.bundle_id AS bundleId, r.repo_id AS repoId, r.repo_directory AS repoDirectory, sr.full_name AS repoFullName,
735
+ tu.view_dependencies AS dependencies, tu.view_security AS security,
736
+ (SELECT role FROM managed_user WHERE id = tu.user_id) AS role
737
+ FROM managed_team_user tu
738
+ JOIN managed_team_repo tr ON tr.team_id = tu.team_id
739
+ JOIN managed_report r ON r.repo_id = tr.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
740
+ LEFT JOIN managed_selected_repo sr ON sr.repo_id = r.repo_id
741
+ WHERE tu.user_id = ? AND (r.visible = 1 OR (SELECT role FROM managed_user WHERE id = tu.user_id) IN ('admin', 'manage'))
742
+ ORDER BY r.uploaded_at DESC, r.filename ASC, r.id ASC`,
743
+ ),
744
+ // Bundles are scoped through the same team -> repository links as reports.
745
+ // They have no visibility flag: the bundle directory must fall inside a
746
+ // team repository scope to appear in that team's sidebar.
747
+ selectUserTeamBundlesStmt: db.prepare(
748
+ `SELECT DISTINCT tr.team_id AS teamId, b.id AS id, b.slug AS slug, b.integrity AS integrity,
749
+ 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
750
+ FROM managed_team_user tu
751
+ JOIN managed_team_repo tr ON tr.team_id = tu.team_id
752
+ JOIN managed_bundle b ON b.repo_id = tr.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL}
753
+ JOIN managed_selected_repo sr ON sr.repo_id = b.repo_id
754
+ WHERE tu.user_id = ?
755
+ ORDER BY b.uploaded_at DESC, b.filename ASC, b.id ASC`,
756
+ ),
757
+ selectUserRepoScopesStmt: db.prepare(
758
+ `SELECT DISTINCT tr.repo_id AS repoId, NULLIF(tr.path, '') AS path
759
+ FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id WHERE tu.user_id = ?`,
760
+ ),
761
+ // A report is readable iff one of the user's team scopes contains it.
762
+ selectReportReadableStmt: db.prepare(
763
+ `SELECT 1 FROM managed_report r
764
+ JOIN managed_team_repo tr ON tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
765
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id
766
+ WHERE r.id = ? AND tu.user_id = ? LIMIT 1`,
767
+ ),
768
+ selectBundleSecurityStmt: db.prepare(
769
+ `SELECT 1 FROM managed_bundle b
770
+ JOIN managed_team_repo tr ON tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL}
771
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id
772
+ WHERE b.id = ? AND tu.user_id = ? AND tu.view_security = 1
773
+ AND (? IS NULL OR tr.team_id = ?) LIMIT 1`,
774
+ ),
775
+ // The viewer's effective visibility permissions for a report: OR'd (MAX over
776
+ // 0/1) across memberships whose repository path contains the report. NULLs
777
+ // (no such membership) read as 0 = no permission.
778
+ selectReportPermsStmt: db.prepare(
779
+ `SELECT MAX(tu.view_dependencies) AS dependencies, MAX(tu.view_security) AS security
780
+ FROM managed_report r
781
+ JOIN managed_team_repo tr ON tr.repo_id = r.repo_id AND ${REPORT_IN_TEAM_PATH_SQL}
782
+ JOIN managed_team_user tu ON tu.team_id = tr.team_id AND tu.user_id = ?
783
+ WHERE r.id = ?`,
784
+ ),
785
+ selectUserOptionsStmt: db.prepare(`SELECT id, login, name, role FROM managed_user ORDER BY login ASC`),
786
+ selectTeamReposStmt: db.prepare(
787
+ `SELECT tr.team_id AS teamId, tr.repo_id AS repoId, sr.full_name AS fullName, NULLIF(tr.path, '') AS path
788
+ FROM managed_team_repo tr JOIN managed_selected_repo sr ON sr.repo_id = tr.repo_id
789
+ ORDER BY sr.full_name ASC, tr.path ASC`,
790
+ ),
791
+ selectTeamMembersStmt: db.prepare(
792
+ `SELECT tu.team_id AS teamId, tu.user_id AS userId, u.login AS login,
793
+ tu.view_dependencies AS viewDependencies, tu.view_security AS viewSecurity
794
+ FROM managed_team_user tu JOIN managed_user u ON u.id = tu.user_id
795
+ ORDER BY u.login ASC`,
796
+ ),
797
+ upsertTeamRepoStmt: db.prepare(
798
+ `INSERT OR IGNORE INTO managed_team_repo (team_id, repo_id, path)
799
+ SELECT ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM managed_team_repo WHERE team_id = ? AND repo_id = ? AND path = '')`,
800
+ ),
801
+ deleteTeamRepoStmt: db.prepare(`DELETE FROM managed_team_repo WHERE team_id = ? AND repo_id = ?`),
802
+ deleteTeamRepoPathStmt: db.prepare(`DELETE FROM managed_team_repo WHERE team_id = ? AND repo_id = ? AND path = ?`),
803
+ upsertTeamMemberStmt: db.prepare(
804
+ `INSERT INTO managed_team_user (team_id, user_id, view_dependencies, view_security) VALUES (?, ?, ?, ?)
805
+ ON CONFLICT(team_id, user_id) DO UPDATE SET
806
+ view_dependencies = excluded.view_dependencies, view_security = excluded.view_security`,
807
+ ),
808
+ deleteTeamMemberStmt: db.prepare(`DELETE FROM managed_team_user WHERE team_id = ? AND user_id = ?`),
809
+ }
810
+ }
811
+
812
+ type RepoRow = {
813
+ repoId: number; fullName: string; priv: number; installId: number | null
814
+ branch: string; htmlUrl: string; addedBy: string | null; addedAt: number; active: number
815
+ }
816
+
817
+ // The repo-selection slice of ManagedDb, split out to keep openSqliteManagedDb
818
+ // within the per-function line budget. Closes over its prepared statements.
819
+ function selectedRepoMethods(stmts: ReturnType<typeof prepareStatements>) {
820
+ const { upsertRepoStmt, deleteRepoStmt, deactivateRepoStmt, reactivateRepoStmt, selectReposStmt, selectAllReposStmt,
821
+ selectReportsForRepoStmt, selectBundlesForRepoStmt, deleteReportsForRepoStmt, deleteBundlesForRepoStmt } = stmts
822
+ const readRepo = (r: RepoRow): SelectedRepo => ({
823
+ repoId: r.repoId, fullName: r.fullName, private: r.priv === 1,
824
+ installationId: r.installId, defaultBranch: r.branch, htmlUrl: r.htmlUrl,
825
+ addedBy: r.addedBy, addedAt: r.addedAt,
826
+ })
827
+ const readManagedRepo = (r: RepoRow): ManagedRepo => ({ ...readRepo(r), active: r.active === 1 })
828
+ return {
829
+ async selectRepo(repo: SelectedRepoInput, now: number): Promise<void> {
830
+ await upsertRepoStmt.run(
831
+ repo.repoId, repo.fullName, repo.private ? 1 : 0, repo.installationId,
832
+ repo.defaultBranch, repo.htmlUrl, repo.addedBy, now, now,
833
+ )
834
+ },
835
+ async deselectRepo(repoId: number): Promise<boolean> {
836
+ return Number((await deleteRepoStmt.run(repoId)).changes) > 0
837
+ },
838
+ async listSelectedRepos(): Promise<SelectedRepo[]> {
839
+ const rows = (await selectReposStmt.all()) as RepoRow[]
840
+ return rows.map(readRepo)
841
+ },
842
+ async listAllRepos(): Promise<ManagedRepo[]> {
843
+ const rows = (await selectAllReposStmt.all()) as RepoRow[]
844
+ return rows.map(readManagedRepo)
845
+ },
846
+ async deactivateRepo(repoId: number): Promise<boolean> {
847
+ return Number((await deactivateRepoStmt.run(repoId)).changes) > 0
848
+ },
849
+ async reactivateRepo(repoId: number): Promise<boolean> {
850
+ return Number((await reactivateRepoStmt.run(repoId)).changes) > 0
851
+ },
852
+ async deleteRepo(repoId: number): Promise<boolean> {
853
+ return Number((await deleteRepoStmt.run(repoId)).changes) > 0
854
+ },
855
+ async listReportsForRepo(repoId: number): Promise<RepoReportItem[]> {
856
+ return (await selectReportsForRepoStmt.all(repoId)) as unknown as RepoReportItem[]
857
+ },
858
+ async listBundlesForRepo(repoId: number): Promise<RepoDataItem[]> {
859
+ return (await selectBundlesForRepoStmt.all(repoId)) as unknown as RepoDataItem[]
860
+ },
861
+ async deleteReportsForRepo(repoId: number): Promise<number> {
862
+ return Number((await deleteReportsForRepoStmt.run(repoId)).changes)
863
+ },
864
+ async deleteBundlesForRepo(repoId: number): Promise<number> {
865
+ return Number((await deleteBundlesForRepoStmt.run(repoId)).changes)
866
+ },
867
+ }
868
+ }
869
+
870
+ type ReportListRow = {
871
+ id: string; slug: string; filename: string; contentType: string; byteSize: number
872
+ sha256: string; uploadedByLogin: string | null
873
+ repoId: number | null; repoFullName: string | null
874
+ repoDirectory: string; repoEmbedded: number; analyzer: string | null; visible: number
875
+ bundleId: string | null; bundleFilename: string | null; bundleIntegrity: string | null
876
+ uploadedAt: number
877
+ }
878
+ type ReportRow = {
879
+ id: string; slug: string; filename: string; contentType: string; byteSize: number
880
+ sha256: string; uploadedBy: string | null; uploadedAt: number
881
+ repoId: number | null; repoDirectory: string; repoEmbedded: number; analyzer: string | null; visible: number
882
+ bundleId: string | null
883
+ }
884
+
885
+ // The report slice of ManagedDb, split out (like selectedRepoMethods) to keep
886
+ // openSqliteManagedDb small. Closes over its prepared statements.
887
+ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
888
+ const { insertReportStmt, selectReportsStmt, selectReportStmt, deleteReportStmt, setReportRepoStmt, setReportVisibleStmt } = stmts
889
+ return {
890
+ async insertReport(report: ReportRecordInput, now: number): Promise<void> {
891
+ await insertReportStmt.run(
892
+ report.id, preferredSlug(report.id), report.filename, report.contentType, report.byteSize,
893
+ report.sha256, report.uploadedBy, report.uploadedByLogin ?? null, report.repoId,
894
+ report.repoDirectory ?? '', report.repoEmbedded ? 1 : 0, report.analyzer ?? null, report.visible == null ? 0 : report.visible ? 1 : 0, report.bundleId ?? null,
895
+ report.bundleIntegrity ?? null, now,
896
+ )
897
+ },
898
+ async listReports(userId?: string): Promise<AdminReport[]> {
899
+ const rows = (await selectReportsStmt.all(userId ?? null, userId ?? null, userId ?? null)) as ReportListRow[]
900
+ return rows.map((r) => ({
901
+ id: r.id, slug: r.slug, filename: r.filename, contentType: r.contentType, byteSize: r.byteSize,
902
+ sha256: r.sha256, uploadedByLogin: r.uploadedByLogin,
903
+ repoId: r.repoId, repoFullName: r.repoFullName,
904
+ repoDirectory: r.repoDirectory, repoEmbedded: r.repoEmbedded === 1, analyzer: r.analyzer, visible: r.visible === 1,
905
+ bundleId: r.bundleId, bundleFilename: r.bundleFilename, bundleIntegrity: r.bundleIntegrity,
906
+ uploadedAt: r.uploadedAt,
907
+ }))
908
+ },
909
+ async getReport(id: string): Promise<ReportRecord | null> {
910
+ const row = (await selectReportStmt.get(id)) as ReportRow | undefined
911
+ if (row == null) return null
912
+ return {
913
+ id: row.id, slug: row.slug, filename: row.filename, contentType: row.contentType, byteSize: row.byteSize,
914
+ sha256: row.sha256, uploadedBy: row.uploadedBy, uploadedAt: row.uploadedAt,
915
+ repoId: row.repoId, repoDirectory: row.repoDirectory, repoEmbedded: row.repoEmbedded === 1, analyzer: row.analyzer, visible: row.visible === 1,
916
+ bundleId: row.bundleId,
917
+ }
918
+ },
919
+ async getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null> {
920
+ const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
921
+ if (!session) return null
922
+ const user: StoredUser = { id: session.uid, login: session.login, name: session.name, avatarUrl: session.avatar, role: session.role }
923
+ if (user.role === 'none' || !await stmts.selectTeamAccessStmt.get(user.id, teamId)) return { user, teamId: null, reports: [], repositories: [] }
924
+ const whole = user.role === 'admin' || user.role === 'manage'
925
+ const rows = await stmts.selectTeamReportAccessStmt.all(user.id, teamId, whole ? 1 : 0) as {
926
+ id: string; filename: string; byteSize: number; sha256: string; repoDirectory: string
927
+ repoFullName: string | null; dependencies: number; security: number
928
+ }[]
929
+ const repositories = await stmts.selectTeamRepositoriesStmt.all(teamId) as TeamReportAccessSnapshot['repositories']
930
+ return { user, teamId, repositories, reports: rows.map(row => ({
931
+ id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
932
+ repo: { github: row.repoFullName, directory: row.repoDirectory },
933
+ permissions: { dependencies: whole || row.dependencies === 1, security: whole || row.security === 1 },
934
+ })) }
935
+ },
936
+ async getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null> {
937
+ const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
938
+ if (!session) return null
939
+ const user: StoredUser = { id: session.uid, login: session.login, name: session.name, avatarUrl: session.avatar, role: session.role }
940
+ if (user.role === 'none' || ids.length === 0) return { user, reports: [] }
941
+ const admin = user.role === 'admin', manage = user.role === 'manage'
942
+ const rows = await stmts.selectReportAccessStmt.all(JSON.stringify([...new Set(ids)]), user.id,
943
+ admin ? 1 : 0, manage ? 1 : 0, user.id, manage ? 1 : 0) as {
944
+ id: string; filename: string; byteSize: number; sha256: string; repoDirectory: string
945
+ repoFullName: string | null; dependencies: number; security: number
946
+ }[]
947
+ return { user, reports: rows.map(row => ({
948
+ id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
949
+ repo: { github: row.repoFullName, directory: row.repoDirectory },
950
+ permissions: { dependencies: admin || manage || row.dependencies === 1, security: admin || manage || row.security === 1 },
951
+ })) }
952
+ },
953
+ async listReportFilenamesWithBundleHash(bundleId: string, sha256: string): Promise<string[]> {
954
+ const rows = (await stmts.reportFilenamesWithBundleHashStmt.all(bundleId, sha256)) as { filename: string }[]
955
+ return rows.map(row => row.filename)
956
+ },
957
+ async deleteReport(id: string): Promise<boolean> {
958
+ return Number((await deleteReportStmt.run(id)).changes) > 0
959
+ },
960
+ async setReportRepo(id: string, repoId: number | null, repoDirectory = ''): Promise<boolean> {
961
+ return Number((await setReportRepoStmt.run(repoId, repoId == null ? '' : repoDirectory, id)).changes) > 0
962
+ },
963
+ async setReportVisible(id: string, visible: boolean): Promise<boolean> {
964
+ return Number((await setReportVisibleStmt.run(visible ? 1 : 0, id)).changes) > 0
965
+ },
966
+ }
967
+ }
968
+
969
+ type TriageDbRow = {
970
+ findingId: string; color: string | null; triage: string | null
971
+ comment: string | null; fix: string | null; flagged: number | null
972
+ updatedByLogin: string | null; updatedAt: number
973
+ }
974
+ type TriageStateDbRow = { color: string | null; triage: string | null; comment: string | null; fix: string | null; flagged: number | null }
975
+ type TriageEventDbRow = TriageStateDbRow & { seq: number; findingId: string; batchId: string; actorLogin: string | null; at: number }
976
+
977
+ // The per-finding triage slice of ManagedDb. Closes over its prepared
978
+ // statements (and the handle, for the batch write's transaction). A
979
+ // null/empty entry writes the tombstone — every field NULL, writer and time
980
+ // stamped — so a later reader learns the entry was cleared rather than never
981
+ // set. Every change is also appended to the trail; a write equal to the
982
+ // current row is skipped altogether, so a client re-pushing what already
983
+ // stands neither re-stamps the writer nor echoes into the trail. `historyLimit`
984
+ // > 0 keeps only that many events per finding; 0 keeps everything.
985
+ function triageMethods( stmts: ReturnType<typeof prepareStatements>, historyLimit: number) {
986
+ const { upsertTriageStmt, selectTriageStmt, selectTriageStateStmt, insertTriageEventStmt, trimTriageEventsStmt, selectTriageHistoryStmt,
987
+ deleteTriageStmt, deleteTriageHistoryStmt, deleteCommentsStmt, deleteCommentHistoryStmt, countAnnotationsStmt } = stmts
988
+ async function writeEntry(findingId: string, entry: TriageEntryPatch | null, batchId: string, updatedBy: string | null, updatedByLogin: string | null, now: number, reportId: string | null = null): Promise<void> {
989
+ const e = entry ?? {}
990
+ // `flagged: false` is a real value (the explicit un-flag tombstone), so it
991
+ // is 0 here and only null/absent maps to NULL.
992
+ const next: TriageStateDbRow = {
993
+ color: e.color ?? null, triage: e.triage ?? null, comment: e.comment ?? null,
994
+ fix: e.fix ?? null, flagged: e.flagged == null ? null : (e.flagged ? 1 : 0),
995
+ }
996
+ const cur = (await selectTriageStateStmt.get(findingId)) as TriageStateDbRow | undefined
997
+ if (cur != null && cur.color === next.color && cur.triage === next.triage && cur.comment === next.comment
998
+ && cur.fix === next.fix && cur.flagged === next.flagged) return
999
+ await upsertTriageStmt.run(findingId, next.color, next.triage, next.comment, next.fix, next.flagged, updatedBy, updatedByLogin, now)
1000
+ await insertTriageEventStmt.run(findingId, batchId, next.color, next.triage, next.comment, next.fix, next.flagged, updatedBy, updatedByLogin, now, reportId, reportId, reportId)
1001
+ if (historyLimit > 0) await trimTriageEventsStmt.run(findingId, findingId, historyLimit)
1002
+ }
1003
+ return {
1004
+ async getAnnotationRevision(findingIds: readonly string[]): Promise<string> {
1005
+ if (findingIds.length === 0) return '[]'
1006
+ const ids = JSON.stringify(findingIds)
1007
+ // Batch UUIDs survive history trimming and distinguish same-timestamp
1008
+ // writes, even if SQLite reuses a sequence after an annotation purge.
1009
+ return JSON.stringify([
1010
+ await stmts.annotationTriageRevisionStmt.all(ids),
1011
+ await stmts.annotationCommentRevisionStmt.all(ids),
1012
+ ])
1013
+ },
1014
+ async deleteTriage(findingIds: readonly string[]): Promise<number> {
1015
+ if (findingIds.length === 0) return 0
1016
+ const ids = JSON.stringify(findingIds)
1017
+ const { total: deleted } = (await countAnnotationsStmt.get(ids, ids, ids)) as { total: number }
1018
+ await deleteTriageStmt.run(ids)
1019
+ await deleteTriageHistoryStmt.run(ids)
1020
+ await deleteCommentsStmt.run(ids)
1021
+ await deleteCommentHistoryStmt.run(ids)
1022
+ return deleted
1023
+ },
1024
+ async listTriageHistory(findingId: string, limit: number): Promise<TriageEventRow[]> {
1025
+ const rows = (await selectTriageHistoryStmt.all(findingId, limit)) as TriageEventDbRow[]
1026
+ return rows.map((r) => ({
1027
+ seq: r.seq, findingId: r.findingId, batchId: r.batchId,
1028
+ color: r.color, triage: r.triage, comment: r.comment, fix: r.fix,
1029
+ flagged: r.flagged == null ? null : r.flagged === 1,
1030
+ actorLogin: r.actorLogin, at: r.at,
1031
+ }))
1032
+ },
1033
+ async listTriage(findingIds: readonly string[]): Promise<TriageRow[]> {
1034
+ if (findingIds.length === 0) return []
1035
+ const rows = (await selectTriageStmt.all(JSON.stringify(findingIds))) as TriageDbRow[]
1036
+ return rows.map((r) => ({
1037
+ findingId: r.findingId, color: r.color, triage: r.triage, comment: r.comment, fix: r.fix,
1038
+ flagged: r.flagged == null ? null : r.flagged === 1,
1039
+ updatedByLogin: r.updatedByLogin, updatedAt: r.updatedAt,
1040
+ }))
1041
+ },
1042
+ async setTriage(findingId: string, entry: TriageEntryPatch | null, updatedBy: string | null, updatedByLogin: string | null, now: number): Promise<void> {
1043
+ // A single write is its own batch. The row and its event go together.
1044
+ await writeEntry(findingId, entry, randomUUID(), updatedBy, updatedByLogin, now)
1045
+ },
1046
+ async setTriageEntries(entries: readonly (readonly [string, TriageEntryPatch | null])[], updatedBy: string | null, updatedByLogin: string | null, now: number, reportId?: string): Promise<void> {
1047
+ // One transaction, so a batch is never half-applied by a mid-loop error
1048
+ // (and costs one fsync under synchronous = FULL, not one per row); one
1049
+ // batch id groups its rows in the trail.
1050
+ const batchId = randomUUID()
1051
+ for (const [findingId, entry] of entries) await writeEntry(findingId, entry, batchId, updatedBy, updatedByLogin, now, reportId)
1052
+ },
1053
+ }
1054
+ }
1055
+
1056
+ type BundleRow = {
1057
+ id: string; slug: string; integrity: string; filename: string; kind: string | null; byteSize: number
1058
+ uploadedBy: string | null; repoId: number | null; repoDirectory: string; uploadedAt: number
1059
+ }
1060
+ type BundleListRow = {
1061
+ id: string; slug: string; integrity: string; filename: string; kind: string | null; byteSize: number
1062
+ uploadedByLogin: string | null; repoId: number | null; repoFullName: string | null; repoDirectory: string; uploadedAt: number
1063
+ }
1064
+
1065
+ function mapBundle(r: BundleRow): ManagedBundle {
1066
+ return {
1067
+ id: r.id, slug: r.slug, integrity: r.integrity, filename: r.filename, kind: r.kind,
1068
+ byteSize: r.byteSize, uploadedBy: r.uploadedBy, repoId: r.repoId, repoDirectory: r.repoDirectory, uploadedAt: r.uploadedAt,
1069
+ }
1070
+ }
1071
+
1072
+ // The bundle slice of ManagedDb. Closes over its prepared statements.
1073
+ function bundleMethods(stmts: ReturnType<typeof prepareStatements>) {
1074
+ const {
1075
+ insertBundleStmt, selectBundleByIntegrityStmt, selectBundleStmt,
1076
+ selectBundlesStmt, deleteBundleStmt, setBundleRepoStmt, linkReportsToBundleStmt, selectBundleReadableStmt, selectRepoReadableStmt, selectRepoPathReadableStmt,
1077
+ } = stmts
1078
+ return {
1079
+ async insertBundle(bundle: BundleInput, now: number): Promise<void> {
1080
+ await insertBundleStmt.run(
1081
+ bundle.id, preferredSlug(bundle.id), bundle.integrity, bundle.filename, bundle.kind,
1082
+ bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', now,
1083
+ )
1084
+ },
1085
+ async getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null> {
1086
+ const row = (await selectBundleByIntegrityStmt.get(integrity)) as BundleRow | undefined
1087
+ return row == null ? null : mapBundle(row)
1088
+ },
1089
+ async getBundle(id: string): Promise<ManagedBundle | null> {
1090
+ const row = (await selectBundleStmt.get(id)) as BundleRow | undefined
1091
+ return row == null ? null : mapBundle(row)
1092
+ },
1093
+ async listBundles(userId?: string): Promise<AdminBundle[]> {
1094
+ const rows = (await selectBundlesStmt.all(userId ?? null, userId ?? null, userId ?? null)) as BundleListRow[]
1095
+ return rows.map((r) => ({
1096
+ id: r.id, slug: r.slug, integrity: r.integrity, filename: r.filename, kind: r.kind, byteSize: r.byteSize,
1097
+ uploadedByLogin: r.uploadedByLogin, repoId: r.repoId, repoFullName: r.repoFullName, repoDirectory: r.repoDirectory,
1098
+ uploadedAt: r.uploadedAt,
1099
+ }))
1100
+ },
1101
+ async userCanReadBundle(userId: string, id: string): Promise<boolean> {
1102
+ return (await selectBundleReadableStmt.get(id, userId, userId)) != null
1103
+ },
1104
+ async userCanReadRepo(userId: string, repoId: number): Promise<boolean> {
1105
+ return (await selectRepoReadableStmt.get(repoId, userId)) != null
1106
+ },
1107
+ async userCanReadRepoPath(userId: string, repoId: number, directory: string): Promise<boolean> {
1108
+ return (await selectRepoPathReadableStmt.get(directory, repoId, userId)) != null
1109
+ },
1110
+ async deleteBundle(id: string): Promise<boolean> {
1111
+ return Number((await deleteBundleStmt.run(id)).changes) > 0
1112
+ },
1113
+ async setBundleRepo(id: string, repoId: number | null, directory?: string): Promise<boolean> {
1114
+ return Number((await setBundleRepoStmt.run(repoId, repoId == null ? '' : directory ?? '', id)).changes) > 0
1115
+ },
1116
+ async linkReportsToBundle(integrity: string, bundleId: string, userId?: string): Promise<void> {
1117
+ await linkReportsToBundleStmt.run(bundleId, integrity, userId ?? null, userId ?? null, userId ?? null)
1118
+ },
1119
+ }
1120
+ }
1121
+
1122
+ type TeamRow = { id: string; slug: string; name: string }
1123
+ type TeamRepoRow = { teamId: string; repoId: number; fullName: string; path: string | null }
1124
+ type TeamMemberRow = { teamId: string; userId: string; login: string; viewDependencies: number; viewSecurity: number }
1125
+
1126
+ // The team slice of ManagedDb. listTeams reads the three tables in full and
1127
+ // groups in JS (3 queries, not N+1) — fine for the handful of teams a managed
1128
+ // workspace has.
1129
+ function teamMethods( stmts: ReturnType<typeof prepareStatements>) {
1130
+ const {
1131
+ insertTeamStmt, selectTeamByNameStmt, renameTeamStmt, deleteTeamStmt, selectTeamStmt,
1132
+ selectUserRepoScopesStmt, selectTeamsStmt, selectTeamsForUserStmt, selectUserTeamReportsStmt, selectUserTeamBundlesStmt, selectReportReadableStmt,
1133
+ selectReportPermsStmt, selectUserOptionsStmt, selectTeamReposStmt, selectTeamMembersStmt,
1134
+ upsertTeamRepoStmt, deleteTeamRepoStmt, deleteTeamRepoPathStmt, upsertTeamMemberStmt, deleteTeamMemberStmt,
1135
+ } = stmts
1136
+ const methods = {
1137
+ async createTeam(id: string, name: string, now: number): Promise<boolean> {
1138
+ return Number((await insertTeamStmt.run(id, preferredSlug(id), name, now, now)).changes) > 0
1139
+ },
1140
+ async renameTeam(id: string, name: string, now: number): Promise<'ok' | 'name-taken' | 'not-found'> {
1141
+ // The operation transaction serializes the existence/name checks and rename.
1142
+ if ((await selectTeamStmt.get(id)) == null) return 'not-found'
1143
+ const clash = (await selectTeamByNameStmt.get(name)) as { id: string } | undefined
1144
+ if (clash != null && clash.id !== id) return 'name-taken'
1145
+ await renameTeamStmt.run(name, now, id)
1146
+ return 'ok'
1147
+ },
1148
+ async deleteTeam(id: string): Promise<boolean> {
1149
+ return Number((await deleteTeamStmt.run(id)).changes) > 0
1150
+ },
1151
+ async getTeam(id: string): Promise<{ id: string; slug: string; name: string } | null> {
1152
+ const row = (await selectTeamStmt.get(id)) as TeamRow | undefined
1153
+ return row == null ? null : { id: row.id, slug: row.slug, name: row.name }
1154
+ },
1155
+ async listUserOptions(): Promise<UserOption[]> {
1156
+ return ((await selectUserOptionsStmt.all()) as UserOption[]).map((u) => ({ id: u.id, login: u.login, name: u.name, role: u.role }))
1157
+ },
1158
+ async listRepoScopesForUser(userId: string): Promise<{ repoId: number; path: string | null }[]> {
1159
+ return (await selectUserRepoScopesStmt.all(userId)) as { repoId: number; path: string | null }[]
1160
+ },
1161
+ async listTeamsForUser(userId: string): Promise<UserTeam[]> {
1162
+ const teams = (await selectTeamsForUserStmt.all(userId)) as { id: string; slug: string; name: string; dependencies: number; security: number }[]
1163
+ const scopes = await stmts.selectUserTeamScopesStmt.all(userId) as { teamId: string; repoId: number; path: string; fullName: string }[]
1164
+ const reportsByTeam = new Map<string, UserTeamReport[]>()
1165
+ for (const r of (await selectUserTeamReportsStmt.all(userId)) as {
1166
+ teamId: string; id: string; slug: string; filename: string; sha256: string; bundleId: string | null; repoId: number
1167
+ repoDirectory: string; repoFullName: string | null; dependencies: number; security: number; role: string
1168
+ }[]) {
1169
+ const list = reportsByTeam.get(r.teamId) ?? []
1170
+ const cacheKey = createHash('sha256').update(JSON.stringify([
1171
+ r.sha256, r.bundleId, r.filename, r.repoId, r.repoDirectory, r.repoFullName, r.dependencies, r.security, r.role,
1172
+ ])).digest('base64url')
1173
+ list.push({ id: r.id, slug: r.slug, filename: r.filename, repoFullName: r.repoFullName, repoDirectory: r.repoDirectory, cacheKey })
1174
+ reportsByTeam.set(r.teamId, list)
1175
+ }
1176
+ const bundlesByTeam = new Map<string, UserTeamBundle[]>()
1177
+ for (const b of (await selectUserTeamBundlesStmt.all(userId)) as (UserTeamBundle & { teamId: string })[]) {
1178
+ const list = bundlesByTeam.get(b.teamId) ?? []
1179
+ list.push({ id: b.id, slug: b.slug, integrity: b.integrity, filename: b.filename,
1180
+ kind: b.kind, byteSize: b.byteSize, repoId: b.repoId, repoDirectory: b.repoDirectory, repoFullName: b.repoFullName })
1181
+ bundlesByTeam.set(b.teamId, list)
1182
+ }
1183
+ return teams.map((t) => ({
1184
+ id: t.id, slug: t.slug, name: t.name,
1185
+ cacheKey: createHash('sha256').update(JSON.stringify([
1186
+ t.dependencies, t.security, scopes.filter(scope => scope.teamId === t.id),
1187
+ ])).digest('base64url'),
1188
+ reports: reportsByTeam.get(t.id) ?? [],
1189
+ bundles: bundlesByTeam.get(t.id) ?? [],
1190
+ }))
1191
+ },
1192
+ async getUserTeamFeedSnapshot(sessionId: string, now: number) {
1193
+ // One short read-only transaction, shared by SQLite and Postgres. Never
1194
+ // touch last-seen timestamps or include other users' memberships.
1195
+ const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1196
+ if (!session) return null
1197
+ const teams = session.role === 'none' ? [] : await methods.listTeamsForUser(session.uid)
1198
+ return { user: { id: session.uid, role: session.role },
1199
+ revision: teamCatalogRevision(teams),
1200
+ }
1201
+ },
1202
+ async userCanReadReport(userId: string, reportId: string): Promise<boolean> {
1203
+ return (await selectReportReadableStmt.get(reportId, userId)) != null
1204
+ },
1205
+ async userCanReadBundleAdvisories(userId: string, bundleId: string, teamId: string | null): Promise<boolean> {
1206
+ return (await stmts.selectBundleSecurityStmt.get(bundleId, userId, teamId, teamId)) != null
1207
+ },
1208
+ async reportPermissionsFor(userId: string, reportId: string): Promise<TeamUserPermissions> {
1209
+ const row = (await selectReportPermsStmt.get(userId, reportId)) as { dependencies: number | null; security: number | null } | undefined
1210
+ return { dependencies: row?.dependencies === 1, security: row?.security === 1 }
1211
+ },
1212
+ async listTeams(): Promise<AdminTeam[]> {
1213
+ const teams = (await selectTeamsStmt.all()) as TeamRow[]
1214
+ const reposByTeam = new Map<string, TeamRepoLink[]>()
1215
+ for (const r of (await selectTeamReposStmt.all()) as TeamRepoRow[]) {
1216
+ const list = reposByTeam.get(r.teamId) ?? []
1217
+ list.push({ repoId: r.repoId, fullName: r.fullName, path: r.path })
1218
+ reposByTeam.set(r.teamId, list)
1219
+ }
1220
+ const membersByTeam = new Map<string, TeamMember[]>()
1221
+ for (const m of (await selectTeamMembersStmt.all()) as TeamMemberRow[]) {
1222
+ const list = membersByTeam.get(m.teamId) ?? []
1223
+ list.push({ userId: m.userId, login: m.login, dependencies: m.viewDependencies === 1, security: m.viewSecurity === 1 })
1224
+ membersByTeam.set(m.teamId, list)
1225
+ }
1226
+ return teams.map((t) => ({
1227
+ id: t.id, slug: t.slug, name: t.name,
1228
+ repos: reposByTeam.get(t.id) ?? [],
1229
+ members: membersByTeam.get(t.id) ?? [],
1230
+ }))
1231
+ },
1232
+ async setTeamRepo(teamId: string, repoId: number, path: string | null): Promise<void> {
1233
+ // A whole-repository grant supersedes all its path grants atomically.
1234
+ // Adding a redundant path to an existing whole-repo grant is a no-op.
1235
+ if (path == null || path === '') await deleteTeamRepoStmt.run(teamId, repoId)
1236
+ await upsertTeamRepoStmt.run(teamId, repoId, path ?? '', teamId, repoId)
1237
+ },
1238
+ async removeTeamRepo(teamId: string, repoId: number, path?: string | null): Promise<boolean> {
1239
+ const result = path === undefined ? (await deleteTeamRepoStmt.run(teamId, repoId)) : (await deleteTeamRepoPathStmt.run(teamId, repoId, path ?? ''))
1240
+ return Number(result.changes) > 0
1241
+ },
1242
+ async setTeamMember(teamId: string, userId: string, perms: TeamUserPermissions): Promise<void> {
1243
+ await upsertTeamMemberStmt.run(teamId, userId, perms.dependencies ? 1 : 0, perms.security ? 1 : 0)
1244
+ },
1245
+ async removeTeamMember(teamId: string, userId: string): Promise<boolean> {
1246
+ return Number((await deleteTeamMemberStmt.run(teamId, userId)).changes) > 0
1247
+ },
1248
+ }
1249
+ return methods
1250
+ }
1251
+
1252
+ // `triageHistoryLimit`: events kept per finding in the triage trail; 0 (the
1253
+ // default) keeps everything. See ManagedConfig.
1254
+ export interface ManagedDbOptions {
1255
+ triageHistoryLimit?: number
1256
+ }
1257
+
1258
+ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions = {}): ManagedDb {
1259
+ const comments = commentMethods(db)
1260
+ const activity = activityMethods(db)
1261
+ const stmts = prepareStatements(db)
1262
+ const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0)
1263
+ const {
1264
+ upsertUserStmt, selectUserIdStmt, selectGithubIdStmt, insertSessionStmt, selectSessionStmt, selectUsersStmt,
1265
+ touchUserSeenStmt, updateRoleStmt, updateTokensStmt, selectTokensStmt, deleteSessionStmt, deleteExpiredStmt,
1266
+ } = stmts
1267
+
1268
+ return {
1269
+ async upsertUser(user, now, initialAdminGithubId = null) {
1270
+ // New row → a fresh id; ON CONFLICT(github_user_id) keeps an existing
1271
+ // user's id (DO UPDATE leaves it untouched), so re-read to return it.
1272
+ const initialRole = initialAdminGithubId != null && Number.isSafeInteger(initialAdminGithubId) && initialAdminGithubId > 0
1273
+ && user.githubUserId === initialAdminGithubId ? 'admin' : 'none'
1274
+ await upsertUserStmt.run(randomUUID(), user.githubUserId, user.login, user.name, user.avatarUrl, initialRole, now, now)
1275
+ const row = (await selectUserIdStmt.get(user.githubUserId)) as { id: string }
1276
+ return row.id
1277
+ },
1278
+ async createSession(session, now) {
1279
+ await insertSessionStmt.run(session.id, session.userId, session.csrfToken, now, session.expiresAt)
1280
+ await touchUserSeenStmt.run(now, session.userId, now)
1281
+ },
1282
+ async sessionWithUser(id, now) {
1283
+ const row = (await selectSessionStmt.get(id, now)) as SessionRow | undefined
1284
+ if (row == null) return null
1285
+ await touchUserSeenStmt.run(now, row.uid, now)
1286
+ return {
1287
+ session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp },
1288
+ user: { id: row.uid, login: row.login, name: row.name, avatarUrl: row.avatar, role: row.role },
1289
+ }
1290
+ },
1291
+ async deleteSession(id) {
1292
+ await deleteSessionStmt.run(id)
1293
+ },
1294
+ async deleteExpiredSessions(now) {
1295
+ return Number((await deleteExpiredStmt.run(now)).changes)
1296
+ },
1297
+ async listUsers() {
1298
+ const rows = (await selectUsersStmt.all()) as UserRow[]
1299
+ 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 }))
1300
+ },
1301
+ async setUserRole(id, role) {
1302
+ return Number((await updateRoleStmt.run(role, Date.now(), id)).changes) > 0
1303
+ },
1304
+ async setUserTokens(id, tokens) {
1305
+ await updateTokensStmt.run(tokens.accessToken, tokens.refreshToken, tokens.expiresAt, Date.now(), id)
1306
+ },
1307
+ async getUserTokens(id) {
1308
+ const row = (await selectTokensStmt.get(id)) as { access: string | null; refresh: string | null; exp: number | null } | undefined
1309
+ if (row == null || row.access == null) return null
1310
+ return { accessToken: row.access, refreshToken: row.refresh, expiresAt: row.exp }
1311
+ },
1312
+ async getUserGithubId(id) {
1313
+ const row = (await selectGithubIdStmt.get(id)) as { githubId: number } | undefined
1314
+ return row?.githubId ?? null
1315
+ },
1316
+ ...githubMetadataMethods(db),
1317
+ ...selectedRepoMethods(stmts),
1318
+ ...activity,
1319
+ ...comments,
1320
+ ...workspaceShareMethods(db),
1321
+ ...reportMethods(stmts),
1322
+ ...triage,
1323
+ ...importTriageMethods({ ...triage, ...comments }),
1324
+ ...bundleMethods(stmts),
1325
+ ...teamMethods(stmts),
1326
+ async close() {
1327
+ await db.close()
1328
+ },
1329
+ }
1330
+ }