@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.21

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 (168) hide show
  1. package/api/reap.ts +17 -0
  2. package/cli.js +6 -0
  3. package/client/finding-link.js +305 -0
  4. package/client/linked-findings.d.ts +1 -0
  5. package/client/linked-findings.js +111 -0
  6. package/common/bundle-metadata.d.ts +10 -0
  7. package/common/bundle-metadata.js +177 -0
  8. package/common/bundle-reasons.d.ts +2 -0
  9. package/common/bundle-reasons.js +21 -0
  10. package/common/bundle-sources.d.ts +3 -0
  11. package/common/bundle-sources.js +284 -0
  12. package/common/bundle-stats.js +41 -0
  13. package/common/bundle-tabs.js +1 -0
  14. package/common/code-language.js +36 -0
  15. package/common/default-scan-models.ts +30 -0
  16. package/common/finding-id.js +47 -0
  17. package/common/github-pr.ts +56 -0
  18. package/common/managed/comments.ts +34 -0
  19. package/common/managed/permissions.ts +35 -0
  20. package/common/managed/report-content.ts +42 -0
  21. package/common/managed/report-filter.ts +108 -0
  22. package/common/managed/roles.ts +28 -0
  23. package/common/managed/routes.d.ts +2 -0
  24. package/common/managed/routes.js +121 -0
  25. package/common/managed/scan-models.ts +6 -0
  26. package/common/managed/triage.ts +83 -0
  27. package/common/save-error-reason.ts +20 -7
  28. package/common/scan-server.ts +13 -0
  29. package/common/server-info.ts +33 -0
  30. package/common/utf8.d.ts +3 -0
  31. package/common/utf8.js +45 -0
  32. package/out/brotli-fallback.js +3 -3
  33. package/out/client-managed-import.js +81 -0
  34. package/out/client-managed.js +110 -0
  35. package/out/client-sync.js +16 -13
  36. package/out/graph.js +30 -4
  37. package/out/index.html +55 -8
  38. package/out/prism.js +2 -2
  39. package/out/stasis.svg +45 -0
  40. package/out/terminal.js +273 -39
  41. package/out/view.css +1 -1
  42. package/out/view.js +198 -62
  43. package/package.json +179 -55
  44. package/report/index.js +254 -0
  45. package/report/src/finding-id.js +80 -0
  46. package/report/src/finding.js +312 -0
  47. package/report/src/labels.js +33 -0
  48. package/report/src/md-structure.js +471 -0
  49. package/report/src/md-text.js +167 -0
  50. package/report/src/meta.js +76 -0
  51. package/report/src/parse-codex.js +147 -0
  52. package/report/src/parse-deepsec.js +197 -0
  53. package/report/src/parse-deepview-fields.js +375 -0
  54. package/report/src/parse-deepview-md.js +185 -0
  55. package/report/src/parse-md-id.js +137 -0
  56. package/report/src/parse-md.js +322 -0
  57. package/report/src/parse-piolium-id.js +79 -0
  58. package/report/src/parse-piolium-rows.js +131 -0
  59. package/report/src/parse-piolium-tokens.js +175 -0
  60. package/report/src/parse-piolium.js +400 -0
  61. package/report/src/security.js +63 -0
  62. package/report/src/utf8.js +21 -0
  63. package/report/src/write-md-finding.js +273 -0
  64. package/report/src/write-md.js +291 -0
  65. package/server-common/database-config.ts +16 -0
  66. package/server-common/initialize.ts +18 -0
  67. package/server-common/npm-advisories.ts +101 -0
  68. package/{server → server-common}/origin.ts +5 -5
  69. package/server-common/reap.ts +48 -0
  70. package/server-common/scan-config.ts +19 -0
  71. package/server-common/standalone.ts +29 -0
  72. package/server-common/storage-log.ts +34 -0
  73. package/server-common/vercel-blob.ts +110 -0
  74. package/server-e2e/app.ts +485 -0
  75. package/{server → server-e2e}/auth.ts +5 -1
  76. package/{server → server-e2e}/bus-receiver.ts +9 -8
  77. package/{server → server-e2e}/cli.js +9 -4
  78. package/{server → server-e2e}/config.ts +54 -39
  79. package/{server → server-e2e}/db-neon.ts +2 -2
  80. package/{server → server-e2e}/db-revision-sql.ts +7 -10
  81. package/{server → server-e2e}/db-stmt.ts +2 -2
  82. package/{server → server-e2e}/db.ts +96 -135
  83. package/{server → server-e2e}/http.ts +110 -12
  84. package/{server → server-e2e}/hub.ts +44 -14
  85. package/server-e2e/index.ts +17 -0
  86. package/server-e2e/lifecycle.ts +95 -0
  87. package/{server → server-e2e}/neon-driver.ts +2 -2
  88. package/{server → server-e2e}/npm-proxy.ts +11 -144
  89. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  90. package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
  91. package/{server → server-e2e}/objstore/blob.ts +24 -9
  92. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  93. package/{server → server-e2e}/objstore/handlers.ts +19 -20
  94. package/{server → server-e2e}/objstore/init.ts +53 -27
  95. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  96. package/server-e2e/objstore/rest-deny.ts +28 -0
  97. package/server-e2e/objstore/rest-mint.ts +224 -0
  98. package/{server → server-e2e}/objstore/rest.ts +110 -93
  99. package/{server → server-e2e}/objstore/sign.ts +105 -0
  100. package/{server → server-e2e}/objstore/store-neon.ts +10 -14
  101. package/{server → server-e2e}/objstore/store.ts +98 -118
  102. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  103. package/{server → server-e2e}/peer.ts +7 -9
  104. package/{server → server-e2e}/pubsub.ts +29 -36
  105. package/{server → server-e2e}/sign.ts +12 -14
  106. package/{server → server-e2e}/sse-server.ts +105 -73
  107. package/{server → server-e2e}/sse-session.ts +30 -16
  108. package/{server → server-e2e}/static.ts +36 -29
  109. package/server-e2e/sync-handlers.ts +408 -0
  110. package/{server → server-e2e}/util.ts +9 -0
  111. package/{server → server-e2e}/ws-server.ts +29 -23
  112. package/server-managed/activity.ts +231 -0
  113. package/server-managed/avatar-store.ts +51 -0
  114. package/server-managed/blob-store.ts +66 -0
  115. package/server-managed/blob-vercel.ts +125 -0
  116. package/server-managed/brotli.ts +10 -0
  117. package/server-managed/bundle-cache.ts +185 -0
  118. package/server-managed/bundle-catalog.ts +29 -0
  119. package/server-managed/bundle-store.ts +28 -0
  120. package/server-managed/bundle-summary-cache.ts +97 -0
  121. package/server-managed/bundle.ts +39 -0
  122. package/server-managed/cache-storage.ts +40 -0
  123. package/server-managed/cli.js +13 -0
  124. package/server-managed/combined.ts +46 -0
  125. package/server-managed/comments.ts +151 -0
  126. package/server-managed/config.ts +144 -0
  127. package/server-managed/content-access.ts +15 -0
  128. package/server-managed/crypto.ts +25 -0
  129. package/server-managed/db-methods.ts +1336 -0
  130. package/server-managed/db-neon.ts +171 -0
  131. package/server-managed/db-schema.ts +203 -0
  132. package/server-managed/db-table-names.ts +22 -0
  133. package/server-managed/db.ts +109 -0
  134. package/server-managed/github-app.ts +332 -0
  135. package/server-managed/github-metadata.ts +65 -0
  136. package/server-managed/github-oauth.ts +215 -0
  137. package/server-managed/github-pulls.ts +115 -0
  138. package/server-managed/http-response.ts +18 -0
  139. package/server-managed/http.ts +2043 -0
  140. package/server-managed/import-triage.ts +48 -0
  141. package/server-managed/index.ts +135 -0
  142. package/server-managed/public-workspace.ts +150 -0
  143. package/server-managed/repo-path.ts +21 -0
  144. package/server-managed/report-migration.ts +35 -0
  145. package/server-managed/report-query.ts +4 -0
  146. package/server-managed/report-response.ts +16 -0
  147. package/server-managed/report-sources.ts +154 -0
  148. package/server-managed/repository-discovery.ts +82 -0
  149. package/server-managed/repository-policy.ts +27 -0
  150. package/server-managed/session.ts +78 -0
  151. package/server-managed/slugs.ts +38 -0
  152. package/server-managed/sql-postgres.ts +30 -0
  153. package/server-managed/sql.ts +61 -0
  154. package/server-managed/static.ts +28 -0
  155. package/server-managed/storage.ts +34 -0
  156. package/server-managed/team-catalog.ts +7 -0
  157. package/server-managed/team-feed.ts +128 -0
  158. package/server-managed/team-reports.ts +156 -0
  159. package/server-managed/triage-response.ts +16 -0
  160. package/server-managed/uploads.ts +47 -0
  161. package/server-managed/workspace-shares.ts +150 -0
  162. package/server.ts +50 -0
  163. package/server/index.ts +0 -481
  164. package/server/lifecycle.ts +0 -204
  165. package/server/sync-handlers.ts +0 -327
  166. /package/{server → server-e2e}/config.example.json +0 -0
  167. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  168. /package/{server → server-e2e}/validation.ts +0 -0
