@preventive/triage 1.0.0-alpha.44 → 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 (207) 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 +25 -2
  5. package/common/default-scan-models.ts +1 -1
  6. package/common/dependency-reviews.d.ts +0 -5
  7. package/common/dependency-reviews.js +0 -17
  8. package/common/line-diff.js +5 -4
  9. package/common/managed/agent-tasks.ts +292 -0
  10. package/common/managed/github-agents.ts +92 -0
  11. package/common/managed/github-logins.ts +49 -0
  12. package/common/managed/roles.ts +7 -0
  13. package/common/managed/routes.js +88 -7
  14. package/common/managed/team-app.d.ts +5 -2
  15. package/common/managed/team-app.js +2 -2
  16. package/common/server-info.ts +3 -0
  17. package/common/workspace-app.js +6 -3
  18. package/out/brotli-fallback.js +3 -3
  19. package/out/chunk-2XUEZHIP.js +18 -18
  20. package/out/chunk-2ZDZWLFY.js +1 -0
  21. package/out/chunk-3PS4KWD2.js +10 -0
  22. package/out/chunk-4HI2MSAV.js +17 -0
  23. package/out/chunk-4UFAMJAX.js +1 -0
  24. package/out/chunk-7C7DWL7I.js +1 -0
  25. package/out/chunk-7XRR4OFU.js +1 -0
  26. package/out/chunk-BC2UCLFJ.js +1 -0
  27. package/out/chunk-BOIXMUU5.js +3 -0
  28. package/out/chunk-C4Z6ZBXV.js +6 -6
  29. package/out/chunk-CC4OM6TZ.js +3 -0
  30. package/out/chunk-G24A42V6.js +2 -0
  31. package/out/chunk-GDPCLSOS.js +2 -0
  32. package/out/chunk-GO7FT4OD.js +1 -0
  33. package/out/chunk-I2MDGSQX.js +3 -0
  34. package/out/chunk-IYPY47MC.js +1 -0
  35. package/out/chunk-IZ7POWI3.js +18 -0
  36. package/out/chunk-JCIIBLKR.js +1 -1
  37. package/out/chunk-JKGQ3O7O.js +6 -0
  38. package/out/chunk-LE5ZWLG4.js +235 -0
  39. package/out/chunk-MSION3G4.js +36 -0
  40. package/out/chunk-NRPC74AM.js +1 -1
  41. package/out/chunk-OKRLLE5I.js +17 -0
  42. package/out/chunk-OOJM4CTU.js +1 -1
  43. package/out/chunk-PNHBZGDD.js +1 -1
  44. package/out/chunk-PXK7EU4V.js +1 -0
  45. package/out/chunk-QB4CBOX2.js +1 -0
  46. package/out/chunk-R3NZFXVR.js +1 -0
  47. package/out/chunk-R5O6FAW4.js +1 -1
  48. package/out/chunk-S6GIXWQW.js +52 -0
  49. package/out/chunk-SXV2T5C4.js +80 -0
  50. package/out/chunk-TUII34K2.js +1 -1
  51. package/out/chunk-V3KWNNMY.js +1 -0
  52. package/out/chunk-VFWMQ3LG.js +2 -0
  53. package/out/chunk-VH4XMZIW.js +2 -0
  54. package/out/chunk-WFYLMF6Z.js +10 -0
  55. package/out/chunk-WQ74ZBZF.js +7 -0
  56. package/out/client-managed-import.js +3 -3
  57. package/out/client-managed.js +31 -35
  58. package/out/client-sync.js +11 -11
  59. package/out/compare.js +35 -0
  60. package/out/dialogs.js +51 -0
  61. package/out/github-issue-authorized.html +1 -1
  62. package/out/github-issue-authorized.js +1 -1
  63. package/out/graph.js +17 -17
  64. package/out/index.html +30 -34
  65. package/out/prism.js +14 -14
  66. package/out/scan.js +2 -0
  67. package/out/terminal.js +294 -293
  68. package/out/view.css +1 -1
  69. package/out/view.js +16 -107
  70. package/package.json +53 -6
  71. package/risk/actions/findings.js +161 -0
  72. package/risk/actions/resolve.js +219 -0
  73. package/risk/actions/scripts/commands.js +182 -0
  74. package/risk/actions/scripts/grade.js +113 -0
  75. package/risk/actions/scripts/packages.js +123 -0
  76. package/risk/actions/scripts/shell.js +169 -0
  77. package/risk/actions/uses.js +76 -0
  78. package/risk/actions/workflow.js +110 -0
  79. package/risk/files/hosts.js +6 -6
  80. package/risk/files/readability.js +23 -17
  81. package/risk/files/runs-code.js +18 -6
  82. package/risk/files/script.js +7 -3
  83. package/risk/files/stamps.js +8 -7
  84. package/risk/index.d.ts +71 -1
  85. package/risk/index.js +6 -2
  86. package/risk/npm/assess.js +1 -1
  87. package/risk/npm/compare.js +34 -10
  88. package/risk/npm/files.js +413 -2
  89. package/risk/npm/tarball.js +56 -73
  90. package/server-e2e/app.ts +5 -4
  91. package/server-e2e/http.ts +5 -2
  92. package/server-managed/agent-merges.ts +198 -0
  93. package/server-managed/app-agents.ts +30 -0
  94. package/server-managed/avatar-store.ts +1 -0
  95. package/server-managed/bundle-cache.ts +7 -1
  96. package/server-managed/combined.ts +3 -2
  97. package/server-managed/commit-coauthors.ts +83 -0
  98. package/server-managed/commit-lockfiles.ts +72 -0
  99. package/server-managed/config.ts +6 -0
  100. package/server-managed/db-methods.ts +66 -19
  101. package/server-managed/db-neon.ts +89 -21
  102. package/server-managed/db-schema.ts +13 -2
  103. package/server-managed/db.ts +9 -1
  104. package/server-managed/dependabot-access.ts +60 -0
  105. package/server-managed/dependabot-alerts.ts +185 -0
  106. package/server-managed/dependency-review-comments.ts +97 -0
  107. package/server-managed/dependency-reviews.ts +78 -18
  108. package/server-managed/github-accounts.ts +33 -0
  109. package/server-managed/github-actions.ts +71 -0
  110. package/server-managed/github-app.ts +33 -13
  111. package/server-managed/github-errors.ts +2 -0
  112. package/server-managed/github-issue-links.ts +11 -5
  113. package/server-managed/github-link-oauth.ts +69 -0
  114. package/server-managed/github-links.ts +204 -0
  115. package/server-managed/github-oauth.ts +5 -2
  116. package/server-managed/github-pulls.ts +2 -2
  117. package/server-managed/github-requests.ts +39 -0
  118. package/server-managed/github-users.ts +30 -0
  119. package/server-managed/http-response.ts +10 -2
  120. package/server-managed/http.ts +1251 -97
  121. package/server-managed/index.ts +13 -8
  122. package/server-managed/kept-briefly.ts +37 -0
  123. package/server-managed/locked-packages.ts +37 -16
  124. package/server-managed/lockfile-scope.ts +49 -0
  125. package/server-managed/npm-delta-worker.js +23 -0
  126. package/server-managed/npm-delta.ts +178 -0
  127. package/server-managed/npm-insights.ts +20 -11
  128. package/server-managed/npm-kept.ts +83 -6
  129. package/server-managed/npm-loads.ts +114 -37
  130. package/server-managed/npm-packages.ts +110 -64
  131. package/server-managed/npm-risk.ts +4 -1
  132. package/server-managed/pretty-print.ts +67 -23
  133. package/server-managed/pull-comment-reviews.ts +161 -0
  134. package/server-managed/pull-dependencies.ts +300 -0
  135. package/server-managed/pull-dependency-reviews.ts +318 -0
  136. package/server-managed/pull-reviews.ts +205 -0
  137. package/server-managed/queue.ts +16 -0
  138. package/server-managed/release-commits.ts +84 -0
  139. package/server-managed/rename-diff.ts +22 -13
  140. package/server-managed/report-sources.ts +1 -1
  141. package/server-managed/report-store.ts +42 -9
  142. package/server-managed/repository-actions.ts +218 -0
  143. package/server-managed/repository-agents.ts +220 -0
  144. package/server-managed/repository-browser.ts +166 -37
  145. package/server-managed/repository-changes.ts +284 -0
  146. package/server-managed/repository-code.ts +52 -0
  147. package/server-managed/repository-dependency-reviews.ts +157 -0
  148. package/server-managed/repository-history.ts +461 -0
  149. package/server-managed/repository-issues.ts +181 -0
  150. package/server-managed/repository-pulse.ts +439 -0
  151. package/server-managed/repository-risk-audit.ts +13 -0
  152. package/server-managed/repository-risk-read.ts +170 -0
  153. package/server-managed/repository-risk.ts +133 -118
  154. package/server-managed/reviewed-dependencies.ts +149 -0
  155. package/server-managed/session.ts +27 -9
  156. package/server-managed/sql.ts +6 -3
  157. package/server-managed/storage-stores.ts +5 -0
  158. package/server-managed/storage.ts +4 -2
  159. package/server-managed/team-app.ts +19 -3
  160. package/server-managed/team-reports.ts +24 -12
  161. package/server-managed/upstream-cache.ts +12 -0
  162. package/server-managed/workspace-shares.ts +4 -1
  163. package/server.ts +25 -5
  164. package/out/chunk-2ABLKCOY.js +0 -46
  165. package/out/chunk-2KBF5TZ7.js +0 -2
  166. package/out/chunk-35VE6CDH.js +0 -2
  167. package/out/chunk-3BZ7JERB.js +0 -1
  168. package/out/chunk-4XCDFGDP.js +0 -10
  169. package/out/chunk-5LSXOLUS.js +0 -1
  170. package/out/chunk-5RCM2J63.js +0 -1
  171. package/out/chunk-7B6HB4D7.js +0 -1
  172. package/out/chunk-AJ4GTATZ.js +0 -3
  173. package/out/chunk-CH4UN6WK.js +0 -225
  174. package/out/chunk-CMQA4DM6.js +0 -1
  175. package/out/chunk-CVVJAXVY.js +0 -2
  176. package/out/chunk-DIJJNTRH.js +0 -10
  177. package/out/chunk-EDKLBQQN.js +0 -17
  178. package/out/chunk-F47DXCEV.js +0 -33
  179. package/out/chunk-FK3DZEKL.js +0 -1
  180. package/out/chunk-G6AJWENO.js +0 -1
  181. package/out/chunk-GJ4IUCNS.js +0 -1
  182. package/out/chunk-GNXOKIGP.js +0 -80
  183. package/out/chunk-GZ4MS3TE.js +0 -13
  184. package/out/chunk-GZWM7UQM.js +0 -2
  185. package/out/chunk-IU3CCINR.js +0 -3
  186. package/out/chunk-K3X5EJBQ.js +0 -2
  187. package/out/chunk-KEX2ATEW.js +0 -2
  188. package/out/chunk-KYCNJVB7.js +0 -1
  189. package/out/chunk-LPCTJYNA.js +0 -7
  190. package/out/chunk-O4RJY5TP.js +0 -2
  191. package/out/chunk-OD4LPN3H.js +0 -1
  192. package/out/chunk-QWDWTBM7.js +0 -1
  193. package/out/chunk-QYWKC557.js +0 -1
  194. package/out/chunk-R75XOKUP.js +0 -1
  195. package/out/chunk-SWTLADW5.js +0 -1
  196. package/out/chunk-T7OKH4VW.js +0 -1
  197. package/out/chunk-TZ5KB2Y5.js +0 -10
  198. package/out/chunk-UNTRGE26.js +0 -2
  199. package/out/chunk-UTR2LVME.js +0 -2
  200. package/out/chunk-UYAXVRVL.js +0 -1
  201. package/out/chunk-V3ME6FI5.js +0 -2
  202. package/out/chunk-V5O7M2YT.js +0 -1
  203. package/out/chunk-VIHGO77Y.js +0 -1
  204. package/out/chunk-W4J3MGLB.js +0 -1
  205. package/out/chunk-WGG7KQUH.js +0 -1
  206. package/out/chunk-XYFXXB2G.js +0 -10
  207. package/out/chunk-ZESLQCTF.js +0 -6
