@preventive/triage 1.0.0-alpha.32 → 1.0.0-alpha.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/client/linked-findings.js +5 -4
  2. package/common/bundle-metadata.js +4 -3
  3. package/common/bundle-source-links.d.ts +2 -1
  4. package/common/bundle-source-links.js +133 -6
  5. package/common/bundle-sources.d.ts +2 -0
  6. package/common/bundle-sources.js +2 -4
  7. package/common/managed/repository-alias.ts +65 -0
  8. package/common/managed/routes.js +20 -1
  9. package/common/managed/triage.ts +2 -2
  10. package/out/client-managed-import.js +51 -46
  11. package/out/client-managed.js +252 -72
  12. package/out/client-sync.js +14 -14
  13. package/out/graph.js +11 -11
  14. package/out/index.html +1 -0
  15. package/out/terminal.js +169 -169
  16. package/out/view.css +1 -1
  17. package/out/view.js +417 -201
  18. package/package.json +23 -17
  19. package/server-managed/activity.ts +15 -1
  20. package/server-managed/brotli.ts +3 -3
  21. package/server-managed/bundle-advisories.ts +8 -3
  22. package/server-managed/bundle-advisory-inventory.ts +27 -25
  23. package/server-managed/bundle-build-worker.js +45 -9
  24. package/server-managed/bundle-build.ts +35 -11
  25. package/server-managed/bundle-cache.ts +1 -1
  26. package/server-managed/bundle.ts +17 -1
  27. package/server-managed/config.ts +7 -2
  28. package/server-managed/db-methods.ts +170 -49
  29. package/server-managed/db-neon.ts +59 -4
  30. package/server-managed/db-schema.ts +12 -3
  31. package/server-managed/db.ts +9 -1
  32. package/server-managed/github-issue-links.ts +127 -0
  33. package/server-managed/github-issues.ts +1 -1
  34. package/server-managed/http-response.ts +7 -1
  35. package/server-managed/http.ts +248 -80
  36. package/server-managed/index.ts +4 -0
  37. package/server-managed/link-reports.ts +158 -0
  38. package/server-managed/managed-issues.ts +31 -3
  39. package/server-managed/management.ts +17 -4
  40. package/server-managed/object-storage-disk.ts +7 -3
  41. package/server-managed/object-storage-vercel.ts +6 -2
  42. package/server-managed/object-storage.ts +3 -2
  43. package/server-managed/public-workspace.ts +5 -2
  44. package/server-managed/report-sources.ts +6 -5
  45. package/server-managed/report-store.ts +47 -0
  46. package/server-managed/repository-aliases.ts +91 -0
  47. package/server-managed/revisions.ts +2 -2
  48. package/server-managed/sql.ts +3 -2
  49. package/server-managed/storage-encryption.ts +72 -6
  50. package/server-managed/storage-maintenance.ts +64 -49
  51. package/server-managed/storage-payload.ts +11 -10
  52. package/server-managed/storage-stores.ts +3 -1
  53. package/server-managed/storage.ts +17 -0
  54. package/server-managed/team-feed.ts +3 -3
  55. package/server-managed/team-reports.ts +24 -15
  56. package/server-managed/upstream-cache.ts +73 -0
  57. package/server-managed/workspace-shares.ts +16 -12
@@ -4,7 +4,7 @@ import { Readable } from 'node:stream'
4
4
  import { type StorageKey, decryptStorageStream, encryptStorageStream, wrapStorageValue } from '../server-common/storage-crypto.ts'
5
5
  import { ENCRYPTED_CACHE_PREFIX, type ObjectStorage, type RawObject, type RawObjectStorage, deleteObjects, objectPath, openObjectVersion } from './object-storage.ts'
6
6
  import { type StorageDb, type StorageEncryptionState, type StorageRow, type StorageRowKind, dataKeyIdentity, unwrapDataKey } from './storage-db.ts'
7
- import { inspectStorageObject, storageOwner, storageRowPath, verifyStoragePayload } from './storage-payload.ts'
7
+ import { inspectStorageObject, storageOwner, storageRowPaths, verifyStoragePayload } from './storage-payload.ts'
8
8
 
9
9
  const STORAGE_WRITE_MS = 180_000
10
10
  export const STORAGE_UPLOAD_TTL_MS = 86_400_000
@@ -78,7 +78,7 @@ async function openEncrypted(raw: RawObjectStorage, db: StorageDb, key: StorageK
78
78
  for (let attempt = 0; attempt < 3; attempt++) {
79
79
  const row = await db.getStorageRow(owner.type, owner.id)
80
80
  if (!row) return null
81
- if (!owner.cache && storageRowPath(owner.type, row) !== identity) return null
81
+ if (!owner.cache && !storageRowPaths(owner.type, row).includes(identity)) return null
82
82
  if (owner.cache && !row.dataKey) return null
83
83
  if (!owner.cache && !row.encrypted) state = (await db.getStorageEncryption())!
84
84
  const stored = await raw.open(owner.cache ? encryptedCachePath(identity) : identity)
@@ -104,13 +104,78 @@ async function openEncrypted(raw: RawObjectStorage, db: StorageDb, key: StorageK
104
104
  }
105
105
  // Only a pending row may read plaintext, and only the original upload.
106
106
  // Verify first, then reopen that exact version before exposing bytes.
107
- await verifyStoragePayload(owner.type, row, inspected.stream)
107
+ await verifyStoragePayload(owner.type, row, inspected.stream, undefined, identity)
108
108
  const current = await openObjectVersion(raw, identity, stored.version)
109
109
  if (current) return current
110
110
  }
111
111
  throw new Error('Storage changed repeatedly during read')
112
112
  }
