@preventive/triage 1.0.0-alpha.25 → 1.0.0-alpha.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.25",
3
+ "version": "1.0.0-alpha.26",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -54,6 +54,10 @@ Environment:
54
54
  DEEPVIEW_SCAN_SERVER optional HTTP(S) scan-service URL; advertised
55
55
  in /api/config and allowed by the UI CSP.
56
56
  Unset by default (no external scan service).
57
+ GITHUB_NEW_ISSUE_LABELS optional comma-separated labels for the client's
58
+ prefilled GitHub issue form, in addition to
59
+ deepview and security (for security findings).
60
+ Advertised in /api/config and sync connections.
57
61
  DB_PATH sqlite file (default: server-e2e/data/data.db);
58
62
  ignored when an e2e database URL is set
59
63
  DATABASE_URL shared Neon Postgres URL for enabled modes.
@@ -100,11 +100,13 @@ export async function openManagedVercelStorage(token: string, sdk?: VercelBlobSd
100
100
  },
101
101
  delete: id => deletePrefix(blobs, token, cachePath(id, ''), remove),
102
102
  }
103
- // Staging uploads are never published. An interrupted browser leaves only
104
- // parts here; the authenticated cron removes them after a full day.
105
- function reapUploads(now = Date.now()) {
106
- return deletePrefix(blobs, token, '.managed/uploads/', remove,
103
+ // Staging uploads are never published. Automatic maintenance and explicit
104
+ // cleanup remove abandoned parts after a full day.
105
+ async function reapUploads(now = Date.now()) {
106
+ let removed = 0
107
+ await deletePrefix(blobs, token, '.managed/uploads/', async path => { await remove(path); removed++ },
107
108
  blob => new Date(blob.uploadedAt ?? now).getTime() < now - 86_400_000)
109
+ return removed
108
110
  }
109
111
  const sourcesPath = (key: string) => `.managed/cache/report-sources/${validateCacheKey(key)}`
110
112
  const reportSourcesStorage: CacheStorage = {
@@ -3,6 +3,7 @@
3
3
  // GitHub App can provide both flows, with repository grants at installation.
4
4
  import { env } from 'node:process'
5
5
  import { databaseUrls } from '../server-common/database-config.ts'
6
+ import { MAX_UPLOAD_BYTES } from './uploads.ts'
6
7
 
7
8
  const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost'])
8
9
 
@@ -124,7 +125,7 @@ export function loadManagedConfig({ combined = false } = {}): ManagedConfig {
124
125
  githubAppSlug: env['GITHUB_APP_SLUG'] ?? null,
125
126
  githubNewIssueLabels: env['GITHUB_NEW_ISSUE_LABELS'] ?? '',
126
127
  maxReportBytes: intEnv('MAX_REPORT_BYTES', 10_485_760, 1, 104_857_600),
127
- maxBundleBytes: intEnv('MAX_BUNDLE_BYTES', 209_715_200, 1, 1_073_741_824),
128
+ maxBundleBytes: intEnv('MAX_BUNDLE_BYTES', 209_715_200, 1, MAX_UPLOAD_BYTES),
128
129
  triageHistoryLimit: intEnv('TRIAGE_HISTORY_LIMIT', 0, 0, 1_000_000_000),
129
130
  }
130
131
  }
@@ -204,7 +204,9 @@ export interface TriageEventRow {
204
204
  comment: string | null
205
205
  fix: string | null
206
206
  flagged: boolean | null
207
+ actorId: string | null
207
208
  actorLogin: string | null
209
+ actorName: string | null
208
210
  at: number
209
211
  }
210
212
 
@@ -354,6 +356,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
354
356
  insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord>
355
357
  getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null>
356
358
  listReports(userId?: string): Promise<AdminReport[]>
359
+ listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]>
357
360
  getReport(id: string): Promise<ReportRecord | null>
358
361
  getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null>
359
362
  getReportAccessSnapshot(sessionId: string, now: number, ids: readonly string[]): Promise<ReportAccessSnapshot | null>
@@ -571,6 +574,7 @@ function prepareStatements(db: ManagedSql) {
571
574
  WHERE tr.repo_id = r.repo_id AND tu.user_id = ? AND ${REPORT_IN_TEAM_PATH_SQL}))
