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

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.23",
3
+ "version": "1.0.0-alpha.25",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -119,7 +119,9 @@
119
119
  "server-managed/http.ts",
120
120
  "server-managed/import-triage.ts",
121
121
  "server-managed/managed-issues.ts",
122
+ "server-managed/management.ts",
122
123
  "server-managed/index.ts",
124
+ "server-managed/package-entry-points.ts",
123
125
  "server-managed/public-workspace.ts",
124
126
  "server-managed/repo-path.ts",
125
127
  "server-managed/report-migration.ts",
@@ -153,6 +155,7 @@
153
155
  "report/index.js",
154
156
  "report/src/finding-id.js",
155
157
  "report/src/finding.js",
158
+ "report/src/severity.js",
156
159
  "report/src/security.js",
157
160
  "report/src/labels.js",
158
161
  "report/src/md-structure.js",
package/report/index.js CHANGED
@@ -82,6 +82,7 @@ import { parseDeepviewMarkdown } from './src/parse-deepview-md.js'
82
82
  import { parseMarkdownFindings } from './src/parse-md.js'
83
83
  import { parsePioliumFindings } from './src/parse-piolium.js'
84
84
  import { deriveFindingId } from './src/finding-id.js'
85
+ import { normalizeFindingSeverity } from './src/severity.js'
85
86
 
86
87
  // The rest of the surface, so a consumer needs one import: the codex
87
88
  // splitter, the id helpers the analyzer shares with the viewer, and the
