@preventive/triage 1.0.0-alpha.27 → 1.0.0-alpha.29

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 (83) 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 +16 -16
  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 +21 -28
  19. package/server-e2e/static.ts +3 -4
  20. package/server-managed/bundle-advisories.ts +53 -0
  21. package/server-managed/bundle-advisory-inventory.ts +75 -0
  22. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  23. package/server-managed/bundle-build-diagnostics.js +23 -0
  24. package/server-managed/bundle-build-leases.ts +41 -0
  25. package/server-managed/bundle-build-worker.js +54 -0
  26. package/server-managed/bundle-build.ts +168 -0
  27. package/server-managed/bundle-cache.ts +22 -13
  28. package/server-managed/bundle-summary-cache.ts +27 -9
  29. package/server-managed/bundle.ts +66 -1
  30. package/server-managed/cache-storage.ts +4 -0
  31. package/server-managed/content-access.ts +1 -1
  32. package/server-managed/db-methods.ts +122 -20
  33. package/server-managed/db-neon.ts +121 -32
  34. package/server-managed/db-schema.ts +2 -0
  35. package/server-managed/db.ts +5 -1
  36. package/server-managed/github-app.ts +10 -15
  37. package/server-managed/github-errors.ts +21 -0
  38. package/server-managed/github-issues.ts +1 -1
  39. package/server-managed/github-metadata.ts +34 -1
  40. package/server-managed/github-pulls.ts +142 -30
  41. package/server-managed/http.ts +244 -131
  42. package/server-managed/index.ts +28 -11
  43. package/server-managed/management-catalog.ts +45 -0
  44. package/server-managed/object-storage-vercel.ts +1 -1
  45. package/server-managed/public-workspace.ts +20 -14
  46. package/server-managed/report-migration.ts +1 -1
  47. package/server-managed/report-sources.ts +14 -4
  48. package/server-managed/repository-browser.ts +3 -0
  49. package/server-managed/repository-discovery.ts +1 -1
  50. package/server-managed/revisions.ts +35 -0
  51. package/server-managed/sql-postgres.ts +1 -0
  52. package/server-managed/sql.ts +14 -3
  53. package/server-managed/storage-db.ts +12 -5
  54. package/server-managed/storage-maintenance.ts +47 -22
  55. package/server-managed/storage-payload.ts +16 -4
  56. package/server-managed/storage-stores.ts +2 -2
  57. package/server-managed/storage.ts +6 -4
  58. package/server-managed/team-feed.ts +50 -5
  59. package/server-managed/team-reports.ts +85 -22
  60. package/server-managed/workspace-shares.ts +15 -3
  61. package/report/index.js +0 -260
  62. package/report/src/finding-id.js +0 -80
  63. package/report/src/finding.js +0 -314
  64. package/report/src/labels.js +0 -35
  65. package/report/src/md-structure.js +0 -471
  66. package/report/src/md-text.js +0 -167
  67. package/report/src/meta.js +0 -76
  68. package/report/src/parse-codex.js +0 -149
  69. package/report/src/parse-deepsec.js +0 -199
  70. package/report/src/parse-deepview-fields.js +0 -375
  71. package/report/src/parse-deepview-md.js +0 -186
  72. package/report/src/parse-md-id.js +0 -137
  73. package/report/src/parse-md.js +0 -323
  74. package/report/src/parse-piolium-id.js +0 -79
  75. package/report/src/parse-piolium-rows.js +0 -126
  76. package/report/src/parse-piolium-tokens.js +0 -185
  77. package/report/src/parse-piolium.js +0 -399
  78. package/report/src/security.js +0 -63
  79. package/report/src/severity.js +0 -18
  80. package/report/src/utf8.js +0 -21
  81. package/report/src/write-md-finding.js +0 -273
  82. package/report/src/write-md.js +0 -291
  83. /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 { ManagedBundle, 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,9 +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
96
  import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
97
+ import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
96
98
 
97
99
  const SESSION_PATH = '/api/auth/session'
98
100
  const AVATAR_PREFIX = '/api/avatar/'
@@ -115,6 +117,7 @@ const REPORT_SET_REPO_PATH = '/api/admin/reports/set-repo'
115
117
  const REPORT_SET_VISIBLE_PATH = '/api/admin/reports/set-visible'
116
118
  const REPORT_PREFIX = '/api/admin/reports/'
117
119
  const ADMIN_BUNDLES_PATH = '/api/admin/bundles'
120
+ const BUNDLE_CREATE_PATH = '/api/admin/bundles/create'
118
121
  const BUNDLE_SET_REPO_PATH = '/api/admin/bundles/set-repo'
119
122
  const BUNDLE_PREFIX = '/api/admin/bundles/'
120
123
  const MY_TEAMS_PATH = '/api/teams'
@@ -317,16 +320,15 @@ async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES
317
320
  return JSON.parse(buf.toString('utf8') || 'null')
318
321
  }