113
113
 
114
+ async function verifyReportConversion(db: StorageDb, identity: string, bytes: Buffer): Promise<string> {
115
+ const owner = storageOwner(identity)
116
+ if (owner?.type !== 'report' || owner.cache || identity !== `reports/${owner.id}.br`) throw new Error('Invalid report conversion')
117
+ const row = await db.getStorageRow('report', owner.id)
118
+ if (!row) throw new Error('Report deleted during compression')
119
+ // Representation changes must preserve the immutable upload hash.
120
+ await verifyStoragePayload('report', row, Readable.from([bytes]), undefined, identity)
121
+ return owner.id
122
+ }
123
+
124
+ async function finishReportConversion(raw: RawObjectStorage, db: StorageDb, id: string, encrypted: boolean): Promise<void> {
125
+ const row = await db.getStorageRow('report', id)
126
+ if (!row) { await raw.delete(`reports/${id}.br`); throw new Error('Report deleted during compression') }
127
+ // Conversion preserves the original row/key. Keep the original until the
128
+ // compressed PUT has succeeded, and mark encrypted only after removing it.
129
+ await raw.delete(`reports/${id}`)
130
+ if (encrypted) await db.markStorageEncrypted('report', id, row.dataKey!)
131
+ }
132
+
133
+ async function existingReportConversion(raw: RawObjectStorage, identity: string, row: StorageRow, key: StorageKey | null,
134
+ encryptedMode: boolean): Promise<{ version: string; encrypted: boolean } | null> {
135
+ const stored = await raw.open(identity)
136
+ if (!stored) return null
137
+ const inspected = await inspectStorageObject(stored)
138
+ // Activation or a concurrent key allocation can win after the SQL read.
139
+ // Retry with fresh policy/row state before interpreting any ciphertext.
140
+ if (inspected.encrypted && (!encryptedMode || !row.dataKey)) {
141
+ inspected.stream.destroy()
142
+ return { version: stored.version, encrypted: true }
143
+ }
144
+ const source = inspected.encrypted ? await decryptOwned(inspected, key!, 'report', row, identity) : inspected
145
+ await verifyStoragePayload('report', row, source.stream, undefined, identity)
146
+ return { version: stored.version, encrypted: inspected.encrypted }
147
+ }
148
+
149
+ async function publishReportConversion(raw: RawObjectStorage, identity: string, bytes: Buffer, row: StorageRow,
150
+ key: StorageKey | null, expected: string | null, encrypted: boolean): Promise<boolean> {
151
+ if (!encrypted) return raw.put(identity, bytes, AbortSignal.timeout(STORAGE_WRITE_MS), expected)
152
+ const dataKey = unwrapDataKey(key!, 'report', row)
153
+ const source = encryptStorageStream(Readable.from([bytes]), dataKey, identity, bytes.length)
154
+ try { return await raw.put(identity, source, AbortSignal.timeout(STORAGE_WRITE_MS), expected, bytes.length) }
155
+ finally { source.destroy(); dataKey.fill(0) }
156
+ }
157
+
158
+ async function convertStoredReport(raw: RawObjectStorage, db: StorageDb, key: StorageKey | null, identity: string, bytes: Buffer,
159
+ mode: (fresh?: boolean) => Promise<StorageEncryptionState | null>): Promise<null> {
160
+ const id = await verifyReportConversion(db, identity, bytes)
161
+ for (let attempt = 0; attempt < 3; attempt++) {
162
+ const state = await mode(true)
163
+ const row = state ? await db.ensureStorageDataKey('report', id) : await db.getStorageRow('report', id)
164
+ if (!row) throw new Error('Report deleted during compression')
165
+ const current = await existingReportConversion(raw, identity, row, key, Boolean(state))
166
+ if (current?.encrypted && (!state || !row.dataKey)) continue
167
+ // A stale plaintext writer must never replace a concurrent encrypted
168
+ // winner. Retain the original on uncertain writes; the next open resumes.
169
+ if ((!current || state && !current.encrypted) &&
170
+ !await publishReportConversion(raw, identity, bytes, row, key, current?.version ?? null, Boolean(state))) continue
171
+ if (!state && await mode(true)) continue
172
+ await raw.sync?.(identity)
173
+ await finishReportConversion(raw, db, id, Boolean(state))
174
+ return null
175
+ }
176
+ throw new Error('Report changed repeatedly during compression')
177
+ }
178
+
114
179
  // Startup enables encryption before constructing stores; validate the persisted
115
180
  // requirement even when this adapter is opened without a key.
