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

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 (311) 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-2KBF5TZ7.js +2 -0
  72. package/out/chunk-2XUEZHIP.js +28 -0
  73. package/out/chunk-35VE6CDH.js +2 -0
  74. package/out/chunk-3BZ7JERB.js +1 -0
  75. package/out/chunk-4F6JSF57.js +3 -0
  76. package/out/chunk-4XCDFGDP.js +10 -0
  77. package/out/chunk-AQ75QITE.js +1 -0
  78. package/out/chunk-B2RQY6OP.js +2 -0
  79. package/out/chunk-C4Z6ZBXV.js +10 -0
  80. package/out/chunk-CMQA4DM6.js +1 -0
  81. package/out/chunk-CVVJAXVY.js +2 -0
  82. package/out/chunk-DIJJNTRH.js +10 -0
  83. package/out/chunk-GJ4IUCNS.js +1 -0
  84. package/out/chunk-GNXOKIGP.js +80 -0
  85. package/out/chunk-GZ4MS3TE.js +13 -0
  86. package/out/chunk-IISEF2BZ.js +1 -0
  87. package/out/chunk-IU3CCINR.js +3 -0
  88. package/out/chunk-K3X5EJBQ.js +2 -0
  89. package/out/chunk-KEX2ATEW.js +2 -0
  90. package/out/chunk-KYCNJVB7.js +1 -0
  91. package/out/chunk-MYDDTA7W.js +225 -0
  92. package/out/chunk-NRPC74AM.js +1 -0
  93. package/out/chunk-OOJM4CTU.js +1 -0
  94. package/out/chunk-PNHBZGDD.js +1 -0
  95. package/out/chunk-R5O6FAW4.js +2 -0
  96. package/out/chunk-RFZQQXSG.js +17 -0
  97. package/out/chunk-RSHMVYNS.js +1 -0
  98. package/out/chunk-TGANXVTA.js +1 -0
  99. package/out/chunk-TUII34K2.js +1 -0
  100. package/out/chunk-TZ5KB2Y5.js +10 -0
  101. package/out/chunk-UN5ZSYLE.js +1 -0
  102. package/out/chunk-UNTRGE26.js +2 -0
  103. package/out/chunk-UXNUAIIC.js +33 -0
  104. package/out/chunk-UYAXVRVL.js +1 -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-XYDGU6BM.js +51 -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 +297 -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 +358 -0
  134. package/risk/files/script.js +114 -0
  135. package/risk/files/stamps.js +46 -0
  136. package/risk/index.d.ts +142 -0
  137. package/risk/index.js +7 -0
  138. package/risk/npm/assess.js +219 -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/specs.js +124 -0
  154. package/risk/npm/tarball.js +113 -0
  155. package/server-common/database-config.ts +16 -0
  156. package/server-common/initialize.ts +18 -0
  157. package/server-common/npm-advisories.ts +101 -0
  158. package/{server → server-common}/origin.ts +5 -5
  159. package/server-common/reap.ts +48 -0
  160. package/server-common/scan-config.ts +19 -0
  161. package/server-common/standalone.ts +29 -0
  162. package/server-common/storage-crypto.ts +182 -0
  163. package/server-common/storage-log.ts +38 -0
  164. package/server-common/vercel-blob.ts +103 -0
  165. package/server-e2e/app.ts +486 -0
  166. package/{server → server-e2e}/auth.ts +5 -1
  167. package/{server → server-e2e}/bus-receiver.ts +2 -1
  168. package/{server → server-e2e}/cli.js +9 -4
  169. package/{server → server-e2e}/config.ts +56 -35
  170. package/{server → server-e2e}/db-neon.ts +2 -2
  171. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  172. package/{server → server-e2e}/db-stmt.ts +2 -2
  173. package/{server → server-e2e}/db.ts +7 -7
  174. package/{server → server-e2e}/http.ts +116 -18
  175. package/{server → server-e2e}/hub.ts +40 -9
  176. package/server-e2e/index.ts +17 -0
  177. package/server-e2e/lifecycle.ts +95 -0
  178. package/{server → server-e2e}/neon-driver.ts +2 -2
  179. package/{server → server-e2e}/npm-proxy.ts +12 -145
  180. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  181. package/{server → server-e2e}/objstore/blob.ts +1 -1
  182. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  183. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  184. package/{server → server-e2e}/objstore/init.ts +35 -18
  185. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  186. package/server-e2e/objstore/rest-deny.ts +28 -0
  187. package/server-e2e/objstore/rest-mint.ts +224 -0
  188. package/{server → server-e2e}/objstore/rest.ts +27 -26
  189. package/{server → server-e2e}/objstore/sign.ts +105 -0
  190. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  191. package/{server → server-e2e}/objstore/store.ts +7 -7
  192. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  193. package/{server → server-e2e}/pubsub.ts +12 -9
  194. package/{server → server-e2e}/sign.ts +4 -4
  195. package/{server → server-e2e}/sse-server.ts +102 -68
  196. package/{server → server-e2e}/sse-session.ts +28 -8
  197. package/{server → server-e2e}/static.ts +31 -21
  198. package/server-e2e/sync-handlers.ts +408 -0
  199. package/{server → server-e2e}/ws-server.ts +20 -9
  200. package/server-managed/activity.ts +245 -0
  201. package/server-managed/avatar-store.ts +12 -0
  202. package/server-managed/blob-store.ts +17 -0
  203. package/server-managed/brotli.ts +10 -0
  204. package/server-managed/bundle-advisories.ts +120 -0
  205. package/server-managed/bundle-advisory-inventory.ts +77 -0
  206. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  207. package/server-managed/bundle-build-diagnostics.js +23 -0
  208. package/server-managed/bundle-build-leases.ts +41 -0
  209. package/server-managed/bundle-build-worker.js +111 -0
  210. package/server-managed/bundle-build.ts +198 -0
  211. package/server-managed/bundle-cache.ts +199 -0
  212. package/server-managed/bundle-catalog.ts +29 -0
  213. package/server-managed/bundle-commits.ts +140 -0
  214. package/server-managed/bundle-store.ts +24 -0
  215. package/server-managed/bundle-summary-cache.ts +130 -0
  216. package/server-managed/bundle.ts +120 -0
  217. package/server-managed/cache-storage.ts +19 -0
  218. package/server-managed/cli.js +21 -0
  219. package/server-managed/combined.ts +46 -0
  220. package/server-managed/comments.ts +151 -0
  221. package/server-managed/config.ts +158 -0
  222. package/server-managed/content-access.ts +15 -0
  223. package/server-managed/crypto.ts +25 -0
  224. package/server-managed/db-methods.ts +1725 -0
  225. package/server-managed/db-neon.ts +367 -0
  226. package/server-managed/db-schema.ts +227 -0
  227. package/server-managed/db-table-names.ts +22 -0
  228. package/server-managed/db.ts +132 -0
  229. package/server-managed/dependency-reviews.ts +248 -0
  230. package/server-managed/github-app.ts +391 -0
  231. package/server-managed/github-errors.ts +68 -0
  232. package/server-managed/github-issue-links.ts +127 -0
  233. package/server-managed/github-issue-oauth.ts +47 -0
  234. package/server-managed/github-issues.ts +152 -0
  235. package/server-managed/github-metadata.ts +213 -0
  236. package/server-managed/github-oauth.ts +235 -0
  237. package/server-managed/github-pulls.ts +227 -0
  238. package/server-managed/http-response.ts +50 -0
  239. package/server-managed/http.ts +3472 -0
  240. package/server-managed/import-triage.ts +51 -0
  241. package/server-managed/index.ts +187 -0
  242. package/server-managed/kept.ts +28 -0
  243. package/server-managed/link-reports.ts +158 -0
  244. package/server-managed/locked-packages.ts +85 -0
  245. package/server-managed/lru.ts +30 -0
  246. package/server-managed/managed-issues.ts +75 -0
  247. package/server-managed/management-catalog.ts +45 -0
  248. package/server-managed/management.ts +99 -0
  249. package/server-managed/npm-insights.ts +440 -0
  250. package/server-managed/npm-kept.ts +65 -0
  251. package/server-managed/npm-loads.ts +167 -0
  252. package/server-managed/npm-manifest.ts +115 -0
  253. package/server-managed/npm-packages.ts +378 -0
  254. package/server-managed/npm-provenance.ts +123 -0
  255. package/server-managed/npm-risk.ts +177 -0
  256. package/server-managed/object-storage-disk.ts +148 -0
  257. package/server-managed/object-storage-vercel.ts +67 -0
  258. package/server-managed/object-storage.ts +74 -0
  259. package/server-managed/package-entry-points.ts +51 -0
  260. package/server-managed/pretty-print.ts +352 -0
  261. package/server-managed/public-workspace.ts +220 -0
  262. package/server-managed/rename-diff-worker.js +26 -0
  263. package/server-managed/rename-diff.ts +270 -0
  264. package/server-managed/repo-path.ts +21 -0
  265. package/server-managed/report-migration.ts +35 -0
  266. package/server-managed/report-query.ts +10 -0
  267. package/server-managed/report-response.ts +16 -0
  268. package/server-managed/report-sources.ts +178 -0
  269. package/server-managed/report-store.ts +47 -0
  270. package/server-managed/repository-aliases.ts +91 -0
  271. package/server-managed/repository-browser.ts +153 -0
  272. package/server-managed/repository-discovery.ts +82 -0
  273. package/server-managed/repository-policy.ts +27 -0
  274. package/server-managed/repository-risk.ts +418 -0
  275. package/server-managed/revisions.ts +35 -0
  276. package/server-managed/session.ts +92 -0
  277. package/server-managed/slugs.ts +38 -0
  278. package/server-managed/solidity-entry-points.ts +69 -0
  279. package/server-managed/sql-postgres.ts +31 -0
  280. package/server-managed/sql.ts +76 -0
  281. package/server-managed/static.ts +28 -0
  282. package/server-managed/storage-command.ts +16 -0
  283. package/server-managed/storage-db.ts +175 -0
  284. package/server-managed/storage-encryption.ts +279 -0
  285. package/server-managed/storage-maintenance.ts +256 -0
  286. package/server-managed/storage-payload.ts +103 -0
  287. package/server-managed/storage-stores.ts +85 -0
  288. package/server-managed/storage.ts +78 -0
  289. package/server-managed/team-app.ts +94 -0
  290. package/server-managed/team-catalog.ts +7 -0
  291. package/server-managed/team-feed.ts +173 -0
  292. package/server-managed/team-npm-scopes.ts +77 -0
  293. package/server-managed/team-reports.ts +228 -0
  294. package/server-managed/triage-response.ts +16 -0
  295. package/server-managed/type-only-worker.js +19 -0
  296. package/server-managed/type-only.ts +103 -0
  297. package/server-managed/uploads.ts +86 -0
  298. package/server-managed/upstream-cache.ts +215 -0
  299. package/server-managed/view-as.ts +58 -0
  300. package/server-managed/workspace-shares.ts +166 -0
  301. package/server.ts +50 -0
  302. package/server/index.ts +0 -477
  303. package/server/lifecycle.ts +0 -204
  304. package/server/sync-handlers.ts +0 -320
  305. /package/{LICENSE → LICENSE-MIT} +0 -0
  306. /package/{server → server-e2e}/config.example.json +0 -0
  307. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  308. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  309. /package/{server → server-e2e}/peer.ts +0 -0
  310. /package/{server → server-e2e}/util.ts +0 -0
  311. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,227 @@
