@preventive/triage 1.0.0-alpha.5 → 1.0.0-alpha.51

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 (368) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +3 -0
  6. package/client/dependency-paths.js +36 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +115 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +357 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-compare-versions.js +172 -0
  17. package/common/bundle-comparison.d.ts +8 -0
  18. package/common/bundle-comparison.js +62 -0
  19. package/common/bundle-metadata.d.ts +15 -0
  20. package/common/bundle-metadata.js +226 -0
  21. package/common/bundle-pkg-of.d.ts +1 -0
  22. package/common/bundle-pkg-of.js +90 -0
  23. package/common/bundle-reasons.d.ts +2 -0
  24. package/common/bundle-reasons.js +21 -0
  25. package/common/bundle-source-links.d.ts +6 -0
  26. package/common/bundle-source-links.js +216 -0
  27. package/common/bundle-sourcemap.d.ts +10 -0
  28. package/common/bundle-sourcemap.js +100 -0
  29. package/common/bundle-sources.d.ts +7 -0
  30. package/common/bundle-sources.js +333 -0
  31. package/common/bundle-stats.js +41 -0
  32. package/common/bundle-tabs.js +1 -0
  33. package/common/code-language.js +65 -0
  34. package/common/default-scan-models.ts +31 -0
  35. package/common/dependency-reviews.d.ts +18 -0
  36. package/common/dependency-reviews.js +64 -0
  37. package/common/finding-duplicates.js +58 -0
  38. package/common/finding-filters.js +274 -0
  39. package/common/finding-id.js +47 -0
  40. package/common/github-issue-labels.d.ts +1 -0
  41. package/common/github-issue-labels.js +11 -0
  42. package/common/github-pr.ts +56 -0
  43. package/common/gzip.js +59 -0
  44. package/common/line-diff.d.ts +8 -0
  45. package/common/line-diff.js +57 -0
  46. package/common/linked-workspace-groups.js +63 -0
  47. package/common/managed/agent-tasks.ts +292 -0
  48. package/common/managed/comments.ts +34 -0
  49. package/common/managed/finding-detail.d.ts +3 -0
  50. package/common/managed/finding-detail.js +66 -0
  51. package/common/managed/github-agents.ts +114 -0
  52. package/common/managed/github-logins.ts +49 -0
  53. package/common/managed/npm-packages.d.ts +7 -0
  54. package/common/managed/npm-packages.js +51 -0
  55. package/common/managed/permissions.ts +31 -0
  56. package/common/managed/report-content.ts +72 -0
  57. package/common/managed/report-filter.ts +109 -0
  58. package/common/managed/repository-alias.ts +65 -0
  59. package/common/managed/roles.ts +35 -0
  60. package/common/managed/routes.d.ts +2 -0
  61. package/common/managed/routes.js +359 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +6 -0
  64. package/common/managed/team-app.js +47 -0
  65. package/common/managed/triage.ts +83 -0
  66. package/common/managed/upload-seal.ts +126 -0
  67. package/common/pretty-print.d.ts +5 -0
  68. package/common/pretty-print.js +24 -0
  69. package/common/revalidation-conflicts.js +48 -0
  70. package/common/revalidation-input-groups.js +56 -0
  71. package/common/save-error-reason.ts +20 -7
  72. package/common/scan-server.ts +13 -0
  73. package/common/security-links.d.ts +5 -0
  74. package/common/security-links.js +33 -0
  75. package/common/server-info.ts +46 -0
  76. package/common/utf8.d.ts +3 -0
  77. package/common/utf8.js +45 -0
  78. package/common/workspace-app.js +39 -0
  79. package/common/workspace-groups.js +176 -0
  80. package/out/activity.js +4 -0
  81. package/out/brotli-fallback.js +3 -3
  82. package/out/chunk-2K6UN3PC.js +3 -0
  83. package/out/chunk-2XUEZHIP.js +28 -0
  84. package/out/chunk-2ZDZWLFY.js +1 -0
  85. package/out/chunk-4HI2MSAV.js +17 -0
  86. package/out/chunk-6LNQTKAV.js +52 -0
  87. package/out/chunk-7BKKYVWR.js +7 -0
  88. package/out/chunk-7C7DWL7I.js +1 -0
  89. package/out/chunk-7JBSSRD7.js +3 -0
  90. package/out/chunk-7VAWINQF.js +6 -0
  91. package/out/chunk-C4Z6ZBXV.js +10 -0
  92. package/out/chunk-FMXWXDR4.js +36 -0
  93. package/out/chunk-GDPCLSOS.js +2 -0
  94. package/out/chunk-GO7FT4OD.js +1 -0
  95. package/out/chunk-HT2SSA3V.js +1 -0
  96. package/out/chunk-IPAH4LKF.js +232 -0
  97. package/out/chunk-IYPY47MC.js +1 -0
  98. package/out/chunk-IZ7POWI3.js +18 -0
  99. package/out/chunk-JCIIBLKR.js +1 -0
  100. package/out/chunk-KOGNQDSJ.js +1 -0
  101. package/out/chunk-NRPC74AM.js +1 -0
  102. package/out/chunk-OOJM4CTU.js +1 -0
  103. package/out/chunk-PBZPYIG7.js +1 -0
  104. package/out/chunk-PNHBZGDD.js +1 -0
  105. package/out/chunk-PWFUOLZK.js +17 -0
  106. package/out/chunk-PXK7EU4V.js +1 -0
  107. package/out/chunk-QB4CBOX2.js +1 -0
  108. package/out/chunk-R3NZFXVR.js +1 -0
  109. package/out/chunk-R5O6FAW4.js +2 -0
  110. package/out/chunk-SXV2T5C4.js +80 -0
  111. package/out/chunk-TQJDYHVE.js +2 -0
  112. package/out/chunk-TUII34K2.js +1 -0
  113. package/out/chunk-VDRXHIHN.js +3 -0
  114. package/out/chunk-VFWMQ3LG.js +2 -0
  115. package/out/chunk-VH4XMZIW.js +2 -0
  116. package/out/chunk-WCPJDAYA.js +10 -0
  117. package/out/chunk-WFYLMF6Z.js +10 -0
  118. package/out/chunk-WRAJVD35.js +1 -0
  119. package/out/chunk-WYLQWYLA.js +1 -0
  120. package/out/client-managed-import.js +4 -0
  121. package/out/client-managed.js +33 -0
  122. package/out/client-sync.js +11 -17
  123. package/out/compare.js +35 -0
  124. package/out/dialogs.js +55 -0
  125. package/out/github-issue-authorized.html +3 -0
  126. package/out/github-issue-authorized.js +1 -0
  127. package/out/graph.js +17 -4
  128. package/out/index.html +90 -8
  129. package/out/prism.js +14 -10
  130. package/out/scan.js +2 -0
  131. package/out/stasis.svg +20 -0
  132. package/out/terminal.js +366 -39
  133. package/out/view.css +1 -1
  134. package/out/view.js +19 -144
  135. package/package.json +364 -59
  136. package/risk/actions/findings.js +161 -0
  137. package/risk/actions/resolve.js +219 -0
  138. package/risk/actions/scripts/commands.js +182 -0
  139. package/risk/actions/scripts/grade.js +113 -0
  140. package/risk/actions/scripts/packages.js +123 -0
  141. package/risk/actions/scripts/shell.js +169 -0
  142. package/risk/actions/uses.js +76 -0
  143. package/risk/actions/workflow.js +110 -0
  144. package/risk/files/characters.js +196 -0
  145. package/risk/files/hosts.js +205 -0
  146. package/risk/files/readability.js +177 -0
  147. package/risk/files/runs-code.js +615 -0
  148. package/risk/files/script.js +118 -0
  149. package/risk/files/stamps.js +47 -0
  150. package/risk/index.d.ts +254 -0
  151. package/risk/index.js +11 -0
  152. package/risk/npm/assess.js +285 -0
  153. package/risk/npm/combined.js +68 -0
  154. package/risk/npm/compare.js +257 -0
  155. package/risk/npm/dependencies.js +172 -0
  156. package/risk/npm/diff.js +79 -0
  157. package/risk/npm/downloads.js +84 -0
  158. package/risk/npm/files.js +445 -0
  159. package/risk/npm/held.js +63 -0
  160. package/risk/npm/install-scripts.js +34 -0
  161. package/risk/npm/malware.js +36 -0
  162. package/risk/npm/manifest.js +65 -0
  163. package/risk/npm/new-dependency.js +60 -0
  164. package/risk/npm/old-version.js +108 -0
  165. package/risk/npm/registry.js +388 -0
  166. package/risk/npm/release.js +85 -0
  167. package/risk/npm/score.js +59 -0
  168. package/risk/npm/specs.js +124 -0
  169. package/risk/npm/tarball.js +96 -0
  170. package/server-common/database-config.ts +16 -0
  171. package/server-common/initialize.ts +18 -0
  172. package/server-common/npm-advisories.ts +101 -0
  173. package/{server → server-common}/origin.ts +5 -5
  174. package/server-common/reap.ts +48 -0
  175. package/server-common/scan-config.ts +19 -0
  176. package/server-common/standalone.ts +29 -0
  177. package/server-common/storage-crypto.ts +182 -0
  178. package/server-common/storage-log.ts +38 -0
  179. package/server-common/vercel-blob.ts +103 -0
  180. package/server-e2e/app.ts +487 -0
  181. package/{server → server-e2e}/bus-receiver.ts +2 -1
  182. package/{server → server-e2e}/cli.js +9 -4
  183. package/{server → server-e2e}/config.ts +50 -42
  184. package/{server → server-e2e}/db-neon.ts +2 -2
  185. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  186. package/{server → server-e2e}/db-stmt.ts +2 -2
  187. package/{server → server-e2e}/db.ts +7 -7
  188. package/{server → server-e2e}/http.ts +44 -13
  189. package/{server → server-e2e}/hub.ts +40 -9
  190. package/server-e2e/index.ts +17 -0
  191. package/server-e2e/lifecycle.ts +95 -0
  192. package/{server → server-e2e}/neon-driver.ts +2 -2
  193. package/{server → server-e2e}/npm-proxy.ts +12 -145
  194. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  195. package/{server → server-e2e}/objstore/blob.ts +1 -1
  196. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  197. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  198. package/{server → server-e2e}/objstore/init.ts +19 -27
  199. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  200. package/server-e2e/objstore/rest-deny.ts +28 -0
  201. package/server-e2e/objstore/rest-mint.ts +224 -0
  202. package/{server → server-e2e}/objstore/rest.ts +3 -3
  203. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  204. package/{server → server-e2e}/objstore/store.ts +7 -7
  205. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  206. package/{server → server-e2e}/pubsub.ts +12 -9
  207. package/{server → server-e2e}/sign.ts +4 -4
  208. package/{server → server-e2e}/sse-server.ts +60 -33
  209. package/{server → server-e2e}/static.ts +31 -21
  210. package/{server → server-e2e}/sync-handlers.ts +64 -30
  211. package/{server → server-e2e}/ws-server.ts +20 -9
  212. package/server-managed/activity.ts +258 -0
  213. package/server-managed/agent-merges.ts +109 -0
  214. package/server-managed/app-agents.ts +23 -0
  215. package/server-managed/avatar-store.ts +13 -0
  216. package/server-managed/blob-store.ts +17 -0
  217. package/server-managed/brotli.ts +10 -0
  218. package/server-managed/bundle-advisories.ts +120 -0
  219. package/server-managed/bundle-advisory-inventory.ts +77 -0
  220. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  221. package/server-managed/bundle-build-diagnostics.js +23 -0
  222. package/server-managed/bundle-build-leases.ts +41 -0
  223. package/server-managed/bundle-build-worker.js +115 -0
  224. package/server-managed/bundle-build.ts +200 -0
  225. package/server-managed/bundle-cache.ts +211 -0
  226. package/server-managed/bundle-catalog.ts +29 -0
  227. package/server-managed/bundle-commits.ts +140 -0
  228. package/server-managed/bundle-store.ts +24 -0
  229. package/server-managed/bundle-summary-cache.ts +130 -0
  230. package/server-managed/bundle.ts +119 -0
  231. package/server-managed/cache-storage.ts +20 -0
  232. package/server-managed/cli.js +21 -0
  233. package/server-managed/combined.ts +47 -0
  234. package/server-managed/comments.ts +151 -0
  235. package/server-managed/commit-coauthors.ts +106 -0
  236. package/server-managed/commit-lockfiles.ts +76 -0
  237. package/server-managed/comparison.ts +52 -0
  238. package/server-managed/config.ts +164 -0
  239. package/server-managed/content-access.ts +15 -0
  240. package/server-managed/crypto.ts +25 -0
  241. package/server-managed/db-methods.ts +1923 -0
  242. package/server-managed/db-neon.ts +468 -0
  243. package/server-managed/db-schema.ts +238 -0
  244. package/server-managed/db-table-names.ts +22 -0
  245. package/server-managed/db.ts +143 -0
  246. package/server-managed/dependabot-access.ts +60 -0
  247. package/server-managed/dependabot-alerts.ts +185 -0
  248. package/server-managed/dependency-review-comments.ts +98 -0
  249. package/server-managed/dependency-reviews.ts +364 -0
  250. package/server-managed/github-accounts.ts +60 -0
  251. package/server-managed/github-actions.ts +71 -0
  252. package/server-managed/github-app.ts +376 -0
  253. package/server-managed/github-errors.ts +70 -0
  254. package/server-managed/github-graphql.ts +39 -0
  255. package/server-managed/github-issue-links.ts +140 -0
  256. package/server-managed/github-issue-oauth.ts +47 -0
  257. package/server-managed/github-issues.ts +152 -0
  258. package/server-managed/github-link-oauth.ts +69 -0
  259. package/server-managed/github-links.ts +204 -0
  260. package/server-managed/github-metadata.ts +213 -0
  261. package/server-managed/github-oauth.ts +238 -0
  262. package/server-managed/github-pulls.ts +227 -0
  263. package/server-managed/github-requests.ts +136 -0
  264. package/server-managed/github-users.ts +30 -0
  265. package/server-managed/http-response.ts +76 -0
  266. package/server-managed/http.ts +5503 -0
  267. package/server-managed/import-triage.ts +51 -0
  268. package/server-managed/index.ts +192 -0
  269. package/server-managed/kept-briefly.ts +37 -0
  270. package/server-managed/kept.ts +28 -0
  271. package/server-managed/link-reports.ts +162 -0
  272. package/server-managed/listed-activity.ts +39 -0
  273. package/server-managed/locked-packages.ts +152 -0
  274. package/server-managed/lockfile-scope.ts +49 -0
  275. package/server-managed/lru.ts +30 -0
  276. package/server-managed/managed-issues.ts +78 -0
  277. package/server-managed/management-catalog.ts +45 -0
  278. package/server-managed/management.ts +99 -0
  279. package/server-managed/npm-delta-worker.js +23 -0
  280. package/server-managed/npm-delta.ts +194 -0
  281. package/server-managed/npm-insights.ts +449 -0
  282. package/server-managed/npm-kept.ts +225 -0
  283. package/server-managed/npm-loads.ts +264 -0
  284. package/server-managed/npm-manifest.ts +115 -0
  285. package/server-managed/npm-packages.ts +454 -0
  286. package/server-managed/npm-patch.ts +325 -0
  287. package/server-managed/npm-provenance.ts +123 -0
  288. package/server-managed/npm-risk.ts +285 -0
  289. package/server-managed/object-storage-disk.ts +148 -0
  290. package/server-managed/object-storage-vercel.ts +67 -0
  291. package/server-managed/object-storage.ts +75 -0
  292. package/server-managed/package-entry-points.ts +51 -0
  293. package/server-managed/pretty-print.ts +434 -0
  294. package/server-managed/public-workspace.ts +238 -0
  295. package/server-managed/pull-comment-reviews.ts +161 -0
  296. package/server-managed/pull-dependencies.ts +351 -0
  297. package/server-managed/pull-dependency-reviews.ts +334 -0
  298. package/server-managed/pull-reviews.ts +205 -0
  299. package/server-managed/queue.ts +39 -0
  300. package/server-managed/release-commits.ts +84 -0
  301. package/server-managed/rename-diff-worker.js +26 -0
  302. package/server-managed/rename-diff.ts +279 -0
  303. package/server-managed/repo-path.ts +21 -0
  304. package/server-managed/report-migration.ts +35 -0
  305. package/server-managed/report-query.ts +10 -0
  306. package/server-managed/report-response.ts +16 -0
  307. package/server-managed/report-sources.ts +178 -0
  308. package/server-managed/report-store.ts +85 -0
  309. package/server-managed/repository-actions.ts +218 -0
  310. package/server-managed/repository-agents.ts +448 -0
  311. package/server-managed/repository-aliases.ts +91 -0
  312. package/server-managed/repository-browser.ts +294 -0
  313. package/server-managed/repository-changes.ts +284 -0
  314. package/server-managed/repository-code.ts +215 -0
  315. package/server-managed/repository-dependency-reviews.ts +166 -0
  316. package/server-managed/repository-discovery.ts +82 -0
  317. package/server-managed/repository-history.ts +432 -0
  318. package/server-managed/repository-issues.ts +195 -0
  319. package/server-managed/repository-policy.ts +27 -0
  320. package/server-managed/repository-pulls.ts +212 -0
  321. package/server-managed/repository-pulse.ts +460 -0
  322. package/server-managed/repository-risk-assess.ts +105 -0
  323. package/server-managed/repository-risk-audit.ts +79 -0
  324. package/server-managed/repository-risk-read.ts +170 -0
  325. package/server-managed/repository-risk.ts +491 -0
  326. package/server-managed/repository-squashes.ts +168 -0
  327. package/server-managed/request-timing.ts +63 -0
  328. package/server-managed/reviewed-dependencies.ts +149 -0
  329. package/server-managed/revisions.ts +35 -0
  330. package/server-managed/scan-downloads.ts +87 -0
  331. package/server-managed/session.ts +132 -0
  332. package/server-managed/slugs.ts +38 -0
  333. package/server-managed/solidity-entry-points.ts +71 -0
  334. package/server-managed/sql-postgres.ts +34 -0
  335. package/server-managed/sql.ts +88 -0
  336. package/server-managed/static.ts +28 -0
  337. package/server-managed/storage-command.ts +16 -0
  338. package/server-managed/storage-db.ts +183 -0
  339. package/server-managed/storage-encryption.ts +290 -0
  340. package/server-managed/storage-maintenance.ts +259 -0
  341. package/server-managed/storage-payload.ts +121 -0
  342. package/server-managed/storage-stores.ts +91 -0
  343. package/server-managed/storage.ts +82 -0
  344. package/server-managed/team-app.ts +134 -0
  345. package/server-managed/team-catalog.ts +7 -0
  346. package/server-managed/team-feed.ts +180 -0
  347. package/server-managed/team-npm-scopes.ts +106 -0
  348. package/server-managed/team-reports.ts +319 -0
  349. package/server-managed/triage-response.ts +16 -0
  350. package/server-managed/type-only-worker.js +19 -0
  351. package/server-managed/type-only.ts +105 -0
  352. package/server-managed/uploads.ts +106 -0
  353. package/server-managed/upstream-cache.ts +243 -0
  354. package/server-managed/view-as.ts +58 -0
  355. package/server-managed/workspace-shares.ts +169 -0
  356. package/server.ts +70 -0
  357. package/server/index.ts +0 -480
  358. package/server/lifecycle.ts +0 -208
  359. /package/{LICENSE → LICENSE-MIT} +0 -0
  360. /package/{server → server-e2e}/auth.ts +0 -0
  361. /package/{server → server-e2e}/config.example.json +0 -0
  362. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  363. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  364. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  365. /package/{server → server-e2e}/peer.ts +0 -0
  366. /package/{server → server-e2e}/sse-session.ts +0 -0
  367. /package/{server → server-e2e}/util.ts +0 -0
  368. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,258 @@
