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

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 (366) 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 +2 -0
  50. package/common/managed/finding-detail.js +32 -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 +5 -0
  54. package/common/managed/npm-packages.js +34 -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 +347 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +5 -0
  64. package/common/managed/team-app.js +31 -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-2WDZPXVL.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-4XMP7YVH.js +36 -0
  87. package/out/chunk-6LNQTKAV.js +52 -0
  88. package/out/chunk-6S2U2UYG.js +232 -0
  89. package/out/chunk-75YLHBCM.js +3 -0
  90. package/out/chunk-7BKKYVWR.js +7 -0
  91. package/out/chunk-7C7DWL7I.js +1 -0
  92. package/out/chunk-7VAWINQF.js +6 -0
  93. package/out/chunk-C4Z6ZBXV.js +10 -0
  94. package/out/chunk-FC65TPUB.js +1 -0
  95. package/out/chunk-FKBXNUW4.js +1 -0
  96. package/out/chunk-GDPCLSOS.js +2 -0
  97. package/out/chunk-GO7FT4OD.js +1 -0
  98. package/out/chunk-HTBYHSPG.js +1 -0
  99. package/out/chunk-IYPY47MC.js +1 -0
  100. package/out/chunk-IZ7POWI3.js +18 -0
  101. package/out/chunk-JCIIBLKR.js +1 -0
  102. package/out/chunk-NRPC74AM.js +1 -0
  103. package/out/chunk-OOJM4CTU.js +1 -0
  104. package/out/chunk-PBZPYIG7.js +1 -0
  105. package/out/chunk-PNHBZGDD.js +1 -0
  106. package/out/chunk-PWFUOLZK.js +17 -0
  107. package/out/chunk-PXK7EU4V.js +1 -0
  108. package/out/chunk-QB4CBOX2.js +1 -0
  109. package/out/chunk-R3NZFXVR.js +1 -0
  110. package/out/chunk-R5O6FAW4.js +2 -0
  111. package/out/chunk-SXV2T5C4.js +80 -0
  112. package/out/chunk-TQJDYHVE.js +2 -0
  113. package/out/chunk-TUII34K2.js +1 -0
  114. package/out/chunk-UK7RGJT6.js +3 -0
  115. package/out/chunk-VFWMQ3LG.js +2 -0
  116. package/out/chunk-VH4XMZIW.js +2 -0
  117. package/out/chunk-WCPJDAYA.js +10 -0
  118. package/out/chunk-WFYLMF6Z.js +10 -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 +362 -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 +21 -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 +73 -0
  237. package/server-managed/comparison.ts +41 -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 +458 -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 +142 -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 +100 -0
  249. package/server-managed/dependency-reviews.ts +346 -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 +415 -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 +39 -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 +5329 -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/locked-packages.ts +129 -0
  273. package/server-managed/lockfile-scope.ts +49 -0
  274. package/server-managed/lru.ts +30 -0
  275. package/server-managed/managed-issues.ts +78 -0
  276. package/server-managed/management-catalog.ts +45 -0
  277. package/server-managed/management.ts +99 -0
  278. package/server-managed/npm-delta-worker.js +23 -0
  279. package/server-managed/npm-delta.ts +194 -0
  280. package/server-managed/npm-insights.ts +449 -0
  281. package/server-managed/npm-kept.ts +215 -0
  282. package/server-managed/npm-loads.ts +264 -0
  283. package/server-managed/npm-manifest.ts +115 -0
  284. package/server-managed/npm-packages.ts +454 -0
  285. package/server-managed/npm-provenance.ts +123 -0
  286. package/server-managed/npm-risk.ts +285 -0
  287. package/server-managed/object-storage-disk.ts +148 -0
  288. package/server-managed/object-storage-vercel.ts +67 -0
  289. package/server-managed/object-storage.ts +75 -0
  290. package/server-managed/package-entry-points.ts +51 -0
  291. package/server-managed/pretty-print.ts +434 -0
  292. package/server-managed/public-workspace.ts +238 -0
  293. package/server-managed/pull-comment-reviews.ts +161 -0
  294. package/server-managed/pull-dependencies.ts +348 -0
  295. package/server-managed/pull-dependency-reviews.ts +328 -0
  296. package/server-managed/pull-reviews.ts +205 -0
  297. package/server-managed/queue.ts +39 -0
  298. package/server-managed/release-commits.ts +84 -0
  299. package/server-managed/rename-diff-worker.js +26 -0
  300. package/server-managed/rename-diff.ts +279 -0
  301. package/server-managed/repo-path.ts +21 -0
  302. package/server-managed/report-migration.ts +35 -0
  303. package/server-managed/report-query.ts +10 -0
  304. package/server-managed/report-response.ts +16 -0
  305. package/server-managed/report-sources.ts +178 -0
  306. package/server-managed/report-store.ts +85 -0
  307. package/server-managed/repository-actions.ts +218 -0
  308. package/server-managed/repository-agents.ts +355 -0
  309. package/server-managed/repository-aliases.ts +91 -0
  310. package/server-managed/repository-browser.ts +294 -0
  311. package/server-managed/repository-changes.ts +284 -0
  312. package/server-managed/repository-code.ts +215 -0
  313. package/server-managed/repository-dependency-reviews.ts +165 -0
  314. package/server-managed/repository-discovery.ts +82 -0
  315. package/server-managed/repository-history.ts +432 -0
  316. package/server-managed/repository-issues.ts +195 -0
  317. package/server-managed/repository-policy.ts +27 -0
  318. package/server-managed/repository-pulls.ts +156 -0
  319. package/server-managed/repository-pulse.ts +460 -0
  320. package/server-managed/repository-risk-assess.ts +105 -0
  321. package/server-managed/repository-risk-audit.ts +79 -0
  322. package/server-managed/repository-risk-read.ts +170 -0
  323. package/server-managed/repository-risk.ts +491 -0
  324. package/server-managed/repository-squashes.ts +168 -0
  325. package/server-managed/request-timing.ts +63 -0
  326. package/server-managed/reviewed-dependencies.ts +150 -0
  327. package/server-managed/revisions.ts +35 -0
  328. package/server-managed/scan-downloads.ts +87 -0
  329. package/server-managed/session.ts +132 -0
  330. package/server-managed/slugs.ts +38 -0
  331. package/server-managed/solidity-entry-points.ts +71 -0
  332. package/server-managed/sql-postgres.ts +34 -0
  333. package/server-managed/sql.ts +88 -0
  334. package/server-managed/static.ts +28 -0
  335. package/server-managed/storage-command.ts +16 -0
  336. package/server-managed/storage-db.ts +183 -0
  337. package/server-managed/storage-encryption.ts +290 -0
  338. package/server-managed/storage-maintenance.ts +259 -0
  339. package/server-managed/storage-payload.ts +121 -0
  340. package/server-managed/storage-stores.ts +91 -0
  341. package/server-managed/storage.ts +82 -0
  342. package/server-managed/team-app.ts +134 -0
  343. package/server-managed/team-catalog.ts +7 -0
  344. package/server-managed/team-feed.ts +180 -0
  345. package/server-managed/team-npm-scopes.ts +106 -0
  346. package/server-managed/team-reports.ts +307 -0
  347. package/server-managed/triage-response.ts +16 -0
  348. package/server-managed/type-only-worker.js +19 -0
  349. package/server-managed/type-only.ts +105 -0
  350. package/server-managed/uploads.ts +106 -0
  351. package/server-managed/upstream-cache.ts +243 -0
  352. package/server-managed/view-as.ts +58 -0
  353. package/server-managed/workspace-shares.ts +169 -0
  354. package/server.ts +70 -0
  355. package/server/index.ts +0 -480
  356. package/server/lifecycle.ts +0 -208
  357. /package/{LICENSE → LICENSE-MIT} +0 -0
  358. /package/{server → server-e2e}/auth.ts +0 -0
  359. /package/{server → server-e2e}/config.example.json +0 -0
  360. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  361. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  362. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  363. /package/{server → server-e2e}/peer.ts +0 -0
  364. /package/{server → server-e2e}/sse-session.ts +0 -0
  365. /package/{server → server-e2e}/util.ts +0 -0
  366. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,285 @@