1
+ import { type GithubFixRef, type GithubFixResult, type GithubFixStatus, githubIssueClosedReason, isGithubRepoName, parseGithubFixUrl } from '../common/github-pr.ts'
2
+ import type { ManagedConfig } from './config.ts'
3
+ import type { ManagedDb, TeamReportAccessSnapshot } from './db.ts'
4
+ import type { GithubMetadata, GithubRepositoryVisibility } from './github-metadata.ts'
5
+ import { ensureUserAccessToken } from './github-oauth.ts'
6
+
7
+ type Metadata = Pick<GithubMetadata, 'title' | 'description' | 'status' | 'stateReason'>
8
+ const MAX_GITHUB_LOOKUPS = 200
9
+ const GITHUB_LOOKUP_TIMEOUT_MS = 10_000
10
+ const GITHUB_METADATA_TTL_MS = 60_000
11
+ const GITHUB_PUBLIC_VISIBILITY_TTL_MS = 60_000
12
+ const GITHUB_PRIVATE_REPOSITORIES_URL = 'https://api.github.com/user/repos?visibility=private&per_page=100&page=1'
13
+
14
+ type Repository = TeamReportAccessSnapshot['repositories'][number]
15
+ type RepositoryPayload = { id?: unknown; full_name?: unknown; private?: unknown; visibility?: unknown; permissions?: { pull?: unknown } }
16
+ type RepositoryChecks = { remaining: number; verified: Set<string> }
17
+ type RepositoryAccessContext = {
18
+ db: ManagedDb
19
+ visibility: GithubRepositoryVisibility[]
20
+ publicRepos: Set<string>
21
+ token: string | null
22
+ signal: AbortSignal
23
+ fetchImpl: typeof fetch
24
+ checks: RepositoryChecks
25
+ includePrivateList?: boolean
26
+ }
27
+
28
+ function githubRequest(token: string | null): RequestInit {
29
+ return {
30
+ headers: {
31
+ ...(token ? { authorization: `Bearer ${token}` } : {}), accept: 'application/vnd.github+json',
32
+ 'user-agent': 'deepview-triage', 'x-github-api-version': '2022-11-28',
33
+ },
34
+ redirect: 'error',
35
+ }
36
+ }
37
+
38
+ function repositoryMatches(repo: Repository, body: RepositoryPayload | null): boolean {
39
+ return body?.id === repo.repoId && typeof body.full_name === 'string' && body.full_name.toLowerCase() === repo.github.toLowerCase()
40
+ }
41
+
42
+ async function listPrivateRepositories(token: string, signal: AbortSignal, fetchImpl: typeof fetch): Promise<(RepositoryPayload | null)[]> {
43
+ try {
44
+ const response = await fetchImpl(GITHUB_PRIVATE_REPOSITORIES_URL, githubRequest(token))
45
+ if (!response.ok) return []
46
+ const body = await response.json() as (RepositoryPayload | null)[] | null
47
+ return Array.isArray(body) && body.length <= 100 && !signal.aborted ? body : []
48
+ } catch { return [] } // A failed list is unknown access; direct checks can still verify it.
49
+ }
50
+
51
+ // Public visibility can be reused briefly across users. Private access stays
52
+ // request-local: one page of the viewer's own repo list grants matching repos,
53
+ // then direct checks cover omissions, pagination and failed list requests.
54
+ async function readableRepositories(repositories: Repository[], { db, visibility, publicRepos, token, signal, fetchImpl, checks, includePrivateList = true }: RepositoryAccessContext): Promise<Set<string>> {
55
+ const readable = new Set(publicRepos)
56
+ const byId = new Map(repositories.map(repo => [repo.repoId, repo]))
57
+ const unverified = repositories.filter(repo => !readable.has(repo.github))
58
+ const updates = new Map<number, GithubRepositoryVisibility>()
59
+ const grant = (repo: Repository, body: RepositoryPayload, checkedAt: number) => {
60
+ const isPublic = body.private === false && body.visibility === 'public'
61
+ readable.add(repo.github)
62
+ checks.verified.add(repo.github)
63
+ if (isPublic) publicRepos.add(repo.github)
64
+ updates.set(repo.repoId, { repoId: repo.repoId, github: repo.github, public: isPublic, checkedAt })
65
+ }
66
+ if (includePrivateList && token && unverified.length > 0 && !signal.aborted) {
67
+ const checkedAt = Date.now()
68
+ for (const entry of await listPrivateRepositories(token, signal, fetchImpl)) {
69
+ const repo = typeof entry?.id === 'number' ? byId.get(entry.id) : undefined
70
+ if (repo && !readable.has(repo.github) && repositoryMatches(repo, entry) && entry?.permissions?.pull === true) grant(repo, entry, checkedAt)
71
+ }
72
+ }
73
+ const formerlyPublic = new Set(visibility.filter(entry => entry.public
74
+ && byId.get(entry.repoId)?.github.toLowerCase() === entry.github.toLowerCase()).map(entry => entry.repoId))
75
+ const pending = unverified.filter(repo => !readable.has(repo.github) && (token || formerlyPublic.has(repo.repoId))).slice(0, checks.remaining)
76
+ checks.remaining -= pending.length
77
+ for (let i = 0; i < pending.length && !signal.aborted; i += 4) {
78
+ await Promise.all(pending.slice(i, i + 4).map(async repo => {
79
+ if (signal.aborted) return
80
+ const checkedAt = Date.now()
81
+ try {
82
+ const response = await fetchImpl(`https://api.github.com/repos/${repo.github}`, githubRequest(token))
83
+ if (!response.ok) return
84
+ const body = await response.json() as RepositoryPayload | null
85
+ if (!signal.aborted && body && repositoryMatches(repo, body)
86
+ && (token || (body.private === false && body.visibility === 'public'))) grant(repo, body, checkedAt)
87
+ } catch { /* Unverified access must never expose cached metadata. */ }
88
+ }))
89
+ }
90
+ if (updates.size > 0) await db.setGithubRepositoryVisibility([...updates.values()])
91
+ return readable
92
+ }
93
+
94
+ async function verifyPublicRefreshes(config: ManagedConfig, userId: string, repositories: Repository[], context: RepositoryAccessContext): Promise<string | null> {
95
+ // Cached public metadata needs no credentials. New content uses a viewer
96
+ // token only after a live check, so privatization cannot publish private data
97
+ // under an old public grant. Anonymous reads remain available without a token.
98
+ const { db, publicRepos } = context
99
+ const token = context.token ?? await ensureUserAccessToken(config, db, userId, Date.now(), context.fetchImpl)
100
+ if (token) {
101
+ const currentPublic = new Set<string>()
102
+ const readable = await readableRepositories(repositories, { ...context, publicRepos: currentPublic, token, includePrivateList: false })
103
+ for (const repo of repositories) if (readable.has(repo.github) && !currentPublic.has(repo.github)) publicRepos.delete(repo.github)
104
+ }
105
+ return token
106
+ }
107
+
108
+ // ref.repo is always the canonical name from managed_selected_repo, never an
109
+ // owner/repo/path taken from a Fix URL. Redirects cannot move the user token.
110
+ async function fetchMetadata(ref: GithubFixRef, token: string | null, fetchImpl: typeof fetch): Promise<Metadata | null> {
111
+ try {
112
+ const response = await fetchImpl(`https://api.github.com/repos/${ref.repo}/${ref.kind === 'pull' ? 'pulls' : 'issues'}/${ref.number}`, githubRequest(token))
113
+ if (!response.ok) return null
114
+ const body = await response.json() as { number?: unknown; title?: unknown; body?: unknown; state?: unknown; merged?: unknown;
115
+ draft?: unknown; base?: { repo?: { full_name?: unknown } }; repository_url?: unknown; pull_request?: unknown; state_reason?: unknown } | null
116
+ if (body?.number !== ref.number || typeof body.title !== 'string' || !body.title.trim() || body.title.length > 1024
117
+ || !['open', 'closed'].includes(String(body.state)) || (body.body != null && typeof body.body !== 'string')) return null
118
+ let status: GithubFixStatus
119
+ if (ref.kind === 'pull') {
120
+ if (typeof body.merged !== 'boolean' || typeof body.base?.repo?.full_name !== 'string'
121
+ || body.base.repo.full_name.toLowerCase() !== ref.repo.toLowerCase()) return null
122
+ status = body.merged ? 'merged' : body.state === 'closed' ? 'closed' : body.draft === true ? 'draft' : 'open'
123
+ } else {
124
+ // The issues API also accepts PR numbers. Do not cache a PR as a closed
125
+ // ordinary issue: only the pulls API can establish its merged state.
126
+ if (body.pull_request != null || typeof body.repository_url !== 'string'
127
+ || body.repository_url.toLowerCase() !== `https://api.github.com/repos/${ref.repo}`.toLowerCase()) return null
128
+ status = body.state === 'closed' ? 'closed' : 'open'
129
+ }
130
+ const stateReason = ref.kind === 'issue' && status === 'closed' ? githubIssueClosedReason(body.state_reason) : null
131
+ return { title: body.title, description: body.body ?? null, status, stateReason }
132
+ } catch { return null }
133
+ }
134
+
135
+ // The workspace handler checks user -> team membership and finding visibility.
136
+ // Shared private metadata also requires this viewer's live GitHub access,
137
+ // including for admins. Only recently verified public repos skip that check.
138
+ export async function lookupFixes(config: ManagedConfig, db: ManagedDb, snapshot: TeamReportAccessSnapshot, urls: string[], fetchImpl: typeof fetch = globalThis.fetch): Promise<GithubFixResult[]> {
139
+ const allowed = new Map(snapshot.repositories.filter(repo => isGithubRepoName(repo.github)).map(repo => [repo.github.toLowerCase(), repo]))
140
+ const parsed = urls.map(parseGithubFixUrl)
141
+ const jobs = new Map<string, GithubFixRef>()
142
+ const keys = parsed.map(ref => {
143
+ const repo = ref && allowed.get(ref.repo.toLowerCase())
144
+ if (!ref || !repo) return null
145
+ const key = `${repo.repoId}:${ref.kind}:${ref.number}`
146
+ jobs.set(key, { ...ref, repo: repo.github })
147
+ return key
148
+ })
149
+ if (jobs.size === 0) return []
150
+ const now = Date.now()
151
+ const signal = AbortSignal.timeout(GITHUB_LOOKUP_TIMEOUT_MS)
152
+ const fetchWithinDeadline: typeof fetch = (input, init) => {
153
+ signal.throwIfAborted()
154
+ return fetchImpl(input, { ...init, signal })
155
+ }
156
+ const repositories = [...new Map([...jobs.values()].map(ref => {
157
+ const repo = allowed.get(ref.repo.toLowerCase())!
158
+ return [repo.repoId, repo] as const
159
+ })).values()]
160
+ const visibility = await db.listGithubRepositoryVisibility(repositories.map(repo => repo.repoId))
161
+ const byId = new Map(repositories.map(repo => [repo.repoId, repo]))
162
+ const publicRepos = new Set(visibility.filter(entry => entry.public && entry.checkedAt <= now
163
+ && entry.checkedAt + GITHUB_PUBLIC_VISIBILITY_TTL_MS > now
164
+ && byId.get(entry.repoId)?.github.toLowerCase() === entry.github.toLowerCase()).map(entry => byId.get(entry.repoId)!.github))
165
+ let token = repositories.some(repo => !publicRepos.has(repo.github))
166
+ ? await ensureUserAccessToken(config, db, snapshot.user.id, now, fetchWithinDeadline) : null
167
+ const checks: RepositoryChecks = { remaining: MAX_GITHUB_LOOKUPS, verified: new Set() }
168
+ const context = { db, visibility, publicRepos, token, signal, fetchImpl: fetchWithinDeadline, checks }
169
+ const readable = await readableRepositories(repositories, context)
170
+ const authorized = [...jobs.entries()].filter(([, ref]) => readable.has(ref.repo))
171
+ const metadata = new Map((await db.listGithubMetadata(authorized.map(([key]) => key))).map(entry => [entry.key, entry]))
172
+ const lastCheckedAt = (key: string) => {
173
+ const cached = metadata.get(key)
174
+ return Math.max(cached?.fetchedAt ?? 0, cached?.attemptedAt ?? 0)
175
+ }
176
+ const pending = authorized.filter(([key, ref]) => {
177
+ const cached = metadata.get(key)
178
+ // Backfill closed issues cached before closure reasons existed, within the
179
+ // same authorized/capped queue. Even an unknown reason completes backfill.
180
+ return !cached || (ref.kind === 'issue' && cached.status === 'closed' && cached.stateReason == null)
181
+ || (['open', 'draft'].includes(cached.status) && cached.fetchedAt + GITHUB_METADATA_TTL_MS <= now)
182
+ }).toSorted(([a], [b]) => lastCheckedAt(a) - lastCheckedAt(b)).slice(0, MAX_GITHUB_LOOKUPS)
183
+ // Only stale/missing, distinct, authorized entries consume the request budget.
184
+ // Prioritize missing/oldest entries so a large workspace makes progress.
185
+ if (pending.length > 0) {
186
+ const publicRefreshes = new Set(pending.filter(([, ref]) => publicRepos.has(ref.repo) && !checks.verified.has(ref.repo)).map(([, ref]) => ref.repo))
187
+ if (publicRefreshes.size > 0) token = await verifyPublicRefreshes(config, snapshot.user.id, repositories.filter(repo => publicRefreshes.has(repo.github)), context)
188
+ const fresh: Omit<GithubMetadata, 'attemptedAt'>[] = []
189
+ const attempted: string[] = []
190
+ // All waves and access checks share one deadline and four-call limit.
191
+ for (let i = 0; i < pending.length && !signal.aborted; i += 4) {
192
+ await Promise.all(pending.slice(i, i + 4).map(async ([key, ref]) => {
193
+ if (signal.aborted) return
194
+ if (metadata.has(key)) attempted.push(key)
195
+ const result = await fetchMetadata(ref, publicRepos.has(ref.repo) && !checks.verified.has(ref.repo) ? null : token, fetchWithinDeadline)
196
+ if (result) fresh.push({ key, ...result, fetchedAt: Date.now() })
197
+ }))
198
+ }
199
+ // Only started cached reads rotate. Missing entries retain priority, and
200
+ // jobs skipped by the cap, deadline or absent token keep their place.
201
+ if (attempted.length > 0) await db.recordGithubMetadataAttempts(attempted, Date.now())
202
+ if (fresh.length > 0) {
203
+ await db.setGithubMetadata(fresh)
204
+ // A concurrent merged result must win over a slower open/closed read.
205
+ for (const entry of await db.listGithubMetadata(fresh.map(row => row.key))) metadata.set(entry.key, entry)
206
+ }
207
+ }
208
+ if (publicRepos.size > 0) {
209
+ // Another request may already have discovered a public -> private change
210
+ // and written new private metadata. Honor that newer visibility observation.
211
+ const current = await db.listGithubRepositoryVisibility(repositories.filter(repo => publicRepos.has(repo.github)).map(repo => repo.repoId))
212
+ const stillPublic = new Set(current.filter(entry => entry.public
213
+ && byId.get(entry.repoId)?.github.toLowerCase() === entry.github.toLowerCase()).map(entry => byId.get(entry.repoId)!.github))
214
+ for (const [key, ref] of authorized) if (publicRepos.has(ref.repo) && !stillPublic.has(ref.repo)) metadata.delete(key)
215
+ }
216
+ // The handler rechecks workspace access and saved Fix links before returning.
217
+ return urls.flatMap((url, index): GithubFixResult[] => {
218
+ const key = keys[index]
219
+ if (!key) return []
220
+ const found = metadata.get(key)
221
+ return [found ? { url, title: found.title, description: found.description, status: found.status, stateReason: found.stateReason } : { url, error: 'unavailable' }]
222
+ })
223
+ }
224
+
225
+ export function storedFixUrls(entries: { fix: string | null }[]): string[] {
226
+ return [...new Set(entries.flatMap(entry => entry.fix && parseGithubFixUrl(entry.fix) ? [entry.fix] : []))].toSorted()
227
+ }
@@ -0,0 +1,50 @@
1
+ import type { ServerResponse } from 'node:http'
2
+ import { Buffer } from 'node:buffer'
3
+ import { pipeline } from 'node:stream/promises'
4
+ import { createBrotliCompress, constants as zlibConstants } from 'node:zlib'
5
+ import { UPLOAD_CHUNK_BYTES } from './uploads.ts'
6
+
7
+ // Keep the header ASCII while filename* carries the original UTF-8 name.
8
+ export function attachmentDisposition(filename: string): string {
9
+ const fallback = filename.replaceAll(/[^\u0020-\u007E]|["\\%]/gu, '_')
10
+ const encoded = encodeURIComponent(filename.toWellFormed()).replaceAll(/['()*]/gu, ch => `%${ch.codePointAt(0)!.toString(16).toUpperCase()}`)
11
+ return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`
12
+ }
13
+
14
+ // Writing body chunks enables the function's streamed-response path. Data is
15
+ // already materialized by the authorization/filtering layer before we get here.
16
+ export function writeResponse(res: ServerResponse, body: string | Buffer): void {
17
+ const bytes = typeof body === 'string' ? Buffer.from(body) : body
18
+ if (bytes.length <= UPLOAD_CHUNK_BYTES) { res.end(body); return }
19
+ for (let offset = 0; offset < bytes.length; offset += 64 * 1024) res.write(bytes.subarray(offset, offset + 64 * 1024))
20
+ res.end()
21
+ }
22
+
23
+ export function sendJson(res: ServerResponse, status: number, body: unknown, headers: Record<string, string> = {}): void {
24
+ res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store', ...headers })
25
+ writeResponse(res, JSON.stringify(body))
26
+ }
27
+
28
+ // Lines of JSON, Brotli-encoded and sent as each is written, so the reader
29
+ // has each while the next is made. A reader gone, or no longer `allowed`
30
+ // before a line, has the response cut short, as is one whose lines fail: a
31
+ // response that ends is one with every line.
32
+ export async function sendLines(res: ServerResponse, lines: AsyncIterable<unknown>, allowed: () => Promise<boolean>) {
33
+ res.writeHead(200, { 'content-type': 'application/x-ndjson', 'content-encoding': 'br', 'cache-control': 'private, no-store', 'x-content-type-options': 'nosniff' })
34
+ const encoder = createBrotliCompress({ params: { [zlibConstants.BROTLI_PARAM_QUALITY]: 4 } })
35
+ const sent = pipeline(encoder, res).catch(() => {})
36
+ const closed = new Promise(resolve => { res.once('close', resolve) })
37
+ try {
38
+ for await (const line of lines) {
39
+ if (res.destroyed || !await allowed()) { res.destroy(); return }
40
+ encoder.write(`${JSON.stringify(line)}\n`)
41
+ await Promise.race([new Promise<void>(resolve => { encoder.flush(() => { resolve() }) }), closed])
42
+ }
43
+ } catch (err) {
44
+ console.warn('managed: a streamed response failed:', err)
45
+ res.destroy()
46
+ return
47
+ }
48
+ encoder.end()
49
+ await sent
50
+ }