@preventive/triage 1.0.0-alpha.25 → 1.0.0-alpha.27
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/common/server-info.ts +1 -0
- package/out/client-managed-import.js +18 -18
- package/out/client-managed.js +66 -65
- package/out/prism.js +2 -2
- package/out/view.css +1 -1
- package/out/view.js +182 -182
- package/package.json +12 -2
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +5 -1
- package/server-common/vercel-blob.ts +6 -3
- package/server-e2e/config.ts +4 -0
- package/server-managed/avatar-store.ts +1 -40
- package/server-managed/blob-store.ts +3 -52
- package/server-managed/bundle-cache.ts +0 -28
- package/server-managed/bundle-store.ts +3 -7
- package/server-managed/cache-storage.ts +0 -25
- package/server-managed/cli.js +10 -2
- package/server-managed/config.ts +14 -2
- package/server-managed/db-methods.ts +56 -25
- package/server-managed/db-neon.ts +9 -4
- package/server-managed/db.ts +7 -1
- package/server-managed/http.ts +93 -62
- package/server-managed/import-triage.ts +6 -3
- package/server-managed/index.ts +32 -9
- package/server-managed/object-storage-disk.ts +144 -0
- package/server-managed/object-storage-vercel.ts +60 -0
- package/server-managed/object-storage.ts +71 -0
- package/server-managed/report-query.ts +6 -0
- package/server-managed/repository-browser.ts +5 -1
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql.ts +0 -5
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +168 -0
- package/server-managed/storage-encryption.ts +211 -0
- package/server-managed/storage-maintenance.ts +182 -0
- package/server-managed/storage-payload.ts +60 -0
- package/server-managed/storage-stores.ts +81 -0
- package/server-managed/storage.ts +46 -24
- package/server-managed/uploads.ts +30 -12
- package/server-managed/blob-vercel.ts +0 -125
|
@@ -12,6 +12,8 @@ import { type ImportTriageStore, importTriageMethods } from './import-triage.ts'
|
|
|
12
12
|
import type { ManagedSql } from './sql.ts'
|
|
13
13
|
import { type WorkspaceShareStore, workspaceShareMethods } from './workspace-shares.ts'
|
|
14
14
|
import { ManagedMutationError, type ManagementStore, managementMethods } from './management.ts'
|
|
15
|
+
import { type StorageKey } from '../server-common/storage-crypto.ts'
|
|
16
|
+
import { type StorageDb, type StoredTokens, decodeStorageTokens, encodeStorageTokens, storageMethods, storageState, validateStorageUpload } from './storage-db.ts'
|
|
15
17
|
|
|
16
18
|
// A managed user identity (the subset of GitHub's `GET /user` we keep). Input
|
|
17
19
|
// to the upsert; `githubUserId` is the provider lookup key, never exposed to
|
|
@@ -131,6 +133,7 @@ export interface ReportAccessSnapshot {
|
|
|
131
133
|
// bundle links; `bundleIntegrity` is the report's declared primary bundle (kept
|
|
132
134
|
// so a later bundle upload of that integrity re-links it).
|
|
133
135
|
export interface ReportRecordInput {
|
|
136
|
+
dataKey?: string | null
|
|
134
137
|
id: string
|
|
135
138
|
filename: string
|
|
136
139
|
contentType: string
|
|
@@ -204,7 +207,9 @@ export interface TriageEventRow {
|
|
|
204
207
|
comment: string | null
|
|
205
208
|
fix: string | null
|
|
206
209
|
flagged: boolean | null
|
|
210
|
+
actorId: string | null
|
|
207
211
|
actorLogin: string | null
|
|
212
|
+
actorName: string | null
|
|
208
213
|
at: number
|
|
209
214
|
}
|
|
210
215
|
|
|
@@ -226,7 +231,7 @@ export interface ManagedBundle {
|
|
|
226
231
|
|
|
227
232
|
// What the upload handler supplies to record a bundle; the store stamps
|
|
228
233
|
// uploaded_at. `uploadedByLogin` is the durable uploader-login snapshot.
|
|
229
|
-
export type BundleInput = Omit<ManagedBundle, 'uploadedAt' | 'slug' | 'repoDirectory'> & { uploadedByLogin: string | null; repoDirectory?: string }
|
|
234
|
+
export type BundleInput = Omit<ManagedBundle, 'uploadedAt' | 'slug' | 'repoDirectory'> & { uploadedByLogin: string | null; repoDirectory?: string; dataKey?: string | null }
|
|
230
235
|
|
|
231
236
|
// A bundle row for the "Manage bundles" list — adds the uploader login + repo
|
|
232
237
|
// full name display joins (null when absent / since removed).
|
|
@@ -308,7 +313,7 @@ export interface UserTeam {
|
|
|
308
313
|
}
|
|
309
314
|
|
|
310
315
|
// Backend-agnostic store surface (SQLite + PostgreSQL implementations).
|
|
311
|
-
export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore, ManagementStore {
|
|
316
|
+
export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, StorageDb {
|
|
312
317
|
// Upsert the identity; returns the user's opaque id (stable across logins).
|
|
313
318
|
// Initial-admin approval comes only from trusted login configuration. It
|
|
314
319
|
// promotes a matching No access identity only while it is the sole user.
|
|
@@ -354,7 +359,10 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
|
|
|
354
359
|
insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord>
|
|
355
360
|
getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null>
|
|
356
361
|
listReports(userId?: string): Promise<AdminReport[]>
|
|
362
|
+
listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]>
|
|
357
363
|
getReport(id: string): Promise<ReportRecord | null>
|
|
364
|
+
// Writer-locked reconciliation waits for an uncertain earlier insert.
|
|
365
|
+
resolveReportUpload(id: string): Promise<ReportRecord | null>
|
|
358
366
|
getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null>
|
|
359
367
|
getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null>
|
|
360
368
|
listReportFilenamesWithBundleHash(bundleId: string, sha256: string): Promise<string[]>
|
|
@@ -389,6 +397,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
|
|
|
389
397
|
// declared its integrity.
|
|
390
398
|
insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void>
|
|
391
399
|
getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null>
|
|
400
|
+
resolveBundleUpload(integrity: string): Promise<ManagedBundle | null>
|
|
392
401
|
getBundle(id: string): Promise<ManagedBundle | null>
|
|
393
402
|
listBundles(userId?: string): Promise<AdminBundle[]>
|
|
394
403
|
userCanReadBundle(userId: string, id: string): Promise<boolean>
|
|
@@ -496,11 +505,11 @@ function prepareStatements(db: ManagedSql) {
|
|
|
496
505
|
updateRoleStmt: db.prepare(`UPDATE managed_user SET role = ?, updated_at = ? WHERE id = ?`),
|
|
497
506
|
updateTokensStmt: db.prepare(
|
|
498
507
|
`UPDATE managed_user
|
|
499
|
-
SET gh_access_token = ?, gh_refresh_token = ?, gh_token_expires_at = ?, updated_at = ?
|
|
508
|
+
SET gh_access_token = ?, gh_refresh_token = ?, gh_token_expires_at = ?, gh_tokens_encrypted = ?, updated_at = ?
|
|
500
509
|
WHERE id = ?`,
|
|
501
510
|
),
|
|
502
511
|
selectTokensStmt: db.prepare(
|
|
503
|
-
`SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp
|
|
512
|
+
`SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp, gh_tokens_encrypted AS encrypted
|
|
504
513
|
FROM managed_user WHERE id = ?`,
|
|
505
514
|
),
|
|
506
515
|
deleteSessionStmt: db.prepare(`DELETE FROM managed_session WHERE id = ?`),
|
|
@@ -548,9 +557,9 @@ function prepareStatements(db: ManagedSql) {
|
|
|
548
557
|
),
|
|
549
558
|
insertReportStmt: db.prepare(
|
|
550
559
|
`WITH candidate(id, slug) AS (VALUES (?, ?))
|
|
551
|
-
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)
|
|
560
|
+
INSERT INTO managed_report (id, slug, filename, content_type, byte_size, sha256, uploaded_by, uploaded_by_login, repo_id, repo_directory, repo_embedded, analyzer, visible, bundle_id, bundle_integrity, uploaded_at, data_key, storage_encrypted)
|
|
552
561
|
SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_report WHERE slug = candidate.slug) THEN id ELSE slug END,
|
|
553
|
-
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
|
|
562
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
|
|
554
563
|
),
|
|
555
564
|
// LEFT JOINs so a report whose uploader / repo / bundle was removed (the FK
|
|
556
565
|
// nulled) still lists, with null display fields.
|
|
@@ -571,6 +580,7 @@ function prepareStatements(db: ManagedSql) {
|
|
|
571
580
|
WHERE tr.repo_id = r.repo_id AND tu.user_id = ? AND ${REPORT_IN_TEAM_PATH_SQL}))
|
|
572
581
|
ORDER BY r.uploaded_at DESC, r.filename ASC`,
|
|
573
582
|
),
|
|
583
|
+
findingCatalogReportsStmt: db.prepare(`SELECT id, byte_size AS byteSize FROM managed_report WHERE id > ? ORDER BY id LIMIT ?`),
|
|
574
584
|
selectReportStmt: db.prepare(
|
|
575
585
|
`SELECT id, slug, filename, content_type AS contentType, byte_size AS byteSize,
|
|
576
586
|
sha256, uploaded_by AS uploadedBy, uploaded_at AS uploadedAt,
|
|
@@ -646,7 +656,7 @@ function prepareStatements(db: ManagedSql) {
|
|
|
646
656
|
selectTriageHistoryStmt: db.prepare(
|
|
647
657
|
`SELECT e.seq AS seq, e.finding_id AS findingId, e.batch_id AS batchId, e.color AS color, e.triage AS triage,
|
|
648
658
|
e.comment AS comment, e.fix AS fix, e.flagged AS flagged,
|
|
649
|
-
COALESCE(u.login, e.actor_login) AS actorLogin, e.at AS at
|
|
659
|
+
u.id AS actorId, COALESCE(u.login, e.actor_login) AS actorLogin, u.name AS actorName, e.at AS at
|
|
650
660
|
FROM managed_finding_triage_event e
|
|
651
661
|
LEFT JOIN managed_user u ON u.id = e.actor_id
|
|
652
662
|
WHERE e.finding_id = ?
|
|
@@ -676,9 +686,9 @@ function prepareStatements(db: ManagedSql) {
|
|
|
676
686
|
WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY id`),
|
|
677
687
|
insertBundleStmt: db.prepare(
|
|
678
688
|
`WITH candidate(id, slug) AS (VALUES (?, ?))
|
|
679
|
-
INSERT INTO managed_bundle (id, slug, integrity, filename, kind, byte_size, uploaded_by, uploaded_by_login, repo_id, repo_directory, uploaded_at)
|
|
689
|
+
INSERT INTO managed_bundle (id, slug, integrity, filename, kind, byte_size, uploaded_by, uploaded_by_login, repo_id, repo_directory, uploaded_at, data_key, storage_encrypted)
|
|
680
690
|
SELECT id, CASE WHEN EXISTS (SELECT 1 FROM managed_bundle WHERE slug = candidate.slug) THEN id ELSE slug END,
|
|
681
|
-
?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
|
|
691
|
+
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? FROM candidate`,
|
|
682
692
|
),
|
|
683
693
|
selectBundleByIntegrityStmt: db.prepare(
|
|
684
694
|
`SELECT id, slug, integrity, filename, kind, byte_size AS byteSize,
|
|
@@ -920,7 +930,7 @@ async function authorizeUpload(stmts: ReturnType<typeof prepareStatements>, sess
|
|
|
920
930
|
}
|
|
921
931
|
|
|
922
932
|
// The report slice of ManagedDb closes over its prepared statements.
|
|
923
|
-
function reportMethods(stmts: ReturnType<typeof prepareStatements
|
|
933
|
+
function reportMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql, key: StorageKey | null) {
|
|
924
934
|
const { insertReportStmt, selectReportsStmt, selectReportStmt, deleteReportStmt, setReportRepoStmt, setReportVisibleStmt } = stmts
|
|
925
935
|
async function getReport(id: string): Promise<ReportRecord | null> {
|
|
926
936
|
const row = (await selectReportStmt.get(id)) as ReportRow | undefined
|
|
@@ -932,16 +942,18 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
|
|
|
932
942
|
return row ? getReport(row.id) : null
|
|
933
943
|
}
|
|
934
944
|
async function insertReport(report: ReportRecordInput, now: number): Promise<void> {
|
|
945
|
+
await validateStorageUpload(db, key, 'report', report.id, report.dataKey)
|
|
935
946
|
await insertReportStmt.run(
|
|
936
947
|
report.id, preferredSlug(report.id), report.filename, report.contentType, report.byteSize,
|
|
937
948
|
report.sha256, report.uploadedBy, report.uploadedByLogin ?? null, report.repoId,
|
|
938
949
|
report.repoDirectory ?? '', report.repoEmbedded ? 1 : 0, report.analyzer ?? null, report.visible == null ? 0 : report.visible ? 1 : 0, report.bundleId ?? null,
|
|
939
|
-
report.bundleIntegrity ?? null, now,
|
|
950
|
+
report.bundleIntegrity ?? null, now, report.dataKey ?? null, report.dataKey ? 1 : 0,
|
|
940
951
|
)
|
|
941
952
|
}
|
|
942
953
|
return {
|
|
943
954
|
insertReport,
|
|
944
955
|
getReport,
|
|
956
|
+
resolveReportUpload: getReport,
|
|
945
957
|
getReportByHash,
|
|
946
958
|
async insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord> {
|
|
947
959
|
await authorizeUpload(stmts, sessionId, report)
|
|
@@ -963,6 +975,9 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
|
|
|
963
975
|
uploadedAt: r.uploadedAt,
|
|
964
976
|
}))
|
|
965
977
|
},
|
|
978
|
+
async listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]> {
|
|
979
|
+
return await stmts.findingCatalogReportsStmt.all(after, limit) as Pick<ReportRecord, 'id' | 'byteSize'>[]
|
|
980
|
+
},
|
|
966
981
|
async getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null> {
|
|
967
982
|
const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
|
|
968
983
|
if (!session) return null
|
|
@@ -1019,7 +1034,9 @@ type TriageDbRow = {
|
|
|
1019
1034
|
updatedByLogin: string | null; updatedAt: number
|
|
1020
1035
|
}
|
|
1021
1036
|
type TriageStateDbRow = { color: string | null; triage: string | null; comment: string | null; fix: string | null; flagged: number | null }
|
|
1022
|
-
type TriageEventDbRow = TriageStateDbRow & {
|
|
1037
|
+
type TriageEventDbRow = TriageStateDbRow & {
|
|
1038
|
+
seq: number; findingId: string; batchId: string; actorId: string | null; actorLogin: string | null; actorName: string | null; at: number
|
|
1039
|
+
}
|
|
1023
1040
|
|
|
1024
1041
|
// The per-finding triage slice of ManagedDb. Closes over its prepared
|
|
1025
1042
|
// statements (and the handle, for the batch write's transaction). A
|
|
@@ -1074,7 +1091,7 @@ function triageMethods( stmts: ReturnType<typeof prepareStatements>, historyLimi
|
|
|
1074
1091
|
seq: r.seq, findingId: r.findingId, batchId: r.batchId,
|
|
1075
1092
|
color: r.color, triage: r.triage, comment: r.comment, fix: r.fix,
|
|
1076
1093
|
flagged: r.flagged == null ? null : r.flagged === 1,
|
|
1077
|
-
actorLogin: r.actorLogin, at: r.at,
|
|
1094
|
+
actorId: r.actorId, actorLogin: r.actorLogin, actorName: r.actorName, at: r.at,
|
|
1078
1095
|
}))
|
|
1079
1096
|
},
|
|
1080
1097
|
async listTriage(findingIds: readonly string[]): Promise<TriageRow[]> {
|
|
@@ -1117,23 +1134,27 @@ function mapBundle(r: BundleRow): ManagedBundle {
|
|
|
1117
1134
|
}
|
|
1118
1135
|
|
|
1119
1136
|
// The bundle slice of ManagedDb. Closes over its prepared statements.
|
|
1120
|
-
function bundleMethods(stmts: ReturnType<typeof prepareStatements
|
|
1137
|
+
function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql, key: StorageKey | null) {
|
|
1121
1138
|
const {
|
|
1122
1139
|
insertBundleStmt, selectBundleByIntegrityStmt, selectBundleStmt,
|
|
1123
1140
|
selectBundlesStmt, deleteBundleStmt, setBundleRepoStmt, linkReportsToBundleStmt, selectBundleReadableStmt, selectRepoReadableStmt, selectRepoPathReadableStmt,
|
|
1124
1141
|
} = stmts
|
|
1142
|
+
async function getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null> {
|
|
1143
|
+
const row = (await selectBundleByIntegrityStmt.get(integrity)) as BundleRow | undefined
|
|
1144
|
+
return row == null ? null : mapBundle(row)
|
|
1145
|
+
}
|
|
1125
1146
|
return {
|
|
1126
1147
|
async insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void> {
|
|
1127
1148
|
await authorizeUpload(stmts, sessionId, bundle)
|
|
1149
|
+
await validateStorageUpload(db, key, 'bundle', bundle.id, bundle.dataKey)
|
|
1128
1150
|
await insertBundleStmt.run(
|
|
1129
1151
|
bundle.id, preferredSlug(bundle.id), bundle.integrity, bundle.filename, bundle.kind,
|
|
1130
|
-
bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', now,
|
|
1152
|
+
bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', now, bundle.dataKey ?? null, bundle.dataKey ? 1 : 0,
|
|
1131
1153
|
)
|
|
1132
1154
|
},
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
},
|
|
1155
|
+
getBundleByIntegrity,
|
|
1156
|
+
// The distinct method name selects a writer-locked reconciliation scope.
|
|
1157
|
+
resolveBundleUpload: getBundleByIntegrity,
|
|
1137
1158
|
async getBundle(id: string): Promise<ManagedBundle | null> {
|
|
1138
1159
|
const row = (await selectBundleStmt.get(id)) as BundleRow | undefined
|
|
1139
1160
|
return row == null ? null : mapBundle(row)
|
|
@@ -1300,13 +1321,16 @@ function teamMethods( stmts: ReturnType<typeof prepareStatements>) {
|
|
|
1300
1321
|
// `triageHistoryLimit`: events kept per finding in the triage trail; 0 (the
|
|
1301
1322
|
// default) keeps everything. See ManagedConfig.
|
|
1302
1323
|
export interface ManagedDbOptions {
|
|
1324
|
+
storageEncryptionKey?: StorageKey | null
|
|
1303
1325
|
triageHistoryLimit?: number
|
|
1304
1326
|
}
|
|
1305
1327
|
|
|
1306
1328
|
export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions = {}): ManagedDb {
|
|
1329
|
+
const key = options.storageEncryptionKey ?? null
|
|
1307
1330
|
const comments = commentMethods(db)
|
|
1308
1331
|
const activity = activityMethods(db)
|
|
1309
1332
|
const stmts = prepareStatements(db)
|
|
1333
|
+
const reports = reportMethods(stmts, db, key)
|
|
1310
1334
|
const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0)
|
|
1311
1335
|
const {
|
|
1312
1336
|
upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectSessionStmt, selectUsersStmt,
|
|
@@ -1314,6 +1338,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1314
1338
|
} = stmts
|
|
1315
1339
|
|
|
1316
1340
|
const methods: Omit<ManagedDb, keyof ManagementStore> = {
|
|
1341
|
+
...storageMethods(db, key),
|
|
1317
1342
|
async upsertUser(user, now, initialAdminGithubId = null) {
|
|
1318
1343
|
// New row → a fresh id; ON CONFLICT(github_user_id) keeps an existing
|
|
1319
1344
|
// user's id (DO UPDATE leaves it untouched), so re-read to return it.
|
|
@@ -1354,12 +1379,18 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1354
1379
|
return Number((await updateRoleStmt.run(role, Date.now(), id)).changes) > 0
|
|
1355
1380
|
},
|
|
1356
1381
|
async setUserTokens(id, tokens) {
|
|
1357
|
-
|
|
1382
|
+
const state = await storageState(db, key)
|
|
1383
|
+
const value = state ? encodeStorageTokens(key!, id, tokens.accessToken, tokens.refreshToken) : { access: tokens.accessToken, refresh: tokens.refreshToken }
|
|
1384
|
+
await updateTokensStmt.run(value.access, value.refresh, tokens.expiresAt, state ? 1 : 0, Date.now(), id)
|
|
1358
1385
|
},
|
|
1359
1386
|
async getUserTokens(id) {
|
|
1360
|
-
const
|
|
1387
|
+
const state = await storageState(db, key)
|
|
1388
|
+
const row = (await selectTokensStmt.get(id)) as StoredTokens | undefined
|
|
1361
1389
|
if (row == null || row.access == null) return null
|
|
1362
|
-
|
|
1390
|
+
if (row.encrypted && !state) throw new Error('Encrypted tokens without storage encryption state')
|
|
1391
|
+
if (!row.encrypted && state?.complete) throw new Error('Unexpected plaintext GitHub tokens')
|
|
1392
|
+
const value = row.encrypted ? decodeStorageTokens(key!, id, row) : row
|
|
1393
|
+
return { accessToken: value.access!, refreshToken: value.refresh, expiresAt: row.exp }
|
|
1363
1394
|
},
|
|
1364
1395
|
async getUserGithubId(id) {
|
|
1365
1396
|
const row = (await selectGithubIdStmt.get(id)) as { githubId: number } | undefined
|
|
@@ -1371,10 +1402,10 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
|
|
|
1371
1402
|
...activity,
|
|
1372
1403
|
...comments,
|
|
1373
1404
|
...workspaceShareMethods(db),
|
|
1374
|
-
...
|
|
1405
|
+
...reports,
|
|
1375
1406
|
...triage,
|
|
1376
|
-
...importTriageMethods({ ...triage, ...comments }),
|
|
1377
|
-
...bundleMethods(stmts),
|
|
1407
|
+
...importTriageMethods({ getReport: reports.getReport, ...triage, ...comments }),
|
|
1408
|
+
...bundleMethods(stmts, db, key),
|
|
1378
1409
|
...teamMethods(stmts),
|
|
1379
1410
|
async close() {
|
|
1380
1411
|
await db.close()
|
|
@@ -2,11 +2,12 @@ import { AsyncLocalStorage } from 'node:async_hooks'
|
|
|
2
2
|
import { WebSocket } from 'ws'
|
|
3
3
|
import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
|
|
4
4
|
import { MANAGED_SCHEMA } from './db-schema.ts'
|
|
5
|
+
import { STORAGE_SCHEMA } from './storage-db.ts'
|
|
5
6
|
import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
|
|
6
7
|
import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
|
|
7
8
|
import { COMMENT_SCHEMA } from './comments.ts'
|
|
8
9
|
import { ACTIVITY_SCHEMA } from './activity.ts'
|
|
9
|
-
import {
|
|
10
|
+
import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
|
|
10
11
|
import { postgresSchema, postgresSql } from './sql-postgres.ts'
|
|
11
12
|
import { managedTableRenames } from './db-table-names.ts'
|
|
12
13
|
import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
|
|
@@ -57,7 +58,7 @@ async function initialize(db: PgConnection): Promise<void> {
|
|
|
57
58
|
await db.query(postgresSchema(WORKSPACE_SHARE_SCHEMA))
|
|
58
59
|
await db.query('INSERT INTO managed_schema_version VALUES (4)')
|
|
59
60
|
}
|
|
60
|
-
await db.query(postgresSchema(GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA))
|
|
61
|
+
await db.query(postgresSchema(GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA + STORAGE_SCHEMA))
|
|
61
62
|
await db.query(`ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS state_reason ${GITHUB_STATE_REASON_COLUMN}`)
|
|
62
63
|
await db.query('ALTER TABLE managed_github_metadata ADD COLUMN IF NOT EXISTS attempted_at BIGINT')
|
|
63
64
|
if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 5')).rows.length === 0) {
|
|
@@ -83,6 +84,10 @@ async function initialize(db: PgConnection): Promise<void> {
|
|
|
83
84
|
await db.query('CREATE INDEX IF NOT EXISTS managed_report_hash_idx ON managed_report(sha256, uploaded_at, id)')
|
|
84
85
|
await db.query('INSERT INTO managed_schema_version VALUES (8)')
|
|
85
86
|
}
|
|
87
|
+
for (const table of ['managed_report', 'managed_bundle']) {
|
|
88
|
+
await db.query(`ALTER TABLE ${table} ADD COLUMN IF NOT EXISTS data_key TEXT, ADD COLUMN IF NOT EXISTS storage_encrypted INTEGER NOT NULL DEFAULT 0`)
|
|
89
|
+
}
|
|
90
|
+
await db.query('ALTER TABLE managed_user ADD COLUMN IF NOT EXISTS gh_tokens_encrypted INTEGER NOT NULL DEFAULT 0')
|
|
86
91
|
await db.query('COMMIT')
|
|
87
92
|
} catch (err) {
|
|
88
93
|
await db.query('ROLLBACK')
|
|
@@ -155,11 +160,11 @@ export async function openPostgresManagedDb(connect: PgConnect, options: Managed
|
|
|
155
160
|
} catch (err) {
|
|
156
161
|
await db.query('ROLLBACK').catch(() => {})
|
|
157
162
|
await db.release().catch(() => {})
|
|
158
|
-
if (write && commitAttempted) throw new
|
|
163
|
+
if (write && commitAttempted) throw new Error('Managed commit outcome is uncertain', { cause: err })
|
|
159
164
|
throw err
|
|
160
165
|
}
|
|
161
166
|
try { await db.release() }
|
|
162
|
-
catch (err) { if (write) throw new
|
|
167
|
+
catch (err) { if (write) throw new Error('Managed commit outcome is uncertain', { cause: err }); throw err }
|
|
163
168
|
return result
|
|
164
169
|
},
|
|
165
170
|
close() { closed = true },
|
package/server-managed/db.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { migrateReportLocations } from './report-migration.ts'
|
|
|
7
7
|
import { initCommentMethods } from './comments.ts'
|
|
8
8
|
import { initActivityMethods } from './activity.ts'
|
|
9
9
|
import { MANAGED_SCHEMA } from './db-schema.ts'
|
|
10
|
+
import { STORAGE_SCHEMA } from './storage-db.ts'
|
|
10
11
|
import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
|
|
11
12
|
import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
|
|
12
13
|
import { managedTableRenames } from './db-table-names.ts'
|
|
@@ -65,7 +66,12 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
|
|
|
65
66
|
db.exec('PRAGMA synchronous = FULL;')
|
|
66
67
|
db.exec('PRAGMA foreign_keys = ON;')
|
|
67
68
|
migrateManagedTableNames(db)
|
|
68
|
-
db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA)
|
|
69
|
+
db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA + STORAGE_SCHEMA)
|
|
70
|
+
for (const table of ['managed_report', 'managed_bundle']) {
|
|
71
|
+
ensureColumn(db, table, 'data_key', 'TEXT')
|
|
72
|
+
ensureColumn(db, table, 'storage_encrypted', 'INTEGER NOT NULL DEFAULT 0')
|
|
73
|
+
}
|
|
74
|
+
ensureColumn(db, 'managed_user', 'gh_tokens_encrypted', 'INTEGER NOT NULL DEFAULT 0')
|
|
69
75
|
ensureColumn(db, 'managed_github_metadata', 'state_reason', GITHUB_STATE_REASON_COLUMN)
|
|
70
76
|
ensureColumn(db, 'managed_github_metadata', 'attempted_at', 'INTEGER')
|
|
71
77
|
// Existing public links must opt in too; never preserve an implicit grant.
|
package/server-managed/http.ts
CHANGED
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
49
49
|
import { pipeline } from 'node:stream/promises'
|
|
50
50
|
import { NPM_ADVISORIES_TIMEOUT_MS, fetchNpmAdvisories } from '../server-common/npm-advisories.ts'
|
|
51
|
-
import { UPLOAD_CHUNK_BYTES, type UploadKind, putUploadPart, readUpload, validUploadPart } from './uploads.ts'
|
|
51
|
+
import { UPLOAD_CHUNK_BYTES, type UploadKind, deleteUpload, putUploadPart, readUpload, validUpload, validUploadPart } from './uploads.ts'
|
|
52
52
|
import { type BundleCache, type BundleCachePart, MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
|
|
53
53
|
import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
|
|
54
54
|
import type { BundleStore } from './bundle-store.ts'
|
|
@@ -59,7 +59,7 @@ import type { AvatarStore } from './avatar-store.ts'
|
|
|
59
59
|
import type { BlobStore } from './blob-store.ts'
|
|
60
60
|
import { bundleIntegrity, bundleKind, reportBundleHashes } from './bundle.ts'
|
|
61
61
|
import type { ManagedConfig } from './config.ts'
|
|
62
|
-
import type { ManagedDb, ManagedSession, ReportRecord, StoredUser, TriageEventRow } from './db.ts'
|
|
62
|
+
import type { ManagedBundle, ManagedDb, ManagedSession, ReportRecord, StoredUser, TriageEventRow } from './db.ts'
|
|
63
63
|
import type { OriginGate } from '../server-common/origin.ts'
|
|
64
64
|
import { isRole, roleAtLeast } from '../common/managed/roles.ts'
|
|
65
65
|
import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
|
|
@@ -82,7 +82,7 @@ import { clearCookie, endSession, readSession } from './session.ts'
|
|
|
82
82
|
import type { ActivityContext, ActivityInput } from './activity.ts'
|
|
83
83
|
import { acceptsReportMetadata } from './report-response.ts'
|
|
84
84
|
import { TeamReportsError, loadTeamReports, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
|
|
85
|
-
import { MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
|
|
85
|
+
import { FINDING_CATALOG_PAGE_BYTES, FINDING_CATALOG_PAGE_COUNT, MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
|
|
86
86
|
import { lookupFixes, storedFixUrls } from './github-pulls.ts'
|
|
87
87
|
import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
|
|
88
88
|
import { ISSUE_LOGIN_PATH, isIssueOAuthCallback, issueLoginRedirect, issueOAuthCallback } from './github-issue-oauth.ts'
|
|
@@ -92,7 +92,6 @@ import { handlePublicWorkspace } from './public-workspace.ts'
|
|
|
92
92
|
import { serveUserTeamFeed } from './team-feed.ts'
|
|
93
93
|
import { hashToken, randomToken } from './crypto.ts'
|
|
94
94
|
import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
|
|
95
|
-
import { ManagedCommitError } from './sql.ts'
|
|
96
95
|
import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
|
|
97
96
|
|
|
98
97
|
const SESSION_PATH = '/api/auth/session'
|
|
@@ -280,19 +279,32 @@ function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: st
|
|
|
280
279
|
}
|
|
281
280
|
|
|
282
281
|
async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
|
|
283
|
-
|
|
282
|
+
const cancel = req.method === 'DELETE'
|
|
283
|
+
if (req.method !== 'POST' && !cancel) { send405(res, 'POST, DELETE'); return }
|
|
284
284
|
const s = await checkMutation(req, res, deps, cookie)
|
|
285
285
|
if (!s || !requireManageRole(res, s.user)) return
|
|
286
286
|
if (!deps.uploadStore) { sendJson(res, 404, { error: 'not-found' }); return }
|
|
287
|
-
const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)
|
|
288
|
-
if (!match) { sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
289
|
-
const id = match[2]!,
|
|
287
|
+
const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)(?:\/(0|[1-9][0-9]*))?$/u.exec(path)
|
|
288
|
+
if (!match || (cancel ? match[3] !== undefined : match[3] === undefined)) { sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
289
|
+
const id = match[2]!, kind = match[1] as UploadKind
|
|
290
|
+
if (cancel) {
|
|
291
|
+
const count = Number(req.headers['x-upload-parts'])
|
|
292
|
+
if (!validUpload(id, count)) { sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
293
|
+
await deleteUpload(deps.uploadStore, s.session.id, kind, id, count)
|
|
294
|
+
sendJson(res, 200, { ok: true }); return
|
|
295
|
+
}
|
|
296
|
+
const index = Number(match[3])
|
|
290
297
|
const maxBytes = kind === 'reports' ? deps.config.maxReportBytes : deps.config.maxBundleBytes
|
|
291
|
-
|
|
298
|
+
const discard = async () => {
|
|
299
|
+
if (validUpload(id, index + 1)) {
|
|
300
|
+
await deleteUpload(deps.uploadStore!, s.session.id, kind, id, index + 1).catch(err => console.warn('managed: upload cleanup failed:', err))
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
if (!validUploadPart(id, index, maxBytes)) { await discard(); sendJson(res, 400, { error: 'bad-upload' }); return }
|
|
292
304
|
let bytes
|
|
293
305
|
try { bytes = await readBodyBytes(req, Math.min(UPLOAD_CHUNK_BYTES, maxBytes)) }
|
|
294
|
-
catch { sendJson(res, 413, { error: 'too-large' }); return }
|
|
295
|
-
if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
|
|
306
|
+
catch { await discard(); sendJson(res, 413, { error: 'too-large' }); return }
|
|
307
|
+
if (bytes.length === 0) { await discard(); sendJson(res, 400, { error: 'empty' }); return }
|
|
296
308
|
if (await manageMutation(req, res, deps, cookie) == null) return
|
|
297
309
|
await putUploadPart(deps.uploadStore, s.session.id, kind, id, index, bytes)
|
|
298
310
|
sendJson(res, 200, { ok: true })
|
|
@@ -901,18 +913,22 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
|
|
|
901
913
|
const id = randomUUID()
|
|
902
914
|
const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
|
|
903
915
|
const { bundleId, integrity } = await resolveReportBundle(deps, s.user, bytes)
|
|
904
|
-
await deps.reportStore.put(id, bytes)
|
|
916
|
+
const dataKey = await deps.reportStore.put(id, bytes)
|
|
905
917
|
let report: ReportRecord
|
|
906
918
|
try {
|
|
907
919
|
report = await deps.db.insertOrReuseReport({
|
|
908
|
-
id, filename, contentType, byteSize: bytes.length, sha256,
|
|
920
|
+
id, filename, contentType, byteSize: bytes.length, sha256, dataKey,
|
|
909
921
|
uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: matchedRepo?.repoId ?? null,
|
|
910
922
|
repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId, bundleIntegrity: integrity,
|
|
911
923
|
}, Date.now(), s.session.id)
|
|
912
924
|
} catch (err) {
|
|
913
|
-
//
|
|
914
|
-
|
|
915
|
-
|
|
925
|
+
// A successful COMMIT can lose its acknowledgement. Never delete a file
|
|
926
|
+
// until a writer-locked read confirms that this candidate did not commit.
|
|
927
|
+
let committed: ReportRecord | null
|
|
928
|
+
try { committed = await deps.db.resolveReportUpload(id) }
|
|
929
|
+
catch (lookup) { throw new AggregateError([err, lookup], 'Report upload reconciliation failed', { cause: lookup }) }
|
|
930
|
+
if (committed) report = committed
|
|
931
|
+
else { await deps.reportStore.delete(id).catch(() => {}); throw err }
|
|
916
932
|
}
|
|
917
933
|
if (report.id !== id) await deps.reportStore.delete(id).catch(() => {})
|
|
918
934
|
await sendUploadedReport(req, res, deps, cookie, report, report.id !== id)
|
|
@@ -1030,7 +1046,7 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
|
|
|
1030
1046
|
if (!current) return
|
|
1031
1047
|
if (JSON.stringify(current.repo) !== JSON.stringify(access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
|
|
1032
1048
|
sendJson(res, 200, refs ? result : {
|
|
1033
|
-
...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false, packageEntryPoints: [] } : {}), path, commit,
|
|
1049
|
+
...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false, packageEntryPoints: [], solidityEntryPoints: [], soliditySuggestionsLimited: false } : {}), path, commit,
|
|
1034
1050
|
})
|
|
1035
1051
|
} catch (err) {
|
|
1036
1052
|
if (err instanceof GithubApiError) {
|
|
@@ -1142,6 +1158,18 @@ async function handleListBundles(res: ServerResponse, deps: ManagedHttpDeps, coo
|
|
|
1142
1158
|
await backfillBundleSummaries(bundles, deps.bundleCache)
|
|
1143
1159
|
}
|
|
1144
1160
|
|
|
1161
|
+
type UploadedBundle = Pick<ManagedBundle, 'id' | 'integrity' | 'filename' | 'byteSize' | 'repoId' | 'repoDirectory'>
|
|
1162
|
+
async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, bundle: UploadedBundle, deduped: boolean): Promise<void> {
|
|
1163
|
+
const s = await manageMutation(req, res, deps, cookie)
|
|
1164
|
+
if (!s) return
|
|
1165
|
+
if (deduped && !(await canAccessBundle(deps, s.user, bundle.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
|
|
1166
|
+
const { id, integrity, filename, byteSize, repoId, repoDirectory } = bundle
|
|
1167
|
+
// Re-uploading also repairs reports uploaded while the bundle was inaccessible.
|
|
1168
|
+
await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1169
|
+
prebuildBundle(deps, id)
|
|
1170
|
+
sendJson(res, deduped ? 200 : 201, { id, integrity, filename, repoId, repoDirectory, ...(deduped ? { deduped: true } : { byteSize }) })
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1145
1173
|
// POST /api/admin/bundles — upload a bundle. Mutation: same-origin + CSRF,
|
|
1146
1174
|
// admin|manage. Raw bytes; X-Bundle-Filename names it, optional X-Repo-Id and
|
|
1147
1175
|
// X-Repo-Directory assign a repository location. The bundle's identity is its content hash (sha512), UNIQUE — a
|
|
@@ -1178,48 +1206,31 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1178
1206
|
const integrity = bundleIntegrity(bytes)
|
|
1179
1207
|
const filename = sanitizeFilename(firstHeader(req.headers['x-bundle-filename']), 'bundle')
|
|
1180
1208
|
const existing = await deps.db.getBundleByIntegrity(integrity)
|
|
1181
|
-
if (existing
|
|
1182
|
-
s = await manageMutation(req, res, deps, cookie)
|
|
1183
|
-
if (!s) return
|
|
1184
|
-
if (!(await canAccessBundle(deps, s.user, existing.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
|
|
1185
|
-
// Reports uploaded while this bundle was inaccessible retain only its
|
|
1186
|
-
// integrity. An authorized re-upload repairs those pending links too.
|
|
1187
|
-
await deps.db.linkReportsToBundle(integrity, existing.id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1188
|
-
prebuildBundle(deps, existing.id)
|
|
1189
|
-
sendJson(res, 200, { id: existing.id, integrity, filename: existing.filename, repoId: existing.repoId, repoDirectory: existing.repoDirectory, deduped: true })
|
|
1190
|
-
return
|
|
1191
|
-
}
|
|
1209
|
+
if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, true); return }
|
|
1192
1210
|
const id = randomUUID()
|
|
1193
1211
|
const kind = bundleKind(filename)
|
|
1194
|
-
await deps.bundleStore.put(id, bytes, kind)
|
|
1212
|
+
const dataKey = await deps.bundleStore.put(id, bytes, kind)
|
|
1195
1213
|
try {
|
|
1196
1214
|
await deps.db.insertBundle({
|
|
1197
|
-
id, integrity, filename, kind,
|
|
1215
|
+
id, integrity, filename, kind, dataKey,
|
|
1198
1216
|
byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: repo.repoId, repoDirectory: directory,
|
|
1199
1217
|
}, Date.now(), s.session.id)
|
|
1200
1218
|
} catch (err) {
|
|
1201
|
-
// A committed row may already point here even though insertion rejected.
|
|
1202
|
-
if (!(err instanceof ManagedCommitError)) await deps.bundleStore.delete(id).catch(() => {})
|
|
1203
|
-
if (err instanceof ManagedMutationError) throw err
|
|
1204
1219
|
// A concurrent upload of identical bytes can insert this integrity (UNIQUE)
|
|
1205
1220
|
// between our dedup check and this insert — treat that as a dedup, not a 500.
|
|
1206
1221
|
// Any other failure rethrows.
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1222
|
+
let raced: ManagedBundle | null
|
|
1223
|
+
try { raced = await deps.db.resolveBundleUpload(integrity) }
|
|
1224
|
+
catch (lookup) { throw new AggregateError([err, lookup], 'Bundle upload reconciliation failed', { cause: lookup }) }
|
|
1225
|
+
// Preserve a committed candidate after a lost acknowledgement. If this
|
|
1226
|
+
// read fails too, leave the bytes for later reconciliation.
|
|
1227
|
+
if (raced?.id !== id) await deps.bundleStore.delete(id).catch(() => {})
|
|
1228
|
+
if (err instanceof ManagedMutationError) throw err
|
|
1229
|
+
if (!raced) throw err
|
|
1230
|
+
if (raced.id !== id) { await sendUploadedBundle(req, res, deps, cookie, raced, true); return }
|
|
1231
|
+
// Our insert committed: continue with the ordinary creation response.
|
|
1216
1232
|
}
|
|
1217
|
-
|
|
1218
|
-
s = await manageMutation(req, res, deps, cookie)
|
|
1219
|
-
if (!s) return
|
|
1220
|
-
await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
|
|
1221
|
-
prebuildBundle(deps, id)
|
|
1222
|
-
sendJson(res, 201, { id, integrity, filename, byteSize: bytes.length, repoId: repo.repoId, repoDirectory: directory })
|
|
1233
|
+
await sendUploadedBundle(req, res, deps, cookie, { id, integrity, filename, byteSize: bytes.length, repoId: repo.repoId, repoDirectory: directory }, false)
|
|
1223
1234
|
}
|
|
1224
1235
|
|
|
1225
1236
|
// GET /api/admin/bundles/<id> — download a stored bundle (admin|manage). Bytes
|
|
@@ -1582,7 +1593,8 @@ async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHt
|
|
|
1582
1593
|
const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
|
|
1583
1594
|
if (!visible.has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1584
1595
|
const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map((row: TriageEventRow) => ({
|
|
1585
|
-
seq: row.seq, at: row.at,
|
|
1596
|
+
seq: row.seq, at: row.at, actorId: row.actorId, actorLogin: row.actorLogin, actorName: row.actorName,
|
|
1597
|
+
batchId: row.batchId, entry: triageWireEntry(row, true),
|
|
1586
1598
|
}))
|
|
1587
1599
|
// A database read can outlive a role, membership, or visibility change.
|
|
1588
1600
|
const latest = await readSession(deps.config, deps.db, cookie, Date.now())
|
|
@@ -1632,9 +1644,31 @@ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse,
|
|
|
1632
1644
|
sendJson(res, 200, { ok: true })
|
|
1633
1645
|
}
|
|
1634
1646
|
|
|
1647
|
+
// GET only: clients match local annotations against these IDs without sending
|
|
1648
|
+
// their local finding IDs or annotation bodies to discover which reports exist.
|
|
1649
|
+
async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, search: URLSearchParams): Promise<void> {
|
|
1650
|
+
const s = await readAdminSession(res, deps, cookie)
|
|
1651
|
+
if (!s) return
|
|
1652
|
+
const after = search.get('after') ?? ''
|
|
1653
|
+
if (after && !/^[a-f\d-]{36}$/iu.test(after)) { sendJson(res, 400, { error: 'bad-cursor' }); return }
|
|
1654
|
+
const stored = await deps.db.listFindingCatalogReports(after, FINDING_CATALOG_PAGE_COUNT + 1)
|
|
1655
|
+
const reports = []
|
|
1656
|
+
let bytes = 0, cursor = after, processed = 0
|
|
1657
|
+
for (const report of stored) {
|
|
1658
|
+
if (processed >= FINDING_CATALOG_PAGE_COUNT || (processed > 0 && bytes + report.byteSize > FINDING_CATALOG_PAGE_BYTES)) break
|
|
1659
|
+
bytes += report.byteSize
|
|
1660
|
+
processed++
|
|
1661
|
+
cursor = report.id
|
|
1662
|
+
const findingIds = [...await visibleFindingIds(deps, s.user, report.id, s.session.id, null)]
|
|
1663
|
+
if (findingIds.length > 0) reports.push({ id: report.id, findingIds })
|
|
1664
|
+
}
|
|
1665
|
+
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1666
|
+
sendJson(res, 200, { reports, nextCursor: processed < stored.length ? cursor : null })
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1635
1669
|
// Admin imports compare the exact snapshot shown by the conflict dialog before
|
|
1636
1670
|
// writing. The store commits shared finding triage and imported comments together.
|
|
1637
|
-
async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id
|
|
1671
|
+
async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
|
|
1638
1672
|
const s = await adminMutation(req, res, deps, cookie)
|
|
1639
1673
|
if (!s) return
|
|
1640
1674
|
let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
|
|
@@ -1658,15 +1692,10 @@ async function handleImportTriage(req: IncomingMessage, res: ServerResponse, dep
|
|
|
1658
1692
|
parsed.push([findingId, patch])
|
|
1659
1693
|
}
|
|
1660
1694
|
}
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
let visible: Set<string> | undefined
|
|
1664
|
-
if (id !== undefined) {
|
|
1665
|
-
if (!(await deps.db.getReport(id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1666
|
-
visible = await visibleFindingIds(deps, s.user, id, s.session.id, null)
|
|
1667
|
-
}
|
|
1695
|
+
if (!(await deps.db.getReport(id))) { sendJson(res, 404, { error: 'no-report' }); return }
|
|
1696
|
+
const visible = await visibleFindingIds(deps, s.user, id, s.session.id, null)
|
|
1668
1697
|
if (await readAdminSession(res, deps, cookie) == null) return
|
|
1669
|
-
if (
|
|
1698
|
+
if (ids.some(findingId => !visible.has(findingId))) { sendJson(res, 404, { error: 'no-finding' }); return }
|
|
1670
1699
|
if (reading) { sendJson(res, 200, { snapshots: await deps.db.getImportTriage(ids) }); return }
|
|
1671
1700
|
const applied = await deps.db.importTriage(parsed, expected!, { id: s.user.id, login: s.user.login }, id, Date.now())
|
|
1672
1701
|
sendJson(res, applied ? 200 : 409, { ok: applied })
|
|
@@ -1925,7 +1954,9 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
1925
1954
|
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
1926
1955
|
const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
|
|
1927
1956
|
sendJson(res, 200, { ...info, ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}),
|
|
1928
|
-
managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
|
|
1957
|
+
managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
|
|
1958
|
+
uploadChunkBytes: UPLOAD_CHUNK_BYTES, uploadMaxBytes: { reports: config.maxReportBytes, bundles: config.maxBundleBytes },
|
|
1959
|
+
} : {}) } })
|
|
1929
1960
|
return
|
|
1930
1961
|
}
|
|
1931
1962
|
// OAuth: start → redirect to GitHub with the CSRF state cookie.
|
|
@@ -2064,9 +2095,9 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
|
|
|
2064
2095
|
if (method !== 'POST') { send405(res, 'POST'); return }
|
|
2065
2096
|
await handleSetReportVisible(req, res, deps, cookie); return
|
|
2066
2097
|
}
|
|
2067
|
-
if (path === '/api/admin/
|
|
2068
|
-
if (method !== '
|
|
2069
|
-
await
|
|
2098
|
+
if (path === '/api/admin/reports/finding-ids') {
|
|
2099
|
+
if (method !== 'GET') { send405(res, 'GET'); return }
|
|
2100
|
+
await handleImportFindingIds(res, deps, cookie, url.searchParams); return
|
|
2070
2101
|
}
|
|
2071
2102
|
const importTriage = /^\/api\/admin\/reports\/([a-f\d-]{36})\/import-triage$/iu.exec(path)
|
|
2072
2103
|
if (importTriage) {
|