@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,91 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { reportRepoGithub } from '@preventive/report'
3
+ import { type RepositoryAliasInput, matchRepositoryAlias, repositoryAliasNeedsFilePrefix } from '../common/managed/repository-alias.ts'
4
+ import type { ManagedDb } from './db.ts'
5
+ import { ManagedMutationError } from './management.ts'
6
+ import { normalizeTeamPath } from './repo-path.ts'
7
+ import type { ManagedSql } from './sql.ts'
8
+
9
+ export const REPOSITORY_ALIAS_SCHEMA = `
10
+ CREATE TABLE IF NOT EXISTS managed_repository_alias (
11
+ id TEXT PRIMARY KEY,
12
+ old_repo TEXT NOT NULL,
13
+ old_path TEXT NOT NULL DEFAULT '',
14
+ repo_id INTEGER NOT NULL REFERENCES managed_selected_repo(repo_id) ON DELETE CASCADE,
15
+ new_path TEXT NOT NULL DEFAULT '',
16
+ UNIQUE(old_repo, old_path)
17
+ ) STRICT;
18
+ `
19
+ export interface RepositoryAlias extends RepositoryAliasInput { id: string }
20
+ export interface RepositoryAliasStore {
21
+ listRepositoryAliases(): Promise<RepositoryAlias[]>
22
+ saveRepositoryAlias(sessionId: string, id: string | null, input: unknown): Promise<RepositoryAlias>
23
+ deleteRepositoryAlias(sessionId: string, id: string): Promise<void>
24
+ getRepositoryImportLocation(github: string, directory: string, filePrefix?: string): Promise<{ repoId: number | null; directory: string }>
25
+ }
26
+
27
+ // Keep expensive inventory reads outside database transactions and skip them
28
+ // unless a currently connected alias can use the bundle's existing paths.
29
+ export async function resolveRepositoryImportLocation(db: ManagedDb, github: string, directory: string, loadFilePrefix: () => Promise<string>) {
30
+ const aliases = await db.listRepositoryAliases()
31
+ let filePrefix = ''
32
+ if (repositoryAliasNeedsFilePrefix(github, directory, aliases)) {
33
+ const active = new Set((await db.listSelectedRepos()).map(repo => repo.repoId))
34
+ if (repositoryAliasNeedsFilePrefix(github, directory, aliases.filter(alias => active.has(alias.repoId)))) filePrefix = await loadFilePrefix()
35
+ }
36
+ return db.getRepositoryImportLocation(github, directory, filePrefix)
37
+ }
38
+
39
+ function parseAlias(input: unknown): RepositoryAliasInput {
40
+ if (!input || typeof input !== 'object') throw new ManagedMutationError(400, 'bad-alias')
41
+ const { oldRepo, oldPath, repoId, newPath } = input as RepositoryAliasInput
42
+ const github = typeof oldRepo === 'string' ? reportRepoGithub({ repo: { github: oldRepo.trim() } }) : null
43
+ if (!github) throw new ManagedMutationError(400, 'bad-old-repo')
44
+ if (typeof oldPath !== 'string' || typeof newPath !== 'string') throw new ManagedMutationError(400, 'bad-directory')
45
+ const old = normalizeTeamPath(oldPath), target = normalizeTeamPath(newPath)
46
+ if (!old.ok || !target.ok) throw new ManagedMutationError(400, 'bad-directory')
47
+ if (!Number.isSafeInteger(repoId) || repoId <= 0) throw new ManagedMutationError(400, 'bad-repo')
48
+ return { oldRepo: github.toLowerCase(), oldPath: old.path ?? '', repoId, newPath: target.path ?? '' }
49
+ }
50
+
51
+ export function repositoryAliasMethods(db: ManagedSql): RepositoryAliasStore {
52
+ async function authorize(sessionId: string) {
53
+ const user = await db.prepare(`SELECT u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
54
+ WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, Date.now()) as { role: string } | undefined
55
+ if (!user) throw new ManagedMutationError(401, 'unauthenticated')
56
+ if (user.role !== 'admin') throw new ManagedMutationError(403, 'forbidden')
57
+ }
58
+ async function listRepositoryAliases() {
59
+ return await db.prepare(`SELECT id, old_repo AS oldRepo, old_path AS oldPath, repo_id AS repoId, new_path AS newPath
60
+ FROM managed_repository_alias ORDER BY old_repo, old_path, id`).all() as RepositoryAlias[]
61
+ }
62
+ return {
63
+ listRepositoryAliases,
64
+ async saveRepositoryAlias(sessionId, id, input) {
65
+ await authorize(sessionId)
66
+ const alias = parseAlias(input)
67
+ if (!await db.prepare('SELECT repo_id FROM managed_selected_repo WHERE repo_id = ? AND active = 1').get(alias.repoId)) throw new ManagedMutationError(400, 'bad-repo')
68
+ if (id && !await db.prepare('SELECT id FROM managed_repository_alias WHERE id = ?').get(id)) throw new ManagedMutationError(404, 'no-alias')
69
+ const duplicate = await db.prepare('SELECT id FROM managed_repository_alias WHERE old_repo = ? AND old_path = ?').get(alias.oldRepo, alias.oldPath) as { id: string } | undefined
70
+ if (duplicate && duplicate.id !== id) throw new ManagedMutationError(409, 'alias-exists')
71
+ const key = id ?? randomUUID()
72
+ await db.prepare(`INSERT INTO managed_repository_alias (id, old_repo, old_path, repo_id, new_path) VALUES (?, ?, ?, ?, ?)
73
+ ON CONFLICT(id) DO UPDATE SET old_repo = excluded.old_repo, old_path = excluded.old_path, repo_id = excluded.repo_id, new_path = excluded.new_path`)
74
+ .run(key, alias.oldRepo, alias.oldPath, alias.repoId, alias.newPath)
75
+ return { id: key, ...alias }
76
+ },
77
+ async deleteRepositoryAlias(sessionId, id) {
78
+ await authorize(sessionId)
79
+ if (!(await db.prepare('DELETE FROM managed_repository_alias WHERE id = ?').run(id)).changes) throw new ManagedMutationError(404, 'no-alias')
80
+ },
81
+ async getRepositoryImportLocation(github, directory, filePrefix = '') {
82
+ const alias = matchRepositoryAlias(github, directory, await listRepositoryAliases(), filePrefix)
83
+ const normalized = normalizeTeamPath(alias?.directory ?? directory)
84
+ if (!normalized.ok) throw new ManagedMutationError(400, 'bad-directory')
85
+ const repo = await (alias
86
+ ? db.prepare('SELECT repo_id AS repoId FROM managed_selected_repo WHERE active = 1 AND repo_id = ?').get(alias.repoId)
87
+ : db.prepare('SELECT repo_id AS repoId FROM managed_selected_repo WHERE active = 1 AND LOWER(full_name) = ?').get(github.toLowerCase())) as { repoId: number } | undefined
88
+ return { repoId: repo?.repoId ?? null, directory: normalized.path ?? '' }
89
+ },
90
+ }
91
+ }
@@ -0,0 +1,153 @@
1
+ import type { ManagedConfig } from './config.ts'
2
+ import type { SelectedRepo } from './db.ts'
3
+ import { GithubApiError, githubJson, githubRepoReadPermission, githubUserIdentity, repoAccessToken } from './github-app.ts'
4
+ import { MAX_PACKAGE_BYTES, readPackageEntryPoints } from './package-entry-points.ts'
5
+ import { readSolidityEntryPoints } from './solidity-entry-points.ts'
6
+ import { parseGithubCommit } from './bundle-commits.ts'
7
+
8
+ const REFS_PAGE = 100
9
+
10
+ export interface RepositoryEntry { name: string; path: string; type: 'dir' | 'file' | 'symlink' | 'submodule' }
11
+
12
+ // Restricted managers can walk the ancestors of their grants, without seeing
13
+ // sibling names or files outside those grants. null means the entire repo.
14
+ export function scopedDirectory(path: string, scopes: (string | null)[]): RepositoryEntry[] | null {
15
+ if (scopes.some(scope => !scope || path === scope || path.startsWith(scope + '/'))) return null
16
+ const prefix = path ? path + '/' : ''
17
+ const names = new Set(scopes.filter((scope): scope is string => !!scope && scope.startsWith(prefix)).map(scope => scope.slice(prefix.length).split('/')[0]!))
18
+ return [...names].toSorted().map(name => ({ name, path: prefix + name, type: 'dir' }))
19
+ }
20
+
21
+ export interface RepositoryBrowserUser { githubUserId: number | null; token: string | null }
22
+ type GithubIdentity = Awaited<ReturnType<typeof githubUserIdentity>>
23
+
24
+ function credentialFailure(err: unknown): err is GithubApiError {
25
+ return err instanceof GithubApiError && ([401, 404].includes(err.status) || err.message === 'github-status-403')
26
+ }
27
+
28
+ // Create once per HTTP request. Coalesce concurrent credential lookups without
29
+ // caching repository visibility or permissions, or sharing credentials across users.
30
+ export function createRepositoryBrowser(config: ManagedConfig, user: RepositoryBrowserUser, fetchImpl: typeof fetch = globalThis.fetch) {
31
+ const tokens = new Map<number, Promise<string | null>>()
32
+ let identity: Promise<GithubIdentity> | undefined
33
+ const getIdentity = (fresh = false): Promise<GithubIdentity> => {
34
+ if (!user.token || user.githubUserId == null) return Promise.reject(new GithubApiError(404, 'no-repository'))
35
+ if (!identity || fresh) {
36
+ identity = githubUserIdentity(user.token, fetchImpl).then(value => {
37
+ if (value.id !== user.githubUserId) throw new GithubApiError(404, 'no-repository')
38
+ return value
39
+ })
40
+ }
41
+ return identity
42
+ }
43
+ return {
44
+ reader(repo: SelectedRepo) {
45
+ const installationToken = () => {
46
+ if (repo.installationId == null) return Promise.resolve(null)
47
+ const cached = tokens.get(repo.installationId)
48
+ const token = cached ?? repoAccessToken(config, repo.installationId, fetchImpl).catch((err: unknown) => {
49
+ if (!credentialFailure(err)) throw err
50
+ // Stale installations cannot prevent verified public access.
51
+ return null
52
+ })
53
+ if (!cached) tokens.set(repo.installationId, token)
54
+ return token
55
+ }
56
+ return repositoryReader(repo, user.token, installationToken, getIdentity, fetchImpl)
57
+ },
58
+ }
59
+ }
60
+
61
+ async function repositoryReader(repo: SelectedRepo, userToken: string | null, installationToken: () => Promise<string | null>, getIdentity: (fresh?: boolean) => Promise<GithubIdentity>, fetchImpl: typeof fetch) {
62
+ const base = `https://api.github.com/repos/${repo.fullName.split('/').map(encodeURIComponent).join('/')}`
63
+ // Public reads can use the signed-in user's quota without repository grants.
64
+ // Nonpublic reads still require the repository App and the effective-user gate.
65
+ let token = userToken ?? await installationToken()
66
+ const read = (suffix: string) => githubJson(base + suffix, token, fetchImpl)
67
+ const metadata = async () => {
68
+ try { return await read('') }
69
+ catch (err) {
70
+ if (!token || !credentialFailure(err)) throw err
71
+ const fallback = await installationToken()
72
+ if (fallback && fallback !== token) {
73
+ token = fallback
74
+ try { return await read('') } catch (fallbackError) { if (!credentialFailure(fallbackError)) throw fallbackError }
75
+ }
76
+ // Anonymous fallback is only for unavailable credentials, never rate
77
+ // limits, network errors, or GitHub outages.
78
+ token = null
79
+ return read('')
80
+ }
81
+ }
82
+ const checkAccess = async (freshIdentity = false) => {
83
+ // Stored visibility can be stale (or internal with private=false). Require
84
+ // current, explicit public visibility or the caller's effective permission.
85
+ const data = await metadata() as { id?: unknown; full_name?: unknown; private?: unknown; visibility?: unknown; default_branch?: unknown }
86
+ if (data?.id !== repo.repoId || typeof data.full_name !== 'string' || data.full_name.toLowerCase() !== repo.fullName.toLowerCase()) {
87
+ throw new GithubApiError(404, 'no-repository')
88
+ }
89
+ if (data.private === false && data.visibility === 'public') return data
90
+ if (!token) throw new GithubApiError(404, 'no-repository')
91
+ const appToken = await installationToken()
92
+ if (!appToken) throw new GithubApiError(404, 'no-repository')
93
+ if (!(await githubRepoReadPermission(repo.fullName, appToken, await getIdentity(freshIdentity), fetchImpl))) {
94
+ throw new GithubApiError(404, 'no-repository')
95
+ }
96
+ token = appToken
97
+ return data
98
+ }
99
+ const current = await checkAccess()
100
+ const defaultBranch = typeof current.default_branch === 'string' ? current.default_branch : ''
101
+ return {
102
+ recheckAccess: () => checkAccess(true),
103
+ // Server-only credential, selected by the same live visibility/permission
104
+ // gate as directory browsing. Never included in an HTTP response.
105
+ readToken: () => token,
106
+ async refs() {
107
+ // Suggestions are bounded; the input also accepts any branch or tag name.
108
+ const list = async (kind: string) => {
109
+ const data = await read(`/${kind}?per_page=${REFS_PAGE}`)
110
+ if (!Array.isArray(data)) throw new GithubApiError(502, 'github-malformed')
111
+ return data
112
+ }
113
+ const [branches, tags] = await Promise.all([list('branches'), list('tags')])
114
+ const names = (items: { name?: unknown }[]) => items.flatMap(item => typeof item?.name === 'string' ? [item.name] : [])
115
+ // Each tag's commit, for the tag cache. A full page may leave tags
116
+ // out; only a shorter one lists every tag.
117
+ const tagCommits = tags.flatMap(item => typeof item?.name === 'string' && item.name && typeof item.commit?.sha === 'string'
118
+ && /^[a-f\d]{40}$/u.test(item.commit.sha) ? [{ name: item.name, sha: item.commit.sha }] : [])
119
+ return { defaultBranch, branches: names(branches), tags: names(tags), tagCommits, tagsComplete: tags.length < REFS_PAGE }
120
+ },
121
+ async commit(ref: string) {
122
+ if (/^[a-f\d]{40}$/iu.test(ref)) return ref
123
+ const data = await read(`/commits/${encodeURIComponent(ref || (defaultBranch ? `heads/${defaultBranch}` : 'HEAD'))}`) as { sha?: unknown }
124
+ if (typeof data?.sha !== 'string' || !/^[a-f\d]{40}$/iu.test(data.sha)) throw new GithubApiError(502, 'github-malformed')
125
+ return data.sha
126
+ },
127
+ // The first commit a listing from `sha` returns is that commit, without
128
+ // the files GET /commits/:sha adds.
129
+ async commitDetails(sha: string) {
130
+ return /^[a-f\d]{40}$/u.test(sha) ? parseGithubCommit(sha, await read(`/commits?sha=${sha}&per_page=1`)) : null
131
+ },
132
+ async directory(path: string, commit: string) {
133
+ const data = await read(`/contents/${path.split('/').map(encodeURIComponent).join('/')}?ref=${encodeURIComponent(commit)}`)
134
+ if (!Array.isArray(data)) throw new GithubApiError(400, 'not-a-directory')
135
+ const entries: RepositoryEntry[] = []
136
+ for (const item of data) {
137
+ if (typeof item?.name !== 'string' || item.name.includes('/') || item.name === '.' || item.name === '..') continue
138
+ const expectedPath = path ? `${path}/${item.name}` : item.name
139
+ if (item.path !== expectedPath) continue
140
+ const type = item.submodule_git_url ? 'submodule' : item.type
141
+ if (['dir', 'file', 'symlink', 'submodule'].includes(type)) entries.push({ name: item.name, path: expectedPath, type })
142
+ }
143
+ const manifestPath = path ? `${path}/package.json` : 'package.json'
144
+ const manifest = data.find(item => item?.path === manifestPath && item.name === 'package.json' && item.type === 'file' && !item.submodule_git_url
145
+ && typeof item.size === 'number' && item.size >= 0 && item.size <= MAX_PACKAGE_BYTES && typeof item.sha === 'string' && /^[a-f\d]{40}$/iu.test(item.sha))
146
+ const packageEntryPoints = manifest ? await readPackageEntryPoints(manifest.sha, path, read) : undefined
147
+ const solidity = await readSolidityEntryPoints(path, data, read)
148
+ return { entries, limited: data.length >= 1000, ...(packageEntryPoints ? { packageEntryPoints } : {}),
149
+ ...(solidity.paths.length > 0 || solidity.limited ? { solidityEntryPoints: solidity.paths, soliditySuggestionsLimited: solidity.limited } : {}),
150
+ }
151
+ },
152
+ }
153
+ }
@@ -0,0 +1,82 @@
1
+ import { createHash } from 'node:crypto'
2
+ import type { ManagedConfig } from './config.ts'
3
+ import { GithubApiError, filterInstalledRepos, listInstalledRepos, listUserRepos } from './github-app.ts'
4
+ import type { ConnectedRepo, RepoListing } from './github-app.ts'
5
+
6
+ const CACHE_MS = 60_000
7
+ const MAX_ENTRIES = 100
8
+ interface Entry {
9
+ expires: number
10
+ pending: boolean
11
+ value: Promise<ConnectedRepo[]>
12
+ }
13
+
14
+ // One directory per HTTP handler. Searches/pages reuse discovery, but selected
15
+ // flags still come from the database on every request. User results are scoped
16
+ // to both the managed identity and its current GitHub credential. This cache is
17
+ // only for discovery; connecting a repo still verifies fresh installation access.
18
+ export class RepositoryDiscovery {
19
+ private readonly cache = new Map<string, Entry>()
20
+ private readonly config: ManagedConfig
21
+ private readonly fetchImpl: typeof fetch
22
+ private readonly now: () => number
23
+
24
+ constructor(config: ManagedConfig, fetchImpl: typeof fetch = (...args) => globalThis.fetch(...args), now: () => number = Date.now) {
25
+ this.config = config
26
+ this.fetchImpl = fetchImpl
27
+ this.now = now
28
+ }
29
+
30
+ private read(key: string, load: () => Promise<ConnectedRepo[]>): Promise<ConnectedRepo[]> {
31
+ const cached = this.cache.get(key)
32
+ if (cached && (cached.pending || cached.expires > this.now())) return cached.value
33
+ this.cache.delete(key)
34
+ while (this.cache.size >= MAX_ENTRIES) this.cache.delete(this.cache.keys().next().value!)
35
+ const entry: Entry = { expires: 0, pending: true, value: Promise.resolve([]) }
36
+ entry.value = load().then(repos => {
37
+ entry.expires = this.now() + CACHE_MS
38
+ entry.pending = false
39
+ return repos
40
+ }, (err: unknown) => {
41
+ if (this.cache.get(key) === entry) this.cache.delete(key)
42
+ throw err
43
+ })
44
+ this.cache.set(key, entry)
45
+ return entry.value
46
+ }
47
+
48
+ async list(scope: 'installed' | 'public', userId: string, token: string | null, showAll = false, refresh = false): Promise<RepoListing> {
49
+ // Invalidate derived user results along with the installation catalogue.
50
+ // An older in-flight read cannot repopulate this map after refresh.
51
+ if (refresh) this.cache.clear()
52
+ const installed = () => this.read('installed', () => listInstalledRepos(this.config, this.fetchImpl))
53
+ if (scope === 'installed' && showAll) return { repositories: await installed(), tokenMissing: false }
54
+ const publicInstalled = async (): Promise<ConnectedRepo[]> => (await installed()).filter(repo => repo.visibility === 'public')
55
+ if (token == null) return { repositories: scope === 'installed' ? await publicInstalled() : [], tokenMissing: true }
56
+ const credential = createHash('sha256').update(token).digest('hex')
57
+ const key = JSON.stringify([scope, userId, credential])
58
+ try {
59
+ if (scope === 'installed') {
60
+ const repositories = await this.read(key, async () => filterInstalledRepos(this.config, await installed(), token, this.fetchImpl))
61
+ return { repositories, tokenMissing: false }
62
+ }
63
+ // Installations only deduplicate the public picker. Their failure must
64
+ // not block otherwise readable public repos or look like a stale login.
65
+ // Cache the two sources separately so failed installation discovery is
66
+ // retried without caching a degraded combined list for another minute.
67
+ const [userRepos, installedRepos] = await Promise.all([
68
+ this.read(key, () => listUserRepos(token)),
69
+ installed().catch((err: unknown) => {
70
+ console.warn('managed: installed repo deduplication failed:', err)
71
+ return []
72
+ }),
73
+ ])
74
+ const installedIds = new Set(installedRepos.map(repo => repo.id))
75
+ const repositories = userRepos.filter(repo => repo.visibility === 'public' && !installedIds.has(repo.id))
76
+ return { repositories, tokenMissing: false }
77
+ } catch (err) {
78
+ if (!(err instanceof GithubApiError) || err.status !== 401) throw err
79
+ return { repositories: scope === 'installed' ? await publicInstalled() : [], tokenMissing: true }
80
+ }
81
+ }
82
+ }
@@ -0,0 +1,27 @@
1
+ import type { StoredUser } from './db.ts'
2
+
3
+ const WHITEHAT = [
4
+ 291301, // ChALkeR
5
+ 259122746, // exo-nikita
6
+ 247161625, // ex0sec
7
+ ]
8
+ const whitehatIds = new Set(WHITEHAT)
9
+
10
+ // (1) Server permission to add repositories. Keep this independent of GitHub
11
+ // identity and ecosystem exceptions if other install modes broaden it later.
12
+ export function canAddRepositories(user: StoredUser): boolean {
13
+ return user.role === 'admin'
14
+ }
15
+
16
+ // (3b) Only the ecosystem safeguard for public repositories is bypassed by
17
+ // WHITEHAT. Callers must separately establish (0) server readability, (1) add
18
+ // permission, and (2) server admin or GitHub access to this repository.
19
+ export function passesPublicRepositorySafeguard(githubUserId: number | null, involved: boolean): boolean {
20
+ return involved || (githubUserId != null && whitehatIds.has(githubUserId))
21
+ }
22
+
23
+ // Arbitrary public repositories have no involvement evidence, so (2) requires
24
+ // admin even if (1) expands in the future. This capability is also the UI gate.
25
+ export function canAddAnyPublicRepository(user: StoredUser, githubUserId: number | null): boolean {
26
+ return canAddRepositories(user) && user.role === 'admin' && passesPublicRepositorySafeguard(githubUserId, false)
27
+ }