1
+ // Persistent workspace activity. Uploads are recorded in the same transaction
2
+ // as their metadata; triage reuses its existing immutable trail, including
3
+ // legacy entries. Never copy annotation bodies or credentials into this feed.
4
+ import { randomUUID } from 'node:crypto'
5
+ import type { DatabaseSync } from 'node:sqlite'
6
+ import type { ManagedSql } from './sql.ts'
7
+
8
+ export interface ActivityInput {
9
+ kind: 'access' | 'repository' | 'visibility' | 'delete'
10
+ actor: string
11
+ actorId?: string | null
12
+ action: string
13
+ repo?: string | null
14
+ reportId?: string | null
15
+ bundleId?: string | null
16
+ repoId?: number | null
17
+ repoDirectory?: string | null
18
+ report?: string | null
19
+ }
20
+
21
+ export interface ActivityContext {
22
+ finding: string
23
+ reportId: string
24
+ report: string
25
+ repo: string | null
26
+ }
27
+
28
+ export type ActivityEntry = {
29
+ id: string; kind: string; actor: string | null; action: string
30
+ repo: string | null; reportId: string | null; report: string | null
31
+ finding: string | null; at: number
32
+ }
33
+
34
+ export interface ActivityQuery {
35
+ page: number
36
+ limit: number
37
+ kind: string
38
+ query: string
39
+ repo?: string
40
+ actor?: string
41
+ // null = admin; [] = no accessible findings. Context is supplied by the
42
+ // server, never by the client. It also replaces potentially private context
43
+ // from an old report carrying the same globally shared finding.
44
+ contexts: ActivityContext[] | null
45
+ userId?: string
46
+ }
47
+
48
+ export interface ActivityFilters {
49
+ repos: string[]
50
+ users: { id: string; login: string; detail: string | null }[]
51
+ }
52
+
53
+ export interface ActivityStore {
54
+ recordActivity(entry: ActivityInput, at: number): Promise<void>
55
+ listActivityReports(userId: string): Promise<Omit<ActivityContext, 'finding'>[]>
56
+ listActivity(query: ActivityQuery): Promise<{ history: ActivityEntry[]; total: number; page: number; limit: number; filters: ActivityFilters }>
57
+ }
58
+
59
+ const triageFields = `'triage:' || e.seq AS id, 'triage' AS kind,
60
+ COALESCE(u.login, e.actor_login) AS actor, e.actor_id AS actorId,
61
+ CASE WHEN e.color IS NULL AND e.triage IS NULL AND e.comment IS NULL AND e.fix IS NULL AND e.flagged IS NULL
62
+ THEN 'cleared triage' ELSE 'updated triage' END AS action`
63
+ const triage = `SELECT ${triageFields}, e.repo, e.report_id AS reportId, e.report, e.finding_id AS finding, e.at
64
+ FROM managed_finding_triage_event e LEFT JOIN managed_user u ON u.id = e.actor_id`
65
+ const commentFields = `'comment:' || e.seq AS id, 'triage' AS kind,
66
+ COALESCE(u.login, e.actor_login) AS actor, e.actor_id AS actorId, e.action`
67
+ const comments = `SELECT ${commentFields}, e.repo, e.report_id AS reportId, e.report, e.finding_id AS finding, e.at
68
+ FROM managed_finding_comment_event e LEFT JOIN managed_user u ON u.id = e.actor_id`
69
+ const adminSource = `${triage} UNION ALL ${comments} UNION ALL
70
+ SELECT id, kind, actor, actor_id AS actorId, action, repo, report_id AS reportId, report, NULL AS finding, at FROM managed_activity`
71
+ // Keep the finite set of accessible findings outside the indexed event
72
+ // lookup. Scanning all events against json_each for every row is quadratic.
73
+ const managerSource = `SELECT ${triageFields},
74
+ json_extract(c.value, '$.repo') AS repo, json_extract(c.value, '$.reportId') AS reportId,
75
+ json_extract(c.value, '$.report') AS report, e.finding_id AS finding, e.at
76
+ FROM json_each(:contexts) c
77
+ CROSS JOIN managed_finding_triage_event e ON e.finding_id = json_extract(c.value, '$.finding')
78
+ LEFT JOIN managed_user u ON u.id = e.actor_id
79
+ UNION ALL SELECT ${commentFields},
80
+ json_extract(c.value, '$.repo') AS repo, json_extract(c.value, '$.reportId') AS reportId,
81
+ json_extract(c.value, '$.report') AS report, e.finding_id AS finding, e.at
82
+ FROM json_each(:contexts) c
83
+ CROSS JOIN managed_finding_comment_event e ON e.finding_id = json_extract(c.value, '$.finding')
84
+ LEFT JOIN managed_user u ON u.id = e.actor_id
85
+ UNION ALL SELECT a.id, a.kind, a.actor, a.actor_id AS actorId,
86
+ CASE WHEN a.kind = 'repository' THEN 'changed a report repository assignment' ELSE a.action END AS action,
87
+ p.full_name AS repo, r.id AS reportId, r.filename AS report, NULL AS finding, a.at
88
+ FROM managed_activity a JOIN managed_report r ON r.id = a.report_id
89
+ LEFT JOIN managed_selected_repo p ON p.repo_id = r.repo_id
90
+ WHERE EXISTS (
91
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
92
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
93
+ WHERE tu.user_id = :userId AND tr.repo_id = r.repo_id AND ${withinTeamPath('r.repo_directory')}
94
+ )
95
+ UNION ALL SELECT a.id, a.kind, a.actor, a.actor_id AS actorId,
96
+ CASE WHEN a.kind = 'repository' THEN 'changed a bundle repository assignment' ELSE a.action END AS action,
97
+ p.full_name AS repo, NULL AS reportId, b.filename AS report, NULL AS finding, a.at
98
+ FROM managed_activity a JOIN managed_bundle b ON b.id = a.bundle_id
99
+ LEFT JOIN managed_selected_repo p ON p.repo_id = b.repo_id
100
+ WHERE a.report_id IS NULL AND EXISTS (
101
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
102
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
103
+ WHERE tu.user_id = :userId AND tr.repo_id = b.repo_id AND ${withinTeamPath('b.repo_directory')}
104
+ )
105
+ UNION ALL SELECT a.id, a.kind, a.actor, a.actor_id AS actorId, a.action, a.repo, a.report_id AS reportId,
106
+ a.report, NULL AS finding, a.at FROM managed_activity a
107
+ WHERE a.kind = 'delete' AND (a.report_id IS NOT NULL OR a.bundle_id IS NOT NULL)
108
+ AND NOT EXISTS (SELECT 1 FROM managed_report WHERE id = a.report_id)
109
+ AND NOT EXISTS (SELECT 1 FROM managed_bundle WHERE id = a.bundle_id)
110
+ AND EXISTS (SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
111
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
112
+ WHERE tu.user_id = :userId AND tr.repo_id = a.repo_id
113
+ AND ${withinTeamPath('a.repo_directory')})`
114
+
115
+ export const ACTIVITY_SCHEMA = `
116
+ CREATE TABLE IF NOT EXISTS managed_activity (
117
+ id TEXT PRIMARY KEY, kind TEXT NOT NULL, actor TEXT, action TEXT NOT NULL,
118
+ repo TEXT, report_id TEXT, report TEXT, at INTEGER NOT NULL,
119
+ bundle_id TEXT, repo_id INTEGER, repo_directory TEXT, actor_id TEXT
120
+ ) STRICT;
121
+ CREATE INDEX IF NOT EXISTS managed_activity_at_idx ON managed_activity(at, id);
122
+ CREATE INDEX IF NOT EXISTS finding_triage_event_at_idx ON managed_finding_triage_event(at, seq);
123
+ `
124
+
125
+ export function initActivityMethods(db: DatabaseSync): void {
126
+ db.exec(ACTIVITY_SCHEMA)
127
+ migrateActivityScope(db)
128
+ db.exec('CREATE INDEX IF NOT EXISTS managed_activity_actor_at_idx ON managed_activity(actor_id, at)')
129
+ // Stable upload IDs make backfill idempotent, including after restarts.
130
+ // Snapshots deliberately have no cascading FKs: deletion is itself activity.
131
+ // CREATE TRIGGER IF NOT EXISTS keeps an older body; replace the one that
132
+ // predates server-built bundles so builds are not recorded as uploads.
133
+ const bundleTrigger = db.prepare("SELECT sql FROM sqlite_schema WHERE type = 'trigger' AND name = 'managed_bundle_activity'").get() as { sql: string } | undefined
134
+ if (bundleTrigger && !bundleTrigger.sql.includes('provenance')) db.exec('DROP TRIGGER managed_bundle_activity')
135
+ for (const type of ['report', 'bundle']) {
136
+ const columns = `id, kind, actor, action, repo, report_id, report, at, bundle_id, actor_id`
137
+ const values = (alias: string) => `'${type}-upload:' || ${alias}.id, 'upload',
138
+ COALESCE(${alias}.uploaded_by_login, (SELECT login FROM managed_user WHERE id = ${alias}.uploaded_by)), ${uploadAction(type, alias)},
139
+ (SELECT full_name FROM managed_selected_repo WHERE repo_id = ${alias}.repo_id),
140
+ ${type === 'report' ? `${alias}.id` : 'NULL'}, ${alias}.filename, ${alias}.uploaded_at,
141
+ ${type === 'bundle' ? `${alias}.id` : 'NULL'}, ${alias}.uploaded_by`
142
+ db.exec(`
143
+ CREATE TRIGGER IF NOT EXISTS managed_${type}_activity AFTER INSERT ON managed_${type}
144
+ BEGIN INSERT INTO managed_activity (${columns}) SELECT ${values('NEW')}; END;
145
+ INSERT OR IGNORE INTO managed_activity (${columns}) SELECT ${values('source')} FROM managed_${type} source;
146
+ `)
147
+ }
148
+ }
149
+
150
+ // The creation entry for a stored report or bundle. Shared with the Postgres
151
+ // trigger function; `alias` is the trigger row (NEW) or a backfill alias.
152
+ export function uploadAction(type: string, alias: string): string {
153
+ return type === 'bundle' ? `CASE WHEN ${alias}.provenance = 'build' THEN 'built a bundle' ELSE 'uploaded a bundle' END` : `'uploaded a ${type}'`
154
+ }
155
+
156
+ export function activityMethods(db: ManagedSql): ActivityStore {
157
+ const insert = db.prepare(`INSERT INTO managed_activity (id, kind, actor, action, repo, report_id, report, at, bundle_id, repo_id, repo_directory, actor_id)
158
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
159
+ const admin = activityStatements(db, adminSource)
160
+ const manager = activityStatements(db, managerSource)
161
+ const reports = db.prepare(`SELECT r.id AS reportId, r.filename AS report, p.full_name AS repo
162
+ FROM managed_report r JOIN managed_selected_repo p ON p.repo_id = r.repo_id
163
+ WHERE EXISTS (SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
164
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
165
+ WHERE tu.user_id = ? AND tr.repo_id = r.repo_id AND ${withinTeamPath('r.repo_directory')})
166
+ ORDER BY r.uploaded_at DESC, r.id`)
167
+ return {
168
+ async recordActivity(entry, at) {
169
+ await insert.run(randomUUID(), entry.kind, entry.actor, entry.action, entry.repo ?? null, entry.reportId ?? null, entry.report ?? null, at,
170
+ entry.bundleId ?? null, entry.repoId ?? null, entry.repoDirectory ?? null, entry.actorId ?? null)
171
+ },
172
+ async listActivityReports(userId) {
173
+ return (await reports.all(userId)) as Omit<ActivityContext, 'finding'>[]
174
+ },
175
+ async listActivity({ page, limit, kind, query, repo = '', actor = '', contexts, userId }) {
176
+ const stmts = contexts == null ? admin : manager
177
+ const scope = contexts == null ? {} : { contexts: JSON.stringify(contexts), userId: userId ?? '' }
178
+ const params = { ...scope, kind, query, repo, actor }
179
+ const read = async (at: number) => (await stmts.rows.all({ ...params, limit, offset: (at - 1) * limit })) as (ActivityEntry & { total: number | string })[]
180
+ // A page's rows carry the total. Only a page past the last has none to
181
+ // carry it: it is counted, and the last page read in its place.
182
+ let currentPage = page, rows = await read(page)
183
+ let total = rows.length > 0 ? Number(rows[0]!.total) : 0
184
+ if (rows.length === 0 && page > 1) {
185
+ total = Number(((await stmts.count.get(params)) as { total: number | string }).total)
186
+ currentPage = Math.min(page, Math.max(1, Math.ceil(total / limit)))
187
+ if (currentPage !== page) rows = total === 0 ? [] : await read(currentPage)
188
+ }
189
+ const history = rows.map(({ total: _total, ...entry }) => entry)
190
+ const filters: ActivityFilters = { repos: [], users: [] }
191
+ for (const row of (await stmts.filters.all(scope)) as { facet: string; id: string; login: string; detail: string | null }[]) {
192
+ if (row.facet === 'repo') filters.repos.push(row.id)
193
+ else filters.users.push({ id: row.id, login: row.login, detail: row.detail })
194
+ }
195
+ return { history, total, page: currentPage, limit, filters }
196
+ },
197
+ }
198
+ }
199
+
200
+ // Both rows and selector choices come from the authorized source. Keep legacy
201
+ // login-only actors separate: a historical login is not proof of user identity.
202
+ function activityStatements(db: ManagedSql, source: string) {
203
+ const activity = `WITH events AS (${source}), activity AS (
204
+ SELECT events.*, COALESCE('user:' || actorId, 'legacy:' || actor) AS actorKey FROM events
205
+ )`
206
+ const filtered = `${activity} SELECT id, kind, actor, action, repo, reportId, report, finding, at FROM activity
207
+ WHERE (:kind = 'all' OR kind = :kind)
208
+ AND (:repo = '' OR repo = :repo) AND (:actor = '' OR actorKey = :actor)
209
+ AND (:query = '' OR instr(lower(coalesce(actor, '') || ' ' || action || ' ' ||
210
+ coalesce(repo, '') || ' ' || coalesce(report, '') || ' ' || coalesce(finding, '')), lower(:query)) > 0)`
211
+ // A page's rows each carry the total, so the source is read once for both;
212
+ // the selector choices, repositories then users, once between them.
213
+ return {
214
+ count: db.prepare(`SELECT count(*) AS total FROM (${filtered}) AS filtered`),
215
+ rows: db.prepare(`SELECT filtered.*, count(*) OVER () AS total FROM (${filtered}) AS filtered ORDER BY at DESC,
216
+ CASE WHEN kind = 'triage' THEN CAST(substr(id, instr(id, ':') + 1) AS INTEGER) ELSE 0 END DESC,
217
+ id DESC LIMIT :limit OFFSET :offset`),
218
+ filters: db.prepare(`${activity} SELECT 'repo' AS facet, repo AS id, repo AS login, NULL AS detail, repo AS sort, '' AS tiebreak
219
+ FROM (SELECT DISTINCT repo FROM activity WHERE repo IS NOT NULL AND repo <> '') AS repos
220
+ UNION ALL SELECT 'user' AS facet, a.actorKey AS id, COALESCE(u.login, a.actor, a.actorId) AS login,
221
+ CASE WHEN a.actorId IS NULL THEN 'Legacy actor' ELSE NULL END AS detail, COALESCE(u.login, a.actor, a.actorId) AS sort, a.actorKey AS tiebreak FROM (
222
+ SELECT actorKey, actorId, actor, ROW_NUMBER() OVER (PARTITION BY actorKey ORDER BY at DESC, id DESC) AS position
223
+ FROM activity WHERE actorKey IS NOT NULL AND actorKey <> 'legacy:'
224
+ ) a LEFT JOIN managed_user u ON u.id = a.actorId WHERE a.position = 1
225
+ ORDER BY facet, sort, tiebreak`),
226
+ }
227
+ }
228
+
229
+ // Literal path comparison preserves separators, case and SQL wildcard chars.
230
+ function withinTeamPath(path: string): string {
231
+ return `(tr.path IS NULL OR tr.path = '' OR ${path} = tr.path OR substr(${path}, 1, length(tr.path) + 1) = tr.path || '/')`
232
+ }
233
+
234
+ function migrateActivityScope(db: DatabaseSync): void {
235
+ const columns = new Set((db.prepare('PRAGMA table_info(managed_activity)').all() as { name: string }[]).map(column => column.name))
236
+ const missing = [['bundle_id', 'TEXT'], ['repo_id', 'INTEGER'], ['repo_directory', 'TEXT'], ['actor_id', 'TEXT']].filter(([name]) => !columns.has(name!))
237
+ if (missing.length === 0) return
238
+ db.exec('BEGIN')
239
+ try {
240
+ for (const [name, type] of missing) db.exec(`ALTER TABLE managed_activity ADD COLUMN ${name} ${type}`)
241
+ if (!columns.has('bundle_id') || !columns.has('actor_id')) {
242
+ db.exec('DROP TRIGGER IF EXISTS managed_report_activity; DROP TRIGGER IF EXISTS managed_bundle_activity;')
243
+ }
244
+ if (!columns.has('bundle_id')) db.exec("UPDATE managed_activity SET bundle_id = substr(id, 15) WHERE id LIKE 'bundle-upload:%'")
245
+ if (!columns.has('actor_id')) {
246
+ // Existing upload targets retain a reliable uploader identity. Do not
247
+ // guess identities from historical logins: names can change or be reused.
248
+ for (const type of ['report', 'bundle']) {
249
+ db.exec(`UPDATE managed_activity SET actor_id = (
250
+ SELECT uploaded_by FROM managed_${type} WHERE id = substr(managed_activity.id, 15)
251
+ ) WHERE id LIKE '${type}-upload:%'`)
252
+ }
253
+ }
254
+ // Legacy deletions/assignments without a durable target/scope remain
255
+ // admin-only; neither filenames nor repository names authorize access.
256
+ db.exec('COMMIT')
257
+ } catch (err) { db.exec('ROLLBACK'); throw err }
258
+ }
@@ -0,0 +1,109 @@
1
+ // The commits agents' pull requests brought to a repository's default
2
+ // branch, for its Pulse (repository-pulse.ts commitStats), as the pull
3
+ // requests kept tell them (repository-pulls.ts readPulls: whose each is,
4
+ // and what one merged brought).
5
+ import type { PullList } from './repository-pulls.ts'
6
+ import type { HistoryCommit } from './repository-history.ts'
7
+
8
+ // How long before or after a pull request rebased onto the branch was merged
9
+ // its copies are committed.
10
+ const REBASE_SKEW_MS = 10 * 60_000
11
+
12
+ // The agents' pull requests merged since a year's start, as read so far
13
+ // (agentMerges): the commits each brought, and when it was merged; those
14
+ // rebased onto the branch by their last commit there, when merged, and
15
+ // their commits' subjects, the oldest first (agentCommits); and how far the
16
+ // pull requests are read, as the list tells (repository-pulls.ts PullList:
17
+ // `through`, `reach`, `next`). And of every pull request merged, an agent's
18
+ // or not, its merge commit, when and its number (`pulls`, by which the
19
+ // Commits tab tells those squashed too, http.ts pulseMerges; none of one
20
+ // kept before numbers were), and one rebased so (`pullRebases`), for the
21
+ // commits pushed to the branch directly to be told (directCommits).
22
+ export interface AgentMerges { merges: [string, number][]; rebases: Rebase[]; through: number | null; reach?: number
23
+ next: { after: string; from: number; stop: number; read: number; to?: number } | null; pulls?: [sha: string, at: number, number?: number][]; pullRebases?: Rebase[] }
24
+ // A pull request rebased onto the branch: its last commit there, when it was
25
+ // merged, its latest commits' subjects, the oldest first, and how many more
26
+ // it had than those read (`more`).
27
+ type Rebase = [sha: string, at: number, subjects: string[], more?: number]
28
+
29
+ // What the pull requests merged since `since` of those read (`list`) brought:
30
+ // an agent's, its merge commit, a squash merge's one commit, and one merged
31
+ // with a merge commit, its own commits too; and every one's merge commit,
32
+ // those rebased by their commits' subjects.
33
+ export function agentMerges(list: PullList, since: number): AgentMerges {
34
+ const merges = new Map<string, number>()
35
+ const [rebases, pulls, pullRebases]: [Rebase[], [string, number, number][], Rebase[]] = [[], [], []]
36
+ for (const { number, merged } of list.rows) {
37
+ if (!merged || merged.at < since) continue
38
+ const { sha, at, subjects, more = 0 } = merged
39
+ const rebase: Rebase | null = subjects ? more > 0 ? [sha, at, subjects, more] : [sha, at, subjects] : null
40
+ pulls.push([sha, at, number])
41
+ if (rebase) pullRebases.push(rebase)
42
+ if (!merged.agent) continue
43
+ for (const oid of [sha, ...merged.own ?? []]) merges.set(oid, at)
44
+ if (rebase) rebases.push(rebase)
45
+ }
46
+ return { merges: [...merges], rebases, through: list.through, ...list.reach === undefined ? {} : { reach: list.reach }, next: list.next, pulls, pullRebases }
47
+ }
48
+
49
+ // Whether two reads of the pull requests merged (agentMerges) bring the
50
+ // same commits, the agents' and every one's (`pulls`, `pullRebases`, which
51
+ // tell those pushed directly), whatever order each read them in.
52
+ export function sameMerges(a: AgentMerges, b: AgentMerges) {
53
+ const sorted = (entries: unknown[] = []) => entries.map(entry => JSON.stringify(entry)).toSorted()
54
+ const brought = (read: AgentMerges) => JSON.stringify([read.merges, read.rebases, read.pulls, read.pullRebases].map(sorted))
55
+ return brought(a) === brought(b)
56
+ }
57
+
58
+ // A commit's subject as GitHub heads its message: whole, or, past what it
59
+ // shows, cut short before an ellipsis.
60
+ const headed = (subject: string, headline: string) => headline.endsWith('…') ? subject.startsWith(headline.slice(0, -1)) : subject === headline
61
+
62
+ // The commits of `commits` (a year's) the agents' pull requests brought
63
+ // (`read`, agentMerges): those it names, and of one rebased, its commits'
64
+ // copies, the last and those below it on their first parents, as long as
65
+ // each one's subject is that of one of its commits older than the last
66
+ // matched, as Commits tells a rebase (repository-squashes.ts squashedPulls),
67
+ // past those of its commits the rebase left none of (an empty one GitHub
68
+ // counted no files of), and, past them all, as many more as it had before
69
+ // its latest read; a squash merge's one commit alone, its subject the pull
70
+ // request's own.
71
+ export function agentCommits(read: AgentMerges, commits: HistoryCommit[]): Set<string> {
72
+ const shas = new Set(read.merges.map(([sha]) => sha))
73
+ const bySha = new Map(commits.map(commit => [commit.sha, commit]))
74
+ for (const [last, merged, subjects, more = 0] of read.rebases) {
75
+ let commit = bySha.get(last)
76
+ let at = subjects.length - 1
77
+ for (; commit && at >= 0; commit = bySha.get(commit.parents[0] ?? '')) {
78
+ const [copied, upTo] = [commit, at]
79
+ const found = subjects.findLastIndex((subject, index) => index <= upTo && headed(copied.subject, subject))
80
+ if (found < 0) break
81
+ shas.add(commit.sha)
82
+ at = found - 1
83
+ }
84
+ // Each of its latest commits read copied, as many as it had before them
85
+ // the commits below, on their first parents, as long as each was
86
+ // committed as it was merged, as a rebase commits its copies: not past
87
+ // its first, its empty ones, which a rebase leaves out, counted too.
88
+ const copiedAsMerged = (when: number | null) => when !== null && Math.abs(when - merged) <= REBASE_SKEW_MS
89
+ for (let rest = at < 0 ? more : 0; commit && rest > 0 && copiedAsMerged(commit.committedAt); rest--, commit = bySha.get(commit.parents[0] ?? '')) {
90
+ shas.add(commit.sha)
91
+ }
92
+ }
93
+ return shas
94
+ }
95
+
96
+ // The commits of `commits` (a year's, from the branch's `head`) pushed to
97
+ // it directly: on its first parents from the head, none a merge, none a
98
+ // pull request brought (`read.pulls`: its merge commit, a squash's one
99
+ // commit, or the copies of one rebased onto it, as agentCommits tells
100
+ // them); those a merge brought from its other parents, not.
101
+ export function directCommits(read: AgentMerges, commits: HistoryCommit[], head: string): Set<string> {
102
+ const pulled = agentCommits({ ...read, merges: (read.pulls ?? []).map(([sha, at]) => [sha, at]), rebases: read.pullRebases ?? [] }, commits)
103
+ const bySha = new Map(commits.map(commit => [commit.sha, commit]))
104
+ const direct = new Set<string>()
105
+ for (let commit = bySha.get(head); commit; commit = bySha.get(commit.parents[0] ?? '')) {
106
+ if (commit.parents.length <= 1 && !pulled.has(commit.sha)) direct.add(commit.sha)
107
+ }
108
+ return direct
109
+ }
@@ -0,0 +1,23 @@
1
+ // The agents the server knows by their GitHub Apps or accounts (the Agents
2
+ // tab's, server-managed/repository-agents.ts, whose marks the Commits tab
3
+ // reads on commits too, repository-history.ts).
4
+ import type { AppAgent, AppAgentMarks } from '../common/managed/agent-tasks.ts'
5
+ import { GITHUB_AGENTS, type GithubAgent } from '../common/managed/github-agents.ts'
6
+
7
+ // The agents known by their GitHub Apps or accounts, beside Claude and
8
+ // Codex: those of GITHUB_AGENTS with branches of their own, in its order
9
+ // (common/managed/github-agents.ts, where each is told, its accounts among
10
+ // it), each its id and name, its avatar (its mark, served by
11
+ // /api/avatar/<avatar>, a GitHub App's, `app-<id>`), the fingerprint it
12
+ // leaves, and its branches' prefix.
13
+ export const APP_AGENTS: readonly (AppAgent & AppAgentMarks)[] = Object.entries<GithubAgent>(GITHUB_AGENTS)
14
+ .flatMap(([id, { name, avatar, prefix, fingerprint }]) => prefix ? [{ id, name, prefix, ...avatar ? { avatar } : {}, ...fingerprint ? { fingerprint } : {} }] : [])
15
+ // The prefixes of the branches agents work on: Claude Code's and Codex's,
16
+ // and the app agents'.
17
+ export const AGENT_BRANCH_PREFIXES = ['claude/', 'codex/', ...APP_AGENTS.map(agent => agent.prefix)]
18
+ // What the marks agents leave on commits are read by, as the history keeps
19
+ // them (repository-history.ts commitMarks), and branches' tips and the
20
+ // commits the app agents' accounts authored (repository-agents.ts): bumped
21
+ // as how they are told changes, so that the Agents tab reads again those
22
+ // kept by older rules.
23
+ export const AGENT_MARKS_READ = 1
@@ -0,0 +1,13 @@
1
+ // Shared avatar cache interface, implemented by storage-stores.ts.
2
+ import type { Buffer } from 'node:buffer'
3
+
4
+ export interface CachedAvatar {
5
+ contentType: string
6
+ bytes: Buffer
7
+ }
8
+
9
+ export interface AvatarStore {
10
+ put(id: string, contentType: string, bytes: Buffer): Promise<void>
11
+ get(id: string): Promise<CachedAvatar | null>
12
+ delete(id: string): Promise<void>
13
+ }
@@ -0,0 +1,17 @@
1
+ // Logical upload-store interfaces shared by disk and Vercel storage.
2
+ import type { Buffer } from 'node:buffer'
3
+ import type { Readable } from 'node:stream'
4
+
5
+ export interface OpenedBlob {
6
+ // Remote streams may omit their size; callers then omit Content-Length.
7
+ size: number | null
8
+ stream: Readable
9
+ }
10
+
11
+ export interface BlobStore {
12
+ // Encrypted upload stores return the wrapped data key for the SQL insert.
13
+ put(id: string, bytes: Buffer): Promise<string | null>
14
+ get(id: string): Promise<Buffer | null>
15
+ open(id: string): Promise<OpenedBlob | null>
16
+ delete(id: string): Promise<void>
17
+ }
@@ -0,0 +1,10 @@
1
+ import { brotliCompress, constants } from 'node:zlib'
2
+ import { promisify } from 'node:util'
3
+ import type { Buffer } from 'node:buffer'
4
+
5
+ const compress = promisify(brotliCompress)
6
+
7
+ // Bundles and cold caches use quality 4; stored reports use quality 9.
8
+ export function encodeBrotli(bytes: Uint8Array, quality = 4): Promise<Buffer> {
9
+ return compress(bytes, { params: { [constants.BROTLI_PARAM_QUALITY]: quality } })
10
+ }
@@ -0,0 +1,120 @@
1
+ import { type Advisory, type CacheStore, type Package, advisories } from '@preventive/upstream/advisories.js'
2
+ import { type Client, HttpError, createClient } from '@preventive/upstream/github.js'
3
+ import { isNpmPackageName, npmPackageScope } from '../common/managed/npm-packages.js'
4
+ import { NPM_REGISTRY, askUpstream } from './npm-packages.ts'
5
+
6
+ export const ADVISORIES_TIMEOUT_MS = 30_000
7
+ const PUBLIC_AT_ONCE = 16
8
+ const order = (a: string, b: string) => Number(a > b) - Number(a < b)
9
+
10
+ // Whether anyone may read a package, so that its name may go to a service
11
+ // other than npm (OSV): an unscoped one always, npm's private packages
12
+ // always being scoped; a scoped one where npm answers it anonymously, asked
13
+ // by a HEAD, which reads nothing of it. One npm can't be asked of is taken
14
+ // as private, and not kept; the rest are, for PUBLIC_MS, PUBLIC_NAMES at
15
+ // most, as a package may be made private, or public. A name npm can't have
16
+ // is asked of no one, upstream refusing it.
17
+ const PUBLIC_MS = 60 * 60_000
18
+ const PUBLIC_NAMES = 10_000
19
+ const publicNames = new Map<string, { at: number; open: boolean }>()
20
+ async function isPublicNpmPackage(name: string, signal: AbortSignal): Promise<boolean> {
21
+ if (!isNpmPackageName(name)) return false
22
+ if (npmPackageScope(name) === null) return true
23
+ const kept = publicNames.get(name)
24
+ if (kept && Date.now() - kept.at < PUBLIC_MS) return kept.open
25
+ let open
26
+ try { open = await askUpstream(`${NPM_REGISTRY}/${name}`, { method: 'HEAD', limit: Number.MAX_SAFE_INTEGER, signal, absent: [401, 403, 404] }) !== null } catch { return false }
27
+ publicNames.delete(name)
28
+ publicNames.set(name, { at: Date.now(), open })
29
+ while (publicNames.size > PUBLIC_NAMES) publicNames.delete(publicNames.keys().next().value!)
30
+ return open
31
+ }
32
+
33
+ // The npm packages anyone may read (isPublicNpmPackage), a few asked at a
34
+ // time, within PUBLIC_BUDGET_MS, a third of the audit's: a scoped one npm
35
+ // hasn't told of by then is taken as private, asked of npm alone, for the
36
+ // audit to keep the rest of its time.
37
+ const PUBLIC_BUDGET_MS = 10_000
38
+ async function publicNpmNames(packages: Package[], signal: AbortSignal): Promise<Set<string>> {
39
+ const names = [...new Set(packages.filter(pkg => pkg.ecosystem === 'npm').map(pkg => pkg.name))]
40
+ const open = new Set<string>()
41
+ const budget = new AbortController()
42
+ const timer = setTimeout(() => { budget.abort(new Error('public-check-budget')) }, PUBLIC_BUDGET_MS)
43
+ const asking = AbortSignal.any([signal, budget.signal])
44
+ try {
45
+ for (let at = 0; at < names.length; at += PUBLIC_AT_ONCE) {
46
+ const chunk = names.slice(at, at + PUBLIC_AT_ONCE)
47
+ const checked = await Promise.all(chunk.map(name => isPublicNpmPackage(name, asking)))
48
+ for (const [i, name] of chunk.entries()) if (checked[i]) open.add(name)
49
+ }
50
+ } finally { clearTimeout(timer) }
51
+ return open
52
+ }
53
+
54
+ function advisoryGithubClient(token: string | null, signal: AbortSignal): Client {
55
+ const authenticated = createClient({ token, userAgent: 'deepview-triage' })
56
+ let client = authenticated
57
+ return {
58
+ ...authenticated,
59
+ async listRepoAdvisories(options) {
60
+ const current = client
61
+ try { return await current.listRepoAdvisories(options) } catch (error) {
62
+ if (token === null || current !== authenticated || !(error instanceof HttpError) || error.status !== 401) throw error
63
+ signal.throwIfAborted()
64
+ // Published advisories remain readable after a token is revoked. Reuse
65
+ // anonymous access for subsequent repositories in this audit as well.
66
+ if (client === authenticated) client = createClient({ token: null, userAgent: 'deepview-triage' })
67
+ return await client.listRepoAdvisories(options)
68
+ }
69
+ },
70
+ }
71
+ }
72
+
73
+ // The managed caller supplies its user's token when available; public shares
74
+ // remain anonymous. Upstream sends this credential only to GitHub and returns
75
+ // published advisories, matching versions across npm, OSV and repository rows:
76
+ // OSV's for npm packages anyone may read too, the malicious packages' records
77
+ // among them, which npm's registry hasn't.
78
+ // `cache` keeps each repository's listing (upstream-cache.ts) across audits;
79
+ // `github`, a client of the caller's, asks GitHub in place of one with
80
+ // `githubToken`.
81
+ export async function fetchBundleAdvisories(packages: Package[], signal: AbortSignal,
82
+ { debug = false, repoAdvisories = false, details = false, githubToken = null, github, cache }: {
83
+ debug?: boolean; repoAdvisories?: boolean; details?: boolean; githubToken?: string | null; github?: Client; cache?: CacheStore | undefined
84
+ } = {}): Promise<
85
+ { status: 200; body: Advisory[] } | { status: 502; body: { error: string } }
86
+ > {
87
+ let onAbort: (() => void) | undefined
88
+ try {
89
+ signal.throwIfAborted()
90
+ if (packages.length === 0) return { status: 200, body: [] }
91
+ // Upstream owns each transport's timeout and exposes no cancellation
92
+ // option. Bound the caller's wait for the multi-request audit as well.
93
+ const deadline = new Promise<never>((_resolve, reject) => {
94
+ onAbort = () => reject(signal.reason)
95
+ signal.addEventListener('abort', onAbort, { once: true })
96
+ })
97
+ // npm packages anyone may read are asked of OSV too (upstream's `osv`),
98
+ // for the malicious packages' records npm's registry hasn't; a private
99
+ // one's name never leaves for OSV, as it never does for Socket. A package
100
+ // is wholly in one ask or the other, so upstream's order holds.
101
+ const open = await Promise.race([publicNpmNames(packages, signal), deadline])
102
+ const toOsv = (pkg: Package) => pkg.ecosystem === 'npm' && open.has(pkg.name)
103
+ // Kept in the caller's store, or without one in upstream's disk cache,
104
+ // where the server set one (auditCache).
105
+ const options = { github: github ?? advisoryGithubClient(githubToken, signal), repoAdvisories, details, ...(cache && { cache }) }
106
+ const asked = [{ part: packages.filter(toOsv), osv: true }, { part: packages.filter(pkg => !toOsv(pkg)), osv: false }]
107
+ const result = await Promise.race([Promise.all(asked.map(({ part, osv }) => part.length === 0 ? Promise.resolve([]) : advisories(part, { ...options, osv }))), deadline])
108
+ return { status: 200, body: result.flat().toSorted((a, b) => order(a.ecosystem, b.ecosystem) || order(a.name, b.name)) }
109
+ } catch (error) {
110
+ // Answered as upstream's failure alone: logged, its message, or with
111
+ // `debug` the whole error, but for a request its client gave up on. One
112
+ // past its deadline (aborted with a TimeoutError, as AbortSignal.timeout
113
+ // and the bundle route's timer abort) is the server giving up: logged.
114
+ const cancelled = signal.aborted && (signal.reason as { name?: unknown } | undefined)?.name !== 'TimeoutError'
115
+ if (!cancelled) console.warn('managed: advisories failed:', debug || !(error instanceof Error) ? error : error.message)
116
+ return { status: 502, body: { error: 'upstream-unavailable' } }
117
+ } finally {
118
+ if (onAbort) signal.removeEventListener('abort', onAbort)
119
+ }
120
+ }
@@ -0,0 +1,77 @@
1
+ import { isEvidenceFile } from '@exodus/stasis/audit-corrections'
2
+ import { packageRepo } from '@exodus/stasis-core/bundle-util'
3
+ import type { Package } from '@preventive/upstream/advisories.js'
4
+ import type { BundleDetails } from '../common/bundle-metadata.js'
5
+
6
+ export interface SkippedAdvisoryPackage {
7
+ ecosystem: string
8
+ name: string
9
+ version: string
10
+ because: string
11
+ }
12
+ export interface BundleAdvisoryInventory { packages: Package[]; skipped: SkippedAdvisoryPackage[] }
13
+ interface BundleModule { ecosystem?: string; name?: string; version?: string; repo?: { github?: Package['github'] }; files: Record<string, unknown> }
14
+
15
+ // The GitHub repository a bundled package.json's text names, read as Stasis
16
+ // records a dependency's (packageRepo) and as Node reads the file, past a byte
17
+ // order mark the bundle keeps. Undefined where it names none or doesn't parse.
18
+ function manifestRepo(text: unknown): Package['github'] {
19
+ if (typeof text !== 'string') return undefined
20
+ try { return packageRepo(JSON.parse(text.replace(/^\uFEFF/u, '')))?.github } catch { return undefined }
21
+ }
22
+
23
+ function supported(ecosystem: string): ecosystem is Package['ecosystem'] {
24
+ return ['npm', 'cargo', 'composer', 'soldeer', 'github'].includes(ecosystem)
25
+ }
26
+
27
+ function skipReason(ecosystem: string, version: string): string | undefined {
28
+ if (ecosystem === 'cargo-git') return 'Crate vendored from git; its identity is not a crates.io package.'
29
+ if (ecosystem === 'cargo-unknown') return 'Crate source is unknown because .cargo-checksum.json is missing.'
30
+ if (ecosystem === 'composer' && /^dev-|-dev$/iu.test(version.replace(/#.*$/su, ''))) return 'Composer dev versions cannot be matched against release advisories.'
31
+ return undefined
32
+ }
33
+
34
+ // Package and reason presence both require a recorded evidence file, by Stasis's
35
+ // audit rule (server-only, as its semver uses Node's npm). Entry and manually
36
+ // added files count even when they have no incoming import edge.
37
+ export function bundleAdvisoryInventory(details: BundleDetails, paths: Iterable<string> | null = null): BundleAdvisoryInventory {
38
+ const modules = (details.bundle as { modules?: ReadonlyMap<string, BundleModule> } | undefined)?.modules
39
+ if (details.kind !== 'stasis' || !modules) return { packages: [], skipped: [] }
40
+ const selected = paths === null ? null : new Set(paths)
41
+ const packages = new Map<string, { ecosystem: Package['ecosystem']; name: string; versions: Set<string>; github?: Package['github'] | null }>()
42
+ const skipped = new Map<string, SkippedAdvisoryPackage>()
43
+ for (const [dir, info] of modules) {
44
+ if (dir === '.') continue
45
+ const ecosystem = info.ecosystem ?? (/(?:^|\/)node_modules\//u.test(dir) ? 'npm' : undefined)
46
+ const { name, version } = info
47
+ if (ecosystem === undefined || typeof name !== 'string' || !name || typeof version !== 'string' || !version) continue
48
+ if (!Object.keys(info.files).some(file => (!selected || selected.has(`${dir}/${file}`)) && isEvidenceFile(name, version, file, ecosystem))) continue
49
+ const because = skipReason(ecosystem, version)
50
+ if (!supported(ecosystem) || because !== undefined) {
51
+ skipped.set(JSON.stringify([ecosystem, name, version]), { ecosystem, name, version, because: because ?? `No advisory source is supported for ${ecosystem}.` })
52
+ continue
53
+ }
54
+ // Foundry's branch '.' means the superproject's branch. As in Stasis,
55
+ // 0.0.0 tells upstream its actual version is unknown, so every range covers it.
56
+ const auditedVersion = ecosystem === 'github' && version === '.' ? '0.0.0' : version
57
+ const key = JSON.stringify([ecosystem, ecosystem === 'github' ? name.toLowerCase() : name])
58
+ if (!packages.has(key)) packages.set(key, { ecosystem, name, versions: new Set() })
59
+ const pkg = packages.get(key)!
60
+ pkg.versions.add(auditedVersion)
61
+ // A bundle from before Stasis recorded `repo` may still carry the npm
62
+ // package's package.json; ask the repository it names, as Stasis does.
63
+ let github = ecosystem === 'github' ? undefined : info.repo?.github
64
+ if (github === undefined && ecosystem === 'npm') github = manifestRepo(info.files['package.json'])
65
+ if (github !== undefined) {
66
+ // Upstream accepts one repository per package. Conflicting hints (for
67
+ // example, versions from before and after a move) use registry discovery.
68
+ if (pkg.github === undefined) pkg.github = github
69
+ else if (pkg.github !== null && pkg.github.toLowerCase() !== github.toLowerCase()) pkg.github = null
70
+ }
71
+ }
72
+ const byPackage = (a: { ecosystem: string; name: string }, b: { ecosystem: string; name: string }) => a.ecosystem.localeCompare(b.ecosystem) || a.name.localeCompare(b.name)
73
+ return {
74
+ packages: [...packages.values()].map(({ github, ...pkg }) => ({ ...pkg, versions: [...pkg.versions].toSorted(), ...(github ? { github } : {}) })).toSorted(byPackage),
75
+ skipped: [...skipped.values()].toSorted((a, b) => byPackage(a, b) || a.version.localeCompare(b.version)),
76
+ }
77
+ }
@@ -0,0 +1,10 @@
1
+ export interface BundleBuildDiagnostic {
2
+ message: string
3
+ name?: string
4
+ code?: string
5
+ status?: number
6
+ stack?: string
7
+ cause?: BundleBuildDiagnostic
8
+ }
9
+ export function bundleBuildDiagnostic(error: unknown, token: string | null): BundleBuildDiagnostic
10
+ export type BundleBuildStage = 'worker-start' | 'build' | 'scope' | 'serialize' | 'compress'