@@ -0,0 +1,149 @@
1
+ // The reviewed dependencies a host keeps beyond the app, as it registers
2
+ // them before the server is assembled (server.ts registerReviewedDependencies):
3
+ // an async method telling whether a dependency, as a lockfile locks it, is
4
+ // reviewed there (`lookup`), and, optionally, one reading the reviews of
5
+ // dependencies an open pull request's comments make (`parseComments`,
6
+ // pull-comment-reviews.ts), and one formatting one's own dependency reviews
7
+ // as a comment, for a copy button to give (`formatDependencyReviews`,
8
+ // dependency-review-comments.ts). Registered, they replace the commit a repository
9
+ // is reviewed since (pull-dependency-reviews.ts): a version passes by the
10
+ // reviews its teams publish, and those its pull request's comments make, as
11
+ // many as the repository requires, or by `lookup`'s saying so, and is
12
+ // otherwise unreviewed. A patched version (pnpm's patchedDependencies)
13
+ // passes by `lookup`, or reviews its comments make of its patch, alone, the
14
+ // app reviewing no patch yet. Those comments' reviews count for the pull
15
+ // request alone, as it is open: the host's to keep, as it is merged, among
16
+ // those `lookup` tells of.
17
+ //
18
+ // Each version is asked of `lookup` once a request, as many at once as
19
+ // ASK_AT_ONCE, its answer kept in this instance KEPT_MS; one it fails to
20
+ // answer is unreviewed, and counted so (`unasked`).
21
+ import { type Lru, lru } from './lru.ts'
22
+
23
+ export type ReviewedLockfile = 'npm' | 'pnpm' | 'yarn1'
24
+ // A dependency as a lockfile locks it: npm's always with its integrity (one
25
+ // with none is never reviewed, nor asked of), its shasum where the lockfile
26
+ // has one (Yarn 1's, after a tarball's `#`), and the hash of its patch where
27
+ // it is patched (pnpm's).
28
+ export interface ReviewedDependency {
29
+ ecosystem: 'npm'
30
+ lockfile: ReviewedLockfile
31
+ name: string
32
+ version: string
33
+ integrity?: string
34
+ shasum?: string
35
+ patchHash?: string
36
+ }
37
+ export type ReviewVerdict = 'accept' | 'accept-with-nits' | 'request-changes' | 'reject'
38
+ // A comment of an open pull request's, as `parseComments` is given it: one
39
+ // on the pull request (`comment`), a review's own (`review`, whatever its
40
+ // state, a comment's alone too), or one on a line of its diff
41
+ // (`review-comment`); by its author's GitHub login; when it was written, and
42
+ // last edited (`editedAt`) where it was.
43
+ export interface ReviewComment {
44
+ kind: 'comment' | 'review' | 'review-comment'
45
+ id: string
46
+ author: string | null
47
+ body: string
48
+ state?: string
49
+ createdAt: string
50
+ editedAt?: string
51
+ url: string
52
+ }
53
+ // What `parseComments` reads: the pull request, its comments, and the
54
+ // dependencies: every one the lockfiles its review holds to add that can be
55
+ // reviewed and `lookup` doesn't pass, and the others still wanting reviews.
56
+ export interface ParseCommentsInput { repository: string; number: number; comments: ReviewComment[]; dependencies: ReviewedDependency[] }
57
+ // A review its comments make, by `user` (a GitHub login, one of their
58
+ // authors'), of each of `dependencies`, with its verdict, as of `time` (when
59
+ // it was made, an ISO date or milliseconds; that user's latest comment's
60
+ // where none is told).
61
+ export interface CommentReview {
62
+ user: string
63
+ time?: string | number
64
+ dependencies: { ecosystem?: 'npm'; name: string; version: string; integrity: string; patchHash?: string; status: ReviewVerdict }[]
65
+ }
66
+ // A version a dependency review is of, or compared it with: its package's
67
+ // name, its version and integrity, and its tarball's shasum and its patch's
68
+ // hash where they are known (a lockfile's, pnpm's patched dependencies').
69
+ export interface ReviewedVersion { name: string; version: string; integrity: string; shasum?: string; patchHash?: string }
70
+ // One of one's own published dependency reviews, as `formatDependencyReviews`
71
+ // is given it: the version it is of (`new`) and the one it compared it with
72
+ // (`old`, none where it read its package whole); its verdict (`status`);
73
+ // whether it read every file (`coverage`, `full`, else `partial`); its final
74
+ // comment; each file's verdict and comment (`files`, of those with either);
75
+ // by whom (`reviewer`, their GitHub login), and when it was published.
76
+ export interface DependencyReviewEntry {
77
+ ecosystem: 'npm'
78
+ old: ReviewedVersion | null
79
+ new: ReviewedVersion
80
+ status: ReviewVerdict
81
+ coverage: 'full' | 'partial'
82
+ comment: string
83
+ files: { path: string; status: ReviewVerdict | null; comment: string }[]
84
+ reviewer: string
85
+ publishedAt: number
86
+ }
87
+ // What `formatDependencyReviews` formats: one's own reviews, never another's,
88
+ // and the pull request whose versions they are of, where its copy asks.
89
+ export interface FormatDependencyReviewsInput { reviews: DependencyReviewEntry[]; pullRequest?: { repository: string; number: number } }
90
+ export interface ReviewedDependencies {
91
+ lookup(dependency: ReviewedDependency): Promise<boolean>
92
+ parseComments?(input: ParseCommentsInput): Promise<CommentReview[]>
93
+ // The comment to copy, none where it is empty, null or undefined.
94
+ formatDependencyReviews?(input: FormatDependencyReviewsInput): Promise<string | null | undefined>
95
+ }
96
+
97
+ const ASK_AT_ONCE = 16
98
+ const KEPT_MS = 60_000
99
+
100
+ let registered: ReviewedDependencies | null = null
101
+ let assembled = false
102
+ // Each lookup's answers, apart.
103
+ const answers = new WeakMap<ReviewedDependencies['lookup'], Lru<{ at: number; reviewed: boolean }>>()
104
+
105
+ // Registers the host's reviewed dependencies, once, before the server is
106
+ // assembled (init()): a server already assembled keeps what it was given.
107
+ export function registerReviewedDependencies(reviewed: ReviewedDependencies): void {
108
+ const optional = (method: unknown) => method === undefined || typeof method === 'function'
109
+ if (typeof reviewed?.lookup !== 'function' || !optional(reviewed.parseComments) || !optional(reviewed.formatDependencyReviews)) {
110
+ throw new TypeError('registerReviewedDependencies: expected { lookup, parseComments?, formatDependencyReviews? }, async methods')
111
+ }
112
+ if (assembled) throw new Error('registerReviewedDependencies: call it before init(), as the server takes them as it is assembled')
113
+ if (registered) throw new Error('registerReviewedDependencies: reviewed dependencies are registered already')
114
+ registered = { lookup: reviewed.lookup.bind(reviewed), ...reviewed.parseComments ? { parseComments: reviewed.parseComments.bind(reviewed) } : {},
115
+ ...reviewed.formatDependencyReviews ? { formatDependencyReviews: reviewed.formatDependencyReviews.bind(reviewed) } : {} }
116
+ }
117
+
118
+ // The reviewed dependencies registered, as a server takes them once it is
119
+ // assembled; none registers after.
120
+ export function assembledReviewedDependencies(): ReviewedDependencies | null {
121
+ assembled = true
122
+ return registered
123
+ }
124
+
125
+ // Which of `dependencies`, by their keys, `lookup` passes, and how many it
126
+ // failed to answer.
127
+ export async function askReviewed(lookup: ReviewedDependencies['lookup'], dependencies: Map<string, ReviewedDependency>): Promise<{ passed: Set<string>; unasked: number }> {
128
+ const passed = new Set<string>()
129
+ let unasked = 0
130
+ const pending = [...dependencies]
131
+ const kept = answers.get(lookup) ?? answers.set(lookup, lru(100_000)).get(lookup)!
132
+ await Promise.all(Array.from({ length: Math.min(ASK_AT_ONCE, pending.length) }, async () => {
133
+ for (let next = pending.pop(); next; next = pending.pop()) {
134
+ const [key, dependency] = next
135
+ const known = kept.get(key)
136
+ let answer = known && Date.now() - known.at < KEPT_MS ? known.reviewed : null
137
+ if (answer === null) {
138
+ try { answer = await lookup({ ...dependency }) === true } catch (error) {
139
+ unasked++
140
+ console.warn('reviewed dependencies: failed to answer', `${dependency.name}@${dependency.version}`, error instanceof Error ? error.message : error)
141
+ continue
142
+ }
143
+ kept.set(key, { at: Date.now(), reviewed: answer })
144
+ }
145
+ if (answer) passed.add(key)
146
+ }
147
+ }))
148
+ return { passed, unasked }
149
+ }
@@ -5,6 +5,7 @@
5
5
  //
