@preventive/triage 1.0.0-alpha.26 → 1.0.0-alpha.28

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 (96) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/client/linked-findings.js +1 -1
  3. package/common/bundle-metadata.js +12 -10
  4. package/common/code-language.js +17 -2
  5. package/common/default-scan-models.ts +3 -3
  6. package/common/finding-id.js +1 -1
  7. package/common/managed/report-content.ts +1 -1
  8. package/common/managed/report-filter.ts +1 -1
  9. package/out/client-managed-import.js +24 -24
  10. package/out/client-managed.js +93 -68
  11. package/out/graph.js +11 -11
  12. package/out/index.html +1 -1
  13. package/out/prism.js +2 -2
  14. package/out/stasis.svg +0 -25
  15. package/out/terminal.js +163 -163
  16. package/out/view.css +1 -1
  17. package/out/view.js +223 -190
  18. package/package.json +29 -29
  19. package/server-common/storage-crypto.ts +182 -0
  20. package/server-common/storage-log.ts +5 -1
  21. package/server-common/vercel-blob.ts +6 -3
  22. package/server-e2e/static.ts +3 -4
  23. package/server-managed/avatar-store.ts +1 -40
  24. package/server-managed/blob-store.ts +3 -52
  25. package/server-managed/bundle-advisories.ts +53 -0
  26. package/server-managed/bundle-advisory-inventory.ts +75 -0
  27. package/server-managed/bundle-build-leases.ts +41 -0
  28. package/server-managed/bundle-build-worker.js +44 -0
  29. package/server-managed/bundle-build.ts +141 -0
  30. package/server-managed/bundle-cache.ts +22 -41
  31. package/server-managed/bundle-store.ts +3 -7
  32. package/server-managed/bundle-summary-cache.ts +27 -9
  33. package/server-managed/bundle.ts +66 -1
  34. package/server-managed/cache-storage.ts +4 -25
  35. package/server-managed/cli.js +10 -2
  36. package/server-managed/config.ts +12 -1
  37. package/server-managed/content-access.ts +1 -1
  38. package/server-managed/db-methods.ts +163 -40
  39. package/server-managed/db-neon.ts +124 -30
  40. package/server-managed/db-schema.ts +2 -0
  41. package/server-managed/db.ts +11 -1
  42. package/server-managed/github-app.ts +10 -15
  43. package/server-managed/github-errors.ts +21 -0
  44. package/server-managed/github-issues.ts +1 -1
  45. package/server-managed/github-metadata.ts +34 -1
  46. package/server-managed/github-pulls.ts +142 -30
  47. package/server-managed/http.ts +278 -167
  48. package/server-managed/index.ts +26 -9
  49. package/server-managed/management-catalog.ts +45 -0
  50. package/server-managed/object-storage-disk.ts +144 -0
  51. package/server-managed/object-storage-vercel.ts +60 -0
  52. package/server-managed/object-storage.ts +71 -0
  53. package/server-managed/public-workspace.ts +20 -14
  54. package/server-managed/report-migration.ts +1 -1
  55. package/server-managed/report-sources.ts +14 -4
  56. package/server-managed/repository-browser.ts +8 -1
  57. package/server-managed/repository-discovery.ts +1 -1
  58. package/server-managed/revisions.ts +35 -0
  59. package/server-managed/solidity-entry-points.ts +69 -0
  60. package/server-managed/sql-postgres.ts +1 -0
  61. package/server-managed/sql.ts +14 -8
  62. package/server-managed/storage-command.ts +16 -0
  63. package/server-managed/storage-db.ts +175 -0
  64. package/server-managed/storage-encryption.ts +211 -0
  65. package/server-managed/storage-maintenance.ts +197 -0
  66. package/server-managed/storage-payload.ts +72 -0
  67. package/server-managed/storage-stores.ts +81 -0
  68. package/server-managed/storage.ts +48 -24
  69. package/server-managed/team-feed.ts +50 -5
  70. package/server-managed/team-reports.ts +85 -22
  71. package/server-managed/uploads.ts +2 -2
  72. package/server-managed/workspace-shares.ts +15 -3
  73. package/report/index.js +0 -260
  74. package/report/src/finding-id.js +0 -80
  75. package/report/src/finding.js +0 -314
  76. package/report/src/labels.js +0 -35
  77. package/report/src/md-structure.js +0 -471
  78. package/report/src/md-text.js +0 -167
  79. package/report/src/meta.js +0 -76
  80. package/report/src/parse-codex.js +0 -149
  81. package/report/src/parse-deepsec.js +0 -199
  82. package/report/src/parse-deepview-fields.js +0 -375
  83. package/report/src/parse-deepview-md.js +0 -186
  84. package/report/src/parse-md-id.js +0 -137
  85. package/report/src/parse-md.js +0 -323
  86. package/report/src/parse-piolium-id.js +0 -79
  87. package/report/src/parse-piolium-rows.js +0 -126
  88. package/report/src/parse-piolium-tokens.js +0 -185
  89. package/report/src/parse-piolium.js +0 -399
  90. package/report/src/security.js +0 -63
  91. package/report/src/severity.js +0 -18
  92. package/report/src/utf8.js +0 -21
  93. package/report/src/write-md-finding.js +0 -273
  94. package/report/src/write-md.js +0 -291
  95. package/server-managed/blob-vercel.ts +0 -127
  96. /package/{LICENSE → LICENSE-MIT} +0 -0
@@ -31,7 +31,7 @@
31
31
  // DELETE /api/admin/reports/<id> → admin|manage deletes a report | 401/403/404
32
32
  // POST /api/admin/reports/set-repo → admin|manage attaches/detaches a report's repo | 401/403/404
33
33
  // GET /api/bundles/<id>/{metadata,contents} → authorized encoded bytes | 401/404/422
34
- // GET /api/bundles/<id>/advisories → published npm advisories (security access) | 401/403/404
34
+ // GET /api/bundles/<id>/advisories → published dependency advisories (security access) | 401/403/404
35
35
  // GET /api/bundles/<id>/download → authorized original upload | 401/404
36
36
  // GET /api/admin/bundles → admin all bundles; managers own/team bundles | 401/403
37
37
  // POST /api/admin/bundles → admin|manage uploads a bundle (raw body) | 401/403/413
@@ -47,26 +47,27 @@
47
47
  // POST /api/auth/logout → same-origin + CSRF, drops the session
48
48
  import type { IncomingMessage, ServerResponse } from 'node:http'
49
49
  import { pipeline } from 'node:stream/promises'
50
- import { NPM_ADVISORIES_TIMEOUT_MS, fetchNpmAdvisories } from '../server-common/npm-advisories.ts'
50
+ import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
51
+ import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
51
52
  import { UPLOAD_CHUNK_BYTES, type UploadKind, deleteUpload, putUploadPart, readUpload, validUpload, validUploadPart } from './uploads.ts'