319
322
 
320
- // Validate a mutation: same-origin gate + an authenticated session whose
321
- // double-submit CSRF token matches the X-CSRF-Token header. Returns the session,
322
- // 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.
323
326
  async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
324
- if (!deps.originGate.isOriginAllowed(req)) { sendJson(res, 403, { error: 'origin-denied' }); return null }
325
327
  const s = await readSession(deps.config, deps.db, cookie, Date.now())
326
328
  if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
327
329
  const csrf = firstHeader(req.headers['x-csrf-token'])
328
330
  if (csrf == null) { sendJson(res, 403, { error: 'csrf-missing' }); return null }
329
- 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 }
330
332
  return s
331
333
  }
332
334
 
@@ -747,12 +749,6 @@ async function resolveUploadRepoId(req: IncomingMessage, res: ServerResponse, de
747
749
  return { ok: true, repoId: n }
748
750
  }
749
751
 
750
- async function canChangeReportRepo(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
751
- if (!roleAtLeast(user.role, 'manage') || !(await canViewReport(deps, user, reportId))) return false
752
- const report = await deps.db.getReport(reportId)
753
- return report != null && (user.role === 'admin' || report.repoId === null || await deps.db.userCanReadReport(user.id, reportId))
754
- }
755
-
756
752
  // POST /api/admin/reports/set-repo — attach / detach a stored report's repo
757
753
  // + directory link. Mutation: same-origin + CSRF, admin|manage. Body
758
754
  // { reportId, repoId, directory }, where repoId is null (detach) or a
@@ -814,18 +810,10 @@ async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, de
814
810
  // Visible to admin|manage. Read-only, so no CSRF (like /api/admin/users).
815
811
  // `maxBytes` lets the page show / pre-check the upload size cap; `repos` feeds
816
812
  // the upload repo picker.
817
- async function handleListReports(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
818
- const s = await readManageSession(res, deps, cookie)
819
- if (s == null) return
813
+ async function handleListReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession): Promise<void> {
820
814
  sendJson(res, 200, {
821
- reports: await Promise.all((await deps.db.listReports(s.user.role === 'admin' ? undefined : s.user.id)).map(async report => {
822
- const bundleAllowed = report.bundleId != null && await canAccessBundle(deps, s.user, report.bundleId)
823
- return { ...report, canChangeRepo: await canChangeReportRepo(deps, s.user, report.id),
824
- bundleId: bundleAllowed ? report.bundleId : null, bundleFilename: bundleAllowed ? report.bundleFilename : null }
825
- })),
815
+ ...await deps.db.getReportCatalog(session.id, Date.now()),
826
816
  maxBytes: deps.config.maxReportBytes,
827
- repos: selectableRepos(await bundleRepos(deps, s.user)),
828
- repoScopes: s.user.role === 'admin' ? null : await deps.db.listRepoScopesForUser(s.user.id),
829
817
  })
830
818
  }
831
819
 
@@ -988,15 +976,10 @@ async function canAccessBundle(deps: ManagedHttpDeps, user: StoredUser, id: stri
988
976
  return rec.repoId !== null && deps.db.userCanReadRepoPath(user.id, rec.repoId, rec.repoDirectory)
989
977
  }
990
978
 