116
181
  async function storagePolicy(db: StorageDb, key: StorageKey | null) {
@@ -156,17 +221,18 @@ export async function createEncryptedObjectStorage(raw: RawObjectStorage, db: St
156
221
  const owner = storageOwner(identity), state = await mode()
157
222
  if (state && owner) {
158
223
  const row = await db.getStorageRow(owner.type, owner.id)
159
- if (!row || (owner.cache ? !row.dataKey : storageRowPath(owner.type, row) !== identity)) return false
224
+ if (!row || (owner.cache ? !row.dataKey : !storageRowPaths(owner.type, row).includes(identity))) return false
160
225
  }
161
226
  // Existence is metadata only; open() authenticates the actual bytes.
162
227
  return await raw.head(state && owner?.cache ? encryptedCachePath(identity) : identity) !== null
163
228
  },
164
- async put(identity, bytes) {
229
+ async put(identity, bytes, { replaceReport = false } = {}) {
165
230
  logicalKey(identity)
231
+ if (replaceReport) return convertStoredReport(raw, db, key, identity, bytes, mode)
166
232
  const state = await mode()
233
+ const owner = storageOwner(identity)
167
234
  if (identity.startsWith('avatars/')) { await raw.put(identity, bytes); return null }
168
235
  if (!state) return putPlaintext(raw, identity, bytes, () => mode(true))
169
- const owner = storageOwner(identity)
170
236
  let dataKey: Buffer, wrapped: string | null = null
171
237
  if (owner?.cache) {
172
238
  const row = await db.ensureStorageDataKey(owner.type, owner.id)
@@ -1,8 +1,9 @@
1
+ import type { Buffer } from 'node:buffer'
1
2
  import { type StorageKey, decryptStorageStream, encryptStorageStream } from '../server-common/storage-crypto.ts'
2
3
  import { ENCRYPTED_CACHE_PREFIX, type RawObjectStorage, deleteObjects, isBlobId, openObjectVersion } from './object-storage.ts'
3
- import { type StorageDb, type StorageEncryptionState, type StorageMigrationRow, unwrapDataKey } from './storage-db.ts'
4
+ import { type StorageDb, type StorageEncryptionState, type StorageMigrationRow, type StorageRow, type StorageRowKind, unwrapDataKey } from './storage-db.ts'
4
5
  import { STORAGE_UPLOAD_TTL_MS } from './storage-encryption.ts'
5
- import { StoragePayloadError, inspectStorageObject, storageOwner, storageRowPath, verifiedStoragePayload, verifyStoragePayload } from './storage-payload.ts'
6
+ import { StoragePayloadError, inspectStorageObject, storageOwner, storageRowPaths, verifiedStoragePayload, verifyStoragePayload } from './storage-payload.ts'
6
7
 
7
8
  async function replacementFailure(raw: RawObjectStorage, identity: string, version: string, signal: AbortSignal): Promise<string> {
8
9
  const format = (value: string) => value.startsWith('W/') ? 'weak' : value.startsWith('"') && value.endsWith('"') ? 'quoted' : 'unquoted'
@@ -21,63 +22,77 @@ async function replacementFailure(raw: RawObjectStorage, identity: string, versi
21
22
  }
22
23
 
23
24
  type MigrationPhase = 'prepare-key' | 'open-payload' | 'verify-existing' | 'encrypt-upload' | 'verify-replacement' | 'checkpoint'
25
+ async function migratePayload(raw: RawObjectStorage, db: StorageDb, type: StorageRowKind, row: StorageRow,
26
+ dataKey: Buffer, identity: string, signal: AbortSignal, progress: (phase: MigrationPhase) => void): Promise<boolean | string | null> {
27
+ progress('open-payload')
28
+ const source = await raw.open(identity, signal)
29
+ if (!source) return false
30
+ let inspected = await inspectStorageObject(source)
31
+ if (inspected.encrypted) {
32
+ progress('verify-existing')
33
+ // A previous PUT may have completed before a timeout or lost SQL ack.
34
+ try {
35
+ const decoded = await decryptStorageStream(inspected.stream, dataKey, identity)
36
+ await verifyStoragePayload(type, row, decoded.stream, signal, identity)
37
+ } catch {
38
+ signal.throwIfAborted()
39
+ // Legacy arbitrary bytes can share the magic prefix. A matching
40
+ // original upload hash is required before encrypting them as plaintext.
41
+ const legacy = await openObjectVersion(raw, identity, source.version, signal)
42
+ if (!legacy) return 'Payload disappeared or changed version before plaintext verification'
43
+ inspected = { ...legacy, encrypted: false }
44
+ }
45
+ }
46
+ if (!inspected.encrypted) {
47
+ progress('encrypt-upload')
48
+ signal.throwIfAborted()
49
+ const verified = verifiedStoragePayload(type, row, inspected.stream, signal, identity)
50
+ const encrypted = encryptStorageStream(verified, dataKey, identity, inspected.size)
51
+ let replaced: boolean
52
+ try { replaced = await raw.put(identity, encrypted, signal, source.version, inspected.size ?? undefined) }
53
+ // A provider may wrap a request-body error; keep safe payload diagnostics.
54
+ catch (err) { throw verified.errored ?? err }
55
+ finally { encrypted.destroy(); verified.destroy(); inspected.stream.destroy(); source.stream.destroy() }
56
+ if (!replaced) return await replacementFailure(raw, identity, source.version, signal)
57
+ // Do not mark the row encrypted until the stored bytes authenticate and
58
+ // match the original upload (including Brotli decompression).
59
+ progress('verify-replacement')
60
+ const replacement = await raw.open(identity, signal)
61
+ if (!replacement) {
62
+ if (!await db.getStorageRow(type, row.id)) return null
63
+ throw new StoragePayloadError('Migrated payload unavailable')
64
+ }
65
+ const decoded = await decryptStorageStream(replacement.stream, dataKey, identity)
66
+ await verifyStoragePayload(type, row, decoded.stream, signal, identity)
67
+ }
68
+ signal.throwIfAborted()
69
+ // Persist observed replacements, including both report representations
70
+ // if they coexist, before marking the whole row encrypted.
71
+ progress('checkpoint')
72
+ await raw.sync?.(identity, signal)
73
+ return true
74
+ }
75
+
24
76
  async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey, item: StorageMigrationRow, signal: AbortSignal,
25
77
  progress: (phase: MigrationPhase) => void): Promise<string | void> {
26
78
  if (item.type === 'user') { await db.migrateStorageUserTokens(item.id); return }
27
79
  progress('prepare-key')
28
80
  const row = await db.ensureStorageDataKey(item.type, item.id)
29
81
  if (!row || row.encrypted) return
30
- const dataKey = unwrapDataKey(key, item.type, row), identity = storageRowPath(item.type, row)
82
+ const dataKey = unwrapDataKey(key, item.type, row)
31
83
  try {
32
- progress('open-payload')
33
- const source = await raw.open(identity, signal)
34
- if (!source) {
84
+ let found = false
85
+ for (const identity of storageRowPaths(item.type, row)) {
86
+ const result = await migratePayload(raw, db, item.type, row, dataKey, identity, signal, progress)
87
+ if (result === null) return
88
+ if (typeof result === 'string') return result
89
+ found ||= result
90
+ }
91
+ if (!found) {
35
92
  if (!await db.getStorageRow(item.type, item.id)) return
36
93
  throw new StoragePayloadError('Migration payload unavailable')
37
94
  }
38
- let inspected = await inspectStorageObject(source)
39
- if (inspected.encrypted) {
40
- progress('verify-existing')
41
- // A previous PUT may have completed before a timeout or lost SQL ack.
42
- try {
43
- const decoded = await decryptStorageStream(inspected.stream, dataKey, identity)
44
- await verifyStoragePayload(item.type, row, decoded.stream, signal)
45
- } catch {
46
- signal.throwIfAborted()
47
- // Legacy arbitrary bytes can share the magic prefix. A matching
48
- // original upload hash is required before encrypting them as plaintext.
49
- const legacy = await openObjectVersion(raw, identity, source.version, signal)
50
- if (!legacy) return 'Payload disappeared or changed version before plaintext verification'
51
- inspected = { ...legacy, encrypted: false }
52
- }
53
- }
54
- if (!inspected.encrypted) {
55
- progress('encrypt-upload')
56
- signal.throwIfAborted()
57
- const verified = verifiedStoragePayload(item.type, row, inspected.stream, signal)
58
- const encrypted = encryptStorageStream(verified, dataKey, identity, inspected.size)
59
- let replaced: boolean
60
- try { replaced = await raw.put(identity, encrypted, signal, source.version, inspected.size ?? undefined) }
61
- // A provider may wrap a request-body error; keep safe payload diagnostics.
62
- catch (err) { throw verified.errored ?? err }
63
- finally { encrypted.destroy(); verified.destroy(); inspected.stream.destroy(); source.stream.destroy() }
64
- if (!replaced) return await replacementFailure(raw, identity, source.version, signal)
65
- // Do not mark the row encrypted until the stored bytes authenticate and
66
- // match the original upload (including decompression for sourcemaps).
67
- progress('verify-replacement')
68
- const replacement = await raw.open(identity, signal)
69
- if (!replacement) {
70
- if (!await db.getStorageRow(item.type, item.id)) return
71
- throw new StoragePayloadError('Migrated payload unavailable')
72
- }
73
- const decoded = await decryptStorageStream(replacement.stream, dataKey, identity)
74
- await verifyStoragePayload(item.type, row, decoded.stream, signal)
75
- }
76
95
  signal.throwIfAborted()
77
- // Another process may have renamed ciphertext but stopped before syncing
78
- // the directory. Persist the observed rename even on the resume path.
79
- progress('checkpoint')
80
- await raw.sync?.(identity, signal)
81
96
  await db.markStorageEncrypted(item.type, item.id, row.dataKey!)
82
97
  } finally { dataKey.fill(0) }
83
98
  }
@@ -116,7 +131,7 @@ async function cleanupLegacy(raw: RawObjectStorage, db: StorageDb, state: Storag
116
131
  const { owner, temporary } = target
117
132
  if (!temporary && !owner.cache) {
118
133
  const row = await db.getStorageRow(owner.type, owner.id)
119
- if (row && storageRowPath(owner.type, row) === object.key) continue
134
+ if (row && storageRowPaths(owner.type, row).includes(object.key)) continue
120
135
  }
121
136
  const stored = await raw.head(object.key, signal)
122
137
  if (!stored) continue
@@ -11,13 +11,14 @@ import type { StorageRow, StorageRowKind } from './storage-db.ts'
11
11
  // not fix it. Keep this distinct from database, storage and cancellation errors.
12
12
  export class StoragePayloadError extends Error {}
13
13
 
14
- export function storageRowPath(type: StorageRowKind, row: Pick<StorageRow, 'id' | 'kind'>): string {
15
- return `${type === 'report' ? 'reports' : 'bundles'}/${row.id}${type === 'bundle' && row.kind === 'sourcemap' ? '.map.br' : ''}`
14
+ export function storageRowPaths(type: StorageRowKind, row: Pick<StorageRow, 'id' | 'kind'>): string[] {
15
+ return type === 'report' ? [`reports/${row.id}.br`, `reports/${row.id}`]
16
+ : [`bundles/${row.id}${row.kind === 'sourcemap' ? '.map.br' : ''}`]
16
17
  }
17
18
  export function storageOwner(identity: string): { type: StorageRowKind; id: string; cache: boolean } | null {
18
19
  const match = /^(reports|bundles)\/([^/]+)$/u.exec(identity)
19
20
  if (match) {
20
- const id = match[1] === 'bundles' ? match[2]!.replace(/\.map\.br$/u, '') : match[2]!
21
+ const id = match[2]!.replace(match[1] === 'bundles' ? /\.map\.br$/u : /\.br$/u, '')
21
22
  if (isBlobId(id)) return { type: match[1] === 'reports' ? 'report' : 'bundle', id, cache: false }
22
23
  }
23
24
  const cache = /^cache\/(?:bundles|report-sources)\/([a-f\d-]{36})\//u.exec(identity)
@@ -50,20 +51,20 @@ export async function inspectStorageObject(stored: RawObject): Promise<RawObject
50
51
  return { ...stored, stream, encrypted: length === prefix.length && prefix.equals(STORAGE_MAGIC) }
51
52
  }
52
53
 
53
- // Sourcemap integrity describes the original upload, before Brotli storage.
54
+ // Report hashes and sourcemap integrity describe the original upload, before Brotli storage.
54
55
  // Hash incrementally, including decompression, so migration stays streaming.
55
- export async function verifyStoragePayload(type: StorageRowKind, row: StorageRow, source: Readable, signal?: AbortSignal): Promise<void> {
56
+ export async function verifyStoragePayload(type: StorageRowKind, row: StorageRow, source: Readable, signal?: AbortSignal, identity?: string): Promise<void> {
56
57
  const hash = createHash(type === 'report' ? 'sha256' : 'sha512')
57
58
  const sink = new Writable({ write(chunk, _encoding, next) { hash.update(chunk); next() } })
58
59
  const options = signal ? { signal } : {}
59
60
  try {
60
- if (type === 'bundle' && row.kind === 'sourcemap') {
61
+ if (type === 'bundle' && row.kind === 'sourcemap' || type === 'report' && identity?.endsWith('.br')) {
61
62
  await pipeline(source, createBrotliDecompress(), sink, options)
62
63
  } else await pipeline(source, sink, options)
63
64
  } catch (err) {
64
65
  const code = (err as NodeJS.ErrnoException).code ?? ''
65
66
  if (['Z_DATA_ERROR', 'Z_BUF_ERROR'].includes(code) || code.startsWith('ERR__ERROR_FORMAT_')) {
66
- throw new StoragePayloadError('Stored sourcemap cannot be decompressed', { cause: err })
67
+ throw new StoragePayloadError(`Stored ${type === 'report' ? 'report' : 'sourcemap'} cannot be decompressed`, { cause: err })
67
68
  }
68
69
  throw err
69
70
  }
@@ -73,10 +74,10 @@ export async function verifyStoragePayload(type: StorageRowKind, row: StorageRow
73
74
 
74
75
  // Forward the stored representation with backpressure, but withhold EOF until
75
76
  // its upload hash passes. Encryption cannot emit its final frame, and an atomic
76
- // PUT cannot commit, until verification (including sourcemap decompression) ends.
77
- export function verifiedStoragePayload(type: StorageRowKind, row: StorageRow, source: Readable, signal?: AbortSignal): Readable {
77
+ // PUT cannot commit, until verification (including Brotli decompression) ends.
78
+ export function verifiedStoragePayload(type: StorageRowKind, row: StorageRow, source: Readable, signal?: AbortSignal, identity?: string): Readable {
78
79
  const input = new PassThrough(), output = new PassThrough()
79
- const verification = verifyStoragePayload(type, row, input, signal)
80
+ const verification = verifyStoragePayload(type, row, input, signal, identity)
80
81
  // Propagate asynchronous decompression errors even if the source is stalled.
81
82
  void verification.catch(err => { source.destroy(err); output.destroy(err) })
82
83
  const forwarding = pipeline(source, async function* (chunks) {
@@ -5,6 +5,7 @@ import type { AvatarStore } from './avatar-store.ts'
5
5
  import type { BlobStore } from './blob-store.ts'
6
6
  import type { BundleCacheStorage } from './bundle-cache.ts'
7
7
  import { createBundleStore } from './bundle-store.ts'
8
+ import { createReportStore } from './report-store.ts'
8
9
  import { CacheMissError, type CacheStorage, validateCacheKey } from './cache-storage.ts'
9
10
  import { type ObjectStorage, isBlobId } from './object-storage.ts'
10
11
 
@@ -71,7 +72,8 @@ export function createManagedStores(objects: ObjectStorage, avatarSidecar: boole
71
72
  delete: value => bundles.delete(id(value)),
72
73
  }
73
74
  return {
74
- reportStore: blobStore(objects, 'reports'),
75
+ reportStore: createReportStore(blobStore(objects, 'reports'), blobStore(objects, 'reports', '.br'),
76
+ (value, bytes) => objects.put(`reports/${id(value)}.br`, bytes, { replaceReport: true })),
75
77
  bundleStore: createBundleStore(blobStore(objects, 'bundles'), blobStore(objects, 'bundles', '.map.br')),
76
78
  uploadStore: blobStore(objects, 'uploads'),
77
79
  avatarStore: avatarStore(objects, avatarSidecar),
@@ -34,6 +34,23 @@ export async function openManagedStorage(config: ManagedConfig) {
34
34
  }
35
35
  const objects = await createEncryptedObjectStorage(raw, db, key)
36
36
  const storage = createManagedStores(objects, !config.neonUrl)
37
+ // Move legacy team link uploads into encrypted SQL before serving catalogs.
38
+ // The DB transaction commits first; a failed blob cleanup cannot lose links.
39
+ for (const report of await db.listLegacyLinkReports()) {
40
+ if (!key) throw new Error('Migrating managed link reports requires MANAGED_STORAGE_ENCRYPTION_KEY')
41
+ let bytes
42
+ try { bytes = await storage.reportStore.get(report.id) } catch (error) {
43
+ if (!await db.getReport(report.id)) continue // another instance completed migration
44
+ throw error
45
+ }
46
+ if (!bytes) {
47
+ if (!await db.getReport(report.id)) continue
48
+ throw new Error(`Missing legacy link report: ${report.id}`)
49
+ }
50
+ if (await db.migrateLinkReport(report.id, bytes.toString('utf8'))) {
51
+ await storage.reportStore.delete(report.id).catch(error => console.warn('managed: legacy link cleanup failed:', error))
52
+ }
53
+ }
37
54
  return { ...storage, db, uploadStore: config.neonUrl ? storage.uploadStore : undefined,
38
55
  bundleCache: createBundleCache(storage.cacheStorage, db, storage.bundleStore),
39
56
  reportSourcesCache: createReportSourcesCache(storage.reportSourcesStorage, db, storage.reportStore, storage.bundleStore),
@@ -47,7 +47,7 @@ export async function serveTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
47
47
  // the focused team needs report parsing or annotation reads; null is the
48
48
  // catalog-only subscription used on landing and management pages.
49
49
  export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
50
- sessionId: string, user: StoredUser, teamId: string | null, options: FeedOptions = {}): Promise<void> {
50
+ sessionId: string, user: StoredUser, teamId: string | null, options: FeedOptions & { reportId?: string | null } = {}): Promise<void> {
51
51
  let ids: string[] = [], visibilityKey: string | undefined
52
52
  let focusedKey: string | undefined
53
53
  let previousState: { catalog: number; annotations: number } | undefined
@@ -86,7 +86,7 @@ export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDe
86
86
  return
87
87
  }
88
88
  let triage: string | undefined
89
- const snapshot = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
89
+ const snapshot = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, options.reportId)
90
90
  if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
91
91
  checkUser(snapshot)
92
92
  const key = teamSnapshotKey(snapshot)
@@ -102,7 +102,7 @@ export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDe
102
102
  }
103
103
  }
104
104
  if (snapshot.teamId) triage = JSON.stringify([key, await deps.db.getAnnotationRevision(ids)])
105
- const current = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
105
+ const current = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, options.reportId)
106
106
  // A concurrent access/content change discards only the annotation read.
107
107
  checkUser(current)
108
108
  if (!current || teamSnapshotKey(current) !== key) return
@@ -1,5 +1,6 @@
1
1
  // Team content is filtered as one workspace, with separate report envelopes.
2
2
  // Bounded snapshots retain filtered content and visibility for repeated reads.
3
+ import { mergeLinkGroups } from './link-reports.ts'
3
4
  import { Buffer } from 'node:buffer'
4
5
  import { backfillFindingIds, reportEntries, stampSecurityGroups } from '@preventive/report'
5
6
  import { managedFindingSourcePaths, readManagedReport } from '../common/managed/report-content.ts'
@@ -7,6 +8,7 @@ import { filterReportData, projectFinding } from '../common/managed/report-filte
7
8
  import type { BlobStore } from './blob-store.ts'
8
9
  import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
9
10
  import { triageWireEntry } from './triage-response.ts'
11
+ import { visibleManagedIssues } from './managed-issues.ts'
10
12
  import { MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
11
13
 
12
14
  type Finding = Record<string, unknown>
@@ -54,16 +56,17 @@ export class TeamReportsError extends Error {
54
56
  constructor(status: number, error: string) { super(error); this.status = status }
55
57
  }
56
58
  export function teamSnapshotKey(snapshot: TeamReportAccessSnapshot): string {
57
- return JSON.stringify([snapshot.user.id, snapshot.user.role, snapshot.teamId, snapshot.reports, snapshot.repositories])
59
+ return JSON.stringify([snapshot.user.id, snapshot.user.role, snapshot.teamId, snapshot.hiddenReportId ?? null, snapshot.reports, snapshot.repositories, snapshot.linkRevision])
58
60
  }
59
- export async function teamSnapshot(db: ManagedDb, sessionId: string, teamId: string): Promise<TeamReportAccessSnapshot> {
60
- const snapshot = await db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
61
+ export async function teamSnapshot(db: ManagedDb, sessionId: string, teamId: string, reportId: string | null = null): Promise<TeamReportAccessSnapshot> {
62
+ const snapshot = await db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, reportId)
61
63
  if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
62
64
  if (!snapshot.teamId) throw new TeamReportsError(404, 'no-team')
65
+ if (reportId !== null && !snapshot.reports.some(report => report.id === reportId)) throw new TeamReportsError(404, 'no-report')
63
66
  return snapshot
64
67
  }
65
68
  export async function recheckTeam(db: ManagedDb, sessionId: string, snapshot: TeamReportAccessSnapshot): Promise<void> {
66
- if (teamSnapshotKey(await teamSnapshot(db, sessionId, snapshot.teamId!)) !== teamSnapshotKey(snapshot)) throw new TeamReportsError(404, 'workspace-changed')
69
+ if (teamSnapshotKey(await teamSnapshot(db, sessionId, snapshot.teamId!, snapshot.reportId)) !== teamSnapshotKey(snapshot)) throw new TeamReportsError(404, 'workspace-changed')
67
70
  }
68
71
  function groupsOf(data: unknown): Finding[][] {
69
72
  return (reportEntries(data) ?? []).map(entry => (Array.isArray(entry) ? entry : [entry]).filter(
@@ -100,6 +103,7 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
100
103
  }
101
104
  }))
102
105
  for (const result of results) if (result.status === 'rejected') throw result.reason
106
+ const globalLinks = snapshot.linkRevision ? await db.getEnabledLinkGroups(snapshot.linkRevision) : []
103
107
  const edges = new Map<string, Set<string>>()
104
108
  const connect = (a: string, b: string) => {
105
109
  if (!edges.has(a)) edges.set(a, new Set())
@@ -107,10 +111,8 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
107
111
  }
108
112
  // A star is enough for transitive security propagation, without a quadratic
109
113
  // expansion for large links. Unknown IDs can connect known findings.
110
- for (const report of reports) {
111
- for (const ids of linksOf(report.data) ?? []) {
112
- for (const id of ids.slice(1)) { connect(ids[0]!, id); connect(id, ids[0]!) }
113
- }
114
+ for (const ids of globalLinks.concat(reports.flatMap(report => linksOf(report.data) ?? []))) {
115
+ for (const id of ids.slice(1)) { connect(ids[0]!, id); connect(id, ids[0]!) }
114
116
  }
115
117
  const groups = reports.flatMap(report => groupsOf(report.data).map(group => group.map(f => projectFinding(f, report.data as Finding)!)))
116
118
  stampSecurityGroups(groups, { linkedIds: id => edges.get(id) ?? [] })
@@ -130,7 +132,9 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
130
132
  }
131
133
  for (const report of reports) {
132
134
  const links = linksOf(report.data)
133
- if (links) report.data = { source: 'links', findings: [], links: links.map(ids => [...new Set(ids.filter(id => allIds.has(id)))]).filter(ids => ids.length >= 2) }
135
+ // Hidden previews are independently authorized; preserve a hidden links
136
+ // document even though its referenced findings are outside this response.
137
+ if (links && !snapshot.hiddenReportId) report.data = { source: 'links', findings: [], links: links.map(ids => [...new Set(ids.filter(id => allIds.has(id)))]).filter(ids => ids.length >= 2) }
134
138
  }
135
139
  let outputBytes = 14
136
140
  const parts = [Buffer.from('{"reports":[')]
@@ -141,7 +145,10 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
141
145
  if (index) parts.push(Buffer.from(','))
142
146
  parts.push(bytes)
143
147
  }
144
- parts.push(Buffer.from(']}'))
148
+ const links = mergeLinkGroups(globalLinks, allIds)
149
+ const suffix = Buffer.from(links.length > 0 ? `],"links":${JSON.stringify(links)}}` : ']}')
150
+ if (outputBytes + suffix.length > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
151
+ parts.push(suffix)
145
152
  const body = Buffer.concat(parts)
146
153
  retainVisibility(db, teamSnapshotKey(snapshot), visible)
147
154
  retainReports(db, teamSnapshotKey(snapshot), body)
@@ -190,13 +197,13 @@ export async function teamReportVisibility(db: ManagedDb, store: BlobStore, snap
190
197
  return visible.get(reportId)!
191
198
  }
192
199
  export async function teamFindingIds(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
193
- const snapshot = await teamSnapshot(db, sessionId, teamId)
200
+ const snapshot = await teamSnapshot(db, sessionId, teamId, reportId)
194
201
  const visible = await teamReportVisibility(db, store, snapshot, reportId)
195
202
  await recheckTeam(db, sessionId, snapshot)
196
203
  return visible.ids
197
204
  }
198
205
  export async function teamSourcePaths(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
199
- const snapshot = await teamSnapshot(db, sessionId, teamId)
206
+ const snapshot = await teamSnapshot(db, sessionId, teamId, reportId)
200
207
  const visible = await teamReportVisibility(db, store, snapshot, reportId)
201
208
  await recheckTeam(db, sessionId, snapshot)
202
209
  return visible.sourcePaths
@@ -211,9 +218,11 @@ export async function loadTeamAnnotations(db: ManagedDb, store: BlobStore, snaps
211
218
  : new Map([[reportId, await teamReportVisibility(db, store, snapshot, reportId)]])
212
219
  const ids = new Set<string>()
213
220
  for (const report of visible.values()) for (const id of report.ids) ids.add(id)
214
- const { triage, comments } = await db.getAnnotations([...ids])
221
+ const { triage, comments, issues: savedIssues } = await db.getAnnotations([...ids])
222
+ const issues = Object.fromEntries(visibleManagedIssues(savedIssues, snapshot)
223
+ .map(issue => [issue.findingId, { url: issue.issueUrl!, autoFix: issue.autoFixUrl }]))
215
224
  const entries = Object.fromEntries(triage.map(row => [row.findingId, triageWireEntry(row)]))
216
- const annotated = new Set([...triage.map(row => row.findingId), ...comments.map(comment => comment.findingId)])
225
+ const annotated = new Set([...triage.map(row => row.findingId), ...comments.map(comment => comment.findingId), ...Object.keys(issues)])
217
226
  const reports = Object.fromEntries([...visible].map(([id, report]) => [id, [...report.ids].filter(finding => annotated.has(finding))]))
218
- return { reports, entries, comments }
227
+ return { reports, entries, comments, issues }
219
228
  }
@@ -0,0 +1,73 @@
1
+ import type { CacheStore } from '@preventive/upstream/advisories.js'
2
+ import type { ManagedSql } from './sql.ts'
3
+
4
+ // Records @preventive/upstream would otherwise keep in its disk cache, shared
5
+ // by every instance: each repository's published advisory listing, written by
6
+ // dependency audits that ask repositories, keyed `github/advisories/owner/name`.
7
+ // Upstream stamps, validates and expires each value (90 minutes for a listing);
8
+ // a refresh replaces the row, and there is one row per repository, so rows are
9
+ // not evicted. Only public repositories publish advisories: rows are shared
10
+ // across viewers.
11
+ export const UPSTREAM_CACHE_SCHEMA = `
12
+ CREATE TABLE IF NOT EXISTS managed_upstream_cache (
13
+ cache_key TEXT PRIMARY KEY,
14
+ value TEXT NOT NULL,
15
+ cached_at INTEGER NOT NULL
16
+ ) STRICT;
17
+ `
18
+
19
+ export interface UpstreamCacheStore {
20
+ getUpstreamCacheEntry(key: string): Promise<string | null>
21
+ setUpstreamCacheEntry(key: string, value: string, cachedAt: number): Promise<void>
22
+ }
23
+
24
+ export function upstreamCacheMethods(db: ManagedSql): UpstreamCacheStore {
25
+ const get = db.prepare('SELECT value FROM managed_upstream_cache WHERE cache_key = ?')
26
+ // A slower concurrent audit cannot replace a newer listing with an older one.
27
+ const set = db.prepare(`INSERT INTO managed_upstream_cache (cache_key, value, cached_at) VALUES (?, ?, ?)
28
+ ON CONFLICT (cache_key) DO UPDATE SET value = excluded.value, cached_at = excluded.cached_at
29
+ WHERE managed_upstream_cache.cached_at <= excluded.cached_at`)
30
+ return {
31
+ async getUpstreamCacheEntry(key) {
32
+ const row = await get.get(key) as { value: string } | undefined
33
+ return row?.value ?? null
34
+ },
35
+ async setUpstreamCacheEntry(key, value, cachedAt) {
36
+ await set.run(key, value, cachedAt)
37
+ },
38
+ }
39
+ }
40
+
41
+ // An audit's `cache`: where the server has an upstream disk cache (off Vercel,
42
+ // set at startup), that keeps everything; else this store keeps listings.
43
+ export function auditCache(cacheDir: string | null | undefined, db: UpstreamCacheStore, signal: AbortSignal, debug = false): CacheStore | undefined {
44
+ return cacheDir ? undefined : upstreamCache(db, signal, debug)
45
+ }
46
+
47
+ // Only listings are kept: upstream's other records (npm version documents,
48
+ // registries' repository lookups) read as misses and are not written.
49
+ const CACHED_TYPE = 'github/advisories'
50
+
51
+ // The cache is an optimization: a database failure is a miss, never a failed
52
+ // audit. Upstream keeps listing after an abandoned audit; once `signal` aborts,
53
+ // the request's database work is over and the store is left alone.
54
+ export function upstreamCache(db: UpstreamCacheStore, signal: AbortSignal, debug = false): CacheStore {
55
+ return {
56
+ async read(type, key) {
57
+ if (type !== CACHED_TYPE || signal.aborted) return null
58
+ try {
59
+ const value = await db.getUpstreamCacheEntry(`${type}/${key}`)
60
+ return value === null ? null : JSON.parse(value) as unknown
61
+ } catch (error) {
62
+ if (debug) console.warn('managed: upstream cache read failed:', error)
63
+ return null
64
+ }
65
+ },
66
+ async write(type, key, value) {
67
+ if (type !== CACHED_TYPE || signal.aborted) return
68
+ try { await db.setUpstreamCacheEntry(`${type}/${key}`, JSON.stringify(value), Date.now()) } catch (error) {
69
+ if (debug) console.warn('managed: upstream cache write failed:', error)
70
+ }
71
+ },
72
+ }
73
+ }