52
53
  import { type BundleCache, type BundleCachePart, MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
53
54
  import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
55
+ import { contentAccess } from './content-access.ts'
54
56
  import type { BundleStore } from './bundle-store.ts'
55
57
  import { Buffer } from 'node:buffer'
56
58
  import { createHash, randomUUID } from 'node:crypto'
57
- import { teamCatalogRevision } from './team-catalog.ts'
58
59
  import type { AvatarStore } from './avatar-store.ts'
59
60
  import type { BlobStore } from './blob-store.ts'
60
- import { bundleIntegrity, bundleKind, reportBundleHashes } from './bundle.ts'
61
+ import { bundleIntegrity, bundleKind, bundleRepo, reportBundleHashes } from './bundle.ts'
61
62
  import type { ManagedConfig } from './config.ts'
62
- import type { ManagedDb, ManagedSession, ReportRecord, StoredUser, TriageEventRow } from './db.ts'
63
+ import type { ManagedBundle, ManagedDb, ManagedSession, ReportRecord, SelectedRepo, StoredUser, TriageEventRow } from './db.ts'
63
64
  import type { OriginGate } from '../server-common/origin.ts'
64
65
  import { isRole, roleAtLeast } from '../common/managed/roles.ts'
65
66
  import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
66
67
  import { filterReportData } from '../common/managed/report-filter.ts'
67
68
  import type { TriageEntryPatch } from '../common/managed/triage.ts'
68
69
  import { MAX_FINDING_ID, MAX_TRIAGE_BODY_BYTES, MAX_TRIAGE_ENTRIES, MAX_TRIAGE_HISTORY, parseTriageEntryPatch } from '../common/managed/triage.ts'
69
- import { reportRepoGithub } from '../report/index.js'
70
+ import { reportRepoGithub } from '@preventive/report'
70
71
  import { loadManagedFindings, readManagedReport } from '../common/managed/report-content.ts'
71
72
  import type { ReportSourcesCache } from './report-sources.ts'
72
73
  import { normalizeTeamPath } from './repo-path.ts'
@@ -76,12 +77,12 @@ import { GithubApiError, collectRepos, fetchPublicRepository, installUrl, public
76
77
  import type { ConnectedRepo } from './github-app.ts'
77
78
  import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
78
79
  import { RepositoryDiscovery } from './repository-discovery.ts'
79
- import { createRepositoryBrowser, scopedDirectory } from './repository-browser.ts'
80
+ import { type RepositoryEntry, createRepositoryBrowser, scopedDirectory } from './repository-browser.ts'
80
81
  import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
81
82
  import { clearCookie, endSession, readSession } from './session.ts'
82
83
  import type { ActivityContext, ActivityInput } from './activity.ts'
83
84
  import { acceptsReportMetadata } from './report-response.ts'
84
- import { TeamReportsError, loadTeamReports, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
85
+ import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
85
86
  import { FINDING_CATALOG_PAGE_BYTES, FINDING_CATALOG_PAGE_COUNT, MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
86
87
  import { lookupFixes, storedFixUrls } from './github-pulls.ts'
87
88
  import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
@@ -90,10 +91,10 @@ import { sendJson, writeResponse } from './http-response.ts'
90
91
  import { triageWireEntry } from './triage-response.ts'
91
92
  import { handlePublicWorkspace } from './public-workspace.ts'
92
93
  import { serveUserTeamFeed } from './team-feed.ts'
93
- import { hashToken, randomToken } from './crypto.ts'
94
+ import { hashToken, randomToken, safeEqual } from './crypto.ts'
94
95
  import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
95
- import { ManagedCommitError } from './sql.ts'
96
96
  import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
97
+ import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
97
98
 
98
99
  const SESSION_PATH = '/api/auth/session'
99
100
  const AVATAR_PREFIX = '/api/avatar/'
@@ -116,6 +117,7 @@ const REPORT_SET_REPO_PATH = '/api/admin/reports/set-repo'
116
117
  const REPORT_SET_VISIBLE_PATH = '/api/admin/reports/set-visible'
117
118
  const REPORT_PREFIX = '/api/admin/reports/'
118
119
  const ADMIN_BUNDLES_PATH = '/api/admin/bundles'
120
+ const BUNDLE_CREATE_PATH = '/api/admin/bundles/create'
119
121
  const BUNDLE_SET_REPO_PATH = '/api/admin/bundles/set-repo'
120
122
  const BUNDLE_PREFIX = '/api/admin/bundles/'
121
123
  const MY_TEAMS_PATH = '/api/teams'
@@ -318,16 +320,15 @@ async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES
318
320
  return JSON.parse(buf.toString('utf8') || 'null')
319
321
  }
320
322
 
321
- // Validate a mutation: same-origin gate + an authenticated session whose
322
- // double-submit CSRF token matches the X-CSRF-Token header. Returns the session,
323
- // or null after having already sent the 401/403.
323
+ // The router enforces same-origin for every request. A mutation also needs an
324
+ // authenticated session whose CSRF token matches the X-CSRF-Token header.
325
+ // Returns the session, or null after having already sent the 401/403.
324
326
  async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
325
- if (!deps.originGate.isOriginAllowed(req)) { sendJson(res, 403, { error: 'origin-denied' }); return null }
326
327
  const s = await readSession(deps.config, deps.db, cookie, Date.now())
327
328
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
328
329
  const csrf = firstHeader(req.headers['x-csrf-token'])
329
330
  if (csrf == null) { sendJson(res, 403, { error: 'csrf-missing' }); return null }
330
- if (csrf !== s.session.csrfToken) { sendJson(res, 403, { error: 'csrf-mismatch' }); return null }
331
+ if (!safeEqual(csrf, s.session.csrfToken)) { sendJson(res, 403, { error: 'csrf-mismatch' }); return null }
331
332
  return s
332
333
  }
333
334
 
@@ -748,12 +749,6 @@ async function resolveUploadRepoId(req: IncomingMessage, res: ServerResponse, de
748
749
  return { ok: true, repoId: n }
749
750
  }
750
751
 
751
- async function canChangeReportRepo(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
752
- if (!roleAtLeast(user.role, 'manage') || !(await canViewReport(deps, user, reportId))) return false
753
- const report = await deps.db.getReport(reportId)
754
- return report != null && (user.role === 'admin' || report.repoId === null || await deps.db.userCanReadReport(user.id, reportId))
755
- }
756
-
757
752
  // POST /api/admin/reports/set-repo — attach / detach a stored report's repo
758
753
  // + directory link. Mutation: same-origin + CSRF, admin|manage. Body
759
754
  // { reportId, repoId, directory }, where repoId is null (detach) or a
@@ -815,18 +810,10 @@ async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, de
815
810
  // Visible to admin|manage. Read-only, so no CSRF (like /api/admin/users).
816
811
  // `maxBytes` lets the page show / pre-check the upload size cap; `repos` feeds
817
812
  // the upload repo picker.
818
- async function handleListReports(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
819
- const s = await readManageSession(res, deps, cookie)
820
- if (s == null) return
813
+ async function handleListReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession): Promise<void> {
821
814
  sendJson(res, 200, {
822
- reports: await Promise.all((await deps.db.listReports(s.user.role === 'admin' ? undefined : s.user.id)).map(async report => {
823
- const bundleAllowed = report.bundleId != null && await canAccessBundle(deps, s.user, report.bundleId)
824
- return { ...report, canChangeRepo: await canChangeReportRepo(deps, s.user, report.id),
825
- bundleId: bundleAllowed ? report.bundleId : null, bundleFilename: bundleAllowed ? report.bundleFilename : null }
826
- })),
815
+ ...await deps.db.getReportCatalog(session.id, Date.now()),
827
816
  maxBytes: deps.config.maxReportBytes,
828
- repos: selectableRepos(await bundleRepos(deps, s.user)),
829
- repoScopes: s.user.role === 'admin' ? null : await deps.db.listRepoScopesForUser(s.user.id),
830
817
  })
831
818
  }
832
819
 
@@ -914,18 +901,22 @@ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, dep
914
901
  const id = randomUUID()
915
902
  const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
916
903
  const { bundleId, integrity } = await resolveReportBundle(deps, s.user, bytes)
917
- await deps.reportStore.put(id, bytes)
904
+ const dataKey = await deps.reportStore.put(id, bytes)
918
905
  let report: ReportRecord
919
906
  try {
920
907
  report = await deps.db.insertOrReuseReport({
921
- id, filename, contentType, byteSize: bytes.length, sha256,
908
+ id, filename, contentType, byteSize: bytes.length, sha256, dataKey,
922
909
  uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: matchedRepo?.repoId ?? null,
923
910
  repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId, bundleIntegrity: integrity,
924
911
  }, Date.now(), s.session.id)
925
912
  } catch (err) {
926
- // Losing the commit acknowledgement cannot turn primary bytes into garbage.
927
- if (!(err instanceof ManagedCommitError)) await deps.reportStore.delete(id).catch(() => {})
928
- throw err
913
+ // A successful COMMIT can lose its acknowledgement. Never delete a file
914
+ // until a writer-locked read confirms that this candidate did not commit.
915
+ let committed: ReportRecord | null
916
+ try { committed = await deps.db.resolveReportUpload(id) }
917
+ catch (lookup) { throw new AggregateError([err, lookup], 'Report upload reconciliation failed', { cause: lookup }) }
918
+ if (committed) report = committed
919
+ else { await deps.reportStore.delete(id).catch(() => {}); throw err }
929
920
  }
930
921
  if (report.id !== id) await deps.reportStore.delete(id).catch(() => {})
931
922
  await sendUploadedReport(req, res, deps, cookie, report, report.id !== id)
@@ -985,15 +976,10 @@ async function canAccessBundle(deps: ManagedHttpDeps, user: StoredUser, id: stri
985
976
  return rec.repoId !== null && deps.db.userCanReadRepoPath(user.id, rec.repoId, rec.repoDirectory)
986
977
  }
987
978
 
988
- async function canChangeBundleRepo(deps: ManagedHttpDeps, user: StoredUser, repoId: number | null, directory: string) {
989
- return roleAtLeast(user.role, 'manage') && (user.role === 'admin' || repoId === null || await deps.db.userCanReadRepoPath(user.id, repoId, directory))
990
- }
991
-
992
979
  async function bundleRepos(deps: ManagedHttpDeps, user: StoredUser) {
993
980
  const repos = await deps.db.listSelectedRepos()
994
- if (user.role === 'admin') return repos
995
- const allowed = await Promise.all(repos.map(repo => deps.db.userCanReadRepo(user.id, repo.repoId)))
996
- return repos.filter((_, index) => allowed[index])
981
+ const access = await contentAccess(deps.db, user)
982
+ return repos.filter(access.bundle)
997
983
  }
998
984
 
999
985
  async function repositoryBrowserUser(deps: ManagedHttpDeps, userId: string) {
@@ -1011,6 +997,40 @@ async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHtt
1011
997
  sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
1012
998
  }
1013
999
 
1000
+ type RepositoryReader = Awaited<ReturnType<ReturnType<typeof createRepositoryBrowser>['reader']>>
1001
+ type RepositoryContents = Awaited<ReturnType<typeof readRepositoryContents>>
1002
+ type RepositoryRefs = Awaited<ReturnType<RepositoryReader['refs']>> & { defaultContents?: RepositoryContents | null }
1003
+
1004
+ async function readRepositoryContents(reader: RepositoryReader, ref: string, path: string, virtualEntries: RepositoryEntry[] | null) {
1005
+ const commit = await reader.commit(ref)
1006
+ const contents = virtualEntries ? { entries: virtualEntries, limited: false } : await reader.directory(path, commit)
1007
+ return { ...contents, path, commit }
1008
+ }
1009
+
1010
+ async function readRepositoryRefs(db: ManagedDb, reader: RepositoryReader, repo: SelectedRepo, withDefault: boolean, virtualEntries: RepositoryEntry[] | null): Promise<RepositoryRefs> {
1011
+ // Wait for both, including failures: a deleted cached branch must not prevent
1012
+ // a successful live default lookup from recovering the initial directory.
1013
+ const [refs, contents] = await Promise.allSettled([
1014
+ reader.refs(),
1015
+ withDefault && repo.cachedDefaultBranch ? readRepositoryContents(reader, `heads/${repo.cachedDefaultBranch}`, '', virtualEntries) : Promise.resolve(null),
1016
+ ])
1017
+ if (refs.status === 'rejected') throw refs.reason
1018
+ const defaultBranch = refs.value.defaultBranch || null
1019
+ const changed = defaultBranch !== repo.cachedDefaultBranch
1020
+ if (changed) await db.cacheRepoDefaultBranch(repo, defaultBranch)
1021
+ if (!withDefault) return refs.value
1022
+ if (!defaultBranch) return { ...refs.value, defaultContents: null }
1023
+ if (changed) return { ...refs.value, defaultContents: await readRepositoryContents(reader, `heads/${defaultBranch}`, '', virtualEntries) }
1024
+ if (contents.status === 'rejected') throw contents.reason
1025
+ return { ...refs.value, defaultContents: contents.value }
1026
+ }
1027
+
1028
+ function scopeRepositoryContents(contents: RepositoryContents | null, virtualEntries: RepositoryEntry[] | null) {
1029
+ return contents && { ...contents, ...(virtualEntries ? {
1030
+ entries: virtualEntries, limited: false, packageEntryPoints: [], solidityEntryPoints: [], soliditySuggestionsLimited: false,
1031
+ } : {}) }
1032
+ }
1033
+
1014
1034
  // Recheck repository/path grants after upstream reads, before returning names.
1015
1035
  async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams, refs: boolean): Promise<void> {
1016
1036
  const repoId = Number(query.get('repoId'))
@@ -1019,7 +1039,8 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1019
1039
  if (!Number.isSafeInteger(repoId) || repoId <= 0 || !normalized.ok || ref.length > 1024 || /\p{Cc}/u.test(ref)) {
1020
1040
  sendJson(res, 400, { error: 'bad-request' }); return
1021
1041
  }
1022
- const path = normalized.path ?? ''
1042
+ const withDefault = refs && query.get('withDefault') === 'true'
1043
+ const path = refs ? '' : normalized.path ?? ''
1023
1044
  const authorize = async () => {
1024
1045
  const s = await readManageSession(res, deps, cookie)
1025
1046
  if (!s) return null
@@ -1027,7 +1048,7 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1027
1048
  if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
1028
1049
  const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
1029
1050
  const virtualEntries = scopedDirectory(path, scopes)
1030
- if (!refs && virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
1051
+ if ((!refs || withDefault) && virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
1031
1052
  return { repo, virtualEntries, user: s.user }
1032
1053
  }
1033
1054
  const access = await authorize()
@@ -1035,16 +1056,19 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1035
1056
  try {
1036
1057
  const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id))
1037
1058
  const reader = await browser.reader(access.repo)
1038
- const commit = refs ? '' : await reader.commit(ref)
1039
- const result = refs ? await reader.refs() : access.virtualEntries
1040
- ? { entries: access.virtualEntries, limited: false } : await reader.directory(path, commit)
1059
+ const revisions = refs ? await readRepositoryRefs(deps.db, reader, access.repo, withDefault, access.virtualEntries) : null
1060
+ const contents = refs ? null : await readRepositoryContents(reader, ref, path, access.virtualEntries)
1041
1061
  await reader.recheckAccess()
1042
1062
  const current = await authorize()
1043
1063
  if (!current) return
1044
- if (JSON.stringify(current.repo) !== JSON.stringify(access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1045
- sendJson(res, 200, refs ? result : {
1046
- ...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false, packageEntryPoints: [] } : {}), path, commit,
1047
- })
1064
+ // This request (or another reader) may have refreshed the shared hint. It
1065
+ // does not change repository identity, credentials, or managed grants.
1066
+ if (JSON.stringify({ ...current.repo, cachedDefaultBranch: access.repo.cachedDefaultBranch }) !== JSON.stringify(access.repo)) {
1067
+ sendJson(res, 409, { error: 'repository-changed' }); return
1068
+ }
1069
+ sendJson(res, 200, refs ? { ...revisions, ...(withDefault ? {
1070
+ defaultContents: scopeRepositoryContents(revisions?.defaultContents ?? null, current.virtualEntries),
1071
+ } : {}) } : scopeRepositoryContents(contents, current.virtualEntries))
1048
1072
  } catch (err) {
1049
1073
  if (err instanceof GithubApiError) {
1050
1074
  sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
@@ -1064,19 +1088,18 @@ function prebuildBundle(deps: ManagedHttpDeps, id: string) {
1064
1088
 
1065
1089
  // GET /api/bundles/:id/{metadata,contents}. Source/cache bytes are already encoded;
1066
1090
  // the browser's HTTP stack decompresses them without a Brotli JS dependency.
1067
- async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, part: BundleCachePart) {
1068
- const s = await readSession(deps.config, deps.db, cookie, Date.now())
1069
- if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1070
- if (!(await canAccessBundle(deps, s.user, id))) { sendJson(res, 404, { error: 'no-bundle' }); return }
1071
- const rec = await deps.db.getBundle(id)
1091
+ async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, part: BundleCachePart) {
1092
+ const access = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
1093
+ if (!access) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1094
+ const rec = access.bundle
1072
1095
  if (!rec) { sendJson(res, 404, { error: 'no-bundle' }); return }
1073
1096
  if (!deps.bundleCache) { sendJson(res, 503, { error: 'unavailable' }); return }
1074
1097
  let cached
1075
1098
  try { cached = await deps.bundleCache.open(rec, part) }
1076
1099
  catch { sendJson(res, 422, { error: 'bundle-unavailable' }); return }
1077
1100
  // A cold build can outlast a session or membership change.
1078
- const current = await readSession(deps.config, deps.db, cookie, Date.now())
1079
- if (!current || !(await canAccessBundle(deps, current.user, id))) {
1101
+ const current = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
1102
+ if (!current?.bundle) {
1080
1103
  cached.stream.destroy()
1081
1104
  sendJson(res, current ? 404 : 401, { error: current ? 'no-bundle' : 'unauthenticated' })
1082
1105
  return
@@ -1090,42 +1113,44 @@ async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps
1090
1113
  try { await pipeline(cached.stream, res) } catch { res.destroy() }
1091
1114
  }
1092
1115
 
1093
- // Published npm advisories require security access, independently of access to
1116
+ // Published dependency advisories require security access, independently of access to
1094
1117
  // unpublished dependency findings. Managers retain their normal bundle access.
1095
- async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null, reason: string) {
1118
+ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, teamId: string | null, reason: string, repoAdvisories: boolean) {
1096
1119
  const authorize = async () => {
1097
- const session = await readSession(deps.config, deps.db, cookie, Date.now())
1098
- if (!session) { sendJson(res, 401, { error: 'unauthenticated' }); return false }
1099
- if (!(await canAccessBundle(deps, session.user, id))) { sendJson(res, 404, { error: 'no-bundle' }); return false }
1100
- if (!roleAtLeast(session.user.role, 'manage') && !(await deps.db.userCanReadBundleAdvisories(session.user.id, id, teamId))) {
1101
- sendJson(res, 403, { error: 'security-access-required' }); return false
1120
+ const access = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id, teamId)
1121
+ if (!access) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
1122
+ if (!access.bundle) { sendJson(res, 404, { error: 'no-bundle' }); return null }
1123
+ if (!access.canReadAdvisories) {
1124
+ sendJson(res, 403, { error: 'security-access-required' }); return null
1102
1125
  }
1103
- return true
1126
+ return access.bundle
1104
1127
  }
1105
- if (!(await authorize())) return
1106
- const record = await deps.db.getBundle(id)
1107
- if (!record) { sendJson(res, 404, { error: 'no-bundle' }); return }
1128
+ const record = await authorize()
1129
+ if (!record) return
1108
1130
  if (record.kind !== 'stasis') { sendJson(res, 422, { error: 'unsupported-bundle' }); return }
1109
1131
  if (!deps.bundleCache) { sendJson(res, 503, { error: 'unavailable' }); return }
1110
- let packages: Record<string, string[]> | null | undefined
1111
- try { packages = await deps.bundleCache.packageVersions(record, reason) }
1132
+ let inventory: BundleAdvisoryInventory | null | undefined
1133
+ try { inventory = await deps.bundleCache.advisoryInventory(record, reason) }
1112
1134
  catch { sendJson(res, 422, { error: 'bundle-unavailable' }); return }
1113
1135
  if (!(await authorize())) return
1114
- if (packages === null) { sendJson(res, 413, { error: 'payload-too-large' }); return }
1115
- if (packages === undefined) { sendJson(res, 400, { error: 'unknown-reason' }); return }
1116
- const body = Buffer.from(JSON.stringify(packages))
1117
- if (body.length > MAX_PACKAGE_INVENTORY_BYTES) { sendJson(res, 413, { error: 'payload-too-large' }); return }
1136
+ if (inventory === null) { sendJson(res, 413, { error: 'payload-too-large' }); return }
1137
+ if (inventory === undefined) { sendJson(res, 400, { error: 'unknown-reason' }); return }
1138
+ if (Buffer.byteLength(JSON.stringify(inventory)) > MAX_PACKAGE_INVENTORY_BYTES) { sendJson(res, 413, { error: 'payload-too-large' }); return }
1118
1139
  const controller = new AbortController()
1119
1140
  const onClose = () => { if (!res.writableEnded) controller.abort() }
1120
1141
  res.on('close', onClose)
1121
- const timer = setTimeout(() => controller.abort(), NPM_ADVISORIES_TIMEOUT_MS)
1142
+ const timer = setTimeout(() => controller.abort(), ADVISORIES_TIMEOUT_MS)
1122
1143
  try {
1123
1144
  if (res.destroyed) return
1124
- const result = Object.keys(packages).length === 0 ? { status: 200, body: {} }
1125
- : await fetchNpmAdvisories(body, controller.signal, deps.config.debug)
1145
+ const needsGithub = inventory.packages.some(pkg => repoAdvisories || pkg.ecosystem === 'github' || pkg.ecosystem === 'soldeer')
1146
+ const githubToken = needsGithub ? await ensureUserAccessToken(deps.config, deps.db, session.userId, Date.now(), (url, init) => fetch(url, {
1147
+ ...init, signal: init?.signal ? AbortSignal.any([controller.signal, init.signal]) : controller.signal,
1148
+ })) : null
1149
+ if (res.destroyed || (needsGithub && !(await authorize()))) return
1150
+ const result = await fetchBundleAdvisories(inventory.packages, controller.signal, { debug: deps.config.debug, repoAdvisories, githubToken })
1126
1151
  if (res.destroyed || !(await authorize())) return
1127
1152
  // Return just inventory and public advisories, never source or report data.
1128
- sendJson(res, result.status, result.status >= 200 && result.status < 300 ? { packages, advisories: result.body } : result.body)
1153
+ sendJson(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body)
1129
1154
  } finally {
1130
1155
  clearTimeout(timer)
1131
1156
  res.off('close', onClose)
@@ -1135,29 +1160,87 @@ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps
1135
1160
  // GET /api/admin/bundles — the uploaded bundles for the "Manage bundles" page.
1136
1161
  // admin|manage, read-only (no CSRF). `maxBytes` is the upload cap; `repos` feeds
1137
1162
  // the upload repo picker.
1138
- async function handleListBundles(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1139
- let s = await readManageSession(res, deps, cookie)
1140
- if (s == null) return
1141
- const summaries = await bundleSummaries(await deps.db.listBundles(s.user.role === 'admin' ? undefined : s.user.id), deps.bundleCache)
1163
+ async function handleListBundles(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession): Promise<void> {
1164
+ const before = await deps.db.getBundleCatalog(session.id, Date.now())
1165
+ const summaries = await bundleSummaries(before.bundles, deps.bundleCache)
1142
1166
  // Even cached storage reads may outlast changes to access or locations.
1143
- s = await readManageSession(res, deps, cookie)
1144
- if (s == null) return
1145
- const bundles = await deps.db.listBundles(s.user.role === 'admin' ? undefined : s.user.id)
1167
+ const catalog = await deps.db.getBundleCatalog(session.id, Date.now())
1146
1168
  sendJson(res, 200, {
1147
- bundles: await Promise.all(bundles.map(async bundle => ({
1169
+ ...catalog,
1170
+ bundles: catalog.bundles.map(bundle => ({
1148
1171
  ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }),
1149
- canChangeRepo: await canChangeBundleRepo(deps, s.user, bundle.repoId, bundle.repoDirectory),
1150
- }))),
1172
+ })),
1151
1173
  maxBytes: deps.config.maxBundleBytes,
1152
- repos: selectableRepos(await bundleRepos(deps, s.user)),
1153
- repoScopes: s.user.role === 'admin' ? null : await deps.db.listRepoScopesForUser(s.user.id),
1154
1174
  })
1155
- await backfillBundleSummaries(bundles, deps.bundleCache)
1175
+ await backfillBundleSummaries(catalog.bundles, deps.bundleCache)
1176
+ }
1177
+
1178
+ type UploadedBundle = Pick<ManagedBundle, 'id' | 'integrity' | 'filename' | 'byteSize' | 'repoId' | 'repoDirectory'>
1179
+ async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, bundle: UploadedBundle, deduped: boolean): Promise<void> {
1180
+ const s = await manageMutation(req, res, deps, cookie)
1181
+ if (!s) return
1182
+ if (deduped && !(await canAccessBundle(deps, s.user, bundle.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
1183
+ const { id, integrity, filename, byteSize, repoId, repoDirectory } = bundle
1184
+ // Re-uploading also repairs reports uploaded while the bundle was inaccessible.
1185
+ await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
1186
+ prebuildBundle(deps, id)
1187
+ sendJson(res, deduped ? 200 : 201, { id, integrity, filename, repoId, repoDirectory, ...(deduped ? { deduped: true } : { byteSize }) })
1188
+ }
1189
+
1190
+ async function handleCreateBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1191
+ const initial = await manageMutation(req, res, deps, cookie)
1192
+ if (!initial) return
1193
+ const controller = new AbortController()
1194
+ const onClose = () => controller.abort()
1195
+ res.on('close', onClose)
1196
+ try {
1197
+ let body: unknown
1198
+ try { body = await readJsonBody(req, 128 * 1024) } catch { throw new BundleBuildError(400, 'bad-body') }
1199
+ const input = parseBundleBuild(body)
1200
+ const authorize = async (directory: string) => {
1201
+ const session = await manageMutation(req, res, deps, cookie)
1202
+ if (!session) return null
1203
+ const repo = (await bundleRepos(deps, session.user)).find(item => item.repoId === input.repoId)
1204
+ if (!repo) throw new BundleBuildError(404, 'no-repository')
1205
+ const scopes = session.user.role === 'admin' ? [null]
1206
+ : (await deps.db.listRepoScopesForUser(session.user.id)).filter(scope => scope.repoId === input.repoId).map(scope => scope.path)
1207
+ if (scopedDirectory(directory, scopes) !== null) throw new BundleBuildError(403, 'build-scope')
1208
+ return { ...session, repo, scopes }
1209
+ }
1210
+ const access = await authorize(input.directory)
1211
+ if (!access) return
1212
+ await withBundleBuildLease(deps.db, access.user.id, controller.signal, async signal => {
1213
+ const reader = await createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id)).reader(access.repo)
1214
+ signal.throwIfAborted()
1215
+ const built = await buildRepositoryBundle(access.user.id, {
1216
+ input, github: access.repo.fullName, token: reader.readToken(), maxBytes: deps.config.maxBundleBytes, scopes: access.scopes,
1217
+ }, signal)
1218
+ signal.throwIfAborted()
1219
+ await reader.recheckAccess()
1220
+ const current = await authorize(built.directory)
1221
+ if (!current) return
1222
+ if (JSON.stringify({ ...current.repo, cachedDefaultBranch: access.repo.cachedDefaultBranch }) !== JSON.stringify(access.repo)) {
1223
+ throw new BundleBuildError(409, 'repository-changed')
1224
+ }
1225
+ signal.throwIfAborted()
1226
+ await storeUploadedBundle(req, res, deps, cookie, current, Buffer.from(built.bytes), input.repoId, built.directory, built.filename)
1227
+ })
1228
+ } catch (error) {
1229
+ if (res.destroyed || controller.signal.aborted) return
1230
+ if (error instanceof BundleBuildError) { sendJson(res, error.status, { error: error.code }); return }
1231
+ if (error instanceof GithubApiError) {
1232
+ sendJson(res, error.status === 401 ? 502 : error.status, { error: error.message }, error.retryAfter == null ? {} : { 'retry-after': String(error.retryAfter) })
1233
+ return
1234
+ }
1235
+ throw error
1236
+ } finally { res.off('close', onClose) }
1156
1237
  }
1157
1238
 
1158
1239
  // POST /api/admin/bundles — upload a bundle. Mutation: same-origin + CSRF,
1159
1240
  // admin|manage. Raw bytes; X-Bundle-Filename names it, optional X-Repo-Id and
1160
- // X-Repo-Directory assign a repository location. The bundle's identity is its content hash (sha512), UNIQUE — a
1241
+ // X-Repo-Directory assign a repository location; Stasis repo headers default
1242
+ // to a matching connected repository within the uploader's access, otherwise
1243
+ // the bundle stays unattached. Its identity is its content hash (sha512), UNIQUE — a
1161
1244
  // re-upload of identical bytes dedupes to the existing row (no second copy).
1162
1245
  // After storing, any reports that declared this integrity but weren't linked yet
1163
1246
  // get attached (auto-link). 413 over the cap, 400 on empty.
@@ -1179,76 +1262,87 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
1179
1262
  if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
1180
1263
  const repo = await resolveUploadRepoId(req, res, deps)
1181
1264
  if (!repo.ok) return
1182
- let rawDirectory
1265
+ const filename = sanitizeFilename(firstHeader(req.headers['x-bundle-filename']), 'bundle')
1266
+ const kind = bundleKind(filename)
1267
+ // Explicit locations override embedded defaults. Reuploads keep their
1268
+ // stored location, so only new bundles need their origin header decoded.
1269
+ const integrity = bundleIntegrity(bytes)
1270
+ const existing = await deps.db.getBundleByIntegrity(integrity)
1271
+ let inferredLocation = false
1272
+ let rawDirectory: unknown
1183
1273
  try { rawDirectory = decodeURIComponent(firstHeader(req.headers['x-repo-directory']) ?? '') }
1184
1274
  catch { sendJson(res, 400, { error: 'bad-directory' }); return }
1275
+ if (!existing && repo.repoId == null && kind === 'stasis') {
1276
+ const embedded = await bundleRepo(bytes)
1277
+ 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
1282
+ if (req.headers['x-repo-directory'] == null) {
1283
+ rawDirectory = embedded?.directory ?? ''
1284
+ inferredLocation = true
1285
+ }
1286
+ }
1287
+ s = await checkMutation(req, res, deps, cookie)
1288
+ if (!s || !requireManageRole(res, s.user)) return
1289
+ }
1185
1290
  const normalized = normalizeTeamPath(rawDirectory)
1186
1291
  if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
1187
- const directory = repo.repoId == null ? '' : normalized.path ?? ''
1292
+ let directory = repo.repoId == null ? '' : normalized.path ?? ''
1188
1293
  if (repo.repoId !== null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repo.repoId, directory))) {
1189
- sendJson(res, 403, { error: 'forbidden' }); return
1294
+ if (!inferredLocation) { sendJson(res, 403, { error: 'forbidden' }); return }
1295
+ // A bundle's own stamp is a default, not a required destination. Retain
1296
+ // it in the archive while letting the uploader assign an allowed location.
1297
+ repo.repoId = null
1298
+ directory = ''
1190
1299
  }
1191
- const integrity = bundleIntegrity(bytes)
1192
- const filename = sanitizeFilename(firstHeader(req.headers['x-bundle-filename']), 'bundle')
1300
+ await storeUploadedBundle(req, res, deps, cookie, s, bytes, repo.repoId, directory, filename, integrity)
1301
+ }
1302
+
1303
+ 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> {
1193
1305
  const existing = await deps.db.getBundleByIntegrity(integrity)
1194
- if (existing != null) {
1195
- s = await manageMutation(req, res, deps, cookie)
1196
- if (!s) return
1197
- if (!(await canAccessBundle(deps, s.user, existing.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
1198
- // Reports uploaded while this bundle was inaccessible retain only its
1199
- // integrity. An authorized re-upload repairs those pending links too.
1200
- await deps.db.linkReportsToBundle(integrity, existing.id, s.user.role === 'admin' ? undefined : s.user.id)
1201
- prebuildBundle(deps, existing.id)
1202
- sendJson(res, 200, { id: existing.id, integrity, filename: existing.filename, repoId: existing.repoId, repoDirectory: existing.repoDirectory, deduped: true })
1203
- return
1204
- }
1306
+ if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, true); return }
1205
1307
  const id = randomUUID()
1206
1308
  const kind = bundleKind(filename)
1207
- await deps.bundleStore.put(id, bytes, kind)
1309
+ const dataKey = await deps.bundleStore.put(id, bytes, kind)
1208
1310
  try {
1209
1311
  await deps.db.insertBundle({
1210
- id, integrity, filename, kind,
1211
- byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: repo.repoId, repoDirectory: directory,
1312
+ id, integrity, filename, kind, dataKey,
1313
+ byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId, repoDirectory: directory,
1212
1314
  }, Date.now(), s.session.id)
1213
1315
  } catch (err) {
1214
- // A committed row may already point here even though insertion rejected.
1215
- if (!(err instanceof ManagedCommitError)) await deps.bundleStore.delete(id).catch(() => {})
1216
- if (err instanceof ManagedMutationError) throw err
1217
1316
  // A concurrent upload of identical bytes can insert this integrity (UNIQUE)
1218
1317
  // between our dedup check and this insert — treat that as a dedup, not a 500.
1219
1318
  // Any other failure rethrows.
1220
- const raced = await deps.db.getBundleByIntegrity(integrity)
1221
- if (raced != null) {
1222
- s = await manageMutation(req, res, deps, cookie)
1223
- if (!s) return
1224
- if (!(await canAccessBundle(deps, s.user, raced.id))) { sendJson(res, 409, { error: 'bundle-conflict' }); return }
1225
- await deps.db.linkReportsToBundle(integrity, raced.id, s.user.role === 'admin' ? undefined : s.user.id)
1226
- prebuildBundle(deps, raced.id)
1227
- sendJson(res, 200, { id: raced.id, integrity, filename: raced.filename, repoId: raced.repoId, repoDirectory: raced.repoDirectory, deduped: true }); return }
1228
- throw err
1319
+ let raced: ManagedBundle | null
1320
+ try { raced = await deps.db.resolveBundleUpload(integrity) }
1321
+ catch (lookup) { throw new AggregateError([err, lookup], 'Bundle upload reconciliation failed', { cause: lookup }) }
1322
+ // Preserve a committed candidate after a lost acknowledgement. If this
1323
+ // read fails too, leave the bytes for later reconciliation.
1324
+ if (raced?.id !== id) await deps.bundleStore.delete(id).catch(() => {})
1325
+ if (err instanceof ManagedMutationError) throw err
1326
+ if (!raced) throw err
1327
+ if (raced.id !== id) { await sendUploadedBundle(req, res, deps, cookie, raced, true); return }
1328
+ // Our insert committed: continue with the ordinary creation response.
1229
1329
  }
1230
- // Auto-link reports that declared this integrity before the bundle existed.
1231
- s = await manageMutation(req, res, deps, cookie)
1232
- if (!s) return
1233
- await deps.db.linkReportsToBundle(integrity, id, s.user.role === 'admin' ? undefined : s.user.id)
1234
- prebuildBundle(deps, id)
1235
- sendJson(res, 201, { id, integrity, filename, byteSize: bytes.length, repoId: repo.repoId, repoDirectory: directory })
1330
+ await sendUploadedBundle(req, res, deps, cookie, { id, integrity, filename, byteSize: bytes.length, repoId, repoDirectory: directory }, false)
1236
1331
  }
1237
1332
 
1238
1333
  // GET /api/admin/bundles/<id> — download a stored bundle (admin|manage). Bytes
1239
1334
  // are opaque archives, so served as application/octet-stream. Sourcemaps use
1240
1335
  // HTTP Brotli decoding to restore the uploaded .map bytes. 404 no such
1241
1336
  // bundle; 503 row-without-bytes (store desync).
1242
- async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1243
- const s = await readSession(deps.config, deps.db, cookie, Date.now())
1244
- if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1245
- if (!(await canAccessBundle(deps, s.user, id))) { sendJson(res, 404, { error: 'no-bundle' }); return }
1246
- const rec = await deps.db.getBundle(id)
1337
+ async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string): Promise<void> {
1338
+ const access = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
1339
+ if (!access) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1340
+ const rec = access.bundle
1247
1341
  if (rec == null) { sendJson(res, 404, { error: 'no-bundle' }); return }
1248
1342
  const stored = await deps.bundleStore.open(id, rec.kind)
1249
1343
  if (stored == null) { sendJson(res, 503, { error: 'unavailable' }); return }
1250
- const current = await readSession(deps.config, deps.db, cookie, Date.now())
1251
- if (!current || !(await canAccessBundle(deps, current.user, id))) {
1344
+ const current = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
1345
+ if (!current?.bundle) {
1252
1346
  stored.stream.destroy()
1253
1347
  sendJson(res, current ? 404 : 401, { error: 'no-bundle' }); return
1254
1348
  }
@@ -1302,17 +1396,22 @@ async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: Ma
1302
1396
  // GET /api/teams — the CURRENT user's teams, each with the reports and bundles
1303
1397
  // attached to the team's repos, for the sidebar's per-user Teams section. Any approved
1304
1398
  // user (not just admin|manage); a user only ever sees their own teams.
1305
- async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1306
- let s = await readSession(deps.config, deps.db, cookie, Date.now())
1307
- if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1308
- const summaries = await bundleSummaries(s.user.role === 'none' ? [] : (await deps.db.listTeamsForUser(s.user.id)).flatMap(team => team.bundles), deps.bundleCache)
1309
- s = await readSession(deps.config, deps.db, cookie, Date.now())
1310
- if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1311
- const teams = s.user.role === 'none' ? [] : await deps.db.listTeamsForUser(s.user.id)
1399
+ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession): Promise<void> {
1400
+ let snapshot = await deps.db.getUserTeamFeedSnapshot(session.id, Date.now())
1401
+ if (!snapshot) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1402
+ const summaries = await bundleSummaries(snapshot.teams.flatMap(team => team.bundles), deps.bundleCache)
1403
+ const current = await deps.db.getFeedState(session.id, Date.now())
1404
+ if (!current) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1405
+ // Keep the post-storage authorization fence, but reload the catalog only
1406
+ // when a concurrent mutation actually invalidated its consistent snapshot.
1407
+ if (current.user.id !== snapshot.user.id || current.user.role !== snapshot.user.role || current.catalog !== snapshot.catalog) {
1408
+ snapshot = await deps.db.getUserTeamFeedSnapshot(session.id, Date.now())
1409
+ if (!snapshot) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1410
+ }
1411
+ const { teams, revision } = snapshot
1312
1412
  sendJson(res, 200, {
1313
1413
  teams: teams.map(team => ({ ...team, bundles: team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }) })) })),
