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

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 (96) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/client/linked-findings.js +1 -1
  3. package/common/bundle-metadata.js +12 -10
  4. package/common/code-language.js +17 -2
  5. package/common/default-scan-models.ts +3 -3
  6. package/common/finding-id.js +1 -1
  7. package/common/managed/report-content.ts +1 -1
  8. package/common/managed/report-filter.ts +1 -1
  9. package/out/client-managed-import.js +24 -24
  10. package/out/client-managed.js +93 -68
  11. package/out/graph.js +11 -11
  12. package/out/index.html +1 -1
  13. package/out/prism.js +2 -2
  14. package/out/stasis.svg +0 -25
  15. package/out/terminal.js +163 -163
  16. package/out/view.css +1 -1
  17. package/out/view.js +223 -190
  18. package/package.json +29 -29
  19. package/server-common/storage-crypto.ts +182 -0
  20. package/server-common/storage-log.ts +5 -1
  21. package/server-common/vercel-blob.ts +6 -3
  22. package/server-e2e/static.ts +3 -4
  23. package/server-managed/avatar-store.ts +1 -40
  24. package/server-managed/blob-store.ts +3 -52
  25. package/server-managed/bundle-advisories.ts +53 -0
  26. package/server-managed/bundle-advisory-inventory.ts +75 -0
  27. package/server-managed/bundle-build-leases.ts +41 -0
  28. package/server-managed/bundle-build-worker.js +44 -0
  29. package/server-managed/bundle-build.ts +141 -0
  30. package/server-managed/bundle-cache.ts +22 -41
  31. package/server-managed/bundle-store.ts +3 -7
  32. package/server-managed/bundle-summary-cache.ts +27 -9
  33. package/server-managed/bundle.ts +66 -1
  34. package/server-managed/cache-storage.ts +4 -25
  35. package/server-managed/cli.js +10 -2
  36. package/server-managed/config.ts +12 -1
  37. package/server-managed/content-access.ts +1 -1
  38. package/server-managed/db-methods.ts +163 -40
  39. package/server-managed/db-neon.ts +124 -30
  40. package/server-managed/db-schema.ts +2 -0
  41. package/server-managed/db.ts +11 -1
  42. package/server-managed/github-app.ts +10 -15
  43. package/server-managed/github-errors.ts +21 -0
  44. package/server-managed/github-issues.ts +1 -1
  45. package/server-managed/github-metadata.ts +34 -1
  46. package/server-managed/github-pulls.ts +142 -30
  47. package/server-managed/http.ts +278 -167
  48. package/server-managed/index.ts +26 -9
  49. package/server-managed/management-catalog.ts +45 -0
  50. package/server-managed/object-storage-disk.ts +144 -0
  51. package/server-managed/object-storage-vercel.ts +60 -0
  52. package/server-managed/object-storage.ts +71 -0
  53. package/server-managed/public-workspace.ts +20 -14
  54. package/server-managed/report-migration.ts +1 -1
  55. package/server-managed/report-sources.ts +14 -4
  56. package/server-managed/repository-browser.ts +8 -1
  57. package/server-managed/repository-discovery.ts +1 -1
  58. package/server-managed/revisions.ts +35 -0
  59. package/server-managed/solidity-entry-points.ts +69 -0
  60. package/server-managed/sql-postgres.ts +1 -0
  61. package/server-managed/sql.ts +14 -8
  62. package/server-managed/storage-command.ts +16 -0
  63. package/server-managed/storage-db.ts +175 -0
  64. package/server-managed/storage-encryption.ts +211 -0
  65. package/server-managed/storage-maintenance.ts +197 -0
  66. package/server-managed/storage-payload.ts +72 -0
  67. package/server-managed/storage-stores.ts +81 -0
  68. package/server-managed/storage.ts +48 -24
  69. package/server-managed/team-feed.ts +50 -5
  70. package/server-managed/team-reports.ts +85 -22
  71. package/server-managed/uploads.ts +2 -2
  72. package/server-managed/workspace-shares.ts +15 -3
  73. package/report/index.js +0 -260
  74. package/report/src/finding-id.js +0 -80
  75. package/report/src/finding.js +0 -314
  76. package/report/src/labels.js +0 -35
  77. package/report/src/md-structure.js +0 -471
  78. package/report/src/md-text.js +0 -167
  79. package/report/src/meta.js +0 -76
  80. package/report/src/parse-codex.js +0 -149
  81. package/report/src/parse-deepsec.js +0 -199
  82. package/report/src/parse-deepview-fields.js +0 -375
  83. package/report/src/parse-deepview-md.js +0 -186
  84. package/report/src/parse-md-id.js +0 -137
  85. package/report/src/parse-md.js +0 -323
  86. package/report/src/parse-piolium-id.js +0 -79
  87. package/report/src/parse-piolium-rows.js +0 -126
  88. package/report/src/parse-piolium-tokens.js +0 -185
  89. package/report/src/parse-piolium.js +0 -399
  90. package/report/src/security.js +0 -63
  91. package/report/src/severity.js +0 -18
  92. package/report/src/utf8.js +0 -21
  93. package/report/src/write-md-finding.js +0 -273
  94. package/report/src/write-md.js +0 -291
  95. package/server-managed/blob-vercel.ts +0 -127
  96. /package/{LICENSE → LICENSE-MIT} +0 -0
