@preventive/triage 1.0.0-alpha.19 → 1.0.0-alpha.20

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 (81) hide show
  1. package/cli.js +6 -0
  2. package/client/finding-link.js +305 -0
  3. package/client/linked-findings.d.ts +1 -0
  4. package/client/linked-findings.js +111 -0
  5. package/common/bundle-metadata.d.ts +10 -0
  6. package/common/bundle-metadata.js +177 -0
  7. package/common/bundle-reasons.d.ts +2 -0
  8. package/common/bundle-reasons.js +21 -0
  9. package/common/bundle-sources.d.ts +3 -0
  10. package/common/bundle-sources.js +284 -0
  11. package/common/bundle-stats.js +41 -0
  12. package/common/bundle-tabs.js +1 -0
  13. package/common/code-language.js +36 -0
  14. package/common/default-scan-models.ts +30 -0
  15. package/common/finding-id.js +47 -0
  16. package/common/github-pr.ts +56 -0
  17. package/common/managed/comments.ts +34 -0
  18. package/common/managed/permissions.ts +35 -0
  19. package/common/managed/report-content.ts +42 -0
  20. package/common/managed/report-filter.ts +108 -0
  21. package/common/managed/roles.ts +28 -0
  22. package/common/managed/routes.d.ts +2 -0
  23. package/common/managed/routes.js +121 -0
  24. package/common/managed/scan-models.ts +6 -0
  25. package/common/managed/triage.ts +83 -0
  26. package/out/index.html +4 -0
  27. package/out/view.css +1 -1
  28. package/out/view.js +82 -82
  29. package/package.json +82 -3
  30. package/server-e2e/cli.js +1 -1
  31. package/server-managed/activity.ts +231 -0
  32. package/server-managed/avatar-store.ts +51 -0
  33. package/server-managed/blob-store.ts +66 -0
  34. package/server-managed/blob-vercel.ts +125 -0
  35. package/server-managed/brotli.ts +10 -0
  36. package/server-managed/bundle-cache.ts +185 -0
  37. package/server-managed/bundle-catalog.ts +29 -0
  38. package/server-managed/bundle-store.ts +28 -0
  39. package/server-managed/bundle-summary-cache.ts +97 -0
  40. package/server-managed/bundle.ts +39 -0
  41. package/server-managed/cache-storage.ts +40 -0
  42. package/server-managed/cli.js +13 -0
  43. package/server-managed/combined.ts +46 -0
  44. package/server-managed/comments.ts +151 -0
  45. package/server-managed/config.ts +144 -0
  46. package/server-managed/content-access.ts +15 -0
  47. package/server-managed/crypto.ts +25 -0
  48. package/server-managed/db-methods.ts +1330 -0
  49. package/server-managed/db-neon.ts +171 -0
  50. package/server-managed/db-schema.ts +203 -0
  51. package/server-managed/db-table-names.ts +22 -0
  52. package/server-managed/db.ts +109 -0
  53. package/server-managed/github-app.ts +332 -0
  54. package/server-managed/github-metadata.ts +65 -0
  55. package/server-managed/github-oauth.ts +215 -0
  56. package/server-managed/github-pulls.ts +115 -0
  57. package/server-managed/http-response.ts +18 -0
  58. package/server-managed/http.ts +2043 -0
  59. package/server-managed/import-triage.ts +48 -0
  60. package/server-managed/index.ts +135 -0
  61. package/server-managed/public-workspace.ts +150 -0
  62. package/server-managed/repo-path.ts +21 -0
  63. package/server-managed/report-migration.ts +35 -0
  64. package/server-managed/report-query.ts +4 -0
  65. package/server-managed/report-response.ts +16 -0
  66. package/server-managed/report-sources.ts +154 -0
  67. package/server-managed/repository-discovery.ts +82 -0
  68. package/server-managed/repository-policy.ts +25 -0
  69. package/server-managed/session.ts +78 -0
  70. package/server-managed/slugs.ts +38 -0
  71. package/server-managed/sql-postgres.ts +30 -0
  72. package/server-managed/sql.ts +61 -0
  73. package/server-managed/static.ts +28 -0
  74. package/server-managed/storage.ts +34 -0
  75. package/server-managed/team-catalog.ts +7 -0
  76. package/server-managed/team-feed.ts +128 -0
  77. package/server-managed/team-reports.ts +156 -0
  78. package/server-managed/triage-response.ts +16 -0
  79. package/server-managed/uploads.ts +47 -0
  80. package/server-managed/workspace-shares.ts +150 -0
  81. package/server.ts +50 -0