1314
- // Derivatives do not change the catalog revision used by the live feed.
1315
- revision: teamCatalogRevision(teams),
1414
+ revision,
1316
1415
  })
1317
1416
  await backfillBundleSummaries(teams.flatMap(team => team.bundles), deps.bundleCache)
1318
1417
  }
@@ -1500,17 +1599,12 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
1500
1599
 
1501
1600
  // The server selects the complete workspace; clients cannot omit a report
1502
1601
  // or links file to evade classification through the rest of their team.
1503
- async function handleTeamReports(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string): Promise<void> {
1504
- const s = await readSession(deps.config, deps.db, cookie, Date.now())
1505
- if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1506
- const snapshot = await teamSnapshot(deps.db, s.session.id, teamId)
1507
- const reports = await loadTeamReports(deps.db, deps.reportStore, snapshot)
1508
- await recheckTeam(deps.db, s.session.id, snapshot)
1509
- const parts = [Buffer.from('{"reports":[')]
1510
- for (const [index, report] of reports.entries()) parts.push(Buffer.from(`${index ? ',' : ''}${JSON.stringify(report)}`))
1511
- parts.push(Buffer.from(']}'))
1602
+ async function handleTeamReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, teamId: string): Promise<void> {
1603
+ const snapshot = await teamSnapshot(deps.db, session.id, teamId)
1604
+ const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
1605
+ await recheckTeam(deps.db, session.id, snapshot)
1512
1606
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
1513
- writeResponse(res, Buffer.concat(parts))
1607
+ writeResponse(res, body)
1514
1608
  }