@@ -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
+ }
@@ -0,0 +1,144 @@
1
+ // Managed-server boot config, parsed once at startup and
2
+ // failing fast on a missing / invalid required value — the same discipline as
3
+ // server-e2e/config.ts. Auth (GitHub identity + sessions) is required. An
4
+ // optional, SEPARATE GitHub App (id + private key + slug) enables PRIVATE repo
5
+ // listing via installation tokens — kept distinct from the login App so its
6
+ // Contents permission never lands on the login consent. The sync protocol is
7
+ // not parsed here yet.
8
+ import { env } from 'node:process'
9
+ import { databaseUrls } from '../server-common/database-config.ts'
10
+
11
+ const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost'])
12
+
13
+ export interface ManagedConfig {
14
+ port: number
15
+ host: string
16
+ dbPath: string
17
+ neonUrl?: string | null
18
+ blobToken?: string | null
19
+ serverless?: boolean
20
+ debug: boolean
21
+ allowShare: boolean
22
+ trustProxyEnv: string | undefined
23
+ // GitHub App user-authorization (identity) credentials — the App's client id
24
+ // + secret, used by the login flow.
25
+ githubClientId: string
26
+ githubClientSecret: string
27
+ // Preapproved GitHub identity promoted on login when it is the only user
28
+ // and its role is No access, including a login after registration.
29
+ initialAdminGithubId: number | null
30
+ // Absolute callback registered with GitHub, e.g.
31
+ // 'https://triage.example.com/api/oauth/github/callback'.
32
+ oauthCallbackUrl: string
33
+ // Whether cookies carry `Secure` (callback is https). Also gates the
34
+ // `__Host-` prefix, so loopback http dev can run with a plain cookie name.
35
+ cookieSecure: boolean
36
+ sessionCookieName: string
37
+ sessionTtlMs: number
38
+ // Optional SEPARATE GitHub App for PRIVATE repository access — distinct from
39
+ // the login App above. It carries the Contents: Read permission (so its
40
+ // consent never touches login) and is read via installation tokens: the id +
41
+ // PEM private key mint them; the slug builds the "Connect a repository"
42
+ // install URL. id/key absent → private repos are off (public-only listing);
43
+ // slug absent → no install link.
44
+ githubAppId: string | null
45
+ githubAppPrivateKey: string | null
46
+ githubAppSlug: string | null
47
+ // Max accepted size (bytes) for an uploaded report on the "Manage reports"
48
+ // page. Reports are findings dumps (JSON / markdown / CSV), small to a few MB.
49
+ maxReportBytes: number
50
+ // Max accepted size (bytes) for an uploaded bundle (sourcemap / stasis
51
+ // archive). Bundles run larger than reports, so a higher cap (default 100 MiB,
52
+ // matching the e2e objstore per-upload cap).
53
+ maxBundleBytes: number
54
+ // How many triage-trail events to keep per finding (managed_finding_triage_event):
55
+ // 0, the default, keeps everything — the trail is the record. An operator
56
+ // who would rather bound the store sets a positive count; older events of a
57
+ // finding are then trimmed as new ones land.
58
+ triageHistoryLimit: number
59
+ }
60
+
61
+ function fail(msg: string): never {
62
+ throw new Error(msg)
63
+ }
64
+
65
+ function requireStr(name: string): string {
66
+ const v = env[name]
67
+ if (v == null || v === '') fail(`Missing required env ${name} (managed mode).`)
68
+ return v
69
+ }
70
+
71
+ function intEnv(name: string, def: number, min: number, max: number): number {
72
+ const raw = env[name]
73
+ const n = raw == null ? def : Number(raw)
74
+ if (!Number.isSafeInteger(n) || n < min || n > max) {
75
+ fail(`Invalid ${name}: ${raw} — must be an integer in [${min}, ${max}].`)
76
+ }
77
+ return n
78
+ }
79
+
80
+ function optionalGithubId(name: string): number | null {
81
+ const raw = env[name]
82
+ if (raw == null || raw === '') return null
83
+ const id = Number(raw)
84
+ if (!/^[1-9]\d*$/u.test(raw) || !Number.isSafeInteger(id)) {
85
+ fail(`Invalid ${name}: must be a single positive numeric GitHub user ID.`)
86
+ }
87
+ return id
88
+ }
89
+
90
+ function urlOrFail(name: string, raw: string): URL {
91
+ try { return new URL(raw) } catch { fail(`${name} is not a valid URL: ${raw}`) }
92
+ }
93
+
94
+ export function loadManagedConfig({ combined = false } = {}): ManagedConfig {
95
+ const serverless = env['VERCEL'] === '1'
96
+ const neonUrl = databaseUrls({ combined }).managed
97
+ const blobToken = env['BLOB_READ_WRITE_TOKEN'] || null
98
+ if (serverless && !neonUrl) fail('Vercel managed mode requires DATABASE_URL or MANAGED_DATABASE_URL.')
99
+ if (neonUrl && !blobToken) fail('Managed Neon mode requires BLOB_READ_WRITE_TOKEN.')
100
+ const host = env['HOST'] ?? '127.0.0.1'
101
+ const oauthCallbackUrl = requireStr('OAUTH_CALLBACK_URL')
102
+ const callback = urlOrFail('OAUTH_CALLBACK_URL', oauthCallbackUrl)
103
+ const cookieSecure = callback.protocol === 'https:'
104
+ // A `__Host-` cookie mandates `Secure`, so any non-loopback bind must be
105
+ // HTTPS — fail fast (mirrors server-e2e's boot check). Loopback dev over
106
+ // http is allowed with a non-prefixed cookie name.
107
+ if ((serverless || !LOOPBACK_HOSTS.has(host)) && !cookieSecure) {
108
+ fail(`Vercel or non-loopback HOST=${host} requires an https OAUTH_CALLBACK_URL — the session cookie needs Secure.`)
109
+ }
110
+ const sessionCookieName = env['SESSION_COOKIE_NAME'] ?? '__Host-dvsid'
111
+ if (sessionCookieName.startsWith('__Host-') && !cookieSecure) {
112
+ fail(`SESSION_COOKIE_NAME=${sessionCookieName} uses the __Host- prefix but the callback is not https. Use a non-prefixed name for loopback http dev.`)
113
+ }
114
+ return {
115
+ neonUrl, blobToken, serverless,
116
+ port: intEnv('PORT', 8765, 0, 65535),
117
+ host,
118
+ // DB_PATH belongs to e2e in a combined process. Keep the two stores apart.
119
+ dbPath: env['MANAGED_DB_PATH'] ?? (combined ? undefined : env['DB_PATH']) ?? 'server-managed/data/managed.db',
120
+ debug: env['DEBUG'] === '1' || env['DEBUG'] === 'true',
121
+ allowShare: env['DEEPVIEW_ALLOW_SHARE'] === '1',
122
+ trustProxyEnv: env['TRUST_PROXY'] ?? (serverless ? '1' : undefined),
123
+ githubClientId: requireStr('GITHUB_CLIENT_ID'),
124
+ githubClientSecret: requireStr('GITHUB_CLIENT_SECRET'),
125
+ initialAdminGithubId: optionalGithubId('MANAGED_INITIAL_ADMIN_GITHUB_ID'),
126
+ oauthCallbackUrl,
127
+ cookieSecure,
128
+ sessionCookieName,
129
+ sessionTtlMs: intEnv('SESSION_TTL_MS', 1_209_600_000, 60_000, 7_776_000_000),
130
+ githubAppId: env['GITHUB_APP_ID'] ?? null,
131
+ githubAppPrivateKey: normalizePem(env['GITHUB_APP_PRIVATE_KEY']),
132
+ githubAppSlug: env['GITHUB_APP_SLUG'] ?? null,
133
+ maxReportBytes: intEnv('MAX_REPORT_BYTES', 10_485_760, 1, 104_857_600),
134
+ maxBundleBytes: intEnv('MAX_BUNDLE_BYTES', 104_857_600, 1, 1_073_741_824),
135
+ triageHistoryLimit: intEnv('TRIAGE_HISTORY_LIMIT', 0, 0, 1_000_000_000),
136
+ }
137
+ }
138
+
139
+ // PEM private keys are awkward in env vars; accept a literal multi-line value or
140
+ // one with escaped newlines (`\n`). Empty/absent → null (private repos off).
141
+ function normalizePem(raw: string | undefined): string | null {
142
+ if (raw == null || raw === '') return null
143
+ return raw.includes('\\n') ? raw.replaceAll('\\n', '\n') : raw
144
+ }
@@ -0,0 +1,15 @@
1
+ import type { ManagedDb, StoredUser } from './db.ts'
2
+
3
+ export type RepoScope = { repoId: number; path: string | null }
4
+ type Content = { repoId: number | null; repoDirectory?: string }
5
+
6
+ // Read grants for each request: neither the manager role nor a cached page
7
+ // confers repository access. Bundles have repository scope; reports also have
8
+ // a directory scope. Unassigned content is only available to administrators.
9
+ export async function contentAccess(db: ManagedDb, user: StoredUser) {
10
+ const scopes = user.role === 'admin' ? null : await db.listRepoScopesForUser(user.id)
11
+ const bundle = (item: Content) => scopes == null || scopes.some(scope => scope.repoId === item.repoId)
12
+ const report = (item: Content) => scopes == null || scopes.some(scope => scope.repoId === item.repoId
13
+ && (!scope.path || item.repoDirectory === scope.path || item.repoDirectory?.startsWith(scope.path + '/')))
14
+ return { scopes, bundle, report }
15
+ }
@@ -0,0 +1,25 @@
1
+ // Token helpers for the managed auth server. The raw session token lives ONLY
2
+ // in the cookie; the DB row id is its SHA-256 (see db.ts / session.ts), so a
3
+ // DB read can't reconstruct a live cookie. No HMAC pepper is needed — the
4
+ // tokens are 256-bit random, not guessable or reversible from their hash.
5
+ import { createHash, randomBytes, timingSafeEqual } from 'node:crypto'
6
+
7
+ // 256-bit URL-safe random token: the session cookie value and the OAuth CSRF
8
+ // `state` nonce.
9
+ export function randomToken(): string {
10
+ return randomBytes(32).toString('base64url')
11
+ }
12
+
13
+ // At-rest id for a session cookie token (stored as the managed_session PK).
14
+ export function hashToken(token: string): string {
15
+ return createHash('sha256').update(token, 'utf8').digest('base64url')
16
+ }
17
+
18
+ // Constant-time equality for CSRF / OAuth-state checks. A length mismatch
19
+ // short-circuits (the length of these fixed-width tokens isn't a secret).
20
+ export function safeEqual(a: string, b: string): boolean {
21
+ const ab = Buffer.from(a, 'utf8')
22
+ const bb = Buffer.from(b, 'utf8')
23
+ if (ab.length !== bb.length) return false
24
+ return timingSafeEqual(ab, bb)
25
+ }