1
+ // What calls for a look at an npm version, as risk tells it (risk/), for the
2
+ // managed viewer: risk is asked on the server alone, its records kept where
3
+ // each belongs by its type (risk/npm/registry.js, assess.js):
4
+ //
5
+ // - `risk/npm/assessments`, what of an assessment holds whatever the time:
6
+ // a public package's in the public npm cache, as its pretty-printed files
7
+ // are (storage-payload.ts PUBLIC_CACHE), by a hash of its key; a private
8
+ // one's in this instance's memory alone.
9
+ // - `risk/npm/versions`, what a version's document says, which npm never
10
+ // changes once published: a public package's in the database's upstream
11
+ // cache table, shared by every instance; a private one's in memory.
12
+ // - `risk/npm/downloads`, a package's downloads a day, which risk tells as
13
+ // kept for a week: a public package's in the database too, so that another
14
+ // instance, or this one restarted, asks npm's downloads API, which answers
15
+ // some 40 lookups a minute, none of them again that week, each removed a
16
+ // day past it (npm-kept.ts pruneKept); a private one's in memory.
17
+ // - `risk/npm/packages`, `risk/npm/old-versions`, `risk/npm/public` and
18
+ // `risk/npm/advisories`, which risk asks again after an hour: a public
19
+ // package's in memory and in the database too, read from memory first, so
20
+ // that a cold instance, as a serverless one starts, reads no whole document
21
+ // of npm's again that one before it read within the hour; by a hash of
22
+ // their type and key, as a dependency's spec runs to any length, each
23
+ // removed a day on, and one past 256 KiB as JSON (a package's deprecated
24
+ // versions, its messages and all, run long) in memory alone. A private
25
+ // one's in memory.
26
+ // - `npm/tarballs`: none asked, as tarballs are read as the viewer's loads
27
+ // read them, kept where they keep them (npm-loads.ts loadNpmTarball), and
28
+ // an assessment kept needs none.
29
+ //
30
+ // What memory keeps is held to 2,000 records and 64 MiB as JSON, the first
31
+ // kept going first.
32
+ //
33
+ // A cache failure is a miss, never a failed assessment. Packages' documents
34
+ // are read as every other npm document here is, within their bounds and
35
+ // shared budget (npm-packages.ts readNpmPackument), and tarballs as the
36
+ // viewer's loads read them, bounded as they arrive and among the loads made
37
+ // at once. Assessments made at once are bounded as those loads are, each
38
+ // unpacking up to two tarballs: one past them is refused as busy.
39
+ import { Buffer } from 'node:buffer'
40
+ import { createHash } from 'node:crypto'
41
+ import { buffer } from 'node:stream/consumers'
42
+ import type { CacheStore } from '@preventive/upstream/npm.js'
43
+ import { DEFAULT_MINIMUM_RELEASE_AGE, type NpmAssessment, assessNpm, npmReadyDownload, npmReadyDownloads } from '../risk/index.js'
44
+ import { CacheMissError, type CacheStorage } from './cache-storage.ts'
45
+ import { npmVersionStore } from './npm-kept.ts'
46
+ import { loadNpmTarball } from './npm-loads.ts'
47
+ import { NpmPackageError, type NpmVersionDocument, npmTokenReads, readNpmPackument, readNpmVersion } from './npm-packages.ts'
48
+ import { npmPackageScope } from '../common/managed/npm-packages.js'
49
+ import { pruneKept } from './npm-kept.ts'
50
+ import type { UpstreamCacheStore } from './upstream-cache.ts'
51
+
52
+ const MEMORY_ENTRIES = 2000
53
+ // As npm-loads.ts MAX_ACTIVE_LOADS.
54
+ const MAX_ACTIVE_ASSESSMENTS = 4
55
+ // Of them, those no reader waits on (`spare`), such as a scan's: the rest are
56
+ // left for readers.
57
+ export const MAX_SPARE_ASSESSMENTS = 2
58
+ let assessing = 0
59
+ // What memory holds at most, by its records' length as JSON: the first kept
60
+ // go first past it, and a record past an eighth of it isn't kept at all.
61
+ const MEMORY_LENGTH = 64 * 1024 * 1024
62
+ let memoryLength = 0
63
+ const memory = new Map<string, { value: unknown; length: number }>()
64
+ function forget(key: string) {
65
+ const kept = memory.get(key)
66
+ if (!kept) return
67
+ memory.delete(key)
68
+ memoryLength -= kept.length
69
+ }
70
+ function remember(key: string, value: unknown) {
71
+ forget(key)
72
+ const length = JSON.stringify(value)?.length ?? 0
73
+ if (length > MEMORY_LENGTH / 8) return
74
+ memory.set(key, { value, length })
75
+ memoryLength += length
76
+ while (memory.size > MEMORY_ENTRIES || memoryLength > MEMORY_LENGTH) forget(memory.keys().next().value!)
77
+ }
78
+
79
+ const digest = (type: string, key: string) => createHash('sha256').update(`${type}\0${key}`).digest('hex')
80
+ const hashed = (type: string, key: string) => `risk/${digest(type, key)}`
81
+ // What of a public package is kept in the database by its key, and for how
82
+ // long its downloads are: risk's week, and a day more.
83
+ const IN_DATABASE = new Set(['risk/npm/versions', 'risk/npm/downloads'])
84
+ const DOWNLOADS_KEPT_MS = 8 * 24 * 60 * 60_000
85
+ // What of a public package risk asks again after an hour, kept in memory and
86
+ // in the database (HOURLY_PREFIX), each a day, none past HOURLY_LENGTH as
87
+ // JSON there.
88
+ const HOURLY = new Set(['risk/npm/packages', 'risk/npm/old-versions', 'risk/npm/public', 'risk/npm/advisories'])
89
+ const HOURLY_PREFIX = 'risk/hourly/'
90
+ const HOURLY_KEPT_MS = 24 * 60 * 60_000
91
+ const HOURLY_LENGTH = 256 * 1024
92
+ const databaseKey = (type: string, key: string) => HOURLY.has(type) ? `${HOURLY_PREFIX}${digest(type, key)}` : `${type}/${key}`
93
+
94
+ export interface NpmRiskStores {
95
+ db: UpstreamCacheStore
96
+ npmCache?: CacheStorage | undefined
97
+ debug?: boolean | undefined
98
+ }
99
+
100
+ // A cache that reads and writes many of a type at once too, as risk's
101
+ // readying of downloads asks (npmReadyDownloads), and reads records of any
102
+ // types at once, as risk reads a package's record and its versions'
103
+ // (registry.js npmPackageFacts): one read and one write of the database each,
104
+ // not one a key, as a request's queries take turns on its connection
105
+ // (db-neon.ts).
106
+ export interface NpmRiskCache extends CacheStore {
107
+ readEach(entries: [type: string, key: string][]): Promise<unknown[]>
108
+ readMany(type: string, keys: string[]): Promise<Map<string, unknown>>
109
+ writeMany(type: string, entries: [key: string, value: unknown][]): Promise<void>
110
+ }
111
+
112
+ // Risk's cache for one package: `isPublic` whether anyone can read it, so
113
+ // that what is kept of it can be shared.
114
+ export function npmRiskCache({ db, npmCache, debug }: NpmRiskStores, isPublic: boolean): NpmRiskCache {
115
+ const warn = (what: string, error: unknown) => { if (debug) console.warn(`managed: risk cache ${what} failed:`, error) }
116
+ const memoryKey = (type: string, key: string) => `${isPublic ? '' : 'private\0'}${type}\0${key}`
117
+ // Whether a record of `type` is read of the database: unless it is one
118
+ // kept in memory too (HOURLY) and there.
119
+ const ofDatabase = (type: string, key: string) => isPublic && (IN_DATABASE.has(type) || HOURLY.has(type) && !memory.has(memoryKey(type, key)))
120
+ // A record as the database keeps it, read: an hourly one kept in memory
121
+ // too, for the next to ask.
122
+ const readOf = (type: string, key: string, row: string | null | undefined) => {
123
+ const value: unknown = row == null ? null : JSON.parse(row)
124
+ if (value !== null && HOURLY.has(type)) remember(memoryKey(type, key), value)
125
+ return value
126
+ }
127
+ const cache: NpmRiskCache = {
128
+ async readEach(entries) {
129
+ const asked = new Set(entries.flatMap(([type, key], i) => ofDatabase(type, key) ? [i] : []))
130
+ let rows = new Map<string, string>()
131
+ if (asked.size > 0) {
132
+ try { rows = await db.getUpstreamCacheEntries([...asked].map(i => databaseKey(...entries[i]!))) } catch (error) { warn('read', error) }
133
+ }
134
+ return Promise.all(entries.map(([type, key], i) => {
135
+ if (!asked.has(i)) return cache.read(type, key)
136
+ try { return readOf(type, key, rows.get(databaseKey(type, key))) } catch (error) { warn('read', error); return null }
137
+ }))
138
+ },
139
+ async readMany(type, keys) {
140
+ const values = await cache.readEach(keys.map(key => [type, key]))
141
+ return new Map(keys.map((key, i) => [key, values[i]]))
142
+ },
143
+ async writeMany(type, entries) {
144
+ if (!isPublic || !IN_DATABASE.has(type)) { for (const [key, value] of entries) await cache.write(type, key, value); return }
145
+ const at = Date.now()
146
+ try { await db.setUpstreamCacheEntries(entries.map(([key, value]) => [`${type}/${key}`, JSON.stringify(value)]), at) } catch (error) { warn('write', error); return }
147
+ if (type === 'risk/npm/downloads') await pruneKept(db, `${type}/`, DOWNLOADS_KEPT_MS, at, error => { warn('prune', error) })
148
+ },
149
+ async read(type, key) {
150
+ if (type === 'npm/tarballs') return null
151
+ try {
152
+ if (isPublic && type === 'risk/npm/assessments' && npmCache) return JSON.parse((await buffer((await npmCache.open(hashed(type, key))).stream)).toString('utf8'))
153
+ if (ofDatabase(type, key)) return readOf(type, key, await db.getUpstreamCacheEntry(databaseKey(type, key)))
154
+ } catch (error) { if (!(error instanceof CacheMissError)) warn('read', error); return null }
155
+ return memory.get(memoryKey(type, key))?.value ?? null
156
+ },
157
+ async write(type, key, value) {
158
+ if (type === 'npm/tarballs') return
159
+ if (isPublic && HOURLY.has(type)) remember(memoryKey(type, key), value)
160
+ try {
161
+ if (isPublic && type === 'risk/npm/assessments' && npmCache) { await npmCache.put(hashed(type, key), Buffer.from(JSON.stringify(value))); return }
162
+ if (isPublic && IN_DATABASE.has(type)) {
163
+ const at = Date.now()
164
+ await db.setUpstreamCacheEntry(databaseKey(type, key), JSON.stringify(value), at)
165
+ if (type === 'risk/npm/downloads') await pruneKept(db, `${type}/`, DOWNLOADS_KEPT_MS, at, error => { warn('prune', error) })
166
+ return
167
+ }
168
+ if (isPublic && HOURLY.has(type)) {
169
+ const json = JSON.stringify(value)
170
+ if (json.length > HOURLY_LENGTH) return
171
+ const at = Date.now()
172
+ await db.setUpstreamCacheEntry(databaseKey(type, key), json, at)
173
+ await pruneKept(db, HOURLY_PREFIX, HOURLY_KEPT_MS, at, error => { warn('prune', error) })
174
+ return
175
+ }
176
+ } catch (error) { warn('write', error); return }
177
+ remember(memoryKey(type, key), value)
178
+ },
179
+ }
180
+ return cache
181
+ }
182
+
183
+ // A version's tarball as the viewer's loads read it (npm-loads.ts), its bytes
184
+ // held to the integrity its reader's document names: given up on, and the
185
+ // load released, once `signal` aborts, the load going on for its other
186
+ // readers, so that a reader gone holds no assessment's place.
187
+ async function tarballOf(doc: NpmVersionDocument | undefined, signal: AbortSignal): Promise<Uint8Array> {
188
+ if (!doc) throw new NpmPackageError(502, 'upstream-invalid')
189
+ signal.throwIfAborted()
190
+ const load = loadNpmTarball(doc)
191
+ let onAbort = () => {}
192
+ const aborted = new Promise<never>((_, reject) => { onAbort = () => reject(signal.reason); signal.addEventListener('abort', onAbort, { once: true }) })
193
+ try { return await Promise.race([load.result, aborted]) } finally {
194
+ signal.removeEventListener('abort', onAbort)
195
+ load.release()
196
+ }
197
+ }
198
+
199
+ // The version `doc` names assessed against `old`'s, where given, each as the
200
+ // reader's readNpmVersion read it, or against the one risk picks of its
201
+ // versions (`'pick'`, assessNpm `oldVersion: true`), its document read as the
202
+ // open version's is where its tarball is wanted: trusted however new and however few
203
+ // download it where a team lists its scope (`teamScoped`), its own packages;
204
+ // else held to risk's minimum release age and flagged as unpopular. A new
205
+ // dependency on a package of a scope a team lists (`ownScope`, asked once a
206
+ // scope) is told no tier nor age, as an own package. Its
207
+ // documents are read with npm's token where the reader may have it
208
+ // (`privileged`, as readNpmVersion), its dependencies' without; what is kept
209
+ // of it is shared only where neither version is private, and OSV is asked of
210
+ // its advisories only where it isn't, a private package's name never sent to
211
+ // OSV, as it never is to Socket (http.ts). A `spare` one, no
212
+ // reader waiting on it, is made only within MAX_SPARE_ASSESSMENTS, and tells
213
+ // its dependencies without asking npm of their packages (assessNpm
214
+ // `dependencies`), nor its advisories, which a scan asks of a lockfile's
215
+ // packages at once (repository-risk.ts).
216
+ export function npmRisk(doc: NpmVersionDocument, old: NpmVersionDocument | 'pick' | null,
217
+ { teamScoped, ownScope = () => Promise.resolve(false), fromEmpty = false, privileged, signal, spare = false, ...stores }: NpmRiskStores & { teamScoped: boolean;
218
+ ownScope?: (scope: string) => Promise<boolean>; fromEmpty?: boolean; privileged: boolean; signal: AbortSignal; spare?: boolean }): Promise<NpmAssessment> {
219
+ const scopes = new Map<string, Promise<boolean>>()
220
+ const owned = (name: string) => {
221
+ const scope = npmPackageScope(name)
222
+ if (scope === null) return false
223
+ if (!scopes.has(scope)) scopes.set(scope, ownScope(scope))
224
+ return scopes.get(scope)!
225
+ }
226
+ const given = old === 'pick' ? null : old
227
+ // The one picked, read as the viewer reads a version, kept a day where public.
228
+ const documentOf = async (version: string) => [doc, given].find(read => read?.version === version)
229
+ ?? await readNpmVersion(doc.name, version, privileged, signal, npmVersionStore(stores.db, stores.debug)) ?? undefined
230
+ return bounded(spare, () => assessNpm(doc.name, doc.version, { oldVersion: old === 'pick' ? true : old?.version ?? null, fromEmpty,
231
+ minimumReleaseAge: teamScoped ? 0 : DEFAULT_MINIMUM_RELEASE_AGE,
232
+ popularity: !teamScoped, cache: npmRiskCache(stores, !doc.private && !given?.private), signal, dependencies: !spare, advisories: !spare, osv: !doc.private, owned,
233
+ packument: (name, { authorized }) => readNpmPackument(name, privileged && authorized, signal),
234
+ tarball: async (_name, version) => tarballOf(await documentOf(version), signal) }))
235
+ }
236
+
237
+ // An assessment made within MAX_ACTIVE_ASSESSMENTS, a `spare` one within
238
+ // MAX_SPARE_ASSESSMENTS, all across the server; one past them refused as
239
+ // busy (`npm-busy`).
240
+ async function bounded(spare: boolean, assess: () => Promise<NpmAssessment>): Promise<NpmAssessment> {
241
+ if (assessing >= (spare ? MAX_SPARE_ASSESSMENTS : MAX_ACTIVE_ASSESSMENTS)) throw new NpmPackageError(429, 'npm-busy')
242
+ assessing++
243
+ try { return await assess() } finally { assessing-- }
244
+ }
245
+
246
+ // A version npm has no document of, as risk tells one npm removed, as it
247
+ // removes every one it finds malware in (assessNpm): its package taken down
248
+ // by npm, as a scan's (`spare`), and its advisories of malware, npm's and,
249
+ // where anyone can read its package, OSV's, which alone has some of those
250
+ // npm removed (nx's), a private package's name never sent to OSV. Its
251
+ // package's document read anonymously, else with npm's token alone where
252
+ // that reads more for this reader (`privileged`, npmTokenReads), and kept as
253
+ // a private package's is; no
254
+ // tarball read, nothing of the version being left. Spare, within
255
+ // MAX_SPARE_ASSESSMENTS as a scan's others are. An NpmRiskError where
256
+ // nothing tells of it.
257
+ export function npmRiskRemoved(name: string, version: string,
258
+ { teamScoped, privileged, signal, ...stores }: NpmRiskStores & { teamScoped: boolean; privileged: boolean; signal: AbortSignal }): Promise<NpmAssessment> {
259
+ return bounded(true, async () => {
260
+ const open = await readNpmPackument(name, false, signal)
261
+ return assessNpm(name, version, { minimumReleaseAge: teamScoped ? 0 : DEFAULT_MINIMUM_RELEASE_AGE, popularity: false, downloads: false, dependencies: false,
262
+ osv: open !== null, cache: npmRiskCache(stores, false), signal,
263
+ packument: (read, { authorized }) => read === name
264
+ ? open || !npmTokenReads(name, privileged && authorized) ? Promise.resolve(open) : readNpmPackument(name, true, signal, { anonymous: false })
265
+ : readNpmPackument(read, privileged && authorized, signal),
266
+ tarball: () => Promise.reject(new NpmPackageError(502, 'upstream-invalid')) })
267
+ })
268
+ }
269
+
270
+ // What a scan readies before assessing its packages (repository-risk.ts):
271
+ // their downloads, as risk asks them, kept where a public package's are
272
+ // (npmRiskCache), so that their assessments wait on npm's downloads API for
273
+ // none. At once, those kept and unscoped ones, in bulk, all public, as only a
274
+ // scoped package can be private; each other one on its own
275
+ // (npmReadyScanDownload), kept only where npm counts some, which it does of
276
+ // public packages alone: a private one's none, as of one it has no such
277
+ // package of, isn't kept where public ones are, for its assessment to ask
278
+ // its own where private ones are.
279
+ export const npmReadyScanDownloads = (names: string[], ready: (name: string) => void, stores: NpmRiskStores, signal: AbortSignal) =>
280
+ npmReadyDownloads(names, ready, { cache: npmRiskCache(stores, true), signal })
281
+ export function npmReadyScanDownload(name: string, stores: NpmRiskStores, signal: AbortSignal) {
282
+ const cache = npmRiskCache(stores, true)
283
+ const counted = (value: unknown) => !(value && typeof value === 'object' && 'none' in value)
284
+ return npmReadyDownload(name, { cache: { read: cache.read, write: (type, key, value) => counted(value) ? cache.write(type, key, value) : Promise.resolve() }, signal })
285
+ }
@@ -0,0 +1,148 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { link, lstat, mkdir, open, readdir, rename, rm, rmdir, stat } from 'node:fs/promises'
3
+ import { dirname, join } from 'node:path'
4
+ import { type ObjectPage, type RawObjectStorage, objectPath } from './object-storage.ts'
5
+
6
+ function missing(err: unknown): boolean { return (err as NodeJS.ErrnoException).code === 'ENOENT' }
7
+ function version(info: { dev: number; ino: number; size: number; mtimeMs: number; ctimeMs: number }): string {
8
+ return JSON.stringify([info.dev, info.ino, info.size, info.mtimeMs, info.ctimeMs])
9
+ }
10
+
11
+ async function syncDirectory(dir: string): Promise<void> {
12
+ try {
13
+ const directory = await open(dir, 'r')
14
+ try { await directory.sync() } finally { await directory.close() }
15
+ } catch (err) {
16
+ // Windows and some mounted filesystems cannot open/fsync directories.
17
+ // Preserve real I/O failures; file fsync remains mandatory on every write.
18
+ if (!['EINVAL', 'ENOTSUP', 'EOPNOTSUPP', 'ENOSYS', 'EISDIR', 'EBADF', 'EPERM'].includes((err as NodeJS.ErrnoException).code ?? '')) throw err
19
+ }
20
+ }
21
+
22
+ // Keyset pagination survives concurrent file creation/deletion. A final sweep
23
+ // catches objects inserted before the cursor during migration.
24
+ async function listFiles(dir: string, prefix: string, cursor: string | null, limit: number, signal?: AbortSignal): Promise<ObjectPage> {
25
+ const objects: ObjectPage['objects'] = []
26
+ async function visit(key: string): Promise<void> {
27
+ signal?.throwIfAborted()
28
+ let entries
29
+ try {
30
+ if (!(await lstat(join(dir, key))).isDirectory()) {
31
+ console.warn('managed-storage: skipping unsupported directory', JSON.stringify(key))
32
+ return
33
+ }
34
+ entries = await readdir(join(dir, key), { withFileTypes: true })
35
+ }
36
+ catch (err) { if (missing(err)) return; throw err }
37
+ const order = (entry: typeof entries[number]) => `${entry.name}${entry.isDirectory() ? '/' : ''}`
38
+ entries.sort((a, b) => order(a) < order(b) ? -1 : order(a) > order(b) ? 1 : 0)
39
+ for (const entry of entries) {
40
+ signal?.throwIfAborted()
41
+ const child = `${key}${entry.name}`
42
+ if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) {
43
+ console.warn('managed-storage: skipping unsupported entry', JSON.stringify(child))
44
+ continue
45
+ }
46
+ try { objectPath(child) }
47
+ catch { console.warn('managed-storage: skipping invalid entry', JSON.stringify(child)); continue }
48
+ if (entry.isDirectory()) {
49
+ const folder = `${child}/`
50
+ if (cursor && folder < cursor && !cursor.startsWith(folder)) continue
51
+ await visit(folder)
52
+ if (objects.length > limit) return
53
+ }
54
+ else if (entry.isFile() && (!cursor || child > cursor)) {
55
+ try { objects.push({ key: objectPath(child), modifiedAt: (await stat(join(dir, child))).mtimeMs }) }
56
+ catch (err) { if (!missing(err)) throw err }
57
+ if (objects.length > limit) return
58
+ }
59
+ }
60
+ // Another migration worker can observe an unlink before its writer has
61
+ // synced the directory. Persist those observed removals before an empty
62
+ // inventory can mark the shared migration complete.
63
+ try { await syncDirectory(join(dir, key)) } catch (err) { if (!missing(err)) throw err }
64
+ }
65
+ if (!prefix.endsWith('/') || !Number.isInteger(limit) || limit < 1) throw new Error('Invalid storage page')
66
+ objectPath(`${prefix}validate`)
67
+ await visit(prefix)
68
+ return { objects: objects.slice(0, limit), cursor: objects.length > limit ? objects[limit - 1]!.key : null }
69
+ }
70
+
71
+ export function createDiskObjectStorage(dir: string): RawObjectStorage {
72
+ const path = (key: string) => join(dir, objectPath(key))
73
+ return {
74
+ async head(key, signal) {
75
+ signal?.throwIfAborted()
76
+ try {
77
+ const info = await stat(path(key))
78
+ return { size: info.size, version: version(info), modifiedAt: info.mtimeMs }
79
+ } catch (err) { if (missing(err)) return null; throw err }
80
+ },
81
+ async open(key, signal) {
82
+ signal?.throwIfAborted()
83
+ let file
84
+ try { file = await open(path(key), 'r') }
85
+ catch (err) { if (missing(err)) return null; throw err }
86
+ try {
87
+ const info = await file.stat()
88
+ return { size: info.size, version: version(info), modifiedAt: info.mtimeMs,
89
+ stream: file.createReadStream(signal ? { signal } : {}) }
90
+ } catch (err) { await file.close(); throw err }
91
+ },
92
+ async put(key, bytes, signal, expected) {
93
+ const target = path(key)
94
+ // Temp-file + rename makes replacement atomic to readers. Concurrent
95
+ // migrations use the same persisted data key and immutable plaintext.
96
+ const temp = `${target}.${randomUUID()}.tmp`
97
+ await mkdir(dirname(target), { recursive: true })
98
+ const file = await open(temp, 'wx', 0o600)
99
+ try {
100
+ await file.writeFile(bytes, signal ? { signal } : {})
101
+ await file.sync(); await file.close()
102
+ signal?.throwIfAborted()
103
+ if (expected === null) {
104
+ try { await link(temp, target) }
105
+ catch (err) { if ((err as NodeJS.ErrnoException).code === 'EEXIST') return false; throw err }
106
+ } else if (expected === undefined) await rename(temp, target)
107
+ else {
108
+ try { if (version(await stat(target)) !== expected) return false }
109
+ catch (err) { if (missing(err)) return false; throw err }
110
+ await rename(temp, target)
111
+ }
112
+ await syncDirectory(dirname(target))
113
+ return true
114
+ } finally { await file.close(); await rm(temp, { force: true }) }
115
+ },
116
+ async delete(key, expected, signal) {
117
+ signal?.throwIfAborted()
118
+ try {
119
+ if (expected !== undefined && version(await stat(path(key))) !== expected) return false
120
+ await rm(path(key), { force: true })
121
+ // Persist plaintext removal before migration checkpoints completion.
122
+ // Otherwise a crash could restore the directory entry after SQL has
123
+ // durably disabled further plaintext inventory sweeps.
124
+ await syncDirectory(dirname(path(key)))
125
+ return true
126
+ } catch (err) { if (missing(err)) return true; throw err }
127
+ },
128
+ async sync(key, signal) { signal?.throwIfAborted(); await syncDirectory(dirname(path(key))) },
129
+ async prune(prefix, signal) {
130
+ if (!prefix.endsWith('/')) throw new Error('Invalid storage prefix')
131
+ objectPath(`${prefix}validate`)
132
+ async function visit(folder: string): Promise<void> {
133
+ signal?.throwIfAborted()
134
+ let entries
135
+ try {
136
+ if (!(await lstat(folder)).isDirectory()) return
137
+ entries = await readdir(folder, { withFileTypes: true })
138
+ }
139
+ catch (err) { if (missing(err)) return; throw err }
140
+ for (const entry of entries) if (entry.isDirectory()) await visit(join(folder, entry.name))
141
+ try { await rmdir(folder); await syncDirectory(dirname(folder)) }
142
+ catch (err) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes((err as NodeJS.ErrnoException).code ?? '')) throw err }
143
+ }
144
+ await visit(join(dir, prefix))
145
+ },
146
+ list: (prefix, cursor, limit, signal) => listFiles(dir, prefix, cursor, limit, signal),
147
+ }
148
+ }
@@ -0,0 +1,67 @@
1
+ import { Readable } from 'node:stream'
2
+ import { Buffer } from 'node:buffer'
3
+ import { type VercelBlobSdk, isNotFound, loadVercelBlobSdk } from '../server-common/vercel-blob.ts'
4
+ import { type RawObjectStorage, objectPath } from './object-storage.ts'
5
+
6
+ export async function openVercelObjectStorage(token: string, sdk?: VercelBlobSdk): Promise<RawObjectStorage> {
7
+ const blobs = sdk ?? await loadVercelBlobSdk()
8
+ const path = (key: string) => `.managed/${objectPath(key)}`
9
+ const storage: RawObjectStorage = {
10
+ async head(key, signal) {
11
+ try {
12
+ const meta = await blobs.head(path(key), { token, ...(signal ? { abortSignal: signal } : {}) })
13
+ if (!meta.etag) throw new Error('Missing object version')
14
+ return { size: meta.size, version: meta.etag, modifiedAt: new Date(meta.uploadedAt ?? Date.now()).getTime() }
15
+ } catch (err) { if (isNotFound(err, blobs)) return null; throw err }
16
+ },
17
+ async open(key, signal) {
18
+ try {
19
+ // Read the stored representation: transport compression can weaken its
20
+ // ETag, which cannot satisfy ifMatch, and changes the advertised size.
21
+ const result = await blobs.get(path(key), { token, access: 'private', useCache: false,
22
+ headers: { 'accept-encoding': 'identity' }, ...(signal ? { abortSignal: signal } : {}) })
23
+ if (!result) return null
24
+ if (result.statusCode !== 200 || !result.stream || !result.blob.etag) {
25
+ await result.stream?.cancel()
26
+ throw new Error('Unexpected blob response or missing object version')
27
+ }
28
+ return { size: result.blob.size === 0 ? null : result.blob.size, version: result.blob.etag,
29
+ modifiedAt: new Date(result.blob.uploadedAt ?? Date.now()).getTime(),
30
+ stream: Readable.fromWeb(result.stream as Parameters<typeof Readable.fromWeb>[0], signal ? { signal } : {}) }
31
+ } catch (err) { if (isNotFound(err, blobs)) return null; throw err }
32
+ },
33
+ async put(key, bytes, signal, expected, sizeHint) {
34
+ const multipart = (sizeHint ?? (Buffer.isBuffer(bytes) ? bytes.length : Infinity)) >= 5 * 1024 * 1024
35
+ try { await blobs.put(path(key), bytes, { token, access: 'private', addRandomSuffix: false, allowOverwrite: expected !== null,
36
+ ...(expected ? { ifMatch: expected } : {}), multipart, contentType: 'application/octet-stream', cacheControlMaxAge: 60, ...(signal ? { abortSignal: signal } : {}) })
37
+ return true
38
+ } catch (err) {
39
+ if (blobs.BlobPreconditionFailedError && err instanceof blobs.BlobPreconditionFailedError) return false
40
+ // The SDK uses a generic error for an occupied pathname. Reconcile a
41
+ // create-only failure against storage; the caller verifies the winner.
42
+ if (expected === null && await storage.head(key, signal)) return false
43
+ throw err
44
+ }
45
+ },
46
+ async delete(key, version, signal) {
47
+ signal?.throwIfAborted()
48
+ try { await blobs.del(path(key), { token, ...(version ? { ifMatch: version } : {}), ...(signal ? { abortSignal: signal } : {}) }); return true }
49
+ catch (err) {
50
+ if (isNotFound(err, blobs)) return true
51
+ if (blobs.BlobPreconditionFailedError && err instanceof blobs.BlobPreconditionFailedError) return false
52
+ throw err
53
+ }
54
+ },
55
+ async list(prefix, cursor, limit, signal) {
56
+ signal?.throwIfAborted()
57
+ if (!prefix.endsWith('/') || !Number.isInteger(limit) || limit < 1) throw new Error('Invalid storage page')
58
+ objectPath(`${prefix}validate`)
59
+ const page = await blobs.list({ prefix: `.managed/${prefix}`, token, limit, ...(cursor ? { cursor } : {}), ...(signal ? { abortSignal: signal } : {}) })
60
+ if (page.hasMore && (!page.cursor || page.cursor === cursor)) throw new Error('Invalid blob pagination')
61
+ return { objects: page.blobs.filter(blob => blob.pathname.startsWith(`.managed/${prefix}`)).map(blob => ({ key: blob.pathname.slice('.managed/'.length),
62
+ modifiedAt: new Date(blob.uploadedAt ?? Date.now()).getTime() })),
63
+ cursor: page.hasMore ? page.cursor! : null }
64
+ },
65
+ }
66
+ return storage
67
+ }
@@ -0,0 +1,75 @@
1
+ import type { Buffer } from 'node:buffer'
2
+ import type { Readable } from 'node:stream'
3
+ import type { OpenedBlob } from './blob-store.ts'
4
+ import { validateCacheKey } from './cache-storage.ts'
5
+
6
+ export const ENCRYPTED_CACHE_PREFIX = 'cache-encrypted-v1/'
7
+ const STORAGE_PREFIXES = ['reports/', 'bundles/', 'avatars/', 'uploads/', 'cache/', ENCRYPTED_CACHE_PREFIX]
8
+ export interface RawObject extends OpenedBlob { version: string; modifiedAt: number }
9
+ type ObjectMetadata = Omit<RawObject, 'stream'>
10
+ interface ListedObject { key: string; modifiedAt: number }
11
+ export interface ObjectPage { objects: ListedObject[]; cursor: string | null }
12
+ export interface RawObjectStorage {
13
+ open(key: string, signal?: AbortSignal): Promise<RawObject | null>
14
+ head(key: string, signal?: AbortSignal): Promise<ObjectMetadata | null>
15
+ prune?(prefix: string, signal?: AbortSignal): Promise<void>
16
+ // Persist an observed disk replacement before its SQL migration checkpoint.
17
+ sync?(key: string, signal?: AbortSignal): Promise<void>
18
+ // A plaintext size hint selects multipart uploads without buffering streams.
19
+ // Publish atomically only after consuming the body through clean EOF; a body
20
+ // error must preserve the existing object (migration verifies while streaming).
21
+ // A null expected version creates only if absent, without replacing a winner.
22
+ put(key: string, bytes: Buffer | Readable, signal?: AbortSignal, expected?: string | null, sizeHint?: number): Promise<boolean>
23
+ // Return false if the original version changed; do not delete its replacement.
24
+ delete(key: string, version?: string, signal?: AbortSignal): Promise<boolean>
25
+ list(prefix: string, cursor: string | null, limit: number, signal?: AbortSignal): Promise<ObjectPage>
26
+ }
27
+ export interface ObjectStorage {
28
+ open(key: string): Promise<OpenedBlob | null>
29
+ get(key: string): Promise<Buffer | null>
30
+ exists(key: string): Promise<boolean>
31
+ put(key: string, bytes: Buffer, options?: { replaceReport?: boolean }): Promise<string | null>
32
+ delete(key: string): Promise<void>
33
+ // Every object under `prefix`, but those under `keep`, a prefix within it.
34
+ deletePrefix(prefix: string, keep?: string): Promise<void>
35
+ }
36
+
37
+ export function isBlobId(value: string): boolean {
38
+ return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u.test(value)
39
+ }
40
+
41
+ export function objectPath(key: string): string {
42
+ validateCacheKey(key)
43
+ if (!STORAGE_PREFIXES.some(prefix => key.startsWith(prefix))) throw new Error('Invalid managed storage namespace')
44
+ return key
45
+ }
46
+
47
+ // Hash verification and migration may consume a stream before it can be used.
48
+ // Reopen only the same object version, closing any concurrent replacement.
49
+ export async function openObjectVersion(raw: RawObjectStorage, key: string, version: string, signal?: AbortSignal): Promise<RawObject | null> {
50
+ const current = await raw.open(key, signal)
51
+ if (current?.version === version) return current
52
+ current?.stream.destroy()
53
+ return null
54
+ }
55
+
56
+ export async function deleteObjects(raw: RawObjectStorage, prefix: string, signal: AbortSignal, before?: number, keep?: string): Promise<number> {
57
+ const eligible = (modifiedAt: number) => before === undefined || modifiedAt < before
58
+ // Finish listing before deleting: provider cursors may be offset-based.
59
+ const cursors = new Set<string>(), keys = new Set<string>()
60
+ let cursor: string | null = null
61
+ do {
62
+ const page = await raw.list(prefix, cursor, 100, signal)
63
+ for (const object of page.objects) if (eligible(object.modifiedAt) && (keep === undefined || !object.key.startsWith(keep))) keys.add(object.key)
64
+ cursor = page.cursor
65
+ if (cursor !== null && cursors.has(cursor)) throw new Error('Invalid blob pagination')
66
+ if (cursor !== null) cursors.add(cursor)
67
+ } while (cursor !== null)
68
+ let removed = 0
69
+ for (const key of keys) {
70
+ const stored = await raw.head(key, signal)
71
+ // Ignore stale listings and preserve concurrent replacements.
72
+ if (stored && eligible(stored.modifiedAt) && await raw.delete(key, stored.version, signal)) removed++
73
+ }
74
+ return removed
75
+ }
@@ -0,0 +1,51 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { normalizeTeamPath } from './repo-path.ts'
3
+
4
+ export const MAX_PACKAGE_BYTES = 256 * 1024
5
+ const MAX_ENTRY_POINTS = 100
6
+
7
+ // Declared paths, not a Node resolver: never guess defaults, expand export
8
+ // patterns, or execute package code. Keep every suggestion in its package.
9
+ export function packageEntryPoints(manifest: unknown, directory: string): string[] {
10
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return []
11
+ const pkg = manifest as { main?: unknown; exports?: unknown; bin?: unknown }
12
+ const paths = new Set<string>()
13
+ const add = (value: unknown) => {
14
+ if (typeof value !== 'string' || value.startsWith('/') || value.endsWith('/') || /[*?]|^[a-z][a-z\d+.-]*:/iu.test(value)) return
15
+ const normalized = normalizeTeamPath(value)
16
+ if (!normalized.ok || !normalized.path || normalized.path === 'package.json') return
17
+ const path = directory ? `${directory}/${normalized.path}` : normalized.path
18
+ if (path.length <= 500 && paths.size < MAX_ENTRY_POINTS) paths.add(path)
19
+ }
20
+ add(pkg.main)
21
+ const pending = [pkg.exports]
22
+ for (let visited = 0; pending.length > 0 && visited < 4096; visited++) {
23
+ const value = pending.pop()
24
+ if (typeof value === 'string') { if (value.startsWith('./')) add(value); continue }
25
+ if (Array.isArray(value)) pending.push(...value.slice(0, 4096 - visited).toReversed())
26
+ else if (value && typeof value === 'object') {
27
+ const entries = Object.entries(value).filter(([key]) => key !== 'types' && !key.startsWith('types@'))
28
+ pending.push(...entries.slice(0, 4096 - visited).toReversed().map(([, target]) => target))
29
+ }
30
+ }
31
+ if (typeof pkg.bin === 'string') add(pkg.bin)
32
+ else if (pkg.bin && typeof pkg.bin === 'object' && !Array.isArray(pkg.bin)) Object.values(pkg.bin).forEach(add)
33
+ return [...paths]
34
+ }
35
+
36
+ // Use the immutable blob from the authorized listing, never download_url or a
37
+ // resolved symlink target. Malformed or oversized manifests are optional.
38
+ export async function readPackageEntryPoints(sha: string, directory: string, read: (suffix: string) => Promise<unknown>): Promise<string[]> {
39
+ try {
40
+ const blob = await read(`/git/blobs/${sha}`) as { encoding?: unknown; content?: unknown; size?: unknown }
41
+ if (blob?.encoding !== 'base64' || typeof blob.content !== 'string' || typeof blob.size !== 'number'
42
+ || blob.size < 0 || blob.size > MAX_PACKAGE_BYTES || blob.content.length > MAX_PACKAGE_BYTES * 2) return []
43
+ const bytes = Buffer.from(blob.content, 'base64')
44
+ if (bytes.length !== blob.size || bytes.length > MAX_PACKAGE_BYTES) return []
45
+ return packageEntryPoints(JSON.parse(bytes.toString('utf8')), directory)
46
+ } catch {
47
+ // Suggestions must not prevent browsing. The handler still rechecks
48
+ // GitHub and managed access after this optional upstream read.
49
+ return []
50
+ }
51
+ }