1515
1609
 
1516
1610
  // Triage requires both a writing role and access to the report itself.
@@ -2013,7 +2107,8 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2013
2107
  // every managed data route before reading bodies or looking up resources.
2014
2108
  const managedDataPath = ['/api/admin', '/api/reports', '/api/bundles', '/api/teams', '/api/avatar', '/api/github']
2015
2109
  .some(prefix => path === prefix || path.startsWith(prefix + '/'))
2016
- if (managedDataPath && await readWorkspaceSession(res, deps, cookie) == null) return
2110
+ const workspaceSession = managedDataPath ? await readWorkspaceSession(res, deps, cookie) : null
2111
+ if (managedDataPath && !workspaceSession) return
2017
2112
  const issueRoute = /^\/api\/teams\/([^/]+)\/issues$/u.exec(path)
2018
2113
  if (issueRoute) {
2019
2114
  await handleWorkspaceIssue(req, res, deps, cookie, decodeURIComponent(issueRoute[1]!), url.searchParams); return
@@ -2083,7 +2178,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2083
2178
  // Reports: list / upload on the exact path, download / delete per-id on the
2084
2179
  // prefix. Method-dispatched here since each path carries two verbs.
2085
2180
  if (path === ADMIN_REPORTS_PATH) {
2086
- if (method === 'GET') { await handleListReports(res, deps, cookie); return }
2181
+ if (method === 'GET') { await handleListReports(res, deps, workspaceSession!.session); return }
2087
2182
  if (method === 'POST') { await handleUploadReport(req, res, deps, cookie); return }
2088
2183
  send405(res, 'GET, POST'); return
2089
2184
  }
@@ -2117,19 +2212,23 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2117
2212
  const bundleAdvisories = /^\/api\/bundles\/([a-f\d-]{36})\/advisories$/iu.exec(path)
2118
2213
  if (bundleAdvisories) {
2119
2214
  if (method !== 'GET') { send405(res, 'GET'); return }
2120
- await handleBundleAdvisories(res, deps, cookie, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '')
2215
+ await handleBundleAdvisories(res, deps, workspaceSession!.session, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '', url.searchParams.get('repoAdvisories') === 'true')
2121
2216
  return
2122
2217
  }
2123
2218
  const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download)$/iu.exec(path)