@@ -0,0 +1,197 @@
1
+ import { type StorageKey, decryptStorageStream, encryptStorageStream } from '../server-common/storage-crypto.ts'
2
+ 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 { STORAGE_UPLOAD_TTL_MS } from './storage-encryption.ts'
5
+ import { StoragePayloadError, inspectStorageObject, storageOwner, storageRowPath, verifyStoragePayload } from './storage-payload.ts'
6
+
7
+ async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey, item: StorageMigrationRow, signal: AbortSignal): Promise<string | void> {
8
+ if (item.type === 'user') { await db.migrateStorageUserTokens(item.id); return }
9
+ const row = await db.ensureStorageDataKey(item.type, item.id)
10
+ if (!row || row.encrypted) return
11
+ const dataKey = unwrapDataKey(key, item.type, row), identity = storageRowPath(item.type, row)
12
+ try {
13
+ const source = await raw.open(identity, signal)
14
+ if (!source) {
15
+ if (!await db.getStorageRow(item.type, item.id)) return
16
+ throw new StoragePayloadError('Migration payload unavailable')
17
+ }
18
+ let inspected = await inspectStorageObject(source)
19
+ if (inspected.encrypted) {
20
+ // A previous PUT may have completed before a timeout or lost SQL ack.
21
+ try {
22
+ const decoded = await decryptStorageStream(inspected.stream, dataKey, identity)
23
+ await verifyStoragePayload(item.type, row, decoded.stream, signal)
24
+ } catch {
25
+ signal.throwIfAborted()
26
+ // Legacy arbitrary bytes can share the magic prefix. A matching
27
+ // original upload hash is required before encrypting them as plaintext.
28
+ const legacy = await openObjectVersion(raw, identity, source.version, signal)
29
+ if (!legacy) return 'Payload disappeared or changed version before plaintext verification'
30
+ inspected = { ...legacy, encrypted: false }
31
+ }
32
+ }
33
+ if (!inspected.encrypted) {
34
+ await verifyStoragePayload(item.type, row, inspected.stream, signal)
35
+ signal.throwIfAborted()
36
+ const current = await openObjectVersion(raw, identity, source.version, signal)
37
+ if (!current) return 'Payload disappeared or changed version before encryption'
38
+ const encrypted = encryptStorageStream(current.stream, dataKey, identity, current.size)
39
+ let replaced: boolean
40
+ try { replaced = await raw.put(identity, encrypted, signal, source.version, current.size ?? undefined) }
41
+ finally { encrypted.destroy(); current.stream.destroy() }
42
+ if (!replaced) return 'Conditional replacement rejected (object version precondition failed)'
43
+ // Do not mark the row encrypted until the stored bytes authenticate and
44
+ // match the original upload (including decompression for sourcemaps).
45
+ const replacement = await raw.open(identity, signal)
46
+ if (!replacement) {
47
+ if (!await db.getStorageRow(item.type, item.id)) return
48
+ throw new StoragePayloadError('Migrated payload unavailable')
49
+ }
50
+ const decoded = await decryptStorageStream(replacement.stream, dataKey, identity)
51
+ await verifyStoragePayload(item.type, row, decoded.stream, signal)
52
+ }
53
+ signal.throwIfAborted()
54
+ // Another process may have renamed ciphertext but stopped before syncing
55
+ // the directory. Persist the observed rename even on the resume path.
56
+ await raw.sync?.(identity, signal)
57
+ await db.markStorageEncrypted(item.type, item.id, row.dataKey!)
58
+ } finally { dataKey.fill(0) }
59
+ }
60
+
61
+ const CLEANUP_PREFIXES = ['cache/', 'reports/', 'bundles/', ENCRYPTED_CACHE_PREFIX]
62
+ function cleanupPosition(state: StorageEncryptionState): { prefix: number; cursor: string | null } {
63
+ const value = state.cleanupCursor === null ? { prefix: 0, cursor: null } : JSON.parse(state.cleanupCursor)
64
+ if (!Number.isInteger(value.prefix) || value.prefix < 0 || value.prefix >= CLEANUP_PREFIXES.length
65
+ || (value.cursor !== null && typeof value.cursor !== 'string')) throw new Error('Invalid legacy cleanup cursor')
66
+ return value
67
+ }
68
+
69
+ // The database directory may be shared. Recognize managed paths, including
70
+ // disk atomic-write temps, before treating any listed file as ours to remove.
71
+ function cleanupTarget(identity: string) {
72
+ const temp = /^(.+)\.([^.]+)\.tmp$/u.exec(identity)
73
+ const temporary = !!temp && isBlobId(temp[2]!)
74
+ const path = temporary ? temp![1]! : identity
75
+ const encryptedCache = path.startsWith(ENCRYPTED_CACHE_PREFIX)
76
+ if (encryptedCache && !temporary) return null
77
+ const owner = storageOwner(encryptedCache ? `cache/${path.slice(ENCRYPTED_CACHE_PREFIX.length)}` : path)
78
+ return owner ? { owner, temporary } : null
79
+ }
80
+
81
+ // Referenced-data migration never depends on Blob listings. A separate bounded
82
+ // inventory removes legacy caches and unreferenced plaintext; ciphertext
83
+ // orphans do not hold a recoverable data key and need no manifest-based GC.
84
+ async function cleanupLegacy(raw: RawObjectStorage, db: StorageDb, state: StorageEncryptionState, limit: number, signal: AbortSignal) {
85
+ const position = cleanupPosition(state), prefix = CLEANUP_PREFIXES[position.prefix]!
86
+ const page = await raw.list(prefix, position.cursor, Math.min(limit, 16), signal)
87
+ let found = 0, retryAt: number | null = null
88
+ for (const object of page.objects) {
89
+ signal.throwIfAborted()
90
+ const target = cleanupTarget(object.key)
91
+ if (!target) continue
92
+ const { owner, temporary } = target
93
+ if (!temporary && !owner.cache) {
94
+ const row = await db.getStorageRow(owner.type, owner.id)
95
+ if (row && storageRowPath(owner.type, row) === object.key) continue
96
+ }
97
+ const stored = await raw.head(object.key, signal)
98
+ if (!stored) continue
99
+ // A killed disk writer can leave plaintext or ciphertext in its temp file.
100
+ // Recheck its current age and give live writes a full staging grace window.
101
+ if (temporary && stored.modifiedAt > Date.now() - STORAGE_UPLOAD_TTL_MS) {
102
+ found++
103
+ retryAt = Math.min(retryAt ?? Infinity, stored.modifiedAt + STORAGE_UPLOAD_TTL_MS)
104
+ continue
105
+ }
106
+ // Old pre-activation orphans are legacy even when their arbitrary bytes
107
+ // share the magic prefix. Keep a full staging grace window around enable:
108
+ // Blob Last-Modified has only second precision, and a new encrypted upload
109
+ // may still be waiting for its SQL insert.
110
+ let legacy = temporary || owner.cache || stored.modifiedAt < state.enabledAt - STORAGE_UPLOAD_TTL_MS
111
+ if (!legacy) {
112
+ const source = await raw.open(object.key, signal)
113
+ if (!source) continue
114
+ try {
115
+ if (source.version !== stored.version) throw new Error('Legacy object changed during cleanup')
116
+ const inspected = await inspectStorageObject(source)
117
+ legacy = !inspected.encrypted
118
+ inspected.stream.destroy()
119
+ } finally { source.stream.destroy() }
120
+ }
121
+ if (legacy) {
122
+ found++
123
+ if (!await raw.delete(object.key, stored.version, signal)) throw new Error('Legacy object changed during cleanup')
124
+ }
125
+ }
126
+ const next = page.cursor === null ? position.prefix + 1 < CLEANUP_PREFIXES.length ? { prefix: position.prefix + 1, cursor: null } : null
127
+ : { ...position, cursor: page.cursor }
128
+ // A full pass without removals closes offset-pagination gaps left by deletes.
129
+ await db.advanceStorageCleanup(state.cleanupCursor, next === null ? null : JSON.stringify(next), found)
130
+ return { processed: page.objects.length || 1, retryAt }
131
+ }
132
+
133
+ interface StorageMigrationResult extends StorageEncryptionState {
134
+ retryAt: number | null
135
+ failures: { type: StorageMigrationRow['type']; id: string; message: string }[]
136
+ }
137
+ interface MigrationOptions { maxObjects?: number; maxMs?: number | undefined; signal?: AbortSignal | undefined }
138
+ export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
139
+ { maxObjects = 64, maxMs = 150_000, signal: stopping }: MigrationOptions = {}): Promise<StorageMigrationResult> {
140
+ if (!Number.isSafeInteger(maxObjects) || maxObjects < 1 || !Number.isSafeInteger(maxMs) || maxMs < 1) throw new Error('Invalid migration budget')
141
+ const deadline = Date.now() + maxMs, timeout = AbortSignal.timeout(maxMs)
142
+ const signal = stopping ? AbortSignal.any([stopping, timeout]) : timeout
143
+ const errors: unknown[] = []
144
+ const failures: StorageMigrationResult['failures'] = []
145
+ let processed = 0, retryAt: number | null = null, state = await db.getStorageEncryption()
146
+ if (!state) throw new Error('Storage encryption is not enabled; configure MANAGED_STORAGE_ENCRYPTION_KEY')
147
+ // At most one SQL pass per invocation. Checkpoint each row, including a
148
+ // failure: other rows progress, and the failed row is retried next pass.
149
+ while (!state.complete && processed < maxObjects && Date.now() < deadline && !signal.aborted) {
150
+ const rows = await db.listStorageMigrationRows(state.cursor, Math.min(16, maxObjects - processed))
151
+ if (stopping?.aborted) break
152
+ if (rows.length === 0) { await db.advanceStorageMigration(state.cursor, null); break }
153
+ for (const row of rows) {
154
+ if (Date.now() >= deadline || signal.aborted) break
155
+ try {
156
+ const pending = await migrateRow(raw, db, key, row, signal)
157
+ if (pending) failures.push({ type: row.type, id: row.id, message: pending })
158
+ }
159
+ catch (err) {
160
+ // A row started late gets a full budget next time. Skip an oversized
161
+ // first row until the next pass so later rows and tokens can advance.
162
+ if (stopping?.aborted || (signal.aborted && processed > 0)) break
163
+ const failure = signal.aborted
164
+ ? `Could not migrate ${row.type} ${row.id} within MANAGED_STORAGE_ENCRYPTION_MIGRATE_MAX_MS=${maxMs}; increase the budget`
165
+ : err instanceof StoragePayloadError ? err.message : null
166
+ if (failure === null) errors.push(new Error(`Could not migrate ${row.type} ${row.id}`, { cause: err }))
167
+ else failures.push({ type: row.type, id: row.id, message: failure })
168
+ }
169
+ if (stopping?.aborted) break
170
+ await db.advanceStorageMigration(state.cursor, row.position)
171
+ processed++
172
+ state = (await db.getStorageEncryption())!
173
+ }
174
+ }
175
+ state = (await db.getStorageEncryption())!
176
+ while (!state.cleanupComplete && processed < maxObjects && Date.now() < deadline && !signal.aborted) {
177
+ try {
178
+ const cleanup = await cleanupLegacy(raw, db, state, maxObjects - processed, signal)
179
+ processed += cleanup.processed
180
+ retryAt = cleanup.retryAt
181
+ }
182
+ catch (err) { if (!signal.aborted) errors.push(err); break }
183
+ state = (await db.getStorageEncryption())!
184
+ if (retryAt !== null) break // Wait for staging grace rather than spinning over live temp files.
185
+ }
186
+ if (errors.length > 0) throw new AggregateError(errors, 'Storage migration has pending failures', { cause: errors[0] })
187
+ return { ...state, retryAt, failures }
188
+ }
189
+
190
+ export async function reapStorageUploads(raw: RawObjectStorage, db: StorageDb, now = Date.now(), stopping?: AbortSignal): Promise<number> {
191
+ if (stopping?.aborted) return 0
192
+ const timeout = AbortSignal.timeout(150_000)
193
+ const signal = stopping ? AbortSignal.any([stopping, timeout]) : timeout
194
+ await db.getStorageEncryption()
195
+ try { return await deleteObjects(raw, 'uploads/', signal, now - STORAGE_UPLOAD_TTL_MS) }
196
+ catch (err) { if (stopping?.aborted) return 0; throw err }
197
+ }
@@ -0,0 +1,72 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { createHash } from 'node:crypto'
3
+ import { Readable, Writable } from 'node:stream'
4
+ import { pipeline } from 'node:stream/promises'
5
+ import { createBrotliDecompress } from 'node:zlib'
6
+ import { STORAGE_MAGIC } from '../server-common/storage-crypto.ts'
7
+ import { type RawObject, isBlobId } from './object-storage.ts'
8
+ import type { StorageRow, StorageRowKind } from './storage-db.ts'
9
+
10
+ // A damaged/missing payload needs repair; retrying the whole cleanup job does
11
+ // not fix it. Keep this distinct from database, storage and cancellation errors.
12
+ export class StoragePayloadError extends Error {}
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' : ''}`
16
+ }
17
+ export function storageOwner(identity: string): { type: StorageRowKind; id: string; cache: boolean } | null {
18
+ const match = /^(reports|bundles)\/([^/]+)$/u.exec(identity)
19
+ if (match) {
20
+ const id = match[1] === 'bundles' ? match[2]!.replace(/\.map\.br$/u, '') : match[2]!
21
+ if (isBlobId(id)) return { type: match[1] === 'reports' ? 'report' : 'bundle', id, cache: false }
22
+ }
23
+ const cache = /^cache\/(?:bundles|report-sources)\/([a-f\d-]{36})\//u.exec(identity)
24
+ return cache && isBlobId(cache[1]!) ? { type: 'bundle', id: cache[1]!, cache: true } : null
25
+ }
26
+
27
+ // Peek without buffering the object or consuming bytes from its next reader.
28
+ export async function inspectStorageObject(stored: RawObject): Promise<RawObject & { encrypted: boolean }> {
29
+ const original = stored.stream
30
+ const iterator = original[Symbol.asyncIterator]()
31
+ const first: Buffer[] = [], prefix = Buffer.alloc(STORAGE_MAGIC.length)
32
+ let length = 0
33
+ try {
34
+ while (length < prefix.length) {
35
+ const next = await iterator.next()
36
+ if (next.done) break
37
+ const bytes = Buffer.from(next.value)
38
+ first.push(bytes)
39
+ length += bytes.copy(prefix, length, 0, prefix.length - length)
40
+ }
41
+ } catch (err) { original.destroy(); throw err }
42
+ async function* joined() {
43
+ try {
44
+ yield* first
45
+ for (;;) { const next = await iterator.next(); if (next.done) return; yield next.value }
46
+ } finally { await iterator.return?.(); original.destroy() }
47
+ }
48
+ const stream = Readable.from(joined(), { objectMode: false })
49
+ stream.once('close', () => original.destroy())
50
+ return { ...stored, stream, encrypted: length === prefix.length && prefix.equals(STORAGE_MAGIC) }
51
+ }
52
+
53
+ // Sourcemap integrity describes the original upload, before Brotli storage.
54
+ // Hash incrementally, including decompression, so migration stays streaming.
55
+ export async function verifyStoragePayload(type: StorageRowKind, row: StorageRow, source: Readable, signal?: AbortSignal): Promise<void> {
56
+ const hash = createHash(type === 'report' ? 'sha256' : 'sha512')
57
+ const sink = new Writable({ write(chunk, _encoding, next) { hash.update(chunk); next() } })
58
+ const options = signal ? { signal } : {}
59
+ try {
60
+ if (type === 'bundle' && row.kind === 'sourcemap') {
61
+ await pipeline(source, createBrotliDecompress(), sink, options)
62
+ } else await pipeline(source, sink, options)
63
+ } catch (err) {
64
+ const code = (err as NodeJS.ErrnoException).code ?? ''
65
+ 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
+ }
68
+ throw err
69
+ }
70
+ const actual = type === 'report' ? hash.digest('base64url') : `sha512-${hash.digest('base64')}`
71
+ if (actual !== row.hash) throw new StoragePayloadError('Stored payload does not match its upload hash')
72
+ }
@@ -0,0 +1,81 @@
1
+ // Logical stores share one encryption boundary. Compression, identifiers and
2
+ // public response bytes stay the same with either physical backend.
3
+ import { Buffer } from 'node:buffer'
4
+ import type { AvatarStore } from './avatar-store.ts'
5
+ import type { BlobStore } from './blob-store.ts'
6
+ import type { BundleCacheStorage } from './bundle-cache.ts'
7
+ import { createBundleStore } from './bundle-store.ts'
8
+ import { CacheMissError, type CacheStorage, validateCacheKey } from './cache-storage.ts'
9
+ import { type ObjectStorage, isBlobId } from './object-storage.ts'
10
+
11
+ function id(value: string): string {
12
+ if (!isBlobId(value)) throw new Error('Invalid managed blob id')
13
+ return value
14
+ }
15
+
16
+ function blobStore(objects: ObjectStorage, name: string, suffix = ''): BlobStore {
17
+ const path = (value: string) => `${name}/${id(value)}${suffix}`
18
+ return {
19
+ put: (value, bytes) => objects.put(path(value), bytes),
20
+ get: value => objects.get(path(value)),
21
+ open: value => objects.open(path(value)),
22
+ delete: value => objects.delete(path(value)),
23
+ }
24
+ }
25
+
26
+ function cacheStore(objects: ObjectStorage, name: string): CacheStorage {
27
+ const path = (key: string) => `cache/${name}/${validateCacheKey(key)}`
28
+ return {
29
+ exists: key => objects.exists(path(key)),
30
+ put: async (key, bytes) => { await objects.put(path(key), bytes) },
31
+ async open(key) {
32
+ const value = await objects.open(path(key))
33
+ if (!value) throw new CacheMissError()
34
+ return value
35
+ },
36
+ delete: prefix => objects.deletePrefix(`${path(prefix)}/`),
37
+ }
38
+ }
39
+
40
+ function avatarStore(objects: ObjectStorage, sidecar: boolean): AvatarStore {
41
+ return {
42
+ async put(value, contentType, bytes) {
43
+ const path = `avatars/${id(value)}`, type = contentType.split(';', 1)[0]!.trim()
44
+ if (!/^image\/[a-z0-9.+-]+$/iu.test(type)) throw new Error('Invalid avatar type')
45
+ if (sidecar) {
46
+ await objects.put(path, bytes)
47
+ await objects.put(`${path}.type`, Buffer.from(type))
48
+ } else await objects.put(path, Buffer.concat([Buffer.from(`${type}\n`), bytes]))
49
+ },
50
+ async get(value) {
51
+ const path = `avatars/${id(value)}`
52
+ const bytes = await objects.get(path)
53
+ if (!bytes) return null
54
+ if (sidecar) {
55
+ const type = await objects.get(`${path}.type`)
56
+ return type ? { bytes, contentType: type.toString().trim() || 'application/octet-stream' } : null
57
+ }
58
+ const split = bytes.indexOf(10)
59
+ if (split < 0 || split > 100) throw new Error('Invalid cached avatar')
60
+ return { contentType: bytes.subarray(0, split).toString(), bytes: bytes.subarray(split + 1) }
61
+ },
62
+ }
63
+ }
64
+
65
+ export function createManagedStores(objects: ObjectStorage, avatarSidecar: boolean) {
66
+ const bundles = cacheStore(objects, 'bundles')
67
+ const cacheStorage: BundleCacheStorage = {
68
+ exists: (value, file) => bundles.exists(`${id(value)}/${file}`),
69
+ put: (value, file, bytes) => bundles.put(`${id(value)}/${file}`, bytes),
70
+ open: (value, file) => bundles.open(`${id(value)}/${file}`),
71
+ delete: value => bundles.delete(id(value)),
72
+ }
73
+ return {
74
+ reportStore: blobStore(objects, 'reports'),
75
+ bundleStore: createBundleStore(blobStore(objects, 'bundles'), blobStore(objects, 'bundles', '.map.br')),
76
+ uploadStore: blobStore(objects, 'uploads'),
77
+ avatarStore: avatarStore(objects, avatarSidecar),
78
+ cacheStorage,
79
+ reportSourcesStorage: cacheStore(objects, 'report-sources'),
80
+ }
81
+ }
@@ -1,34 +1,58 @@
1
1
  // Backend selection is shared by standalone, combined and function entrypoints.