991
- async function canChangeBundleRepo(deps: ManagedHttpDeps, user: StoredUser, repoId: number | null, directory: string) {
992
- return roleAtLeast(user.role, 'manage') && (user.role === 'admin' || repoId === null || await deps.db.userCanReadRepoPath(user.id, repoId, directory))
993
- }
994
-
995
979
  async function bundleRepos(deps: ManagedHttpDeps, user: StoredUser) {
996
980
  const repos = await deps.db.listSelectedRepos()
997
- if (user.role === 'admin') return repos
998
- const allowed = await Promise.all(repos.map(repo => deps.db.userCanReadRepo(user.id, repo.repoId)))
999
- return repos.filter((_, index) => allowed[index])
981
+ const access = await contentAccess(deps.db, user)
982
+ return repos.filter(access.bundle)
1000
983
  }
1001
984
 
1002
985
  async function repositoryBrowserUser(deps: ManagedHttpDeps, userId: string) {
@@ -1014,6 +997,40 @@ async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHtt
1014
997
  sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
1015
998
  }
1016
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
+
1017
1034
  // Recheck repository/path grants after upstream reads, before returning names.
1018
1035
  async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams, refs: boolean): Promise<void> {
1019
1036
  const repoId = Number(query.get('repoId'))
@@ -1022,7 +1039,8 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1022
1039
  if (!Number.isSafeInteger(repoId) || repoId <= 0 || !normalized.ok || ref.length > 1024 || /\p{Cc}/u.test(ref)) {
1023
1040
  sendJson(res, 400, { error: 'bad-request' }); return
1024
1041
  }
1025
- const path = normalized.path ?? ''
1042
+ const withDefault = refs && query.get('withDefault') === 'true'
1043
+ const path = refs ? '' : normalized.path ?? ''
1026
1044
  const authorize = async () => {
1027
1045
  const s = await readManageSession(res, deps, cookie)
1028
1046
  if (!s) return null
@@ -1030,7 +1048,7 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1030
1048
  if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
1031
1049
  const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
1032
1050
  const virtualEntries = scopedDirectory(path, scopes)
1033
- 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 }
1034
1052
  return { repo, virtualEntries, user: s.user }
1035
1053
  }
1036
1054
  const access = await authorize()
@@ -1038,16 +1056,19 @@ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDep
1038
1056
  try {
1039
1057
  const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id))
1040
1058
  const reader = await browser.reader(access.repo)
1041
- const commit = refs ? '' : await reader.commit(ref)
1042
- const result = refs ? await reader.refs() : access.virtualEntries
1043
- ? { 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)
1044
1061
  await reader.recheckAccess()
1045
1062
  const current = await authorize()
1046
1063
  if (!current) return