572
575
  ORDER BY r.uploaded_at DESC, r.filename ASC`,
573
576
  ),
577
+ findingCatalogReportsStmt: db.prepare(`SELECT id, byte_size AS byteSize FROM managed_report WHERE id > ? ORDER BY id LIMIT ?`),
574
578
  selectReportStmt: db.prepare(
575
579
  `SELECT id, slug, filename, content_type AS contentType, byte_size AS byteSize,
576
580
  sha256, uploaded_by AS uploadedBy, uploaded_at AS uploadedAt,
@@ -646,7 +650,7 @@ function prepareStatements(db: ManagedSql) {
646
650
  selectTriageHistoryStmt: db.prepare(
647
651
  `SELECT e.seq AS seq, e.finding_id AS findingId, e.batch_id AS batchId, e.color AS color, e.triage AS triage,
648
652
  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
653
+ u.id AS actorId, COALESCE(u.login, e.actor_login) AS actorLogin, u.name AS actorName, e.at AS at
650
654
  FROM managed_finding_triage_event e
651
655
  LEFT JOIN managed_user u ON u.id = e.actor_id
652
656
  WHERE e.finding_id = ?
@@ -963,6 +967,9 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
963
967
  uploadedAt: r.uploadedAt,
964
968
  }))
965
969
  },
970
+ async listFindingCatalogReports(after: string, limit: number): Promise<Pick<ReportRecord, 'id' | 'byteSize'>[]> {
971
+ return await stmts.findingCatalogReportsStmt.all(after, limit) as Pick<ReportRecord, 'id' | 'byteSize'>[]
972
+ },
966
973
  async getTeamReportAccessSnapshot(sessionId: string, now: number, teamId: string): Promise<TeamReportAccessSnapshot | null> {
967
974
  const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
968
975
  if (!session) return null
@@ -1019,7 +1026,9 @@ type TriageDbRow = {
1019
1026
  updatedByLogin: string | null; updatedAt: number
1020
1027
  }
1021
1028
  type TriageStateDbRow = { color: string | null; triage: string | null; comment: string | null; fix: string | null; flagged: number | null }
1022
- type TriageEventDbRow = TriageStateDbRow & { seq: number; findingId: string; batchId: string; actorLogin: string | null; at: number }
1029
+ type TriageEventDbRow = TriageStateDbRow & {
1030
+ seq: number; findingId: string; batchId: string; actorId: string | null; actorLogin: string | null; actorName: string | null; at: number
1031
+ }
1023
1032
 
1024
1033
  // The per-finding triage slice of ManagedDb. Closes over its prepared
1025
1034
  // statements (and the handle, for the batch write's transaction). A
@@ -1074,7 +1083,7 @@ function triageMethods( stmts: ReturnType<typeof prepareStatements>, historyLimi
1074
1083
  seq: r.seq, findingId: r.findingId, batchId: r.batchId,
1075
1084
  color: r.color, triage: r.triage, comment: r.comment, fix: r.fix,
1076
1085
  flagged: r.flagged == null ? null : r.flagged === 1,
1077
- actorLogin: r.actorLogin, at: r.at,
1086
+ actorId: r.actorId, actorLogin: r.actorLogin, actorName: r.actorName, at: r.at,
1078
1087
  }))
1079
1088
  },
1080
1089
  async listTriage(findingIds: readonly string[]): Promise<TriageRow[]> {
@@ -1307,6 +1316,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1307
1316
  const comments = commentMethods(db)
1308
1317
  const activity = activityMethods(db)
1309
1318
  const stmts = prepareStatements(db)
1319
+ const reports = reportMethods(stmts)
1310
1320
  const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0)
1311
1321
  const {
1312
1322
  upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectSessionStmt, selectUsersStmt,
@@ -1371,9 +1381,9 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1371
1381
  ...activity,
1372
1382
  ...comments,
1373
1383
  ...workspaceShareMethods(db),
1374
- ...reportMethods(stmts),
1384
+ ...reports,
1375
1385
  ...triage,
1376
- ...importTriageMethods({ ...triage, ...comments }),
1386
+ ...importTriageMethods({ getReport: reports.getReport, ...triage, ...comments }),
1377
1387
  ...bundleMethods(stmts),
1378
1388
  ...teamMethods(stmts),
1379
1389
  async close() {
@@ -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'
@@ -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'
@@ -280,19 +280,32 @@ function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: st
280
280
  }
281
281
 
282
282
  async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
283
- if (req.method !== 'POST') { send405(res, 'POST'); return }
283
+ const cancel = req.method === 'DELETE'
284
+ if (req.method !== 'POST' && !cancel) { send405(res, 'POST, DELETE'); return }
284
285
  const s = await checkMutation(req, res, deps, cookie)
285
286
  if (!s || !requireManageRole(res, s.user)) return
286
287
  if (!deps.uploadStore) { sendJson(res, 404, { error: 'not-found' }); return }
287
- const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)\/(0|[1-9][0-9]*)$/u.exec(path)
288
- if (!match) { sendJson(res, 400, { error: 'bad-upload' }); return }
289
- const id = match[2]!, index = Number(match[3]), kind = match[1] as UploadKind
288
+ const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)(?:\/(0|[1-9][0-9]*))?$/u.exec(path)
289
+ if (!match || (cancel ? match[3] !== undefined : match[3] === undefined)) { sendJson(res, 400, { error: 'bad-upload' }); return }
290
+ const id = match[2]!, kind = match[1] as UploadKind
291
+ if (cancel) {
292
+ const count = Number(req.headers['x-upload-parts'])
293
+ if (!validUpload(id, count)) { sendJson(res, 400, { error: 'bad-upload' }); return }
294
+ await deleteUpload(deps.uploadStore, s.session.id, kind, id, count)
295
+ sendJson(res, 200, { ok: true }); return
296
+ }
297
+ const index = Number(match[3])
290
298
  const maxBytes = kind === 'reports' ? deps.config.maxReportBytes : deps.config.maxBundleBytes
291
- if (!validUploadPart(id, index, maxBytes)) { sendJson(res, 400, { error: 'bad-upload' }); return }
299
+ const discard = async () => {
300
+ if (validUpload(id, index + 1)) {
301
+ await deleteUpload(deps.uploadStore!, s.session.id, kind, id, index + 1).catch(err => console.warn('managed: upload cleanup failed:', err))
302
+ }
303
+ }
304
+ if (!validUploadPart(id, index, maxBytes)) { await discard(); sendJson(res, 400, { error: 'bad-upload' }); return }
292
305
  let bytes
293
306
  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 }
307
+ catch { await discard(); sendJson(res, 413, { error: 'too-large' }); return }
308
+ if (bytes.length === 0) { await discard(); sendJson(res, 400, { error: 'empty' }); return }
296
309
  if (await manageMutation(req, res, deps, cookie) == null) return
297
310
  await putUploadPart(deps.uploadStore, s.session.id, kind, id, index, bytes)
298
311
  sendJson(res, 200, { ok: true })
@@ -1582,7 +1595,8 @@ async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHt
1582
1595
  const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
1583
1596
  if (!visible.has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
1584
1597
  const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map((row: TriageEventRow) => ({
1585
- seq: row.seq, at: row.at, actorLogin: row.actorLogin, batchId: row.batchId, entry: triageWireEntry(row, true),
1598
+ seq: row.seq, at: row.at, actorId: row.actorId, actorLogin: row.actorLogin, actorName: row.actorName,
1599
+ batchId: row.batchId, entry: triageWireEntry(row, true),
1586
1600
  }))
1587
1601
  // A database read can outlive a role, membership, or visibility change.
1588
1602
  const latest = await readSession(deps.config, deps.db, cookie, Date.now())
@@ -1632,9 +1646,31 @@ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse,
1632
1646
  sendJson(res, 200, { ok: true })
1633
1647
  }
1634
1648
 
1649
+ // GET only: clients match local annotations against these IDs without sending
1650
+ // their local finding IDs or annotation bodies to discover which reports exist.
1651
+ async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, search: URLSearchParams): Promise<void> {
1652
+ const s = await readAdminSession(res, deps, cookie)
1653
+ if (!s) return
1654
+ const after = search.get('after') ?? ''
1655
+ if (after && !/^[a-f\d-]{36}$/iu.test(after)) { sendJson(res, 400, { error: 'bad-cursor' }); return }
1656
+ const stored = await deps.db.listFindingCatalogReports(after, FINDING_CATALOG_PAGE_COUNT + 1)
1657
+ const reports = []
1658
+ let bytes = 0, cursor = after, processed = 0
1659
+ for (const report of stored) {
1660
+ if (processed >= FINDING_CATALOG_PAGE_COUNT || (processed > 0 && bytes + report.byteSize > FINDING_CATALOG_PAGE_BYTES)) break
1661
+ bytes += report.byteSize
1662
+ processed++
1663
+ cursor = report.id
1664
+ const findingIds = [...await visibleFindingIds(deps, s.user, report.id, s.session.id, null)]
1665
+ if (findingIds.length > 0) reports.push({ id: report.id, findingIds })
1666
+ }
1667
+ if (await readAdminSession(res, deps, cookie) == null) return
1668
+ sendJson(res, 200, { reports, nextCursor: processed < stored.length ? cursor : null })
1669
+ }
1670
+
1635
1671
  // Admin imports compare the exact snapshot shown by the conflict dialog before
1636
1672
  // 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?: string): Promise<void> {
1673
+ async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1638
1674
  const s = await adminMutation(req, res, deps, cookie)
1639
1675
  if (!s) return
1640
1676
  let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
@@ -1658,15 +1694,10 @@ async function handleImportTriage(req: IncomingMessage, res: ServerResponse, dep
1658
1694
  parsed.push([findingId, patch])
1659
1695
  }
1660
1696
  }
1661
- // Workspace imports remain report-scoped. Standalone local triage can be
1662
- // imported before its reports; only administrators have this global access.
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
- }
1697
+ if (!(await deps.db.getReport(id))) { sendJson(res, 404, { error: 'no-report' }); return }
1698
+ const visible = await visibleFindingIds(deps, s.user, id, s.session.id, null)
1668
1699
  if (await readAdminSession(res, deps, cookie) == null) return
1669
- if (visible && ids.some(findingId => !visible.has(findingId))) { sendJson(res, 404, { error: 'no-finding' }); return }
1700
+ if (ids.some(findingId => !visible.has(findingId))) { sendJson(res, 404, { error: 'no-finding' }); return }
1670
1701
  if (reading) { sendJson(res, 200, { snapshots: await deps.db.getImportTriage(ids) }); return }
1671
1702
  const applied = await deps.db.importTriage(parsed, expected!, { id: s.user.id, login: s.user.login }, id, Date.now())
1672
1703
  sendJson(res, applied ? 200 : 409, { ok: applied })
@@ -1925,7 +1956,9 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
1925
1956
  if (method !== 'GET') { send405(res, 'GET'); return }
1926
1957
  const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
1927
1958
  sendJson(res, 200, { ...info, ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}),
1928
- managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? { uploadChunkBytes: UPLOAD_CHUNK_BYTES } : {}) } })
1959
+ managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
1960
+ uploadChunkBytes: UPLOAD_CHUNK_BYTES, uploadMaxBytes: { reports: config.maxReportBytes, bundles: config.maxBundleBytes },
1961
+ } : {}) } })
1929
1962
  return
1930
1963
  }
1931
1964
  // OAuth: start → redirect to GitHub with the CSRF state cookie.
@@ -2064,9 +2097,9 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2064
2097
  if (method !== 'POST') { send405(res, 'POST'); return }
2065
2098
  await handleSetReportVisible(req, res, deps, cookie); return
2066
2099
  }
2067
- if (path === '/api/admin/import-triage') {
2068
- if (method !== 'POST') { send405(res, 'POST'); return }
2069
- await handleImportTriage(req, res, deps, cookie); return
2100
+ if (path === '/api/admin/reports/finding-ids') {
2101
+ if (method !== 'GET') { send405(res, 'GET'); return }
2102
+ await handleImportFindingIds(res, deps, cookie, url.searchParams); return
2070
2103
  }
2071
2104
  const importTriage = /^\/api\/admin\/reports\/([a-f\d-]{36})\/import-triage$/iu.exec(path)
2072
2105
  if (importTriage) {
@@ -11,11 +11,11 @@ export interface ImportTriageSnapshot {
11
11
  }
12
12
  export interface ImportTriageStore {
13
13
  getImportTriage(ids: readonly string[]): Promise<Record<string, ImportTriageSnapshot>>
14
- importTriage(entries: [string, TriageEntryPatch | null][], expected: Record<string, string>, actor: { id: string; login: string }, reportId: string | undefined, now: number): Promise<boolean>
14
+ importTriage(entries: [string, TriageEntryPatch | null][], expected: Record<string, string>, actor: { id: string; login: string }, reportId: string, now: number): Promise<boolean>
15
15
  }
16
16
 
17
17
  // Raw store methods share one transaction for comparison, triage, and comments.
18
- export function importTriageMethods(store: Pick<ManagedDb, 'listTriage' | 'listComments' | 'setTriageEntries' | 'createComment'>): ImportTriageStore {
18
+ export function importTriageMethods(store: Pick<ManagedDb, 'getReport' | 'listTriage' | 'listComments' | 'setTriageEntries' | 'createComment'>): ImportTriageStore {
19
19
  async function snapshot(ids: readonly string[]) {
20
20
  const rows = new Map((await store.listTriage(ids)).map(row => [row.findingId, row]))
21
21
  const comments = await store.listComments(ids)
@@ -30,6 +30,9 @@ export function importTriageMethods(store: Pick<ManagedDb, 'listTriage' | 'listC
30
30
  return {
31
31
  getImportTriage: snapshot,
32
32
  async importTriage(entries, expected, actor, reportId, now) {
33
+ // A report deleted after the HTTP membership check must not leave newly
34
+ // imported annotations behind. Check existence in the write transaction.
35
+ if (!reportId || !(await store.getReport(reportId))) return false
33
36
  const current = await snapshot(entries.map(([id]) => id))
34
37
  if (entries.some(([id]) => current[id]!.version !== expected[id])) return false
35
38
  const changes: [string, TriageEntryPatch | null][] = []
@@ -38,7 +41,7 @@ export function importTriageMethods(store: Pick<ManagedDb, 'listTriage' | 'listC
38
41
  changes.push([id, Object.keys(entry).length > 0 ? entry : null])
39
42
  if (comment && !current[id]!.comments.some(existing => existing.body === comment)) {
40
43
  await store.createComment({ findingId: id, body: comment, authorId: null, authorLogin: null,
41
- actor, createdAt: null, updatedAt: null, reportId: reportId ?? null }, now)
44
+ actor, createdAt: null, updatedAt: null, reportId }, now)
42
45
  }
43
46
  }
44
47
  await store.setTriageEntries(changes, actor.id, actor.login, now, reportId)
@@ -12,9 +12,10 @@ import { type ManagedHttpDeps, createManagedRequestHandler } from './http.ts'
12
12
  import { loadManagedStatic } from './static.ts'
13
13
  import { openManagedStorage } from './storage.ts'
14
14
 
15
- // Expired-session sweep period. Lookups already exclude expired rows
16
- // (`WHERE expires_at > now`), so this is housekeeping, not a security control.
17
- const SESSION_GC_INTERVAL_MS = 3_600_000
15
+ // Housekeeping runs on ordinary traffic too, including serverless instances
16
+ // without a timer or cron. Failed sweeps retry on later traffic after a minute.
17
+ const REAP_INTERVAL_MS = 3_600_000
18
+ const REAP_RETRY_MS = 60_000
18
19
 
19
20
  type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown'>>
20
21
 
@@ -39,25 +40,46 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
39
40
  const serveStatic = loadManagedStatic(fileURLToPath(new URL('../out/', import.meta.url)), {
40
41
  indexOnly: options.next != null, scanServer: options.serverInfo?.deepviewScanServer ?? null,
41
42
  })
42
- const handleRequest = createManagedRequestHandler({
43
+ const routeRequest = createManagedRequestHandler({
43
44
  ...options, config, db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache, originGate, serveStatic,
44
45
  ...('uploadStore' in storage ? { uploadStore: storage.uploadStore } : {}),
45
46
  isShuttingDown, track,
46
47
  })
47
48
 
48
49
  let cleanup: Promise<void> | undefined
50
+ let nextCleanupAt = 0
49
51
  function reap(): Promise<void> {
50
52
  if (cleanup) return cleanup
53
+ const startedAt = Date.now()
54
+ nextCleanupAt = startedAt + REAP_INTERVAL_MS
55
+ let sessions = 0, uploads = 0
51
56
  cleanup = runReapers({
52
- sessions: () => db.deleteExpiredSessions(Date.now()),
53
- ...('reapUploads' in storage ? { uploads: () => storage.reapUploads() } : {}),
57
+ sessions: async () => { sessions = await db.deleteExpiredSessions(Date.now()) },
58
+ ...('reapUploads' in storage ? { uploads: async () => { uploads = await storage.reapUploads() } } : {}),
59
+ }).then(() => {
60
+ nextCleanupAt = Date.now() + REAP_INTERVAL_MS
61
+ return console.info(`managed-reaper: removed ${sessions} expired session(s), ${uploads} stale upload part(s) in ${Date.now() - startedAt}ms`)
62
+ }, err => {
63
+ nextCleanupAt = Date.now() + REAP_RETRY_MS
64
+ throw err
54
65
  }).finally(() => { cleanup = undefined })
55
66
  track(cleanup)
56
67
  return cleanup
57
68
  }
69
+ function automaticReap(): Promise<void> {
70
+ if (isShuttingDown() || cleanup || Date.now() < nextCleanupAt) return Promise.resolve()
71
+ return reap().catch(err => console.warn('managed-reaper: cleanup failed:', err))
72
+ }
73
+ async function handleRequest(req: Parameters<typeof routeRequest>[0], res: Parameters<typeof routeRequest>[1]): Promise<void> {
74
+ const maintenance = automaticReap()
75
+ // Send the normal response without waiting for storage housekeeping, but
76
+ // keep the invocation alive until both finish. No detached serverless work.
77
+ try { await routeRequest(req, res) }
78
+ finally { await maintenance }
79
+ }
58
80
  const gcTimer = config.serverless ? null : setInterval(() => {
59
- void reap().catch(err => console.warn('managed: maintenance failed:', err))
60
- }, SESSION_GC_INTERVAL_MS)
81
+ void automaticReap()
82
+ }, REAP_INTERVAL_MS)
61
83
  rollback.defer(stop)
62
84
 
63
85
  function stop(): void {
@@ -2,3 +2,9 @@
2
2
  // the encoded response, independently of the per-upload size limit.
3
3
  export const MAX_REPORT_QUERY_COUNT = 4096
4
4
  export const MAX_REPORT_QUERY_BYTES = 1024 * 1024 * 1024
5
+
6
+ // The import catalog scans a bounded page, not the whole installation. A
7
+ // report larger than the byte target occupies a page by itself; upload limits
8
+ // still bound that single report and the cursor must always make progress.
9
+ export const FINDING_CATALOG_PAGE_COUNT = 128
10
+ export const FINDING_CATALOG_PAGE_BYTES = 32 * 1024 * 1024
@@ -7,6 +7,8 @@ import type { IncomingMessage } from 'node:http'
7
7
  import type { BlobStore } from './blob-store.ts'
8
8
 
9
9
  export const UPLOAD_CHUNK_BYTES = 3 * 1024 * 1024
10
+ export const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024
11
+ const MAX_UPLOAD_PARTS = Math.ceil(MAX_UPLOAD_BYTES / UPLOAD_CHUNK_BYTES)
10
12
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
11
13
  export type UploadKind = 'reports' | 'bundles'
12
14
 
@@ -16,7 +18,24 @@ function partId(session: string, kind: UploadKind, id: string, index: number): s
16
18
  }
17
19
 
18
20
  export function validUploadPart(id: string, index: number, maxBytes: number): boolean {
19
- return UUID.test(id) && Number.isSafeInteger(index) && index >= 0 && index < Math.ceil(maxBytes / UPLOAD_CHUNK_BYTES)
21
+ return UUID.test(id) && Number.isSafeInteger(index) && index >= 0 && index < Math.ceil(Math.min(maxBytes, MAX_UPLOAD_BYTES) / UPLOAD_CHUNK_BYTES)
22
+ }
23
+
24
+ export function validUpload(id: string, count: number): boolean {
25
+ return UUID.test(id) && Number.isSafeInteger(count) && count > 0
26
+ }
27
+
28
+ // Limit cleanup to indices that any configured server could have accepted,
29
+ // even if the request advertises an enormous count or the limit was lowered.
30
+ export async function deleteUpload(store: BlobStore, session: string, kind: UploadKind, id: string, count: number): Promise<void> {
31
+ if (!validUpload(id, count)) throw new Error('bad-upload')
32
+ const end = Math.min(count, MAX_UPLOAD_PARTS), errors = []
33
+ for (let start = 0; start < end; start += 8) {
34
+ const results = await Promise.allSettled(Array.from({ length: Math.min(8, end - start) },
35
+ (_, index) => store.delete(partId(session, kind, id, start + index))))
36
+ for (const result of results) if (result.status === 'rejected') errors.push(result.reason)
37
+ }
38
+ if (errors.length > 0) throw new AggregateError(errors, 'Upload cleanup failed')
20
39
  }
21
40
 
22
41
  export function putUploadPart(store: BlobStore, session: string, kind: UploadKind, id: string, index: number, bytes: Buffer): Promise<void> {
@@ -26,14 +45,13 @@ export function putUploadPart(store: BlobStore, session: string, kind: UploadKin
26
45
  export async function readUpload(store: BlobStore, req: IncomingMessage, session: string, kind: UploadKind, maxBytes: number): Promise<Buffer> {
27
46
  const id = req.headers['x-upload-id']
28
47
  const count = Number(req.headers['x-upload-parts']), size = Number(req.headers['x-upload-size'])
29
- if (typeof id !== 'string' || !UUID.test(id) || !Number.isSafeInteger(size) || size <= 0
30
- || !Number.isSafeInteger(count) || count !== Math.ceil(size / UPLOAD_CHUNK_BYTES)) throw new Error('bad-upload')
31
- if (size > maxBytes) throw new Error('too-large')
32
- const ids = Array.from({ length: count }, (_, index) => partId(session, kind, id, index))
33
- const parts: Buffer[] = []
48
+ if (typeof id !== 'string' || !validUpload(id, count)) throw new Error('bad-upload')
34
49
  try {
35
- for (const [index, key] of ids.entries()) {
36
- const bytes = await store.get(key)
50
+ if (!Number.isSafeInteger(size) || size <= 0 || count !== Math.ceil(size / UPLOAD_CHUNK_BYTES)) throw new Error('bad-upload')
51
+ if (size > maxBytes || size > MAX_UPLOAD_BYTES) throw new Error('too-large')
52
+ const parts: Buffer[] = []
53
+ for (let index = 0; index < count; index++) {
54
+ const bytes = await store.get(partId(session, kind, id, index))
37
55
  const expected = Math.min(UPLOAD_CHUNK_BYTES, size - index * UPLOAD_CHUNK_BYTES)
38
56
  if (!bytes || bytes.length !== expected) throw new Error('bad-upload')
39
57
  parts.push(bytes)
@@ -41,7 +59,7 @@ export async function readUpload(store: BlobStore, req: IncomingMessage, session
41
59
  return Buffer.concat(parts, size)
42
60
  } finally {
43
61
  // These bytes can no longer be finalized. Failed/abandoned chunk uploads
44
- // are also covered by the daily staging sweep.
45
- for (const key of ids) await store.delete(key).catch(err => console.warn('managed: upload cleanup failed:', err))
62
+ // are also covered by automatic staging maintenance.
63
+ await deleteUpload(store, session, kind, id, count).catch(err => console.warn('managed: upload cleanup failed:', err))
46
64
  }
47
65
  }