6
6
  // Async throughout — the store (db.ts) is async so a future non-SQLite
7
7
  // backend slots in without touching these helpers.
8
+ import { AsyncLocalStorage } from 'node:async_hooks'
8
9
  import type { ManagedConfig } from './config.ts'
9
10
  import type { ManagedDb, ManagedSession, ManagedUser, StoredUser } from './db.ts'
10
11
  import { hashToken, randomToken } from './crypto.ts'
@@ -13,13 +14,15 @@ import { hashToken, randomToken } from './crypto.ts'
13
14
  // `cookieName` when cookies are Secure.
14
15
  export const STATE_COOKIE = 'dvstate'
15
16
 
16
- // Base names for GitHub issue authorization state (github-issue-oauth.ts) and
17
- // an administrator's read-only view as another user (view-as.ts).
17
+ // Base names for GitHub issue authorization state (github-issue-oauth.ts),
18
+ // linking another GitHub account (github-link-oauth.ts) and an
19
+ // administrator's read-only view as another user (view-as.ts).
18
20
  export const ISSUE_STATE_COOKIE = 'dvissuestate'
21
+ export const LINK_STATE_COOKIE = 'dvlinkstate'
19
22
  export const VIEW_COOKIE = 'dvview'
20
23
  // The session cookie cannot share a name with these, or one would overwrite