2
- import { dirname, join } from 'node:path'
3
- import { createDiskAvatarStore } from './avatar-store.ts'
4
- import { createDiskBlobStore } from './blob-store.ts'
5
- import { createDiskBundleStore } from './bundle-store.ts'
6
- import { createBundleCache, createDiskBundleCache } from './bundle-cache.ts'
7
- import { createDiskCacheStorage } from './cache-storage.ts'
2
+ import { dirname } from 'node:path'
3
+ import { parseStorageKey } from '../server-common/storage-crypto.ts'
4
+ import { createBundleCache } from './bundle-cache.ts'
8
5
  import { createReportSourcesCache } from './report-sources.ts'
9
- import { openManagedVercelStorage } from './blob-vercel.ts'
10
6
  import { openNeonManagedDb } from './db-neon.ts'
11
7
  import type { ManagedConfig } from './config.ts'
8
+ import { createEncryptedObjectStorage } from './storage-encryption.ts'
9
+ import { createManagedStores } from './storage-stores.ts'
10
+ import { createDiskObjectStorage } from './object-storage-disk.ts'
11
+ import { openVercelObjectStorage } from './object-storage-vercel.ts'
12
+ import { migrateStorage, reapStorageUploads } from './storage-maintenance.ts'
13
+
14
+ // Database-only callers (notably status) must not activate byte encryption.
15
+ export async function openManagedStorageDb(config: ManagedConfig) {
16
+ const options = { triageHistoryLimit: config.triageHistoryLimit, storageEncryptionKey: parseStorageKey(config.storageEncryptionKey) }
17
+ return config.neonUrl ? openNeonManagedDb(config.neonUrl, options)
18
+ : (await import('./db.ts')).openSqliteManagedDb(config.dbPath, options)
19
+ }
12
20
 
