@preventive/triage 1.0.0-alpha.3 → 1.0.0-alpha.31
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/LICENSE-APACHE +202 -0
- package/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +111 -0
- package/common/bundle-commit.d.ts +1 -0
- package/common/bundle-commit.js +4 -0
- package/common/bundle-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +182 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-source-links.d.ts +5 -0
- package/common/bundle-source-links.js +82 -0
- package/common/bundle-sources.d.ts +3 -0
- package/common/bundle-sources.js +284 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +51 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -0
- package/common/github-issue-labels.d.ts +1 -0
- package/common/github-issue-labels.js +11 -0
- package/common/github-pr.ts +56 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/permissions.ts +35 -0
- package/common/managed/report-content.ts +42 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +124 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/triage.ts +83 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +35 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/out/brotli-fallback.js +3 -3
- package/out/client-managed-import.js +83 -0
- package/out/client-managed.js +169 -0
- package/out/client-sync.js +16 -13
- package/out/github-issue-authorized.html +3 -0
- package/out/github-issue-authorized.js +1 -0
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +20 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +259 -63
- package/package.json +205 -57
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +38 -0
- package/server-common/vercel-blob.ts +103 -0
- package/server-e2e/app.ts +486 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +2 -1
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +56 -35
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +1 -1
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +7 -7
- package/{server → server-e2e}/http.ts +110 -12
- package/{server → server-e2e}/hub.ts +40 -9
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +11 -144
- package/{server → server-e2e}/objstore/blob-fs.ts +2 -2
- package/{server → server-e2e}/objstore/blob-vercel.ts +13 -117
- package/{server → server-e2e}/objstore/blob.ts +9 -2
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +9 -8
- package/{server → server-e2e}/objstore/init.ts +35 -18
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +58 -33
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +6 -10
- package/{server → server-e2e}/objstore/store.ts +7 -7
- package/{server → server-e2e}/objstore/tokens.ts +1 -1
- package/{server → server-e2e}/pubsub.ts +12 -9
- package/{server → server-e2e}/sign.ts +4 -4
- package/{server → server-e2e}/sse-server.ts +102 -68
- package/{server → server-e2e}/sse-session.ts +28 -8
- package/{server → server-e2e}/static.ts +31 -21
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +20 -9
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +12 -0
- package/server-managed/blob-store.ts +17 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-advisories.ts +53 -0
- package/server-managed/bundle-advisory-inventory.ts +75 -0
- package/server-managed/bundle-build-diagnostics.d.ts +10 -0
- package/server-managed/bundle-build-diagnostics.js +23 -0
- package/server-managed/bundle-build-leases.ts +41 -0
- package/server-managed/bundle-build-worker.js +54 -0
- package/server-managed/bundle-build.ts +168 -0
- package/server-managed/bundle-cache.ts +166 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +24 -0
- package/server-managed/bundle-summary-cache.ts +116 -0
- package/server-managed/bundle.ts +104 -0
- package/server-managed/cache-storage.ts +19 -0
- package/server-managed/cli.js +21 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +149 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1517 -0
- package/server-managed/db-neon.ts +277 -0
- package/server-managed/db-schema.ts +206 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +120 -0
- package/server-managed/github-app.ts +385 -0
- package/server-managed/github-errors.ts +49 -0
- package/server-managed/github-issue-oauth.ts +48 -0
- package/server-managed/github-issues.ts +152 -0
- package/server-managed/github-metadata.ts +98 -0
- package/server-managed/github-oauth.ts +199 -0
- package/server-managed/github-pulls.ts +227 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2350 -0
- package/server-managed/import-triage.ts +51 -0
- package/server-managed/index.ts +175 -0
- package/server-managed/managed-issues.ts +47 -0
- package/server-managed/management-catalog.ts +45 -0
- package/server-managed/management.ts +86 -0
- package/server-managed/object-storage-disk.ts +144 -0
- package/server-managed/object-storage-vercel.ts +63 -0
- package/server-managed/object-storage.ts +71 -0
- package/server-managed/package-entry-points.ts +51 -0
- package/server-managed/public-workspace.ts +146 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +10 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +166 -0
- package/server-managed/repository-browser.ts +139 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +27 -0
- package/server-managed/revisions.ts +35 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql-postgres.ts +31 -0
- package/server-managed/sql.ts +72 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +175 -0
- package/server-managed/storage-encryption.ts +211 -0
- package/server-managed/storage-maintenance.ts +223 -0
- package/server-managed/storage-payload.ts +72 -0
- package/server-managed/storage-stores.ts +81 -0
- package/server-managed/storage.ts +58 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +173 -0
- package/server-managed/team-reports.ts +219 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +65 -0
- package/server-managed/workspace-shares.ts +162 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -477
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -320
- /package/{LICENSE → LICENSE-MIT} +0 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/peer.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Backend selection is shared by standalone, combined and function entrypoints.
|
|
2
|
+
import { dirname } from 'node:path'
|
|
3
|
+
import { parseStorageKey } from '../server-common/storage-crypto.ts'
|
|
4
|
+
import { createBundleCache } from './bundle-cache.ts'
|
|
5
|
+
import { createReportSourcesCache } from './report-sources.ts'
|
|
6
|
+
import { openNeonManagedDb } from './db-neon.ts'
|
|
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
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function openManagedStorage(config: ManagedConfig) {
|
|
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),
|
|
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
|
+
console.info('managed-storage-migration-start:', JSON.stringify({ migrated: state?.migrated, cursor: state?.cursor }))
|
|
45
|
+
const result = await migrateStorage(raw, db, key!, { maxMs: config.storageEncryptionMigrateMaxMs, signal })
|
|
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) } : {}),
|
|
53
|
+
}
|
|
54
|
+
} catch (err) {
|
|
55
|
+
await db.close()
|
|
56
|
+
throw err
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
|
|
3
|
+
// Version only this user's visible catalog, including access and content keys.
|
|
4
|
+
// REST and the live feed must describe exactly the same snapshot.
|
|
5
|
+
export function teamCatalogRevision(teams: readonly unknown[]): string {
|
|
6
|
+
return createHash('sha256').update(JSON.stringify(teams)).digest('base64url')
|
|
7
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import type { ServerResponse } from 'node:http'
|
|
2
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
3
|
+
import type { ManagedHttpDeps } from './http.ts'
|
|
4
|
+
import type { StoredUser, TeamReportAccessSnapshot } from './db.ts'
|
|
5
|
+
import { TeamReportsError, teamSnapshotKey, teamWorkspaceFindingIds } from './team-reports.ts'
|
|
6
|
+
import type { WorkspaceShareFeedState } from './workspace-shares.ts'
|
|
7
|
+
|
|
8
|
+
// Short database reads work across instances without pinning a Neon connection.
|
|
9
|
+
// All polling belongs to the awaited request; nothing runs after it completes.
|
|
10
|
+
export const TEAM_FEED_POLL_MS = 3_000
|
|
11
|
+
export const TEAM_FEED_LIFETIME_MS = 240_000 // below the configured Vercel 300s
|
|
12
|
+
const HEARTBEAT_MS = 15_000
|
|
13
|
+
|
|
14
|
+
type FeedOptions = { pollMs?: number; lifetimeMs?: number }
|
|
15
|
+
type Publish = (event: 'teams' | 'triage', revision: string | undefined) => boolean
|
|
16
|
+
|
|
17
|
+
// Public capabilities remain scoped to their single shared workspace.
|
|
18
|
+
export async function serveTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
|
|
19
|
+
snapshot: TeamReportAccessSnapshot, recheck: () => Promise<void>,
|
|
20
|
+
options: FeedOptions & { readState?: () => Promise<WorkspaceShareFeedState | null> } = {}): Promise<void> {
|
|
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
|
+
}
|
|
29
|
+
await serveFeed(res, deps, async publish => {
|
|
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()
|
|
36
|
+
ids ??= [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
|
|
37
|
+
const triage = await deps.db.getAnnotationRevision(ids)
|
|
38
|
+
const after = options.readState ? await readState() : undefined
|
|
39
|
+
if (!after || !sameAccess(after, state)) await recheck()
|
|
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
|
|
43
|
+
}, options)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Membership and content notifications cover all of the user's teams. Only
|
|
47
|
+
// the focused team needs report parsing or annotation reads; null is the
|
|
48
|
+
// catalog-only subscription used on landing and management pages.
|
|
49
|
+
export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
|
|
50
|
+
sessionId: string, user: StoredUser, teamId: string | null, options: FeedOptions = {}): Promise<void> {
|
|
51
|
+
let ids: string[] = [], visibilityKey: string | undefined
|
|
52
|
+
let focusedKey: string | undefined
|
|
53
|
+
let previousState: { catalog: number; annotations: number } | undefined
|
|
54
|
+
const checkUser = (current: { user: Pick<StoredUser, 'id' | 'role'> } | null) => {
|
|
55
|
+
if (!current) throw new TeamReportsError(401, 'unauthenticated')
|
|
56
|
+
if (current.user.id !== user.id || current.user.role !== user.role || current.user.role === 'none') {
|
|
57
|
+
throw new TeamReportsError(403, 'access-changed')
|
|
58
|
+
}
|
|
59
|
+
}
|
|
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
|
+
}
|
|
75
|
+
const catalog = await deps.db.getUserTeamFeedSnapshot(sessionId, Date.now())
|
|
76
|
+
checkUser(catalog)
|
|
77
|
+
checkUser(await deps.db.getReportAccessSnapshot(sessionId, Date.now(), []))
|
|
78
|
+
// Publish catalog state before loading any report blobs. A slow or broken
|
|
79
|
+
// focused report must not hide membership/content changes from the client.
|
|
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
|
+
}
|
|
88
|
+
let triage: string | undefined
|
|
89
|
+
const snapshot = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
|
|
90
|
+
if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
|
|
91
|
+
checkUser(snapshot)
|
|
92
|
+
const key = teamSnapshotKey(snapshot)
|
|
93
|
+
if (key !== visibilityKey) {
|
|
94
|
+
try {
|
|
95
|
+
ids = snapshot.teamId ? [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)] : []
|
|
96
|
+
visibilityKey = key
|
|
97
|
+
} catch {
|
|
98
|
+
// Missing, malformed or temporarily unavailable blobs only suspend
|
|
99
|
+
// triage. Retry visibility on the next poll, even for the same key.
|
|
100
|
+
publish('triage', undefined)
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
if (snapshot.teamId) triage = JSON.stringify([key, await deps.db.getAnnotationRevision(ids)])
|
|
105
|
+
const current = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
|
|
106
|
+
// A concurrent access/content change discards only the annotation read.
|
|
107
|
+
checkUser(current)
|
|
108
|
+
if (!current || teamSnapshotKey(current) !== key) return
|
|
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!
|
|
114
|
+
}, options)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function serveFeed(res: ServerResponse, deps: ManagedHttpDeps, read: (publish: Publish) => Promise<void>,
|
|
118
|
+
{ pollMs = TEAM_FEED_POLL_MS, lifetimeMs = TEAM_FEED_LIFETIME_MS }: FeedOptions): Promise<void> {
|
|
119
|
+
const controller = new AbortController()
|
|
120
|
+
const close = () => controller.abort()
|
|
121
|
+
res.once('close', close)
|
|
122
|
+
const deadline = setTimeout(close, lifetimeMs)
|
|
123
|
+
const stopped = () => controller.signal.aborted || res.destroyed || deps.isShuttingDown()
|
|
124
|
+
// Notifications carry no annotation bodies, finding IDs, or global cursors.
|
|
125
|
+
// The catalog's opaque version lets clients reuse an identical REST snapshot;
|
|
126
|
+
// every connection still confirms current access, including after reconnects.
|
|
127
|
+
function write(frame: string): boolean {
|
|
128
|
+
if (stopped()) return false
|
|
129
|
+
if (res.write(frame)) return true
|
|
130
|
+
// Slow consumers reconnect and read current state; never queue a backlog.
|
|
131
|
+
res.destroy()
|
|
132
|
+
return false
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
if (stopped()) return
|
|
136
|
+
res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8',
|
|
137
|
+
'cache-control': 'private, no-store, no-transform', 'x-accel-buffering': 'no',
|
|
138
|
+
'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer' })
|
|
139
|
+
res.flushHeaders()
|
|
140
|
+
let heartbeat = Date.now()
|
|
141
|
+
const previous = new Map<string, string>()
|
|
142
|
+
const publish: Publish = (event, revision) => {
|
|
143
|
+
if (stopped()) return false
|
|
144
|
+
if (revision === undefined) { previous.delete(event); return true }
|
|
145
|
+
if (revision === previous.get(event)) return true
|
|
146
|
+
const data = event === 'teams' ? JSON.stringify({ revision }) : '{}'
|
|
147
|
+
if (!write(`event: ${event}\ndata: ${data}\n\n`)) return false
|
|
148
|
+
previous.set(event, revision)
|
|
149
|
+
heartbeat = Date.now()
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
while (!stopped()) {
|
|
153
|
+
if (deps.db.withRequest) await deps.db.withRequest(() => read(publish))
|
|
154
|
+
else await read(publish)
|
|
155
|
+
if (stopped()) break
|
|
156
|
+
if (Date.now() - heartbeat >= HEARTBEAT_MS) {
|
|
157
|
+
if (!write(': keepalive\n\n')) break
|
|
158
|
+
heartbeat = Date.now()
|
|
159
|
+
}
|
|
160
|
+
await delay(pollMs, undefined, { signal: controller.signal }).catch(error => {
|
|
161
|
+
if (!controller.signal.aborted) throw error
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
} catch (error) {
|
|
165
|
+
if (!res.headersSent) throw error
|
|
166
|
+
if (error instanceof TeamReportsError) write('event: close\ndata: {}\n\n')
|
|
167
|
+
else if (!stopped()) throw error
|
|
168
|
+
} finally {
|
|
169
|
+
clearTimeout(deadline)
|
|
170
|
+
res.off('close', close)
|
|
171
|
+
if (res.headersSent && !res.destroyed) res.end()
|
|
172
|
+
}
|
|
173
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Team content is filtered as one workspace, with separate report envelopes.
|
|
2
|
+
// Bounded snapshots retain filtered content and visibility for repeated reads.
|
|
3
|
+
import { Buffer } from 'node:buffer'
|
|
4
|
+
import { backfillFindingIds, reportEntries, stampSecurityGroups } from '@preventive/report'
|
|
5
|
+
import { managedFindingSourcePaths, readManagedReport } from '../common/managed/report-content.ts'
|
|
6
|
+
import { filterReportData, projectFinding } from '../common/managed/report-filter.ts'
|
|
7
|
+
import type { BlobStore } from './blob-store.ts'
|
|
8
|
+
import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
|
|
9
|
+
import { triageWireEntry } from './triage-response.ts'
|
|
10
|
+
import { MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
|
|
11
|
+
|
|
12
|
+
type Finding = Record<string, unknown>
|
|
13
|
+
type TeamReport = { id: string; filename: string; data: unknown; repo: { github: string | null; directory: string } }
|
|
14
|
+
type ReportVisibility = { ids: Set<string>; sourcePaths: Set<string> }
|
|
15
|
+
type Visibility = Map<string, ReportVisibility>
|
|
16
|
+
const VISIBILITY_CACHE_MAX_ITEMS = 250_000
|
|
17
|
+
const VISIBILITY_CACHE_MAX_SNAPSHOTS = 32
|
|
18
|
+
const caches = new WeakMap<ManagedDb, Map<string, Visibility>>()
|
|
19
|
+
const REPORT_CACHE_BYTES = 16 * 1024 * 1024
|
|
20
|
+
const reportCaches = new WeakMap<ManagedDb, Map<string, Buffer>>()
|
|
21
|
+
function retainReports(db: ManagedDb, key: string, bytes: Buffer) {
|
|
22
|
+
if (bytes.length > REPORT_CACHE_BYTES) return
|
|
23
|
+
let cache = reportCaches.get(db)
|
|
24
|
+
if (!cache) { cache = new Map(); reportCaches.set(db, cache) }
|
|
25
|
+
cache.delete(key)
|
|
26
|
+
let total = bytes.length + [...cache.values()].reduce((sum, value) => sum + value.length, 0)
|
|
27
|
+
for (const [oldest, value] of cache) {
|
|
28
|
+
if (total <= REPORT_CACHE_BYTES && cache.size < VISIBILITY_CACHE_MAX_SNAPSHOTS) break
|
|
29
|
+
cache.delete(oldest); total -= value.length
|
|
30
|
+
}
|
|
31
|
+
cache.set(key, bytes)
|
|
32
|
+
}
|
|
33
|
+
function visibilityWeight(visible: Visibility): number {
|
|
34
|
+
return [...visible.values()].reduce((sum, v) => sum + v.ids.size + v.sourcePaths.size, 0)
|
|
35
|
+
}
|
|
36
|
+
function retainVisibility(db: ManagedDb, key: string, visible: Visibility): void {
|
|
37
|
+
let cache = caches.get(db)
|
|
38
|
+
// Replacement contributes its weight once and does not evict a peer merely
|
|
39
|
+
// because the cache already holds the maximum number of snapshots.
|
|
40
|
+
cache?.delete(key)
|
|
41
|
+
const incoming = visibilityWeight(visible)
|
|
42
|
+
if (incoming > VISIBILITY_CACHE_MAX_ITEMS) return
|
|
43
|
+
if (!cache) { cache = new Map(); caches.set(db, cache) }
|
|
44
|
+
let total = incoming + [...cache.values()].reduce((sum, entry) => sum + visibilityWeight(entry), 0)
|
|
45
|
+
for (const [oldest, entry] of cache) {
|
|
46
|
+
if (cache.size < VISIBILITY_CACHE_MAX_SNAPSHOTS && total <= VISIBILITY_CACHE_MAX_ITEMS) break
|
|
47
|
+
cache.delete(oldest)
|
|
48
|
+
total -= visibilityWeight(entry)
|
|
49
|
+
}
|
|
50
|
+
cache.set(key, visible)
|
|
51
|
+
}
|
|
52
|
+
export class TeamReportsError extends Error {
|
|
53
|
+
status: number
|
|
54
|
+
constructor(status: number, error: string) { super(error); this.status = status }
|
|
55
|
+
}
|
|
56
|
+
export function teamSnapshotKey(snapshot: TeamReportAccessSnapshot): string {
|
|
57
|
+
return JSON.stringify([snapshot.user.id, snapshot.user.role, snapshot.teamId, snapshot.reports, snapshot.repositories])
|
|
58
|
+
}
|
|
59
|
+
export async function teamSnapshot(db: ManagedDb, sessionId: string, teamId: string): Promise<TeamReportAccessSnapshot> {
|
|
60
|
+
const snapshot = await db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
|
|
61
|
+
if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
|
|
62
|
+
if (!snapshot.teamId) throw new TeamReportsError(404, 'no-team')
|
|
63
|
+
return snapshot
|
|
64
|
+
}
|
|
65
|
+
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')
|
|
67
|
+
}
|
|
68
|
+
function groupsOf(data: unknown): Finding[][] {
|
|
69
|
+
return (reportEntries(data) ?? []).map(entry => (Array.isArray(entry) ? entry : [entry]).filter(
|
|
70
|
+
(finding): finding is Finding => finding !== null && typeof finding === 'object' && !Array.isArray(finding),
|
|
71
|
+
))
|
|
72
|
+
}
|
|
73
|
+
function linksOf(data: unknown): string[][] | null {
|
|
74
|
+
const record = data as { source?: string; links?: string[][] }
|
|
75
|
+
if (record?.source !== 'links' || !Array.isArray(record.links)) return null
|
|
76
|
+
if (!record.links.every(ids => Array.isArray(ids) && ids.every(id => typeof id === 'string'))) throw new TeamReportsError(422, 'unreadable-links')
|
|
77
|
+
return record.links
|
|
78
|
+
}
|
|
79
|
+
async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<{ body: Buffer; visible: Visibility }> {
|
|
80
|
+
if (snapshot.reports.length > MAX_REPORT_QUERY_COUNT || snapshot.reports.reduce((n, r) => n + r.byteSize, 0) > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
|
|
81
|
+
const reports: TeamReport[] = []
|
|
82
|
+
let inputBytes = 0, next = 0
|
|
83
|
+
// Bound both remote reads and raw buffers, retaining stable report order for
|
|
84
|
+
// the cross-report classification that follows. Finish workers on failure.
|
|
85
|
+
const results = await Promise.allSettled(Array.from({ length: Math.min(4, snapshot.reports.length) }, async () => {
|
|
86
|
+
while (next < snapshot.reports.length) {
|
|
87
|
+
const index = next++
|
|
88
|
+
const access = snapshot.reports[index]!
|
|
89
|
+
|
|
90
|
+
const bytes = await store.get(access.id)
|
|
91
|
+
if (!bytes) throw new TeamReportsError(503, 'unavailable')
|
|
92
|
+
inputBytes += bytes.length
|
|
93
|
+
if (inputBytes > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
|
|
94
|
+
const parsed = readManagedReport(bytes.toString('utf8'), access.filename)
|
|
95
|
+
if (!parsed.data) throw new TeamReportsError(422, 'unreadable-report')
|
|
96
|
+
// Backfill before filtering so links, annotations and the client agree on
|
|
97
|
+
// IDs even when hiding a component would change the original row shape.
|
|
98
|
+
await backfillFindingIds(groupsOf(parsed.data).flat())
|
|
99
|
+
reports[index] = { id: access.id, filename: access.filename, data: parsed.data, repo: access.repo }
|
|
100
|
+
}
|
|
101
|
+
}))
|
|
102
|
+
for (const result of results) if (result.status === 'rejected') throw result.reason
|
|
103
|
+
const edges = new Map<string, Set<string>>()
|
|
104
|
+
const connect = (a: string, b: string) => {
|
|
105
|
+
if (!edges.has(a)) edges.set(a, new Set())
|
|
106
|
+
edges.get(a)!.add(b)
|
|
107
|
+
}
|
|
108
|
+
// A star is enough for transitive security propagation, without a quadratic
|
|
109
|
+
// 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
|
+
}
|
|
115
|
+
const groups = reports.flatMap(report => groupsOf(report.data).map(group => group.map(f => projectFinding(f, report.data as Finding)!)))
|
|
116
|
+
stampSecurityGroups(groups, { linkedIds: id => edges.get(id) ?? [] })
|
|
117
|
+
const securityIds = new Set(groups.flat().filter(f => f['isSecurity'] === true).map(f => String(f['id'])))
|
|
118
|
+
const allIds = new Set<string>(), visible: Visibility = new Map()
|
|
119
|
+
for (let i = 0; i < reports.length; i++) {
|
|
120
|
+
const access = snapshot.reports[i]!, report = reports[i]!
|
|
121
|
+
report.data = filterReportData(report.data, access.permissions, access.repo, securityIds)
|
|
122
|
+
const ids = new Set<string>()
|
|
123
|
+
for (const finding of groupsOf(report.data).flat()) {
|
|
124
|
+
// Preserve linked security classification for the UI lens, including a
|
|
125
|
+
// linked component that a dependency permission will hide from the UI.
|
|
126
|
+
finding['isSecurity'] = securityIds.has(String(finding['id']))
|
|
127
|
+
if (typeof finding['id'] === 'string') { ids.add(finding['id']); allIds.add(finding['id']) }
|
|
128
|
+
}
|
|
129
|
+
visible.set(report.id, { ids, sourcePaths: managedFindingSourcePaths(groupsOf(report.data).flat()) })
|
|
130
|
+
}
|
|
131
|
+
for (const report of reports) {
|
|
132
|
+
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) }
|
|
134
|
+
}
|
|
135
|
+
let outputBytes = 14
|
|
136
|
+
const parts = [Buffer.from('{"reports":[')]
|
|
137
|
+
for (const [index, report] of reports.entries()) {
|
|
138
|
+
const bytes = Buffer.from(JSON.stringify(report))
|
|
139
|
+
outputBytes += bytes.length + 1
|
|
140
|
+
if (outputBytes > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
|
|
141
|
+
if (index) parts.push(Buffer.from(','))
|
|
142
|
+
parts.push(bytes)
|
|
143
|
+
}
|
|
144
|
+
parts.push(Buffer.from(']}'))
|
|
145
|
+
const body = Buffer.concat(parts)
|
|
146
|
+
retainVisibility(db, teamSnapshotKey(snapshot), visible)
|
|
147
|
+
retainReports(db, teamSnapshotKey(snapshot), body)
|
|
148
|
+
return { body, visible }
|
|
149
|
+
}
|
|
150
|
+
const pendingWorkspaces = new WeakMap<ManagedDb, Map<string, ReturnType<typeof buildTeamWorkspace>>>()
|
|
151
|
+
async function loadTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot) {
|
|
152
|
+
let pending = pendingWorkspaces.get(db)
|
|
153
|
+
if (!pending) { pending = new Map(); pendingWorkspaces.set(db, pending) }
|
|
154
|
+
const key = teamSnapshotKey(snapshot)
|
|
155
|
+
const existing = pending.get(key)
|
|
156
|
+
if (existing) return existing
|
|
157
|
+
const job = buildTeamWorkspace(db, store, snapshot)
|
|
158
|
+
pending.set(key, job)
|
|
159
|
+
try { return await job } finally { pending.delete(key) }
|
|
160
|
+
}
|
|
161
|
+
// Treat the encoded body as immutable and recheck access before sending it.
|
|
162
|
+
// Warm HTTP reads need neither a parsed copy nor another serialization pass.
|
|
163
|
+
export async function loadTeamReportsResponse(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<Buffer> {
|
|
164
|
+
const cached = reportCaches.get(db)?.get(teamSnapshotKey(snapshot))
|
|
165
|
+
return cached ?? (await loadTeamWorkspace(db, store, snapshot)).body
|
|
166
|
+
}
|
|
167
|
+
export async function loadTeamReports(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<TeamReport[]> {
|
|
168
|
+
// Object consumers get their own copy, including when sharing a cold load.
|
|
169
|
+
const body = await loadTeamReportsResponse(db, store, snapshot)
|
|
170
|
+
return (JSON.parse(body.toString('utf8')) as { reports: TeamReport[] }).reports
|
|
171
|
+
}
|
|
172
|
+
async function teamVisibility(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<Visibility> {
|
|
173
|
+
const key = teamSnapshotKey(snapshot)
|
|
174
|
+
let visible = caches.get(db)?.get(key)
|
|
175
|
+
if (!visible) {
|
|
176
|
+
// A large snapshot may deliberately remain uncached, or be evicted by
|
|
177
|
+
// another request. Authorization uses this load's result in either case.
|
|
178
|
+
visible = (await loadTeamWorkspace(db, store, snapshot)).visible
|
|
179
|
+
}
|
|
180
|
+
return visible
|
|
181
|
+
}
|
|
182
|
+
export async function teamWorkspaceFindingIds(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<Set<string>> {
|
|
183
|
+
const ids = new Set<string>()
|
|
184
|
+
for (const report of (await teamVisibility(db, store, snapshot)).values()) for (const id of report.ids) ids.add(id)
|
|
185
|
+
return ids
|
|
186
|
+
}
|
|
187
|
+
export async function teamReportVisibility(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot, reportId: string): Promise<ReportVisibility> {
|
|
188
|
+
if (!snapshot.reports.some(r => r.id === reportId)) throw new TeamReportsError(404, 'no-report')
|
|
189
|
+
const visible = await teamVisibility(db, store, snapshot)
|
|
190
|
+
return visible.get(reportId)!
|
|
191
|
+
}
|
|
192
|
+
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)
|
|
194
|
+
const visible = await teamReportVisibility(db, store, snapshot, reportId)
|
|
195
|
+
await recheckTeam(db, sessionId, snapshot)
|
|
196
|
+
return visible.ids
|
|
197
|
+
}
|
|
198
|
+
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)
|
|
200
|
+
const visible = await teamReportVisibility(db, store, snapshot, reportId)
|
|
201
|
+
await recheckTeam(db, sessionId, snapshot)
|
|
202
|
+
return visible.sourcePaths
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Serialize shared annotation bodies once. Reports carry only the annotated
|
|
206
|
+
// finding IDs visible in that report; callers revalidate access before sending.
|
|
207
|
+
export async function loadTeamAnnotations(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot, reportId: string | null = null) {
|
|
208
|
+
// Restrict annotation reads only after classifying the complete workspace:
|
|
209
|
+
// links and findings in other reports can affect this report's visibility.
|
|
210
|
+
const visible = reportId === null ? await teamVisibility(db, store, snapshot)
|
|
211
|
+
: new Map([[reportId, await teamReportVisibility(db, store, snapshot, reportId)]])
|
|
212
|
+
const ids = new Set<string>()
|
|
213
|
+
for (const report of visible.values()) for (const id of report.ids) ids.add(id)
|
|
214
|
+
const { triage, comments } = await db.getAnnotations([...ids])
|
|
215
|
+
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)])
|
|
217
|
+
const reports = Object.fromEntries([...visible].map(([id, report]) => [id, [...report.ids].filter(finding => annotated.has(finding))]))
|
|
218
|
+
return { reports, entries, comments }
|
|
219
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TriageRow } from './db.ts'
|
|
2
|
+
import { type TriageEntryPatch, isTriageBucket } from '../common/managed/triage.ts'
|
|
3
|
+
|
|
4
|
+
// A stored triage row (current state or a trail event) → its wire entry: only
|
|
5
|
+
// set fields present, `false` kept for flagged (the explicit un-flag tombstone
|
|
6
|
+
// must round-trip), and null for the row of a cleared entry (every field
|
|
7
|
+
// null) — the reader adopts the clear.
|
|
8
|
+
export function triageWireEntry(row: Pick<TriageRow, 'color' | 'triage' | 'comment' | 'fix' | 'flagged'>, legacyHistory = false): TriageEntryPatch | null {
|
|
9
|
+
const e: TriageEntryPatch = {}
|
|
10
|
+
if (row.color != null) e.color = row.color
|
|
11
|
+
if (isTriageBucket(row.triage)) e.triage = row.triage
|
|
12
|
+
if (legacyHistory && row.comment != null) e.comment = row.comment
|
|
13
|
+
if (row.fix != null) e.fix = row.fix
|
|
14
|
+
if (row.flagged != null) e.flagged = row.flagged
|
|
15
|
+
return Object.keys(e).length > 0 ? e : null
|
|
16
|
+
}
|