@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.41

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 (312) 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 +2 -0
  6. package/client/dependency-paths.js +24 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +112 -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 +522 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-metadata.d.ts +14 -0
  17. package/common/bundle-metadata.js +226 -0
  18. package/common/bundle-pkg-of.d.ts +1 -0
  19. package/common/bundle-pkg-of.js +90 -0
  20. package/common/bundle-reasons.d.ts +2 -0
  21. package/common/bundle-reasons.js +21 -0
  22. package/common/bundle-source-links.d.ts +6 -0
  23. package/common/bundle-source-links.js +216 -0
  24. package/common/bundle-sourcemap.d.ts +10 -0
  25. package/common/bundle-sourcemap.js +100 -0
  26. package/common/bundle-sources.d.ts +7 -0
  27. package/common/bundle-sources.js +310 -0
  28. package/common/bundle-stats.js +41 -0
  29. package/common/bundle-tabs.js +1 -0
  30. package/common/code-language.js +65 -0
  31. package/common/default-scan-models.ts +31 -0
  32. package/common/dependency-reviews.d.ts +23 -0
  33. package/common/dependency-reviews.js +81 -0
  34. package/common/finding-duplicates.js +58 -0
  35. package/common/finding-filters.js +274 -0
  36. package/common/finding-id.js +47 -0
  37. package/common/github-issue-labels.d.ts +1 -0
  38. package/common/github-issue-labels.js +11 -0
  39. package/common/github-pr.ts +56 -0
  40. package/common/gzip.js +59 -0
  41. package/common/line-diff.d.ts +8 -0
  42. package/common/line-diff.js +56 -0
  43. package/common/linked-workspace-groups.js +63 -0
  44. package/common/managed/comments.ts +34 -0
  45. package/common/managed/npm-packages.d.ts +5 -0
  46. package/common/managed/npm-packages.js +34 -0
  47. package/common/managed/permissions.ts +31 -0
  48. package/common/managed/report-content.ts +72 -0
  49. package/common/managed/report-filter.ts +108 -0
  50. package/common/managed/repository-alias.ts +65 -0
  51. package/common/managed/roles.ts +28 -0
  52. package/common/managed/routes.d.ts +2 -0
  53. package/common/managed/routes.js +254 -0
  54. package/common/managed/scan-models.ts +6 -0
  55. package/common/managed/team-app.d.ts +2 -0
  56. package/common/managed/team-app.js +31 -0
  57. package/common/managed/triage.ts +83 -0
  58. package/common/managed/upload-seal.ts +126 -0
  59. package/common/pretty-print.d.ts +5 -0
  60. package/common/pretty-print.js +24 -0
  61. package/common/revalidation-conflicts.js +48 -0
  62. package/common/revalidation-input-groups.js +56 -0
  63. package/common/save-error-reason.ts +20 -7
  64. package/common/scan-server.ts +13 -0
  65. package/common/server-info.ts +35 -0
  66. package/common/utf8.d.ts +3 -0
  67. package/common/utf8.js +45 -0
  68. package/common/workspace-app.js +23 -0
  69. package/common/workspace-groups.js +176 -0
  70. package/out/brotli-fallback.js +3 -3
  71. package/out/chunk-2AFQKRAM.js +51 -0
  72. package/out/chunk-2KBF5TZ7.js +2 -0
  73. package/out/chunk-2XUEZHIP.js +28 -0
  74. package/out/chunk-35VE6CDH.js +2 -0
  75. package/out/chunk-3BZ7JERB.js +1 -0
  76. package/out/chunk-4F6JSF57.js +3 -0
  77. package/out/chunk-4XCDFGDP.js +10 -0
  78. package/out/chunk-C4Z6ZBXV.js +10 -0
  79. package/out/chunk-CMQA4DM6.js +1 -0
  80. package/out/chunk-CVVJAXVY.js +2 -0
  81. package/out/chunk-DIJJNTRH.js +10 -0
  82. package/out/chunk-GJ4IUCNS.js +1 -0
  83. package/out/chunk-GNXOKIGP.js +80 -0
  84. package/out/chunk-GZ4MS3TE.js +13 -0
  85. package/out/chunk-IU3CCINR.js +3 -0
  86. package/out/chunk-K3X5EJBQ.js +2 -0
  87. package/out/chunk-KEX2ATEW.js +2 -0
  88. package/out/chunk-KVEUXIEU.js +1 -0
  89. package/out/chunk-KYCNJVB7.js +1 -0
  90. package/out/chunk-NRPC74AM.js +1 -0
  91. package/out/chunk-OOJM4CTU.js +1 -0
  92. package/out/chunk-PNHBZGDD.js +1 -0
  93. package/out/chunk-QZ3A376W.js +1 -0
  94. package/out/chunk-R5O6FAW4.js +2 -0
  95. package/out/chunk-RFZQQXSG.js +17 -0
  96. package/out/chunk-RSHMVYNS.js +1 -0
  97. package/out/chunk-TUII34K2.js +1 -0
  98. package/out/chunk-TY4RZD3Z.js +226 -0
  99. package/out/chunk-TZ5KB2Y5.js +10 -0
  100. package/out/chunk-U6OPEXIA.js +1 -0
  101. package/out/chunk-UNTRGE26.js +2 -0
  102. package/out/chunk-UXNUAIIC.js +33 -0
  103. package/out/chunk-UYAXVRVL.js +1 -0
  104. package/out/chunk-UZAZM2SX.js +2 -0
  105. package/out/chunk-V3ME6FI5.js +2 -0
  106. package/out/chunk-V5O7M2YT.js +1 -0
  107. package/out/chunk-VIHGO77Y.js +1 -0
  108. package/out/chunk-VQJCXGIY.js +7 -0
  109. package/out/chunk-W4J3MGLB.js +1 -0
  110. package/out/chunk-WGG7KQUH.js +1 -0
  111. package/out/chunk-WZDTZE64.js +1 -0
  112. package/out/chunk-XL35ZV6R.js +1 -0
  113. package/out/chunk-XYFXXB2G.js +10 -0
  114. package/out/chunk-YVZH5SHP.js +1 -0
  115. package/out/chunk-ZBBOBQ43.js +1 -0
  116. package/out/chunk-ZMQQCGF6.js +2 -0
  117. package/out/client-managed-import.js +4 -0
  118. package/out/client-managed.js +37 -0
  119. package/out/client-sync.js +10 -13
  120. package/out/github-issue-authorized.html +3 -0
  121. package/out/github-issue-authorized.js +1 -0
  122. package/out/graph.js +17 -4
  123. package/out/index.html +90 -8
  124. package/out/prism.js +6 -2
  125. package/out/stasis.svg +20 -0
  126. package/out/terminal.js +365 -39
  127. package/out/view.css +1 -1
  128. package/out/view.js +83 -117
  129. package/package.json +298 -59
  130. package/risk/files/characters.js +196 -0
  131. package/risk/files/hosts.js +205 -0
  132. package/risk/files/readability.js +171 -0
  133. package/risk/files/runs-code.js +595 -0
  134. package/risk/files/script.js +114 -0
  135. package/risk/files/stamps.js +46 -0
  136. package/risk/index.d.ts +156 -0
  137. package/risk/index.js +7 -0
  138. package/risk/npm/assess.js +223 -0
  139. package/risk/npm/combined.js +65 -0
  140. package/risk/npm/compare.js +228 -0
  141. package/risk/npm/dependencies.js +153 -0
  142. package/risk/npm/diff.js +43 -0
  143. package/risk/npm/downloads.js +84 -0
  144. package/risk/npm/files.js +34 -0
  145. package/risk/npm/held.js +60 -0
  146. package/risk/npm/install-scripts.js +34 -0
  147. package/risk/npm/malware.js +36 -0
  148. package/risk/npm/manifest.js +65 -0
  149. package/risk/npm/new-dependency.js +30 -0
  150. package/risk/npm/old-version.js +108 -0
  151. package/risk/npm/registry.js +282 -0
  152. package/risk/npm/release.js +85 -0
  153. package/risk/npm/score.js +59 -0
  154. package/risk/npm/specs.js +124 -0
  155. package/risk/npm/tarball.js +113 -0
  156. package/server-common/database-config.ts +16 -0
  157. package/server-common/initialize.ts +18 -0
  158. package/server-common/npm-advisories.ts +101 -0
  159. package/{server → server-common}/origin.ts +5 -5
  160. package/server-common/reap.ts +48 -0
  161. package/server-common/scan-config.ts +19 -0
  162. package/server-common/standalone.ts +29 -0
  163. package/server-common/storage-crypto.ts +182 -0
  164. package/server-common/storage-log.ts +38 -0
  165. package/server-common/vercel-blob.ts +103 -0
  166. package/server-e2e/app.ts +486 -0
  167. package/{server → server-e2e}/auth.ts +5 -1
  168. package/{server → server-e2e}/bus-receiver.ts +2 -1
  169. package/{server → server-e2e}/cli.js +9 -4
  170. package/{server → server-e2e}/config.ts +56 -35
  171. package/{server → server-e2e}/db-neon.ts +2 -2
  172. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  173. package/{server → server-e2e}/db-stmt.ts +2 -2
  174. package/{server → server-e2e}/db.ts +7 -7
  175. package/{server → server-e2e}/http.ts +116 -18
  176. package/{server → server-e2e}/hub.ts +40 -9
  177. package/server-e2e/index.ts +17 -0
  178. package/server-e2e/lifecycle.ts +95 -0
  179. package/{server → server-e2e}/neon-driver.ts +2 -2
  180. package/{server → server-e2e}/npm-proxy.ts +12 -145
  181. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  182. package/{server → server-e2e}/objstore/blob.ts +1 -1
  183. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  184. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  185. package/{server → server-e2e}/objstore/init.ts +35 -18
  186. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  187. package/server-e2e/objstore/rest-deny.ts +28 -0
  188. package/server-e2e/objstore/rest-mint.ts +224 -0
  189. package/{server → server-e2e}/objstore/rest.ts +27 -26
  190. package/{server → server-e2e}/objstore/sign.ts +105 -0
  191. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  192. package/{server → server-e2e}/objstore/store.ts +7 -7
  193. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  194. package/{server → server-e2e}/pubsub.ts +12 -9
  195. package/{server → server-e2e}/sign.ts +4 -4
  196. package/{server → server-e2e}/sse-server.ts +102 -68
  197. package/{server → server-e2e}/sse-session.ts +28 -8
  198. package/{server → server-e2e}/static.ts +31 -21
  199. package/server-e2e/sync-handlers.ts +408 -0
  200. package/{server → server-e2e}/ws-server.ts +20 -9
  201. package/server-managed/activity.ts +245 -0
  202. package/server-managed/avatar-store.ts +12 -0
  203. package/server-managed/blob-store.ts +17 -0
  204. package/server-managed/brotli.ts +10 -0
  205. package/server-managed/bundle-advisories.ts +120 -0
  206. package/server-managed/bundle-advisory-inventory.ts +77 -0
  207. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  208. package/server-managed/bundle-build-diagnostics.js +23 -0
  209. package/server-managed/bundle-build-leases.ts +41 -0
  210. package/server-managed/bundle-build-worker.js +111 -0
  211. package/server-managed/bundle-build.ts +198 -0
  212. package/server-managed/bundle-cache.ts +199 -0
  213. package/server-managed/bundle-catalog.ts +29 -0
  214. package/server-managed/bundle-commits.ts +140 -0
  215. package/server-managed/bundle-store.ts +24 -0
  216. package/server-managed/bundle-summary-cache.ts +130 -0
  217. package/server-managed/bundle.ts +120 -0
  218. package/server-managed/cache-storage.ts +19 -0
  219. package/server-managed/cli.js +21 -0
  220. package/server-managed/combined.ts +46 -0
  221. package/server-managed/comments.ts +151 -0
  222. package/server-managed/config.ts +158 -0
  223. package/server-managed/content-access.ts +15 -0
  224. package/server-managed/crypto.ts +25 -0
  225. package/server-managed/db-methods.ts +1725 -0
  226. package/server-managed/db-neon.ts +367 -0
  227. package/server-managed/db-schema.ts +227 -0
  228. package/server-managed/db-table-names.ts +22 -0
  229. package/server-managed/db.ts +132 -0
  230. package/server-managed/dependency-reviews.ts +248 -0
  231. package/server-managed/github-app.ts +391 -0
  232. package/server-managed/github-errors.ts +68 -0
  233. package/server-managed/github-issue-links.ts +127 -0
  234. package/server-managed/github-issue-oauth.ts +47 -0
  235. package/server-managed/github-issues.ts +152 -0
  236. package/server-managed/github-metadata.ts +213 -0
  237. package/server-managed/github-oauth.ts +235 -0
  238. package/server-managed/github-pulls.ts +227 -0
  239. package/server-managed/http-response.ts +50 -0
  240. package/server-managed/http.ts +3472 -0
  241. package/server-managed/import-triage.ts +51 -0
  242. package/server-managed/index.ts +187 -0
  243. package/server-managed/kept.ts +28 -0
  244. package/server-managed/link-reports.ts +158 -0
  245. package/server-managed/locked-packages.ts +85 -0
  246. package/server-managed/lru.ts +30 -0
  247. package/server-managed/managed-issues.ts +75 -0
  248. package/server-managed/management-catalog.ts +45 -0
  249. package/server-managed/management.ts +99 -0
  250. package/server-managed/npm-insights.ts +440 -0
  251. package/server-managed/npm-kept.ts +65 -0
  252. package/server-managed/npm-loads.ts +167 -0
  253. package/server-managed/npm-manifest.ts +115 -0
  254. package/server-managed/npm-packages.ts +378 -0
  255. package/server-managed/npm-provenance.ts +123 -0
  256. package/server-managed/npm-risk.ts +177 -0
  257. package/server-managed/object-storage-disk.ts +148 -0
  258. package/server-managed/object-storage-vercel.ts +67 -0
  259. package/server-managed/object-storage.ts +74 -0
  260. package/server-managed/package-entry-points.ts +51 -0
  261. package/server-managed/pretty-print.ts +352 -0
  262. package/server-managed/public-workspace.ts +220 -0
  263. package/server-managed/rename-diff-worker.js +26 -0
  264. package/server-managed/rename-diff.ts +270 -0
  265. package/server-managed/repo-path.ts +21 -0
  266. package/server-managed/report-migration.ts +35 -0
  267. package/server-managed/report-query.ts +10 -0
  268. package/server-managed/report-response.ts +16 -0
  269. package/server-managed/report-sources.ts +178 -0
  270. package/server-managed/report-store.ts +47 -0
  271. package/server-managed/repository-aliases.ts +91 -0
  272. package/server-managed/repository-browser.ts +153 -0
  273. package/server-managed/repository-discovery.ts +82 -0
  274. package/server-managed/repository-policy.ts +27 -0
  275. package/server-managed/repository-risk.ts +420 -0
  276. package/server-managed/revisions.ts +35 -0
  277. package/server-managed/session.ts +92 -0
  278. package/server-managed/slugs.ts +38 -0
  279. package/server-managed/solidity-entry-points.ts +69 -0
  280. package/server-managed/sql-postgres.ts +31 -0
  281. package/server-managed/sql.ts +76 -0
  282. package/server-managed/static.ts +28 -0
  283. package/server-managed/storage-command.ts +16 -0
  284. package/server-managed/storage-db.ts +175 -0
  285. package/server-managed/storage-encryption.ts +279 -0
  286. package/server-managed/storage-maintenance.ts +256 -0
  287. package/server-managed/storage-payload.ts +103 -0
  288. package/server-managed/storage-stores.ts +85 -0
  289. package/server-managed/storage.ts +78 -0
  290. package/server-managed/team-app.ts +94 -0
  291. package/server-managed/team-catalog.ts +7 -0
  292. package/server-managed/team-feed.ts +173 -0
  293. package/server-managed/team-npm-scopes.ts +77 -0
  294. package/server-managed/team-reports.ts +228 -0
  295. package/server-managed/triage-response.ts +16 -0
  296. package/server-managed/type-only-worker.js +19 -0
  297. package/server-managed/type-only.ts +103 -0
  298. package/server-managed/uploads.ts +86 -0
  299. package/server-managed/upstream-cache.ts +215 -0
  300. package/server-managed/view-as.ts +58 -0
  301. package/server-managed/workspace-shares.ts +166 -0
  302. package/server.ts +50 -0
  303. package/server/index.ts +0 -477
  304. package/server/lifecycle.ts +0 -204
  305. package/server/sync-handlers.ts +0 -320
  306. /package/{LICENSE → LICENSE-MIT} +0 -0
  307. /package/{server → server-e2e}/config.example.json +0 -0
  308. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  309. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  310. /package/{server → server-e2e}/peer.ts +0 -0
  311. /package/{server → server-e2e}/util.ts +0 -0
  312. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,245 @@