@@ -193,7 +194,10 @@ export function readReport(content) {
193
194
  let jsonError
194
195
  try {
195
196
  const data = JSON.parse(content)
196
- if (reportEntries(data)) return { data, format: 'json', reason: null }
197
+ if (reportEntries(data)) {
198
+ for (const finding of flattenFindings(reportEntries(data))) normalizeFindingSeverity(finding)
199
+ return { data, format: 'json', reason: null }
200
+ }
197
201
  return { data: null, format: null, reason: 'JSON, but not a report: no findings array' }
198
202
  } catch (err) {
199
203
  jsonError = err
@@ -233,6 +237,8 @@ function flattenFindings(entries) {
233
237
  // untouched. Batched via Promise.all — sequential awaits would
234
238
  // serialise hundreds of crypto.subtle.digest calls for no reason.
235
239
  export async function backfillFindingIds(findings) {
240
+ // Managed responses may arrive as already-parsed objects.
241
+ for (const finding of findings) normalizeFindingSeverity(finding)
236
242
  const idLess = findings.filter((f) => !f.id)
237
243
  if (idLess.length === 0) return
238
244
  const derived = await Promise.all(idLess.map(deriveFindingId))
@@ -71,7 +71,7 @@ export async function deriveFindingId(f) {
71
71
  // The choice above as the object that gets hashed. Key order is part of
72
72
  // the id — JSON.stringify keeps insertion order — so every shape lists
73
73
  // severity and description first.
74
- function fingerprintOf(f) {
74
+ export function fingerprintOf(f) {
75
75
  if (f._idBasis) return f._idBasis
76
76
  const { severity, description } = f
77
77
  if (f.fileHash) return { severity, description, fileHash: f.fileHash }
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { fenceRanges, inFence } from './md-structure.js'
10
10
  import { SOURCE_LABELS } from './labels.js'
11
+ import { canonicalSeverity } from './severity.js'
11
12
 
12
13
  // Severity ranking — higher = more severe. Two stacks: vulnerabilities
13
14
  // (critical → low) over bug-class findings (high_bug → bug), with
@@ -33,6 +34,7 @@ export const SEVERITIES = ['critical', 'high', 'medium', 'low', 'high_bug', 'bug
33
34
  // Only a known tier counts: an unrecognised one would sort to rank 0 and
34
35
  // render an uncolored badge, so the intrinsic severity stands instead.
35
36
  function validCorrected(corrected) {
37
+ corrected = canonicalSeverity(corrected)
36
38
  return corrected != null && corrected in SEVERITY_ORDER ? corrected : null
37
39
  }
38
40
 
@@ -40,20 +42,20 @@ function validCorrected(corrected) {
40
42
  // correction — it IS that report's finding — so no report key is needed;
41
43
  // divergence across reports comes from correctedVariants.
42
44
  export function effectiveSeverity(f) {
43
- return validCorrected(f?.correctedSeverity) ?? f?.severity
45
+ return validCorrected(f?.correctedSeverity) ?? canonicalSeverity(f?.severity)
44
46
  }
45
47
 
46
48
  // True when the finding carries a valid correction that actually changes
47
49
  // the tier — the trigger for the dual badge / reason affordance.
48
50
  export function hasSeverityCorrection(f) {
49
51
  const c = validCorrected(f?.correctedSeverity)
50
- return c != null && c !== f?.severity
52
+ return c != null && c !== canonicalSeverity(f?.severity)
51
53
  }
52
54
 
53
55
  // Every display, count and sort site reads severity through this with the
54
56
  // current lens (`state.severityMode`), not off `f.severity`.
55
57
  export function displayedSeverity(f, mode) {
56
- return mode === 'original' ? f?.severity : effectiveSeverity(f)
58
+ return mode === 'original' ? canonicalSeverity(f?.severity) : effectiveSeverity(f)
57
59
  }
58
60
 
59
61
  // The per-report map of a deduped survivor, but only where the reports
@@ -61,7 +63,7 @@ export function displayedSeverity(f, mode) {
61
63
  export function correctedVariants(f) {
62
64
  const byReport = f?._correctedByReport
63
65
  if (!byReport) return null
64
- const tiers = new Set(Object.values(byReport).map((v) => v?.severity))
66
+ const tiers = new Set(Object.values(byReport).map((v) => canonicalSeverity(v?.severity)))
65
67
  return tiers.size > 1 ? byReport : null
66
68
  }
67
69
 
@@ -3,6 +3,8 @@
3
3
  // export dialog, the analyzer dropdown, the page header — so a filter
4
4
  // the dialog lists and the header line in the file can't disagree.
5
5
 
6
+ import { canonicalSeverity } from './severity.js'
7
+
6
8
  export const SEVERITY_LABELS = {
7
9
  critical: 'Critical', high: 'High', medium: 'Medium', low: 'Low',
8
10
  high_bug: 'High bug', bug: 'Bug', informational: 'Informational',
@@ -29,5 +31,5 @@ export const SOURCE_LABELS = {
29
31
  // invent one, and the reader is better served by the word than by a
30
32
  // blank.
31
33
  export function severityLabel(severity) {
32
- return SEVERITY_LABELS[severity] ?? String(severity ?? '')
34
+ return SEVERITY_LABELS[canonicalSeverity(severity)] ?? String(severity ?? '')
33
35
  }
@@ -14,6 +14,8 @@
14
14
  // Only the first path in `relevant_paths` becomes `f.file` (some
15
15
  // findings list several, as `path1 | path2 | …`); the rest are dropped.
16
16
 
17
+ import { normalizeFindingSeverity } from './severity.js'
18
+
17
19
  const REQUIRED_COLUMNS = [
18
20
  'finding_url', 'repository', 'title', 'description', 'severity',
19
21
  'configured_scan_id', 'relevant_paths',
@@ -143,5 +145,5 @@ function rowToFinding(r) {
143
145
  if (r.commit_hash) finding.commitHash = r.commit_hash
144
146
  if (r.detected_at) finding.detectedAt = r.detected_at
145
147
  if (r.committed_at) finding.committedAt = r.committed_at
146
- return finding
148
+ return normalizeFindingSeverity(finding)
147
149
  }
@@ -30,6 +30,7 @@
30
30
  // `## SEVERITY (n)` header appears and the chain moves on.
31
31
 
32
32
  import { normalizeNewlines, splitHeadingLine } from './md-structure.js'
33
+ import { normalizeFindingSeverity } from './severity.js'
33
34
 
34
35
  // The `## SEVERITY (n)` header that marks a DeepSec document. Splitting
35
36
  // on it with the tier captured interleaves tiers and content:
@@ -50,6 +51,7 @@ function mapSeverity(s) {
50
51
  case 'HIGH_BUG': return 'high_bug'
51
52
  case 'BUG': return 'bug'
52
53
  case 'INFO': case 'INFORMATIONAL': return 'informational'
54
+ case 'INFORMATIVE': return 'informative'
53
55
  default: return 'medium'
54
56
  }
55
57
  }
@@ -193,5 +195,5 @@ function parseBlock(block, severity) {
193
195
  if (fields.reasoning) finding.revalidateVerdict = fields.reasoning.replaceAll('**', '')
194
196
  }
195
197
  if (fields.slug) finding.slug = fields.slug
196
- return finding
198
+ return normalizeFindingSeverity(finding, severity === 'informative' ? 'medium' : severity)
197
199
  }
@@ -56,6 +56,7 @@
56
56
  import { locationLabel } from './finding.js'
57
57
  import { H2_RE, H3_RE, H4_RE, normalizeNewlines, splitByHeading, splitLeading } from './md-structure.js'
58
58
  import { applyFact, buildDescription, narrativeSplit, readAnalyzer, readEvidence, readProse, readRepository, splitFacts, splitSections, tierOf } from './parse-deepview-fields.js'
59
+ import { normalizeFindingSeverity } from './severity.js'
59
60
 
60
61
  const MARKER_RE = /^<!--\s*DeepView findings export\b[^>]*-->/u
61
62
  const CASE_RE = /^Case \d+ of \d+(?:\s|$)/u
@@ -136,7 +137,7 @@ function readCase(body, depth, entryTitle, tier) {
136
137
  for (const [field, value] of fields) f[field] = value
137
138
  const reason = sections.find((s) => s.label.toLowerCase() === 'severity correction')
138
139
  if (reason?.body) f.correctedSeverityReason = readProse(reason.body)
139
- return { finding: f, analyzer }
140
+ return { finding: normalizeFindingSeverity(f), analyzer }
140
141
  }
141
142
 
142
143
  // The producer and the run for the whole report: from the Analyzer facts
@@ -30,9 +30,10 @@
30
30
  // carry anything mandatory.
31
31
 
32
32
  import { frozenIdBasis } from './parse-md-id.js'
33
+ import { normalizeFindingSeverity } from './severity.js'
33
34
  import { LIST_MARKER_RE, findMdLink, normalizeNewlines, splitHeadingLine, unescapeMd } from './md-structure.js'
34
35
 
35
- const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'high_bug', 'bug', 'informational'])
36
+ const VALID_SEVERITIES = new Set(['critical', 'high', 'medium', 'low', 'high_bug', 'bug', 'informational', 'informative'])
36
37
 
37
38
  export function parseMarkdownFindings(content) {
38
39
  const text = normalizeNewlines(content).trim()
@@ -115,7 +116,7 @@ function parseBlock(block) {
115
116
  const idBasis = frozenIdBasis(block)
116
117
  if (idBasis) finding._idBasis = idBasis
117
118
 
118
- return finding
119
+ return normalizeFindingSeverity(finding)
119
120
  }
120
121
 
121
122
  // The sections half (before the first `---`) and the metadata half
@@ -5,8 +5,9 @@
5
5
 
6
6
  import { cellValue, parseCodeRef, stripBold, tableObjects } from './md-structure.js'
7
7
  import { frozenIdBasis } from './parse-piolium-id.js'
8
+ import { normalizeFindingSeverity } from './severity.js'
8
9
  import {
9
- idCell, leadingId, leadingLink, mapSeverity, severityFromId, slugTitle,
10
+ idCell, leadingId, leadingLink, mapSeverity, resolveSeverity, severityFromId, slugTitle,
10
11
  } from './parse-piolium-tokens.js'
11
12
 
12
13
  // Normalize a table-row object to the shared row shape used by the
@@ -33,10 +34,7 @@ export function indexRowOf(obj) {
33
34
  // namespaced to read as an opaque token rather than a URL. A Location
34
35
  // column, where a table has one, is parsed like any code reference.
35
36
  export function fromIndexRow(row, sevFallback = '') {
36
- const severity = mapSeverity(row.severity)
37
- || sevFallback
38
- || severityFromId(row.id)
39
- || 'medium'
37
+ const { severity, identitySeverity } = resolveSeverity(mapSeverity(row.severity), sevFallback, severityFromId(row.id))
40
38
  const { file, line, locationLink } = parseCodeRef(row.location || '')
41
39
  const finding = {
42
40
  file: file || 'unknown',
@@ -49,12 +47,12 @@ export function fromIndexRow(row, sevFallback = '') {
49
47
  // The fingerprint reads the same reference its own way — see
50
48
  // parse-piolium-id.js.
51
49
  finding._idBasis = frozenIdBasis({
52
- severity, description: finding.description, ref: row.location || '', id: row.id,
50
+ severity: identitySeverity, description: finding.description, ref: row.location || '', id: row.id,
53
51
  })
54
52
  if (row.pocStatus) finding.pocStatus = row.pocStatus
55
53
  if (row.status) finding.status = row.status
56
54
  if (row.parent) finding.parent = row.parent
57
- return finding
55
+ return normalizeFindingSeverity(finding)
58
56
  }
59
57
 
60
58
  // Findings rendered as a list: the mode outline asks for "links to
@@ -92,10 +90,7 @@ export function listFindings(body, sev, index) {
92
90
  }
93
91
 
94
92
  const row = index.get(id)
95
- const severity = mapSeverity(row?.severity)
96
- || sev
97
- || severityFromId(id)
98
- || 'medium'
93
+ const { severity, identitySeverity } = resolveSeverity(mapSeverity(row?.severity), sev, severityFromId(id))
99
94
  const finding = { file: 'unknown', line: '?', severity, description: stripBold(title) }
100
95
  if (id) finding.location = `piolium:${id}`
101
96
  else if (link) finding.location = link
@@ -103,7 +98,7 @@ export function listFindings(body, sev, index) {
103
98
  if (row?.pocStatus) finding.pocStatus = row.pocStatus
104
99
  if (row?.status) finding.status = row.status
105
100
  if (row?.parent) finding.parent = row.parent
106
- out.push({ id, finding })
101
+ out.push({ id, finding: normalizeFindingSeverity(finding, identitySeverity) })
107
102
  }
108
103
  return out
109
104
  }
@@ -23,10 +23,20 @@ export function mapSeverity(s) {
23
23
  case 'MEDIUM': return 'medium'
24
24
  case 'LOW': return 'low'
25
25
  case 'INFO': case 'INFORMATIONAL': return 'informational'
26
+ case 'INFORMATIVE': return 'informative'
26
27
  default: return ''
27
28
  }
28
29
  }
29
30
 
31
+ // Keep the pre-alias fallback for the ID fingerprint, while displaying the
32
+ // newly recognized tier. Inputs are ordered from strongest to weakest source.
33
+ export function resolveSeverity(...tiers) {
34
+ return {
35
+ severity: tiers.find(Boolean) || 'medium',
36
+ identitySeverity: tiers.find(tier => tier && tier !== 'informative') || 'medium',
37
+ }
38
+ }
39
+
30
40
  // Final-report ids are severity-prefixed and sequential — `C1`, `H2`,
31
41
  // `H-001` in lite consolidation — so the prefix is a second source for
32
42
  // the tier. Only that exact scheme counts: a bare leading letter would
@@ -42,7 +52,7 @@ export function severityFromId(id) {
42
52
  // findings. Anchored to the whole heading, so "High memory usage in
43
53
  // parser" is never mistaken for one.
44
54
  export function severityGroupOf(heading) {
45
- const m = /^(critical|high|medium|low|informational|info)(?:[ -](?:severity|risk))?(?:[ -]findings?)?(?:\s*\(\d+\))?$/iu
55
+ const m = /^(critical|high|medium|low|informational|informative|info)(?:[ -](?:severity|risk))?(?:[ -]findings?)?(?:\s*\(\d+\))?$/iu
46
56
  .exec((heading || '').trim())
47
57
  return m ? mapSeverity(m[1]) : ''
48
58
  }
@@ -51,7 +61,7 @@ export function severityGroupOf(heading) {
51
61
  // `High: remaining` — for sections recognized by their CONTENT rather
52
62
  // than the anchored severityGroupOf shape.
53
63
  export function headerSeverity(header) {
54
- const m = /^(critical|high|medium|low|informational|info)\b/iu.exec((header || '').trim())
64
+ const m = /^(critical|high|medium|low|informational|informative|info)\b/iu.exec((header || '').trim())
55
65
  return m ? mapSeverity(m[1]) : ''
56
66
  }
57
67
 
@@ -64,11 +64,12 @@
64
64
 
65
65
  import { H2_RE, H3_RE, H4_RE, normalizeNewlines, parseCodeRef, parseLabelledFields, splitByHeading, splitLeading, tableObjects } from './md-structure.js'
66
66
  import { frozenIdBasis } from './parse-piolium-id.js'
67
+ import { normalizeFindingSeverity } from './severity.js'
67
68
  import { fromIndexRow, indexRowOf, listFindings, variantFindings } from './parse-piolium-rows.js'
68
69
  import {
69
70
  CODE_REF_FIELDS, codeRefOf, headerSeverity, idCell, idFromToken,
70
71
  isVariantsHeading, mapSeverity, parseHeading, preambleMeta,
71
- severityFromId, severityGroupOf,
72
+ resolveSeverity, severityFromId, severityGroupOf,
72
73
  } from './parse-piolium-tokens.js'
73
74
 
74
75
 
@@ -295,11 +296,9 @@ function parseBlock(heading, body, index, groupSeverity = '') {
295
296
  // Precedence: the block's own bullet, the index row, the enclosing
296
297
  // group, the id's prefix, then medium — where an unrecognized tier
297
298
  // stays visible rather than dropping out.
298
- const severity = mapSeverity(fields.severity)
299
- || mapSeverity(row?.severity)
300
- || groupSeverity
301
- || severityFromId(id)
302
- || 'medium'
299
+ const { severity, identitySeverity } = resolveSeverity(
300
+ mapSeverity(fields.severity), mapSeverity(row?.severity), groupSeverity, severityFromId(id),
301
+ )
303
302
 
304
303
  const ref = parseCodeRef(codeRefOf(fields))
305
304
  // A `**Line:**` / `**Lines:**` bullet supplies the line when the
@@ -332,7 +331,7 @@ function parseBlock(heading, body, index, groupSeverity = '') {
332
331
  // not. finding-id.js prefers `_idBasis` when deriving the uuid; read
333
332
  // that module's header before touching either side.
334
333
  finding._idBasis = frozenIdBasis({
335
- severity, description: finding.description, ref: codeRefOf(fields), lineBullet, id,
334
+ severity: identitySeverity, description: finding.description, ref: codeRefOf(fields), lineBullet, id,
336
335
  })
337
336
  // Auxiliary provenance, kept as plain strings so an export can cite
338
337
  // the audit's own artifacts — as parse-md.js keeps branch / status.
@@ -344,7 +343,7 @@ function parseBlock(heading, body, index, groupSeverity = '') {
344
343
  const parent = row?.parent || (variantOf ? idCell(variantOf[1]) : '')
345
344
  if (parent) finding.parent = parent
346
345
 
347
- return { id, finding }
346
+ return { id, finding: normalizeFindingSeverity(finding) }
348
347
  }
349
348
 
350
349
  // The `## ` sections, keyed case-folded. A repeated header CONCATENATES
@@ -0,0 +1,18 @@
1
+ import { fingerprintOf } from './finding-id.js'
2
+
3
+ // Informative is an input spelling of the existing Informational tier.
4
+ export function canonicalSeverity(value) {
5
+ return typeof value === 'string' && value.trim().toLowerCase() === 'informative' ? 'informational' : value
6
+ }
7
+
8
+ // Normalize presentation without re-keying stored triage. Markdown parsers
9
+ // can supply the severity their previous fallback used for the fingerprint.
10
+ export function normalizeFindingSeverity(finding, identitySeverity = finding.severity) {
11
+ const severity = canonicalSeverity(finding.severity)
12
+ if (severity !== finding.severity) {
13
+ if (!finding.id && !finding._idBasis) finding._idBasis = fingerprintOf({ ...finding, severity: identitySeverity })
14
+ finding.severity = severity
15
+ }
16
+ if (finding.correctedSeverity !== undefined) finding.correctedSeverity = canonicalSeverity(finding.correctedSeverity)
17
+ return finding
18
+ }
@@ -17,6 +17,7 @@ import type { Buffer } from 'node:buffer'
17
17
 
18
18
  type VercelBlobBody = Readable | Buffer | string | Blob | ArrayBuffer | ReadableStream<Uint8Array>
19
19
  export type VercelBlobSdk = {
20
+ BlobNotFoundError: new () => Error
20
21
  put: (
21
22
  pathname: string,
22
23
  body: VercelBlobBody,
@@ -83,24 +84,12 @@ export type VercelBlobSdk = {
83
84
  }
84
85
 
85
86
  // Recognise "blob is gone" errors uniformly across read/write/delete
86
- // paths so callers can treat them as success (delete) or
87
- // not-found (read). The SDK exposes BlobNotFoundError as a class with
88
- // `.name === 'BlobNotFoundError'`; checking the name string avoids
89
- // importing the class at the top level (which would force the optional
90
- // peer dep to resolve).
91
- //
92
- // Class-name check ONLY — the SDK's internal mapper turns every API
93
- // `not_found` into BlobNotFoundError-by-name, so a bare-404 transport
94
- // leak doesn't reach here. A broader `/does not exist|\b404\b/` match
95
- // is DANGEROUS: it also matches BlobStoreNotFoundError's "This store
96
- // does not exist.", so a config fault (revoked token, deleted store)
97
- // would silently surface as every-blob-missing across reads/unlinks,
98
- // masking the fatal misconfiguration. The tight name check lets
99
- // BlobStoreNotFoundError / other classes propagate as real exceptions.
100
- export function isNotFound(err: unknown): boolean {
101
- if (err == null || typeof err !== 'object') return false
102
- const name = (err as { name?: unknown }).name
103
- return typeof name === 'string' && name === 'BlobNotFoundError'
87
+ // paths so callers can treat them as success (delete) or not-found (read).
88
+ // SDK error subclasses retain `.name === 'Error'`; identify the missing-blob
89
+ // class from the same lazily loaded SDK instance that performs the request.
90
+ // Store/configuration failures must propagate, never masquerade as cache misses.
91
+ export function isNotFound(err: unknown, sdk: Pick<VercelBlobSdk, 'BlobNotFoundError'>): boolean {
92
+ return err instanceof sdk.BlobNotFoundError
104
93
  }
105
94
 
106
95
  export async function loadVercelBlobSdk(): Promise<VercelBlobSdk> {
@@ -195,7 +195,7 @@ function buildStatStaging(sdk: VercelBlobSdk, token: string): BlobBackend['statS
195
195
  const h = await sdk.head(stagingBlobPath(tag, stagingId), { token })
196
196
  return h.size
197
197
  } catch (err) {
198
- if (isNotFound(err)) return null
198
+ if (isNotFound(err, sdk)) return null
199
199
  throw err
200
200
  }
201
201
  }
@@ -266,7 +266,7 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
266
266
  // same condition (blob-fs.ts maps ENOENT → `unavailable`), telling
267
267
  // the client the resource is gone for good when it should refetch.
268
268
  // See server-e2e/README.md's GET status table.
269
- if (isNotFound(err)) return { ok: false, reason: 'unavailable', detail: 'vercel-get-not-found' }
269
+ if (isNotFound(err, sdk)) return { ok: false, reason: 'unavailable', detail: 'vercel-get-not-found' }
270
270
  throw err
271
271
  }
272
272
  // SDK returned null (no blob) — same "bytes missing for a live row"
@@ -297,7 +297,7 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
297
297
  // Blob vanished between get() and the head() size fallback (a
298
298
  // racing reaper GC) — still the "live row present, bytes gone"
299
299
  // transient, so `unavailable` (503), matching the get() path above.
300
- if (isNotFound(headErr)) return { ok: false, reason: 'unavailable', detail: 'vercel-head-not-found' }
300
+ if (isNotFound(headErr, sdk)) return { ok: false, reason: 'unavailable', detail: 'vercel-head-not-found' }
301
301
  throw headErr
302
302
  }
303
303
  }
@@ -328,7 +328,7 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
328
328
  function buildUnlink(sdk: VercelBlobSdk, token: string, op: 'staging' | 'live', toPath: (tag: string, id: string) => string): (tag: string, id: string) => Promise<void> {
329
329
  return async (tag, id) => {
330
330
  try { await sdk.del(toPath(tag, id), { token }) } catch (err) {
331
- if (isNotFound(err)) return
331
+ if (isNotFound(err, sdk)) return
332
332
  // Don't propagate — the DB-side row drop has already
333
333
  // committed by the time the caller reaches here, and the
334
334
  // reaper picks up the stranded blob on its next sweep. Same
@@ -64,7 +64,7 @@ export async function openManagedVercelStorage(token: string, sdk?: VercelBlobSd
64
64
  // Content-Length in that case so HTTP clients do not truncate the body.
65
65
  const size = result.blob.size === 0 ? null : result.blob.size
66
66
  return { size, stream: Readable.fromWeb(result.stream as Parameters<typeof Readable.fromWeb>[0]) }
67
- } catch (err) { if (isNotFound(err)) return null; throw err }
67
+ } catch (err) { if (isNotFound(err, blobs)) return null; throw err }
68
68
  }
69
69
  async function get(path: string) {
70
70
  const result = await open(path)
@@ -74,7 +74,7 @@ export async function openManagedVercelStorage(token: string, sdk?: VercelBlobSd
74
74
  return Buffer.concat(parts)
75
75
  }
76
76
  async function remove(path: string) {
77
- try { await blobs.del(path, { token }) } catch (err) { if (!isNotFound(err)) throw err }
77
+ try { await blobs.del(path, { token }) } catch (err) { if (!isNotFound(err, blobs)) throw err }
78
78
  }
79
79
  function store(name: string, suffix = ''): BlobStore {
80
80
  const path = (id: string) => `.managed/${name}/${validate(id)}${suffix}`
@@ -90,7 +90,7 @@ export async function openManagedVercelStorage(token: string, sdk?: VercelBlobSd
90
90
  const cacheStorage: BundleCacheStorage = {
91
91
  async exists(id, file) {
92
92
  try { await blobs.head(cachePath(id, file), { token }); return true }
93
- catch (err) { if (isNotFound(err)) return false; throw err }
93
+ catch (err) { if (isNotFound(err, blobs)) return false; throw err }
94
94
  },
95
95
  put: (id, file, bytes) => put(cachePath(id, file), bytes),
96
96
  async open(id, file) {
@@ -110,7 +110,7 @@ export async function openManagedVercelStorage(token: string, sdk?: VercelBlobSd
110
110
  const reportSourcesStorage: CacheStorage = {
111
111
  async exists(key) {
112
112
  try { await blobs.head(sourcesPath(key), { token }); return true }
113
- catch (err) { if (isNotFound(err)) return false; throw err }
113
+ catch (err) { if (isNotFound(err, blobs)) return false; throw err }
114
114
  },
115
115
  put: (key, bytes) => put(sourcesPath(key), bytes),
116
116
  async open(key) {
@@ -1,6 +1,6 @@
1
1
  import { createHash, randomUUID } from 'node:crypto'
2
2
  import { teamCatalogRevision } from './team-catalog.ts'
3
- import type { Role } from '../common/managed/roles.ts'
3
+ import { type Role, roleAtLeast } from '../common/managed/roles.ts'
4
4
  import type { TeamUserPermissions } from '../common/managed/permissions.ts'
5
5
  import type { TriageEntryPatch } from '../common/managed/triage.ts'
6
6
  import { preferredSlug } from './slugs.ts'
@@ -11,6 +11,7 @@ import { type ManagedIssueStore, managedIssueMethods } from './managed-issues.ts
11
11
  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
+ import { ManagedMutationError, type ManagementStore, managementMethods } from './management.ts'
14
15
 
15
16
  // A managed user identity (the subset of GitHub's `GET /user` we keep). Input
16
17
  // to the upsert; `githubUserId` is the provider lookup key, never exposed to
@@ -307,7 +308,7 @@ export interface UserTeam {
307
308
  }
308
309
 
309
310
  // Backend-agnostic store surface (SQLite + PostgreSQL implementations).
310
- export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore {
311
+ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore, ManagementStore {
311
312
  // Upsert the identity; returns the user's opaque id (stable across logins).
312
313
  // Initial-admin approval comes only from trusted login configuration. It
313
314
  // promotes a matching No access identity only while it is the sole user.
@@ -349,7 +350,8 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
349
350
  insertReport(report: ReportRecordInput, now: number): Promise<void>
350
351
  // Content reuse is atomic with insertion, including across server instances.
351
352
  // Existing copies are kept; the oldest matching row is the stable identity.
352
- insertOrReuseReport(report: ReportRecordInput, now: number): Promise<ReportRecord>
353
+ // HTTP uploads pass sessionId to recheck role/destination in that transaction.
354
+ insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord>
353
355
  getReportByHash(sha256: string, analyzer: string | null): Promise<ReportRecord | null>
354
356
  listReports(userId?: string): Promise<AdminReport[]>
355
357
  getReport(id: string): Promise<ReportRecord | null>
@@ -385,7 +387,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
385
387
  // (referencing reports' bundle_id null out via the FK). linkReportsToBundle
386
388
  // attaches a freshly-stored bundle to the (still-unlinked) reports that
387
389
  // declared its integrity.
388
- insertBundle(bundle: BundleInput, now: number): Promise<void>
390
+ insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void>
389
391
  getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null>
390
392
  getBundle(id: string): Promise<ManagedBundle | null>
391
393
  listBundles(userId?: string): Promise<AdminBundle[]>
@@ -904,8 +906,20 @@ type ReportRow = {
904
906
  bundleId: string | null
905
907
  }
906
908
 
907
- // The report slice of ManagedDb, split out (like selectedRepoMethods) to keep
908
- // openSqliteManagedDb small. Closes over its prepared statements.
909
+ // Byte storage may outlast a logout or grant change. HTTP uploads reauthorize
910
+ // inside the metadata writer transaction after their bytes have been stored.
911
+ async function authorizeUpload(stmts: ReturnType<typeof prepareStatements>, sessionId: string | undefined,
912
+ item: { uploadedBy: string | null; repoId: number | null; repoDirectory?: string }) {
913
+ if (sessionId === undefined) return // Trusted imports and fixtures use the raw store API.
914
+ const user = await stmts.selectSessionStmt.get(sessionId, Date.now()) as SessionRow | undefined
915
+ if (!user) throw new ManagedMutationError(401, 'unauthenticated')
916
+ if (user.uid !== item.uploadedBy || !roleAtLeast(user.role, 'manage')) throw new ManagedMutationError(403, 'forbidden')
917
+ if (item.repoId === null) return
918
+ if (!(await stmts.selectReposStmt.all() as RepoRow[]).some(repo => repo.repoId === item.repoId)) throw new ManagedMutationError(400, 'repo-not-selected')
919
+ if (user.role !== 'admin' && !await stmts.selectRepoPathReadableStmt.get(item.repoDirectory ?? '', item.repoId, user.uid)) throw new ManagedMutationError(403, 'repo-forbidden')
920
+ }
921
+
922
+ // The report slice of ManagedDb closes over its prepared statements.
909
923
  function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
910
924
  const { insertReportStmt, selectReportsStmt, selectReportStmt, deleteReportStmt, setReportRepoStmt, setReportVisibleStmt } = stmts
911
925
  async function getReport(id: string): Promise<ReportRecord | null> {
@@ -929,7 +943,8 @@ function reportMethods(stmts: ReturnType<typeof prepareStatements>) {
929
943
  insertReport,
930
944
  getReport,
931
945
  getReportByHash,
932
- async insertOrReuseReport(report: ReportRecordInput, now: number): Promise<ReportRecord> {
946
+ async insertOrReuseReport(report: ReportRecordInput, now: number, sessionId?: string): Promise<ReportRecord> {
947
+ await authorizeUpload(stmts, sessionId, report)
933
948
  // The driver holds its writer lock for this whole operation. Checking
934
949
  // again here closes the race between the HTTP lookup and blob upload.
935
950
  const existing = await getReportByHash(report.sha256, report.analyzer ?? null)
@@ -1108,7 +1123,8 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>) {
1108
1123
  selectBundlesStmt, deleteBundleStmt, setBundleRepoStmt, linkReportsToBundleStmt, selectBundleReadableStmt, selectRepoReadableStmt, selectRepoPathReadableStmt,
1109
1124
  } = stmts
1110
1125
  return {
1111
- async insertBundle(bundle: BundleInput, now: number): Promise<void> {
1126
+ async insertBundle(bundle: BundleInput, now: number, sessionId?: string): Promise<void> {
1127
+ await authorizeUpload(stmts, sessionId, bundle)
1112
1128
  await insertBundleStmt.run(
1113
1129
  bundle.id, preferredSlug(bundle.id), bundle.integrity, bundle.filename, bundle.kind,
1114
1130
  bundle.byteSize, bundle.uploadedBy, bundle.uploadedByLogin ?? null, bundle.repoId, bundle.repoId == null ? '' : bundle.repoDirectory ?? '', now,
@@ -1297,7 +1313,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1297
1313
  touchUserSeenStmt, updateRoleStmt, updateTokensStmt, selectTokensStmt, deleteSessionStmt, deleteExpiredStmt,
1298
1314
  } = stmts
1299
1315
 
1300
- return {
1316
+ const methods: Omit<ManagedDb, keyof ManagementStore> = {
1301
1317
  async upsertUser(user, now, initialAdminGithubId = null) {
1302
1318
  // New row → a fresh id; ON CONFLICT(github_user_id) keeps an existing
1303
1319
  // user's id (DO UPDATE leaves it untouched), so re-read to return it.
@@ -1364,4 +1380,5 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1364
1380
  await db.close()
1365
1381
  },
1366
1382
  }
1383
+ return { ...methods, ...managementMethods(methods) }
1367
1384
  }
@@ -6,7 +6,7 @@ import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-met
6
6
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
7
7
  import { COMMENT_SCHEMA } from './comments.ts'
8
8
  import { ACTIVITY_SCHEMA } from './activity.ts'
9
- import { type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
9
+ import { ManagedCommitError, type ManagedSqlDriver, scopeManagedMethods } from './sql.ts'
10
10
  import { postgresSchema, postgresSql } from './sql-postgres.ts'
11
11
  import { managedTableRenames } from './db-table-names.ts'
12
12
  import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
@@ -144,16 +144,23 @@ export async function openPostgresManagedDb(connect: PgConnect, options: Managed
144
144
  async scope(write, work) {
145
145
  if (closed) throw new Error('Managed database is closed')
146
146
  const db = await connect()
147
+ let commitAttempted = false
148
+ let result
147
149
  try {
148
150
  await db.query(write ? 'BEGIN' : 'BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY')
149
151
  if (write) await db.query(LOCK)
150
- const result = await context.run(db, work)
152
+ result = await context.run(db, work)
153
+ commitAttempted = true
151
154
  await db.query('COMMIT')
152
- return result
153
155
  } catch (err) {
154
156
  await db.query('ROLLBACK').catch(() => {})
157
+ await db.release().catch(() => {})
158
+ if (write && commitAttempted) throw new ManagedCommitError(err)
155
159
  throw err
156
- } finally { await db.release() }
160
+ }
161
+ try { await db.release() }
162
+ catch (err) { if (write) throw new ManagedCommitError(err); throw err }
163
+ return result
157
164
  },
158
165
  close() { closed = true },
159
166
  }