@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,63 @@
1
+ // Where a request's time goes, told back in its Server-Timing header, which
2
+ // DevTools shows beside the request: the database's statements and the time
3
+ // spent in its operations (sql.ts), new pooled connections and their
4
+ // handshakes (db-neon.ts), GitHub's calls (github-app.ts githubJson), and
5
+ // the whole, from the handler's start to its answer's head; an instance's
6
+ // first request marked cold. Counts and durations alone, nothing of what was
7
+ // asked; a streamed answer's head tells what was known as it was written.
8
+ import { AsyncLocalStorage } from 'node:async_hooks'
9
+ import { ServerResponse } from 'node:http'
10
+
11
+ interface Timing {
12
+ started: number
13
+ cold: boolean
14
+ statements: number
15
+ db: number; dbMs: number
16
+ conn: number; connMs: number
17
+ gh: number; ghMs: number
18
+ }
19
+ const requests = new AsyncLocalStorage<Timing>()
20
+ let served = false
21
+
22
+ // `work` timed, its response's head carrying what it spent where; a test's
23
+ // stand-in for a response, not a ServerResponse, is left as it is.
24
+ export function runTimed<T>(res: ServerResponse, work: () => T): T {
25
+ const timing: Timing = { started: performance.now(), cold: !served, statements: 0, db: 0, dbMs: 0, conn: 0, connMs: 0, gh: 0, ghMs: 0 }
26
+ served = true
27
+ if (res instanceof ServerResponse) {
28
+ const writeHead = res.writeHead
29
+ res.writeHead = function (this: ServerResponse, ...args: Parameters<ServerResponse['writeHead']>) {
30
+ if (!this.headersSent) this.setHeader('server-timing', header(timing))
31
+ return writeHead.apply(this, args)
32
+ } as ServerResponse['writeHead']
33
+ }
34
+ return requests.run(timing, work)
35
+ }
36
+
37
+ // `work`, its time counted as `kind`'s where a request is timed.
38
+ export async function timed<T>(kind: 'db' | 'conn' | 'gh', work: () => Promise<T>): Promise<T> {
39
+ const timing = requests.getStore()
40
+ if (!timing) return work()
41
+ const start = performance.now()
42
+ try { return await work() } finally {
43
+ timing[kind]++
44
+ timing[`${kind}Ms`] += performance.now() - start
45
+ }
46
+ }
47
+
48
+ // A statement sent to the database, where a request is timed.
49
+ export function countStatement(): void {
50
+ const timing = requests.getStore()
51
+ if (timing) timing.statements++
52
+ }
53
+
54
+ function header(timing: Timing): string {
55
+ const ms = (value: number) => value.toFixed(1)
56
+ return [
57
+ ...timing.db > 0 || timing.statements > 0 ? [`db;dur=${ms(timing.dbMs)};desc="${timing.statements} statements"`] : [],
58
+ ...timing.conn > 0 ? [`conn;dur=${ms(timing.connMs)}`] : [],
59
+ ...timing.gh > 0 ? [`gh;dur=${ms(timing.ghMs)};desc="${timing.gh} calls"`] : [],
60
+ ...timing.cold ? ['cold;desc="first request of this instance"'] : [],
61
+ `app;dur=${ms(performance.now() - timing.started)}`,
62
+ ].join(', ')
63
+ }
@@ -0,0 +1,150 @@
1
+ // The reviewed dependencies a host keeps beyond the app, as it registers
2
+ // them before the server is assembled (server.ts registerReviewedDependencies):
3
+ // an async method telling whether a dependency, as a lockfile locks it, is
4
+ // reviewed there (`lookup`), and, optionally, one reading the reviews of
5
+ // dependencies an open pull request's comments make (`parseComments`,
6
+ // pull-comment-reviews.ts), and one formatting one's own dependency reviews
7
+ // as a comment, for a copy button to give (`formatDependencyReviews`,
8
+ // dependency-review-comments.ts). Registered, they replace the commit a repository
9
+ // is reviewed since (pull-dependency-reviews.ts): a version passes by the
10
+ // reviews its teams publish, and those its pull request's comments make, as
11
+ // many as the repository requires, or by `lookup`'s saying so, and is
12
+ // otherwise unreviewed. A patched version (pnpm's patchedDependencies)
13
+ // passes by `lookup`, or reviews its comments make of its patch, alone, the
14
+ // app reviewing no patch yet. Those comments' reviews count for the pull
15
+ // request alone, as it is open: the host's to keep, as it is merged, among
16
+ // those `lookup` tells of.
17
+ //
18
+ // Each version is asked of `lookup` once a request, as many at once as
19
+ // ASK_AT_ONCE, its answer kept in this instance KEPT_MS; one it fails to
20
+ // answer is unreviewed, and counted so (`unasked`).
21
+ import { type Lru, lru } from './lru.ts'
22
+
23
+ export type ReviewedLockfile = 'npm' | 'pnpm' | 'yarn1'
24
+ // A dependency as a lockfile locks it: npm's always with its integrity (one
25
+ // with none is never reviewed, nor asked of), its shasum where the lockfile
26
+ // has one (Yarn 1's, after a tarball's `#`), and the hash of its patch where
27
+ // it is patched (pnpm's).
28
+ export interface ReviewedDependency {
29
+ ecosystem: 'npm'
30
+ lockfile: ReviewedLockfile
31
+ name: string
32
+ version: string
33
+ integrity?: string
34
+ shasum?: string
35
+ patchHash?: string
36
+ }
37
+ export type ReviewVerdict = 'accept' | 'accept-with-nits' | 'request-changes' | 'reject'
38
+ // A comment of an open pull request's, as `parseComments` is given it: one
39
+ // on the pull request (`comment`), a review's own (`review`, whatever its
40
+ // state, a comment's alone too), or one on a line of its diff
41
+ // (`review-comment`); by its author's GitHub login; when it was written, and
42
+ // last edited (`editedAt`) where it was.
43
+ export interface ReviewComment {
44
+ kind: 'comment' | 'review' | 'review-comment'
45
+ id: string
46
+ author: string | null
47
+ body: string
48
+ state?: string
49
+ createdAt: string
50
+ editedAt?: string
51
+ url: string
52
+ }
53
+ // What `parseComments` reads: the pull request, its comments, and the
54
+ // dependencies: every one the lockfiles its review holds to add that can be
55
+ // reviewed and `lookup` doesn't pass, and the others still wanting reviews.
56
+ export interface ParseCommentsInput { repository: string; number: number; comments: ReviewComment[]; dependencies: ReviewedDependency[] }
57
+ // A review its comments make, by `user` (a GitHub login, one of their
58
+ // authors'), of each of `dependencies`, with its verdict, as of `time` (when
59
+ // it was made, an ISO date or milliseconds; that user's latest comment's
60
+ // where none is told).
61
+ export interface CommentReview {
62
+ user: string
63
+ time?: string | number
64
+ dependencies: { ecosystem?: 'npm'; name: string; version: string; integrity: string; patchHash?: string; status: ReviewVerdict }[]
65
+ }
66
+ // A version a dependency review is of, or compared it with: its package's
67
+ // name, its version, the integrity and, where npm tells one, the shasum of
68
+ // its tarball, each held to the tarball reviewed, and its patch's hash where
69
+ // it is known (pnpm's patched dependencies').
70
+ export interface ReviewedVersion { name: string; version: string; integrity: string; shasum?: string; patchHash?: string }
71
+ // One of one's own published dependency reviews, as `formatDependencyReviews`
72
+ // is given it: the version it is of (`new`) and the one it compared it with
73
+ // (`old`, none where it read its package whole); its verdict (`status`);
74
+ // whether it read every file (`coverage`, `full`, else `partial`); its final
75
+ // comment; each file's verdict and comment (`files`, of those with either);
76
+ // by whom (`reviewer`, their GitHub login), and when it was published.
77
+ export interface DependencyReviewEntry {
78
+ ecosystem: 'npm'
79
+ old: ReviewedVersion | null
80
+ new: ReviewedVersion
81
+ status: ReviewVerdict
82
+ coverage: 'full' | 'partial'
83
+ comment: string
84
+ files: { path: string; status: ReviewVerdict | null; comment: string }[]
85
+ reviewer: string
86
+ publishedAt: number
87
+ }
88
+ // What `formatDependencyReviews` formats: one's own reviews, never another's,
89
+ // and the pull request whose versions they are of, where its copy asks.
90
+ export interface FormatDependencyReviewsInput { reviews: DependencyReviewEntry[]; pullRequest?: { repository: string; number: number } }
91
+ export interface ReviewedDependencies {
92
+ lookup(dependency: ReviewedDependency): Promise<boolean>
93
+ parseComments?(input: ParseCommentsInput): Promise<CommentReview[]>
94
+ // The comment to copy, none where it is empty, null or undefined.
95
+ formatDependencyReviews?(input: FormatDependencyReviewsInput): Promise<string | null | undefined>
96
+ }
97
+
98
+ const ASK_AT_ONCE = 16
99
+ const KEPT_MS = 60_000
100
+
101
+ let registered: ReviewedDependencies | null = null
102
+ let assembled = false
103
+ // Each lookup's answers, apart.
104
+ const answers = new WeakMap<ReviewedDependencies['lookup'], Lru<{ at: number; reviewed: boolean }>>()
105
+
106
+ // Registers the host's reviewed dependencies, once, before the server is
107
+ // assembled (init()): a server already assembled keeps what it was given.
108
+ export function registerReviewedDependencies(reviewed: ReviewedDependencies): void {
109
+ const optional = (method: unknown) => method === undefined || typeof method === 'function'
110
+ if (typeof reviewed?.lookup !== 'function' || !optional(reviewed.parseComments) || !optional(reviewed.formatDependencyReviews)) {
111
+ throw new TypeError('registerReviewedDependencies: expected { lookup, parseComments?, formatDependencyReviews? }, async methods')
112
+ }
113
+ if (assembled) throw new Error('registerReviewedDependencies: call it before init(), as the server takes them as it is assembled')
114
+ if (registered) throw new Error('registerReviewedDependencies: reviewed dependencies are registered already')
115
+ registered = { lookup: reviewed.lookup.bind(reviewed), ...reviewed.parseComments ? { parseComments: reviewed.parseComments.bind(reviewed) } : {},
116
+ ...reviewed.formatDependencyReviews ? { formatDependencyReviews: reviewed.formatDependencyReviews.bind(reviewed) } : {} }
117
+ }
118
+
119
+ // The reviewed dependencies registered, as a server takes them once it is
120
+ // assembled; none registers after.
121
+ export function assembledReviewedDependencies(): ReviewedDependencies | null {
122
+ assembled = true
123
+ return registered
124
+ }
125
+
126
+ // Which of `dependencies`, by their keys, `lookup` passes, and how many it
127
+ // failed to answer.
128
+ export async function askReviewed(lookup: ReviewedDependencies['lookup'], dependencies: Map<string, ReviewedDependency>): Promise<{ passed: Set<string>; unasked: number }> {
129
+ const passed = new Set<string>()
130
+ let unasked = 0
131
+ const pending = [...dependencies]
132
+ const kept = answers.get(lookup) ?? answers.set(lookup, lru(100_000)).get(lookup)!
133
+ await Promise.all(Array.from({ length: Math.min(ASK_AT_ONCE, pending.length) }, async () => {
134
+ for (let next = pending.pop(); next; next = pending.pop()) {
135
+ const [key, dependency] = next
136
+ const known = kept.get(key)
137
+ let answer = known && Date.now() - known.at < KEPT_MS ? known.reviewed : null
138
+ if (answer === null) {
139
+ try { answer = await lookup({ ...dependency }) === true } catch (error) {
140
+ unasked++
141
+ console.warn('reviewed dependencies: failed to answer', `${dependency.name}@${dependency.version}`, error instanceof Error ? error.message : error)
142
+ continue
143
+ }
144
+ kept.set(key, { at: Date.now(), reviewed: answer })
145
+ }
146
+ if (answer) passed.add(key)
147
+ }
148
+ }))
149
+ return { passed, unasked }
150
+ }
@@ -0,0 +1,35 @@
1
+ // Internal invalidation counters. Never expose these global values to clients:
2
+ // feeds still publish only the existing user/visibility-scoped revisions.
3
+ const catalogTables = ['managed_link_report', 'managed_team', 'managed_team_user', 'managed_team_repo', 'managed_report', 'managed_bundle', 'managed_selected_repo']
4
+ const annotationTables = ['managed_finding_triage', 'managed_finding_triage_event', 'managed_finding_comment', 'managed_finding_issue']
5
+ export function revisionSchema(postgres = false): string {
6
+ let sql = `CREATE TABLE IF NOT EXISTS managed_maintenance_lease (
7
+ id INTEGER PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL
8
+ ); CREATE TABLE IF NOT EXISTS managed_change_revision (
9
+ id INTEGER PRIMARY KEY, catalog INTEGER NOT NULL, annotations INTEGER NOT NULL
10
+ ); INSERT INTO managed_change_revision VALUES (1, 0, 0) ON CONFLICT(id) DO NOTHING;`
11
+ for (const [column, tables] of [['catalog', catalogTables], ['annotations', annotationTables]] as const) {
12
+ const update = `UPDATE managed_change_revision SET ${column} = ${column} + 1 WHERE id = 1;`
13
+ if (postgres) {
14
+ sql += `CREATE OR REPLACE FUNCTION managed_${column}_changed() RETURNS trigger LANGUAGE plpgsql AS $$
15
+ BEGIN IF EXISTS (SELECT 1 FROM managed_revision_rows) THEN ${update} END IF; RETURN NULL; END $$;`
16
+ }
17
+ for (const table of tables) {
18
+ if (postgres) {
19
+ sql += `DROP TRIGGER IF EXISTS ${table}_revision ON ${table};`
20
+ for (const event of ['INSERT', 'UPDATE', 'DELETE']) {
21
+ sql += `DROP TRIGGER IF EXISTS ${table}_revision_${event} ON ${table};
22
+ CREATE TRIGGER ${table}_revision_${event} AFTER ${event} ON ${table}
23
+ REFERENCING ${event === 'DELETE' ? 'OLD' : 'NEW'} TABLE AS managed_revision_rows
24
+ FOR EACH STATEMENT EXECUTE FUNCTION managed_${column}_changed();`
25
+ }
26
+ } else {
27
+ for (const event of ['INSERT', 'UPDATE', 'DELETE']) {
28
+ sql += `CREATE TRIGGER IF NOT EXISTS ${table}_revision_${event}
29
+ AFTER ${event} ON ${table} BEGIN ${update} END;`
30
+ }
31
+ }
32
+ }
33
+ }
34
+ return sql
35
+ }
@@ -0,0 +1,87 @@
1
+ // A scan's packages' downloads, as risk asks them, readied before the
2
+ // packages are assessed (repository-risk.ts), so that no assessment waits on
3
+ // npm's downloads API, which answers some 40 lookups a minute: a package is
4
+ // given to the assessors once its downloads are kept (npm-risk.ts
5
+ // npmReadyScanDownloads), those that can be readied at once (kept already,
6
+ // and unscoped ones, asked in bulk) first, each lockfile's as it is read, and
7
+ // the rest (scoped ones, which npm answers only one by one) one at a time,
8
+ // at the pace npm allows, while the assessors go on with the others.
9
+ import { setTimeout as delay } from 'node:timers/promises'
10
+ import { type Queue, queue } from './queue.ts'
11
+
12
+ export interface DownloadsSource {
13
+ // Readies those of `names` that can be at once, `ready` told each.
14
+ atOnce(names: string[], ready: (name: string) => void, signal: AbortSignal): Promise<void>
15
+ // Readies one on its own.
16
+ one(name: string, signal: AbortSignal): Promise<void>
17
+ }
18
+
19
+ // How npm asking to be asked later is told (`busy`), and waited on: asked
20
+ // again after a wait, up to `retries` times running.
21
+ export interface DownloadsPacing { busy(error: unknown): boolean; retries: number; waitMs: number }
22
+
23
+ export interface ReadyingDownloads<T> {
24
+ // Packages to give once their downloads are ready.
25
+ add(items: T[]): void
26
+ // Once all added are given, or the scan stops.
27
+ close(): Promise<void>
28
+ }
29
+
30
+ // Readies the downloads of the packages added, each given (`give`) once its
31
+ // name's are, or once readying them fails otherwise than npm asking to wait,
32
+ // or npm keeps asking past `retries`: its assessment then asks its own.
33
+ export function readyingDownloads<T extends { name: string }>(source: DownloadsSource, signal: AbortSignal, give: (item: T) => void,
34
+ { busy, retries, waitMs }: DownloadsPacing): ReadyingDownloads<T> {
35
+ const waiting = new Map<string, T[]>()
36
+ const atOnce: Queue<string[]> = queue(signal), oneByOne: Queue<string> = queue(signal)
37
+ const release = (name: string) => { for (const item of waiting.get(name) ?? []) give(item); waiting.delete(name) }
38
+ const wait = async (attempt: number) => { try { await delay(waitMs * Math.min(attempt + 1, 5), undefined, { signal }); return true } catch { return false } }
39
+
40
+ // A lockfile's names, those that can be readied at once, again after a wait
41
+ // where npm asks: false once the scan stops.
42
+ const readyNow = async (names: string[]) => {
43
+ for (let attempt = 0; attempt <= retries; attempt++) {
44
+ const left = names.filter(name => waiting.has(name))
45
+ try { await source.atOnce(left, release, signal); return true } catch (error) {
46
+ if (signal.aborted) return false
47
+ if (!busy(error)) { for (const name of left) release(name); return true }
48
+ if (!await wait(attempt)) return false
49
+ }
50
+ }
51
+ return true
52
+ }
53
+ // Each lockfile's, the rest left to oneByOne.
54
+ const readyAtOnce = async () => {
55
+ for (let names = await atOnce.take(); names !== undefined; names = await atOnce.take()) {
56
+ if (!await readyNow(names)) return
57
+ for (const name of names) if (waiting.has(name)) oneByOne.add(name)
58
+ }
59
+ }
60
+ const readyOneByOne = async () => {
61
+ for (let name = await oneByOne.take(); name !== undefined; name = await oneByOne.take()) {
62
+ for (let attempt = 0; attempt <= retries; attempt++) {
63
+ try { await source.one(name, signal); break } catch (error) {
64
+ if (signal.aborted) return
65
+ if (!busy(error) || !await wait(attempt)) break
66
+ }
67
+ }
68
+ release(name)
69
+ }
70
+ }
71
+ const lanes = [readyAtOnce().finally(() => { oneByOne.close() }), readyOneByOne()]
72
+ return {
73
+ add(items) {
74
+ const names: string[] = []
75
+ for (const item of items) {
76
+ const kept = waiting.get(item.name)
77
+ if (kept) kept.push(item)
78
+ else { waiting.set(item.name, [item]); names.push(item.name) }
79
+ }
80
+ if (names.length > 0) atOnce.add(names)
81
+ },
82
+ async close() {
83
+ atOnce.close()
84
+ await Promise.all(lanes)
85
+ },
86
+ }
87
+ }
@@ -0,0 +1,132 @@
1
+ // Session + cookie plumbing for the managed auth server. The raw session
2
+ // token lives ONLY in the cookie; the DB row id is its SHA-256 (see crypto.ts)
3
+ // so a DB read can't reconstruct a live cookie. A double-submit CSRF token is
4
+ // minted per session and returned to the client (echoed back on mutations).
5
+ //
6
+ // Async throughout — the store (db.ts) is async so a future non-SQLite
7
+ // backend slots in without touching these helpers.
8
+ import { AsyncLocalStorage } from 'node:async_hooks'
9
+ import type { ManagedConfig } from './config.ts'
10
+ import type { ManagedDb, ManagedSession, ManagedUser, StoredUser } from './db.ts'
11
+ import { hashToken, randomToken } from './crypto.ts'
12
+
13
+ // Base name for the short-lived OAuth-state cookie; `__Host-` is prefixed via
14
+ // `cookieName` when cookies are Secure.
15
+ export const STATE_COOKIE = 'dvstate'
16
+
17
+ // Base names for GitHub issue authorization state (github-issue-oauth.ts),
18
+ // linking another GitHub account (github-link-oauth.ts) and an
19
+ // administrator's read-only view as another user (view-as.ts).
20
+ export const ISSUE_STATE_COOKIE = 'dvissuestate'
21
+ export const LINK_STATE_COOKIE = 'dvlinkstate'
22
+ export const VIEW_COOKIE = 'dvview'
23
+ // The session cookie cannot share a name with these, or one would overwrite
24
+ // or be read as the other.
25
+ export const RESERVED_COOKIES: readonly string[] = [STATE_COOKIE, ISSUE_STATE_COOKIE, LINK_STATE_COOKIE, VIEW_COOKIE]
26
+
27
+ // `__Host-`-prefix the base name when cookies are Secure (the prefix mandates
28
+ // Secure + Path=/ + no Domain, which `buildCookie` already emits).
29
+ export function cookieName(config: ManagedConfig, base: string): string {
30
+ return config.cookieSecure ? `__Host-${base}` : base
31
+ }
32
+
33
+ export function buildCookie(
34
+ name: string, value: string,
35
+ opts: { maxAgeS: number; secure: boolean; sameSite?: 'Lax' | 'Strict' },
36
+ ): string {
37
+ const parts = [`${name}=${value}`, 'Path=/', 'HttpOnly', `SameSite=${opts.sameSite ?? 'Lax'}`, `Max-Age=${opts.maxAgeS}`]
38
+ if (opts.secure) parts.push('Secure')
39
+ return parts.join('; ')
40
+ }
41
+
42
+ // Expire a cookie now (Max-Age=0).
43
+ export function clearCookie(name: string, secure: boolean): string {
44
+ return buildCookie(name, '', { maxAgeS: 0, secure })
45
+ }
46
+
47
+ export function parseCookies(header: string | undefined): Map<string, string> {
48
+ const out = new Map<string, string>()
49
+ if (!header) return out
50
+ for (const part of header.split(';')) {
51
+ const eq = part.indexOf('=')
52
+ if (eq < 0) continue
53
+ const k = part.slice(0, eq).trim()
54
+ if (k) out.set(k, part.slice(eq + 1).trim())
55
+ }
56
+ return out
57
+ }
58
+
59
+ // Mint a session for a freshly-authenticated user. Upserts the identity, then
60
+ // stores the session keyed by the cookie token's hash. Returns the cookie to
61
+ // set and the CSRF token (also persisted on the row), or null for an account
62
+ // a user linked to theirs, which can neither sign in nor register.
63
+ export async function createSession(
64
+ config: ManagedConfig, db: ManagedDb, user: ManagedUser, now: number,
65
+ ): Promise<{ setCookie: string; csrfToken: string; userId: string } | null> {
66
+ const token = randomToken()
67
+ const csrfToken = randomToken()
68
+ const userId = await db.upsertUser(user, now, config.initialAdminGithubId)
69
+ if (userId == null) return null
70
+ await db.createSession({ id: hashToken(token), userId, csrfToken, expiresAt: now + config.sessionTtlMs }, now)
71
+ const setCookie = buildCookie(config.sessionCookieName, token, {
72
+ maxAgeS: Math.floor(config.sessionTtlMs / 1000), secure: config.cookieSecure, sameSite: 'Lax',
73
+ })
74
+ return { setCookie, csrfToken, userId }
75
+ }
76
+
77
+ // A request's sign-in, whose presence its first read to find it records.
78
+ // Every read after it in that request (the rechecks after its reads of
79
+ // storage, GitHub or npm among them) only reads: last_seen_at was written a
80
+ // moment before, by the read that let the request in. What the router's
81
+ // gate read is handed to the read after it that says nothing was read
82
+ // between them (`handed`), asking nothing again; once only, and to none
83
+ // once another read came first.
84
+ type SessionRead = { session: ManagedSession; user: StoredUser }
85
+ const requests = new AsyncLocalStorage<{ seen: boolean; handed: { cookieHeader: string | undefined; read: SessionRead } | null }>()
86
+ export function runRequestPresence<T>(work: () => T): T {
87
+ return requests.run({ seen: false, handed: null }, work)
88
+ }
89
+ export function handOverSession(cookieHeader: string | undefined, read: SessionRead): void {
90
+ const request = requests.getStore()
91
+ if (request) request.handed = { cookieHeader, read }
92
+ }
93
+
94
+ // Resolve the current session (+ user) from the request's cookies, or null.
95
+ // A view cookie resolves only to that admin's view as another user: a view
96
+ // that has ended never falls back to the admin's own session. `own` skips the
97
+ // view and reads the signed-in session itself. `handed` takes the gate's
98
+ // read, where it is this read's first since (handOverSession).
99
+ export async function readSession(
100
+ config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined, now: number, { own = false, handed = false } = {},
101
+ ): Promise<SessionRead | null> {
102
+ const request = requests.getStore()
103
+ const given = request?.handed
104
+ if (request) request.handed = null
105
+ if (handed && !own && given && given.cookieHeader === cookieHeader) return given.read
106
+ const ids = sessionIds(config, cookieHeader, { own })
107
+ if (!ids) return null
108
+ const seen = request?.seen === true
109
+ const s = ids.viewer === null
110
+ ? await (seen ? db.getSessionWithUser : db.sessionWithUser)(ids.id, now)
111
+ : await (seen ? db.getViewSessionWithUser : db.viewSessionWithUser)(ids.id, ids.viewer, now)
112
+ if (s && request) request.seen = true
113
+ return s
114
+ }
115
+
116
+ // The sessions the request's cookies name, as readSession reads them: the
117
+ // one acting (`id`), a view where an admin views as another user, with the
118
+ // admin's own it is a view of (`viewer`); none signed out.
119
+ export function sessionIds(config: ManagedConfig, cookieHeader: string | undefined, { own = false } = {}): { id: string; viewer: string | null } | null {
120
+ const cookies = parseCookies(cookieHeader)
121
+ const token = cookies.get(config.sessionCookieName)
122
+ if (token == null || token === '') return null
123
+ const view = own ? undefined : cookies.get(cookieName(config, VIEW_COOKIE))
124
+ return view != null && view !== '' ? { id: hashToken(view), viewer: hashToken(token) } : { id: hashToken(token), viewer: null }
125
+ }
126
+
127
+ // Drop the session named by the request's cookie (logout). The caller also
128
+ // clears the cookie client-side.
129
+ export async function endSession(config: ManagedConfig, db: ManagedDb, cookieHeader: string | undefined): Promise<void> {
130
+ const token = parseCookies(cookieHeader).get(config.sessionCookieName)
131
+ if (token != null && token !== '') await db.deleteSession(hashToken(token))
132
+ }
@@ -0,0 +1,38 @@
1
+ import type { DatabaseSync } from 'node:sqlite'
2
+
3
+ // Legacy non-UUID IDs stay intact. Production IDs are UUIDs; only their final
4
+ // component is proposed as a slug, never an arbitrary prefix or abbreviation.
5
+ export function preferredSlug(id: string): string {
6
+ return /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-([a-f\d]{12})$/iu.exec(id)?.[1] ?? id
7
+ }
8
+
9
+ export function allocateMissingSlugs(rows: { id: string; slug: string | null }[]): { id: string; slug: string }[] {
10
+ // Reserve legacy IDs before UUID suffixes can claim them.
11
+ const used = new Set(rows.flatMap(row => row.slug == null ? [row.id] : [row.id, row.slug]))
12
+ const allocated = []
13
+ for (const row of rows) {
14
+ if (row.slug != null) continue
15
+ const candidate = preferredSlug(row.id)
16
+ const slug = used.has(candidate) ? row.id : candidate
17
+ allocated.push({ id: row.id, slug })
18
+ used.add(slug)
19
+ }
20
+ return allocated
21
+ }
22
+
23
+ // Allocate once, across the entire namespace rather than a viewer's teams.
24
+ // Keep existing slugs stable when names, memberships, or collisions change.
25
+ export function migrateSlugs(db: DatabaseSync): void {
26
+ db.exec('BEGIN IMMEDIATE')
27
+ try {
28
+ for (const table of ['managed_team', 'managed_report', 'managed_bundle']) {
29
+ const columns = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[]
30
+ if (!columns.some(column => column.name === 'slug')) db.exec(`ALTER TABLE ${table} ADD COLUMN slug TEXT`)
31
+ const rows = db.prepare(`SELECT id, slug FROM ${table} ORDER BY id`).all() as { id: string; slug: string | null }[]
32
+ const update = db.prepare(`UPDATE ${table} SET slug = ? WHERE id = ?`)
33
+ for (const row of allocateMissingSlugs(rows)) update.run(row.slug, row.id)
34
+ db.exec(`CREATE UNIQUE INDEX IF NOT EXISTS ${table}_slug_idx ON ${table}(slug)`)
35
+ }
36
+ db.exec('COMMIT')
37
+ } catch (err) { db.exec('ROLLBACK'); throw err }
38
+ }
@@ -0,0 +1,71 @@
1
+ import { normalizeTeamPath } from './repo-path.ts'
2
+
3
+ const MAX_SUGGESTIONS = 100
4
+ const EXCLUDED_DIRECTORIES = new Set(['test', 'tests', 'script', 'scripts', 'mock', 'mocks', 'node_modules', 'vendor', 'out', 'artifacts', 'cache', 'build', 'dist', 'broadcast'])
5
+
6
+ function sourcePath(value: unknown): value is string {
7
+ if (typeof value !== 'string' || !value.endsWith('.sol') || /\.(?:t|s|test|spec)\.sol$/iu.test(value)) return false
8
+ const normalized = normalizeTeamPath(value)
9
+ const directories = value.split('/').slice(0, -1).map(part => part.toLowerCase())
10
+ const sourceRoot = directories.findIndex(part => part === 'src' || part === 'contracts')
11
+ return normalized.ok && normalized.path === value && !directories.some((part, index) =>
12
+ EXCLUDED_DIRECTORIES.has(part) || (part === 'lib' && (sourceRoot < 0 || index < sourceRoot)))
13
+ }
14
+
15
+ type ListingEntry = { name?: unknown; path?: unknown; type?: unknown; sha?: unknown; submodule_git_url?: unknown }
16
+
17
+ // Maintain the alphabetically first 100 unique candidates even when GitHub's
18
+ // input is unordered. Return whether a unique candidate exceeds the capacity.
19
+ export function addSoliditySuggestion(paths: string[], path: string): boolean {
20
+ let start = 0
21
+ let end = paths.length
22
+ while (start < end) {
23
+ const middle = Math.floor((start + end) / 2)
24
+ if (paths[middle]! < path) start = middle + 1
25
+ else end = middle
26
+ }
27
+ if (paths[start] === path) return false
28
+ const full = paths.length === MAX_SUGGESTIONS
29
+ if (start < MAX_SUGGESTIONS) {
30
+ // Evict before inserting, so retained candidates never exceed the cap.
31
+ if (full) paths.pop()
32
+ paths.splice(start, 0, path)
33
+ }
34
+ return full
35
+ }
36
+
37
+ // Suggestions from filenames, not deployability or import analysis. Read at
38
+ // most two source trees, using SHAs from the authorized pinned directory.
39
+ export async function readSolidityEntryPoints(directory: string, listing: unknown[], read: (suffix: string) => Promise<unknown>) {
40
+ const prefix = directory ? directory + '/' : ''
41
+ const entries = listing.filter((entry): entry is ListingEntry => !!entry && typeof entry === 'object').filter(entry =>
42
+ typeof entry.name === 'string' && !entry.name.includes('/') && entry.path === prefix + entry.name && !entry.submodule_git_url)
43
+ const hasFramework = entries.some(entry => entry.type === 'file' && typeof entry.name === 'string'
44
+ && (entry.name === 'foundry.toml' || /^hardhat\.config\.(?:[cm]?js|ts)$/u.test(entry.name)))
45
+ const roots = entries.filter(entry => entry.type === 'dir' && (entry.name === 'contracts' || (hasFramework && entry.name === 'src'))
46
+ && typeof entry.sha === 'string' && /^[a-f\d]{40}$/iu.test(entry.sha)).slice(0, 2)
47
+ const paths: string[] = []
48
+ let limited = false
49
+ for (const entry of entries) {
50
+ if (entry.type === 'file' && sourcePath(entry.path) && addSoliditySuggestion(paths, entry.path)) limited = true
51
+ }
52
+ // Both roots' listings asked together, then read in order.
53
+ const listings = roots.map(root => Promise.resolve().then(() => (sourcePath(`${root.path}/Contract.sol`) ? read(`/git/trees/${root.sha}?recursive=1`) : null)))
54
+ for (const listed of listings) listed.catch(() => {})
55
+ for (const [i, root] of roots.entries()) {
56
+ try {
57
+ const data = await listings[i] as { tree?: unknown; truncated?: unknown } | null
58
+ if (!Array.isArray(data?.tree)) continue
59
+ limited ||= data.truncated === true
60
+ for (const entry of data.tree) {
61
+ if (entry?.type !== 'blob' || !['100644', '100755'].includes(entry.mode) || typeof entry.path !== 'string') continue
62
+ const path = `${root.path}/${entry.path}`
63
+ if (sourcePath(path) && addSoliditySuggestion(paths, path)) limited = true
64
+ }
65
+ } catch {
66
+ // Optional suggestions must not block browsing. The request handler
67
+ // rechecks GitHub access and local path grants after all reads.
68
+ }
69
+ }
70
+ return { paths, limited }
71
+ }
@@ -0,0 +1,34 @@
1
+ // The small SQL dialect used by the shared store. Parameter values never pass
2
+ // through these rewrites; only our own SQL does. Keep aliases case-sensitive.
3
+ export function postgresSql(source: string): { sql: string; names: string[] } {
4
+ const names: string[] = []
5
+ let position = 0
6
+ let sql = source.replaceAll('json_each(:contexts)', 'jsonb_array_elements(:contexts::jsonb)')
7
+ .replaceAll("CAST(unixepoch('subsec') * 1000 AS INTEGER)", 'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)')
8
+ .replaceAll(/json_extract\((\w+\.value), '\$\.(\w+)'\)/gu, "($1->>'$2')")
9
+ .replaceAll('json_each(?)', 'jsonb_array_elements_text(?::jsonb)')
10
+ .replaceAll('INSERT OR IGNORE INTO', 'INSERT INTO')
11
+ .replaceAll('CROSS JOIN', 'JOIN')
12
+ .replaceAll(/\binstr\(/gu, 'strpos(')
13
+ // Rows gathered as one JSON array value, NULL (not '[]') where none are.
14
+ .replaceAll(/\bjson_group_array\(/gu, 'json_agg(')
15
+ .replaceAll(/\bjson_object\(/gu, 'json_build_object(')
16
+ // Quoted literals (including ':') are never placeholders or identifiers.
17
+ sql = sql.split(/('(?:''|[^'])*')/u).map((part, i) => i % 2 ? part : part
18
+ .replaceAll(/\?|(?<!:):([a-zA-Z]\w*)/gu, (_match, name: string | undefined) => {
19
+ if (!name) return `$${++position}`
20
+ let index = names.indexOf(name)
21
+ if (index < 0) { index = names.length; names.push(name) }
22
+ return `$${index + 1}`
23
+ })
24
+ .replaceAll(/\b[a-z]+[A-Z]\w*\b/gu, name => `"${name}"`)
25
+ ).join('')
26
+ sql = sql.replaceAll(/(\$\d+) IS NULL/gu, '$1::text IS NULL')
27
+ if (source.includes('INSERT OR IGNORE')) sql += ' ON CONFLICT DO NOTHING'
28
+ return { sql, names }
29
+ }
30
+
31
+ export function postgresSchema(sql: string): string {
32
+ return sql.replaceAll(') STRICT;', ');').replaceAll(/\bINTEGER\b/gu, 'BIGINT')
33
+ .replaceAll(/seq\s+BIGINT PRIMARY KEY/gu, 'seq BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY')
34
+ }