21
24
  // or be read as the other.
22
- export const RESERVED_COOKIES: readonly string[] = [STATE_COOKIE, ISSUE_STATE_COOKIE, VIEW_COOKIE]
25
+ export const RESERVED_COOKIES: readonly string[] = [STATE_COOKIE, ISSUE_STATE_COOKIE, LINK_STATE_COOKIE, VIEW_COOKIE]
23
26
 
24
27
  // `__Host-`-prefix the base name when cookies are Secure (the prefix mandates
25
28
  // Secure + Path=/ + no Domain, which `buildCookie` already emits).
@@ -55,13 +58,15 @@ export function parseCookies(header: string | undefined): Map<string, string> {
55
58
 
56
59
  // Mint a session for a freshly-authenticated user. Upserts the identity, then
57
60
  // stores the session keyed by the cookie token's hash. Returns the cookie to
58
- // set and the CSRF token (also persisted on the row).
61
+ // set and the CSRF token (also persisted on the row), or null for an account
62
+ // a user linked to theirs, which can neither sign in nor register.
59
63
  export async function createSession(
60
64
  config: ManagedConfig, db: ManagedDb, user: ManagedUser, now: number,
61
- ): Promise<{ setCookie: string; csrfToken: string; userId: string }> {
65
+ ): Promise<{ setCookie: string; csrfToken: string; userId: string } | null> {
62
66
  const token = randomToken()
63
67
  const csrfToken = randomToken()
64
68
  const userId = await db.upsertUser(user, now, config.initialAdminGithubId)
69
+ if (userId == null) return null
65
70
  await db.createSession({ id: hashToken(token), userId, csrfToken, expiresAt: now + config.sessionTtlMs }, now)
66
71
  const setCookie = buildCookie(config.sessionCookieName, token, {
67
72
  maxAgeS: Math.floor(config.sessionTtlMs / 1000), secure: config.cookieSecure, sameSite: 'Lax',
@@ -69,19 +74,32 @@ export async function createSession(
69
74
  return { setCookie, csrfToken, userId }
70
75
  }
71
76
 
77
+ // A request's sign-in, whose presence its first read to find it records.
78
+ // Every read after it in that request (the rechecks after its reads of
79
+ // storage, GitHub or npm among them) only reads: last_seen_at was written a
80
+ // moment before, by the read that let the request in.
81
+ const requests = new AsyncLocalStorage<{ seen: boolean }>()
82
+ export function runRequestPresence<T>(work: () => T): T {
83
+ return requests.run({ seen: false }, work)
84
+ }
85
+
72
86
  // Resolve the current session (+ user) from the request's cookies, or null.
73
87
  // A view cookie resolves only to that admin's view as another user: a view
74
88
  // that has ended never falls back to the admin's own session. `own` skips the
75
89
  // view and reads the signed-in session itself.
76
- export function readSession(
90
+ export async function readSession(
77
91
  config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined, now: number, { own = false } = {},
78
92
  ): Promise<{ session: ManagedSession; user: StoredUser } | null> {
79
93
  const cookies = parseCookies(cookieHeader)
80
94
  const token = cookies.get(config.sessionCookieName)
81
- if (token == null || token === '') return Promise.resolve(null)
95
+ if (token == null || token === '') return null
82
96
  const view = own ? undefined : cookies.get(cookieName(config, VIEW_COOKIE))
83
- if (view != null && view !== '') return db.viewSessionWithUser(hashToken(view), hashToken(token), now)
84
- return db.sessionWithUser(hashToken(token), now)
97
+ const request = requests.getStore(), seen = request?.seen === true
98
+ const s = view != null && view !== ''
99
+ ? await (seen ? db.getViewSessionWithUser : db.viewSessionWithUser)(hashToken(view), hashToken(token), now)
100
+ : await (seen ? db.getSessionWithUser : db.sessionWithUser)(hashToken(token), now)
101
+ if (s && request) request.seen = true
102
+ return s
85
103
  }
86
104
 
87
105
  // Drop the session named by the request's cookie (logout). The caller also
@@ -19,11 +19,14 @@ export interface ManagedSqlDriver extends ManagedSql {
19
19
  // These operations execute exactly one SQL statement. Postgres already gives
20
20
  // that statement an atomic snapshot; an explicit BEGIN/COMMIT adds only trips.
21
21
  // Lease and cache writes coordinate through their conditional UPSERT/UPDATE alone.
22
- const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption',
22
+ const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption', 'getTeam', 'getComment',
23
+ 'userCanReadReport', 'userCanReadRepo', 'userCanReadRepoPath', 'userCanReadBundle', 'reportPermissionsFor', 'listComments', 'listManagedIssues',
24
+ 'listTriageHistory', 'listUsers', 'listUserOptions', 'listActivityReports', 'getRepoFullName', 'getSessionWithUser', 'getViewSessionWithUser',
25
+ 'listTriageFixes',
23
26
  'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
24
27
  'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease',
25
- 'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'setUpstreamCacheEntry', 'setUpstreamCacheEntries', 'ensureSessionUploadKey',
26
- 'listGithubCommits', 'listGithubCommitTags', 'setGithubCommits'])
28
+ 'getUpstreamCacheEntry', 'getUpstreamCacheEntries', 'getFirstUpstreamCacheEntries', 'setUpstreamCacheEntry', 'setUpstreamCacheEntries', 'ensureSessionUploadKey',
29
+ 'listGithubCommits', 'listGithubCommitTags', 'setGithubCommits', 'getAvatarKey', 'listUsersByAccount'])
27
30
  // Session presence and upload keys change only their own rows.
28
31
  const UNLOCKED_WRITES = new Set(['sessionWithUser', 'viewSessionWithUser', 'ensureSessionUploadKey'])
29
32
 
@@ -61,6 +61,11 @@ function avatarStore(objects: ObjectStorage, sidecar: boolean): AvatarStore {
61
61
  if (split < 0 || split > 100) throw new Error('Invalid cached avatar')
62
62
  return { contentType: bytes.subarray(0, split).toString(), bytes: bytes.subarray(split + 1) }
63
63
  },
64
+ async delete(value) {
65
+ const path = `avatars/${id(value)}`
66
+ await objects.delete(path)
67
+ if (sidecar) await objects.delete(`${path}.type`)
68
+ },
64
69
  }
65
70
  }
