@preventive/triage 1.0.0-alpha.33 → 1.0.0-alpha.34

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 (48) hide show
  1. package/client/linked-findings.js +5 -4
  2. package/common/bundle-metadata.js +4 -3
  3. package/common/bundle-source-links.d.ts +2 -1
  4. package/common/bundle-source-links.js +133 -6
  5. package/common/bundle-sources.d.ts +2 -0
  6. package/common/bundle-sources.js +2 -4
  7. package/common/managed/repository-alias.ts +65 -0
  8. package/common/managed/routes.js +20 -1
  9. package/common/managed/triage.ts +2 -2
  10. package/out/client-managed-import.js +51 -46
  11. package/out/client-managed.js +252 -72
  12. package/out/client-sync.js +14 -14
  13. package/out/graph.js +11 -11
  14. package/out/index.html +1 -0
  15. package/out/terminal.js +169 -169
  16. package/out/view.css +1 -1
  17. package/out/view.js +410 -202
  18. package/package.json +22 -17
  19. package/server-managed/activity.ts +15 -1
  20. package/server-managed/bundle-advisories.ts +8 -3
  21. package/server-managed/bundle-advisory-inventory.ts +27 -25
  22. package/server-managed/bundle-build-worker.js +45 -9
  23. package/server-managed/bundle-build.ts +35 -11
  24. package/server-managed/bundle-cache.ts +1 -1
  25. package/server-managed/bundle.ts +17 -1
  26. package/server-managed/config.ts +5 -0
  27. package/server-managed/db-methods.ts +168 -49
  28. package/server-managed/db-neon.ts +59 -4
  29. package/server-managed/db-schema.ts +12 -3
  30. package/server-managed/db.ts +9 -1
  31. package/server-managed/github-issue-links.ts +127 -0
  32. package/server-managed/github-issues.ts +1 -1
  33. package/server-managed/http-response.ts +7 -1
  34. package/server-managed/http.ts +248 -80
  35. package/server-managed/index.ts +4 -0
  36. package/server-managed/link-reports.ts +158 -0
  37. package/server-managed/managed-issues.ts +31 -3
  38. package/server-managed/management.ts +17 -4
  39. package/server-managed/public-workspace.ts +5 -2
  40. package/server-managed/report-sources.ts +6 -5
  41. package/server-managed/repository-aliases.ts +91 -0
  42. package/server-managed/revisions.ts +2 -2
  43. package/server-managed/sql.ts +3 -2
  44. package/server-managed/storage.ts +17 -0
  45. package/server-managed/team-feed.ts +3 -3
  46. package/server-managed/team-reports.ts +24 -15
  47. package/server-managed/upstream-cache.ts +73 -0
  48. package/server-managed/workspace-shares.ts +16 -12
@@ -41,13 +41,17 @@
41
41
  // GET /api/admin/teams → admin teams (+ members/repos) + pickers | 401/403
42
42
  // POST /api/admin/teams → admin creates a team | 401/403/409
43
43
  // POST /api/admin/teams/rename → admin renames a team | 401/403/404/409
44
+ // POST /api/admin/teams/set-hidden → admin hides/restores a team | 401/403/404
44
45
  // POST /api/admin/teams/delete → admin deletes a team | 401/403/404
45
46
  // POST /api/admin/teams/{set,remove}-repo → admin links/unlinks a repo (+path) | 401/403/404
46
47
  // POST /api/admin/teams/{set,remove}-member → admin links/unlinks a user (+perms) | 401/403/404
47
48
  // POST /api/auth/logout → same-origin + CSRF, drops the session
49
+ import { mergeLinkGroups } from './link-reports.ts'
50
+ import { backfillFindingIds, reportEntries } from '@preventive/report'
48
51
  import type { IncomingMessage, ServerResponse } from 'node:http'
49
52
  import { pipeline } from 'node:stream/promises'
50
53
  import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
54
+ import { auditCache } from './upstream-cache.ts'
51
55
  import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
52
56
  import { UPLOAD_CHUNK_BYTES, type UploadKind, deleteUpload, putUploadPart, readUpload, validUpload, validUploadPart } from './uploads.ts'