13
21
  export async function openManagedStorage(config: ManagedConfig) {
14
- const options = { triageHistoryLimit: config.triageHistoryLimit }
15
- if (config.neonUrl) {
16
- if (!config.blobToken) throw new Error('Managed Neon mode requires BLOB_READ_WRITE_TOKEN')
17
- const storage = await openManagedVercelStorage(config.blobToken)
18
- const db = await openNeonManagedDb(config.neonUrl, options)
19
- return { ...storage, db, bundleCache: createBundleCache(storage.cacheStorage, db, storage.bundleStore),
22
+ const key = parseStorageKey(config.storageEncryptionKey)
23
+ if (config.storageEncryptionMigrate && !key) throw new Error('MANAGED_STORAGE_ENCRYPTION_MIGRATE requires MANAGED_STORAGE_ENCRYPTION_KEY')
24
+ if (config.neonUrl && !config.blobToken) throw new Error('Managed Neon mode requires BLOB_READ_WRITE_TOKEN')
25
+ if (config.serverless && !config.neonUrl) throw new Error('Serverless managed storage requires Neon')
26
+ const db = await openManagedStorageDb(config)
27
+ try {
28
+ const raw = config.neonUrl ? await openVercelObjectStorage(config.blobToken!) : createDiskObjectStorage(dirname(config.dbPath))
29
+ // Enable before exposing stores or token writers. Concurrent first starts
30
+ // serialize activation in the database and validate the winning key.
31
+ if (key && !await db.getStorageEncryption()) {
32
+ if (config.vercelPreview) throw new Error('Storage encryption must be enabled outside a Vercel preview deployment')
33
+ await db.enableStorageEncryption()
34
+ }
35
+ const objects = await createEncryptedObjectStorage(raw, db, key)
36
+ const storage = createManagedStores(objects, !config.neonUrl)
37
+ return { ...storage, db, uploadStore: config.neonUrl ? storage.uploadStore : undefined,
38
+ bundleCache: createBundleCache(storage.cacheStorage, db, storage.bundleStore),
20
39
  reportSourcesCache: createReportSourcesCache(storage.reportSourcesStorage, db, storage.reportStore, storage.bundleStore),
40
+ async reapStorage(signal?: AbortSignal) {
41
+ if (!config.storageEncryptionMigrate || signal?.aborted) return
42
+ const state = await db.getStorageEncryption()
43
+ if (state?.complete && state.cleanupComplete) return
44
+ const result = await migrateStorage(raw, db, key!, { maxMs: config.storageEncryptionMigrateMaxMs, signal })
45
+ for (const failure of result.failures) console.warn('managed-storage-migration-row:', JSON.stringify(failure))
46
+ console.info('managed-storage-migration:', JSON.stringify({
47
+ complete: result.complete === 1, cleanupComplete: result.cleanupComplete === 1,
48
+ migrated: result.migrated, cursor: result.cursor, retryAt: result.retryAt,
49
+ failed: result.failures.length, cancelled: signal?.aborted === true,
50
+ }))
51
+ },
52
+ ...(config.neonUrl ? { reapUploads: (signal?: AbortSignal) => reapStorageUploads(raw, db, Date.now(), signal) } : {}),
21
53
  }
22
- }
23
- if (config.serverless) throw new Error('Serverless managed storage requires Neon')
24
- const { openSqliteManagedDb } = await import('./db.ts')
25
- const db = openSqliteManagedDb(config.dbPath, options)
26
- const dir = dirname(config.dbPath)
27
- const reportStore = createDiskBlobStore(join(dir, 'reports'))
28
- const bundleStore = createDiskBundleStore(join(dir, 'bundles'))
29
- return { db, reportStore, bundleStore,
30
- reportSourcesCache: createReportSourcesCache(createDiskCacheStorage(join(dir, 'cache', 'report-sources')), db, reportStore, bundleStore),
31
- avatarStore: createDiskAvatarStore(join(dir, 'avatars')),
32
- bundleCache: createDiskBundleCache(join(dir, 'cache', 'bundles'), db, bundleStore),
54
+ } catch (err) {
55
+ await db.close()
56
+ throw err
33
57
  }
34
58
  }
@@ -3,6 +3,7 @@ import { setTimeout as delay } from 'node:timers/promises'
3
3
  import type { ManagedHttpDeps } from './http.ts'
4
4
  import type { StoredUser, TeamReportAccessSnapshot } from './db.ts'
5
5
  import { TeamReportsError, teamSnapshotKey, teamWorkspaceFindingIds } from './team-reports.ts'
6
+ import type { WorkspaceShareFeedState } from './workspace-shares.ts'
6
7
 
7
8
  // Short database reads work across instances without pinning a Neon connection.
8
9
  // All polling belongs to the awaited request; nothing runs after it completes.
@@ -15,14 +16,30 @@ type Publish = (event: 'teams' | 'triage', revision: string | undefined) => bool
15
16
 
16
17
  // Public capabilities remain scoped to their single shared workspace.
17
18
  export async function serveTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
18
- snapshot: TeamReportAccessSnapshot, recheck: () => Promise<void>, options: FeedOptions = {}): Promise<void> {
19
+ snapshot: TeamReportAccessSnapshot, recheck: () => Promise<void>,
20
+ options: FeedOptions & { readState?: () => Promise<WorkspaceShareFeedState | null> } = {}): Promise<void> {
19
21
  let ids: string[] | undefined
22
+ let previous: WorkspaceShareFeedState | undefined
23
+ const sameAccess = (a: WorkspaceShareFeedState, b?: WorkspaceShareFeedState) => a.grant === b?.grant && a.catalog === b.catalog
24
+ const readState = async () => {
25
+ const state = await options.readState!()
26
+ if (!state) throw new TeamReportsError(404, 'workspace-changed')
27
+ return state
28
+ }
20
29
  await serveFeed(res, deps, async publish => {
21
- await recheck()
30
+ const state = options.readState ? await readState() : undefined
31
+ // The first poll still compares the full snapshot loaded before the stream.
32
+ // Unchanged polls validate the live grant without reading report catalogs.
33
+ if (state && sameAccess(state, previous)) {
34
+ if (state.annotations === previous!.annotations) return
35
+ } else await recheck()
22
36
  ids ??= [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
23
37
  const triage = await deps.db.getAnnotationRevision(ids)
24
- await recheck()
38
+ const after = options.readState ? await readState() : undefined
39
+ if (!after || !sameAccess(after, state)) await recheck()
25
40
  publish('triage', triage)
41
+ // A write during the read must remain pending for the following poll.
42
+ if (after?.annotations === state?.annotations) previous = after
26
43
  }, options)
27
44
  }
28
45
 
@@ -32,6 +49,8 @@ export async function serveTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
32
49
  export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
33
50
  sessionId: string, user: StoredUser, teamId: string | null, options: FeedOptions = {}): Promise<void> {
34
51
  let ids: string[] = [], visibilityKey: string | undefined
52
+ let focusedKey: string | undefined
53
+ let previousState: { catalog: number; annotations: number } | undefined
35
54
  const checkUser = (current: { user: Pick<StoredUser, 'id' | 'role'> } | null) => {
36
55
  if (!current) throw new TeamReportsError(401, 'unauthenticated')
37
56
  if (current.user.id !== user.id || current.user.role !== user.role || current.user.role === 'none') {
@@ -39,12 +58,33 @@ export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDe
39
58
  }
40
59
  }
41
60
  await serveFeed(res, deps, async publish => {
61
+ const state = await deps.db.getFeedState(sessionId, Date.now())
62
+ checkUser(state)
63
+ if (state!.catalog === previousState?.catalog && (!teamId || state!.annotations === previousState?.annotations)) return
64
+ if (teamId && focusedKey !== undefined && state!.catalog === previousState?.catalog) {
65
+ // Annotation changes cannot change visibility. Read only this workspace's
66
+ // IDs, then fence against concurrent access/content changes before sending.
67
+ const triage = JSON.stringify([focusedKey, await deps.db.getAnnotationRevision(ids)])
68
+ const after = await deps.db.getFeedState(sessionId, Date.now())
69
+ checkUser(after)
70
+ if (after!.catalog !== state!.catalog) return
71
+ publish('triage', triage)
72
+ if (after!.annotations === state!.annotations) previousState = state!
73
+ return
74
+ }
42
75
  const catalog = await deps.db.getUserTeamFeedSnapshot(sessionId, Date.now())
43
76
  checkUser(catalog)
44
77
  checkUser(await deps.db.getReportAccessSnapshot(sessionId, Date.now(), []))
45
78
  // Publish catalog state before loading any report blobs. A slow or broken
46
79
  // focused report must not hide membership/content changes from the client.
47
- if (!publish('teams', catalog!.revision) || !teamId) return
80
+ if (!publish('teams', catalog!.revision)) return
81
+ if (!teamId) {
82
+ // A mutation during the snapshot must cause a fresh read next poll.
83
+ const current = await deps.db.getFeedState(sessionId, Date.now())
84
+ checkUser(current)
85
+ if (current!.catalog === state!.catalog) previousState = state!
86
+ return
87
+ }
48
88
  let triage: string | undefined
49
89
  const snapshot = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
50
90
  if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
@@ -67,6 +107,10 @@ export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDe
67
107
  checkUser(current)
68
108
  if (!current || teamSnapshotKey(current) !== key) return
69
109
  publish('triage', triage)
110
+ focusedKey = snapshot.teamId ? key : undefined
111
+ const after = await deps.db.getFeedState(sessionId, Date.now())
112
+ checkUser(after)
113
+ if (after!.catalog === state!.catalog && after!.annotations === state!.annotations) previousState = state!
70
114
  }, options)
71
115
  }
72
116
 
@@ -106,7 +150,8 @@ async function serveFeed(res: ServerResponse, deps: ManagedHttpDeps, read: (publ
106
150
  return true
107
151
  }
108
152
  while (!stopped()) {
109
- await read(publish)
153
+ if (deps.db.withRequest) await deps.db.withRequest(() => read(publish))
154
+ else await read(publish)
110
155
  if (stopped()) break
111
156
  if (Date.now() - heartbeat >= HEARTBEAT_MS) {
112
157
  if (!write(': keepalive\n\n')) break