@@ -0,0 +1,185 @@
1
+ // Metadata is cached as Brotli. Contents use the stored Brotli bytes directly:
2
+ // unchanged Stasis uploads or sourcemaps compressed once at upload.
3
+ import { Buffer } from 'node:buffer'
4
+ import { mkdir, open, rename, rm, stat, writeFile } from 'node:fs/promises'
5
+ import { join } from 'node:path'
6
+ import { randomUUID } from 'node:crypto'
7
+ import { brotliDecompress } from 'node:zlib'
8
+ import { promisify } from 'node:util'
9
+ import { BUNDLE_METADATA_VERSION, type BundleDetails, createBundleMetadata, createBundleSummary, parseBundleContents } from '../common/bundle-metadata.js'
10
+ import { bundleReasons } from '../common/bundle-reasons.js'
11
+ import { bundlePackageVersions } from '../common/bundle-sources.js'
12
+ import { decodeUtf8 } from '../common/utf8.js'
13
+ import type { OpenedBlob } from './blob-store.ts'
14
+ import type { BundleStore } from './bundle-store.ts'
15
+ import { encodeBrotli } from './brotli.ts'
16
+ import type { ManagedBundle, ManagedDb } from './db.ts'
17
+ import { SUMMARY_FILENAME, createBundleSummaryCache } from './bundle-summary-cache.ts'
18
+
19
+ const decompress = promisify(brotliDecompress)
20
+ const MAX_DECODED_BYTES = 512 * 1024 * 1024
21
+ export const MAX_PACKAGE_INVENTORY_BYTES = 1024 * 1024
22
+ export type BundleCachePart = 'metadata' | 'contents'
23
+ export type BundleCacheRecord = Pick<ManagedBundle, 'id' | 'integrity' | 'filename' | 'kind' | 'byteSize'>
24
+ export interface BundleSummary { files: number; codeFiles: number; lines: number }
25
+
26
+ export async function readBundleDetails(record: BundleCacheRecord, store: BundleStore) {
27
+ const bytes = await store.get(record.id, record.kind)
28
+ if (!bytes) return null
29
+ const decoded = record.kind === 'stasis' || record.kind === 'sourcemap' ? await decompress(bytes, { maxOutputLength: MAX_DECODED_BYTES }) : bytes
30
+ if (decoded.length > MAX_DECODED_BYTES) throw new Error('Decoded bundle too large')
31
+ return parseBundleContents(decodeUtf8(decoded), { integrity: record.integrity, kind: record.kind, size: record.byteSize })
32
+ }
33
+
34
+ export interface BundleCacheStorage {
35
+ exists(id: string, file: string): Promise<boolean>
36
+ put(id: string, file: string, bytes: Buffer): Promise<void>
37
+ open(id: string, file: string): Promise<OpenedBlob>
38
+ // Remove all cached versions for this bundle, including legacy derivatives.
39
+ delete(id: string): Promise<void>
40
+ }
41
+
42
+ const filename = `v${BUNDLE_METADATA_VERSION}-metadata.json.br`
43
+ const packagesFilename = 'v2-package-versions.json'
44
+
45
+ // All scopes share one bounded derivative. Never decode full bundle metadata
46
+ // on advisory requests, including when selecting a reason. Persist null when
47
+ // the combined inventory exceeds the budget, without disabling metadata.
48
+ function encodePackageInventory(details: BundleDetails): Buffer {
49
+ const parts: string[] = []
50
+ let size = 0
51
+ function append(part: string) {
52
+ size += Buffer.byteLength(part)
53
+ if (size > MAX_PACKAGE_INVENTORY_BYTES) return false
54
+ parts.push(part)
55
+ return true
56
+ }
57
+ function inventory(paths: Set<string> | null) {
58
+ if (!append('{')) return false
59
+ let first = true
60
+ for (const [name, versions] of bundlePackageVersions(details, paths)) {
61
+ if (!append(`${first ? '' : ','}${JSON.stringify(name)}:${JSON.stringify([...versions].toSorted())}`)) return false
62
+ first = false
63
+ }
64
+ return append('}')
65
+ }
66
+ append('{"all":')
67
+ if (!inventory(null) || !append(',"reasons":{')) return Buffer.from('null')
68
+ let first = true
69
+ for (const [reason, paths] of bundleReasons(details)) {
70
+ if (!append(`${first ? '' : ','}${JSON.stringify(reason)}:`) || !inventory(paths)) return Buffer.from('null')
71
+ first = false
72
+ }
73
+ if (!append('}}')) return Buffer.from('null')
74
+ return Buffer.from(parts.join(''))
75
+ }
76
+
77
+ async function build(record: BundleCacheRecord, storage: BundleCacheStorage, db: ManagedDb, store: BundleStore) {
78
+ const details = await readBundleDetails(record, store)
79
+ if (!details) throw new Error('Bundle bytes unavailable')
80
+ const metadata = { ...await createBundleMetadata(details), id: record.id, filename: record.filename }
81
+ const body = await encodeBrotli(Buffer.from(JSON.stringify(metadata)))
82
+ if (!(await db.getBundle(record.id))) throw new Error('Bundle deleted')
83
+ await storage.put(record.id, filename, body)
84
+ if (record.kind === 'stasis') await storage.put(record.id, packagesFilename, encodePackageInventory(details))
85
+ const summary = createBundleSummary(details, metadata)
86
+ await storage.put(record.id, SUMMARY_FILENAME, Buffer.from(JSON.stringify(summary)))
87
+ // A different instance may have deleted the row while these writes ran.
88
+ // Reconcile after publishing so its cleanup cannot be undone by us.
89
+ if (!(await db.getBundle(record.id))) {
90
+ await storage.delete(record.id)
91
+ throw new Error('Bundle deleted')
92
+ }
93
+ return summary
94
+ }
95
+
96
+ export function createBundleCache(storage: BundleCacheStorage, db: ManagedDb, store: BundleStore) {
97
+ const pending = new Map<string, Promise<void>>()
98
+ // The database deduplicates by integrity, so each hash has one persistent
99
+ // bundle/cache directory, shared across teams and repeated uploads.
100
+ const summaries = createBundleSummaryCache(storage, async record => {
101
+ const details = await readBundleDetails(record, store)
102
+ if (!details) throw new Error('Bundle bytes unavailable')
103
+ return createBundleSummary(details)
104
+ }, id => db.getBundle(id))
105
+ let queue = Promise.resolve()
106
+ async function ensure(record: BundleCacheRecord): Promise<void> {
107
+ const existing = pending.get(record.id)
108
+ if (existing) return existing
109
+ const job = (async () => {
110
+ if (await storage.exists(record.id, filename) && (record.kind !== 'stasis' || await storage.exists(record.id, packagesFilename))) return
111
+ const work = queue.then(async () => summaries.remember(record, await build(record, storage, db, store)))
112
+ queue = work.catch(() => {})
113
+ await work
114
+ })()
115
+ pending.set(record.id, job)
116
+ try { await job } finally { if (pending.get(record.id) === job) pending.delete(record.id) }
117
+ }
118
+ return {
119
+ prebuild: ensure,
120
+ summary: summaries.summary,
121
+ summaryStatus: summaries.summaryStatus,
122
+ backfillSummaries: summaries.backfill,
123
+ async open(record: ManagedBundle, part: BundleCachePart) {
124
+ if (part === 'contents') {
125
+ if (record.kind !== 'stasis' && record.kind !== 'sourcemap') throw new Error('Unsupported bundle')
126
+ const stored = await store.open(record.id, record.kind)
127
+ if (!stored) throw new Error('Bundle bytes unavailable')
128
+ return stored
129
+ }
130
+ await ensure(record)
131
+ return storage.open(record.id, filename)
132
+ },
133
+ async packageVersions(record: ManagedBundle, reason = ''): Promise<Record<string, string[]> | null | undefined> {
134
+ if (record.kind !== 'stasis') return {}
135
+ await ensure(record)
136
+ const cached = await storage.open(record.id, packagesFilename)
137
+ try {
138
+ if (cached.size != null && cached.size > MAX_PACKAGE_INVENTORY_BYTES) return null
139
+ const chunks: Buffer[] = []
140
+ let size = 0
141
+ for await (const chunk of cached.stream) {
142
+ size += chunk.length
143
+ if (size > MAX_PACKAGE_INVENTORY_BYTES) return null
144
+ chunks.push(Buffer.from(chunk))
145
+ }
146
+ const inventory = JSON.parse(decodeUtf8(Buffer.concat(chunks)))
147
+ if (inventory === null) return null
148
+ return reason ? (Object.hasOwn(inventory.reasons, reason) ? inventory.reasons[reason] : undefined) : inventory.all
149
+ } finally { cached.stream.destroy() }
150
+ },
151
+ async delete(id: string) {
152
+ // Call after deleting the row. Waiting prevents an in-flight builder
153
+ // from recreating its files after deletion has completed.
154
+ await pending.get(id)?.catch(() => {})
155
+ await summaries.forget(id)
156
+ await storage.delete(id)
157
+ },
158
+ }
159
+ }
160
+ export type BundleCache = ReturnType<typeof createBundleCache>
161
+
162
+ export function createDiskBundleCache(dir: string, db: ManagedDb, store: BundleStore): BundleCache {
163
+ function directory(id: string) {
164
+ if (!/^[a-f\d-]{36}$/iu.test(id)) throw new Error('Invalid bundle id')
165
+ return join(dir, id)
166
+ }
167
+ return createBundleCache({
168
+ async exists(id, file) {
169
+ try { await stat(join(directory(id), file)); return true }
170
+ catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false; throw err }
171
+ },
172
+ async put(id, file, bytes) {
173
+ await mkdir(directory(id), { recursive: true })
174
+ const target = join(directory(id), file), temp = `${target}.${randomUUID()}.tmp`
175
+ try { await writeFile(temp, bytes); await rename(temp, target) }
176
+ finally { await rm(temp, { force: true }) }
177
+ },
178
+ async open(id, name) {
179
+ const file = await open(join(directory(id), name), 'r')
180
+ try { return { size: (await file.stat()).size, stream: file.createReadStream() } }
181
+ catch (err) { await file.close(); throw err }
182
+ },
183
+ async delete(id) { await rm(directory(id), { recursive: true, force: true }) },
184
+ }, db, store)
185
+ }
@@ -0,0 +1,29 @@
1
+ import type { BundleCache, BundleSummary } from './bundle-cache.ts'
2
+ import type { AdminBundle, UserTeamBundle } from './db.ts'
3
+
4
+ const SUMMARY_READ_CONCURRENCY = 8
5
+
6
+ // Read only existing summaries: cold/malformed bundles must never trigger
7
+ // parsing or wait for metadata builds before the catalog response is sent.
8
+ export async function bundleSummaries(bundles: readonly (AdminBundle | UserTeamBundle)[], cache?: BundleCache) {
9
+ const summaries = new Map<string, { summary: BundleSummary | null; summaryRetryAt: number | null }>()
10
+ if (cache) {
11
+ const unique = [...new Map(bundles.map(bundle => [bundle.integrity, bundle])).values()]
12
+ let next = 0
13
+ // Cold reads may each access remote storage. Bound those requests as well
14
+ // as the subsequent backfill, without dropping entries from large catalogs.
15
+ await Promise.all(Array.from({ length: Math.min(SUMMARY_READ_CONCURRENCY, unique.length) }, async () => {
16
+ while (next < unique.length) {
17
+ const bundle = unique[next++]!
18
+ summaries.set(bundle.integrity, await cache.summaryStatus(bundle).catch(() => ({ summary: null, summaryRetryAt: null })))
19
+ }
20
+ }))
21
+ }
22
+ return summaries
23
+ }
24
+
25
+ // Called after sending the response. Each cache permits one small batch at a
26
+ // time, deduplicated by content hash, and skips persisted retry-backoff markers.
27
+ export async function backfillBundleSummaries(bundles: readonly (AdminBundle | UserTeamBundle)[], cache?: BundleCache) {
28
+ await cache?.backfillSummaries([...new Map(bundles.map(bundle => [bundle.integrity, bundle])).values()])
29
+ }
@@ -0,0 +1,28 @@
1
+ // Sourcemaps are stored once as Brotli. The DB retains the uploaded filename,
2
+ // byte size and integrity so compression does not change report links/dedup.
3
+ import type { Buffer } from 'node:buffer'
4
+ import { type BlobStore, type OpenedBlob, createDiskBlobStore } from './blob-store.ts'
5
+ import { encodeBrotli } from './brotli.ts'
6
+
7
+ export interface BundleStore {
8
+ put(id: string, bytes: Buffer, kind: string | null): Promise<void>
9
+ get(id: string, kind: string | null): Promise<Buffer | null>
10
+ open(id: string, kind: string | null): Promise<OpenedBlob | null>
11
+ delete(id: string): Promise<void>
12
+ }
13
+
14
+ export function createBundleStore(archives: BlobStore, sourcemaps: BlobStore): BundleStore {
15
+ const storage = (kind: string | null) => kind === 'sourcemap' ? sourcemaps : archives
16
+ return {
17
+ async put(id, bytes, kind) {
18
+ await storage(kind).put(id, kind === 'sourcemap' ? await encodeBrotli(bytes) : bytes)
19
+ },
20
+ get: (id, kind) => storage(kind).get(id),
21
+ open: (id, kind) => storage(kind).open(id),
22
+ async delete(id) { await Promise.all([archives.delete(id), sourcemaps.delete(id)]) },
23
+ }
24
+ }
25
+
26
+ export function createDiskBundleStore(dir: string): BundleStore {
27
+ return createBundleStore(createDiskBlobStore(dir), createDiskBlobStore(dir, '.map.br'))
28
+ }
@@ -0,0 +1,97 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { setImmediate } from 'node:timers/promises'
3
+ import { BUNDLE_METADATA_VERSION } from '../common/bundle-metadata.js'
4
+ import { decodeUtf8 } from '../common/utf8.js'
5
+ import type { BundleCacheRecord, BundleCacheStorage, BundleSummary } from './bundle-cache.ts'
6
+
7
+ export const SUMMARY_FILENAME = `v${BUNDLE_METADATA_VERSION}-summary.json`
8
+ const RETRY_MS = 5 * 60_000
9
+ const BACKFILL_LIMIT = 4
10
+ type CachedSummary = BundleSummary | { retryAt: number }
11
+
12
+ async function readSummary(storage: BundleCacheStorage, id: string): Promise<CachedSummary> {
13
+ const cached = await storage.open(id, SUMMARY_FILENAME)
14
+ try {
15
+ const chunks: Buffer[] = []
16
+ let size = 0
17
+ for await (const chunk of cached.stream) {
18
+ size += chunk.length
19
+ if (size > 1024) throw new Error('Invalid bundle summary')
20
+ chunks.push(Buffer.from(chunk))
21
+ }
22
+ const summary = JSON.parse(decodeUtf8(Buffer.concat(chunks))) as CachedSummary
23
+ if (summary && 'retryAt' in summary && Number.isSafeInteger(summary.retryAt) && summary.retryAt > 0) return summary
24
+ const counts = summary as BundleSummary
25
+ if (!counts || ![counts.files, counts.codeFiles, counts.lines].every(value => Number.isSafeInteger(value) && value >= 0)
26
+ || counts.codeFiles > counts.files) throw new Error('Invalid bundle summary')
27
+ return counts
28
+ } finally { cached.stream.destroy() }
29
+ }
30
+
31
+ export function createBundleSummaryCache(storage: BundleCacheStorage, build: (record: BundleCacheRecord) => Promise<BundleSummary>, exists: (id: string) => Promise<unknown>) {
32
+ const entries = new Map<string, { id: string; value: CachedSummary }>()
33
+ const active = new Map<string, Promise<void>>()
34
+ let pending: Promise<void> | null = null
35
+ function remember(record: BundleCacheRecord, value: CachedSummary) {
36
+ if (entries.size >= 256) entries.delete(entries.keys().next().value!)
37
+ entries.set(record.integrity, { id: record.id, value })
38
+ }
39
+ async function read(record: BundleCacheRecord) {
40
+ const known = entries.get(record.integrity)
41
+ if (known) return known.value
42
+ if (!await storage.exists(record.id, SUMMARY_FILENAME)) return null
43
+ const value = await readSummary(storage, record.id)
44
+ remember(record, value)
45
+ return value
46
+ }
47
+ async function publish(record: BundleCacheRecord, value: CachedSummary) {
48
+ if (!await exists(record.id)) return
49
+ await storage.put(record.id, SUMMARY_FILENAME, Buffer.from(JSON.stringify(value)))
50
+ if (!await exists(record.id)) { await storage.delete(record.id); return }
51
+ remember(record, value)
52
+ }
53
+ async function buildOne(record: BundleCacheRecord) {
54
+ try { await publish(record, await build(record)) }
55
+ catch { await publish(record, { retryAt: Date.now() + RETRY_MS }).catch(() => {}) }
56
+ }
57
+ async function summaryStatus(record: BundleCacheRecord) {
58
+ const value = ['stasis', 'sourcemap'].includes(record.kind ?? '') ? await read(record) : null
59
+ return { summary: value && !('retryAt' in value) ? value : null,
60
+ summaryRetryAt: value && 'retryAt' in value ? value.retryAt : null }
61
+ }
62
+ async function backfill(records: readonly BundleCacheRecord[]) {
63
+ // Start after the catalog has been sent. Keep this work awaited by the
64
+ // request owner (including serverless), with no detached tasks or backlog.
65
+ await setImmediate()
66
+ let attempts = 0
67
+ for (const record of records) {
68
+ if (!['stasis', 'sourcemap'].includes(record.kind ?? '')) continue
69
+ const cached = await read(record).catch(() => null)
70
+ if (cached && (!('retryAt' in cached) || cached.retryAt > Date.now())) continue
71
+ if (!await exists(record.id)) continue
72
+ if (attempts++ >= BACKFILL_LIMIT) break
73
+ const job = buildOne(record)
74
+ active.set(record.id, job)
75
+ try { await job } finally { active.delete(record.id) }
76
+ }
77
+ }
78
+ return {
79
+ remember,
80
+ summaryStatus,
81
+ async summary(record: BundleCacheRecord): Promise<BundleSummary | null> {
82
+ return (await summaryStatus(record)).summary
83
+ },
84
+ backfill(records: readonly BundleCacheRecord[]) {
85
+ if (pending) return Promise.resolve() // Another catalog already owns the bounded batch.
86
+ const job = backfill(records).finally(() => { if (pending === job) pending = null })
87
+ pending = job
88
+ return job
89
+ },
90
+ async forget(id: string) {
91
+ // Only this bundle can republish its cache during deletion. A stalled
92
+ // backfill elsewhere must not delay removing these bytes or responding.
93
+ await active.get(id)
94
+ for (const [hash, entry] of entries) if (entry.id === id) entries.delete(hash)
95
+ },
96
+ }
97
+ }
@@ -0,0 +1,39 @@
1
+ // Bundle helpers for the managed server: the content-addressed identity used to
2
+ // dedupe stored bundles and to auto-link reports to them.
3
+ //
4
+ // A report's top-level `bundleHashes` lists the `sha512-<base64>` integrities
5
+ // the analyzer ran against (see ui/view/ingest.js); a stored bundle's identity
6
+ // is that same integrity computed from its bytes. Matching the two is how a
7
+ // report auto-links to its bundle.
8
+ import { createHash } from 'node:crypto'
9
+ import type { Buffer } from 'node:buffer'
10
+
11
+ // `sha512-<base64>` identity for a bundle's bytes. MUST stay byte-identical to
12
+ // the client's common/integrity.js (SHA-512 → standard base64 WITH padding) so
13
+ // a report's `bundleHashes` entries match a stored bundle's computed integrity.
14
+ // (The client uses `Uint8Array.toBase64()`, whose default is standard base64 +
15
+ // padding — the same bytes node:crypto's base64 digest emits.)
16
+ export function bundleIntegrity(bytes: Buffer): string {
17
+ return `sha512-${createHash('sha512').update(bytes).digest('base64')}`
18
+ }
19
+
20
+ // Classify an uploaded bundle by filename — mirrors ui/view/ingest.js
21
+ // `bundleKind`: sourcemap (.map) or stasis (stasis.code.br / .stasis.code.br).
22
+ // null for anything else (stored anyway; the kind is informational).
23
+ export function bundleKind(filename: string): 'sourcemap' | 'stasis' | null {
24
+ const lower = filename.toLowerCase()
25
+ if (lower.endsWith('.map')) return 'sourcemap'
26
+ if (lower === 'stasis.code.br' || lower.endsWith('.stasis.code.br')) return 'stasis'
27
+ return null
28
+ }
29
+
30
+ // Extract the bundle integrities a report declares (its top-level
31
+ // `bundleHashes`). Non-JSON reports (markdown / CSV) or a missing field → [].
32
+ export function reportBundleHashes(bytes: Buffer): string[] {
33
+ let data: unknown
34
+ try { data = JSON.parse(bytes.toString('utf8')) } catch { return [] }
35
+ if (data == null || typeof data !== 'object') return []
36
+ const hashes = (data as { bundleHashes?: unknown }).bundleHashes
37
+ if (!Array.isArray(hashes)) return []
38
+ return hashes.filter((x): x is string => typeof x === 'string' && x !== '')
39
+ }
@@ -0,0 +1,40 @@
1
+ import type { Buffer } from 'node:buffer'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { mkdir, open, rename, rm, stat, writeFile } from 'node:fs/promises'
4
+ import { dirname, join } from 'node:path'
5
+ import type { OpenedBlob } from './blob-store.ts'
6
+
7
+ // Keys are internal relative paths; delete removes a whole directory of variants.
8
+ export interface CacheStorage {
9
+ exists(key: string): Promise<boolean>
10
+ put(key: string, bytes: Buffer): Promise<void>
11
+ open(key: string): Promise<OpenedBlob>
12
+ delete(prefix: string): Promise<void>
13
+ }
14
+
15
+ export function validateCacheKey(key: string): string {
16
+ if (!key.split('/').every(part => /^[a-z0-9][a-z0-9.-]*$/iu.test(part))) throw new Error('Invalid cache key')
17
+ return key
18
+ }
19
+
20
+ export function createDiskCacheStorage(dir: string): CacheStorage {
21
+ const path = (key: string) => join(dir, validateCacheKey(key))
22
+ return {
23
+ async exists(key) {
24
+ try { await stat(path(key)); return true }
25
+ catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') return false; throw err }
26
+ },
27
+ async put(key, bytes) {
28
+ const target = path(key), temp = `${target}.${randomUUID()}.tmp`
29
+ await mkdir(dirname(target), { recursive: true })
30
+ try { await writeFile(temp, bytes); await rename(temp, target) }
31
+ finally { await rm(temp, { force: true }) }
32
+ },
33
+ async open(key) {
34
+ const file = await open(path(key), 'r')
35
+ try { return { size: (await file.stat()).size, stream: file.createReadStream() } }
36
+ catch (err) { await file.close(); throw err }
37
+ },
38
+ async delete(prefix) { await rm(path(prefix), { recursive: true, force: true }) },
39
+ }
40
+ }
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ // Executable entry for the managed-server bin. See server-e2e/cli.js for the
3
+ // full rationale: Node refuses to strip TypeScript types from files under
4
+ // node_modules, so once this package is installed as a dependency
5
+ // server-managed/index.ts can't be executed directly. Register the in-thread
6
+ // type-stripping hook FIRST, then load the server via a dynamic import (a
7
+ // static import would be fetched before the hook is active) and start it.
8
+ //
9
+ // Imported here rather than run as the process entry, so index.ts's
10
+ // `import.meta.main` auto-start gate stays off and we call `start()` ourselves.
11
+ import '../strip-types-loader.js'
12
+ const { start } = await import('./index.ts')
13
+ await start()
@@ -0,0 +1,46 @@
1
+ // Compose the existing HTTP apps on e2e's listener, including its WS/SSE
2
+ // transports and static UI. Managed handles its API, discovery and page HTML;
3
+ // assets and other requests fall through to the unchanged e2e HTTP handler.
4
+ import { resolve } from 'node:path'
5
+ import type { Server } from 'node:http'
6
+ import { withReap } from '../server-common/reap.ts'
7
+ import { loadConfig } from '../server-e2e/config.ts'
8
+ import { createE2eApp } from '../server-e2e/app.ts'
9
+ import { loadManagedConfig } from './config.ts'
10
+ import { LOGIN_PATH } from './github-oauth.ts'
11
+ import { createManagedApp, logManagedStartup } from './index.ts'
12
+
13
+ export async function init(mode: 'managed+e2e' | 'e2e+managed'): Promise<Server> {
14
+ const config = loadManagedConfig({ combined: true })
15
+ const e2eConfig = loadConfig()
16
+ if (!e2eConfig.neonUrl && !config.neonUrl && config.dbPath !== ':memory:' && resolve(config.dbPath) === resolve(e2eConfig.dbPath)) {
17
+ throw new Error('MANAGED_DB_PATH must differ from e2e DB_PATH in combined mode.')
18
+ }
19
+ const e2e = await createE2eApp(e2eConfig)
20
+ // Replace the assembled server's single
21
+ // request listener; adding a second would write two responses per request.
22
+ const handlers = e2e.httpServer.listeners('request')
23
+ const next = handlers[0]
24
+ if (handlers.length !== 1 || !next) throw new Error('Expected one e2e HTTP request handler')
25
+ const managed = await createManagedApp(config, {
26
+ next: e2e.handleRequest,
27
+ isShuttingDown: e2e.isShuttingDown,
28
+ serverInfo: {
29
+ mode, managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName },
30
+ ...(e2eConfig.deepviewScanServer ? { deepviewScanServer: e2eConfig.deepviewScanServer } : {}),
31
+ },
32
+ }).catch(async err => {
33
+ await e2e.httpServer[Symbol.asyncDispose]()
34
+ throw err
35
+ })
36
+ e2e.onShutdown(managed.close)
37
+ e2e.httpServer.removeListener('request', next)
38
+ e2e.httpServer.on('request', withReap(managed.handleRequest,
39
+ { e2e: e2e.reap, managed: managed.reap }, { isShuttingDown: e2e.isShuttingDown }))
40
+ e2e.httpServer.once('listening', () => {
41
+ const address = e2e.httpServer.address()
42
+ const port = typeof address === 'object' && address ? address.port : config.port
43
+ logManagedStartup(config, port, mode)
44
+ })
45
+ return e2e.httpServer
46
+ }
@@ -0,0 +1,151 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import type { DatabaseSync } from 'node:sqlite'
3
+ import { type ManagedComment, canDeleteComment } from '../common/managed/comments.ts'
4
+ import type { ManagedSql } from './sql.ts'
5
+
6
+ export interface CommentInput {
7
+ findingId: string
8
+ body: string
9
+ // Runtime imports may omit authorship, including future admin UI imports.
10
+ // Ordinary comment posts always supply the authenticated user as author.
11
+ authorId: string | null
12
+ authorLogin: string | null
13
+ // The user performing an import need not be the author of its comments.
14
+ // Omitted for ordinary posts, where the author is also the acting user.
15
+ actor?: { id: string; login: string }
16
+ // Omitted dates default to the action time; null preserves unknown dates.
17
+ createdAt?: number | null
18
+ updatedAt?: number | null
19
+ reportId?: string | null
20
+ }
21
+
22
+ export interface CommentStore {
23
+ listComments(findingIds: readonly string[]): Promise<ManagedComment[]>
24
+ listCommentedFindingIds(findingIds: readonly string[]): Promise<string[]>
25
+ getComment(id: string): Promise<ManagedComment | null>
26
+ createComment(input: CommentInput, now: number): Promise<ManagedComment>
27
+ editComment(id: string, authorId: string, authorLogin: string, body: string, version: number, reportId: string, now: number): Promise<ManagedComment | 'conflict' | 'forbidden' | null>
28
+ deleteComment(id: string, actorId: string, actorLogin: string, version: number, reportId: string, now: number): Promise<'deleted' | 'conflict' | 'forbidden' | null>
29
+ }
30
+
31
+ const COMMENT_COLUMNS = `id TEXT PRIMARY KEY, finding_id TEXT NOT NULL, body TEXT NOT NULL,
32
+ author_id TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
33
+ author_login TEXT, created_at INTEGER, updated_at INTEGER,
34
+ version INTEGER NOT NULL DEFAULT 1`
35
+
36
+ export const COMMENT_SCHEMA = `
37
+ CREATE TABLE IF NOT EXISTS managed_finding_comment (${COMMENT_COLUMNS}) STRICT;
38
+ CREATE INDEX IF NOT EXISTS finding_comment_finding_idx ON managed_finding_comment(finding_id, created_at, id);
39
+ CREATE TABLE IF NOT EXISTS managed_finding_comment_event (
40
+ seq INTEGER PRIMARY KEY, comment_id TEXT NOT NULL, finding_id TEXT NOT NULL,
41
+ actor_id TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
42
+ actor_login TEXT, action TEXT NOT NULL, at INTEGER NOT NULL,
43
+ report_id TEXT, report TEXT, repo TEXT
44
+ ) STRICT;
45
+ CREATE INDEX IF NOT EXISTS finding_comment_event_finding_idx ON managed_finding_comment_event(finding_id, seq);
46
+ CREATE INDEX IF NOT EXISTS finding_comment_event_actor_at_idx ON managed_finding_comment_event(actor_id, at);
47
+ CREATE INDEX IF NOT EXISTS finding_comment_event_at_idx ON managed_finding_comment_event(at, seq);
48
+ `
49
+
50
+ export function initCommentMethods(db: DatabaseSync): void {
51
+ db.exec(COMMENT_SCHEMA)
52
+ migrateCommentDates(db)
53
+ // updated_by belongs to the whole triage row, not necessarily its comment.
54
+ // Preserve legacy text without guessing an author. Clearing the old column
55
+ // in the same transaction makes the migration safe to restart. Use a fresh
56
+ // ID so a later legacy-server write cannot collide with an earlier import.
57
+ db.exec('BEGIN')
58
+ try {
59
+ db.exec(`INSERT INTO managed_finding_comment (id, finding_id, body, created_at, updated_at)
60
+ SELECT 'legacy:' || lower(hex(randomblob(16))), finding_id, comment, updated_at, updated_at
61
+ FROM managed_finding_triage WHERE comment IS NOT NULL AND comment != '';
62
+ UPDATE managed_finding_triage SET comment = NULL WHERE comment IS NOT NULL;`)
63
+ db.exec('COMMIT')
64
+ } catch (err) {
65
+ db.exec('ROLLBACK')
66
+ throw err
67
+ }
68
+ }
69
+
70
+ export function commentMethods(db: ManagedSql): CommentStore {
71
+ const fields = `c.id, c.finding_id AS findingId, c.body, c.author_id AS authorId,
72
+ COALESCE(u.login, c.author_login) AS authorLogin,
73
+ c.created_at AS createdAt, c.updated_at AS updatedAt, c.version`
74
+ const select = db.prepare(`SELECT ${fields} FROM managed_finding_comment c
75
+ LEFT JOIN managed_user u ON u.id = c.author_id WHERE c.id = ?`)
76
+ const list = db.prepare(`SELECT ${fields} FROM managed_finding_comment c
77
+ LEFT JOIN managed_user u ON u.id = c.author_id
78
+ WHERE c.finding_id IN (SELECT value FROM json_each(?)) ORDER BY c.created_at NULLS FIRST, c.id`)
79
+ const insert = db.prepare(`INSERT INTO managed_finding_comment
80
+ (id, finding_id, body, author_id, author_login, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`)
81
+ const update = db.prepare(`UPDATE managed_finding_comment SET body = ?, updated_at = ?, version = version + 1
82
+ WHERE id = ? AND author_id = ? AND version = ?`)
83
+ const remove = db.prepare('DELETE FROM managed_finding_comment WHERE id = ? AND (author_id = ? OR (author_id IS NULL AND ? IS NULL)) AND version = ?')
84
+ const userRole = db.prepare('SELECT role FROM managed_user WHERE id = ?')
85
+ // Deleted comments still have audit events which explicit annotation purges
86
+ // must find, including when no current triage/comment record remains.
87
+ const annotated = db.prepare(`SELECT finding_id AS id FROM managed_finding_comment WHERE finding_id IN (SELECT value FROM json_each(?))
88
+ UNION SELECT finding_id AS id FROM managed_finding_comment_event WHERE finding_id IN (SELECT value FROM json_each(?))`)
89
+ const event = db.prepare(`INSERT INTO managed_finding_comment_event
90
+ (comment_id, finding_id, actor_id, actor_login, action, at, report_id, report, repo)
91
+ VALUES (?, ?, ?, ?, ?, ?, ?, (SELECT filename FROM managed_report WHERE id = ?),
92
+ (SELECT p.full_name FROM managed_report r JOIN managed_selected_repo p ON p.repo_id = r.repo_id WHERE r.id = ?))`)
93
+ const read = async (id: string) => (await select.get(id)) as ManagedComment | undefined
94
+ return {
95
+ async listComments(ids) { return ids.length > 0 ? (await list.all(JSON.stringify(ids))) as unknown as ManagedComment[] : [] },
96
+ async listCommentedFindingIds(ids) {
97
+ const json = JSON.stringify(ids)
98
+ return ((await annotated.all(json, json)) as { id: string }[]).map(row => row.id)
99
+ },
100
+ async getComment(id) { return (await read(id)) ?? null },
101
+ async createComment(input, now) {
102
+ const id = randomUUID()
103
+ const createdAt = input.createdAt === undefined ? now : input.createdAt
104
+ const updatedAt = input.updatedAt === undefined ? createdAt : input.updatedAt
105
+ await insert.run(id, input.findingId, input.body, input.authorId, input.authorLogin, createdAt, updatedAt)
106
+ await event.run(id, input.findingId, input.actor?.id ?? input.authorId, input.actor?.login ?? input.authorLogin, 'added a comment', now,
107
+ input.reportId ?? null, input.reportId ?? null, input.reportId ?? null)
108
+ return (await read(id))!
109
+ },
110
+ async editComment(id, authorId, authorLogin, body, version, reportId, now) {
111
+ // Identity/version checks and the event share the operation transaction.
112
+ const current = await read(id)
113
+ if (!current || current.authorId !== authorId || current.version !== version || current.body === body) {
114
+ if (!current) return null
115
+ if (current.authorId !== authorId) return 'forbidden'
116
+ if (current.version !== version) return 'conflict'
117
+ return current
118
+ }
119
+ await update.run(body, now, id, authorId, version)
120
+ await event.run(id, current.findingId, authorId, authorLogin, 'edited a comment', now, reportId, reportId, reportId)
121
+ return (await read(id))!
122
+ },
123
+ async deleteComment(id, actorId, actorLogin, version, reportId, now) {
124
+ const current = await read(id)
125
+ const role = (await userRole.get(actorId) as { role: string } | undefined)?.role ?? 'none'
126
+ const allowed = current != null && canDeleteComment(current, { id: actorId, role })
127
+ if (!current || !allowed || current.version !== version) {
128
+ if (!current) return null
129
+ return allowed ? 'conflict' : 'forbidden'
130
+ }
131
+ await remove.run(id, current.authorId, current.authorId, version)
132
+ await event.run(id, current.findingId, actorId, actorLogin, 'deleted a comment', now, reportId, reportId, reportId)
133
+ return 'deleted'
134
+ },
135
+ }
136
+ }
137
+
138
+ function migrateCommentDates(db: DatabaseSync): void {
139
+ const columns = db.prepare('PRAGMA table_info(managed_finding_comment)').all() as { name: string; notnull: number }[]
140
+ if (!columns.some(column => ['created_at', 'updated_at'].includes(column.name) && column.notnull)) return
141
+ db.exec('BEGIN')
142
+ try {
143
+ db.exec(`CREATE TABLE managed_finding_comment_nullable_dates (${COMMENT_COLUMNS}) STRICT;
144
+ INSERT INTO managed_finding_comment_nullable_dates (id, finding_id, body, author_id, author_login, created_at, updated_at, version)
145
+ SELECT id, finding_id, body, author_id, author_login, created_at, updated_at, version FROM managed_finding_comment;
146
+ DROP TABLE managed_finding_comment;
147
+ ALTER TABLE managed_finding_comment_nullable_dates RENAME TO managed_finding_comment;
148
+ CREATE INDEX finding_comment_finding_idx ON managed_finding_comment(finding_id, created_at, id);`)
149
+ db.exec('COMMIT')
150
+ } catch (err) { db.exec('ROLLBACK'); throw err }
151
+ }