66
71
 
@@ -15,8 +15,10 @@ import { migrateStorage, reapStorageUploads } from './storage-maintenance.ts'
15
15
  // Database-only callers (notably status) must not activate byte encryption.
16
16
  export async function openManagedStorageDb(config: ManagedConfig) {
17
17
  const options = { triageHistoryLimit: config.triageHistoryLimit, storageEncryptionKey: parseStorageKey(config.storageEncryptionKey) }
18
- return config.neonUrl ? openNeonManagedDb(config.neonUrl, options)
19
- : (await import('./db.ts')).openSqliteManagedDb(config.dbPath, options)
18
+ if (!config.neonUrl) return (await import('./db.ts')).openSqliteManagedDb(config.dbPath, options)
19
+ // Serverless instances hold pooled connections only while an invocation lasts.
20
+ const attachPool = config.serverless ? (await import('@vercel/functions')).attachDatabasePool : undefined
21
+ return openNeonManagedDb(config.neonUrl, options, attachPool)
20
22
  }
21
23
 
22
24
  export async function openManagedStorage(config: ManagedConfig) {
@@ -8,6 +8,13 @@
8
8
  // The same read names the reports a restricted reader would find nothing in:
9
9
  // under -deps or -security the catalog leaves those out of the team's list.
10
10
  // Readers with both permissions keep every report, empty or not.
11
+ //
12
+ // A team that is not App also says how many of its reports open in App mode
13
+ // on their own, and its findings for the badge it collapses to. None is as
14
+ // uniform as an App team, and collapses like one. A team mixing App and non-App
15
+ // reports stays expanded, and so does one whose reports each open as App but
16
+ // whose combined workspace does not (contradicting verdicts, say): it has App
17
+ // findings, and only expanded does it show that they fail to combine.
11
18
  import { reportEntries } from '@preventive/report'
12
19
  import { type TeamAppMetadata, managedTeamAppMetadata } from '../common/managed/team-app.js'
13
20
  import type { BlobStore } from './blob-store.ts'
@@ -48,10 +55,14 @@ function remember(db: ManagedDb, key: string, summary: TeamWorkspaceSummary): Te
48
55
  return summary
49
56
  }
