@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.
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +111 -0
- package/common/bundle-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +177 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-sources.d.ts +3 -0
- package/common/bundle-sources.js +284 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +36 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -0
- package/common/github-pr.ts +56 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/permissions.ts +35 -0
- package/common/managed/report-content.ts +42 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +121 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/triage.ts +83 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +33 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/out/brotli-fallback.js +3 -3
- package/out/client-managed-import.js +81 -0
- package/out/client-managed.js +110 -0
- package/out/client-sync.js +16 -13
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +45 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +198 -62
- package/package.json +179 -55
- package/report/index.js +254 -0
- package/report/src/finding-id.js +80 -0
- package/report/src/finding.js +312 -0
- package/report/src/labels.js +33 -0
- package/report/src/md-structure.js +471 -0
- package/report/src/md-text.js +167 -0
- package/report/src/meta.js +76 -0
- package/report/src/parse-codex.js +147 -0
- package/report/src/parse-deepsec.js +197 -0
- package/report/src/parse-deepview-fields.js +375 -0
- package/report/src/parse-deepview-md.js +185 -0
- package/report/src/parse-md-id.js +137 -0
- package/report/src/parse-md.js +322 -0
- package/report/src/parse-piolium-id.js +79 -0
- package/report/src/parse-piolium-rows.js +131 -0
- package/report/src/parse-piolium-tokens.js +175 -0
- package/report/src/parse-piolium.js +400 -0
- package/report/src/security.js +63 -0
- package/report/src/utf8.js +21 -0
- package/report/src/write-md-finding.js +273 -0
- package/report/src/write-md.js +291 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-log.ts +34 -0
- package/server-common/vercel-blob.ts +110 -0
- package/server-e2e/app.ts +485 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +9 -8
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +54 -39
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +7 -10
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +96 -135
- package/{server → server-e2e}/http.ts +110 -12
- package/{server → server-e2e}/hub.ts +44 -14
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +11 -144
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +19 -20
- package/{server → server-e2e}/objstore/init.ts +53 -27
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +110 -93
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +10 -14
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/{server → server-e2e}/pubsub.ts +29 -36
- package/{server → server-e2e}/sign.ts +12 -14
- package/{server → server-e2e}/sse-server.ts +105 -73
- package/{server → server-e2e}/sse-session.ts +30 -16
- package/{server → server-e2e}/static.ts +36 -29
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +51 -0
- package/server-managed/blob-store.ts +66 -0
- package/server-managed/blob-vercel.ts +125 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-cache.ts +185 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +28 -0
- package/server-managed/bundle-summary-cache.ts +97 -0
- package/server-managed/bundle.ts +39 -0
- package/server-managed/cache-storage.ts +40 -0
- package/server-managed/cli.js +13 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +144 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1330 -0
- package/server-managed/db-neon.ts +171 -0
- package/server-managed/db-schema.ts +203 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +109 -0
- package/server-managed/github-app.ts +332 -0
- package/server-managed/github-metadata.ts +65 -0
- package/server-managed/github-oauth.ts +215 -0
- package/server-managed/github-pulls.ts +115 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2043 -0
- package/server-managed/import-triage.ts +48 -0
- package/server-managed/index.ts +135 -0
- package/server-managed/public-workspace.ts +150 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +4 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +154 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +25 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/sql-postgres.ts +30 -0
- package/server-managed/sql.ts +61 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage.ts +34 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +128 -0
- package/server-managed/team-reports.ts +156 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +47 -0
- package/server-managed/workspace-shares.ts +150 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -481
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -327
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /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
|
+
}
|