2124
2219
  if (bundleRead) {
2125
2220
  if (method !== 'GET' && method !== 'HEAD') { send405(res, 'GET, HEAD'); return }
2126
2221
  const id = bundleRead[1]!
2127
- if (bundleRead[2] === 'download') await handleGetBundle(req, res, deps, cookie, id)
2128
- else await handleBundleCache(req, res, deps, cookie, id, bundleRead[2] as BundleCachePart)
2222
+ if (bundleRead[2] === 'download') await handleGetBundle(req, res, deps, workspaceSession!.session, id)
2223
+ else await handleBundleCache(req, res, deps, workspaceSession!.session, id, bundleRead[2] as BundleCachePart)
2129
2224
  return
2130
2225
  }
2226
+ if (path === BUNDLE_CREATE_PATH) {
2227
+ if (method !== 'POST') { send405(res, 'POST'); return }
2228
+ await handleCreateBundle(req, res, deps, cookie); return
2229
+ }
2131
2230
  if (path === ADMIN_BUNDLES_PATH) {
2132
- if (method === 'GET') { await handleListBundles(res, deps, cookie); return }
2231
+ if (method === 'GET') { await handleListBundles(res, deps, workspaceSession!.session); return }
2133
2232
  if (method === 'POST') { await handleUploadBundle(req, res, deps, cookie); return }
2134
2233
  send405(res, 'GET, POST'); return
2135
2234
  }