1047
- if (JSON.stringify(current.repo) !== JSON.stringify(access.repo)) { sendJson(res, 409, { error: 'repository-changed' }); return }
1048
- sendJson(res, 200, refs ? result : {
1049
- ...result, ...(current.virtualEntries ? { entries: current.virtualEntries, limited: false, packageEntryPoints: [], solidityEntryPoints: [], soliditySuggestionsLimited: false } : {}), path, commit,
1050
- })
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))
1051
1072
  } catch (err) {
1052
1073
  if (err instanceof GithubApiError) {
1053
1074
  sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
@@ -1067,19 +1088,18 @@ function prebuildBundle(deps: ManagedHttpDeps, id: string) {
1067
1088
 
1068
1089
  // GET /api/bundles/:id/{metadata,contents}. Source/cache bytes are already encoded;
1069
1090
  // the browser's HTTP stack decompresses them without a Brotli JS dependency.
1070
- async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, part: BundleCachePart) {
1071
- const s = await readSession(deps.config, deps.db, cookie, Date.now())
1072
- if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1073
- if (!(await canAccessBundle(deps, s.user, id))) { sendJson(res, 404, { error: 'no-bundle' }); return }
1074
- 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
1075
1095
  if (!rec) { sendJson(res, 404, { error: 'no-bundle' }); return }
1076
1096
  if (!deps.bundleCache) { sendJson(res, 503, { error: 'unavailable' }); return }
1077
1097
  let cached
1078
1098
  try { cached = await deps.bundleCache.open(rec, part) }
1079
1099
  catch { sendJson(res, 422, { error: 'bundle-unavailable' }); return }
1080
1100
  // A cold build can outlast a session or membership change.
1081
- const current = await readSession(deps.config, deps.db, cookie, Date.now())
1082
- if (!current || !(await canAccessBundle(deps, current.user, id))) {
1101
+ const current = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
1102
+ if (!current?.bundle) {
1083
1103
  cached.stream.destroy()
1084
1104
  sendJson(res, current ? 404 : 401, { error: current ? 'no-bundle' : 'unauthenticated' })
1085
1105
  return
@@ -1093,42 +1113,44 @@ async function handleBundleCache(req: IncomingMessage, res: ServerResponse, deps
1093
1113
  try { await pipeline(cached.stream, res) } catch { res.destroy() }
1094
1114
  }
1095
1115
 
1096
- // Published npm advisories require security access, independently of access to
1116
+ // Published dependency advisories require security access, independently of access to
1097
1117
  // unpublished dependency findings. Managers retain their normal bundle access.
1098
- 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) {
1099
1119
  const authorize = async () => {
1100
- const session = await readSession(deps.config, deps.db, cookie, Date.now())
1101
- if (!session) { sendJson(res, 401, { error: 'unauthenticated' }); return false }
1102
- if (!(await canAccessBundle(deps, session.user, id))) { sendJson(res, 404, { error: 'no-bundle' }); return false }
1103
- if (!roleAtLeast(session.user.role, 'manage') && !(await deps.db.userCanReadBundleAdvisories(session.user.id, id, teamId))) {
1104
- 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
1105
1125
  }
1106
- return true
1126
+ return access.bundle
1107
1127
  }
1108
- if (!(await authorize())) return
1109
- const record = await deps.db.getBundle(id)
1110
- if (!record) { sendJson(res, 404, { error: 'no-bundle' }); return }
1128
+ const record = await authorize()
1129
+ if (!record) return
1111
1130
  if (record.kind !== 'stasis') { sendJson(res, 422, { error: 'unsupported-bundle' }); return }
1112
1131
  if (!deps.bundleCache) { sendJson(res, 503, { error: 'unavailable' }); return }
1113
- let packages: Record<string, string[]> | null | undefined
1114
- try { packages = await deps.bundleCache.packageVersions(record, reason) }
1132
+ let inventory: BundleAdvisoryInventory | null | undefined
1133
+ try { inventory = await deps.bundleCache.advisoryInventory(record, reason) }
1115
1134
  catch { sendJson(res, 422, { error: 'bundle-unavailable' }); return }
1116
1135
  if (!(await authorize())) return
1117
- if (packages === null) { sendJson(res, 413, { error: 'payload-too-large' }); return }
1118
- if (packages === undefined) { sendJson(res, 400, { error: 'unknown-reason' }); return }
1119
- const body = Buffer.from(JSON.stringify(packages))
1120
- 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 }
1121
1139
  const controller = new AbortController()
1122
1140
  const onClose = () => { if (!res.writableEnded) controller.abort() }
1123
1141
  res.on('close', onClose)
1124
- const timer = setTimeout(() => controller.abort(), NPM_ADVISORIES_TIMEOUT_MS)
1142
+ const timer = setTimeout(() => controller.abort(), ADVISORIES_TIMEOUT_MS)
1125
1143
  try {
1126
1144
  if (res.destroyed) return
1127
- const result = Object.keys(packages).length === 0 ? { status: 200, body: {} }
1128
- : 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 })
1129
1151
  if (res.destroyed || !(await authorize())) return
1130
1152
  // Return just inventory and public advisories, never source or report data.
1131
- 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)
1132
1154
  } finally {
1133
1155
  clearTimeout(timer)
1134
1156
  res.off('close', onClose)
@@ -1138,24 +1160,19 @@ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps
1138
1160
  // GET /api/admin/bundles — the uploaded bundles for the "Manage bundles" page.
1139
1161
  // admin|manage, read-only (no CSRF). `maxBytes` is the upload cap; `repos` feeds
1140
1162
  // the upload repo picker.
1141
- async function handleListBundles(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1142
- let s = await readManageSession(res, deps, cookie)
1143
- if (s == null) return
1144
- 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)
1145
1166
  // Even cached storage reads may outlast changes to access or locations.
1146
- s = await readManageSession(res, deps, cookie)
1147
- if (s == null) return
1148
- 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())
1149
1168
  sendJson(res, 200, {
1150
- bundles: await Promise.all(bundles.map(async bundle => ({
1169
+ ...catalog,
1170
+ bundles: catalog.bundles.map(bundle => ({
1151
1171
  ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }),
1152
- canChangeRepo: await canChangeBundleRepo(deps, s.user, bundle.repoId, bundle.repoDirectory),
1153
- }))),
1172
+ })),
1154
1173
  maxBytes: deps.config.maxBundleBytes,
1155
- repos: selectableRepos(await bundleRepos(deps, s.user)),
1156
- repoScopes: s.user.role === 'admin' ? null : await deps.db.listRepoScopesForUser(s.user.id),
1157
1174
  })