50
57
 
58
+ function isLinks(data: unknown): boolean {
59
+ const record = data as { source?: unknown; links?: unknown } | null
60
+ return record?.source === 'links' && Array.isArray(record.links)
61
+ }
62
+
51
63
  // A links document counts by its links, every other report by its findings.
52
64
  function hasContent(data: unknown): boolean {
53
- const record = data as { source?: unknown; links?: unknown } | null
54
- if (record?.source === 'links' && Array.isArray(record.links)) return record.links.length > 0
65
+ if (isLinks(data)) return (data as { links: unknown[] }).links.length > 0
55
66
  return (reportEntries(data) ?? []).some(entry => !Array.isArray(entry) || entry.length > 0)
56
67
  }
57
68
 
@@ -79,7 +90,12 @@ async function classify(db: ManagedDb, store: BlobStore, key: string, team: User
79
90
  }
80
91
  const restricted = access.reports.some(report => !report.permissions.dependencies || !report.permissions.security)
81
92
  const empty = restricted ? body.reports.filter(report => !hasContent(report.data)).map(report => report.id) : []
82
- return remember(db, key, { app: managedTeamAppMetadata(Object.assign(body.reports, { links: body.links ?? [] })), empty })
93
+ const links = body.links ?? []
94
+ const app = managedTeamAppMetadata(Object.assign(body.reports, { links }), { countFindings: true })
95
+ if (app.appMode) return remember(db, key, { app, empty })
96
+ const appReports = body.reports.filter(report => !isLinks(report.data)
97
+ && managedTeamAppMetadata(Object.assign([report], { links })).appMode).length
98
+ return remember(db, key, { app: { ...app, appReports }, empty })
83
99
  })().catch((error: unknown) => {
84
100
  if (error instanceof TeamReportsError && [413, 422].includes(error.status)) return remember(db, key, NO_APP)
85
101
  if (!(error instanceof TeamReportsError)) console.warn('managed: team App classification failed:', error)
@@ -59,6 +59,13 @@ export class TeamReportsError extends Error {
59
59
  export function teamSnapshotKey(snapshot: TeamReportAccessSnapshot): string {
60
60
  return JSON.stringify([snapshot.user.id, snapshot.user.role, snapshot.teamId, snapshot.hiddenReportId ?? null, snapshot.reports, snapshot.repositories, snapshot.linkRevision])
61
61
  }
62
+ // What a built workspace is kept by: everything its build reads, and nothing
63
+ // of who asked. A user's role and grants decide which reports (and which
64
+ // hidden preview) their snapshot holds and each one's permissions, all of it
65
+ // in the key, so members whose snapshots agree share one build.
66
+ function workspaceKey(snapshot: TeamReportAccessSnapshot): string {
67
+ return JSON.stringify([snapshot.teamId, snapshot.hiddenReportId ?? null, snapshot.reports, snapshot.repositories, snapshot.linkRevision])
68
+ }
62
69
  export async function teamSnapshot(db: ManagedDb, sessionId: string, teamId: string, reportId: string | null = null): Promise<TeamReportAccessSnapshot> {
63
70
  const snapshot = await db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, reportId)
64
71
  if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
@@ -145,15 +152,15 @@ async function buildTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Tea
145
152
  if (outputBytes + suffix.length > MAX_REPORT_QUERY_BYTES) throw new TeamReportsError(413, 'batch-too-large')
146
153
  parts.push(suffix)
147
154
  const body = Buffer.concat(parts)
148
- retainVisibility(db, teamSnapshotKey(snapshot), visible)
149
- retainReports(db, teamSnapshotKey(snapshot), body)
155
+ retainVisibility(db, workspaceKey(snapshot), visible)
156
+ retainReports(db, workspaceKey(snapshot), body)
150
157
  return { body, visible }
151
158
  }
152
159
  const pendingWorkspaces = new WeakMap<ManagedDb, Map<string, ReturnType<typeof buildTeamWorkspace>>>()
153
160
  async function loadTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot) {
154
161
  let pending = pendingWorkspaces.get(db)
155
162
  if (!pending) { pending = new Map(); pendingWorkspaces.set(db, pending) }
156
- const key = teamSnapshotKey(snapshot)
163
+ const key = workspaceKey(snapshot)
157
164
  const existing = pending.get(key)
158
165
  if (existing) return existing
159
166
  const job = buildTeamWorkspace(db, store, snapshot)
@@ -163,7 +170,7 @@ async function loadTeamWorkspace(db: ManagedDb, store: BlobStore, snapshot: Team
163
170
  // Treat the encoded body as immutable and recheck access before sending it.
164
171
  // Warm HTTP reads need neither a parsed copy nor another serialization pass.
165
172
  export async function loadTeamReportsResponse(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<Buffer> {
166
- const cached = reportCaches.get(db)?.get(teamSnapshotKey(snapshot))
173
+ const cached = reportCaches.get(db)?.get(workspaceKey(snapshot))
167
174
  return cached ?? (await loadTeamWorkspace(db, store, snapshot)).body
168
175
  }
169
176
  export async function loadTeamReports(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<TeamReport[]> {
@@ -172,8 +179,7 @@ export async function loadTeamReports(db: ManagedDb, store: BlobStore, snapshot:
172
179
  return (JSON.parse(body.toString('utf8')) as { reports: TeamReport[] }).reports
173
180
  }
174
181
  async function teamVisibility(db: ManagedDb, store: BlobStore, snapshot: TeamReportAccessSnapshot): Promise<Visibility> {
175
- const key = teamSnapshotKey(snapshot)
176
- let visible = caches.get(db)?.get(key)
182
+ let visible = caches.get(db)?.get(workspaceKey(snapshot))
177
183
  if (!visible) {
178
184
  // A large snapshot may deliberately remain uncached, or be evicted by
179
185
  // another request. Authorization uses this load's result in either case.
@@ -191,17 +197,23 @@ export async function teamReportVisibility(db: ManagedDb, store: BlobStore, snap
191
197
  const visible = await teamVisibility(db, store, snapshot)
192
198
  return visible.get(reportId)!
193
199
  }
194
- export async function teamFindingIds(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
200
+ // A report's visibility for the session's current snapshot. A workspace built
201
+ // (or waited for) here outlives that snapshot's read, so the team's access is
202
+ // read again after it; one kept is answered with no read in between, which
203
+ // leaves nothing for another snapshot read to guard.
204
+ async function sessionReportVisibility(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<ReportVisibility> {
195
205
  const snapshot = await teamSnapshot(db, sessionId, teamId, reportId)
206
+ const kept = caches.get(db)?.get(workspaceKey(snapshot))?.get(reportId)
207
+ if (kept) return kept
196
208
  const visible = await teamReportVisibility(db, store, snapshot, reportId)
197
209
  await recheckTeam(db, sessionId, snapshot)
198
- return visible.ids
210
+ return visible
211
+ }
212
+ export async function teamFindingIds(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
213
+ return (await sessionReportVisibility(db, store, sessionId, teamId, reportId)).ids
199
214
  }
200
215
  export async function teamSourcePaths(db: ManagedDb, store: BlobStore, sessionId: string, teamId: string, reportId: string): Promise<Set<string>> {
201
- const snapshot = await teamSnapshot(db, sessionId, teamId, reportId)
202
- const visible = await teamReportVisibility(db, store, snapshot, reportId)
203
- await recheckTeam(db, sessionId, snapshot)
204
- return visible.sourcePaths
216
+ return (await sessionReportVisibility(db, store, sessionId, teamId, reportId)).sourcePaths
205
217
  }
206
218
 
207
219
  // Serialize shared annotation bodies once. Reports carry only the annotated
@@ -26,6 +26,9 @@ export interface UpstreamCacheStore {
26
26
  getUpstreamCacheEntry(key: string): Promise<string | null>
27
27
  // Key → value for those of `keys` that are kept.
28
28
  getUpstreamCacheEntries(keys: string[]): Promise<Map<string, string>>
29
+ // Those of `first` that are kept; where none is, those of `then`, in the
30
+ // same statement (`first` whether they are `first`'s).
31
+ getFirstUpstreamCacheEntries(first: string[], then: string[]): Promise<{ first: boolean; entries: Map<string, string> }>
29
32
  setUpstreamCacheEntry(key: string, value: string, cachedAt: number): Promise<void>
30
33
  // Each of `entries` (key, value), as setUpstreamCacheEntry keeps one, in one
31
34
  // statement: the last of a key given twice.
@@ -38,6 +41,10 @@ export interface UpstreamCacheStore {
38
41
  export function upstreamCacheMethods(db: ManagedSql): UpstreamCacheStore {
39
42
  const get = db.prepare('SELECT value FROM managed_upstream_cache WHERE cache_key = ?')
40
43
  const getMany = db.prepare('SELECT cache_key, value FROM managed_upstream_cache WHERE cache_key IN (SELECT value FROM json_each(?))')
44
+ const getFirst = db.prepare(`WITH found AS (SELECT cache_key, value FROM managed_upstream_cache WHERE cache_key IN (SELECT value FROM json_each(?)))
45
+ SELECT cache_key, value, 1 AS in_first FROM found
46
+ UNION ALL SELECT cache_key, value, 0 AS in_first FROM managed_upstream_cache
47
+ WHERE cache_key IN (SELECT value FROM json_each(?)) AND NOT EXISTS (SELECT 1 FROM found)`)
41
48
  // A slower concurrent audit cannot replace a newer listing with an older one.
42
49
  const set = db.prepare(`INSERT INTO managed_upstream_cache (cache_key, value, cached_at) VALUES (?, ?, ?)
43
50
  ON CONFLICT (cache_key) DO UPDATE SET value = excluded.value, cached_at = excluded.cached_at
@@ -55,6 +62,11 @@ export function upstreamCacheMethods(db: ManagedSql): UpstreamCacheStore {
55
62
  const rows = await getMany.all(JSON.stringify(keys)) as { cache_key: string; value: string }[]
56
63
  return new Map(rows.map(row => [row.cache_key, row.value]))
57
64
  },
65
+ async getFirstUpstreamCacheEntries(first, then) {
66
+ if (first.length === 0 && then.length === 0) return { first: false, entries: new Map() }
67
+ const rows = await getFirst.all(JSON.stringify(first), JSON.stringify(then)) as { cache_key: string; value: string; in_first: number }[]
68
+ return { first: rows.some(row => Number(row.in_first) === 1), entries: new Map(rows.map(row => [row.cache_key, row.value])) }
69
+ },
58
70
  async setUpstreamCacheEntry(key, value, cachedAt) {
59
71
  await set.run(key, value, cachedAt)
60
72
  },
@@ -145,15 +145,18 @@ export function workspaceShareMethods(db: ManagedSql): WorkspaceShareStore {
145
145
  const links = await linkRevision(db)
146
146
  const bundleRows = (await q.bundles.all(team.id) as (Omit<ManagedBundle, 'buildConditions'> & { buildConditions: string | null; repoFullName: string })[])
147
147
  .map(row => ({ ...row, buildConditions: row.buildConditions == null ? null : JSON.parse(row.buildConditions) as BundleBuildConditions }))
148
+ const repositories = await q.repositories.all(team.id) as WorkspaceShareSnapshot['repositories']
148
149
  return {
149
150
  user: { id: `share:${tokenHash}`, login: 'public', name: 'Public workspace', avatarUrl: null, role: 'view' },
150
151
  teamId: team.id,
151
152
  linkRevision: links,
152
153
  permissions,
153
- repositories: await q.repositories.all(team.id) as WorkspaceShareSnapshot['repositories'],
154
+ repositories,
154
155
  reports: rows.map(row => ({ id: row.id, filename: row.filename, byteSize: row.byteSize, sha256: row.sha256,
155
156
  repo: { github: row.github, directory: row.directory }, permissions })),
156
157
  team: { ...team, permissions, cacheKey: links,
158
+ repos: repositories.map(repo => ({ repoId: repo.repoId, fullName: repo.github, path: repo.path || null }))
159
+ .toSorted((a, b) => a.fullName.localeCompare(b.fullName) || (a.path ?? '').localeCompare(b.path ?? '')),
157
160
  reports: rows.map(row => ({ id: row.id, slug: row.slug, filename: row.filename, visible: true, analyzer: row.analyzer, repoFullName: row.github, repoDirectory: row.directory,
158
161
  cacheKey: JSON.stringify([row.sha256, row.github, row.directory, row.filename, permissions, links]) })),
159
162
  bundles: bundleRows.map(row => ({ id: row.id, slug: row.slug, integrity: row.integrity,
package/server.ts CHANGED
@@ -2,6 +2,17 @@ import { parseArgs } from 'node:util'
2
2
  import type { Server } from 'node:http'
3
3
  import { startServer } from './server-common/standalone.ts'
4
4
 
5
+ // The reviewed dependencies a host keeps beyond the app, registered before
6
+ // init() (server-managed/reviewed-dependencies.ts): an async method telling
7
+ // whether a dependency, as a lockfile locks it, is reviewed there
8
+ // (`lookup`), and optionally one reading the reviews an open pull request's
9
+ // comments make (`parseComments`), and one formatting one's own dependency
10
+ // reviews as a comment to copy (`formatDependencyReviews`). They replace a
11
+ // repository's commit its dependencies are reviewed since.
12
+ export { registerReviewedDependencies } from './server-managed/reviewed-dependencies.ts'
13
+ export type { CommentReview, DependencyReviewEntry, FormatDependencyReviewsInput, ParseCommentsInput, ReviewComment, ReviewedDependencies, ReviewedDependency,
14
+ ReviewedLockfile, ReviewedVersion, ReviewVerdict } from './server-managed/reviewed-dependencies.ts'
15
+
5
16
  const modes = ['e2e', 'managed', 'managed-e2e', 'e2e-managed'] as const
6
17
  export type ServerMode = typeof modes[number]
7
18
 
@@ -10,20 +21,29 @@ function parseMode(mode: string): ServerMode {
10
21
  throw new Error(`Unknown mode: ${mode}. Expected ${modes.join(', ')}.`)
11
22
  }
12
23
 
24
+ export type InitOptions = {
25
+ mode?: ServerMode
26
+ // False serves only the API: `out/` is never read, nor its UI served, for
27
+ // hosts that serve the UI themselves.
28
+ ui?: boolean
29
+ }
30
+
13
31
  // Assemble the selected server without binding a port. Hosts can reuse its
14
- // request/upgrade listeners or call listen() themselves.
15
- export async function init(mode: ServerMode = 'e2e'): Promise<Server> {
32
+ // request/upgrade listeners or call listen() themselves. `init(mode)` is
33
+ // `init({ mode })`.
34
+ export async function init(options: ServerMode | InitOptions = {}): Promise<Server> {
35
+ const { mode = 'e2e', ui = true }: InitOptions = typeof options === 'string' ? { mode: options } : options
16
36
  const selected = parseMode(mode)
17
37
  if (selected === 'e2e') {
18
38
  const { createE2eApp } = await import('./server-e2e/app.ts')
19
- return (await createE2eApp()).httpServer
39
+ return (await createE2eApp(undefined, { ui })).httpServer
20
40
  }
21
41
  if (selected === 'managed') {
22
42
  const managed = await import('./server-managed/index.ts')
23
- return managed.init()
43
+ return managed.init(undefined, { ui })
24
44
  }
25
45
  const combined = await import('./server-managed/combined.ts')
26
- return combined.init(selected === 'managed-e2e' ? 'managed+e2e' : 'e2e+managed')
46
+ return combined.init(selected === 'managed-e2e' ? 'managed+e2e' : 'e2e+managed', { ui })
27
47
  }
28
48
 
29
49
  export async function start(): Promise<void> {