@preventive/triage 1.0.0-alpha.45 → 1.0.0-alpha.46

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 (79) hide show
  1. package/client/linked-findings.js +3 -0
  2. package/common/bundle-metadata.d.ts +1 -0
  3. package/common/bundle-metadata.js +1 -1
  4. package/common/bundle-sources.js +5 -1
  5. package/common/line-diff.js +5 -4
  6. package/out/{chunk-NPW52BM2.js → chunk-4HI2MSAV.js} +13 -13
  7. package/out/{chunk-AFLZH7WG.js → chunk-4UFAMJAX.js} +1 -1
  8. package/out/{chunk-HMAJDVXN.js → chunk-7XRR4OFU.js} +1 -1
  9. package/out/{chunk-Q6MVYGYV.js → chunk-BOIXMUU5.js} +1 -1
  10. package/out/{chunk-XVBEOGOS.js → chunk-CC4OM6TZ.js} +2 -2
  11. package/out/chunk-G24A42V6.js +2 -0
  12. package/out/{chunk-WI7Z5A2T.js → chunk-JKGQ3O7O.js} +1 -1
  13. package/out/chunk-LE5ZWLG4.js +235 -0
  14. package/out/chunk-MSION3G4.js +36 -0
  15. package/out/{chunk-6HNXNIQY.js → chunk-S6GIXWQW.js} +1 -1
  16. package/out/chunk-V3KWNNMY.js +1 -0
  17. package/out/client-managed-import.js +1 -1
  18. package/out/client-managed.js +25 -25
  19. package/out/compare.js +13 -13
  20. package/out/dialogs.js +40 -40
  21. package/out/graph.js +1 -1
  22. package/out/index.html +6 -6
  23. package/out/scan.js +2 -2
  24. package/out/terminal.js +1 -1
  25. package/out/view.css +1 -1
  26. package/out/view.js +15 -15
  27. package/package.json +3 -1
  28. package/risk/actions/findings.js +14 -9
  29. package/risk/actions/scripts/shell.js +5 -1
  30. package/risk/index.d.ts +5 -2
  31. package/server-e2e/app.ts +5 -4
  32. package/server-e2e/http.ts +5 -2
  33. package/server-managed/bundle-cache.ts +7 -1
  34. package/server-managed/combined.ts +3 -2
  35. package/server-managed/commit-lockfiles.ts +21 -11
  36. package/server-managed/config.ts +6 -0
  37. package/server-managed/db-methods.ts +37 -9
  38. package/server-managed/db-neon.ts +61 -21
  39. package/server-managed/db-schema.ts +4 -0
  40. package/server-managed/db.ts +2 -1
  41. package/server-managed/dependency-reviews.ts +13 -3
  42. package/server-managed/github-app.ts +12 -6
  43. package/server-managed/github-issue-links.ts +11 -5
  44. package/server-managed/github-pulls.ts +2 -2
  45. package/server-managed/github-requests.ts +39 -0
  46. package/server-managed/http-response.ts +10 -2
  47. package/server-managed/http.ts +195 -111
  48. package/server-managed/index.ts +8 -7
  49. package/server-managed/kept-briefly.ts +37 -0
  50. package/server-managed/npm-delta.ts +11 -6
  51. package/server-managed/npm-kept.ts +32 -2
  52. package/server-managed/npm-loads.ts +100 -30
  53. package/server-managed/npm-packages.ts +89 -3
  54. package/server-managed/npm-risk.ts +4 -1
  55. package/server-managed/pretty-print.ts +67 -23
  56. package/server-managed/pull-dependencies.ts +29 -9
  57. package/server-managed/pull-dependency-reviews.ts +8 -8
  58. package/server-managed/queue.ts +16 -0
  59. package/server-managed/rename-diff.ts +22 -13
  60. package/server-managed/report-sources.ts +1 -1
  61. package/server-managed/report-store.ts +42 -9
  62. package/server-managed/repository-actions.ts +15 -6
  63. package/server-managed/repository-browser.ts +119 -31
  64. package/server-managed/repository-dependency-reviews.ts +41 -19
  65. package/server-managed/repository-history.ts +24 -8
  66. package/server-managed/repository-pulse.ts +8 -1
  67. package/server-managed/repository-risk-audit.ts +13 -0
  68. package/server-managed/repository-risk-read.ts +11 -6
  69. package/server-managed/repository-risk.ts +27 -20
  70. package/server-managed/session.ts +18 -4
  71. package/server-managed/sql.ts +5 -2
  72. package/server-managed/storage.ts +4 -2
  73. package/server-managed/team-reports.ts +24 -12
  74. package/server-managed/upstream-cache.ts +12 -0
  75. package/server.ts +14 -5
  76. package/out/chunk-5DHL5NG3.js +0 -2
  77. package/out/chunk-NV7ES4EW.js +0 -1
  78. package/out/chunk-PTGDYLNS.js +0 -36
  79. package/out/chunk-QRD6GIG5.js +0 -229
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.45",
3
+ "version": "1.0.0-alpha.46",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT OR Apache-2.0",
6
6
  "author": {
@@ -139,6 +139,7 @@
139
139
  "server-managed/github-users.ts",
140
140
  "server-managed/github-oauth.ts",
141
141
  "server-managed/github-pulls.ts",
142
+ "server-managed/github-requests.ts",
142
143
  "server-managed/http-response.ts",
143
144
  "server-managed/http.ts",
144
145
  "server-managed/link-reports.ts",
@@ -156,6 +157,7 @@
156
157
  "server-managed/npm-risk.ts",
157
158
  "server-managed/npm-provenance.ts",
158
159
  "server-managed/kept.ts",
160
+ "server-managed/kept-briefly.ts",
159
161
  "server-managed/npm-kept.ts",
160
162
  "server-managed/npm-loads.ts",
161
163
  "server-managed/npm-delta.ts",
@@ -64,16 +64,21 @@ function lockedIn(lockfiles, directory, name) {
64
64
  // The inputs a text names (`${{ inputs.title }}`).
65
65
  const inputsIn = text => typeof text === 'string' ? [...dotted(text).matchAll(/\$\{\{[^}]*?\binputs\s*\.\s*([\w-]+)/gu)].map(match => match[1]) : []
66
66
 
67
- // The inputs a step's action writes into a script: github-script's
68
- // `script`, and those an action of the repository's own (ScanOptions
69
- // `actions`) names in its `run:`s or hands to such an input of a step's.
67
+ // The inputs a step's action, or a job's reusable workflow, writes into a
68
+ // script: github-script's `script`; those one of the repository's own
69
+ // (ScanOptions `actions`) names in its `run:`s or its jobs' images, or hands
70
+ // to such an input of a step's or a job's; and each of another reusable
71
+ // workflow's, or one whose text isn't told, as it may.
70
72
  function scriptInputs(step, actions, seen = new Set()) {
71
73
  if (step.uses?.repo?.toLowerCase() === 'actions/github-script') return ['script']
72
- const dir = step.uses?.kind === 'local' && actions?.has(step.uses.path) && !seen.has(step.uses.path) ? step.uses.path : null
73
- const read = dir === null ? null : readWorkflow('action', actions.get(dir))
74
+ if (step.uses?.kind === 'workflow' || (step.uses?.job && !actions?.has(step.uses.path))) return Object.keys(step.with ?? {})
75
+ const path = step.uses?.kind === 'local' && actions?.has(step.uses.path) && !seen.has(step.uses.path) ? step.uses.path : null
76
+ const read = path === null ? null : readWorkflow(step.uses.job ? 'workflow' : 'action', actions.get(path))
74
77
  if (!read || read instanceof YamlError) return []
75
- return [...new Set(read.jobs.flatMap(job => job.steps.flatMap(each => [...inputsIn(each.run?.script),
76
- ...scriptInputs(each, actions, new Set(seen).add(dir)).flatMap(input => inputsIn(each.with?.[input]))])))]
78
+ const inner = new Set(seen).add(path)
79
+ const handed = each => scriptInputs(each, actions, inner).flatMap(input => inputsIn(each.with?.[input]))
80
+ return [...new Set(read.jobs.flatMap(job => [...job.uses ? handed(job) : [], ...job.images.flatMap(image => inputsIn(image.text)),
81
+ ...job.steps.flatMap(each => [...inputsIn(each.run?.script), ...handed(each)])]))]
77
82
  }
78
83
 
79
84
  // Injections through the inputs an action writes into its scripts, and a
@@ -98,8 +103,8 @@ function jobFindings(job, triggers, { owner, lockfiles, external, actions }) {
98
103
  const finding = usesFinding(job.uses, owner)
99
104
  if (finding) found.push({ ...finding, line: job.uses.line })
100
105
  if (job.secrets === 'inherit' && job.uses.repo && ownerKind(job.uses.repo, owner) === 'third-party') found.push({ id: 'secrets-inherited', severity: 'high', uses: job.uses.uses, key: usesKey(job.uses), line: job.uses.line })
101
- // An input of a reusable workflow, which one writes into its scripts.
102
- for (const [input, value] of Object.entries(job.with ?? {})) for (const { index: _index, ...injected } of scriptInjections(value, triggers)) found.push({ ...injected, line: job.withLines[input] })
106
+ // An input a reusable workflow writes into its scripts.
107
+ for (const input of scriptInputs(job, actions)) for (const { index: _index, ...injected } of scriptInjections(job.with?.[input], triggers)) found.push({ ...injected, line: job.withLines[input] })
103
108
  }
104
109
  for (const image of job.images) {
105
110
  if (!image.read?.digest && !image.text.includes('${{')) found.push({ ...imageFinding(image.text, image.read), line: image.line })
@@ -12,7 +12,11 @@ const SHELL = new RegExp(String.raw`^(?:.*[\/\\])?${SHELLS}(?:\.exe)?$`, 'iu')
12
12
  // pipe, `&&` or `||` the next line goes on from), and the line it starts
13
13
  // on, counting from 0.
14
14
  export function scriptLines(script) {
15
- const lines = script.split('\n')
15
+ // An expression (`${{ … }}`), which GitHub writes in before the shell reads
16
+ // the script, read as a variable is: a value unknown here, each line it
17
+ // spans past its first continuing the one it is on; a `}}` in one of its
18
+ // strings (`format('{{0}}')`) not its end.
19
+ const lines = script.replaceAll(/\$\{\{(?:'[^']*'|[^'])*?\}\}/gu, expression => `$EXPRESSION${' \\\n'.repeat(expression.split('\n').length - 1)}`).split('\n')
16
20
  const joined = []
17
21
  for (let at = 0; at < lines.length; at++) {
18
22
  const start = at
package/risk/index.d.ts CHANGED
@@ -200,8 +200,11 @@ export interface ScanOptions {
200
200
  // Its lockfiles' paths, the packages each locks, as npx runs those, by
201
201
  // name and by `name@version`, and those only devDependencies need.
202
202
  lockfiles?: { path: string; names: ReadonlySet<string>; dev?: ReadonlySet<string> }[]
203
- // Its own actions' metadata by their directories, for where they install
204
- // its packages; else each counts as installing them anywhere.
203
+ // Its own actions' metadata by their directories, and its workflows by
204
+ // their paths: for where its actions install its packages, else each
205
+ // counts as installing them anywhere; and for the inputs its actions and
206
+ // reusable workflows write into their scripts, else each of a reusable
207
+ // workflow's counts as one.
205
208
  actions?: ReadonlyMap<string, string> | null
206
209
  }
207
210
 
package/server-e2e/app.ts CHANGED
@@ -102,11 +102,12 @@ import { createBusReceiver } from './bus-receiver.ts'
102
102
  import { e2eStorageLines } from '../server-common/storage-log.ts'
103
103
 
104
104
  // Each embedding gets its own stores, transports, timers and shutdown state.
105
- export async function createE2eApp(config = loadConfig()) {
106
- return await initializeApp(rollback => assembleE2eApp(config, rollback))
105
+ // `ui: false` serves only the API (see ./http.ts).
106
+ export async function createE2eApp(config = loadConfig(), { ui = true }: { ui?: boolean } = {}) {
107
+ return await initializeApp(rollback => assembleE2eApp(config, ui, rollback))
107
108
  }
108
109
 
109
- async function assembleE2eApp(config: Config, rollback: AsyncDisposableStack) {
110
+ async function assembleE2eApp(config: Config, ui: boolean, rollback: AsyncDisposableStack) {
110
111
  // All external inputs (env vars + optional config.json) are parsed
111
112
  // and validated in ./config.ts; destructure into the uppercase names
112
113
  // the rest of this module uses.
@@ -388,7 +389,7 @@ async function assembleE2eApp(config: Config, rollback: AsyncDisposableStack) {
388
389
  // below.
389
390
  const httpServer = createHttpServer({
390
391
  wss, restDeps: objstoreRestDeps, sseServer, serverInfo: SERVER_INFO, isOriginAllowed,
391
- isShuttingDown, track, handleSaveRest,
392
+ isShuttingDown, track, handleSaveRest, ui,
392
393
  restPutIdleTimeoutMs: REST_PUT_IDLE_TIMEOUT_MS, debug: DEBUG,
393
394
  })
394
395
  const requestHandler = httpServer.listeners('request')[0]!
@@ -59,6 +59,9 @@ export type HttpServerDeps = {
59
59
  handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
60
60
  restPutIdleTimeoutMs: number
61
61
  debug: boolean
62
+ // Serve the static UI bundle (default). False serves only the API and
63
+ // never reads `out/`, for hosts that serve the UI themselves.
64
+ ui?: boolean
62
65
  }
63
66
 
64
67
  // `POST /api/sync/save` dispatch. Returns the in-flight handler promise when
@@ -114,12 +117,12 @@ function dispatchSaveRest(
114
117
  }
115
118
 
116
119
  export function createHttpServer(deps: HttpServerDeps): Server {
117
- const { wss, restDeps, sseServer, serverInfo, isOriginAllowed, isShuttingDown, track, handleSaveRest, restPutIdleTimeoutMs, debug } = deps
120
+ const { wss, restDeps, sseServer, serverInfo, isOriginAllowed, isShuttingDown, track, handleSaveRest, restPutIdleTimeoutMs, debug, ui = true } = deps
118
121
  // Static-file plane (see ./static.ts). The directory is the
119
122
  // `build.js build` output sibling to this file; the loader handles
120
123
  // enumeration, pre-compression, and ETag derivation. Plugged in after
121
124
  // the `/api/objstore/...` REST branch.
122
- const handleStatic = loadStatic(fileURLToPath(new URL('../out', import.meta.url)), serverInfo.deepviewScanServer ?? null)
125
+ const handleStatic = ui ? loadStatic(fileURLToPath(new URL('../out', import.meta.url)), serverInfo.deepviewScanServer ?? null) : () => false
123
126
 
124
127
  const httpServer = createServer((req: HttpRequest, res: ServerResponse) => {
125
128
  // Static mode probe: GET /api/config → this server's `server-info` as JSON.
@@ -24,11 +24,17 @@ export type BundleCacheRecord = Pick<ManagedBundle, 'id' | 'integrity' | 'filena
24
24
  export interface BundleSummary { files: number; codeFiles: number; lines: number; commit?: string; stasisVersion?: number; versionedPackages?: number }
25
25
 
26
26
  export async function readBundleDetails(record: BundleCacheRecord, store: BundleStore) {
27
+ return (await readSizedBundleDetails(record, store))?.details ?? null
28
+ }
29
+
30
+ // As readBundleDetails, with the bytes the bundle decoded to: about what
31
+ // holding its details takes.
32
+ export async function readSizedBundleDetails(record: BundleCacheRecord, store: BundleStore) {
27
33
  const bytes = await store.get(record.id, record.kind)
28
34
  if (!bytes) return null
29
35
  const decoded = record.kind === 'stasis' || record.kind === 'sourcemap' ? await decompress(bytes, { maxOutputLength: MAX_DECODED_BYTES }) : bytes
30
36
  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 })
37
+ return { details: parseBundleContents(decodeUtf8(decoded), { integrity: record.integrity, kind: record.kind, size: record.byteSize }), decodedBytes: decoded.length }
32
38
  }
33
39
 
34
40
  export interface BundleCacheStorage {
@@ -10,13 +10,13 @@ import { loadManagedConfig } from './config.ts'
10
10
  import { LOGIN_PATH } from './github-oauth.ts'
11
11
  import { createManagedApp, logManagedStartup } from './index.ts'
12
12
 
13
- export async function init(mode: 'managed+e2e' | 'e2e+managed'): Promise<Server> {
13
+ export async function init(mode: 'managed+e2e' | 'e2e+managed', { ui = true }: { ui?: boolean } = {}): Promise<Server> {
14
14
  const config = loadManagedConfig({ combined: true })
15
15
  const e2eConfig = loadConfig()
16
16
  if (!e2eConfig.neonUrl && !config.neonUrl && config.dbPath !== ':memory:' && resolve(config.dbPath) === resolve(e2eConfig.dbPath)) {
17
17
  throw new Error('MANAGED_DB_PATH must differ from e2e DB_PATH in combined mode.')
18
18
  }
19
- const e2e = await createE2eApp(e2eConfig)
19
+ const e2e = await createE2eApp(e2eConfig, { ui })
20
20
  // Replace the assembled server's single
21
21
  // request listener; adding a second would write two responses per request.
22
22
  const handlers = e2e.httpServer.listeners('request')
@@ -25,6 +25,7 @@ export async function init(mode: 'managed+e2e' | 'e2e+managed'): Promise<Server>
25
25
  const managed = await createManagedApp(config, {
26
26
  next: e2e.handleRequest,
27
27
  isShuttingDown: e2e.isShuttingDown,
28
+ ui,
28
29
  serverInfo: {
29
30
  mode, managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName },
30
31
  ...(e2eConfig.deepviewScanServer ? { deepviewScanServer: e2eConfig.deepviewScanServer } : {}),
@@ -4,40 +4,50 @@
4
4
  // read as a pull request's are (pull-dependencies.ts readLockfile, kept in
5
5
  // this instance by the commit and path), as many as MAX_LOCKFILES of those,
6
6
  // each's versions (`lockfiles`), and those its reader refuses, of Bun's, or
7
- // past MAX_LOCKFILES (`unread`); and those of some of them (lockedIn), each
8
- // version once by what its reviews are held to (lockedWith), dev only where
9
- // it is wherever it is locked. GitHub having none of a lockfile it lists
10
- // there fails the whole, as it would for any file.
7
+ // past MAX_LOCKFILES (`unread`), as many read at once as READ_AT_ONCE; and
8
+ // those of some of them (lockedIn), each version once by what its reviews
9
+ // are held to (lockedWith), dev only where it is wherever it is locked.
10
+ // GitHub having none of a lockfile it lists there fails the whole, as it
11
+ // would for any file.
11
12
  import { createClient } from '@preventive/upstream/github.js'
12
13
  import { upstreamGithub } from './github-errors.ts'
13
14
  import { GithubApiError } from './github-app.ts'
14
15
  import type { LockedPackage } from './locked-packages.ts'
15
16
  import { type Lru, lru } from './lru.ts'
16
- import { lockedWith, lockfileFormat, readLockfile } from './pull-dependencies.ts'
17
+ import { eachOf, lockedWith, lockfileFormat, lockfileHeld, readLockfile } from './pull-dependencies.ts'
17
18
 
18
19
  export const MAX_LOCKFILES = 100
20
+ const READ_AT_ONCE = 4
19
21
 
20
22
  export interface CommitLockfiles { lockfiles: { path: string; packages: LockedPackage[] }[]; unread: string[] }
23
+ // Set where GitHub was asked for what a read reads (`github`), not all of it
24
+ // held in this instance.
25
+ export interface GithubAsked { github: boolean }
21
26
 
22
27
  // The lockfiles at commit `sha` its review reads, by their paths, each with
23
- // its format (pull-dependencies.ts lockfileFormat), kept by the commit.
28
+ // its format (pull-dependencies.ts lockfileFormat), kept by the commit;
29
+ // `asked` told where GitHub was.
24
30
  const listings: Lru<{ path: string; format: NonNullable<ReturnType<typeof lockfileFormat>> }[]> = lru(20_000, listed => listed.length + 1)
25
- export async function lockfilesAt(fullName: string, token: string, sha: string) {
31
+ export async function lockfilesAt(fullName: string, token: string, sha: string, asked?: GithubAsked) {
26
32
  const key = `${fullName}\0${sha}`
27
33
  const known = listings.get(key)
28
34
  if (known) return known
35
+ if (asked) asked.github = true
29
36
  const { lockfiles } = await upstreamGithub(() => createClient({ token, userAgent: 'deepview-triage' }).findRepoLockfiles({ repo: fullName, sha }))
30
37
  const listed = lockfiles.flatMap(({ path }) => { const format = lockfileFormat(path); return format ? [{ path, format }] : [] })
31
38
  listings.set(key, listed)
32
39
  return listed
33
40
  }
34
41
 
35
- export async function lockedAt(fullName: string, token: string, sha: string, held: (path: string) => boolean = () => true): Promise<CommitLockfiles> {
36
- const listed = (await lockfilesAt(fullName, token, sha)).filter(({ path }) => held(path))
42
+ export async function lockedAt(fullName: string, token: string, sha: string, held: (path: string) => boolean = () => true, asked?: GithubAsked): Promise<CommitLockfiles> {
43
+ const listed = (await lockfilesAt(fullName, token, sha, asked)).filter(({ path }) => held(path))
37
44
  const unread = listed.slice(MAX_LOCKFILES).map(({ path }) => path)
38
45
  const lockfiles: CommitLockfiles['lockfiles'] = []
39
- for (const lockfile of listed.slice(0, MAX_LOCKFILES)) {
40
- const read = await readLockfile(fullName, token, sha, lockfile)
46
+ const reading = listed.slice(0, MAX_LOCKFILES)
47
+ if (asked && reading.some(lockfile => !lockfileHeld(fullName, sha, lockfile))) asked.github = true
48
+ const reads = await eachOf(reading, READ_AT_ONCE, lockfile => readLockfile(fullName, token, sha, lockfile))
49
+ for (const [at, lockfile] of reading.entries()) {
50
+ const read = reads[at]!
41
51
  if ('error' in read && read.error === 'github-not-found') throw new GithubApiError(404, 'github-not-found')
42
52
  if ('error' in read) unread.push(lockfile.path)
43
53
  else lockfiles.push({ path: lockfile.path, packages: read.packages })
@@ -42,6 +42,11 @@ export interface ManagedConfig {
42
42
  cookieSecure: boolean
43
43
  sessionCookieName: string
44
44
  sessionTtlMs: number
45
+ // How long GitHub's answer on a user's access to a repository (its
46
+ // visibility, their permission) is kept for that user and repository
47
+ // (repository-browser.ts), asked again past it; 0 asks on every request
48
+ // (MANAGED_REPOSITORY_ACCESS_CACHE_MS, five minutes by default).
49
+ repositoryAccessCacheMs: number
45
50
  // Optional installation credentials for the same GitHub App as login.
46
51
  // Repository permissions are approved when connecting repositories.
47
52
  githubAppId: string | null
@@ -140,6 +145,7 @@ export function loadManagedConfig({ combined = false } = {}): ManagedConfig {
140
145
  cookieSecure,
141
146
  sessionCookieName,
142
147
  sessionTtlMs: intEnv('SESSION_TTL_MS', 1_209_600_000, 60_000, 7_776_000_000),
148
+ repositoryAccessCacheMs: intEnv('MANAGED_REPOSITORY_ACCESS_CACHE_MS', 300_000, 0, 3_600_000),
143
149
  githubAppId: env['GITHUB_APP_ID'] ?? null,
144
150
  githubAppPrivateKey: normalizePem(env['GITHUB_APP_PRIVATE_KEY']),
145
151
  githubAppSlug: env['GITHUB_APP_SLUG'] ?? null,
@@ -392,6 +392,10 @@ export interface ManagedDb extends DependencyReviewStore, RepositoryDependencyRe
392
392
  // A view session opened by `viewerSessionId`, while that session is current
393
393
  // and its user is still an admin. Presence is the admin's, not the viewed user's.
394
394
  viewSessionWithUser(id: string, viewerSessionId: string, now: number): Promise<{ session: ManagedSession; user: StoredUser } | null>
395
+ // The two above as they read, recording no presence: for a read again in a
396
+ // request whose first read recorded it.
397
+ getSessionWithUser(id: string, now: number): Promise<{ session: ManagedSession; user: StoredUser } | null>
398
+ getViewSessionWithUser(id: string, viewerSessionId: string, now: number): Promise<{ session: ManagedSession; user: StoredUser } | null>
395
399
  // Store `candidate` unless the session already has an upload key; returns
396
400
  // the stored key, or null when the session no longer exists.
397
401
  ensureSessionUploadKey(id: string, candidate: string): Promise<string | null>
@@ -413,6 +417,8 @@ export interface ManagedDb extends DependencyReviewStore, RepositoryDependencyRe
413
417
  deselectRepo(repoId: number): Promise<boolean>
414
418
  listSelectedRepos(): Promise<SelectedRepo[]>
415
419
  listAllRepos(): Promise<ManagedRepo[]>
420
+ // A selected repository's full name, active or not; null for none.
421
+ getRepoFullName(repoId: number): Promise<string | null>
416
422
  deactivateRepo(repoId: number): Promise<boolean>
417
423
  reactivateRepo(repoId: number): Promise<boolean>
418
424
  deleteRepo(repoId: number): Promise<boolean>
@@ -459,6 +465,8 @@ export interface ManagedDb extends DependencyReviewStore, RepositoryDependencyRe
459
465
  // stamp nor the trail. Every change also appends to managed_finding_triage_event;
460
466
  // listTriageHistory walks one finding's trail, newest first.
461
467
  listTriage(findingIds: readonly string[]): Promise<TriageRow[]>
468
+ // The distinct Fix links of those, without the rest of their entries.
469
+ listTriageFixes(findingIds: readonly string[]): Promise<string[]>
462
470
  getAnnotations(findingIds: readonly string[]): Promise<{ triage: TriageRow[]; comments: Awaited<ReturnType<CommentStore['listComments']>>; issues: Awaited<ReturnType<ManagedIssueStore['listManagedIssues']>> }>
463
471
  applyManagedIssueFixes(sessionId: string, snapshot: TeamReportAccessSnapshot, updates: IssueFixUpdate[]): Promise<boolean>
464
472
  // A content-free fingerprint of current annotations, read in one snapshot.
@@ -543,6 +551,15 @@ type SessionRow = {
543
551
  uid: string; login: string; name: string | null; avatar: string | null; role: Role
544
552
  }
545
553
  type ViewSessionRow = SessionRow & { viewerId: string; viewerLogin: string; viewerName: string | null }
554
+ const ownSession = (row: SessionRow) => ({
555
+ session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp, uploadKey: row.uploadKey },
556
+ user: { id: row.uid, login: row.login, name: row.name, avatarUrl: row.avatar, role: row.role },
557
+ })
558
+ const viewSession = (row: ViewSessionRow) => ({
559
+ session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp, uploadKey: row.uploadKey,
560
+ viewer: { id: row.viewerId, login: row.viewerLogin, name: row.viewerName } },
561
+ user: { id: row.uid, login: row.login, name: row.name, avatarUrl: row.avatar, role: row.role },
562
+ })
546
563
 
547
564
  type UserRow = { id: string; login: string; name: string | null; role: Role; created: number; lastSeen: number | null; lastActivity: number | null }
548
565
 
@@ -690,6 +707,7 @@ function prepareStatements(db: ManagedSql) {
690
707
  added_by AS addedBy, added_at AS addedAt, active AS active
691
708
  FROM managed_selected_repo WHERE active = 1 ORDER BY full_name ASC`,
692
709
  ),
710
+ selectRepoFullNameStmt: db.prepare(`SELECT full_name AS fullName FROM managed_selected_repo WHERE repo_id = ?`),
693
711
  selectAllReposStmt: db.prepare(
694
712
  `SELECT repo_id AS repoId, full_name AS fullName, is_private AS priv,
695
713
  installation_id AS installId, default_branch AS branch, cached_default_branch AS cachedDefaultBranch, html_url AS htmlUrl,
@@ -820,6 +838,8 @@ function prepareStatements(db: ManagedSql) {
820
838
  WHERE t.finding_id IN (SELECT value FROM json_each(?))
821
839
  ORDER BY t.finding_id ASC`,
822
840
  ),
841
+ selectTriageFixesStmt: db.prepare(`SELECT DISTINCT fix FROM managed_finding_triage
842
+ WHERE finding_id IN (SELECT value FROM json_each(?)) AND fix IS NOT NULL ORDER BY fix`),
823
843
  annotationTriageRevisionStmt: db.prepare(`
824
844
  SELECT t.finding_id AS id,
825
845
  (SELECT e.batch_id FROM managed_finding_triage_event e
@@ -1065,6 +1085,9 @@ function selectedRepoMethods(stmts: ReturnType<typeof prepareStatements>) {
1065
1085
  const rows = (await selectAllReposStmt.all()) as RepoRow[]
1066
1086
  return rows.map(readManagedRepo)
1067
1087
  },
1088
+ async getRepoFullName(repoId: number): Promise<string | null> {
1089
+ return ((await stmts.selectRepoFullNameStmt.get(repoId)) as { fullName: string } | undefined)?.fullName ?? null
1090
+ },
1068
1091
  async deactivateRepo(repoId: number): Promise<boolean> {
1069
1092
  return Number((await deactivateRepoStmt.run(repoId)).changes) > 0
1070
1093
  },
@@ -1289,6 +1312,10 @@ function triageMethods(stmts: ReturnType<typeof prepareStatements>, historyLimit
1289
1312
  actorId: r.actorId, actorLogin: r.actorLogin, actorName: r.actorName, at: r.at,
1290
1313
  }))
1291
1314
  },
1315
+ async listTriageFixes(findingIds: readonly string[]): Promise<string[]> {
1316
+ if (findingIds.length === 0) return []
1317
+ return ((await stmts.selectTriageFixesStmt.all(JSON.stringify(findingIds))) as { fix: string }[]).map(row => row.fix)
1318
+ },
1292
1319
  async listTriage(findingIds: readonly string[]): Promise<TriageRow[]> {
1293
1320
  if (findingIds.length === 0) return []
1294
1321
  const rows = (await selectTriageStmt.all(JSON.stringify(findingIds))) as TriageDbRow[]
@@ -1658,10 +1685,11 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1658
1685
  const row = (await selectOwnSessionStmt.get(id, now)) as SessionRow | undefined
1659
1686
  if (row == null) return null
1660
1687
  await touchUserSeenStmt.run(now, row.uid, now)
1661
- return {
1662
- session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp, uploadKey: row.uploadKey },
1663
- user: { id: row.uid, login: row.login, name: row.name, avatarUrl: row.avatar, role: row.role },
1664
- }
1688
+ return ownSession(row)
1689
+ },
1690
+ async getSessionWithUser(id, now) {
1691
+ const row = (await selectOwnSessionStmt.get(id, now)) as SessionRow | undefined
1692
+ return row == null ? null : ownSession(row)
1665
1693
  },
1666
1694
  async createViewSession(view, now) {
1667
1695
  if (Number((await insertViewSessionStmt.run(view.id, view.csrfToken, now, view.userId, view.viewerSessionId, now)).changes) === 0) return false
@@ -1672,11 +1700,11 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1672
1700
  const row = (await selectViewSessionStmt.get(id, viewerSessionId, now, now)) as ViewSessionRow | undefined
1673
1701
  if (row == null) return null
1674
1702
  await touchUserSeenStmt.run(now, row.viewerId, now)
1675
- return {
1676
- session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp, uploadKey: row.uploadKey,
1677
- viewer: { id: row.viewerId, login: row.viewerLogin, name: row.viewerName } },
1678
- user: { id: row.uid, login: row.login, name: row.name, avatarUrl: row.avatar, role: row.role },
1679
- }
1703
+ return viewSession(row)
1704
+ },
1705
+ async getViewSessionWithUser(id, viewerSessionId, now) {
1706
+ const row = (await selectViewSessionStmt.get(id, viewerSessionId, now, now)) as ViewSessionRow | undefined
1707
+ return row == null ? null : viewSession(row)
1680
1708
  },
1681
1709
  async ensureSessionUploadKey(id, candidate) {
1682
1710
  const row = (await ensureUploadKeyStmt.get(candidate, id)) as { uploadKey: string } | undefined
@@ -8,7 +8,7 @@ import { AsyncLocalStorage } from 'node:async_hooks'
8
8
  import { WebSocket } from 'ws'
9
9
  import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
10
10
  import { revisionSchema } from './revisions.ts'
11
- import { MANAGED_SCHEMA, VIEW_SESSION_INDEX } from './db-schema.ts'
11
+ import { MANAGED_SCHEMA, REPO_UPLOAD_INDEXES, VIEW_SESSION_INDEX } from './db-schema.ts'
12
12
  import { STORAGE_SCHEMA } from './storage-db.ts'
13
13
  import { GITHUB_COMMIT_SCHEMA, GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
14
14
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
@@ -27,7 +27,8 @@ export interface PgConnection {
27
27
  rows: Record<string, unknown>[]; rowCount?: number | null
28
28
  fields?: { name: string; dataTypeID: number }[]
29
29
  }>
30
- release(): Promise<void>
30
+ // After a failure the connection's state is unknown: discard it, never reuse it.
31
+ release(discard?: boolean): Promise<void>
31
32
  }
32
33
  export type PgConnect = () => Promise<PgConnection>
33
34
  // Content/access mutations cooperate on this transaction-scoped lock. Reads,
@@ -38,7 +39,7 @@ async function currentSchema(db: PgConnection): Promise<boolean> {
38
39
  const exists = (await db.query("SELECT to_regclass('managed_schema_version') AS name")).rows[0]?.['name']
39
40
  if (!exists) return false
40
41
  const versions = new Set((await db.query('SELECT version FROM managed_schema_version')).rows.map(row => Number(row['version'])))
41
- return Array.from({ length: 30 }, (_, i) => i + 1).every(version => versions.has(version))
42
+ return Array.from({ length: 31 }, (_, i) => i + 1).every(version => versions.has(version))
42
43
  }
43
44
 
44
45
  async function migrateRepositoryDefaultCache(db: PgConnection): Promise<void> {
@@ -169,6 +170,12 @@ async function migrateRepositoryDependencyReviewLockfiles(db: PgConnection): Pro
169
170
  await db.query('INSERT INTO managed_schema_version VALUES (30)')
170
171
  }
171
172
 
173
+ async function migrateRepoUploadIndexes(db: PgConnection): Promise<void> {
174
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 31')).rows.length > 0) return
175
+ await db.query(REPO_UPLOAD_INDEXES)
176
+ await db.query('INSERT INTO managed_schema_version VALUES (31)')
177
+ }
178
+
172
179
  async function initialize(db: PgConnection): Promise<void> {
173
180
  if (await currentSchema(db)) return
174
181
  await db.query('BEGIN')
@@ -243,7 +250,7 @@ async function initialize(db: PgConnection): Promise<void> {
243
250
  await db.query(postgresSchema(revisionSchema(true)) + '; INSERT INTO managed_schema_version VALUES (11)')
244
251
  }
245
252
  for (const migrate of [migrateRepositoryDefaultCache, migrateBundleBuildLeases, migrateBundleVisibility, migrateLinkReports, migrateRepositoryAliases, migrateHiddenTeams, migrateIssueFixes, migrateBundleProvenance, migrateUpstreamCache, migrateBundleBuildConditions, migrateSessionUploadKeys, migrateViewSessions, migrateGithubCommits, migrateTeamNpmScopes, migrateDependencyReviews, migrateGithubLinks, migrateRepositoryDependencyReviews,
246
- migrateRepositoryDependencyReviewLockfiles]) await migrate(db)
253
+ migrateRepositoryDependencyReviewLockfiles, migrateRepoUploadIndexes]) await migrate(db)
247
254
  await db.query('COMMIT')
248
255
  } catch (err) {
249
256
  await db.query('ROLLBACK')
@@ -276,7 +283,7 @@ function requestConnections(connect: PgConnect) {
276
283
  const connection = await connect()
277
284
  let result: T
278
285
  try { result = await work(connection) }
279
- catch (error) { await connection.release().catch(() => {}); throw error }
286
+ catch (error) { await connection.release(true).catch(() => {}); throw error }
280
287
  await connection.release()
281
288
  return result
282
289
  }
@@ -287,7 +294,7 @@ function requestConnections(connect: PgConnect) {
287
294
  try { return await work(connection) }
288
295
  catch (error) {
289
296
  delete request.connection
290
- await connection.release().catch(() => {})
297
+ await connection.release(true).catch(() => {})
291
298
  throw error
292
299
  }
293
300
  })
@@ -303,7 +310,7 @@ function requestConnections(connect: PgConnect) {
303
310
  finally {
304
311
  request.ended = true
305
312
  await request.queue
306
- if (failed) await request.connection?.release().catch(() => {})
313
+ if (failed) await request.connection?.release(true).catch(() => {})
307
314
  else await request.connection?.release()
308
315
  }
309
316
  }
@@ -313,7 +320,8 @@ function requestConnections(connect: PgConnect) {
313
320
  // Exposed for parity tests against real PostgreSQL semantics via PGlite.
314
321
  export async function openPostgresManagedDb(connect: PgConnect, options: ManagedDbOptions = {}, reuseConnections = false): Promise<ManagedDb> {
315
322
  const initial = await connect()
316
- try { await initialize(initial) } finally { await initial.release() }
323
+ try { await initialize(initial) } catch (err) { await initial.release(true).catch(() => {}); throw err }
324
+ await initial.release()
317
325
  const { withConnection, withRequest } = requestConnections(connect)
318
326
  const context = new AsyncLocalStorage<PgConnection>()
319
327
  let closed = false
@@ -376,20 +384,52 @@ export async function openPostgresManagedDb(connect: PgConnect, options: Managed
376
384
  return methods
377
385
  }
378
386
 
379
- export async function openNeonManagedDb(url: string, options: ManagedDbOptions = {}): Promise<ManagedDb> {
380
- // Reuse the e2e optional-driver boundary. Requests lease one connection and
381
- // close it before returning; no socket must survive a frozen invocation.
382
- const { Client, neonConfig } = await import('../server-e2e/neon-driver.ts') as unknown as {
383
- Client: new (url: string) => { connect(): Promise<void>; end(): Promise<void>; query: PgConnection['query']; on?(event: 'error', listener: (error: Error) => void): void }
387
+ // Idle connections stay open this long between leases, so a warm instance's
388
+ // requests and feed polls (three seconds apart) skip the TLS, WebSocket and
389
+ // Postgres handshake a new connection repeats.
390
+ const NEON_POOL_IDLE_MS = 5_000
391
+
392
+ type NeonPoolClient = {
393
+ query: PgConnection['query']
394
+ release(discard?: boolean): void
395
+ on(event: string, listener: (...args: unknown[]) => void): void
396
+ }
397
+ export type NeonPool = {
398
+ connect(): Promise<NeonPoolClient>
399
+ end(): Promise<void>
400
+ on(event: string, listener: (...args: unknown[]) => void): void
401
+ }
402
+
403
+ // `attachPool` is Vercel's attachDatabasePool on serverless instances: each
404
+ // release keeps the invocation alive until idle connections have closed, so
405
+ // no socket survives a frozen instance.
406
+ export async function openNeonManagedDb(url: string, options: ManagedDbOptions = {}, attachPool?: (pool: NeonPool) => void): Promise<ManagedDb> {
407
+ // Reuse the e2e optional-driver boundary.
408
+ const { Pool, neonConfig } = await import('../server-e2e/neon-driver.ts') as unknown as {
409
+ Pool: new (config: { connectionString: string; max: number; idleTimeoutMillis: number }) => NeonPool
384
410
  neonConfig: { webSocketConstructor: typeof WebSocket }
385
411
  }
386
412
  neonConfig.webSocketConstructor = WebSocket
387
- return openPostgresManagedDb(async () => {
388
- const client = new Client(url)
389
- // A remote disconnect between operations must reject the next query, not
390
- // become an unhandled EventEmitter error while the request is reading Blob.
391
- client.on?.('error', () => {})
392
- try { await client.connect() } catch (err) { await client.end().catch(() => {}); throw err }
393
- return { query: (sql, params) => client.query(sql, params), release: () => client.end() }
394
- }, options, true)
413
+ // Leases stay unbounded, one per request or operation as before: a cap would
414
+ // stall a request that holds one while it awaits work needing another.
415
+ const pool = new Pool({ connectionString: url, max: Infinity, idleTimeoutMillis: NEON_POOL_IDLE_MS })
416
+ // The pool evicts an idle connection the server closed. Neither that nor a
417
+ // remote disconnect while leased may become an unhandled EventEmitter error;
418
+ // a leased one must instead reject its next query, and is then discarded.
419
+ pool.on('error', () => {})
420
+ attachPool?.(pool)
421
+ const failed = new WeakSet<NeonPoolClient>(), watched = new WeakSet<NeonPoolClient>()
422
+ let ending: Promise<void> | undefined
423
+ const end = () => ending ??= pool.end()
424
+ let db: ManagedDb
425
+ try {
426
+ db = await openPostgresManagedDb(async () => {
427
+ const client = await pool.connect()
428
+ if (!watched.has(client)) { watched.add(client); client.on('error', () => { failed.add(client) }) }
429
+ return { query: (sql, params) => client.query(sql, params),
430
+ release: discard => Promise.try(() => client.release(discard === true || failed.has(client))) }
431
+ }, options, true)
432
+ } catch (err) { await end().catch(() => {}); throw err }
433
+ const close = db.close
434
+ return { ...db, async close() { try { await close() } finally { await end() } } }
395
435
  }
@@ -7,6 +7,10 @@ import { GITHUB_LINK_SCHEMA } from './github-links.ts'
7
7
 
8
8
  // Created after the column migration, which predates it on older databases.
9
9
  export const VIEW_SESSION_INDEX = 'CREATE INDEX IF NOT EXISTS managed_session_viewer_idx ON managed_session(viewer_session);'
10
+ // What a team's scopes join reports and bundles on: their repository, and the
11
+ // directory in it. Created after the column migrations, as above.
12
+ export const REPO_UPLOAD_INDEXES = `CREATE INDEX IF NOT EXISTS managed_report_repo_idx ON managed_report(repo_id, repo_directory);
13
+ CREATE INDEX IF NOT EXISTS managed_bundle_repo_idx ON managed_bundle(repo_id, repo_directory);`
10
14
 
11
15
  export const MANAGED_SCHEMA = `
12
16
  CREATE TABLE IF NOT EXISTS managed_user (
@@ -7,7 +7,7 @@ import { migrateReportLocations } from './report-migration.ts'
7
7
  import { initCommentMethods } from './comments.ts'
8
8
  import { initActivityMethods } from './activity.ts'
9
9
  import { revisionSchema } from './revisions.ts'
10
- import { MANAGED_SCHEMA, VIEW_SESSION_INDEX } from './db-schema.ts'
10
+ import { MANAGED_SCHEMA, REPO_UPLOAD_INDEXES, VIEW_SESSION_INDEX } from './db-schema.ts'
11
11
  import { GITHUB_LINK_USER_COLUMNS } from './github-links.ts'
12
12
  import { REPOSITORY_DEPENDENCY_REVIEW_COLUMNS } from './repository-dependency-reviews.ts'
13
13
  import { STORAGE_SCHEMA } from './storage-db.ts'
@@ -113,6 +113,7 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
113
113
  if (addedVisible) db.prepare('UPDATE managed_report SET visible = 1 WHERE visible = 0').run()
114
114
  ensureColumn(db, 'managed_bundle', 'uploaded_by_login', 'TEXT')
115
115
  ensureColumn(db, 'managed_bundle', 'repo_directory', "TEXT NOT NULL DEFAULT ''")
116
+ db.exec(REPO_UPLOAD_INDEXES)
116
117
  ensureColumn(db, 'managed_bundle', 'visible', 'INTEGER NOT NULL DEFAULT 1')
117
118
  // Existing rows stay NULL (unknown): some may have been built on the server.
118
119
  ensureColumn(db, 'managed_bundle', 'provenance', 'TEXT')
@@ -134,13 +134,23 @@ async function expandReviews(db: ManagedSql, rows: unknown[], user: string): Pro
134
134
  JOIN managed_dependency_review r ON r.id = rt.review_id JOIN managed_team t ON t.id = rt.team_id JOIN managed_user u ON u.id = ?
135
135
  WHERE rt.review_id IN (${placeholders}) AND (r.user_id = u.id OR (${reviewsIn(false)}))
136
136
  ORDER BY t.name, t.id`).all(user, ...ids) as (ReviewTeam & { reviewId: string })[]
137
+ // Each review's own, in their order, by its id.
138
+ const byReview = <T extends { reviewId: string }>(list: T[]) => {
139
+ const of = new Map<string, Omit<T, 'reviewId'>[]>()
140
+ for (const { reviewId, ...row } of list) {
141
+ const own = of.get(reviewId)
142
+ if (own) own.push(row)
143
+ else of.set(reviewId, [row])
144
+ }
145
+ return of
146
+ }
147
+ const [entriesOf, teamsOf] = [byReview(entries), byReview(linked)]
137
148
  return reviews.map(review => {
138
149
  const snapshot = review.files === null ? null : JSON.parse(review.files) as string[] | { paths: string[]; nonTextFiles: string[] }
139
150
  const files = Array.isArray(snapshot) ? snapshot : snapshot?.paths ?? null
140
151
  const nonTextFiles = Array.isArray(snapshot) ? null : snapshot?.nonTextFiles ?? []
141
- const ownEntries = fileEntries(entries.filter(entry => entry.reviewId === review.id).map(({ reviewId: _reviewId, ...entry }) => entry))
142
- return { ...review, files, nonTextFiles, entries: ownEntries, ...summarizeReview(ownEntries, files, nonTextFiles),
143
- teams: linked.filter(team => team.reviewId === review.id).map(({ reviewId: _reviewId, ...team }) => team) }
152
+ const ownEntries = fileEntries((entriesOf.get(review.id) ?? []) as ReviewEntry[])
153
+ return { ...review, files, nonTextFiles, entries: ownEntries, ...summarizeReview(ownEntries, files, nonTextFiles), teams: (teamsOf.get(review.id) ?? []) as ReviewTeam[] }
144
154
  })
145
155
  }
146
156