1158
- await backfillBundleSummaries(bundles, deps.bundleCache)
1175
+ await backfillBundleSummaries(catalog.bundles, deps.bundleCache)
1159
1176
  }
1160
1177
 
1161
1178
  type UploadedBundle = Pick<ManagedBundle, 'id' | 'integrity' | 'filename' | 'byteSize' | 'repoId' | 'repoDirectory'>
@@ -1170,9 +1187,60 @@ async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, dep
1170
1187
  sendJson(res, deduped ? 200 : 201, { id, integrity, filename, repoId, repoDirectory, ...(deduped ? { deduped: true } : { byteSize }) })
1171
1188
  }
1172
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) }
1237
+ }
1238
+
1173
1239
  // POST /api/admin/bundles — upload a bundle. Mutation: same-origin + CSRF,
1174
1240
  // admin|manage. Raw bytes; X-Bundle-Filename names it, optional X-Repo-Id and
1175
- // 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
1176
1244
  // re-upload of identical bytes dedupes to the existing row (no second copy).
1177
1245
  // After storing, any reports that declared this integrity but weren't linked yet
1178
1246
  // get attached (auto-link). 413 over the cap, 400 on empty.
@@ -1194,17 +1262,46 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
1194
1262
  if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
1195
1263
  const repo = await resolveUploadRepoId(req, res, deps)
1196
1264
  if (!repo.ok) return
1197
- 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
1198
1273
  try { rawDirectory = decodeURIComponent(firstHeader(req.headers['x-repo-directory']) ?? '') }
1199
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
+ }
1200
1290
  const normalized = normalizeTeamPath(rawDirectory)
1201
1291
  if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
1202
- const directory = repo.repoId == null ? '' : normalized.path ?? ''
1292
+ let directory = repo.repoId == null ? '' : normalized.path ?? ''
1203
1293
  if (repo.repoId !== null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repo.repoId, directory))) {
1204
- 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 = ''
1205
1299
  }
1206
- const integrity = bundleIntegrity(bytes)
1207
- 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> {
1208
1305
  const existing = await deps.db.getBundleByIntegrity(integrity)
1209
1306
  if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, true); return }
1210
1307
  const id = randomUUID()
@@ -1213,7 +1310,7 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
1213
1310
  try {
1214
1311
  await deps.db.insertBundle({
1215
1312
  id, integrity, filename, kind, dataKey,
1216
- byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: repo.repoId, repoDirectory: directory,
1313
+ byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId, repoDirectory: directory,
1217
1314
  }, Date.now(), s.session.id)
1218
1315
  } catch (err) {
1219
1316
  // A concurrent upload of identical bytes can insert this integrity (UNIQUE)
@@ -1230,23 +1327,22 @@ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, dep
1230
1327
  if (raced.id !== id) { await sendUploadedBundle(req, res, deps, cookie, raced, true); return }
1231
1328
  // Our insert committed: continue with the ordinary creation response.
1232
1329
  }
1233
- await sendUploadedBundle(req, res, deps, cookie, { id, integrity, filename, byteSize: bytes.length, repoId: repo.repoId, repoDirectory: directory }, false)
1330
+ await sendUploadedBundle(req, res, deps, cookie, { id, integrity, filename, byteSize: bytes.length, repoId, repoDirectory: directory }, false)
1234
1331
  }
1235
1332
 
1236
1333
  // GET /api/admin/bundles/<id> — download a stored bundle (admin|manage). Bytes
1237
1334
  // are opaque archives, so served as application/octet-stream. Sourcemaps use
1238
1335
  // HTTP Brotli decoding to restore the uploaded .map bytes. 404 no such
1239
1336
  // bundle; 503 row-without-bytes (store desync).
