@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,27 @@
1
+ import type { StoredUser } from './db.ts'
2
+
3
+ const WHITEHAT = [
4
+ 291301, // ChALkeR
5
+ 259122746, // exo-nikita
6
+ 247161625, // ex0sec
7
+ ]
8
+ const whitehatIds = new Set(WHITEHAT)
9
+
10
+ // (1) Server permission to add repositories. Keep this independent of GitHub
11
+ // identity and ecosystem exceptions if other install modes broaden it later.
12
+ export function canAddRepositories(user: StoredUser): boolean {
13
+ return user.role === 'admin'
14
+ }
15
+
16
+ // (3b) Only the ecosystem safeguard for public repositories is bypassed by
17
+ // WHITEHAT. Callers must separately establish (0) server readability, (1) add
18
+ // permission, and (2) server admin or GitHub access to this repository.
19
+ export function passesPublicRepositorySafeguard(githubUserId: number | null, involved: boolean): boolean {
20
+ return involved || (githubUserId != null && whitehatIds.has(githubUserId))
21
+ }
22
+
23
+ // Arbitrary public repositories have no involvement evidence, so (2) requires
24
+ // admin even if (1) expands in the future. This capability is also the UI gate.
25
+ export function canAddAnyPublicRepository(user: StoredUser, githubUserId: number | null): boolean {
26
+ return canAddRepositories(user) && user.role === 'admin' && passesPublicRepositorySafeguard(githubUserId, false)
27
+ }
@@ -0,0 +1,78 @@
1
+ // Session + cookie plumbing for the managed auth server. The raw session
2
+ // token lives ONLY in the cookie; the DB row id is its SHA-256 (see crypto.ts)
3
+ // so a DB read can't reconstruct a live cookie. A double-submit CSRF token is
4
+ // minted per session and returned to the client (echoed back on mutations).
5
+ //
6
+ // Async throughout — the store (db.ts) is async so a future non-SQLite
7
+ // backend slots in without touching these helpers.
8
+ import type { ManagedConfig } from './config.ts'
9
+ import type { ManagedDb, ManagedSession, ManagedUser, StoredUser } from './db.ts'
10
+ import { hashToken, randomToken } from './crypto.ts'
11
+
12
+ // Base name for the short-lived OAuth-state cookie; `__Host-` is prefixed via
13
+ // `cookieName` when cookies are Secure.
14
+ export const STATE_COOKIE = 'dvstate'
15
+
16
+ // `__Host-`-prefix the base name when cookies are Secure (the prefix mandates
17
+ // Secure + Path=/ + no Domain, which `buildCookie` already emits).
18
+ export function cookieName(config: ManagedConfig, base: string): string {
19
+ return config.cookieSecure ? `__Host-${base}` : base
20
+ }
21
+
22
+ export function buildCookie(
23
+ name: string, value: string,
24
+ opts: { maxAgeS: number; secure: boolean; sameSite?: 'Lax' | 'Strict' },
25
+ ): string {
26
+ const parts = [`${name}=${value}`, 'Path=/', 'HttpOnly', `SameSite=${opts.sameSite ?? 'Lax'}`, `Max-Age=${opts.maxAgeS}`]
27
+ if (opts.secure) parts.push('Secure')
28
+ return parts.join('; ')
29
+ }
30
+
31
+ // Expire a cookie now (Max-Age=0).
32
+ export function clearCookie(name: string, secure: boolean): string {
33
+ return buildCookie(name, '', { maxAgeS: 0, secure })
34
+ }
35
+
36
+ export function parseCookies(header: string | undefined): Map<string, string> {
37
+ const out = new Map<string, string>()
38
+ if (!header) return out
39
+ for (const part of header.split(';')) {
40
+ const eq = part.indexOf('=')
41
+ if (eq < 0) continue
42
+ const k = part.slice(0, eq).trim()
43
+ if (k) out.set(k, part.slice(eq + 1).trim())
44
+ }
45
+ return out
46
+ }
47
+
48
+ // Mint a session for a freshly-authenticated user. Upserts the identity, then
49
+ // stores the session keyed by the cookie token's hash. Returns the cookie to
50
+ // set and the CSRF token (also persisted on the row).
51
+ export async function createSession(
52
+ config: ManagedConfig, db: ManagedDb, user: ManagedUser, now: number,
53
+ ): Promise<{ setCookie: string; csrfToken: string; userId: string }> {
54
+ const token = randomToken()
55
+ const csrfToken = randomToken()
56
+ const userId = await db.upsertUser(user, now, config.initialAdminGithubId)
57
+ await db.createSession({ id: hashToken(token), userId, csrfToken, expiresAt: now + config.sessionTtlMs }, now)
58
+ const setCookie = buildCookie(config.sessionCookieName, token, {
59
+ maxAgeS: Math.floor(config.sessionTtlMs / 1000), secure: config.cookieSecure, sameSite: 'Lax',
60
+ })
61
+ return { setCookie, csrfToken, userId }
62
+ }
63
+
64
+ // Resolve the current session (+ user) from the request's cookies, or null.
65
+ export function readSession(
66
+ config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined, now: number,
67
+ ): Promise<{ session: ManagedSession; user: StoredUser } | null> {
68
+ const token = parseCookies(cookieHeader).get(config.sessionCookieName)
69
+ if (token == null || token === '') return Promise.resolve(null)
70
+ return db.sessionWithUser(hashToken(token), now)
71
+ }
72
+
73
+ // Drop the session named by the request's cookie (logout). The caller also
74
+ // clears the cookie client-side.
75
+ export async function endSession(config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined): Promise<void> {
76
+ const token = parseCookies(cookieHeader).get(config.sessionCookieName)
77
+ if (token != null && token !== '') await db.deleteSession(hashToken(token))
78
+ }
@@ -0,0 +1,38 @@
1
+ import type { DatabaseSync } from 'node:sqlite'
2
+
3
+ // Legacy non-UUID IDs stay intact. Production IDs are UUIDs; only their final
4
+ // component is proposed as a slug, never an arbitrary prefix or abbreviation.
5
+ export function preferredSlug(id: string): string {
6
+ return /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-([a-f\d]{12})$/iu.exec(id)?.[1] ?? id
7
+ }
8
+
9
+ export function allocateMissingSlugs(rows: { id: string; slug: string | null }[]): { id: string; slug: string }[] {
10
+ // Reserve legacy IDs before UUID suffixes can claim them.
11
+ const used = new Set(rows.flatMap(row => row.slug == null ? [row.id] : [row.id, row.slug]))
12
+ const allocated = []
13
+ for (const row of rows) {
14
+ if (row.slug != null) continue
15
+ const candidate = preferredSlug(row.id)
16
+ const slug = used.has(candidate) ? row.id : candidate
17
+ allocated.push({ id: row.id, slug })
18
+ used.add(slug)
19
+ }
20
+ return allocated
21
+ }
22
+
23
+ // Allocate once, across the entire namespace rather than a viewer's teams.
24
+ // Keep existing slugs stable when names, memberships, or collisions change.
25
+ export function migrateSlugs(db: DatabaseSync): void {
26
+ db.exec('BEGIN IMMEDIATE')
27
+ try {
28
+ for (const table of ['managed_team', 'managed_report', 'managed_bundle']) {
29
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
30
+ if (!columns.some(column => column.name === 'slug')) db.exec(`ALTER TABLE ${table} ADD COLUMN slug TEXT`)
31
+ const rows = db.prepare(`SELECT id, slug FROM ${table} ORDER BY id`).all() as { id: string; slug: string | null }[]
32
+ const update = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?`)
33
+ for (const row of allocateMissingSlugs(rows)) update.run(row.slug, row.id)
34
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS ${table}_slug_idx ON ${table}(slug)`)
35
+ }
36
+ db.exec('COMMIT')
37
+ } catch (err) { db.exec('ROLLBACK'); throw err }
38
+ }
@@ -0,0 +1,30 @@
1
+ // The small SQL dialect used by the shared store. Parameter values never pass
2
+ // through these rewrites; only our own SQL does. Keep aliases case-sensitive.
3
+ export function postgresSql(source: string): { sql: string; names: string[] } {
4
+ const names: string[] = []
5
+ let position = 0
6
+ let sql = source.replaceAll('json_each(:contexts)', 'jsonb_array_elements(:contexts::jsonb)')
7
+ .replaceAll(/json_extract\((\w+\.value), '\$\.(\w+)'\)/gu, "($1->>'$2')")
8
+ .replaceAll('json_each(?)', 'jsonb_array_elements_text(?::jsonb)')
9
+ .replaceAll('INSERT OR IGNORE INTO', 'INSERT INTO')
10
+ .replaceAll('CROSS JOIN', 'JOIN')
11
+ .replaceAll(/\binstr\(/gu, 'strpos(')
12
+ // Quoted literals (including ':') are never placeholders or identifiers.
13
+ sql = sql.split(/('(?:''|[^'])*')/u).map((part, i) => i % 2 ? part : part
14
+ .replaceAll(/\?|(?<!:):([a-zA-Z]\w*)/gu, (_match, name: string | undefined) => {
15
+ if (!name) return `$${++position}`
16
+ let index = names.indexOf(name)
17
+ if (index < 0) { index = names.length; names.push(name) }
18
+ return `$${index + 1}`
19
+ })
20
+ .replaceAll(/\b[a-z]+[A-Z]\w*\b/gu, name => `"${name}"`)
21
+ ).join('')
22
+ sql = sql.replaceAll(/(\$\d+) IS NULL/gu, '$1::text IS NULL')
23
+ if (source.includes('INSERT OR IGNORE')) sql += ' ON CONFLICT DO NOTHING'
24
+ return { sql, names }
25
+ }
26
+
27
+ export function postgresSchema(sql: string): string {
28
+ return sql.replaceAll(') STRICT;', ');').replaceAll(/\bINTEGER\b/gu, 'BIGINT')
29
+ .replaceAll(/seq\s+BIGINT PRIMARY KEY/gu, 'seq BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY')
30
+ }
@@ -0,0 +1,61 @@
1
+ // Each public store operation owns a transaction. SQLite serializes access to
2
+ // its single connection across awaits; Neon binds one connection per operation.
3
+ import type { DatabaseSync, SQLInputValue } from 'node:sqlite'
4
+ import type { ManagedDb } from './db-methods.ts'
5
+
6
+ type MaybePromise<T> = T | Promise<T>
7
+ export interface ManagedSql {
8
+ prepare(sql: string): {
9
+ get(...params: unknown[]): MaybePromise<unknown>
10
+ all(...params: unknown[]): MaybePromise<unknown[]>
11
+ run(...params: unknown[]): MaybePromise<{ changes: number | bigint }>
12
+ }
13
+ close(): MaybePromise<void>
14
+ }
15
+ export interface ManagedSqlDriver extends ManagedSql {
16
+ scope<T>(write: boolean, work: () => Promise<T>): Promise<T>
17
+ }
18
+
19
+ export function scopeManagedMethods(methods: ManagedDb, driver: ManagedSqlDriver): ManagedDb {
20
+ const entries = Object.entries(methods).map(([name, method]) => {
21
+ if (name === 'close') return [name, async () => { await driver.close() }]
22
+ const write = !/^(?:get|list|userCanRead|reportPermissionsFor)/u.test(name)
23
+ return [name, (...args: unknown[]) => driver.scope(write, () => method(...args))]
24
+ })
25
+ return Object.fromEntries(entries) as ManagedDb
26
+ }
27
+
28
+ export function createSqliteDriver(db: DatabaseSync): ManagedSqlDriver {
29
+ let queue = Promise.resolve()
30
+ let closed = false
31
+ function enqueue<T>(work: () => Promise<T>): Promise<T> {
32
+ const result = queue.then(work)
33
+ queue = result.then(() => undefined, () => undefined)
34
+ return result
35
+ }
36
+ return {
37
+ prepare(sql) {
38
+ const stmt = db.prepare(sql)
39
+ return {
40
+ get: (...args) => stmt.get(...args as SQLInputValue[]),
41
+ all: (...args) => stmt.all(...args as SQLInputValue[]),
42
+ run: (...args) => stmt.run(...args as SQLInputValue[]),
43
+ }
44
+ },
45
+ scope(write, work) {
46
+ if (closed) return Promise.reject(new Error('Managed database is closed'))
47
+ return enqueue(async () => {
48
+ db.exec(write ? 'BEGIN IMMEDIATE' : 'BEGIN')
49
+ try {
50
+ const result = await work()
51
+ db.exec('COMMIT')
52
+ return result
53
+ } catch (err) {
54
+ db.exec('ROLLBACK')
55
+ throw err
56
+ }
57
+ })
58
+ },
59
+ async close() { closed = true; await queue; db.close() },
60
+ }
61
+ }
@@ -0,0 +1,28 @@
1
+ import { type StaticHandler, loadStatic } from '../server-e2e/static.ts'
2
+ import { parseManagedRoute } from '../common/managed/routes.js'
3
+
4
+ // A deep page URL must still resolve every asset at the app root. Preload
5
+ // headers resolve against the request URL, so their hrefs must be absolute too.
6
+ export function managedPageHtml(html: string): string {
7
+ return html.replaceAll(/(href|src)="\.\//gu, '$1="/')
8
+ }
9
+
10
+ export function loadManagedStatic(staticDir: string, { indexOnly = false, scanServer = null }: { indexOnly?: boolean, scanServer?: string | null } = {}): StaticHandler {
11
+ const serve = loadStatic(staticDir, scanServer, { transformIndex: managedPageHtml, indexOnly })
12
+ return (req, res) => {
13
+ if (!req.url?.startsWith('/') || req.url.startsWith('//')) return false
14
+ const url = new URL(req.url, 'http://localhost')
15
+ let path
16
+ try { path = decodeURIComponent(url.pathname) } catch { return false }
17
+ // No API request, including an unknown API route, may receive app HTML.
18
+ if (path === '/api' || path.startsWith('/api/')) return false
19
+ if (serve(req, res)) return true
20
+ if (req.method !== 'GET' && req.method !== 'HEAD') return false
21
+ // Finding IDs can contain dots (including URL-shaped IDs). Recognised
22
+ // pages still receive HTML; missing assets retain a 404.
23
+ if (path.split('/').at(-1)?.includes('.') && !parseManagedRoute(url)) return false
24
+ const original = req.url
25
+ req.url = '/'
26
+ try { return serve(req, res) } finally { req.url = original }
27
+ }
28
+ }
@@ -0,0 +1,34 @@
1
+ // Backend selection is shared by standalone, combined and function entrypoints.
2
+ import { dirname, join } from 'node:path'
3
+ import { createDiskAvatarStore } from './avatar-store.ts'
4
+ import { createDiskBlobStore } from './blob-store.ts'
5
+ import { createDiskBundleStore } from './bundle-store.ts'
6
+ import { createBundleCache, createDiskBundleCache } from './bundle-cache.ts'
7
+ import { createDiskCacheStorage } from './cache-storage.ts'
8
+ import { createReportSourcesCache } from './report-sources.ts'
9
+ import { openManagedVercelStorage } from './blob-vercel.ts'
10
+ import { openNeonManagedDb } from './db-neon.ts'
11
+ import type { ManagedConfig } from './config.ts'
12
+
13
+ export async function openManagedStorage(config: ManagedConfig) {
14
+ const options = { triageHistoryLimit: config.triageHistoryLimit }
15
+ if (config.neonUrl) {
16
+ if (!config.blobToken) throw new Error('Managed Neon mode requires BLOB_READ_WRITE_TOKEN')
17
+ const storage = await openManagedVercelStorage(config.blobToken)
18
+ const db = await openNeonManagedDb(config.neonUrl, options)
19
+ return { ...storage, db, bundleCache: createBundleCache(storage.cacheStorage, db, storage.bundleStore),
20
+ reportSourcesCache: createReportSourcesCache(storage.reportSourcesStorage, db, storage.reportStore, storage.bundleStore),
21
+ }
22
+ }
23
+ if (config.serverless) throw new Error('Serverless managed storage requires Neon')
24
+ const { openSqliteManagedDb } = await import('./db.ts')
25
+ const db = openSqliteManagedDb(config.dbPath, options)
26
+ const dir = dirname(config.dbPath)
27
+ const reportStore = createDiskBlobStore(join(dir, 'reports'))
28
+ const bundleStore = createDiskBundleStore(join(dir, 'bundles'))
29
+ return { db, reportStore, bundleStore,
30
+ reportSourcesCache: createReportSourcesCache(createDiskCacheStorage(join(dir, 'cache', 'report-sources')), db, reportStore, bundleStore),
31
+ avatarStore: createDiskAvatarStore(join(dir, 'avatars')),
32
+ bundleCache: createDiskBundleCache(join(dir, 'cache', 'bundles'), db, bundleStore),
33
+ }
34
+ }
@@ -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,128 @@
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
+
7
+ // Short database reads work across instances without pinning a Neon connection.
8
+ // All polling belongs to the awaited request; nothing runs after it completes.
9
+ export const TEAM_FEED_POLL_MS = 3_000
10
+ export const TEAM_FEED_LIFETIME_MS = 240_000 // below the configured Vercel 300s
11
+ const HEARTBEAT_MS = 15_000
12
+
13
+ type FeedOptions = { pollMs?: number; lifetimeMs?: number }
14
+ type Publish = (event: 'teams' | 'triage', revision: string | undefined) => boolean
15
+
16
+ // Public capabilities remain scoped to their single shared workspace.
17
+ export async function serveTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
18
+ snapshot: TeamReportAccessSnapshot, recheck: () => Promise<void>, options: FeedOptions = {}): Promise<void> {
19
+ let ids: string[] | undefined
20
+ await serveFeed(res, deps, async publish => {
21
+ await recheck()
22
+ ids ??= [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
23
+ const triage = await deps.db.getAnnotationRevision(ids)
24
+ await recheck()
25
+ publish('triage', triage)
26
+ }, options)
27
+ }
28
+
29
+ // Membership and content notifications cover all of the user's teams. Only
30
+ // the focused team needs report parsing or annotation reads; null is the
31
+ // catalog-only subscription used on landing and management pages.
32
+ export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
33
+ sessionId: string, user: StoredUser, teamId: string | null, options: FeedOptions = {}): Promise<void> {
34
+ let ids: string[] = [], visibilityKey: string | undefined
35
+ const checkUser = (current: { user: Pick<StoredUser, 'id' | 'role'> } | null) => {
36
+ if (!current) throw new TeamReportsError(401, 'unauthenticated')
37
+ if (current.user.id !== user.id || current.user.role !== user.role || current.user.role === 'none') {
38
+ throw new TeamReportsError(403, 'access-changed')
39
+ }
40
+ }
41
+ await serveFeed(res, deps, async publish => {
42
+ const catalog = await deps.db.getUserTeamFeedSnapshot(sessionId, Date.now())
43
+ checkUser(catalog)
44
+ checkUser(await deps.db.getReportAccessSnapshot(sessionId, Date.now(), []))
45
+ // Publish catalog state before loading any report blobs. A slow or broken
46
+ // focused report must not hide membership/content changes from the client.
47
+ if (!publish('teams', catalog!.revision) || !teamId) return
48
+ let triage: string | undefined
49
+ const snapshot = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
50
+ if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
51
+ checkUser(snapshot)
52
+ const key = teamSnapshotKey(snapshot)
53
+ if (key !== visibilityKey) {
54
+ try {
55
+ ids = snapshot.teamId ? [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)] : []
56
+ visibilityKey = key
57
+ } catch {
58
+ // Missing, malformed or temporarily unavailable blobs only suspend
59
+ // triage. Retry visibility on the next poll, even for the same key.
60
+ publish('triage', undefined)
61
+ return
62
+ }
63
+ }
64
+ if (snapshot.teamId) triage = JSON.stringify([key, await deps.db.getAnnotationRevision(ids)])
65
+ const current = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
66
+ // A concurrent access/content change discards only the annotation read.
67
+ checkUser(current)
68
+ if (!current || teamSnapshotKey(current) !== key) return
69
+ publish('triage', triage)
70
+ }, options)
71
+ }
72
+
73
+ async function serveFeed(res: ServerResponse, deps: ManagedHttpDeps, read: (publish: Publish) => Promise<void>,
74
+ { pollMs = TEAM_FEED_POLL_MS, lifetimeMs = TEAM_FEED_LIFETIME_MS }: FeedOptions): Promise<void> {
75
+ const controller = new AbortController()
76
+ const close = () => controller.abort()
77
+ res.once('close', close)
78
+ const deadline = setTimeout(close, lifetimeMs)
79
+ const stopped = () => controller.signal.aborted || res.destroyed || deps.isShuttingDown()
80
+ // Notifications carry no annotation bodies, finding IDs, or global cursors.
81
+ // The catalog's opaque version lets clients reuse an identical REST snapshot;
82
+ // every connection still confirms current access, including after reconnects.
83
+ function write(frame: string): boolean {
84
+ if (stopped()) return false
85
+ if (res.write(frame)) return true
86
+ // Slow consumers reconnect and read current state; never queue a backlog.
87
+ res.destroy()
88
+ return false
89
+ }
90
+ try {
91
+ if (stopped()) return
92
+ res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8',
93
+ 'cache-control': 'private, no-store, no-transform', 'x-accel-buffering': 'no',
94
+ 'x-content-type-options': 'nosniff', 'referrer-policy': 'no-referrer' })
95
+ res.flushHeaders()
96
+ let heartbeat = Date.now()
97
+ const previous = new Map<string, string>()
98
+ const publish: Publish = (event, revision) => {
99
+ if (stopped()) return false
100
+ if (revision === undefined) { previous.delete(event); return true }
101
+ if (revision === previous.get(event)) return true
102
+ const data = event === 'teams' ? JSON.stringify({ revision }) : '{}'
103
+ if (!write(`event: ${event}\ndata: ${data}\n\n`)) return false
104
+ previous.set(event, revision)
105
+ heartbeat = Date.now()
106
+ return true
107
+ }
108
+ while (!stopped()) {
109
+ await read(publish)
110
+ if (stopped()) break
111
+ if (Date.now() - heartbeat >= HEARTBEAT_MS) {
112
+ if (!write(': keepalive\n\n')) break
113
+ heartbeat = Date.now()
114
+ }
115
+ await delay(pollMs, undefined, { signal: controller.signal }).catch(error => {
116
+ if (!controller.signal.aborted) throw error
117
+ })
118
+ }
119
+ } catch (error) {
120
+ if (!res.headersSent) throw error
121
+ if (error instanceof TeamReportsError) write('event: close\ndata: {}\n\n')
122
+ else if (!stopped()) throw error
123
+ } finally {
124
+ clearTimeout(deadline)
125
+ res.off('close', close)
126
+ if (res.headersSent && !res.destroyed) res.end()
127
+ }
128
+ }
@@ -0,0 +1,156 @@
1
+ // Team content is filtered as one workspace, with separate report envelopes.
2
+ // Only the visibility index is retained in memory for annotations/source reads.
3
+ import { Buffer } from 'node:buffer'
4
+ import { backfillFindingIds, reportEntries, stampSecurityGroups } from '../report/index.js'
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 { MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
10
+
11
+ type Finding = Record<string, unknown>
12
+ type TeamReport = { id: string; filename: string; data: unknown; repo: { github: string | null; directory: string } }
13
+ type ReportVisibility = { ids: Set<string>; sourcePaths: Set<string> }
14
+ type Visibility = Map<string, ReportVisibility>
15
+ const VISIBILITY_CACHE_MAX_ITEMS = 250_000
16
+ const VISIBILITY_CACHE_MAX_SNAPSHOTS = 32
17
+ const caches = new WeakMap<ManagedDb, Map<string, Visibility>>()
18
+ function visibilityWeight(visible: Visibility): number {
19
+ return [...visible.values()].reduce((sum, v) => sum + v.ids.size + v.sourcePaths.size, 0)
20
+ }
21
+ function retainVisibility(db: ManagedDb, key: string, visible: Visibility): void {
22
+ let cache = caches.get(db)
23
+ // Replacement contributes its weight once and does not evict a peer merely
24
+ // because the cache already holds the maximum number of snapshots.
25
+ cache?.delete(key)
26
+ const incoming = visibilityWeight(visible)
27
+ if (incoming > VISIBILITY_CACHE_MAX_ITEMS) return
28
+ if (!cache) { cache = new Map(); caches.set(db, cache) }
29
+ let total = incoming + [...cache.values()].reduce((sum, entry) => sum + visibilityWeight(entry), 0)
30
+ for (const [oldest, entry] of cache) {
31
+ if (cache.size < VISIBILITY_CACHE_MAX_SNAPSHOTS && total <= VISIBILITY_CACHE_MAX_ITEMS) break
32
+ cache.delete(oldest)
33
+ total -= visibilityWeight(entry)
34
+ }
35
+ cache.set(key, visible)
36
+ }
37
+ export class TeamReportsError extends Error {
38
+ status: number
39
+ constructor(status: number, error: string) { super(error); this.status = status }
40
+ }
41
+ export function teamSnapshotKey(snapshot: TeamReportAccessSnapshot): string {
42
+ return JSON.stringify([snapshot.user.id, snapshot.user.role, snapshot.teamId, snapshot.reports, snapshot.repositories])
43
+ }
44
+ export async function teamSnapshot(db: ManagedDb, sessionId: string, teamId: string): Promise<TeamReportAccessSnapshot> {
45
+ const snapshot = await db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
46
+ if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
47
+ if (!snapshot.teamId) throw new TeamReportsError(404, 'no-team')
48
+ return snapshot
49
+ }
50
+ export async function recheckTeam(db: ManagedDb, sessionId: string, snapshot: TeamReportAccessSnapshot): Promise<void> {
51
+ if (teamSnapshotKey(await teamSnapshot(db, sessionId, snapshot.teamId!)) !== teamSnapshotKey(snapshot)) throw new TeamReportsError(404, 'workspace-changed')
52
+ }
53
+ function groupsOf(data: unknown): Finding[][] {
54
+ return (reportEntries(data) ?? []).map(entry => (Array.isArray(entry) ? entry : [entry]).filter(
55
+ (finding): finding is Finding => finding !== null && typeof finding === 'object' && !Array.isArray(finding),
56
+ ))
57
+ }
58
+ function linksOf(data: unknown): string[][] | null {
59
+ const record = data as { source?: string; links?: string[][] }
60
+ if (record?.source !== 'links' || !Array.isArray(record.links)) return null
61
+ if (!record.links.every(ids => Array.isArray(ids) && ids.every(id => typeof id === 'string'))) throw new TeamReportsError(422, 'unreadable-links')
62
+ return record.links
63
+ }
64
+ async function loadTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<{ reports: TeamReport[]; visible: Visibility }> {
65
+ 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')
66
+ const reports: TeamReport[] = []
67
+ let inputBytes = 0
68
+ // Retain parsed reports for cross-report classification; release raw bytes
69
+ // after each parse. No unbounded burst of remote reads or duplicate buffers.
70
+ for (const access of snapshot.reports) {
71
+ const bytes = await store.get(access.id)
72
+ if (!bytes) throw new TeamReportsError(503, 'unavailable')
73
+ inputBytes += bytes.length
74
+ if (inputBytes > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
75
+ const parsed = readManagedReport(bytes.toString('utf8'), access.filename)
76
+ if (!parsed.data) throw new TeamReportsError(422, 'unreadable-report')
77
+ // Backfill before filtering so links, annotations and the client agree on
78
+ // IDs even when hiding a component would change the original row shape.
79
+ await backfillFindingIds(groupsOf(parsed.data).flat())
80
+ reports.push({ id: access.id, filename: access.filename, data: parsed.data, repo: access.repo })
81
+ }
82
+ const edges = new Map<string, Set<string>>()
83
+ const connect = (a: string, b: string) => {
84
+ if (!edges.has(a)) edges.set(a, new Set())
85
+ edges.get(a)!.add(b)
86
+ }
87
+ // A star is enough for transitive security propagation, without a quadratic
88
+ // expansion for large links. Unknown IDs can connect known findings.
89
+ for (const report of reports) {
90
+ for (const ids of linksOf(report.data) ?? []) {
91
+ for (const id of ids.slice(1)) { connect(ids[0]!, id); connect(id, ids[0]!) }
92
+ }
93
+ }
94
+ const groups = reports.flatMap(report => groupsOf(report.data).map(group => group.map(f => projectFinding(f, report.data as Finding)!)))
95
+ stampSecurityGroups(groups, { linkedIds: id => edges.get(id) ?? [] })
96
+ const securityIds = new Set(groups.flat().filter(f => f['isSecurity'] === true).map(f => String(f['id'])))
97
+ const allIds = new Set<string>(), visible: Visibility = new Map()
98
+ for (let i = 0; i < reports.length; i++) {
99
+ const access = snapshot.reports[i]!, report = reports[i]!
100
+ report.data = filterReportData(report.data, access.permissions, access.repo, securityIds)
101
+ const ids = new Set<string>()
102
+ for (const finding of groupsOf(report.data).flat()) {
103
+ // Preserve linked security classification for the UI lens, including a
104
+ // linked component that a dependency permission will hide from the UI.
105
+ finding['isSecurity'] = securityIds.has(String(finding['id']))
106
+ if (typeof finding['id'] === 'string') { ids.add(finding['id']); allIds.add(finding['id']) }
107
+ }
108
+ visible.set(report.id, { ids, sourcePaths: managedFindingSourcePaths(groupsOf(report.data).flat()) })
109
+ }
110
+ for (const report of reports) {
111
+ const links = linksOf(report.data)
112
+ if (links) report.data = { source: 'links', findings: [], links: links.map(ids => [...new Set(ids.filter(id => allIds.has(id)))]).filter(ids => ids.length >= 2) }
113
+ }
114
+ let outputBytes = 14
115
+ for (const report of reports) {
116
+ outputBytes += Buffer.byteLength(JSON.stringify(report)) + 1
117
+ if (outputBytes > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
118
+ }
119
+ retainVisibility(db, teamSnapshotKey(snapshot), visible)
120
+ return { reports, visible }
121
+ }
122
+ export async function loadTeamReports(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<TeamReport[]> {
123
+ return (await loadTeamWorkspace(db, store, snapshot)).reports
124
+ }
125
+ async function teamVisibility(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<Visibility> {
126
+ const key = teamSnapshotKey(snapshot)
127
+ let visible = caches.get(db)?.get(key)
128
+ if (!visible) {
129
+ // A large snapshot may deliberately remain uncached, or be evicted by
130
+ // another request. Authorization uses this load's result in either case.
131
+ visible = (await loadTeamWorkspace(db, store, snapshot)).visible
132
+ }
133
+ return visible
134
+ }
135
+ export async function teamWorkspaceFindingIds(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<Set<string>> {
136
+ const ids = new Set<string>()
137
+ for (const report of (await teamVisibility(db, store, snapshot)).values()) for (const id of report.ids) ids.add(id)
138
+ return ids
139
+ }
140
+ export async function teamReportVisibility(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot, reportId: string): Promise<ReportVisibility> {
141
+ if (!snapshot.reports.some(r => r.id === reportId)) throw new TeamReportsError(404, 'no-report')
142
+ const visible = await teamVisibility(db, store, snapshot)
143
+ return visible.get(reportId)!
144
+ }
145
+ export async function teamFindingIds(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
146
+ const snapshot = await teamSnapshot(db, sessionId, teamId)
147
+ const visible = await teamReportVisibility(db, store, snapshot, reportId)
148
+ await recheckTeam(db, sessionId, snapshot)
149
+ return visible.ids
150
+ }
151
+ export async function teamSourcePaths(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
152
+ const snapshot = await teamSnapshot(db, sessionId, teamId)
153
+ const visible = await teamReportVisibility(db, store, snapshot, reportId)
154
+ await recheckTeam(db, sessionId, snapshot)
155
+ return visible.sourcePaths
156
+ }
@@ -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
+ }