53
57
  import { type BundleCache, type BundleCachePart, MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
@@ -58,9 +62,10 @@ import { Buffer } from 'node:buffer'
58
62
  import { createHash, randomUUID } from 'node:crypto'
59
63
  import type { AvatarStore } from './avatar-store.ts'
60
64
  import type { BlobStore } from './blob-store.ts'
61
- import { bundleIntegrity, bundleKind, bundleRepo, reportBundleHashes } from './bundle.ts'
65
+ import { bundleFilePrefix, bundleIntegrity, bundleKind, bundleRepo, reportBundleHashes } from './bundle.ts'
66
+ import { resolveRepositoryImportLocation } from './repository-aliases.ts'
62
67
  import type { ManagedConfig } from './config.ts'
63
- import type { ManagedBundle, ManagedDb, ManagedSession, ReportRecord, SelectedRepo, StoredUser, TriageEventRow } from './db.ts'
68
+ import type { BundleBuildConditions, BundleProvenance, BundleReuse, ManagedBundle, ManagedDb, ManagedSession, ReportRecord, SelectedRepo, StoredUser, TriageEventRow } from './db.ts'
64
69
  import type { OriginGate } from '../server-common/origin.ts'
65
70
  import { isRole, roleAtLeast } from '../common/managed/roles.ts'
66
71
  import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
@@ -85,9 +90,11 @@ import { acceptsReportMetadata } from './report-response.ts'
85
90
  import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
86
91
  import { FINDING_CATALOG_PAGE_BYTES, FINDING_CATALOG_PAGE_COUNT, MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
87
92
  import { lookupFixes, storedFixUrls } from './github-pulls.ts'
93
+ import { lookupIssueLinks } from './github-issue-links.ts'
94
+ import { visibleManagedIssues } from './managed-issues.ts'
88
95
  import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
89
96
  import { ISSUE_LOGIN_PATH, isIssueOAuthCallback, issueLoginRedirect, issueOAuthCallback } from './github-issue-oauth.ts'
90
- import { sendJson, writeResponse } from './http-response.ts'
97
+ import { attachmentDisposition, sendJson, writeResponse } from './http-response.ts'
91
98
  import { triageWireEntry } from './triage-response.ts'
92
99
  import { handlePublicWorkspace } from './public-workspace.ts'
93
100
  import { serveUserTeamFeed } from './team-feed.ts'
@@ -119,6 +126,7 @@ const REPORT_PREFIX = '/api/admin/reports/'
119
126
  const ADMIN_BUNDLES_PATH = '/api/admin/bundles'
120
127
  const BUNDLE_CREATE_PATH = '/api/admin/bundles/create'
121
128
  const BUNDLE_SET_REPO_PATH = '/api/admin/bundles/set-repo'
129
+ const BUNDLE_SET_VISIBLE_PATH = '/api/admin/bundles/set-visible'
122
130
  const BUNDLE_PREFIX = '/api/admin/bundles/'
123
131
  const MY_TEAMS_PATH = '/api/teams'
124
132
  const MY_REPORT_PREFIX = '/api/reports/'
@@ -127,29 +135,39 @@ const MY_REPORT_TRIAGE_HISTORY_SUFFIX = '/triage/history'
127
135
  const ADMIN_TEAMS_PATH = '/api/admin/teams'
128
136
  const TEAM_DELETE_PATH = '/api/admin/teams/delete'
129
137
  const TEAM_RENAME_PATH = '/api/admin/teams/rename'
138
+ const TEAM_SET_HIDDEN_PATH = '/api/admin/teams/set-hidden'
130
139
  const TEAM_SET_REPO_PATH = '/api/admin/teams/set-repo'
131
140
  const TEAM_REMOVE_REPO_PATH = '/api/admin/teams/remove-repo'
132
141
  const TEAM_SET_MEMBER_PATH = '/api/admin/teams/set-member'
133
142
  const TEAM_REMOVE_MEMBER_PATH = '/api/admin/teams/remove-member'
134
143
  const MAX_TEAM_NAME = 100
135
144
 
136
- async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string): Promise<void> {
145
+ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, reportId: string | null): Promise<void> {
137
146
  const s = await readWorkspaceSession(res, deps, cookie)
138
147
  if (!s) return
139
- const snapshot = await teamSnapshot(deps.db, s.session.id, teamId)
148
+ const snapshot = await teamSnapshot(deps.db, s.session.id, teamId, reportId)
140
149
  const ids = [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
141
- const urls = storedFixUrls(await deps.db.listTriage(ids))
150
+ const manualUrls = storedFixUrls(await deps.db.listTriage(ids))
151
+ const storedIssues = await deps.db.listManagedIssues(ids)
152
+ const issues = visibleManagedIssues(storedIssues, snapshot)
153
+ const visibleIds = new Set(issues.map(issue => issue.findingId))
154
+ const urls = [...new Set([...manualUrls, ...issues.flatMap(issue => [issue.issueUrl!, ...(issue.autoFixUrl ? [issue.autoFixUrl] : [])])])]
142
155
  await recheckTeam(deps.db, s.session.id, snapshot)
143
- const fixes = await lookupFixes(deps.config, deps.db, snapshot, urls)
156
+ const result = issues.length > 0
157
+ ? await lookupIssueLinks(deps.config, deps.db, snapshot, urls, storedIssues.filter(issue => visibleIds.has(issue.findingId)))
158
+ : { fixes: await lookupFixes(deps.config, deps.db, snapshot, urls), updates: [] }
144
159
  // Cold report reads, token refresh and upstream batches can outlive changes
145
160
  // to security/links, team grants, publication, sessions or the Fix links.
146
161
  const currentUrls = storedFixUrls(await deps.db.listTriage(ids))
147
162
  if (await readWorkspaceSession(res, deps, cookie) == null) return
148
163
  await recheckTeam(deps.db, s.session.id, snapshot)
149
- if (JSON.stringify(currentUrls) !== JSON.stringify(urls)) {
164
+ if (JSON.stringify(currentUrls) !== JSON.stringify(manualUrls)) {
150
165
  sendJson(res, 404, { error: 'workspace-changed' }); return
151
166
  }
152
- sendJson(res, 200, { fixes })
167
+ if (result.updates.length > 0 && !await deps.db.applyManagedIssueFixes(s.session.id, snapshot, result.updates)) {
168
+ sendJson(res, 404, { error: 'workspace-changed' }); return
169
+ }
170
+ sendJson(res, 200, { fixes: result.fixes })
153
171
  }
154
172
 
155
173
  async function handleWorkspaceIssue(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps,
@@ -787,6 +805,19 @@ async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse,
787
805
  sendJson(res, 200, { ok: true, visible })
788
806
  }
789
807
 
808
+ async function handleSetBundleVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
809
+ const s = await manageMutation(req, res, deps, cookie)
810
+ if (s == null) return
811
+ let body: unknown
812
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
813
+ const bundleId = (body as { bundleId?: unknown } | null)?.bundleId
814
+ const visible = (body as { visible?: unknown } | null)?.visible
815
+ if (typeof bundleId !== 'string' || typeof visible !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
816
+ const { bundle, user } = await deps.db.mutateBundle(s.session.id, bundleId, { type: 'visibility', visible })
817
+ if (bundle.visible !== visible) await activity(deps, user, 'visibility', visible ? 'published a bundle' : 'hid a bundle', { bundleId, report: bundle.filename, repo: await repositoryName(deps, bundle.repoId) })
818
+ sendJson(res, 200, { ok: true, visible })
819
+ }
820
+
790
821
  // POST /api/admin/bundles/set-repo — attach / detach a stored bundle's repo link
791
822
  // and directory (same shape as reports). Body { bundleId, repoId, directory }.
792
823
  async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
@@ -870,7 +901,22 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
870
901
  if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
871
902
  const filename = sanitizeFilename(firstHeader(req.headers['x-report-filename']), 'report.json')
872
903
  const parsed = readManagedReport(bytes.toString('utf8'), filename)
873
- const repoGithub = parsed.data == null ? null : reportRepoGithub(parsed.data)
904
+ if (parsed.data == null) {
905
+ sendJson(res, 400, { error: 'invalid-report', reason: parsed.reason ?? 'Unrecognized report format' })
906
+ return
907
+ }
908
+ if (parsed.format === 'links' || parsed.data.source === 'links') {
909
+ const result = await deps.db.importLinkReport(s.session.id, filename, bytes.toString('utf8'))
910
+ sendJson(res, result.reused ? 200 : 201, { ...result.report, kind: 'links', deduped: result.reused })
911
+ return
912
+ }
913
+ const repoGithub = reportRepoGithub(parsed.data)
914
+ // The browser splits these documents before upload. Enforce that boundary
915
+ // for direct API clients too: one stored record has only one repo's ACL.
916
+ if (parsed.format === 'markdown-generic' && repoGithub == null) {
917
+ sendJson(res, 400, { error: 'invalid-report', reason: 'Multi-product Markdown must be split into one report per product before upload.' })
918
+ return
919
+ }
874
920
  const repoEmbedded = repoGithub != null
875
921
  const rawHeaderDirectory = firstHeader(req.headers['x-repo-directory']) ?? ''
876
922
  let headerDirectory = rawHeaderDirectory
@@ -880,23 +926,26 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
880
926
  const requestedDirectory = repoEmbedded ? parsed.data?.repo?.directory : headerDirectory
881
927
  const normalizedDirectory = normalizeTeamPath(requestedDirectory)
882
928
  if (!normalizedDirectory.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
883
- const directory = normalizedDirectory.path ?? ''
884
- const analyzer = parsed.data != null && typeof parsed.data.source === 'string' ? parsed.data.source : null
885
- const selected = await deps.db.listSelectedRepos()
886
- let matchedRepo = repoGithub == null ? null : selected.find((repo) => repo.fullName.toLocaleLowerCase() === repoGithub.toLocaleLowerCase())
887
- if (repoGithub != null && matchedRepo == null) { sendJson(res, 400, { error: 'repo-not-connected', repo: repoGithub }); return }
929
+ let directory = normalizedDirectory.path ?? ''
930
+ const analyzer = typeof parsed.data.source === 'string' ? parsed.data.source : null
931
+ const sha256 = createHash('sha256').update(bytes).digest('base64url')
932
+ // Reuploads retain their stored location, including after an alias changes.
933
+ const existing = await deps.db.getReportByHash(sha256, analyzer)
934
+ let repoId: number | null = null
935
+ if (repoGithub != null) {
936
+ const location = existing ? { repoId: existing.repoId, directory: existing.repoDirectory }
937
+ : await deps.db.getRepositoryImportLocation(repoGithub, directory)
938
+ repoId = location.repoId; directory = location.directory
939
+ if (!existing && repoId == null) { sendJson(res, 400, { error: 'repo-not-connected', repo: repoGithub }); return }
940
+ }
888
941
  // When the report has no repository header, X-Repo-Id lets the managed uploader
889
942
  // assign it at upload time. Clients may omit it and attach the report later.
890
943
  if (repoGithub == null) {
891
944
  const legacyRepo = await resolveUploadRepoId(req, res, deps)
892
945
  if (!legacyRepo.ok) return
893
- matchedRepo = legacyRepo.repoId == null ? null : selected.find((repo) => repo.repoId === legacyRepo.repoId) ?? null
946
+ repoId = legacyRepo.repoId
894
947
  }
895
- if (matchedRepo && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, matchedRepo.repoId, directory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
896
- const sha256 = createHash('sha256').update(bytes).digest('base64url')
897
- // The analyzer also distinguishes a CSV from the same bytes previously
898
- // uploaded under a filename that did not identify it as a report.
899
- const existing = await deps.db.getReportByHash(sha256, analyzer)
948
+ if (repoId != null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repoId, directory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
900
949
  if (existing) { await sendUploadedReport(req, res, deps, cookie, existing, true); return }
901
950
  const id = randomUUID()
902
951
  const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
@@ -906,7 +955,7 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
906
955
  try {
907
956
  report = await deps.db.insertOrReuseReport({
908
957
  id, filename, contentType, byteSize: bytes.length, sha256, dataKey,
909
- uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: matchedRepo?.repoId ?? null,
958
+ uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId,
910
959
  repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId, bundleIntegrity: integrity,
911
960
  }, Date.now(), s.session.id)
912
961
  } catch (err) {
@@ -938,13 +987,10 @@ async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cooki
938
987
  if (bytes == null) { sendJson(res, 503, { error: 'unavailable' }); return }
939
988
  const current = await readSession(deps.config, deps.db, cookie, Date.now())
940
989
  if (!current || !roleAtLeast(current.user.role, 'manage') || !(await canViewReport(deps, current.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
941
- // The stored filename is already control-/path-stripped (sanitizeReportFilename
942
- // at upload); only a double-quote could break the quoted Content-Disposition.
943
- const dispoName = rec.filename.replaceAll('"', '')
944
990
  res.writeHead(200, {
945
991
  'content-type': rec.contentType,
946
992
  'content-length': String(bytes.length),
947
- 'content-disposition': `attachment; filename="${dispoName}"`,
993
+ 'content-disposition': attachmentDisposition(rec.filename),
948
994
  'x-content-type-options': 'nosniff',
949
995
  'cache-control': 'no-store',
950
996
  })
@@ -973,7 +1019,7 @@ async function canAccessBundle(deps: ManagedHttpDeps, user: StoredUser, id: stri
973
1019
  if (!rec) return false
974
1020
  if (user.role === 'admin') return true
975
1021
  if (user.role === 'manage') return deps.db.userCanReadBundle(user.id, id)
976
- return rec.repoId !== null && deps.db.userCanReadRepoPath(user.id, rec.repoId, rec.repoDirectory)
1022
+ return rec.visible && rec.repoId !== null && deps.db.userCanReadRepoPath(user.id, rec.repoId, rec.repoDirectory)
977
1023
  }
978
1024
 
979
1025
  async function bundleRepos(deps: ManagedHttpDeps, user: StoredUser) {
@@ -1115,7 +1161,7 @@ async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps
1115
1161
 
1116
1162
  // Published dependency advisories require security access, independently of access to
1117
1163
  // unpublished dependency findings. Managers retain their normal bundle access.
1118
- async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, teamId: string | null, reason: string, repoAdvisories: boolean) {
1164
+ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, teamId: string | null, reason: string, repoAdvisories: boolean, details: boolean) {
1119
1165
  const authorize = async () => {
1120
1166
  const access = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id, teamId)
1121
1167
  if (!access) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
@@ -1147,7 +1193,9 @@ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps
1147
1193
  ...init, signal: init?.signal ? AbortSignal.any([controller.signal, init.signal]) : controller.signal,
1148
1194
  })) : null
1149
1195
  if (res.destroyed || (needsGithub && !(await authorize()))) return
1150
- const result = await fetchBundleAdvisories(inventory.packages, controller.signal, { debug: deps.config.debug, repoAdvisories, githubToken })
1196
+ const result = await fetchBundleAdvisories(inventory.packages, controller.signal, {
1197
+ debug: deps.config.debug, repoAdvisories, details, githubToken, cache: auditCache(deps.config.upstreamCacheDir, deps.db, controller.signal, deps.config.debug),
1198
+ })
1151
1199
  if (res.destroyed || !(await authorize())) return
1152
1200
  // Return just inventory and public advisories, never source or report data.
1153
1201
  sendJson(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body)
@@ -1176,10 +1224,18 @@ async function handleListBundles(res: ServerResponse, deps: ManagedHttpDeps, ses
1176
1224
  }
1177
1225
 
1178
1226
  type UploadedBundle = Pick<ManagedBundle, 'id' | 'slug' | 'integrity' | 'filename' | 'byteSize' | 'repoId' | 'repoDirectory'>
1179
- async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, bundle: UploadedBundle, deduped: boolean): Promise<void> {
1227
+ // `reuse` describes a repeated upload/build of an existing row (see reuseBundle).
1228
+ async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined,
1229
+ bundle: UploadedBundle, reuse?: Omit<BundleReuse, 'rename'>): Promise<void> {
1180
1230
  const s = await manageMutation(req, res, deps, cookie)
1181
1231
  if (!s) return
1182
- if (deduped && !(await canAccessBundle(deps, s.user, bundle.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
1232
+ const deduped = reuse != null
1233
+ if (deduped) {
1234
+ // Authorized in the same transaction as the rename. A server build labels
1235
+ // even a row the caller cannot see: the bytes are this server's own output.
1236
+ if (await deps.db.reuseBundleUpload(s.session.id, bundle.id, reuse)) bundle = await deps.db.getBundle(bundle.id) ?? bundle
1237
+ if (!(await canAccessBundle(deps, s.user, bundle.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
1238
+ }
1183
1239
  const { id, slug, integrity, filename, byteSize, repoId, repoDirectory } = bundle
1184
1240
  // Re-uploading also repairs reports uploaded while the bundle was inaccessible.
1185
1241
  await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
@@ -1214,6 +1270,7 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
1214
1270
  signal.throwIfAborted()
1215
1271
  const built = await buildRepositoryBundle(access.user.id, {
1216
1272
  input, github: access.repo.fullName, token: reader.readToken(), maxBytes: deps.config.maxBundleBytes, scopes: access.scopes,
1273
+ cacheDir: deps.config.upstreamCacheDir ?? null,
1217
1274
  }, signal)
1218
1275
  signal.throwIfAborted()
1219
1276
  await reader.recheckAccess()
@@ -1223,7 +1280,9 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
1223
1280
  throw new BundleBuildError(409, 'repository-changed')
1224
1281
  }
1225
1282
  signal.throwIfAborted()
1226
- await storeUploadedBundle(req, res, deps, cookie, current, Buffer.from(built.bytes), input.repoId, built.directory, built.filename)
1283
+ const bytes = Buffer.from(built.bytes)
1284
+ await storeUploadedBundle(req, res, deps, cookie, current, bytes, input.repoId, built.directory, built.filename, 'build',
1285
+ bundleIntegrity(bytes), input.conditions)
1227
1286
  })
1228
1287
  } catch (error) {
1229
1288
  if (res.destroyed || controller.signal.aborted) return
@@ -1241,7 +1300,8 @@ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, dep
1241
1300
  // X-Repo-Directory assign a repository location; Stasis repo headers default
1242
1301
  // to a matching connected repository within the uploader's access, otherwise
1243
1302
  // the bundle stays unattached. Its identity is its content hash (sha512), UNIQUE — a
1244
- // re-upload of identical bytes dedupes to the existing row (no second copy).
1303
+ // re-upload of identical bytes dedupes to the existing row (no second copy) and
1304
+ // renames it, unless the row was built on this server.
1245
1305
  // After storing, any reports that declared this integrity but weren't linked yet
1246
1306
  // get attached (auto-link). 413 over the cap, 400 on empty.
1247
1307
  async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
@@ -1275,12 +1335,18 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
1275
1335
  if (!existing && repo.repoId == null && kind === 'stasis') {
1276
1336
  const embedded = await bundleRepo(bytes)
1277
1337
  const github = reportRepoGithub({ repo: embedded })
1278
- const selected = github == null ? null : (await deps.db.listSelectedRepos())
1279
- .find(candidate => candidate.fullName.toLowerCase() === github.toLowerCase())
1280
- if (selected) {
1281
- repo.repoId = selected.repoId
1338
+ const embeddedDirectory = normalizeTeamPath(embedded?.directory)
1339
+ // Invalid optional metadata must not reject an otherwise unattached bundle.
1340
+ // Only repository-wide aliases/direct connections can resolve without a
1341
+ // valid directory; do not decode file paths or match directory aliases.
1342
+ const location = github == null ? null : embeddedDirectory.ok
1343
+ ? await resolveRepositoryImportLocation(deps.db, github, embeddedDirectory.path ?? '', () => bundleFilePrefix(bytes))
1344
+ : await deps.db.getRepositoryImportLocation(github, '')
1345
+ if (location?.repoId != null) {
1346
+ repo.repoId = location.repoId
1282
1347
  if (req.headers['x-repo-directory'] == null) {
1283
- rawDirectory = embedded?.directory ?? ''
1348
+ if (!embeddedDirectory.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
1349
+ rawDirectory = location.directory
1284
1350
  inferredLocation = true
1285
1351
  }
1286
1352
  }
@@ -1297,19 +1363,21 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
1297
1363
  repo.repoId = null
1298
1364
  directory = ''
1299
1365
  }
1300
- await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, integrity)
1366
+ await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, 'upload', integrity)
1301
1367
  }
1302
1368
 
1303
1369
  async function storeUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined,
1304
- s: { session: ManagedSession; user: StoredUser }, bytes: Buffer, repoId: number | null, directory: string, filename: string, integrity = bundleIntegrity(bytes)): Promise<void> {
1370
+ s: { session: ManagedSession; user: StoredUser }, bytes: Buffer, repoId: number | null, directory: string, filename: string,
1371
+ provenance: BundleProvenance, integrity = bundleIntegrity(bytes), buildConditions: BundleBuildConditions | null = null): Promise<void> {
1372
+ const kind = bundleKind(filename)
1373
+ const reuse = { provenance, filename, kind, buildConditions }
1305
1374
  const existing = await deps.db.getBundleByIntegrity(integrity)
1306
- if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, true); return }
1375
+ if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, reuse); return }
1307
1376
  const id = randomUUID()
1308
- const kind = bundleKind(filename)
1309
1377
  const dataKey = await deps.bundleStore.put(id, bytes, kind)
1310
1378
  try {
1311
1379
  await deps.db.insertBundle({
1312
- id, integrity, filename, kind, dataKey,
1380
+ id, integrity, filename, kind, dataKey, provenance, buildConditions,
1313
1381
  byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId, repoDirectory: directory,
1314
1382
  }, Date.now(), s.session.id)
1315
1383
  } catch (err) {
@@ -1324,12 +1392,12 @@ async function storeUploadedBundle(req: IncomingMessage, res: ServerResponse, de
1324
1392
  if (raced?.id !== id) await deps.bundleStore.delete(id).catch(() => {})
1325
1393
  if (err instanceof ManagedMutationError) throw err
1326
1394
  if (!raced) throw err
1327
- if (raced.id !== id) { await sendUploadedBundle(req, res, deps, cookie, raced, true); return }
1395
+ if (raced.id !== id) { await sendUploadedBundle(req, res, deps, cookie, raced, reuse); return }
1328
1396
  // Our insert committed: continue with the ordinary creation response.
1329
1397
  }
1330
1398
  const stored = await deps.db.getBundle(id)
1331
1399
  if (!stored) { sendJson(res, 404, { error: 'no-bundle' }); return }
1332
- await sendUploadedBundle(req, res, deps, cookie, stored, false)
1400
+ await sendUploadedBundle(req, res, deps, cookie, stored)
1333
1401
  }
1334
1402
 
1335
1403
  // GET /api/admin/bundles/<id> — download a stored bundle (admin|manage). Bytes
@@ -1348,12 +1416,11 @@ async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps:
1348
1416
  stored.stream.destroy()
1349
1417
  sendJson(res, current ? 404 : 401, { error: 'no-bundle' }); return
1350
1418
  }
1351
- const dispoName = rec.filename.replaceAll('"', '')
1352
1419
  res.writeHead(200, {
1353
1420
  'content-type': 'application/octet-stream',
1354
1421
  ...(rec.kind === 'sourcemap' ? { 'content-encoding': 'br' } : {}),
1355
1422
  ...(stored.size == null ? {} : { 'content-length': String(stored.size) }),
1356
- 'content-disposition': `attachment; filename="${dispoName}"`,
1423
+ 'content-disposition': attachmentDisposition(rec.filename),
1357
1424
  'x-content-type-options': 'nosniff',
1358
1425
  'cache-control': 'no-store',
1359
1426
  })
@@ -1470,6 +1537,73 @@ async function handleReportSources(req: IncomingMessage, res: ServerResponse, de
1470
1537
  try { await pipeline(cached.stream, res) } catch { res.destroy() }
1471
1538
  }
1472
1539
 
1540
+ async function reportIds(data: unknown): Promise<Set<string>> {
1541
+ const findings = (reportEntries(data) ?? []).flat().filter((f): f is Record<string, unknown> => f !== null && typeof f === 'object' && !Array.isArray(f))
1542
+ await backfillFindingIds(findings)
1543
+ return new Set(findings.flatMap(f => typeof f['id'] === 'string' ? [f['id']] : []))
1544
+ }
1545
+
1546
+ async function handleDeduplication(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
1547
+ const method = req.method ?? 'GET'
1548
+ if (!(id ? ['GET', 'PATCH'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'GET, PATCH' : 'GET, POST'); return }
1549
+ const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
1550
+ if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
1551
+ if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
1552
+ if (method === 'GET') { sendJson(res, 200, id ? await deps.db.getLinkReport(s.session.id, id) : { reports: await deps.db.listLinkReports() }); return }
1553
+ if (id) {
1554
+ let body
1555
+ try { body = await readJsonBody(req, 1024) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1556
+ const enabled = (body as { enabled?: unknown } | null)?.enabled
1557
+ if (typeof enabled !== 'boolean') { sendJson(res, 400, { error: 'bad-enabled' }); return }
1558
+ await deps.db.setLinkReportEnabled(s.session.id, id, enabled)
1559
+ sendJson(res, 200, { ok: true }); return
1560
+ }
1561
+ let bytes: Buffer
1562
+ try { bytes = await readBodyBytes(req, deps.config.maxReportBytes) }
1563
+ catch (error) { const large = error instanceof Error && error.message === 'too-large'; sendJson(res, large ? 413 : 400, { error: large ? 'too-large' : 'bad-body' }); return }
1564
+ const filename = sanitizeFilename(firstHeader(req.headers['x-report-filename']), 'report.link.json')
1565
+ const result = await deps.db.importLinkReport(s.session.id, filename, bytes.toString('utf8'))
1566
+ sendJson(res, result.reused ? 200 : 201, { ...result.report, deduped: result.reused })
1567
+ }
1568
+
1569
+ async function handleRepositoryAliases(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id?: string): Promise<void> {
1570
+ const method = req.method ?? 'GET'
1571
+ if (!(id ? ['PATCH', 'DELETE'] : ['GET', 'POST']).includes(method)) { send405(res, id ? 'PATCH, DELETE' : 'GET, POST'); return }
1572
+ const s = method === 'GET' ? await readAdminSession(res, deps, cookie) : await checkMutation(req, res, deps, cookie)
1573
+ if (!s) return
1574
+ if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
1575
+ if (method === 'GET') {
1576
+ sendJson(res, 200, { aliases: await deps.db.listRepositoryAliases(), repos: await deps.db.listAllRepos() }); return
1577
+ }
1578
+ if (method === 'DELETE') {
1579
+ await deps.db.deleteRepositoryAlias(s.session.id, id!)
1580
+ sendJson(res, 200, { ok: true }); return
1581
+ }
1582
+ let body
1583
+ try { body = await readJsonBody(req, 8192) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1584
+ const alias = await deps.db.saveRepositoryAlias(s.session.id, id ?? null, body)
1585
+ sendJson(res, id ? 200 : 201, alias)
1586
+ }
1587
+
1588
+ // Live suggestions do not mutate archived content or its stored assignment.
1589
+ // Managers can resolve only destinations their current repository grants cover.
1590
+ async function handleRepositorySuggestion(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1591
+ if (req.method !== 'GET') { send405(res, 'GET'); return }
1592
+ if (!await readManageSession(res, deps, cookie)) return
1593
+ const params = new URL(req.url!, 'http://localhost').searchParams
1594
+ const github = reportRepoGithub({ repo: { github: params.get('repo') } })
1595
+ const directory = normalizeTeamPath(params.get('directory'))
1596
+ const filePrefix = normalizeTeamPath(params.get('filePrefix'))
1597
+ if (!github || !directory.ok || !filePrefix.ok) { sendJson(res, 400, { error: 'bad-location' }); return }
1598
+ const location = await deps.db.getRepositoryImportLocation(github, directory.path ?? '', filePrefix.path ?? '')
1599
+ const repo = location.repoId == null ? null : (await deps.db.listSelectedRepos()).find(row => row.repoId === location.repoId)
1600
+ const s = await readManageSession(res, deps, cookie)
1601
+ if (!s) return
1602
+ const allowed = repo && (s.user.role === 'admin' || await deps.db.userCanReadRepoPath(s.user.id, repo.repoId, location.directory))
1603
+ sendJson(res, 200, { location: allowed ? { repoId: repo.repoId, github: repo.fullName, directory: location.directory,
1604
+ mapped: github.toLowerCase() !== repo.fullName.toLowerCase() || (directory.path ?? '') !== location.directory } : null })
1605
+ }
1606
+
1473
1607
  // GET /api/reports/<id> — admin/manager preview, within management access.
1474
1608
  // Accept: application/json includes parsed, filtered data and the server's repo
1475
1609
  // assignment. Other callers retain the raw text/plain response. The
@@ -1479,32 +1613,33 @@ async function handleReportSources(req: IncomingMessage, res: ServerResponse, de
1479
1613
  async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1480
1614
  const s = await readManageSession(res, deps, cookie)
1481
1615
  if (s == null) return
1482
- if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1616
+ const snapshot = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), [id])
1617
+ const access = snapshot?.reports[0]
1618
+ if (!snapshot || !access || !roleAtLeast(snapshot.user.role, 'manage')) { sendJson(res, 404, { error: 'no-report' }); return }
1483
1619
  const bytes = await deps.reportStore.get(id)
1484
1620
  if (bytes == null) { sendJson(res, 503, { error: 'unavailable' }); return }
1485
- const current = await readSession(deps.config, deps.db, cookie, Date.now())
1486
- if (!current || !roleAtLeast(current.user.role, 'manage') || !(await canViewReport(deps, current.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1487
- if (acceptsReportMetadata(req.headers.accept)) {
1488
- const report = await deps.db.getReport(id)
1489
- if (report == null) { sendJson(res, 404, { error: 'no-report' }); return }
1490
- const repo = { github: await repositoryName(deps, report.repoId), directory: report.repoDirectory }
1491
- const { data } = readManagedReport(bytes.toString('utf8'), report.filename)
1621
+ const metadata = acceptsReportMetadata(req.headers.accept)
1622
+ let envelope
1623
+ if (metadata) {
1624
+ const { data } = readManagedReport(bytes.toString('utf8'), access.filename)
1492
1625
  if (data == null) { sendJson(res, 422, { error: 'unreadable-report' }); return }
1493
- sendJson(res, 200, {
1494
- data,
1495
- repo,
1496
- }, { vary: 'Accept', 'x-content-type-options': 'nosniff' })
1497
- return
1626
+ const links = mergeLinkGroups(snapshot.linkRevision ? await deps.db.getEnabledLinkGroups(snapshot.linkRevision) : [], await reportIds(data))
1627
+ envelope = { data, repo: access.repo, ...(links.length > 0 ? { links } : {}) }
1628
+ }
1629
+ // Link decryption and finding-ID backfill can outlast access or link changes.
1630
+ const current = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), [id])
1631
+ if (!current || current.user.id !== snapshot.user.id || current.user.role !== snapshot.user.role
1632
+ || current.linkRevision !== snapshot.linkRevision || JSON.stringify(current.reports) !== JSON.stringify(snapshot.reports)) {
1633
+ sendJson(res, 404, { error: 'no-report' }); return
1634
+ }
1635
+ if (metadata) {
1636
+ sendJson(res, 200, envelope, { vary: 'Accept', 'x-content-type-options': 'nosniff' }); return
1498
1637
  }
1499
- const out = bytes
1500
1638
  res.writeHead(200, {
1501
- 'content-type': 'text/plain; charset=utf-8',
1502
- 'content-length': String(out.length),
1503
- 'x-content-type-options': 'nosniff',
1504
- 'cache-control': 'no-store',
1505
- vary: 'Accept',
1639
+ 'content-type': 'text/plain; charset=utf-8', 'content-length': String(bytes.length),
1640
+ 'x-content-type-options': 'nosniff', 'cache-control': 'no-store', vary: 'Accept',
1506
1641
  })
1507
- writeResponse(res, out)
1642
+ writeResponse(res, bytes)
1508
1643
  }
1509
1644
 
1510
1645
  // A read-only POST avoids URL-length limits when a workspace has many reports.
@@ -1533,6 +1668,7 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
1533
1668
  // Overlap remote blob reads, retaining only each report's encoded response
1534
1669
  // after processing. Check actual bytes too if storage and metadata disagree.
1535
1670
  const parts = [Buffer.from('{"reports":[')]
1671
+ const findingIds = new Set<string>()
1536
1672
  let inputBytes = 0, outputBytes = parts[0]!.length + 2
1537
1673
  let next = 0, stopped = false
1538
1674
  let inFlightBytes = 0
@@ -1562,6 +1698,7 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
1562
1698
  if (inputBytes > MAX_REPORT_QUERY_BYTES) return fail(413, 'batch-too-large')
1563
1699
  const { data } = readManagedReport(bytes.toString('utf8'), access.filename)
1564
1700
  if (!data) return fail(422, 'unreadable-report')
1701
+ for (const findingId of await reportIds(data)) findingIds.add(findingId)
1565
1702
  const part = Buffer.from(`${index > 0 ? ',' : ''}${JSON.stringify({ id, data: filterReportData(data, access.permissions, access.repo), repo: access.repo })}`)
1566
1703
  outputBytes += part.length
1567
1704
  if (outputBytes > MAX_REPORT_QUERY_BYTES) return fail(413, 'batch-too-large')
@@ -1586,23 +1723,27 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
1586
1723
  if (result.status === 'rejected') throw result.reason
1587
1724
  if (result.value) { sendJson(res, result.value.status, { error: result.value.error }); return }
1588
1725
  }
1726
+ const links = mergeLinkGroups(snapshot.linkRevision ? await deps.db.getEnabledLinkGroups(snapshot.linkRevision) : [], findingIds)
1589
1727
  // Access to an earlier report may change while a later blob is fetched.
1590
1728
  // Reject the whole answer if any content/access/assignment snapshot changed.
1591
1729
  const current = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), ids)
1592
1730
  if (!current || current.user.id !== snapshot.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1593
- if (current.user.role !== snapshot.user.role || current.reports.length !== reports.size
1731
+ if (current.linkRevision !== snapshot.linkRevision || current.user.role !== snapshot.user.role || current.reports.length !== reports.size
1594
1732
  || current.reports.some(report => JSON.stringify(report) !== JSON.stringify(reports.get(report.id)))) {
1595
1733
  sendJson(res, 404, { error: 'no-report' }); return
1596
1734
  }
1597
- parts.push(Buffer.from(']}'))
1735
+ const suffix = Buffer.from(links.length > 0 ? `],"links":${JSON.stringify(links)}}` : ']}')
1736
+ outputBytes += suffix.length - 2
1737
+ if (outputBytes > MAX_REPORT_QUERY_BYTES) { sendJson(res, 413, { error: 'batch-too-large' }); return }
1738
+ parts.push(suffix)
1598
1739
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
1599
1740
  writeResponse(res, Buffer.concat(parts, outputBytes))
1600
1741
  }
1601
1742
 
1602
- // The server selects the complete workspace; clients cannot omit a report
1603
- // or links file to evade classification through the rest of their team.
1604
- async function handleTeamReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, teamId: string): Promise<void> {
1605
- const snapshot = await teamSnapshot(deps.db, session.id, teamId)
1743
+ // The server selects the complete published workspace for classification.
1744
+ // Privileged hidden-report previews are independently scoped and bounded.
1745
+ async function handleTeamReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, teamId: string, reportId: string | null): Promise<void> {
1746
+ const snapshot = await teamSnapshot(deps.db, session.id, teamId, reportId)
1606
1747
  const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
1607
1748
  await recheckTeam(deps.db, session.id, snapshot)
1608
1749
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
@@ -1900,6 +2041,22 @@ async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps:
1900
2041
  sendJson(res, 200, { ok: true, name })
1901
2042
  }
1902
2043
 
2044
+ // Hidden teams retain their configuration but grant no access or sidebar presence.
2045
+ async function handleSetTeamHidden(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
2046
+ const s = await adminMutation(req, res, deps, cookie)
2047
+ if (s == null) return
2048
+ let body: unknown
2049
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
2050
+ const teamId = (body as { teamId?: unknown } | null)?.teamId
2051
+ const hidden = (body as { hidden?: unknown } | null)?.hidden
2052
+ if (typeof teamId !== 'string' || typeof hidden !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
2053
+ const team = await deps.db.getTeam(teamId)
2054
+ if (await readAdminSession(res, deps, cookie) == null) return
2055
+ if (!(await deps.db.setTeamHidden(teamId, hidden, Date.now()))) { sendJson(res, 404, { error: 'no-team' }); return }
2056
+ if (team?.hidden !== hidden) await activity(deps, s.user, 'access', `${hidden ? 'hid' : 'restored'} team ${team?.name ?? teamId}`)
2057
+ sendJson(res, 200, { ok: true, hidden })
2058
+ }
2059
+
1903
2060
  // POST /api/admin/teams/delete — drop a team (its links cascade). Body { teamId }.
1904
2061
  async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1905
2062
  const s = await adminMutation(req, res, deps, cookie)
@@ -2115,6 +2272,11 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2115
2272
  if (issueRoute) {
2116
2273
  await handleWorkspaceIssue(req, res, deps, cookie, decodeURIComponent(issueRoute[1]!), url.searchParams); return
2117
2274
  }
2275
+ const deduplication = /^\/api\/admin\/deduplication(?:\/([a-f\d-]{36}))?$/iu.exec(path)
2276
+ if (deduplication) { await handleDeduplication(req, res, deps, cookie, deduplication[1]); return }
2277
+ const repositoryAliases = /^\/api\/admin\/repositories\/aliases(?:\/([a-f\d-]{36}))?$/iu.exec(path)
2278
+ if (repositoryAliases) { await handleRepositoryAliases(req, res, deps, cookie, repositoryAliases[1]); return }
2279
+ if (path === '/api/admin/repositories/resolve') { await handleRepositorySuggestion(req, res, deps, cookie); return }
2118
2280
  if (path === '/api/admin/links') {
2119
2281
  if (!config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
2120
2282
  if (method !== 'GET') { send405(res, 'GET'); return }
@@ -2214,7 +2376,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2214
2376
  const bundleAdvisories = /^\/api\/bundles\/([a-f\d-]{36})\/advisories$/iu.exec(path)
2215
2377
  if (bundleAdvisories) {
2216
2378
  if (method !== 'GET') { send405(res, 'GET'); return }
2217
- await handleBundleAdvisories(res, deps, workspaceSession!.session, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '', url.searchParams.get('repoAdvisories') === 'true')
2379
+ await handleBundleAdvisories(res, deps, workspaceSession!.session, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '', url.searchParams.get('repoAdvisories') === 'true', url.searchParams.get('details') === 'true')
2218
2380
  return
2219
2381
  }
2220
2382
  const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download)$/iu.exec(path)
@@ -2238,6 +2400,10 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2238
2400
  if (method !== 'POST') { send405(res, 'POST'); return }
2239
2401
  await handleSetBundleRepo(req, res, deps, cookie); return
2240
2402
  }
2403
+ if (path === BUNDLE_SET_VISIBLE_PATH) {
2404
+ if (method !== 'POST') { send405(res, 'POST'); return }
2405
+ await handleSetBundleVisible(req, res, deps, cookie); return
2406
+ }
2241
2407
  if (path.startsWith(BUNDLE_PREFIX)) {
2242
2408
  const id = path.slice(BUNDLE_PREFIX.length)
2243
2409
  if (method === 'GET') { await handleGetBundle(req, res, deps, workspaceSession!.session, id); return }
@@ -2250,18 +2416,19 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2250
2416
  const s = await readWorkspaceSession(res, deps, cookie)
2251
2417
  if (!s) return
2252
2418
  const teamId = teamFeed?.[1] ?? null
2253
- if (teamId) await teamSnapshot(db, s.session.id, teamId)
2254
- await serveUserTeamFeed(res, deps, s.session.id, s.user, teamId); return
2419
+ const reportId = url.searchParams.get('reportId')
2420
+ if (teamId) await teamSnapshot(db, s.session.id, teamId, reportId)
2421
+ await serveUserTeamFeed(res, deps, s.session.id, s.user, teamId, { reportId }); return
2255
2422
  }
2256
2423
  const teamFixes = /^\/api\/teams\/([^/]+)\/fixes$/u.exec(path)
2257
2424
  if (teamFixes) {
2258
2425
  if (method !== 'GET') { send405(res, 'GET'); return }
2259
- await handleWorkspaceFixes(res, deps, cookie, teamFixes[1]!); return
2426
+ await handleWorkspaceFixes(res, deps, cookie, teamFixes[1]!, url.searchParams.get('reportId')); return
2260
2427
  }
2261
2428
  const teamAnnotations = /^\/api\/teams\/([^/]+)\/annotations$/u.exec(path)
2262
2429
  if (teamAnnotations) {
2263
2430
  if (method !== 'GET') { send405(res, 'GET'); return }
2264
- const snapshot = await teamSnapshot(db, workspaceSession!.session.id, teamAnnotations[1]!)
2431
+ const snapshot = await teamSnapshot(db, workspaceSession!.session.id, teamAnnotations[1]!, url.searchParams.get('reportId'))
2265
2432
  const annotations = await loadTeamAnnotations(db, deps.reportStore, snapshot, url.searchParams.get('reportId'))
2266
2433
  await recheckTeam(db, workspaceSession!.session.id, snapshot)
2267
2434
  sendJson(res, 200, annotations); return
@@ -2269,7 +2436,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2269
2436
  const teamReports = /^\/api\/teams\/([^/]+)\/reports$/u.exec(path)
2270
2437
  if (teamReports) {
2271
2438
  if (method !== 'GET') { send405(res, 'GET'); return }
2272
- await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!); return
2439
+ await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!, url.searchParams.get('reportId')); return
2273
2440
  }
2274
2441
  if (path === '/api/reports/query') {
2275
2442
  if (method !== 'POST') { send405(res, 'POST'); return }
@@ -2316,11 +2483,12 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2316
2483
  if (method === 'POST') { await handleCreateTeam(req, res, deps, cookie); return }
2317
2484
  send405(res, 'GET, POST'); return
2318
2485
  }
2319
- if (path === TEAM_DELETE_PATH || path === TEAM_RENAME_PATH || path === TEAM_SET_REPO_PATH || path === TEAM_REMOVE_REPO_PATH
2486
+ if (path === TEAM_DELETE_PATH || path === TEAM_RENAME_PATH || path === TEAM_SET_HIDDEN_PATH || path === TEAM_SET_REPO_PATH || path === TEAM_REMOVE_REPO_PATH
2320
2487
  || path === TEAM_SET_MEMBER_PATH || path === TEAM_REMOVE_MEMBER_PATH) {
2321
2488
  if (method !== 'POST') { send405(res, 'POST'); return }
2322
2489
  if (path === TEAM_DELETE_PATH) { await handleDeleteTeam(req, res, deps, cookie); return }
2323
2490
  if (path === TEAM_RENAME_PATH) { await handleRenameTeam(req, res, deps, cookie); return }
2491
+ if (path === TEAM_SET_HIDDEN_PATH) { await handleSetTeamHidden(req, res, deps, cookie); return }
2324
2492
  if (path === TEAM_SET_REPO_PATH) { await handleSetTeamRepo(req, res, deps, cookie); return }
2325
2493
  if (path === TEAM_REMOVE_REPO_PATH) { await handleRemoveTeamRepo(req, res, deps, cookie); return }
2326
2494
  if (path === TEAM_SET_MEMBER_PATH) { await handleSetTeamMember(req, res, deps, cookie); return }