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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (368) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +3 -0
  6. package/client/dependency-paths.js +36 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +115 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +357 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-compare-versions.js +172 -0
  17. package/common/bundle-comparison.d.ts +8 -0
  18. package/common/bundle-comparison.js +62 -0
  19. package/common/bundle-metadata.d.ts +15 -0
  20. package/common/bundle-metadata.js +226 -0
  21. package/common/bundle-pkg-of.d.ts +1 -0
  22. package/common/bundle-pkg-of.js +90 -0
  23. package/common/bundle-reasons.d.ts +2 -0
  24. package/common/bundle-reasons.js +21 -0
  25. package/common/bundle-source-links.d.ts +6 -0
  26. package/common/bundle-source-links.js +216 -0
  27. package/common/bundle-sourcemap.d.ts +10 -0
  28. package/common/bundle-sourcemap.js +100 -0
  29. package/common/bundle-sources.d.ts +7 -0
  30. package/common/bundle-sources.js +333 -0
  31. package/common/bundle-stats.js +41 -0
  32. package/common/bundle-tabs.js +1 -0
  33. package/common/code-language.js +65 -0
  34. package/common/default-scan-models.ts +31 -0
  35. package/common/dependency-reviews.d.ts +18 -0
  36. package/common/dependency-reviews.js +64 -0
  37. package/common/finding-duplicates.js +58 -0
  38. package/common/finding-filters.js +274 -0
  39. package/common/finding-id.js +47 -0
  40. package/common/github-issue-labels.d.ts +1 -0
  41. package/common/github-issue-labels.js +11 -0
  42. package/common/github-pr.ts +56 -0
  43. package/common/gzip.js +59 -0
  44. package/common/line-diff.d.ts +8 -0
  45. package/common/line-diff.js +57 -0
  46. package/common/linked-workspace-groups.js +63 -0
  47. package/common/managed/agent-tasks.ts +292 -0
  48. package/common/managed/comments.ts +34 -0
  49. package/common/managed/finding-detail.d.ts +3 -0
  50. package/common/managed/finding-detail.js +66 -0
  51. package/common/managed/github-agents.ts +114 -0
  52. package/common/managed/github-logins.ts +49 -0
  53. package/common/managed/npm-packages.d.ts +7 -0
  54. package/common/managed/npm-packages.js +51 -0
  55. package/common/managed/permissions.ts +31 -0
  56. package/common/managed/report-content.ts +72 -0
  57. package/common/managed/report-filter.ts +109 -0
  58. package/common/managed/repository-alias.ts +65 -0
  59. package/common/managed/roles.ts +35 -0
  60. package/common/managed/routes.d.ts +2 -0
  61. package/common/managed/routes.js +359 -0
  62. package/common/managed/scan-models.ts +6 -0
  63. package/common/managed/team-app.d.ts +6 -0
  64. package/common/managed/team-app.js +47 -0
  65. package/common/managed/triage.ts +83 -0
  66. package/common/managed/upload-seal.ts +126 -0
  67. package/common/pretty-print.d.ts +5 -0
  68. package/common/pretty-print.js +24 -0
  69. package/common/revalidation-conflicts.js +48 -0
  70. package/common/revalidation-input-groups.js +56 -0
  71. package/common/save-error-reason.ts +20 -7
  72. package/common/scan-server.ts +13 -0
  73. package/common/security-links.d.ts +5 -0
  74. package/common/security-links.js +33 -0
  75. package/common/server-info.ts +46 -0
  76. package/common/utf8.d.ts +3 -0
  77. package/common/utf8.js +45 -0
  78. package/common/workspace-app.js +39 -0
  79. package/common/workspace-groups.js +176 -0
  80. package/out/activity.js +4 -0
  81. package/out/brotli-fallback.js +3 -3
  82. package/out/chunk-2K6UN3PC.js +3 -0
  83. package/out/chunk-2XUEZHIP.js +28 -0
  84. package/out/chunk-2ZDZWLFY.js +1 -0
  85. package/out/chunk-4HI2MSAV.js +17 -0
  86. package/out/chunk-6LNQTKAV.js +52 -0
  87. package/out/chunk-7BKKYVWR.js +7 -0
  88. package/out/chunk-7C7DWL7I.js +1 -0
  89. package/out/chunk-7JBSSRD7.js +3 -0
  90. package/out/chunk-7VAWINQF.js +6 -0
  91. package/out/chunk-C4Z6ZBXV.js +10 -0
  92. package/out/chunk-FMXWXDR4.js +36 -0
  93. package/out/chunk-GDPCLSOS.js +2 -0
  94. package/out/chunk-GO7FT4OD.js +1 -0
  95. package/out/chunk-HT2SSA3V.js +1 -0
  96. package/out/chunk-IPAH4LKF.js +232 -0
  97. package/out/chunk-IYPY47MC.js +1 -0
  98. package/out/chunk-IZ7POWI3.js +18 -0
  99. package/out/chunk-JCIIBLKR.js +1 -0
  100. package/out/chunk-KOGNQDSJ.js +1 -0
  101. package/out/chunk-NRPC74AM.js +1 -0
  102. package/out/chunk-OOJM4CTU.js +1 -0
  103. package/out/chunk-PBZPYIG7.js +1 -0
  104. package/out/chunk-PNHBZGDD.js +1 -0
  105. package/out/chunk-PWFUOLZK.js +17 -0
  106. package/out/chunk-PXK7EU4V.js +1 -0
  107. package/out/chunk-QB4CBOX2.js +1 -0
  108. package/out/chunk-R3NZFXVR.js +1 -0
  109. package/out/chunk-R5O6FAW4.js +2 -0
  110. package/out/chunk-SXV2T5C4.js +80 -0
  111. package/out/chunk-TQJDYHVE.js +2 -0
  112. package/out/chunk-TUII34K2.js +1 -0
  113. package/out/chunk-VDRXHIHN.js +3 -0
  114. package/out/chunk-VFWMQ3LG.js +2 -0
  115. package/out/chunk-VH4XMZIW.js +2 -0
  116. package/out/chunk-WCPJDAYA.js +10 -0
  117. package/out/chunk-WFYLMF6Z.js +10 -0
  118. package/out/chunk-WRAJVD35.js +1 -0
  119. package/out/chunk-WYLQWYLA.js +1 -0
  120. package/out/client-managed-import.js +4 -0
  121. package/out/client-managed.js +33 -0
  122. package/out/client-sync.js +11 -17
  123. package/out/compare.js +35 -0
  124. package/out/dialogs.js +55 -0
  125. package/out/github-issue-authorized.html +3 -0
  126. package/out/github-issue-authorized.js +1 -0
  127. package/out/graph.js +17 -4
  128. package/out/index.html +90 -8
  129. package/out/prism.js +14 -10
  130. package/out/scan.js +2 -0
  131. package/out/stasis.svg +20 -0
  132. package/out/terminal.js +366 -39
  133. package/out/view.css +1 -1
  134. package/out/view.js +19 -144
  135. package/package.json +364 -59
  136. package/risk/actions/findings.js +161 -0
  137. package/risk/actions/resolve.js +219 -0
  138. package/risk/actions/scripts/commands.js +182 -0
  139. package/risk/actions/scripts/grade.js +113 -0
  140. package/risk/actions/scripts/packages.js +123 -0
  141. package/risk/actions/scripts/shell.js +169 -0
  142. package/risk/actions/uses.js +76 -0
  143. package/risk/actions/workflow.js +110 -0
  144. package/risk/files/characters.js +196 -0
  145. package/risk/files/hosts.js +205 -0
  146. package/risk/files/readability.js +177 -0
  147. package/risk/files/runs-code.js +615 -0
  148. package/risk/files/script.js +118 -0
  149. package/risk/files/stamps.js +47 -0
  150. package/risk/index.d.ts +254 -0
  151. package/risk/index.js +11 -0
  152. package/risk/npm/assess.js +285 -0
  153. package/risk/npm/combined.js +68 -0
  154. package/risk/npm/compare.js +257 -0
  155. package/risk/npm/dependencies.js +172 -0
  156. package/risk/npm/diff.js +79 -0
  157. package/risk/npm/downloads.js +84 -0
  158. package/risk/npm/files.js +445 -0
  159. package/risk/npm/held.js +63 -0
  160. package/risk/npm/install-scripts.js +34 -0
  161. package/risk/npm/malware.js +36 -0
  162. package/risk/npm/manifest.js +65 -0
  163. package/risk/npm/new-dependency.js +60 -0
  164. package/risk/npm/old-version.js +108 -0
  165. package/risk/npm/registry.js +388 -0
  166. package/risk/npm/release.js +85 -0
  167. package/risk/npm/score.js +59 -0
  168. package/risk/npm/specs.js +124 -0
  169. package/risk/npm/tarball.js +96 -0
  170. package/server-common/database-config.ts +16 -0
  171. package/server-common/initialize.ts +18 -0
  172. package/server-common/npm-advisories.ts +101 -0
  173. package/{server → server-common}/origin.ts +5 -5
  174. package/server-common/reap.ts +48 -0
  175. package/server-common/scan-config.ts +19 -0
  176. package/server-common/standalone.ts +29 -0
  177. package/server-common/storage-crypto.ts +182 -0
  178. package/server-common/storage-log.ts +38 -0
  179. package/server-common/vercel-blob.ts +103 -0
  180. package/server-e2e/app.ts +487 -0
  181. package/{server → server-e2e}/bus-receiver.ts +2 -1
  182. package/{server → server-e2e}/cli.js +9 -4
  183. package/{server → server-e2e}/config.ts +50 -42
  184. package/{server → server-e2e}/db-neon.ts +2 -2
  185. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  186. package/{server → server-e2e}/db-stmt.ts +2 -2
  187. package/{server → server-e2e}/db.ts +7 -7
  188. package/{server → server-e2e}/http.ts +44 -13
  189. package/{server → server-e2e}/hub.ts +40 -9
  190. package/server-e2e/index.ts +17 -0
  191. package/server-e2e/lifecycle.ts +95 -0
  192. package/{server → server-e2e}/neon-driver.ts +2 -2
  193. package/{server → server-e2e}/npm-proxy.ts +12 -145
  194. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  195. package/{server → server-e2e}/objstore/blob.ts +1 -1
  196. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  197. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  198. package/{server → server-e2e}/objstore/init.ts +19 -27
  199. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  200. package/server-e2e/objstore/rest-deny.ts +28 -0
  201. package/server-e2e/objstore/rest-mint.ts +224 -0
  202. package/{server → server-e2e}/objstore/rest.ts +3 -3
  203. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  204. package/{server → server-e2e}/objstore/store.ts +7 -7
  205. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  206. package/{server → server-e2e}/pubsub.ts +12 -9
  207. package/{server → server-e2e}/sign.ts +4 -4
  208. package/{server → server-e2e}/sse-server.ts +60 -33
  209. package/{server → server-e2e}/static.ts +31 -21
  210. package/{server → server-e2e}/sync-handlers.ts +64 -30
  211. package/{server → server-e2e}/ws-server.ts +20 -9
  212. package/server-managed/activity.ts +258 -0
  213. package/server-managed/agent-merges.ts +109 -0
  214. package/server-managed/app-agents.ts +23 -0
  215. package/server-managed/avatar-store.ts +13 -0
  216. package/server-managed/blob-store.ts +17 -0
  217. package/server-managed/brotli.ts +10 -0
  218. package/server-managed/bundle-advisories.ts +120 -0
  219. package/server-managed/bundle-advisory-inventory.ts +77 -0
  220. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  221. package/server-managed/bundle-build-diagnostics.js +23 -0
  222. package/server-managed/bundle-build-leases.ts +41 -0
  223. package/server-managed/bundle-build-worker.js +115 -0
  224. package/server-managed/bundle-build.ts +200 -0
  225. package/server-managed/bundle-cache.ts +211 -0
  226. package/server-managed/bundle-catalog.ts +29 -0
  227. package/server-managed/bundle-commits.ts +140 -0
  228. package/server-managed/bundle-store.ts +24 -0
  229. package/server-managed/bundle-summary-cache.ts +130 -0
  230. package/server-managed/bundle.ts +119 -0
  231. package/server-managed/cache-storage.ts +20 -0
  232. package/server-managed/cli.js +21 -0
  233. package/server-managed/combined.ts +47 -0
  234. package/server-managed/comments.ts +151 -0
  235. package/server-managed/commit-coauthors.ts +106 -0
  236. package/server-managed/commit-lockfiles.ts +76 -0
  237. package/server-managed/comparison.ts +52 -0
  238. package/server-managed/config.ts +164 -0
  239. package/server-managed/content-access.ts +15 -0
  240. package/server-managed/crypto.ts +25 -0
  241. package/server-managed/db-methods.ts +1923 -0
  242. package/server-managed/db-neon.ts +468 -0
  243. package/server-managed/db-schema.ts +238 -0
  244. package/server-managed/db-table-names.ts +22 -0
  245. package/server-managed/db.ts +143 -0
  246. package/server-managed/dependabot-access.ts +60 -0
  247. package/server-managed/dependabot-alerts.ts +185 -0
  248. package/server-managed/dependency-review-comments.ts +98 -0
  249. package/server-managed/dependency-reviews.ts +364 -0
  250. package/server-managed/github-accounts.ts +60 -0
  251. package/server-managed/github-actions.ts +71 -0
  252. package/server-managed/github-app.ts +376 -0
  253. package/server-managed/github-errors.ts +70 -0
  254. package/server-managed/github-graphql.ts +39 -0
  255. package/server-managed/github-issue-links.ts +140 -0
  256. package/server-managed/github-issue-oauth.ts +47 -0
  257. package/server-managed/github-issues.ts +152 -0
  258. package/server-managed/github-link-oauth.ts +69 -0
  259. package/server-managed/github-links.ts +204 -0
  260. package/server-managed/github-metadata.ts +213 -0
  261. package/server-managed/github-oauth.ts +238 -0
  262. package/server-managed/github-pulls.ts +227 -0
  263. package/server-managed/github-requests.ts +136 -0
  264. package/server-managed/github-users.ts +30 -0
  265. package/server-managed/http-response.ts +76 -0
  266. package/server-managed/http.ts +5503 -0
  267. package/server-managed/import-triage.ts +51 -0
  268. package/server-managed/index.ts +192 -0
  269. package/server-managed/kept-briefly.ts +37 -0
  270. package/server-managed/kept.ts +28 -0
  271. package/server-managed/link-reports.ts +162 -0
  272. package/server-managed/listed-activity.ts +39 -0
  273. package/server-managed/locked-packages.ts +152 -0
  274. package/server-managed/lockfile-scope.ts +49 -0
  275. package/server-managed/lru.ts +30 -0
  276. package/server-managed/managed-issues.ts +78 -0
  277. package/server-managed/management-catalog.ts +45 -0
  278. package/server-managed/management.ts +99 -0
  279. package/server-managed/npm-delta-worker.js +23 -0
  280. package/server-managed/npm-delta.ts +194 -0
  281. package/server-managed/npm-insights.ts +449 -0
  282. package/server-managed/npm-kept.ts +225 -0
  283. package/server-managed/npm-loads.ts +264 -0
  284. package/server-managed/npm-manifest.ts +115 -0
  285. package/server-managed/npm-packages.ts +454 -0
  286. package/server-managed/npm-patch.ts +325 -0
  287. package/server-managed/npm-provenance.ts +123 -0
  288. package/server-managed/npm-risk.ts +285 -0
  289. package/server-managed/object-storage-disk.ts +148 -0
  290. package/server-managed/object-storage-vercel.ts +67 -0
  291. package/server-managed/object-storage.ts +75 -0
  292. package/server-managed/package-entry-points.ts +51 -0
  293. package/server-managed/pretty-print.ts +434 -0
  294. package/server-managed/public-workspace.ts +238 -0
  295. package/server-managed/pull-comment-reviews.ts +161 -0
  296. package/server-managed/pull-dependencies.ts +351 -0
  297. package/server-managed/pull-dependency-reviews.ts +334 -0
  298. package/server-managed/pull-reviews.ts +205 -0
  299. package/server-managed/queue.ts +39 -0
  300. package/server-managed/release-commits.ts +84 -0
  301. package/server-managed/rename-diff-worker.js +26 -0
  302. package/server-managed/rename-diff.ts +279 -0
  303. package/server-managed/repo-path.ts +21 -0
  304. package/server-managed/report-migration.ts +35 -0
  305. package/server-managed/report-query.ts +10 -0
  306. package/server-managed/report-response.ts +16 -0
  307. package/server-managed/report-sources.ts +178 -0
  308. package/server-managed/report-store.ts +85 -0
  309. package/server-managed/repository-actions.ts +218 -0
  310. package/server-managed/repository-agents.ts +448 -0
  311. package/server-managed/repository-aliases.ts +91 -0
  312. package/server-managed/repository-browser.ts +294 -0
  313. package/server-managed/repository-changes.ts +284 -0
  314. package/server-managed/repository-code.ts +215 -0
  315. package/server-managed/repository-dependency-reviews.ts +166 -0
  316. package/server-managed/repository-discovery.ts +82 -0
  317. package/server-managed/repository-history.ts +432 -0
  318. package/server-managed/repository-issues.ts +195 -0
  319. package/server-managed/repository-policy.ts +27 -0
  320. package/server-managed/repository-pulls.ts +212 -0
  321. package/server-managed/repository-pulse.ts +460 -0
  322. package/server-managed/repository-risk-assess.ts +105 -0
  323. package/server-managed/repository-risk-audit.ts +79 -0
  324. package/server-managed/repository-risk-read.ts +170 -0
  325. package/server-managed/repository-risk.ts +491 -0
  326. package/server-managed/repository-squashes.ts +168 -0
  327. package/server-managed/request-timing.ts +63 -0
  328. package/server-managed/reviewed-dependencies.ts +149 -0
  329. package/server-managed/revisions.ts +35 -0
  330. package/server-managed/scan-downloads.ts +87 -0
  331. package/server-managed/session.ts +132 -0
  332. package/server-managed/slugs.ts +38 -0
  333. package/server-managed/solidity-entry-points.ts +71 -0
  334. package/server-managed/sql-postgres.ts +34 -0
  335. package/server-managed/sql.ts +88 -0
  336. package/server-managed/static.ts +28 -0
  337. package/server-managed/storage-command.ts +16 -0
  338. package/server-managed/storage-db.ts +183 -0
  339. package/server-managed/storage-encryption.ts +290 -0
  340. package/server-managed/storage-maintenance.ts +259 -0
  341. package/server-managed/storage-payload.ts +121 -0
  342. package/server-managed/storage-stores.ts +91 -0
  343. package/server-managed/storage.ts +82 -0
  344. package/server-managed/team-app.ts +134 -0
  345. package/server-managed/team-catalog.ts +7 -0
  346. package/server-managed/team-feed.ts +180 -0
  347. package/server-managed/team-npm-scopes.ts +106 -0
  348. package/server-managed/team-reports.ts +319 -0
  349. package/server-managed/triage-response.ts +16 -0
  350. package/server-managed/type-only-worker.js +19 -0
  351. package/server-managed/type-only.ts +105 -0
  352. package/server-managed/uploads.ts +106 -0
  353. package/server-managed/upstream-cache.ts +243 -0
  354. package/server-managed/view-as.ts +58 -0
  355. package/server-managed/workspace-shares.ts +169 -0
  356. package/server.ts +70 -0
  357. package/server/index.ts +0 -480
  358. package/server/lifecycle.ts +0 -208
  359. /package/{LICENSE → LICENSE-MIT} +0 -0
  360. /package/{server → server-e2e}/auth.ts +0 -0
  361. /package/{server → server-e2e}/config.example.json +0 -0
  362. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  363. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  364. /package/{server → server-e2e}/objstore/sign.ts +0 -0
  365. /package/{server → server-e2e}/peer.ts +0 -0
  366. /package/{server → server-e2e}/sse-session.ts +0 -0
  367. /package/{server → server-e2e}/util.ts +0 -0
  368. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,359 @@