@@ -2139,7 +2238,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2139
2238
  }
2140
2239
  if (path.startsWith(BUNDLE_PREFIX)) {
2141
2240
  const id = path.slice(BUNDLE_PREFIX.length)
2142
- if (method === 'GET') { await handleGetBundle(req, res, deps, cookie, id); return }
2241
+ if (method === 'GET') { await handleGetBundle(req, res, deps, workspaceSession!.session, id); return }
2143
2242
  if (method === 'DELETE') { await handleDeleteBundle(req, res, deps, cookie, id); return }
2144
2243
  send405(res, 'GET, DELETE'); return
2145
2244
  }
@@ -2157,10 +2256,18 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2157
2256
  if (method !== 'GET') { send405(res, 'GET'); return }
2158
2257
  await handleWorkspaceFixes(res, deps, cookie, teamFixes[1]!); return
2159
2258
  }
2259
+ const teamAnnotations = /^\/api\/teams\/([^/]+)\/annotations$/u.exec(path)
2260
+ if (teamAnnotations) {
2261
+ if (method !== 'GET') { send405(res, 'GET'); return }
2262
+ const snapshot = await teamSnapshot(db, workspaceSession!.session.id, teamAnnotations[1]!)
2263
+ const annotations = await loadTeamAnnotations(db, deps.reportStore, snapshot, url.searchParams.get('reportId'))
2264
+ await recheckTeam(db, workspaceSession!.session.id, snapshot)
2265
+ sendJson(res, 200, annotations); return
2266
+ }
2160
2267
  const teamReports = /^\/api\/teams\/([^/]+)\/reports$/u.exec(path)