1240
- async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1241
- const s = await readSession(deps.config, deps.db, cookie, Date.now())
1242
- if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1243
- if (!(await canAccessBundle(deps, s.user, id))) { sendJson(res, 404, { error: 'no-bundle' }); return }
1244
- 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
1245
1341
  if (rec == null) { sendJson(res, 404, { error: 'no-bundle' }); return }
1246
1342
  const stored = await deps.bundleStore.open(id, rec.kind)
1247
1343
  if (stored == null) { sendJson(res, 503, { error: 'unavailable' }); return }
1248
- const current = await readSession(deps.config, deps.db, cookie, Date.now())
1249
- if (!current || !(await canAccessBundle(deps, current.user, id))) {
1344
+ const current = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
1345
+ if (!current?.bundle) {
1250
1346
  stored.stream.destroy()
1251
1347
  sendJson(res, current ? 404 : 401, { error: 'no-bundle' }); return
1252
1348
  }
@@ -1300,17 +1396,22 @@ async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: Ma
1300
1396
  // GET /api/teams — the CURRENT user's teams, each with the reports and bundles
1301
1397
  // attached to the team's repos, for the sidebar's per-user Teams section. Any approved
1302
1398
  // user (not just admin|manage); a user only ever sees their own teams.
1303
- async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1304
- let s = await readSession(deps.config, deps.db, cookie, Date.now())
1305
- if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1306
- const summaries = await bundleSummaries(s.user.role === 'none' ? [] : (await deps.db.listTeamsForUser(s.user.id)).flatMap(team => team.bundles), deps.bundleCache)
1307
- s = await readSession(deps.config, deps.db, cookie, Date.now())
1308
- if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1309
- 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
1310
1412
  sendJson(res, 200, {
1311
1413
  teams: teams.map(team => ({ ...team, bundles: team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }) })) })),
1312
- // Derivatives do not change the catalog revision used by the live feed.
1313
- revision: teamCatalogRevision(teams),
1414
+ revision,
1314
1415
  })
1315
1416
  await backfillBundleSummaries(teams.flatMap(team => team.bundles), deps.bundleCache)
1316
1417
  }
@@ -1498,17 +1599,12 @@ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, dep
1498
1599
 
1499
1600
  // The server selects the complete workspace; clients cannot omit a report
1500
1601
  // or links file to evade classification through the rest of their team.
1501
- async function handleTeamReports(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string): Promise<void> {
1502
- const s = await readSession(deps.config, deps.db, cookie, Date.now())
1503
- if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1504
- const snapshot = await teamSnapshot(deps.db, s.session.id, teamId)
1505
- const reports = await loadTeamReports(deps.db, deps.reportStore, snapshot)
1506
- await recheckTeam(deps.db, s.session.id, snapshot)
1507
- const parts = [Buffer.from('{"reports":[')]
1508
- for (const [index, report] of reports.entries()) parts.push(Buffer.from(`${index ? ',' : ''}${JSON.stringify(report)}`))
1509
- 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)
1510
1606
  res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
1511
- writeResponse(res, Buffer.concat(parts))
1607
+ writeResponse(res, body)
1512
1608
  }
1513
1609
 
1514
1610
  // Triage requires both a writing role and access to the report itself.
@@ -2011,7 +2107,8 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2011
2107
  // every managed data route before reading bodies or looking up resources.
2012
2108
  const managedDataPath = ['/api/admin', '/api/reports', '/api/bundles', '/api/teams', '/api/avatar', '/api/github']
2013
2109
  .some(prefix => path === prefix || path.startsWith(prefix + '/'))
2014
- if (managedDataPath && await readWorkspaceSession(res, deps, cookie) == null) return
2110
+ const workspaceSession = managedDataPath ? await readWorkspaceSession(res, deps, cookie) : null
2111
+ if (managedDataPath && !workspaceSession) return
2015
2112
  const issueRoute = /^\/api\/teams\/([^/]+)\/issues$/u.exec(path)