1
+ // Managed client pages only. API paths and E2E share hashes are not routes.
2
+ import { BUNDLE_TABS } from '../bundle-tabs.js'
3
+ import { MAX_FINDING_ID_LENGTH, isLinkableFindingId } from '../finding-id.js'
4
+ import { isNpmPackageName, isNpmPackageSpec, isPatchHash, isPatchPath } from './npm-packages.js'
5
+
6
+ // How Compare reviews the changes, past its Overview: `code`, beside the
7
+ // tree of changed files. A link names the mode after the bundle or version
8
+ // compared with; an older one's `diff`, a mode since folded into Code,
9
+ // opens Code.
10
+ const COMPARE_MODES = new Set(['code', 'diff'])
11
+ export const compareModeOf = mode => COMPARE_MODES.has(mode) ? 'code' : 'overview'
12
+ // A route's `compareMode` field, where `mode` is one; none for the Overview.
13
+ export const compareModeField = mode => COMPARE_MODES.has(mode) ? { compareMode: 'code' } : {}
14
+ const compareModeSuffix = mode => COMPARE_MODES.has(mode) ? '/code' : ''
15
+
16
+ export const MANAGED_PAGES = Object.freeze({
17
+ manage: '/manage',
18
+ 'manage-bundles': '/manage/bundle',
19
+ 'manage-scans': '/manage/scans',
20
+ 'manage-reports': '/manage/report',
21
+ 'manage-repos': '/manage/repositories',
22
+ 'manage-repo-risk': '/manage/repositories/risk',
23
+ 'admin-users': '/manage/users',
24
+ 'manage-teams': '/manage/team',
25
+ 'manage-import': '/manage/import',
26
+ 'manage-links': '/manage/links',
27
+ 'manage-deduplication': '/manage/deduplication',
28
+ 'manage-history': '/manage/history',
29
+ })
30
+
31
+ // A team's lists, each at /team/<slug>/<what it lists>; its dependencies'
32
+ // sub-tabs past the first at /team/<slug>/dependencies/<tab>.
33
+ const WORKSPACE_CONTENT_VIEWS = new Set(['workspace-reports', 'workspace-bundles', 'workspace-repositories', 'workspace-dependencies'])
34
+ export const DEPENDENCY_TABS = new Set(['pulls', 'versions', 'branch', 'reviews'])
35
+ // A route's `dependencyTab` field, where it names one.
36
+ const dependencyTabField = route => route.view === 'workspace-dependencies' && route.dependencyTab != null ? { dependencyTab: route.dependencyTab } : {}
37
+
38
+ // Dot-only path components are normalised away by URL parsers, even escaped.
39
+ function findingPath(id) {
40
+ if (!isLinkableFindingId(id) || id === '.' || id === '..') return null
41
+ try { return `/finding/${encodeURIComponent(id)}` } catch { return null }
42
+ }
43
+
44
+ export function managedRoutePath(route) {
45
+ if (!route) return null
46
+ if (route.view === 'home') return '/'
47
+ if (route.view === 'reviews') return '/my/reviews'
48
+ if (route.view === 'profile') return '/my/profile'
49
+ if (route.view === 'npm') return npmPackagePath(route)
50
+ if (route.view === 'repository-code') return repositoryCodePath(route)
51
+ if (WORKSPACE_CONTENT_VIEWS.has(route.view)) {
52
+ const tab = dependencyTabField(route).dependencyTab ?? 'pulls'
53
+ if (!/^[A-Za-z0-9_-]+$/u.test(route.teamSlug ?? '') || !DEPENDENCY_TABS.has(tab)) return null
54
+ return `/team/${route.teamSlug}/${route.view.slice(10)}${tab === 'pulls' ? '' : `/${tab}`}`
55
+ }
56
+ if (route.view === 'bundles') {
57
+ if (!/^[A-Za-z0-9_-]+$/u.test(route.bundleSlug ?? '')
58
+ || [route.teamSlug, route.compareSlug].some(slug => slug != null && !/^[A-Za-z0-9_-]+$/u.test(slug))) return null
59
+ const tab = route.bundleTab ?? 'overview'
60
+ if (!BUNDLE_TABS.has(tab)) return null
61
+ const parent = route.teamSlug ? `/team/${route.teamSlug}` : '/manage'
62
+ // Code names its open file by number: 1-based in the bundle's sorted
63
+ // sources, which its content hash fixes. Never by path from the bundle.
64
+ // Its marked lines follow in the fragment, as #L42 or #L42-L69.
65
+ const file = tab === 'code' && isLineNumber(route.file) ? `/${route.file}` : ''
66
+ const lines = file && isLineNumber(route.line)
67
+ ? `#L${route.line}${isLineNumber(route.endLine) && route.endLine > route.line ? `-L${route.endLine}` : ''}` : ''
68
+ // Compare names the bundle compared with by its slug, then its mode
69
+ // (COMPARE_MODES) past the Overview.
70
+ const compare = tab === 'compare' && route.compareSlug != null
71
+ ? `/${route.compareSlug}${compareModeSuffix(route.compareMode)}` : ''
72
+ return `${parent}/bundle/${route.bundleSlug}${tab === 'overview' ? '' : `/${tab}`}${file}${compare}${lines}`
73
+ }
74
+ if (Object.hasOwn(MANAGED_PAGES, route.view)) {
75
+ const path = MANAGED_PAGES[route.view]
76
+ if (route.view === 'manage-deduplication' && route.linkId) return `${path}?report=${encodeURIComponent(route.linkId)}`
77
+ if (route.view === 'manage-bundles' && Number.isSafeInteger(route.createRepoId) && route.createRepoId > 0) return `${path}?createRepo=${route.createRepoId}`
78
+ if (route.view === 'manage-scans' && route.scanMode === 'link') return `${path}?mode=link`
79
+ if (route.view === 'manage-scans' && route.bundleId) {
80
+ return `${path}?bundle=${encodeURIComponent(route.bundleId)}${route.scanMode === 'dependencies' ? '&mode=dependencies' : ''}`
81
+ }
82
+ return route.view === 'manage-history' && route.actor ? `${path}?actor=${encodeURIComponent(route.actor)}` : path
83
+ }
84
+ if (!['findings', 'files'].includes(route.view) || !route.teamSlug
85
+ || [route.teamSlug, route.reportSlug].some(id => id != null && !/^[A-Za-z0-9_-]+$/u.test(id))) return null
86
+ const team = `/team/${encodeURIComponent(route.teamSlug)}`
87
+ const report = route.reportSlug ? `/report/${encodeURIComponent(route.reportSlug)}` : ''
88
+ if (route.finding) {
89
+ const suffix = findingPath(route.finding.id)
90
+ return route.view === 'findings' && suffix ? `${team}${report}${suffix}` : null
91
+ }
92
+ return `${team}${report}${route.view === 'files' ? '/files' : ''}`
93
+ }
94
+
95
+ // The npm viewer: its lookup page, or a package version's Overview, Code,
96
+ // Search, Compare or Treemap tab, `/npm/<name>[@<version or dist-tag>][/<tab>…]`. Code names
97
+ // its file by number and its lines in the fragment, as a bundle's Code
98
+ // does: `/code/<file>#L4`. Compare names the version compared with, then
99
+ // its mode past the Overview: `/compare/<version>/code` or `/diff`. Review
100
+ // names the version reviewed against, and, of a version as a pnpm patch
101
+ // changes it, the patch (`patch`): its hash, and where it is read, a
102
+ // repository by its id, a commit and its file's path there,
103
+ // `?patch=<hash>&repo=<repo id>&commit=<commit>&path=<path>`.
104
+ export const NPM_TABS = new Set(['overview', 'code', 'search', 'compare', 'review', 'treemap'])
105
+ // A patch as the server reads one (server-managed/npm-patch.ts requestedPatch).
106
+ const isPatch = patch => isPatchHash(patch?.hash) && isRepoId(patch.repoId) && COMMIT.test(patch.commit ?? '') && isPatchPath(patch.path)
107
+ function npmPackagePath(route) {
108
+ if (route.packageName == null) return '/npm'
109
+ const tab = route.bundleTab ?? 'overview'
110
+ if (!isNpmPackageName(route.packageName) || (route.packageSpec != null && !isNpmPackageSpec(route.packageSpec)) || !NPM_TABS.has(tab)
111
+ || (route.compareSpec != null && !isNpmPackageSpec(route.compareSpec))) return null
112
+ if (tab === 'review') {
113
+ if (route.reviewPackage != null && !isNpmPackageName(route.reviewPackage) || route.reviewSpec != null && !isNpmPackageSpec(route.reviewSpec)
114
+ || route.patch != null && !isPatch(route.patch)) return null
115
+ const base = route.reviewPackage == null ? '' : `/${route.reviewPackage}${route.reviewSpec == null ? '' : `@${route.reviewSpec}`}`
116
+ const patch = route.patch == null ? ''
117
+ : `?patch=${route.patch.hash}&repo=${route.patch.repoId}&commit=${route.patch.commit}&path=${route.patch.path.split('/').map(encodeURIComponent).join('/')}`
118
+ return `/npm/${route.packageName}${route.packageSpec == null ? '' : `@${route.packageSpec}`}/review${base}${patch}`
119
+ }
120
+ const file = tab === 'code' && isLineNumber(route.file) ? `/${route.file}` : ''
121
+ const lines = file && isLineNumber(route.line)
122
+ ? `#L${route.line}${isLineNumber(route.endLine) && route.endLine > route.line ? `-L${route.endLine}` : ''}` : ''
123
+ const compare = tab === 'compare' && route.compareSpec != null ? `/${route.compareSpec}${compareModeSuffix(route.compareMode)}` : ''
124
+ return `/npm/${route.packageName}${route.packageSpec == null ? '' : `@${route.packageSpec}`}${tab === 'overview' ? '' : `/${tab}`}${file}${compare}${lines}`
125
+ }
126
+
127
+ function parseNpmRoute(path, hash, search) {
128
+ const review = /^\/npm\/((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?\/review(?:\/((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?)?$/u.exec(path)
129
+ if (review) {
130
+ if (!isNpmPackageName(review[1]) || review[2] != null && !isNpmPackageSpec(review[2])
131
+ || review[3] != null && !isNpmPackageName(review[3]) || review[4] != null && !isNpmPackageSpec(review[4])) return null
132
+ // The full dependency form is canonical; a bare version is a shortcut.
133
+ const versionOnly = review[3] && /^\d+\.\d+\.\d+(?:[-+].*)?$/u.test(review[3]) && review[4] == null
134
+ const patch = search.has('patch')
135
+ ? { hash: search.get('patch'), repoId: /^[1-9]\d*$/u.test(search.get('repo') ?? '') ? Number(search.get('repo')) : null, commit: search.get('commit'), path: search.get('path') } : null
136
+ if (patch && !isPatch(patch)) return null
137
+ return { view: 'npm', packageName: review[1], packageSpec: review[2] ?? null, bundleTab: 'review',
138
+ ...(review[3] ? { reviewPackage: versionOnly ? review[1] : review[3], reviewSpec: versionOnly ? review[3] : review[4] ?? null } : {}), ...(patch ? { patch } : {}) }
139
+ }
140
+ const match = /^\/npm(?:\/((?:@[^/@]+\/)?[^/@]+)(?:@([^/]+))?(?:\/(code|search|compare|treemap)(?:\/([^/]+)(?:\/([a-z]+))?)?)?)?$/u.exec(path)
141
+ if (!match) return undefined
142
+ if (match[1] == null) return { view: 'npm' }
143
+ if (!isNpmPackageName(match[1]) || (match[2] != null && !isNpmPackageSpec(match[2]))) return null
144
+ const route = { view: 'npm', packageName: match[1], packageSpec: match[2] ?? null, bundleTab: match[3] ?? 'overview' }
145
+ if (match[4] == null) return route
146
+ if (route.bundleTab === 'compare') {
147
+ return isNpmPackageSpec(match[4]) && (match[5] == null || COMPARE_MODES.has(match[5])) ? { ...route, compareSpec: match[4], ...compareModeField(match[5]) } : null
148
+ }
149
+ if (route.bundleTab !== 'code') return null
150
+ const file = Number(match[4])
151
+ return !match[5] && /^[1-9]\d*$/u.test(match[4]) && Number.isSafeInteger(file) ? { ...route, file, ...codeLines(hash) } : null
152
+ }
153
+
154
+ // The code viewer: a directory of a repository at a commit, on its Code,
155
+ // Search, Treemap, Compare, Commits, Issues, Pull requests, Agents, Pulse
156
+ // or Replay tab, `/repo/<repo id>/<commit>[/<tab>…][?path=<directory>]`,
157
+ // the repository's root where it names no directory. Code, the tab a bare
158
+ // link opens, names its file by number and its lines in the fragment, as a
159
+ // bundle's Code does: `/code/<file>#L4`. Compare names the commit compared
160
+ // with, then its mode past the Overview, as an npm version's does:
161
+ // `/compare/<commit>/code`. An Activity view names its sub-tab past the
162
+ // first (isActivitySubtab): `/pulse/year`. Without a commit,
163
+ // `/repo/<repo id>[?path=<directory>]`, it opens on the default
164
+ // branch's head, or on the revision a route held in memory names (`ref`),
165
+ // and its route then names the commit it opened. Links from before it moved
166
+ // name it under `/manage/code/`, and open the same.
167
+ export const REPOSITORY_CODE_TABS = new Set(['code', 'search', 'treemap', 'compare', 'commits', 'issues', 'pulls', 'agents', 'pulse', 'replay'])
168
+ // The tabs reading none of the directory's files, which a directory at a
169
+ // commit shows alone.
170
+ export const REPOSITORY_FILELESS_TABS = new Set(['commits', 'issues', 'pulls', 'agents', 'pulse', 'replay'])
171
+ // The Activity views' sub-tabs past their first: Issues' and Pull requests'
172
+ // closed ones (open, the first), Pulse's periods (a month, the first),
173
+ // Replay's commits played (the last 300, the first), and one agent's
174
+ // sessions, by its id (every agent's, `all`, the first).
175
+ const ACTIVITY_SUBTABS = new Map([['issues', new Set(['closed'])], ['pulls', new Set(['closed'])],
176
+ ['pulse', new Set(['week', 'quarter', 'year'])], ['replay', new Set(['100', '1000'])]])
177
+ const AGENT_ID = /^[a-z][a-z\d-]{0,31}$/u
178
+ export const isActivitySubtab = (tab, subtab) => tab === 'agents' ? typeof subtab === 'string' && AGENT_ID.test(subtab) && subtab !== 'all'
179
+ : ACTIVITY_SUBTABS.get(tab)?.has(subtab) === true
180
+ const COMMIT = /^[a-f\d]{40}$/u
181
+ const MAX_DIRECTORY = 500
182
+ const isRepoId = value => Number.isSafeInteger(value) && value > 0
183
+
184
+ // A directory as the server takes one (server-managed/repo-path.ts), in its
185
+ // canonical form: `''` the root, else names with no empty, `.` or `..` one
186
+ // among them, nor control characters, backslashes or spaces around one.
187
+ export function isRepositoryDirectory(path) {
188
+ return typeof path === 'string' && (path === '' || path.length <= MAX_DIRECTORY && !/[\p{Cc}\\]/u.test(path)
189
+ && path.split('/').every(part => part !== '' && part !== '.' && part !== '..' && part === part.trim()))
190
+ }
191
+
192
+ function repositoryCodePath(route) {
193
+ const tab = route.bundleTab ?? 'code'
194
+ const path = route.path ?? ''
195
+ if (!isRepoId(route.repoId) || (route.commit != null && !COMMIT.test(route.commit)) || !isRepositoryDirectory(path) || !REPOSITORY_CODE_TABS.has(tab)
196
+ || (route.compareCommit != null && !COMMIT.test(route.compareCommit))) return null
197
+ const directory = path ? `?path=${path.split('/').map(encodeURIComponent).join('/')}` : ''
198
+ if (route.commit == null) return `/repo/${route.repoId}${directory}`
199
+ const file = tab === 'code' && isLineNumber(route.file) ? `/code/${route.file}` : tab === 'code' ? '' : `/${tab}`
200
+ const compare = tab === 'compare' && route.compareCommit != null ? `/${route.compareCommit}${compareModeSuffix(route.compareMode)}` : ''
201
+ const subtab = isActivitySubtab(tab, route.subtab) ? `/${route.subtab}` : ''
202
+ const lines = tab === 'code' && isLineNumber(route.file) && isLineNumber(route.line)
203
+ ? `#L${route.line}${isLineNumber(route.endLine) && route.endLine > route.line ? `-L${route.endLine}` : ''}` : ''
204
+ return `/repo/${route.repoId}/${route.commit}${file}${compare}${subtab}${directory}${lines}`
205
+ }
206
+
207
+ function parseRepositoryCodeRoute(url) {
208
+ const match = /^\/(?:repo|manage\/code)\/([1-9]\d*)(?:\/([a-f\d]{40})(?:\/(code|search|treemap|compare|commits|history|issues|pulls|agents|pulse|replay)(?:\/([^/]+)(?:\/([a-z]+))?)?)?)?$/u
209
+ .exec(url.pathname.replace(/\/$/u, ''))
210
+ if (!match) return undefined
211
+ const repoId = Number(match[1])
212
+ const path = url.searchParams.get('path') ?? ''
213
+ // Commits' tab was History's, `/history`, which links still name.
214
+ const tab = match[3] === 'history' ? 'commits' : match[3] ?? 'code'
215
+ if (!isRepoId(repoId) || !isRepositoryDirectory(path)) return null
216
+ const route = { view: 'repository-code', repoId, commit: match[2] ?? null, path, bundleTab: tab }
217
+ if (match[4] == null) return route
218
+ if (tab === 'compare') {
219
+ return COMMIT.test(match[4]) && (match[5] == null || COMPARE_MODES.has(match[5])) ? { ...route, compareCommit: match[4], ...compareModeField(match[5]) } : null
220
+ }
221
+ if (REPOSITORY_FILELESS_TABS.has(tab)) return match[5] == null && isActivitySubtab(tab, match[4]) ? { ...route, subtab: match[4] } : null
222
+ const file = Number(match[4])
223
+ return tab === 'code' && match[5] == null && /^[1-9]\d*$/u.test(match[4]) && Number.isSafeInteger(file) ? { ...route, file, ...codeLines(url.hash) } : null
224
+ }
225
+
226
+ function isLineNumber(value) {
227
+ return Number.isSafeInteger(value) && value > 0
228
+ }
229
+
230
+ // `#L42` or `#L42-L69`, in either order; anything else marks no lines.
231
+ function codeLines(hash) {
232
+ const match = /^#L([1-9]\d*)(?:-L([1-9]\d*))?$/u.exec(hash)
233
+ const [first, last] = match ? [Number(match[1]), Number(match[2] ?? match[1])] : []
234
+ if (!isLineNumber(first) || !isLineNumber(last)) return {}
235
+ return first === last ? { line: first } : { line: Math.min(first, last), endLine: Math.max(first, last) }
236
+ }
237
+
238
+ export function parseManagedRoute(url) {
239
+ const path = url.pathname.replace(/\/$/u, '') || '/'
240
+ if (path === '/' || path === '/index.html') return { view: 'home' }
241
+ if (path === '/my/reviews') return { view: 'reviews' }
242
+ // Linking an account lands here, naming why GitHub's answer was refused,
243
+ // which the page shows once: the address it settles on drops it.
244
+ if (path === '/my/profile') {
245
+ const linkError = url.searchParams.get('link-error')
246
+ return { view: 'profile', ...(linkError && /^[a-z-]{1,64}$/u.test(linkError) ? { linkError } : {}) }
247
+ }
248
+ const view = Object.keys(MANAGED_PAGES).find(key => MANAGED_PAGES[key] === path)
249
+ if (view) {
250
+ const createRepoId = Number(url.searchParams.get('createRepo'))
251
+ return { view,
252
+ ...(view === 'manage-deduplication' && url.searchParams.get('report') ? { linkId: url.searchParams.get('report') } : {}),
253
+ ...(view === 'manage-bundles' && Number.isSafeInteger(createRepoId) && createRepoId > 0 ? { createRepoId } : {}),
254
+ ...(view === 'manage-history' && url.searchParams.get('actor') ? { actor: url.searchParams.get('actor') } : {}),
255
+ ...(view === 'manage-scans' ? url.searchParams.get('mode') === 'link' ? { scanMode: 'link' }
256
+ : url.searchParams.get('bundle') ? { bundleId: url.searchParams.get('bundle'),
257
+ ...(url.searchParams.get('mode') === 'dependencies' ? { scanMode: 'dependencies' } : {}) } : {} : {}),
258
+ }
259
+ }
260
+ const bundle = /^(?:\/team\/([A-Za-z0-9_-]+)|\/manage)\/bundle\/([A-Za-z0-9_-]+)(?:\/([a-z]+)(?:\/([A-Za-z0-9_-]+)(?:\/([a-z]+))?)?)?$/u.exec(path)
261
+ if (bundle) {
262
+ const bundleTab = bundle[3] ?? 'overview'
263
+ if (!BUNDLE_TABS.has(bundleTab)) return null
264
+ const route = { view: 'bundles', teamSlug: bundle[1] ?? null, bundleSlug: bundle[2], bundleTab }
265
+ if (bundle[4] == null) return route
266
+ if (bundleTab === 'compare') return bundle[5] == null || COMPARE_MODES.has(bundle[5]) ? { ...route, compareSlug: bundle[4], ...compareModeField(bundle[5]) } : null
267
+ const file = Number(bundle[4])
268
+ if (bundleTab !== 'code' || bundle[5] || !/^[1-9]\d*$/u.test(bundle[4]) || !Number.isSafeInteger(file)) return null
269
+ return { ...route, file, ...codeLines(url.hash) }
270
+ }
271
+ const npm = parseNpmRoute(path, url.hash, url.searchParams)
272
+ if (npm !== undefined) return npm
273
+ const code = parseRepositoryCodeRoute(url)
274
+ if (code !== undefined) return code
275
+ const contentList = /^\/team\/([A-Za-z0-9_-]+)\/(reports|bundles|repositories|dependencies)$/u.exec(path)
276
+ if (contentList) return { view: `workspace-${contentList[2]}`, teamSlug: contentList[1] }
277
+ const dependencies = /^\/team\/([A-Za-z0-9_-]+)\/dependencies\/([a-z]+)$/u.exec(path)
278
+ if (dependencies) return DEPENDENCY_TABS.has(dependencies[2]) ? { view: 'workspace-dependencies', teamSlug: dependencies[1], dependencyTab: dependencies[2] } : null
279
+ const match = /^\/team\/([^/]+)(?:\/report\/([^/]+))?(?:\/(files)|\/finding\/([^/]+))?$/u.exec(path)
280
+ if (!match) return null
281
+ try {
282
+ const teamSlug = decodeURIComponent(match[1])
283
+ const reportSlug = match[2] ? decodeURIComponent(match[2]) : null
284
+ if ([teamSlug, reportSlug].some(id => id != null && !/^[A-Za-z0-9_-]+$/u.test(id))) return null
285
+ if (match[4]?.length > MAX_FINDING_ID_LENGTH * 9) return null
286
+ const id = match[4] ? decodeURIComponent(match[4]) : null
287
+ if (id != null && !findingPath(id)) return null
288
+ return { view: match[3] ? 'files' : 'findings', teamSlug, reportSlug, ...(id == null ? {} : { finding: { id } }) }
289
+ } catch { return null }
290
+ }
291
+
292
+ // URL tokens are exact server-assigned slugs. Application state and API calls
293
+ // continue to use UUIDs; never guess IDs from suffixes or accept ID aliases.
294
+ export function resolveManagedRoute(route, teams, adminBundles = []) {
295
+ if (WORKSPACE_CONTENT_VIEWS.has(route.view)) {
296
+ const matches = teams.filter(team => team.slug === route.teamSlug)
297
+ return matches.length === 1 ? { view: route.view, teamId: matches[0].id, ...dependencyTabField(route) } : null
298
+ }
299
+ if (route.view === 'bundles') {
300
+ const { teamSlug, bundleSlug, compareSlug, compareMode, ...rest } = route
301
+ const matches = teamSlug == null ? [] : teams.filter(team => team.slug === teamSlug)
302
+ if (teamSlug != null && matches.length !== 1) return null
303
+ const team = matches[0]
304
+ const candidates = teamSlug == null ? adminBundles : teams.flatMap(entry => entry.bundles ?? [])
305
+ const idsOf = slug => new Set(candidates.filter(bundle => bundle.slug === slug).map(bundle => bundle.id))
306
+ const ids = idsOf(bundleSlug)
307
+ const bundle = (team?.bundles ?? adminBundles).find(entry => entry.slug === bundleSlug)
308
+ if (!bundleSlug || !bundle || ids.size !== 1) return null
309
+ // The bundle compared with, by the same rule; one no longer listed drops
310
+ // out, leaving Compare to pick again.
311
+ const compared = compareSlug == null ? null : idsOf(compareSlug)
312
+ const compare = compared?.size === 1 ? { compareId: [...compared][0], ...compareModeField(compareMode) } : {}
313
+ return { ...rest, teamId: team?.id ?? null, bundleId: bundle.id, ...compare }
314
+ }
315
+ if (!['findings', 'files'].includes(route.view)) return route
316
+ const { teamSlug, reportSlug, ...rest } = route
317
+ const matches = teams.filter(team => team.slug === teamSlug)
318
+ if (!teamSlug || matches.length !== 1) return null
319
+ const team = matches[0]
320
+ if (reportSlug == null) return { ...rest, teamId: team.id, reportId: null }
321
+ const reports = teams.flatMap(entry => entry.reports ?? []).filter(report => report.slug === reportSlug)
322
+ const ids = new Set(reports.map(report => report.id))
323
+ const report = (team.reports ?? []).find(entry => entry.slug === reportSlug)
324
+ return report && ids.size === 1 ? { ...rest, teamId: team.id, reportId: report.id } : null
325
+ }
326
+
327
+ export function managedRouteForIds(route, teams, adminBundles = []) {
328
+ if (WORKSPACE_CONTENT_VIEWS.has(route.view)) {
329
+ const team = teams.find(entry => entry.id === route.teamId)
330
+ const result = { view: route.view, teamSlug: team?.slug, ...dependencyTabField(route) }
331
+ return team?.slug && resolveManagedRoute(result, teams) ? result : null
332
+ }
333
+ if (route.view === 'bundles') {
334
+ const { teamId, bundleId, compareId, compareMode, ...rest } = route
335
+ const team = teams.find(entry => entry.id === teamId)
336
+ if (teamId != null && !team?.slug) return null
337
+ const bundle = (team?.bundles ?? adminBundles).find(entry => entry.id === bundleId)
338
+ if (!bundle?.slug) return null
339
+ const compared = compareId == null ? null
340
+ : (teamId == null ? adminBundles : teams.flatMap(entry => entry.bundles ?? [])).find(entry => entry.id === compareId)
341
+ const result = { ...rest, teamSlug: team?.slug ?? null, bundleSlug: bundle.slug,
342
+ ...(compared?.slug ? { compareSlug: compared.slug, ...compareModeField(compareMode) } : {}) }
343
+ const resolved = resolveManagedRoute(result, teams, adminBundles)
344
+ if (!resolved) return null
345
+ // A compared bundle whose slug doesn't name it alone leaves the route.
346
+ if (result.compareSlug != null && resolved.compareId !== compareId) {
347
+ const { compareSlug: _slug, compareMode: _mode, ...bare } = result
348
+ return bare
349
+ }
350
+ return result
351
+ }
352
+ if (!['findings', 'files'].includes(route.view)) return route
353
+ const { teamId, reportId, ...rest } = route
354
+ const team = teams.find(entry => entry.id === teamId)
355
+ const report = reportId == null ? null : team?.reports?.find(entry => entry.id === reportId)
356
+ if (!team?.slug || (reportId != null && !report?.slug)) return null
357
+ const result = { ...rest, teamSlug: team.slug, reportSlug: report?.slug ?? null }
358
+ return resolveManagedRoute(result, teams) ? result : null
359
+ }
@@ -0,0 +1,6 @@
1
+ import { defaultScanModels } from '../default-scan-models.ts'
2
+
3
+ // Use the same starting catalogue as the UI until managed scan discovery is wired.
4
+ const catalogue = defaultScanModels()
5
+ export const MANAGED_SCAN_MODELS = Object.freeze(catalogue.models)
6
+ export const DEFAULT_MANAGED_SCAN_MODEL = catalogue.defaultModel
@@ -0,0 +1,6 @@
1
+ // Set for a team that is not App: `findings`, its rows (with `countFindings`),
2
+ // and `appReports`, set by the managed catalog: how many reports open in App mode alone.
3
+ export type TeamAppMetadata = { appMode: true; appFindings: number } | { appMode: false; appReports?: number; findings?: number }
4
+ export function managedTeamAppMetadata(workspace: readonly { filename: string; data: unknown }[] & { links?: readonly (readonly string[])[] },
5
+ options?: { countFindings?: boolean }): TeamAppMetadata
6
+ export function managedTeamRevalidationConflicted(workspace: readonly { filename: string; data: unknown }[] & { links?: readonly (readonly string[])[] }): boolean
@@ -0,0 +1,47 @@
1
+ import { inheritReportMeta, isAppFinding, reportEntries } from '@preventive/report'
2
+ import { collectDuplicates } from '../../client/linked-findings.js'
3
+ import { hasRevalidateStamp } from '../finding-filters.js'
4
+ import { workspaceAppMetadata } from '../workspace-app.js'
5
+ import { mergeReportGroups } from '../workspace-groups.js'
6
+
7
+ // The same classification as local workspaces, using only the complete,
8
+ // server-filtered published workspace: the reports of one team response and
9
+ // its `links`. The managed server classifies each team before sending its
10
+ // catalog, so the sidebar opens a team collapsed to its App findings at once.
11
+ // The server backfills finding IDs before building these envelopes.
12
+ export function managedTeamAppMetadata(workspace, options = {}) {
13
+ const { reports, duplicates } = viewReports(workspace)
14
+ return workspaceAppMetadata(reports, id => [...duplicates.get(id) ?? []], options)
15
+ }
16
+
17
+ // Whether copies of one finding in the workspace disagree on what the
18
+ // revalidation pass said, which takes the App lens off a view of it
19
+ // (ui/view/render.js configureReportRevalidation). Asked of all its rows, so
20
+ // that a view of any of its reports holds none either.
21
+ export function managedTeamRevalidationConflicted(workspace) {
22
+ return mergeReportGroups(viewReports(workspace).reports).conflicts.size > 0
23
+ }
24
+
25
+ // The workspace's reports as a view holds them (ui/view/ingest.js), with the
26
+ // duplicates its links name.
27
+ function viewReports(workspace) {
28
+ const duplicates = new Map(), reports = []
29
+ collectDuplicates(workspace.links ?? [], duplicates)
30
+ for (const { filename, data } of workspace) {
31
+ if (data.source === 'links' && Array.isArray(data.links)) {
32
+ collectDuplicates(data.links, duplicates)
33
+ continue
34
+ }
35
+ const entries = (reportEntries(data) ?? []).map(entry => Array.isArray(entry) ? entry : [entry])
36
+ const judged = new Set(entries.flat().filter(hasRevalidateStamp).map(f => f.source ?? data.source ?? null))
37
+ const groups = entries.map(group => group.map(f => {
38
+ const finding = { ...f, _source: f.source ?? data.source ?? null }
39
+ inheritReportMeta(finding, data)
40
+ if (!('isApp' in finding)) finding.isApp = isAppFinding(finding, finding._source)
41
+ finding._sourcePass = judged.has(finding._source)
42
+ return finding
43
+ }))
44
+ reports.push({ fileName: filename, groups })
45
+ }
46
+ return { reports, duplicates }
47
+ }
@@ -0,0 +1,83 @@
1
+ // Per-finding triage annotations on a managed server — shared by the server
2
+ // (which stores one row per finding id, validates the wire shape, and decides
3
+ // per report which ids a viewer may read or write) and the managed client
4
+ // (which mirrors entries into its local triage map). Keyed by finding id
5
+ // alone, like that map: reports mostly repeat one another (a re-scan of the
6
+ // same code carries the same finding ids), and a finding's triage is shared by
7
+ // every report that carries it. A wire entry carries the four server-persisted
8
+ // fields below, or is null for a cleared entry (the server's tombstone); the
9
+ // client's `ignoredReports` deliberately does NOT ride this wire — the
10
+ // per-report ignore is a client-local concept keyed by report name.
11
+ export type TriageBucket = 'inprogress' | 'fixed' | 'invalid' | 'deleted' | 'ignored'
12
+
13
+ const TRIAGE_BUCKETS: readonly TriageBucket[] = ['inprogress', 'fixed', 'invalid', 'deleted', 'ignored']
14
+
15
+ // One finding's server-side triage entry (also the write shape — writes
16
+ // replace the whole entry). Absent fields are unset; `flagged` is tri-state:
17
+ // absent = never set, `true` = flagged, `false` = an explicit un-flag
18
+ // tombstone that must round-trip (see client/state.ts TriageEntry).
19
+ export type TriageEntryPatch = {
20
+ color?: string
21
+ triage?: TriageBucket
22
+ // Legacy triage history/migration only. New comments use separate records;
23
+ // the managed triage write endpoint rejects this field.
24
+ comment?: string
25
+ fix?: string
26
+ flagged?: boolean
27
+ }
28
+
29
+ // Length caps enforced at the write endpoint so one entry can't balloon a row.
30
+ export const MAX_TRIAGE_TEXT = 10_000
31
+ export const MAX_TRIAGE_COLOR = 50
32
+
33
+ // Caps on one write request as a whole — shared with the client, which batches
34
+ // within them: entries per request, the finding-id length (real ids are 36-char
35
+ // uuids), and the JSON body (the server's small default is too small for entry
36
+ // batches, which carry free text).
37
+ export const MAX_TRIAGE_ENTRIES = 200
38
+ export const MAX_FINDING_ID = 100
39
+ export const MAX_TRIAGE_BODY_BYTES = 262_144
40
+
41
+ // The most trail events one history read returns (newest first). Retention is
42
+ // the server's: everything is kept unless its TRIAGE_HISTORY_LIMIT says
43
+ // otherwise.
44
+ export const MAX_TRIAGE_HISTORY = 200
45
+
46
+ export function isTriageBucket(x: unknown): x is TriageBucket {
47
+ return typeof x === 'string' && (TRIAGE_BUCKETS as readonly string[]).includes(x)
48
+ }
49
+
50
+ // Coerce one wire entry (a request-body value) into a clean patch. `null`
51
+ // means "clear the entry"; a malformed value (wrong type, unknown bucket,
52
+ // over-cap string, non-boolean flagged) is 'invalid' so the endpoint 400s
53
+ // rather than store garbage. Null/empty-string fields count as absent (an
54
+ // absent field is cleared anyway under whole-entry replace); unknown keys are
55
+ // ignored.
56
+ export function parseTriageEntryPatch(x: unknown): TriageEntryPatch | null | 'invalid' {
57
+ if (x === null) return null
58
+ if (typeof x !== 'object' || Array.isArray(x)) return 'invalid'
59
+ const o = x as Record<string, unknown>
60
+ const out: TriageEntryPatch = {}
61
+ const color = o['color']
62
+ if (color != null) {
63
+ if (typeof color !== 'string' || color.length > MAX_TRIAGE_COLOR) return 'invalid'
64
+ if (color !== '') out.color = color
65
+ }
66
+ const triage = o['triage']
67
+ if (triage != null) {
68
+ if (!isTriageBucket(triage)) return 'invalid'
69
+ out.triage = triage
70
+ }
71
+ for (const key of ['comment', 'fix'] as const) {
72
+ const v = o[key]
73
+ if (v == null) continue
74
+ if (typeof v !== 'string' || v.length > MAX_TRIAGE_TEXT) return 'invalid'
75
+ if (v !== '') out[key] = v
76
+ }
77
+ const flagged = o['flagged']
78
+ if (flagged != null) {
79
+ if (typeof flagged !== 'boolean') return 'invalid'
80
+ out.flagged = flagged
81
+ }
82
+ return out
83
+ }
@@ -0,0 +1,126 @@
1
+ // Managed uploads cross TLS-terminating proxies (Cloudflare, corporate
2
+ // gateways) that would otherwise read report and bundle bytes in plaintext.
3
+ // The server sends each session an X25519 public key; the browser seals every
4
+ // upload to it with a fresh ephemeral key (X25519 → HKDF-SHA-256 →
5
+ // AES-256-GCM), so the content key itself never crosses the proxy. Text is
6
+ // gzipped before sealing; binary content is sealed as is.
7
+ //
8
+ // Sealed body: version(1) | flags(1) | ephemeral public key(32), then the
9
+ // payload in 1 MiB segments, each sealed under nonce = big-endian segment
10
+ // index with the last byte marking the final segment. The header is the HKDF
11
+ // salt, so altering it changes the key. Segments bound browser memory for
12
+ // large bundles, and their nonces reject reordered, truncated or extended
13
+ // bodies. Every upload derives its own key, so counter nonces never repeat.
14
+
15
+ import { gunzipBytes } from '../gzip.js'
16
+ import { encodeUtf8 } from '../utf8.js'
17
+
18
+ // Set (to the format version) on a request whose body is sealed.
19
+ export const UPLOAD_SEAL_HEADER = 'x-upload-encryption'
20
+ const VERSION = 1
21
+ const FLAG_GZIP = 1
22
+ const CURVE = { name: 'X25519' }
23
+ const HEADER_BYTES = 2 + 32
24
+ const TAG_BYTES = 16
25
+ const SEGMENT_BYTES = 1024 * 1024
26
+ const SNIFF_BYTES = 64 * 1024
27
+ const KEY_INFO = 'deepview-managed-upload.v1.content-key'
28
+
29
+ // The largest sealed body for `maxBytes` of content. Deflate can grow
30
+ // incompressible text slightly (its worst case is about 0.03%).
31
+ export function maxSealedBytes(maxBytes: number): number {
32
+ const payload = maxBytes + Math.ceil(maxBytes / 1024) + 64
33
+ return HEADER_BYTES + payload + TAG_BYTES * (Math.floor(payload / SEGMENT_BYTES) + 1)
34
+ }
35
+
36
+ function nonce(index: number, final: boolean): Uint8Array<ArrayBuffer> {
37
+ const iv = new Uint8Array(12)
38
+ new DataView(iv.buffer).setUint32(7, index)
39
+ iv[11] = final ? 1 : 0
40
+ return iv
41
+ }
42
+
43
+ async function contentKey(secret: ArrayBuffer, header: Uint8Array<ArrayBuffer>, usage: KeyUsage): Promise<CryptoKey> {
44
+ const ikm = await crypto.subtle.importKey('raw', secret, 'HKDF', false, ['deriveKey'])
45
+ return await crypto.subtle.deriveKey({ name: 'HKDF', hash: 'SHA-256', salt: header, info: encodeUtf8(KEY_INFO) },
46
+ ikm, { name: 'AES-GCM', length: 256 }, false, [usage])
47
+ }
48
+
49
+ // Archives and other binary content would only grow under gzip. A NUL byte or
50
+ // invalid UTF-8 in the first 64 KiB marks content as binary.
51
+ async function isText(blob: Blob): Promise<boolean> {
52
+ const head = new Uint8Array(await blob.slice(0, SNIFF_BYTES).arrayBuffer())
53
+ if (head.includes(0)) return false
54
+ try { new TextDecoder('utf-8', { fatal: true }).decode(head, { stream: true }); return true }
55
+ catch { return false }
56
+ }
57
+
58
+ // Seal `blob` to the server's raw public key. Reads the input as a stream;
59
+ // sealed segments are kept as Blobs, which browsers may page to disk. An abort
60
+ // stops reading at the next chunk.
61
+ export async function sealUpload(blob: Blob, publicKey: Uint8Array<ArrayBuffer>, signal?: AbortSignal): Promise<Blob> {
62
+ const gzip = await isText(blob)
63
+ const [server, ephemeral] = await Promise.all([
64
+ crypto.subtle.importKey('raw', publicKey, CURVE, false, []),
65
+ crypto.subtle.generateKey(CURVE, false, ['deriveBits']) as Promise<CryptoKeyPair>,
66
+ ])
67
+ const header = new Uint8Array(HEADER_BYTES)
68
+ header[0] = VERSION
69
+ header[1] = gzip ? FLAG_GZIP : 0
70
+ header.set(new Uint8Array(await crypto.subtle.exportKey('raw', ephemeral.publicKey)), 2)
71
+ const key = await contentKey(await crypto.subtle.deriveBits({ name: 'X25519', public: server }, ephemeral.privateKey, 256), header, 'encrypt')
72
+ const parts = [new Blob([header])], segment = new Uint8Array(SEGMENT_BYTES)
73
+ let filled = 0, index = 0
74
+ const seal = async (final: boolean) => {
75
+ parts.push(new Blob([await crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce(index++, final) }, key, segment.subarray(0, filled))]))
76
+ filled = 0
77
+ }
78
+ const stream: ReadableStream<Uint8Array> = gzip ? blob.stream().pipeThrough(new CompressionStream('gzip')) : blob.stream()
79
+ const reader = stream.getReader()
80
+ try {
81
+ for (let read = await reader.read(); !read.done; read = await reader.read()) {
82
+ signal?.throwIfAborted()
83
+ for (let offset = 0; offset < read.value.length;) {
84
+ // A full segment is final only if the input ends with it.
85
+ if (filled === SEGMENT_BYTES) await seal(false)
86
+ const length = Math.min(SEGMENT_BYTES - filled, read.value.length - offset)
87
+ segment.set(read.value.subarray(offset, offset + length), filled)
88
+ filled += length
89
+ offset += length
90
+ }
91
+ }
92
+ } catch (err) {
93
+ await reader.cancel(err).catch(() => {})
94
+ throw err
95
+ }
96
+ await seal(true)
97
+ return new Blob(parts)
98
+ }
99
+
100
+ // Open a sealed body with the session's private JWK. Throws 'too-large' when
101
+ // the content exceeds `maxBytes`, else 'bad-upload' for any invalid body.
102
+ export async function openUpload(sealed: Uint8Array<ArrayBuffer>, privateKey: JsonWebKey, maxBytes: number): Promise<Uint8Array<ArrayBuffer>> {
103
+ // Every segment but the last is full, so the layout follows from the length.
104
+ const rest = sealed.length - HEADER_BYTES
105
+ const count = Math.ceil(rest / (SEGMENT_BYTES + TAG_BYTES))
106
+ const last = rest - (count - 1) * (SEGMENT_BYTES + TAG_BYTES)
107
+ if (rest < TAG_BYTES || last < TAG_BYTES || sealed[0] !== VERSION || (sealed[1]! & ~FLAG_GZIP) !== 0) throw new Error('bad-upload')
108
+ const gzip = sealed[1] === FLAG_GZIP, plain = new Uint8Array(rest - count * TAG_BYTES)
109
+ if (!gzip && plain.length > maxBytes) throw new Error('too-large')
110
+ try {
111
+ const header = sealed.slice(0, HEADER_BYTES)
112
+ const [key, peer] = await Promise.all([
113
+ crypto.subtle.importKey('jwk', privateKey, CURVE, false, ['deriveBits']),
114
+ crypto.subtle.importKey('raw', header.slice(2), CURVE, false, []),
115
+ ])
116
+ const content = await contentKey(await crypto.subtle.deriveBits({ name: 'X25519', public: peer }, key, 256), header, 'decrypt')
117
+ for (let index = 0; index < count; index++) {
118
+ const start = HEADER_BYTES + index * (SEGMENT_BYTES + TAG_BYTES)
119
+ const segment = sealed.subarray(start, Math.min(start + SEGMENT_BYTES + TAG_BYTES, sealed.length))
120
+ plain.set(new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce(index, index === count - 1) }, content, segment)), index * SEGMENT_BYTES)
121
+ }
122
+ } catch (err) { throw new Error('bad-upload', { cause: err }) }
123
+ if (!gzip) return plain
124
+ try { return await gunzipBytes(plain, { maxBytes }) }
125
+ catch (err) { throw new Error(err instanceof RangeError ? 'too-large' : 'bad-upload', { cause: err }) }
126
+ }
@@ -0,0 +1,5 @@
1
+ // Type declarations for `common/pretty-print.js`, for `.ts` callers.
2
+
3
+ export const MAX_PRETTY_BYTES: number
4
+ export function prettyExtension(path: string): string | null
5
+ export function needsPrettyPrint(path: string, text: string, read?: () => { category: string }): boolean