1
+ // Persistent workspace activity. Uploads are recorded in the same transaction
2
+ // as their metadata; triage reuses its existing immutable trail, including
3
+ // legacy entries. Never copy annotation bodies or credentials into this feed.
4
+ import { randomUUID } from 'node:crypto'
5
+ import type { DatabaseSync } from 'node:sqlite'
6
+ import type { ManagedSql } from './sql.ts'
7
+
8
+ export interface ActivityInput {
9
+ kind: 'access' | 'repository' | 'visibility' | 'delete'
10
+ actor: string
11
+ actorId?: string | null
12
+ action: string
13
+ repo?: string | null
14
+ reportId?: string | null
15
+ bundleId?: string | null
16
+ repoId?: number | null
17
+ repoDirectory?: string | null
18
+ report?: string | null
19
+ }
20
+
21
+ export interface ActivityContext {
22
+ finding: string
23
+ reportId: string
24
+ report: string
25
+ repo: string | null
26
+ }
27
+
28
+ export type ActivityEntry = {
29
+ id: string; kind: string; actor: string | null; action: string
30
+ repo: string | null; reportId: string | null; report: string | null
31
+ finding: string | null; at: number
32
+ }
33
+
34
+ export interface ActivityQuery {
35
+ page: number
36
+ limit: number
37
+ kind: string
38
+ query: string
39
+ repo?: string
40
+ actor?: string
41
+ // null = admin; [] = no accessible findings. Context is supplied by the
42
+ // server, never by the client. It also replaces potentially private context
43
+ // from an old report carrying the same globally shared finding.
44
+ contexts: ActivityContext[] | null
45
+ userId?: string
46
+ }
47
+
48
+ export interface ActivityFilters {
49
+ repos: string[]
50
+ users: { id: string; login: string; detail: string | null }[]
51
+ }
52
+
53
+ export interface ActivityStore {
54
+ recordActivity(entry: ActivityInput, at: number): Promise<void>
55
+ listActivityReports(userId: string): Promise<Omit<ActivityContext, 'finding'>[]>
56
+ listActivity(query: ActivityQuery): Promise<{ history: ActivityEntry[]; total: number; page: number; limit: number; filters: ActivityFilters }>
57
+ }
58
+
59
+ const triageFields = `'triage:' || e.seq AS id, 'triage' AS kind,
60
+ COALESCE(u.login, e.actor_login) AS actor, e.actor_id AS actorId,
61
+ CASE WHEN e.color IS NULL AND e.triage IS NULL AND e.comment IS NULL AND e.fix IS NULL AND e.flagged IS NULL
62
+ THEN 'cleared triage' ELSE 'updated triage' END AS action`
63
+ const triage = `SELECT ${triageFields}, e.repo, e.report_id AS reportId, e.report, e.finding_id AS finding, e.at
64
+ FROM managed_finding_triage_event e LEFT JOIN managed_user u ON u.id = e.actor_id`
65
+ const commentFields = `'comment:' || e.seq AS id, 'triage' AS kind,
66
+ COALESCE(u.login, e.actor_login) AS actor, e.actor_id AS actorId, e.action`
67
+ const comments = `SELECT ${commentFields}, e.repo, e.report_id AS reportId, e.report, e.finding_id AS finding, e.at
68
+ FROM managed_finding_comment_event e LEFT JOIN managed_user u ON u.id = e.actor_id`
69
+ const adminSource = `${triage} UNION ALL ${comments} UNION ALL
70
+ SELECT id, kind, actor, actor_id AS actorId, action, repo, report_id AS reportId, report, NULL AS finding, at FROM managed_activity`
71
+ // Keep the finite set of accessible findings outside the indexed event
72
+ // lookup. Scanning all events against json_each for every row is quadratic.
73
+ const managerSource = `SELECT ${triageFields},
74
+ json_extract(c.value, '$.repo') AS repo, json_extract(c.value, '$.reportId') AS reportId,
75
+ json_extract(c.value, '$.report') AS report, e.finding_id AS finding, e.at
76
+ FROM json_each(:contexts) c
77
+ CROSS JOIN managed_finding_triage_event e ON e.finding_id = json_extract(c.value, '$.finding')
78
+ LEFT JOIN managed_user u ON u.id = e.actor_id
79
+ UNION ALL SELECT ${commentFields},
80
+ json_extract(c.value, '$.repo') AS repo, json_extract(c.value, '$.reportId') AS reportId,
81
+ json_extract(c.value, '$.report') AS report, e.finding_id AS finding, e.at
82
+ FROM json_each(:contexts) c
83
+ CROSS JOIN managed_finding_comment_event e ON e.finding_id = json_extract(c.value, '$.finding')
84
+ LEFT JOIN managed_user u ON u.id = e.actor_id
85
+ UNION ALL SELECT a.id, a.kind, a.actor, a.actor_id AS actorId,
86
+ CASE WHEN a.kind = 'repository' THEN 'changed a report repository assignment' ELSE a.action END AS action,
87
+ p.full_name AS repo, r.id AS reportId, r.filename AS report, NULL AS finding, a.at
88
+ FROM managed_activity a JOIN managed_report r ON r.id = a.report_id
89
+ LEFT JOIN managed_selected_repo p ON p.repo_id = r.repo_id
90
+ WHERE EXISTS (
91
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
92
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
93
+ WHERE tu.user_id = :userId AND tr.repo_id = r.repo_id AND ${withinTeamPath('r.repo_directory')}
94
+ )
95
+ UNION ALL SELECT a.id, a.kind, a.actor, a.actor_id AS actorId,
96
+ CASE WHEN a.kind = 'repository' THEN 'changed a bundle repository assignment' ELSE a.action END AS action,
97
+ p.full_name AS repo, NULL AS reportId, b.filename AS report, NULL AS finding, a.at
98
+ FROM managed_activity a JOIN managed_bundle b ON b.id = a.bundle_id
99
+ LEFT JOIN managed_selected_repo p ON p.repo_id = b.repo_id
100
+ WHERE a.report_id IS NULL AND EXISTS (
101
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
102
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
103
+ WHERE tu.user_id = :userId AND tr.repo_id = b.repo_id AND ${withinTeamPath('b.repo_directory')}
104
+ )
105
+ UNION ALL SELECT a.id, a.kind, a.actor, a.actor_id AS actorId, a.action, a.repo, a.report_id AS reportId,
106
+ a.report, NULL AS finding, a.at FROM managed_activity a
107
+ WHERE a.kind = 'delete' AND (a.report_id IS NOT NULL OR a.bundle_id IS NOT NULL)
108
+ AND NOT EXISTS (SELECT 1 FROM managed_report WHERE id = a.report_id)
109
+ AND NOT EXISTS (SELECT 1 FROM managed_bundle WHERE id = a.bundle_id)
110
+ AND EXISTS (SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
111
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
112
+ WHERE tu.user_id = :userId AND tr.repo_id = a.repo_id
113
+ AND ${withinTeamPath('a.repo_directory')})`
114
+
115
+ export const ACTIVITY_SCHEMA = `
116
+ CREATE TABLE IF NOT EXISTS managed_activity (
117
+ id TEXT PRIMARY KEY, kind TEXT NOT NULL, actor TEXT, action TEXT NOT NULL,
118
+ repo TEXT, report_id TEXT, report TEXT, at INTEGER NOT NULL,
119
+ bundle_id TEXT, repo_id INTEGER, repo_directory TEXT, actor_id TEXT
120
+ ) STRICT;
121
+ CREATE INDEX IF NOT EXISTS managed_activity_at_idx ON managed_activity(at, id);
122
+ CREATE INDEX IF NOT EXISTS finding_triage_event_at_idx ON managed_finding_triage_event(at, seq);
123
+ `
124
+
125
+ export function initActivityMethods(db: DatabaseSync): void {
126
+ db.exec(ACTIVITY_SCHEMA)
127
+ migrateActivityScope(db)
128
+ db.exec('CREATE INDEX IF NOT EXISTS managed_activity_actor_at_idx ON managed_activity(actor_id, at)')
129
+ // Stable upload IDs make backfill idempotent, including after restarts.
130
+ // Snapshots deliberately have no cascading FKs: deletion is itself activity.
131
+ // CREATE TRIGGER IF NOT EXISTS keeps an older body; replace the one that
132
+ // predates server-built bundles so builds are not recorded as uploads.
133
+ const bundleTrigger = db.prepare("SELECT sql FROM sqlite_schema WHERE type = 'trigger' AND name = 'managed_bundle_activity'").get() as { sql: string } | undefined
134
+ if (bundleTrigger && !bundleTrigger.sql.includes('provenance')) db.exec('DROP TRIGGER managed_bundle_activity')
135
+ for (const type of ['report', 'bundle']) {
136
+ const columns = `id, kind, actor, action, repo, report_id, report, at, bundle_id, actor_id`
137
+ const values = (alias: string) => `'${type}-upload:' || ${alias}.id, 'upload',
138
+ COALESCE(${alias}.uploaded_by_login, (SELECT login FROM managed_user WHERE id = ${alias}.uploaded_by)), ${uploadAction(type, alias)},
139
+ (SELECT full_name FROM managed_selected_repo WHERE repo_id = ${alias}.repo_id),
140
+ ${type === 'report' ? `${alias}.id` : 'NULL'}, ${alias}.filename, ${alias}.uploaded_at,
141
+ ${type === 'bundle' ? `${alias}.id` : 'NULL'}, ${alias}.uploaded_by`
142
+ db.exec(`
143
+ CREATE TRIGGER IF NOT EXISTS managed_${type}_activity AFTER INSERT ON managed_${type}
144
+ BEGIN INSERT INTO managed_activity (${columns}) SELECT ${values('NEW')}; END;
145
+ INSERT OR IGNORE INTO managed_activity (${columns}) SELECT ${values('source')} FROM managed_${type} source;
146
+ `)
147
+ }
148
+ }
149
+
150
+ // The creation entry for a stored report or bundle. Shared with the Postgres
151
+ // trigger function; `alias` is the trigger row (NEW) or a backfill alias.
152
+ export function uploadAction(type: string, alias: string): string {
153
+ return type === 'bundle' ? `CASE WHEN ${alias}.provenance = 'build' THEN 'built a bundle' ELSE 'uploaded a bundle' END` : `'uploaded a ${type}'`
154
+ }
155
+
156
+ export function activityMethods(db: ManagedSql): ActivityStore {
157
+ const insert = db.prepare(`INSERT INTO managed_activity (id, kind, actor, action, repo, report_id, report, at, bundle_id, repo_id, repo_directory, actor_id)
158
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
159
+ const admin = activityStatements(db, adminSource)
160
+ const manager = activityStatements(db, managerSource)
161
+ const reports = db.prepare(`SELECT r.id AS reportId, r.filename AS report, p.full_name AS repo
162
+ FROM managed_report r JOIN managed_selected_repo p ON p.repo_id = r.repo_id
163
+ WHERE EXISTS (SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
164
+ JOIN managed_team t ON t.id = tu.team_id AND t.hidden = 0
165
+ WHERE tu.user_id = ? AND tr.repo_id = r.repo_id AND ${withinTeamPath('r.repo_directory')})
166
+ ORDER BY r.uploaded_at DESC, r.id`)
167
+ return {
168
+ async recordActivity(entry, at) {
169
+ await insert.run(randomUUID(), entry.kind, entry.actor, entry.action, entry.repo ?? null, entry.reportId ?? null, entry.report ?? null, at,
170
+ entry.bundleId ?? null, entry.repoId ?? null, entry.repoDirectory ?? null, entry.actorId ?? null)
171
+ },
172
+ async listActivityReports(userId) {
173
+ return (await reports.all(userId)) as Omit<ActivityContext, 'finding'>[]
174
+ },
175
+ async listActivity({ page, limit, kind, query, repo = '', actor = '', contexts, userId }) {
176
+ const stmts = contexts == null ? admin : manager
177
+ const scope = contexts == null ? {} : { contexts: JSON.stringify(contexts), userId: userId ?? '' }
178
+ const params = { ...scope, kind, query, repo, actor }
179
+ const { total } = (await stmts.count.get(params)) as { total: number }
180
+ const currentPage = Math.min(page, Math.max(1, Math.ceil(total / limit)))
181
+ const history = (await stmts.rows.all({ ...params, limit, offset: (currentPage - 1) * limit })) as ActivityEntry[]
182
+ const filters: ActivityFilters = {
183
+ repos: ((await stmts.repos.all(scope)) as { repo: string }[]).map(row => row.repo),
184
+ users: ((await stmts.users.all(scope)) as ActivityFilters['users']).map(row => ({ id: row.id, login: row.login, detail: row.detail })),
185
+ }
186
+ return { history, total, page: currentPage, limit, filters }
187
+ },
188
+ }
189
+ }
190
+
191
+ // Both rows and selector choices come from the authorized source. Keep legacy
192
+ // login-only actors separate: a historical login is not proof of user identity.
193
+ function activityStatements(db: ManagedSql, source: string) {
194
+ const activity = `WITH events AS (${source}), activity AS (
195
+ SELECT events.*, COALESCE('user:' || actorId, 'legacy:' || actor) AS actorKey FROM events
196
+ )`
197
+ const filtered = `${activity} SELECT id, kind, actor, action, repo, reportId, report, finding, at FROM activity
198
+ WHERE (:kind = 'all' OR kind = :kind)
199
+ AND (:repo = '' OR repo = :repo) AND (:actor = '' OR actorKey = :actor)
200
+ AND (:query = '' OR instr(lower(coalesce(actor, '') || ' ' || action || ' ' ||
201
+ coalesce(repo, '') || ' ' || coalesce(report, '') || ' ' || coalesce(finding, '')), lower(:query)) > 0)`
202
+ return {
203
+ count: db.prepare(`SELECT count(*) AS total FROM (${filtered}) AS filtered`),
204
+ rows: db.prepare(`${filtered} ORDER BY at DESC,
205
+ CASE WHEN kind = 'triage' THEN CAST(substr(id, instr(id, ':') + 1) AS INTEGER) ELSE 0 END DESC,
206
+ id DESC LIMIT :limit OFFSET :offset`),
207
+ repos: db.prepare(`${activity} SELECT DISTINCT repo FROM activity WHERE repo IS NOT NULL AND repo <> '' ORDER BY repo`),
208
+ users: db.prepare(`${activity} SELECT a.actorKey AS id, COALESCE(u.login, a.actor, a.actorId) AS login,
209
+ CASE WHEN a.actorId IS NULL THEN 'Legacy actor' ELSE NULL END AS detail FROM (
210
+ SELECT actorKey, actorId, actor, ROW_NUMBER() OVER (PARTITION BY actorKey ORDER BY at DESC, id DESC) AS position
211
+ FROM activity WHERE actorKey IS NOT NULL AND actorKey <> 'legacy:'
212
+ ) a LEFT JOIN managed_user u ON u.id = a.actorId WHERE a.position = 1 ORDER BY login, a.actorKey`),
213
+ }
214
+ }
215
+
216
+ // Literal path comparison preserves separators, case and SQL wildcard chars.
217
+ function withinTeamPath(path: string): string {
218
+ return `(tr.path IS NULL OR tr.path = '' OR ${path} = tr.path OR substr(${path}, 1, length(tr.path) + 1) = tr.path || '/')`
219
+ }
220
+
221
+ function migrateActivityScope(db: DatabaseSync): void {
222
+ const columns = new Set((db.prepare('PRAGMA table_info(managed_activity)').all() as { name: string }[]).map(column => column.name))
223
+ const missing = [['bundle_id', 'TEXT'], ['repo_id', 'INTEGER'], ['repo_directory', 'TEXT'], ['actor_id', 'TEXT']].filter(([name]) => !columns.has(name!))
224
+ if (missing.length === 0) return
225
+ db.exec('BEGIN')
226
+ try {
227
+ for (const [name, type] of missing) db.exec(`ALTER TABLE managed_activity ADD COLUMN ${name} ${type}`)
228
+ if (!columns.has('bundle_id') || !columns.has('actor_id')) {
229
+ db.exec('DROP TRIGGER IF EXISTS managed_report_activity; DROP TRIGGER IF EXISTS managed_bundle_activity;')
230
+ }
231
+ if (!columns.has('bundle_id')) db.exec("UPDATE managed_activity SET bundle_id = substr(id, 15) WHERE id LIKE 'bundle-upload:%'")
232
+ if (!columns.has('actor_id')) {
233
+ // Existing upload targets retain a reliable uploader identity. Do not
234
+ // guess identities from historical logins: names can change or be reused.
235
+ for (const type of ['report', 'bundle']) {
236
+ db.exec(`UPDATE managed_activity SET actor_id = (
237
+ SELECT uploaded_by FROM managed_${type} WHERE id = substr(managed_activity.id, 15)
238
+ ) WHERE id LIKE '${type}-upload:%'`)
239
+ }
240
+ }
241
+ // Legacy deletions/assignments without a durable target/scope remain
242
+ // admin-only; neither filenames nor repository names authorize access.
243
+ db.exec('COMMIT')
244
+ } catch (err) { db.exec('ROLLBACK'); throw err }
245
+ }
@@ -0,0 +1,12 @@
1
+ // Shared avatar cache interface, implemented by storage-stores.ts.
2
+ import type { Buffer } from 'node:buffer'
3
+
4
+ export interface CachedAvatar {
5
+ contentType: string
6
+ bytes: Buffer
7
+ }
8
+
9
+ export interface AvatarStore {
10
+ put(id: string, contentType: string, bytes: Buffer): Promise<void>
11
+ get(id: string): Promise<CachedAvatar | null>
12
+ }
@@ -0,0 +1,17 @@
1
+ // Logical upload-store interfaces shared by disk and Vercel storage.
2
+ import type { Buffer } from 'node:buffer'
3
+ import type { Readable } from 'node:stream'
4
+
5
+ export interface OpenedBlob {
6
+ // Remote streams may omit their size; callers then omit Content-Length.
7
+ size: number | null
8
+ stream: Readable
9
+ }
10
+
11
+ export interface BlobStore {
12
+ // Encrypted upload stores return the wrapped data key for the SQL insert.
13
+ put(id: string, bytes: Buffer): Promise<string | null>
14
+ get(id: string): Promise<Buffer | null>
15
+ open(id: string): Promise<OpenedBlob | null>
16
+ delete(id: string): Promise<void>
17
+ }
@@ -0,0 +1,10 @@
1
+ import { brotliCompress, constants } from 'node:zlib'
2
+ import { promisify } from 'node:util'
3
+ import type { Buffer } from 'node:buffer'
4
+
5
+ const compress = promisify(brotliCompress)
6
+
7
+ // Bundles and cold caches use quality 4; stored reports use quality 9.
8
+ export function encodeBrotli(bytes: Uint8Array, quality = 4): Promise<Buffer> {
9
+ return compress(bytes, { params: { [constants.BROTLI_PARAM_QUALITY]: quality } })
10
+ }
@@ -0,0 +1,120 @@
1
+ import { type Advisory, type CacheStore, type Package, advisories } from '@preventive/upstream/advisories.js'
2
+ import { type Client, HttpError, createClient } from '@preventive/upstream/github.js'
3
+ import { isNpmPackageName, npmPackageScope } from '../common/managed/npm-packages.js'
4
+ import { NPM_REGISTRY, askUpstream } from './npm-packages.ts'
5
+
6
+ export const ADVISORIES_TIMEOUT_MS = 30_000
7
+ const PUBLIC_AT_ONCE = 16
8
+ const order = (a: string, b: string) => Number(a > b) - Number(a < b)
9
+
10
+ // Whether anyone may read a package, so that its name may go to a service
11
+ // other than npm (OSV): an unscoped one always, npm's private packages
12
+ // always being scoped; a scoped one where npm answers it anonymously, asked
13
+ // by a HEAD, which reads nothing of it. One npm can't be asked of is taken
14
+ // as private, and not kept; the rest are, for PUBLIC_MS, PUBLIC_NAMES at
15
+ // most, as a package may be made private, or public. A name npm can't have
16
+ // is asked of no one, upstream refusing it.
17
+ const PUBLIC_MS = 60 * 60_000
18
+ const PUBLIC_NAMES = 10_000
19
+ const publicNames = new Map<string, { at: number; open: boolean }>()
20
+ async function isPublicNpmPackage(name: string, signal: AbortSignal): Promise<boolean> {
21
+ if (!isNpmPackageName(name)) return false
22
+ if (npmPackageScope(name) === null) return true
23
+ const kept = publicNames.get(name)
24
+ if (kept && Date.now() - kept.at < PUBLIC_MS) return kept.open
25
+ let open
26
+ try { open = await askUpstream(`${NPM_REGISTRY}/${name}`, { method: 'HEAD', limit: Number.MAX_SAFE_INTEGER, signal, absent: [401, 403, 404] }) !== null } catch { return false }
27
+ publicNames.delete(name)
28
+ publicNames.set(name, { at: Date.now(), open })
29
+ while (publicNames.size > PUBLIC_NAMES) publicNames.delete(publicNames.keys().next().value!)
30
+ return open
31
+ }
32
+
33
+ // The npm packages anyone may read (isPublicNpmPackage), a few asked at a
34
+ // time, within PUBLIC_BUDGET_MS, a third of the audit's: a scoped one npm
35
+ // hasn't told of by then is taken as private, asked of npm alone, for the
36
+ // audit to keep the rest of its time.
37
+ const PUBLIC_BUDGET_MS = 10_000
38
+ async function publicNpmNames(packages: Package[], signal: AbortSignal): Promise<Set<string>> {
39
+ const names = [...new Set(packages.filter(pkg => pkg.ecosystem === 'npm').map(pkg => pkg.name))]
40
+ const open = new Set<string>()
41
+ const budget = new AbortController()
42
+ const timer = setTimeout(() => { budget.abort(new Error('public-check-budget')) }, PUBLIC_BUDGET_MS)
43
+ const asking = AbortSignal.any([signal, budget.signal])
44
+ try {
45
+ for (let at = 0; at < names.length; at += PUBLIC_AT_ONCE) {
46
+ const chunk = names.slice(at, at + PUBLIC_AT_ONCE)
47
+ const checked = await Promise.all(chunk.map(name => isPublicNpmPackage(name, asking)))
48
+ for (const [i, name] of chunk.entries()) if (checked[i]) open.add(name)
49
+ }
50
+ } finally { clearTimeout(timer) }
51
+ return open
52
+ }
53
+
54
+ function advisoryGithubClient(token: string | null, signal: AbortSignal): Client {
55
+ const authenticated = createClient({ token, userAgent: 'deepview-triage' })
56
+ let client = authenticated
57
+ return {
58
+ ...authenticated,
59
+ async listRepoAdvisories(options) {
60
+ const current = client
61
+ try { return await current.listRepoAdvisories(options) } catch (error) {
62
+ if (token === null || current !== authenticated || !(error instanceof HttpError) || error.status !== 401) throw error
63
+ signal.throwIfAborted()
64
+ // Published advisories remain readable after a token is revoked. Reuse
65
+ // anonymous access for subsequent repositories in this audit as well.
66
+ if (client === authenticated) client = createClient({ token: null, userAgent: 'deepview-triage' })
67
+ return await client.listRepoAdvisories(options)
68
+ }
69
+ },
70
+ }
71
+ }
72
+
73
+ // The managed caller supplies its user's token when available; public shares
74
+ // remain anonymous. Upstream sends this credential only to GitHub and returns
75
+ // published advisories, matching versions across npm, OSV and repository rows:
76
+ // OSV's for npm packages anyone may read too, the malicious packages' records
77
+ // among them, which npm's registry hasn't.
78
+ // `cache` keeps each repository's listing (upstream-cache.ts) across audits;
79
+ // `github`, a client of the caller's, asks GitHub in place of one with
80
+ // `githubToken`.
81
+ export async function fetchBundleAdvisories(packages: Package[], signal: AbortSignal,
82
+ { debug = false, repoAdvisories = false, details = false, githubToken = null, github, cache }: {
83
+ debug?: boolean; repoAdvisories?: boolean; details?: boolean; githubToken?: string | null; github?: Client; cache?: CacheStore | undefined
84
+ } = {}): Promise<
85
+ { status: 200; body: Advisory[] } | { status: 502; body: { error: string } }
86
+ > {
87
+ let onAbort: (() => void) | undefined
88
+ try {
89
+ signal.throwIfAborted()
90
+ if (packages.length === 0) return { status: 200, body: [] }
91
+ // Upstream owns each transport's timeout and exposes no cancellation
92
+ // option. Bound the caller's wait for the multi-request audit as well.
93
+ const deadline = new Promise<never>((_resolve, reject) => {
94
+ onAbort = () => reject(signal.reason)
95
+ signal.addEventListener('abort', onAbort, { once: true })
96
+ })
97
+ // npm packages anyone may read are asked of OSV too (upstream's `osv`),
98
+ // for the malicious packages' records npm's registry hasn't; a private
99
+ // one's name never leaves for OSV, as it never does for Socket. A package
100
+ // is wholly in one ask or the other, so upstream's order holds.
101
+ const open = await Promise.race([publicNpmNames(packages, signal), deadline])
102
+ const toOsv = (pkg: Package) => pkg.ecosystem === 'npm' && open.has(pkg.name)
103
+ // Kept in the caller's store, or without one in upstream's disk cache,
104
+ // where the server set one (auditCache).
105
+ const options = { github: github ?? advisoryGithubClient(githubToken, signal), repoAdvisories, details, ...(cache && { cache }) }
106
+ const asked = [{ part: packages.filter(toOsv), osv: true }, { part: packages.filter(pkg => !toOsv(pkg)), osv: false }]
107
+ const result = await Promise.race([Promise.all(asked.map(({ part, osv }) => part.length === 0 ? Promise.resolve([]) : advisories(part, { ...options, osv }))), deadline])
108
+ return { status: 200, body: result.flat().toSorted((a, b) => order(a.ecosystem, b.ecosystem) || order(a.name, b.name)) }
109
+ } catch (error) {
110
+ // Answered as upstream's failure alone: logged, its message, or with
111
+ // `debug` the whole error, but for a request its client gave up on. One
112
+ // past its deadline (aborted with a TimeoutError, as AbortSignal.timeout
113
+ // and the bundle route's timer abort) is the server giving up: logged.
114
+ const cancelled = signal.aborted && (signal.reason as { name?: unknown } | undefined)?.name !== 'TimeoutError'
115
+ if (!cancelled) console.warn('managed: advisories failed:', debug || !(error instanceof Error) ? error : error.message)
116
+ return { status: 502, body: { error: 'upstream-unavailable' } }
117
+ } finally {
118
+ if (onAbort) signal.removeEventListener('abort', onAbort)
119
+ }
120
+ }
@@ -0,0 +1,77 @@
1
+ import { isEvidenceFile } from '@exodus/stasis/audit-corrections'
2
+ import { packageRepo } from '@exodus/stasis-core/bundle-util'
3
+ import type { Package } from '@preventive/upstream/advisories.js'
4
+ import type { BundleDetails } from '../common/bundle-metadata.js'
5
+
6
+ export interface SkippedAdvisoryPackage {
7
+ ecosystem: string
8
+ name: string
9
+ version: string
10
+ because: string
11
+ }
12
+ export interface BundleAdvisoryInventory { packages: Package[]; skipped: SkippedAdvisoryPackage[] }
13
+ interface BundleModule { ecosystem?: string; name?: string; version?: string; repo?: { github?: Package['github'] }; files: Record<string, unknown> }
14
+
15
+ // The GitHub repository a bundled package.json's text names, read as Stasis
16
+ // records a dependency's (packageRepo) and as Node reads the file, past a byte
17
+ // order mark the bundle keeps. Undefined where it names none or doesn't parse.
18
+ function manifestRepo(text: unknown): Package['github'] {
19
+ if (typeof text !== 'string') return undefined
20
+ try { return packageRepo(JSON.parse(text.replace(/^\uFEFF/u, '')))?.github } catch { return undefined }
21
+ }
22
+
23
+ function supported(ecosystem: string): ecosystem is Package['ecosystem'] {
24
+ return ['npm', 'cargo', 'composer', 'soldeer', 'github'].includes(ecosystem)
25
+ }
26
+
27
+ function skipReason(ecosystem: string, version: string): string | undefined {
28
+ if (ecosystem === 'cargo-git') return 'Crate vendored from git; its identity is not a crates.io package.'
29
+ if (ecosystem === 'cargo-unknown') return 'Crate source is unknown because .cargo-checksum.json is missing.'
30
+ if (ecosystem === 'composer' && /^dev-|-dev$/iu.test(version.replace(/#.*$/su, ''))) return 'Composer dev versions cannot be matched against release advisories.'
31
+ return undefined
32
+ }
33
+
34
+ // Package and reason presence both require a recorded evidence file, by Stasis's
35
+ // audit rule (server-only, as its semver uses Node's npm). Entry and manually
36
+ // added files count even when they have no incoming import edge.
37
+ export function bundleAdvisoryInventory(details: BundleDetails, paths: Iterable<string> | null = null): BundleAdvisoryInventory {
38
+ const modules = (details.bundle as { modules?: ReadonlyMap<string, BundleModule> } | undefined)?.modules
39
+ if (details.kind !== 'stasis' || !modules) return { packages: [], skipped: [] }
40
+ const selected = paths === null ? null : new Set(paths)
41
+ const packages = new Map<string, { ecosystem: Package['ecosystem']; name: string; versions: Set<string>; github?: Package['github'] | null }>()
42
+ const skipped = new Map<string, SkippedAdvisoryPackage>()
43
+ for (const [dir, info] of modules) {
44
+ if (dir === '.') continue
45
+ const ecosystem = info.ecosystem ?? (/(?:^|\/)node_modules\//u.test(dir) ? 'npm' : undefined)
46
+ const { name, version } = info
47
+ if (ecosystem === undefined || typeof name !== 'string' || !name || typeof version !== 'string' || !version) continue
48
+ if (!Object.keys(info.files).some(file => (!selected || selected.has(`${dir}/${file}`)) && isEvidenceFile(name, version, file, ecosystem))) continue
49
+ const because = skipReason(ecosystem, version)
50
+ if (!supported(ecosystem) || because !== undefined) {
51
+ skipped.set(JSON.stringify([ecosystem, name, version]), { ecosystem, name, version, because: because ?? `No advisory source is supported for ${ecosystem}.` })
52
+ continue
53
+ }
54
+ // Foundry's branch '.' means the superproject's branch. As in Stasis,
55
+ // 0.0.0 tells upstream its actual version is unknown, so every range covers it.
56
+ const auditedVersion = ecosystem === 'github' && version === '.' ? '0.0.0' : version
57
+ const key = JSON.stringify([ecosystem, ecosystem === 'github' ? name.toLowerCase() : name])
58
+ if (!packages.has(key)) packages.set(key, { ecosystem, name, versions: new Set() })
59
+ const pkg = packages.get(key)!
60
+ pkg.versions.add(auditedVersion)
61
+ // A bundle from before Stasis recorded `repo` may still carry the npm
62
+ // package's package.json; ask the repository it names, as Stasis does.
63
+ let github = ecosystem === 'github' ? undefined : info.repo?.github
64
+ if (github === undefined && ecosystem === 'npm') github = manifestRepo(info.files['package.json'])
65
+ if (github !== undefined) {
66
+ // Upstream accepts one repository per package. Conflicting hints (for
67
+ // example, versions from before and after a move) use registry discovery.
68
+ if (pkg.github === undefined) pkg.github = github
69
+ else if (pkg.github !== null && pkg.github.toLowerCase() !== github.toLowerCase()) pkg.github = null
70
+ }
71
+ }
72
+ const byPackage = (a: { ecosystem: string; name: string }, b: { ecosystem: string; name: string }) => a.ecosystem.localeCompare(b.ecosystem) || a.name.localeCompare(b.name)
73
+ return {
74
+ packages: [...packages.values()].map(({ github, ...pkg }) => ({ ...pkg, versions: [...pkg.versions].toSorted(), ...(github ? { github } : {}) })).toSorted(byPackage),
75
+ skipped: [...skipped.values()].toSorted((a, b) => byPackage(a, b) || a.version.localeCompare(b.version)),
76
+ }
77
+ }
@@ -0,0 +1,10 @@
1
+ export interface BundleBuildDiagnostic {
2
+ message: string
3
+ name?: string
4
+ code?: string
5
+ status?: number
6
+ stack?: string
7
+ cause?: BundleBuildDiagnostic
8
+ }
9
+ export function bundleBuildDiagnostic(error: unknown, token: string | null): BundleBuildDiagnostic
10
+ export type BundleBuildStage = 'worker-start' | 'build' | 'scope' | 'serialize' | 'compress'
@@ -0,0 +1,23 @@
1
+ // Whitelist diagnostic fields; never inspect/serialize an entire upstream
2
+ // error, request, response, or workerData (which contains the GitHub token).
3
+ export function bundleBuildDiagnostic(error, token, depth = 0) {
4
+ const redact = value => {
5
+ let text = String(value)
6
+ if (token) text = text.replaceAll(token, '[redacted]')
7
+ return text.replaceAll(/\b(?:github_pat_|gh[pousr]_)[\w]+/gu, '[redacted]')
8
+ .replaceAll(/(https?:\/\/)[^\s/@]+:[^\s/@]+@/gu, '$1[redacted]@')
9
+ .replaceAll(/([?&](?:access_token|token|key)=)[^\s&#]*/giu, '$1[redacted]')
10
+ }
11
+ if (!error || typeof error !== 'object') return { message: redact(error).slice(0, 2048) }
12
+ // Upstream HttpError messages append the response body after the status.
13
+ // Keep the method, URL and status, but omit that body from message and stack.
14
+ const raw = typeof error.message === 'string' ? error.message : 'Unknown error'
15
+ const message = error.name === 'HttpError' ? raw.replace(/(\s\d{3}):[\s\S]*$/u, '$1') : raw
16
+ const result = { message: redact(message).slice(0, 2048) }
17
+ if (typeof error.name === 'string') result.name = redact(error.name).slice(0, 128)
18
+ if (typeof error.code === 'string') result.code = redact(error.code).slice(0, 128)
19
+ if (typeof error.status === 'number') result.status = error.status
20
+ if (typeof error.stack === 'string') result.stack = redact(error.stack.replace(raw, message)).slice(0, 8192)
21
+ if (error.cause && depth < 3) result.cause = bundleBuildDiagnostic(error.cause, token, depth + 1)
22
+ return result
23
+ }
@@ -0,0 +1,41 @@
1
+ import type { ManagedSql } from './sql.ts'
2
+
3
+ export const BUNDLE_BUILD_TIMEOUT_MS = 180_000
4
+ // Leave time to terminate a timed-out worker before a crashed owner's slot
5
+ // becomes reusable. Expiration uses the database clock, not an instance's clock.
6
+ const LEASE_MS = BUNDLE_BUILD_TIMEOUT_MS + 60_000
7
+ export const BUNDLE_BUILD_LEASE_SCHEMA = `
8
+ CREATE TABLE IF NOT EXISTS managed_bundle_build_lease (
9
+ slot INTEGER PRIMARY KEY CHECK (slot IN (1, 2)),
10
+ owner TEXT NOT NULL UNIQUE,
11
+ user_id TEXT NOT NULL UNIQUE,
12
+ expires_at INTEGER NOT NULL
13
+ ) STRICT;
14
+ `
15
+
16
+ export interface BundleBuildLeaseStore {
17
+ claimBundleBuildLease(userId: string, owner: string): Promise<boolean>
18
+ releaseBundleBuildLease(owner: string): Promise<void>
19
+ }
20
+
21
+ export function bundleBuildLeaseMethods(db: ManagedSql): BundleBuildLeaseStore {
22
+ return {
23
+ // scopeManagedMethods holds SQLite's write transaction / PostgreSQL's
24
+ // advisory transaction lock across cleanup, inspection, and admission.
25
+ async claimBundleBuildLease(userId, owner) {
26
+ const { now } = await db.prepare("SELECT CAST(unixepoch('subsec') * 1000 AS INTEGER) AS now").get() as { now: number }
27
+ await db.prepare('DELETE FROM managed_bundle_build_lease WHERE expires_at <= ?').run(now)
28
+ const leases = await db.prepare('SELECT slot, user_id AS userId FROM managed_bundle_build_lease').all() as { slot: number; userId: string }[]
29
+ if (leases.length >= 2 || leases.some(lease => lease.userId === userId)) return false
30
+ const slot = leases.some(lease => lease.slot === 1) ? 2 : 1
31
+ await db.prepare('INSERT INTO managed_bundle_build_lease (slot, owner, user_id, expires_at) VALUES (?, ?, ?, ?)')
32
+ .run(slot, owner, userId, now + LEASE_MS)
33
+ return true
34
+ },
35
+ // No user FK: deleting an account must not free a still-running worker's
36
+ // slot. A delayed/crashed owner's cleanup cannot release its successor.
37
+ async releaseBundleBuildLease(owner) {
38
+ await db.prepare('DELETE FROM managed_bundle_build_lease WHERE owner = ?').run(owner)
39
+ },
40
+ }
41
+ }