2016
2113
  if (issueRoute) {
2017
2114
  await handleWorkspaceIssue(req, res, deps, cookie, decodeURIComponent(issueRoute[1]!), url.searchParams); return
@@ -2081,7 +2178,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2081
2178
  // Reports: list / upload on the exact path, download / delete per-id on the
2082
2179
  // prefix. Method-dispatched here since each path carries two verbs.
2083
2180
  if (path === ADMIN_REPORTS_PATH) {
2084
- if (method === 'GET') { await handleListReports(res, deps, cookie); return }
2181
+ if (method === 'GET') { await handleListReports(res, deps, workspaceSession!.session); return }
2085
2182
  if (method === 'POST') { await handleUploadReport(req, res, deps, cookie); return }
2086
2183
  send405(res, 'GET, POST'); return
2087
2184
  }
@@ -2115,19 +2212,23 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2115
2212
  const bundleAdvisories = /^\/api\/bundles\/([a-f\d-]{36})\/advisories$/iu.exec(path)
2116
2213
  if (bundleAdvisories) {
2117
2214
  if (method !== 'GET') { send405(res, 'GET'); return }
2118
- 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')
2119
2216
  return
2120
2217
  }
2121
2218
  const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download)$/iu.exec(path)
2122
2219
  if (bundleRead) {
2123
2220
  if (method !== 'GET' && method !== 'HEAD') { send405(res, 'GET, HEAD'); return }
2124
2221
  const id = bundleRead[1]!
2125
- if (bundleRead[2] === 'download') await handleGetBundle(req, res, deps, cookie, id)
2126
- 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)
2127
2224
  return
2128
2225
  }
2226
+ if (path === BUNDLE_CREATE_PATH) {
2227
+ if (method !== 'POST') { send405(res, 'POST'); return }
2228
+ await handleCreateBundle(req, res, deps, cookie); return
2229
+ }
2129
2230
  if (path === ADMIN_BUNDLES_PATH) {
2130
- if (method === 'GET') { await handleListBundles(res, deps, cookie); return }
2231
+ if (method === 'GET') { await handleListBundles(res, deps, workspaceSession!.session); return }
2131
2232
  if (method === 'POST') { await handleUploadBundle(req, res, deps, cookie); return }
2132
2233
  send405(res, 'GET, POST'); return
2133
2234
  }
@@ -2137,7 +2238,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2137
2238
  }
2138
2239
  if (path.startsWith(BUNDLE_PREFIX)) {
2139
2240
  const id = path.slice(BUNDLE_PREFIX.length)
2140
- if (method === 'GET') { await handleGetBundle(req, res, deps, cookie, id); return }
2241
+ if (method === 'GET') { await handleGetBundle(req, res, deps, workspaceSession!.session, id); return }
2141
2242
  if (method === 'DELETE') { await handleDeleteBundle(req, res, deps, cookie, id); return }
2142
2243
  send405(res, 'GET, DELETE'); return
2143
2244
  }
@@ -2155,10 +2256,18 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2155
2256
  if (method !== 'GET') { send405(res, 'GET'); return }
2156
2257
  await handleWorkspaceFixes(res, deps, cookie, teamFixes[1]!); return
2157
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
+ }
2158
2267
  const teamReports = /^\/api\/teams\/([^/]+)\/reports$/u.exec(path)
2159
2268
  if (teamReports) {
2160
2269
  if (method !== 'GET') { send405(res, 'GET'); return }
2161
- await handleTeamReports(res, deps, cookie, teamReports[1]!); return
2270
+ await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!); return
2162
2271
  }
2163
2272
  if (path === '/api/reports/query') {
2164
2273
  if (method !== 'POST') { send405(res, 'POST'); return }
@@ -2196,7 +2305,7 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2196
2305
  // The signed-in user's own team memberships (any approved user).
2197
2306
  if (path === MY_TEAMS_PATH) {
2198
2307
  if (method !== 'GET') { send405(res, 'GET'); return }
2199
- await handleMyTeams(res, deps, cookie); return
2308
+ await handleMyTeams(res, deps, workspaceSession!.session); return
2200
2309
  }
2201
2310
  // Teams: list / create on the exact path; the link mutations are POST-only
2202
2311
  // action sub-paths (each carries its ids in the JSON body).
@@ -2223,7 +2332,11 @@ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2223
2332
 
2224
2333
  return (req, res) => {
2225
2334
  if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
2226
- 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) => {
2227
2340
  if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
2228
2341
  console.warn('managed: request handler error:', err)
2229
2342
  if (res.headersSent) { try { res.destroy() } catch {} }