2161
2268
  if (teamReports) {
2162
2269
  if (method !== 'GET') { send405(res, 'GET'); return }
2163
- await handleTeamReports(res, deps, cookie, teamReports[1]!); return
2270
+ await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!); return
2164
2271
  }
2165
2272
  if (path === '/api/reports/query') {
2166
2273
  if (method !== 'POST') { send405(res, 'POST'); return }
@@ -2198,7 +2305,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2198
2305
  // The signed-in user's own team memberships (any approved user).
2199
2306
  if (path === MY_TEAMS_PATH) {
2200
2307
  if (method !== 'GET') { send405(res, 'GET'); return }
2201
- await handleMyTeams(res, deps, cookie); return
2308
+ await handleMyTeams(res, deps, workspaceSession!.session); return
2202
2309
  }
2203
2310
  // Teams: list / create on the exact path; the link mutations are POST-only
2204
2311
  // action sub-paths (each carries its ids in the JSON body).
@@ -2225,7 +2332,11 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2225
2332
 
2226
2333
  return (req, res) => {
2227
2334
  if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
2228
- const work = route(req, res).catch((err) => {
2335
+ if (!deps.originGate.isOriginAllowed(req)) { sendJson(res, 403, { error: 'origin-denied' }); return }
2336
+ // Feeds lease a connection per poll, never across streaming sleeps. This
2337
+ // also covers capability-authenticated feeds handled by public-workspace.
2338
+ const feed = /^\/api\/teams(?:\/[^/?]+)?\/feed(?:\?|$)/u.test(req.url ?? '')
2339
+ const work = (db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res)).catch((err) => {
2229
2340
  if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
2230
2341
  console.warn('managed: request handler error:', err)
2231
2342
  if (res.headersSent) { try { res.destroy() } catch {} }