@preventive/triage 1.0.0-alpha.3 → 1.0.0-alpha.31

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 (183) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/api/reap.ts +17 -0
  3. package/cli.js +6 -0
  4. package/client/finding-link.js +305 -0
  5. package/client/linked-findings.d.ts +1 -0
  6. package/client/linked-findings.js +111 -0
  7. package/common/bundle-commit.d.ts +1 -0
  8. package/common/bundle-commit.js +4 -0
  9. package/common/bundle-metadata.d.ts +10 -0
  10. package/common/bundle-metadata.js +182 -0
  11. package/common/bundle-reasons.d.ts +2 -0
  12. package/common/bundle-reasons.js +21 -0
  13. package/common/bundle-source-links.d.ts +5 -0
  14. package/common/bundle-source-links.js +82 -0
  15. package/common/bundle-sources.d.ts +3 -0
  16. package/common/bundle-sources.js +284 -0
  17. package/common/bundle-stats.js +41 -0
  18. package/common/bundle-tabs.js +1 -0
  19. package/common/code-language.js +51 -0
  20. package/common/default-scan-models.ts +30 -0
  21. package/common/finding-id.js +47 -0
  22. package/common/github-issue-labels.d.ts +1 -0
  23. package/common/github-issue-labels.js +11 -0
  24. package/common/github-pr.ts +56 -0
  25. package/common/managed/comments.ts +34 -0
  26. package/common/managed/permissions.ts +35 -0
  27. package/common/managed/report-content.ts +42 -0
  28. package/common/managed/report-filter.ts +108 -0
  29. package/common/managed/roles.ts +28 -0
  30. package/common/managed/routes.d.ts +2 -0
  31. package/common/managed/routes.js +124 -0
  32. package/common/managed/scan-models.ts +6 -0
  33. package/common/managed/triage.ts +83 -0
  34. package/common/save-error-reason.ts +20 -7
  35. package/common/scan-server.ts +13 -0
  36. package/common/server-info.ts +35 -0
  37. package/common/utf8.d.ts +3 -0
  38. package/common/utf8.js +45 -0
  39. package/out/brotli-fallback.js +3 -3
  40. package/out/client-managed-import.js +83 -0
  41. package/out/client-managed.js +169 -0
  42. package/out/client-sync.js +16 -13
  43. package/out/github-issue-authorized.html +3 -0
  44. package/out/github-issue-authorized.js +1 -0
  45. package/out/graph.js +30 -4
  46. package/out/index.html +55 -8
  47. package/out/prism.js +2 -2
  48. package/out/stasis.svg +20 -0
  49. package/out/terminal.js +273 -39
  50. package/out/view.css +1 -1
  51. package/out/view.js +259 -63
  52. package/package.json +205 -57
  53. package/server-common/database-config.ts +16 -0
  54. package/server-common/initialize.ts +18 -0
  55. package/server-common/npm-advisories.ts +101 -0
  56. package/{server → server-common}/origin.ts +5 -5
  57. package/server-common/reap.ts +48 -0
  58. package/server-common/scan-config.ts +19 -0
  59. package/server-common/standalone.ts +29 -0
  60. package/server-common/storage-crypto.ts +182 -0
  61. package/server-common/storage-log.ts +38 -0
  62. package/server-common/vercel-blob.ts +103 -0
  63. package/server-e2e/app.ts +486 -0
  64. package/{server → server-e2e}/auth.ts +5 -1
  65. package/{server → server-e2e}/bus-receiver.ts +2 -1
  66. package/{server → server-e2e}/cli.js +9 -4
  67. package/{server → server-e2e}/config.ts +56 -35
  68. package/{server → server-e2e}/db-neon.ts +2 -2
  69. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  70. package/{server → server-e2e}/db-stmt.ts +2 -2
  71. package/{server → server-e2e}/db.ts +7 -7
  72. package/{server → server-e2e}/http.ts +110 -12
  73. package/{server → server-e2e}/hub.ts +40 -9
  74. package/server-e2e/index.ts +17 -0
  75. package/server-e2e/lifecycle.ts +95 -0
  76. package/{server → server-e2e}/neon-driver.ts +2 -2
  77. package/{server → server-e2e}/npm-proxy.ts +11 -144
  78. package/{server → server-e2e}/objstore/blob-fs.ts +2 -2
  79. package/{server → server-e2e}/objstore/blob-vercel.ts +13 -117
  80. package/{server → server-e2e}/objstore/blob.ts +9 -2
  81. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  82. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  83. package/{server → server-e2e}/objstore/init.ts +35 -18
  84. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  85. package/server-e2e/objstore/rest-deny.ts +28 -0
  86. package/server-e2e/objstore/rest-mint.ts +224 -0
  87. package/{server → server-e2e}/objstore/rest.ts +58 -33
  88. package/{server → server-e2e}/objstore/sign.ts +105 -0
  89. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  90. package/{server → server-e2e}/objstore/store.ts +7 -7
  91. package/{server → server-e2e}/objstore/tokens.ts +1 -1
  92. package/{server → server-e2e}/pubsub.ts +12 -9
  93. package/{server → server-e2e}/sign.ts +4 -4
  94. package/{server → server-e2e}/sse-server.ts +102 -68
  95. package/{server → server-e2e}/sse-session.ts +28 -8
  96. package/{server → server-e2e}/static.ts +31 -21
  97. package/server-e2e/sync-handlers.ts +408 -0
  98. package/{server → server-e2e}/util.ts +9 -0
  99. package/{server → server-e2e}/ws-server.ts +20 -9
  100. package/server-managed/activity.ts +231 -0
  101. package/server-managed/avatar-store.ts +12 -0
  102. package/server-managed/blob-store.ts +17 -0
  103. package/server-managed/brotli.ts +10 -0
  104. package/server-managed/bundle-advisories.ts +53 -0
  105. package/server-managed/bundle-advisory-inventory.ts +75 -0
  106. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  107. package/server-managed/bundle-build-diagnostics.js +23 -0
  108. package/server-managed/bundle-build-leases.ts +41 -0
  109. package/server-managed/bundle-build-worker.js +54 -0
  110. package/server-managed/bundle-build.ts +168 -0
  111. package/server-managed/bundle-cache.ts +166 -0
  112. package/server-managed/bundle-catalog.ts +29 -0
  113. package/server-managed/bundle-store.ts +24 -0
  114. package/server-managed/bundle-summary-cache.ts +116 -0
  115. package/server-managed/bundle.ts +104 -0
  116. package/server-managed/cache-storage.ts +19 -0
  117. package/server-managed/cli.js +21 -0
  118. package/server-managed/combined.ts +46 -0
  119. package/server-managed/comments.ts +151 -0
  120. package/server-managed/config.ts +149 -0
  121. package/server-managed/content-access.ts +15 -0
  122. package/server-managed/crypto.ts +25 -0
  123. package/server-managed/db-methods.ts +1517 -0
  124. package/server-managed/db-neon.ts +277 -0
  125. package/server-managed/db-schema.ts +206 -0
  126. package/server-managed/db-table-names.ts +22 -0
  127. package/server-managed/db.ts +120 -0
  128. package/server-managed/github-app.ts +385 -0
  129. package/server-managed/github-errors.ts +49 -0
  130. package/server-managed/github-issue-oauth.ts +48 -0
  131. package/server-managed/github-issues.ts +152 -0
  132. package/server-managed/github-metadata.ts +98 -0
  133. package/server-managed/github-oauth.ts +199 -0
  134. package/server-managed/github-pulls.ts +227 -0
  135. package/server-managed/http-response.ts +18 -0
  136. package/server-managed/http.ts +2350 -0
  137. package/server-managed/import-triage.ts +51 -0
  138. package/server-managed/index.ts +175 -0
  139. package/server-managed/managed-issues.ts +47 -0
  140. package/server-managed/management-catalog.ts +45 -0
  141. package/server-managed/management.ts +86 -0
  142. package/server-managed/object-storage-disk.ts +144 -0
  143. package/server-managed/object-storage-vercel.ts +63 -0
  144. package/server-managed/object-storage.ts +71 -0
  145. package/server-managed/package-entry-points.ts +51 -0
  146. package/server-managed/public-workspace.ts +146 -0
  147. package/server-managed/repo-path.ts +21 -0
  148. package/server-managed/report-migration.ts +35 -0
  149. package/server-managed/report-query.ts +10 -0
  150. package/server-managed/report-response.ts +16 -0
  151. package/server-managed/report-sources.ts +166 -0
  152. package/server-managed/repository-browser.ts +139 -0
  153. package/server-managed/repository-discovery.ts +82 -0
  154. package/server-managed/repository-policy.ts +27 -0
  155. package/server-managed/revisions.ts +35 -0
  156. package/server-managed/session.ts +78 -0
  157. package/server-managed/slugs.ts +38 -0
  158. package/server-managed/solidity-entry-points.ts +69 -0
  159. package/server-managed/sql-postgres.ts +31 -0
  160. package/server-managed/sql.ts +72 -0
  161. package/server-managed/static.ts +28 -0
  162. package/server-managed/storage-command.ts +16 -0
  163. package/server-managed/storage-db.ts +175 -0
  164. package/server-managed/storage-encryption.ts +211 -0
  165. package/server-managed/storage-maintenance.ts +223 -0
  166. package/server-managed/storage-payload.ts +72 -0
  167. package/server-managed/storage-stores.ts +81 -0
  168. package/server-managed/storage.ts +58 -0
  169. package/server-managed/team-catalog.ts +7 -0
  170. package/server-managed/team-feed.ts +173 -0
  171. package/server-managed/team-reports.ts +219 -0
  172. package/server-managed/triage-response.ts +16 -0
  173. package/server-managed/uploads.ts +65 -0
  174. package/server-managed/workspace-shares.ts +162 -0
  175. package/server.ts +50 -0
  176. package/server/index.ts +0 -477
  177. package/server/lifecycle.ts +0 -204
  178. package/server/sync-handlers.ts +0 -320
  179. /package/{LICENSE → LICENSE-MIT} +0 -0
  180. /package/{server → server-e2e}/config.example.json +0 -0
  181. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  182. /package/{server → server-e2e}/peer.ts +0 -0
  183. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,2350 @@
1
+ // HTTP router for the managed auth server. Mounts the GitHub OAuth flow plus
2
+ // the session endpoints + the mode probe; there is NO api/sync here (a managed
3
+ // server doesn't speak the e2e sync protocol yet). Every handler is async; the
4
+ // boot's `track()` keeps in-flight requests drainable on shutdown.
5
+ //
6
+ // GET /api/config → { mode:'managed', managed:{ loginPath, cookieName } } (public)
7
+ // GET /api/oauth/github/login → 302 to GitHub (+ state cookie)
8
+ // GET /api/oauth/github/callback → the OAuth hook (see github-oauth.ts)
9
+ // GET /api/auth/session → { user, csrfToken } | 401
10
+ // GET /api/teams → the current user's teams + their reports and bundles | 401
11
+ // GET /api/teams/<id>/fixes → PR/issue metadata from visible findings' stored Fix links
12
+ // GET /api/teams/<id>/reports → all reports and links filtered through this team | 401/404
13
+ // GET /api/teams/<id>/feed → SSE notifications for visible triage and comments | 401/404
14
+ // GET /api/reports/<id> → admin/manager report preview | 401/403/404
15
+ // POST /api/reports/query → admin/manager batch preview, with repository metadata | 400/401/403/404/503
16
+ // GET /api/reports/<id>/triage → triage entries (by finding id, shared across reports) for a viewable report's findings | 401/404
17
+ // POST /api/reports/<id>/triage → write triage entries: admin, or ≥triage role + membership | 401/403/404
18
+ // GET /api/reports/<id>/triage/history?finding=<fid> → one visible finding's triage trail, newest first | 400/401/404
19
+ // GET /api/avatar/<id> → cached avatar bytes by user id | 401/404
20
+ // GET /api/admin/users → admin-only user list | 401/403
21
+ // GET /api/admin/history → paginated activity (admin), team content activity (manage) | 401/403
22
+ // POST /api/admin/set-role → admin sets another user's role | 401/403/404
23
+ // GET /api/admin/repositories → admin repo list (each flagged selected) | 401/403
24
+ // POST /api/admin/repositories/select → admin selects/deactivates a repo | 401/403
25
+ // GET /api/admin/repositories/impact → admin attached data summary
26
+ // GET /api/admin/repositories/{browsable,refs,contents} → admin|manage + GitHub read access
27
+ // POST /api/admin/repositories/remove → admin permanently removes a repo
28
+ // GET /api/admin/reports → admin|manage list of uploaded reports | 401/403
29
+ // POST /api/admin/reports → admin|manage uploads a report (raw body) | 401/403/413
30
+ // GET /api/admin/reports/<id> → admin|manage downloads a stored report | 401/403/404
31
+ // DELETE /api/admin/reports/<id> → admin|manage deletes a report | 401/403/404
32
+ // POST /api/admin/reports/set-repo → admin|manage attaches/detaches a report's repo | 401/403/404
33
+ // GET /api/bundles/<id>/{metadata,contents} → authorized encoded bytes | 401/404/422
34
+ // GET /api/bundles/<id>/advisories → published dependency advisories (security access) | 401/403/404
35
+ // GET /api/bundles/<id>/download → authorized original upload | 401/404
36
+ // GET /api/admin/bundles → admin all bundles; managers own/team bundles | 401/403
37
+ // POST /api/admin/bundles → admin|manage uploads a bundle (raw body) | 401/403/413
38
+ // GET /api/admin/bundles/<id> → admin|manage downloads a stored bundle | 401/403/404
39
+ // DELETE /api/admin/bundles/<id> → admin|manage deletes a bundle | 401/403/404
40
+ // POST /api/admin/bundles/set-repo → admin|manage attaches/detaches a bundle's repo | 401/403/404
41
+ // GET /api/admin/teams → admin teams (+ members/repos) + pickers | 401/403
42
+ // POST /api/admin/teams → admin creates a team | 401/403/409
43
+ // POST /api/admin/teams/rename → admin renames a team | 401/403/404/409
44
+ // POST /api/admin/teams/delete → admin deletes a team | 401/403/404
45
+ // POST /api/admin/teams/{set,remove}-repo → admin links/unlinks a repo (+path) | 401/403/404
46
+ // POST /api/admin/teams/{set,remove}-member → admin links/unlinks a user (+perms) | 401/403/404
47
+ // POST /api/auth/logout → same-origin + CSRF, drops the session
48
+ import type { IncomingMessage, ServerResponse } from 'node:http'
49
+ import { pipeline } from 'node:stream/promises'
50
+ import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
51
+ import type { BundleAdvisoryInventory } from './bundle-advisory-inventory.ts'
52
+ import { UPLOAD_CHUNK_BYTES, type UploadKind, deleteUpload, putUploadPart, readUpload, validUpload, validUploadPart } from './uploads.ts'
53
+ import { type BundleCache, type BundleCachePart, MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
54
+ import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
55
+ import { contentAccess } from './content-access.ts'
56
+ import type { BundleStore } from './bundle-store.ts'
57
+ import { Buffer } from 'node:buffer'
58
+ import { createHash, randomUUID } from 'node:crypto'
59
+ import type { AvatarStore } from './avatar-store.ts'
60
+ import type { BlobStore } from './blob-store.ts'
61
+ import { bundleIntegrity, bundleKind, bundleRepo, reportBundleHashes } from './bundle.ts'
62
+ import type { ManagedConfig } from './config.ts'
63
+ import type { ManagedBundle, ManagedDb, ManagedSession, ReportRecord, SelectedRepo, StoredUser, TriageEventRow } from './db.ts'
64
+ import type { OriginGate } from '../server-common/origin.ts'
65
+ import { isRole, roleAtLeast } from '../common/managed/roles.ts'
66
+ import { VISIBILITY_PERMISSIONS, parseTeamUserPermissions } from '../common/managed/permissions.ts'
67
+ import { filterReportData } from '../common/managed/report-filter.ts'
68
+ import type { TriageEntryPatch } from '../common/managed/triage.ts'
69
+ import { MAX_FINDING_ID, MAX_TRIAGE_BODY_BYTES, MAX_TRIAGE_ENTRIES, MAX_TRIAGE_HISTORY, parseTriageEntryPatch } from '../common/managed/triage.ts'
70
+ import { reportRepoGithub } from '@preventive/report'
71
+ import { loadManagedFindings, readManagedReport } from '../common/managed/report-content.ts'
72
+ import type { ReportSourcesCache } from './report-sources.ts'
73
+ import { normalizeTeamPath } from './repo-path.ts'
74
+ import { DEFAULT_MANAGED_SCAN_MODEL, MANAGED_SCAN_MODELS } from '../common/managed/scan-models.ts'
75
+ import { CONFIG_PATH, type ServerInfo } from '../common/server-info.ts'
76
+ import { GithubApiError, collectRepos, fetchPublicRepository, installUrl, publicRepositoryName, repositoryInstallation } from './github-app.ts'
77
+ import type { ConnectedRepo } from './github-app.ts'
78
+ import { canAddAnyPublicRepository, canAddRepositories, passesPublicRepositorySafeguard } from './repository-policy.ts'
79
+ import { RepositoryDiscovery } from './repository-discovery.ts'
80
+ import { type RepositoryEntry, createRepositoryBrowser, scopedDirectory } from './repository-browser.ts'
81
+ import { CALLBACK_PATH, LOGIN_PATH, OAuthError, buildLoginRedirect, ensureUserAccessToken, handleCallback } from './github-oauth.ts'
82
+ import { clearCookie, endSession, readSession } from './session.ts'
83
+ import type { ActivityContext, ActivityInput } from './activity.ts'
84
+ import { acceptsReportMetadata } from './report-response.ts'
85
+ import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, recheckTeam, teamFindingIds, teamSnapshot, teamSourcePaths, teamWorkspaceFindingIds } from './team-reports.ts'
86
+ import { FINDING_CATALOG_PAGE_BYTES, FINDING_CATALOG_PAGE_COUNT, MAX_REPORT_QUERY_BYTES, MAX_REPORT_QUERY_COUNT } from './report-query.ts'
87
+ import { lookupFixes, storedFixUrls } from './github-pulls.ts'
88
+ import { IssueError, MAX_ISSUE_BODY_BYTES, createGithubIssue, parseIssueContext, prepareGithubIssue } from './github-issues.ts'
89
+ import { ISSUE_LOGIN_PATH, isIssueOAuthCallback, issueLoginRedirect, issueOAuthCallback } from './github-issue-oauth.ts'
90
+ import { sendJson, writeResponse } from './http-response.ts'
91
+ import { triageWireEntry } from './triage-response.ts'
92
+ import { handlePublicWorkspace } from './public-workspace.ts'
93
+ import { serveUserTeamFeed } from './team-feed.ts'
94
+ import { hashToken, randomToken, safeEqual } from './crypto.ts'
95
+ import { canDeleteComment, parseCommentBody } from '../common/managed/comments.ts'
96
+ import { ManagedMutationError, reportReferenceSnapshot } from './management.ts'
97
+ import { BundleBuildError, buildRepositoryBundle, parseBundleBuild, withBundleBuildLease } from './bundle-build.ts'
98
+
99
+ const SESSION_PATH = '/api/auth/session'
100
+ const AVATAR_PREFIX = '/api/avatar/'
101
+ const LOGOUT_PATH = '/api/auth/logout'
102
+ const ADMIN_USERS_PATH = '/api/admin/users'
103
+ const ADMIN_HISTORY_PATH = '/api/admin/history'
104
+ const ADMIN_SCAN_MODELS_PATH = '/api/admin/scan/models'
105
+ const SET_ROLE_PATH = '/api/admin/set-role'
106
+ const ADMIN_REPOS_PATH = '/api/admin/repositories'
107
+ const BROWSABLE_REPOS_PATH = '/api/admin/repositories/browsable'
108
+ const REPO_REFS_PATH = '/api/admin/repositories/refs'
109
+ const REPO_CONTENTS_PATH = '/api/admin/repositories/contents'
110
+ const SELECT_REPO_PATH = '/api/admin/repositories/select'
111
+ const ADD_PUBLIC_REPO_PATH = '/api/admin/repositories/add-public'
112
+ const CONNECT_REPO_APP_PATH = '/api/admin/repositories/connect-app'
113
+ const REPO_IMPACT_PATH = '/api/admin/repositories/impact'
114
+ const REMOVE_REPO_PATH = '/api/admin/repositories/remove'
115
+ const ADMIN_REPORTS_PATH = '/api/admin/reports'
116
+ const REPORT_SET_REPO_PATH = '/api/admin/reports/set-repo'
117
+ const REPORT_SET_VISIBLE_PATH = '/api/admin/reports/set-visible'
118
+ const REPORT_PREFIX = '/api/admin/reports/'
119
+ const ADMIN_BUNDLES_PATH = '/api/admin/bundles'
120
+ const BUNDLE_CREATE_PATH = '/api/admin/bundles/create'
121
+ const BUNDLE_SET_REPO_PATH = '/api/admin/bundles/set-repo'
122
+ const BUNDLE_PREFIX = '/api/admin/bundles/'
123
+ const MY_TEAMS_PATH = '/api/teams'
124
+ const MY_REPORT_PREFIX = '/api/reports/'
125
+ const MY_REPORT_TRIAGE_SUFFIX = '/triage'
126
+ const MY_REPORT_TRIAGE_HISTORY_SUFFIX = '/triage/history'
127
+ const ADMIN_TEAMS_PATH = '/api/admin/teams'
128
+ const TEAM_DELETE_PATH = '/api/admin/teams/delete'
129
+ const TEAM_RENAME_PATH = '/api/admin/teams/rename'
130
+ const TEAM_SET_REPO_PATH = '/api/admin/teams/set-repo'
131
+ const TEAM_REMOVE_REPO_PATH = '/api/admin/teams/remove-repo'
132
+ const TEAM_SET_MEMBER_PATH = '/api/admin/teams/set-member'
133
+ const TEAM_REMOVE_MEMBER_PATH = '/api/admin/teams/remove-member'
134
+ const MAX_TEAM_NAME = 100
135
+
136
+ async function handleWorkspaceFixes(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string): Promise<void> {
137
+ const s = await readWorkspaceSession(res, deps, cookie)
138
+ if (!s) return
139
+ const snapshot = await teamSnapshot(deps.db, s.session.id, teamId)
140
+ const ids = [...await teamWorkspaceFindingIds(deps.db, deps.reportStore, snapshot)]
141
+ const urls = storedFixUrls(await deps.db.listTriage(ids))
142
+ await recheckTeam(deps.db, s.session.id, snapshot)
143
+ const fixes = await lookupFixes(deps.config, deps.db, snapshot, urls)
144
+ // Cold report reads, token refresh and upstream batches can outlive changes
145
+ // to security/links, team grants, publication, sessions or the Fix links.
146
+ const currentUrls = storedFixUrls(await deps.db.listTriage(ids))
147
+ if (await readWorkspaceSession(res, deps, cookie) == null) return
148
+ await recheckTeam(deps.db, s.session.id, snapshot)
149
+ if (JSON.stringify(currentUrls) !== JSON.stringify(urls)) {
150
+ sendJson(res, 404, { error: 'workspace-changed' }); return
151
+ }
152
+ sendJson(res, 200, { fixes })
153
+ }
154
+
155
+ async function handleWorkspaceIssue(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps,
156
+ cookie: string | undefined, teamId: string, query: URLSearchParams): Promise<void> {
157
+ if (req.method !== 'GET' && req.method !== 'POST') { send405(res, 'GET, POST'); return }
158
+ const s = req.method === 'GET' ? await readWorkspaceSession(res, deps, cookie) : await checkMutation(req, res, deps, cookie)
159
+ if (!s) return
160
+ if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return }
161
+ let body: unknown = Object.fromEntries(query)
162
+ if (req.method === 'POST') {
163
+ try { body = await readJsonBody(req, MAX_ISSUE_BODY_BYTES) }
164
+ catch { sendJson(res, 400, { error: 'bad-body' }); return }
165
+ }
166
+ const context = parseIssueContext(body)
167
+ const prepared = await prepareGithubIssue(deps.config, deps.db, deps.reportStore, s.session, teamId, context)
168
+ await prepared.recheck()
169
+ if (req.method === 'POST' && prepared.mode === 'api') {
170
+ sendJson(res, 201, await createGithubIssue(prepared, body)); return
171
+ }
172
+ sendJson(res, 200, { mode: prepared.mode, labels: prepared.labels,
173
+ ...(prepared.mode === 'existing' ? { url: prepared.url } : {}),
174
+ ...(prepared.mode === 'pending' ? { repositoryUrl: prepared.repositoryUrl } : {}),
175
+ ...(prepared.mode === 'authorize' || prepared.mode === 'permissions' ? { authorizationPath: prepared.authorizationPath } : {}) })
176
+ }
177
+
178
+ function activity(deps: ManagedHttpDeps, user: StoredUser, kind: ActivityInput['kind'], action: string, context: Pick<ActivityInput, 'repo' | 'reportId' | 'bundleId' | 'report' | 'repoId' | 'repoDirectory'> = {}): Promise<void> {
179
+ return deps.db.recordActivity({ kind, actor: user.login, actorId: user.id, action, ...context }, Date.now())
180
+ }
181
+
182
+ async function repositoryName(deps: ManagedHttpDeps, repoId: number | null | undefined): Promise<string | null> {
183
+ if (repoId == null) return null
184
+ return (await deps.db.listAllRepos()).find(repo => repo.repoId === repoId)?.fullName ?? null
185
+ }
186
+
187
+ // Workspace history is authorized BEFORE filtering, counting, or paging.
188
+ // Managers get content activity for their currently accessible team content;
189
+ // even the displayed report context must come from those accessible reports.
190
+ async function handleHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams): Promise<void> {
191
+ const s = await readManageSession(res, deps, cookie)
192
+ if (s == null) return
193
+ const page = Number(query.get('page') ?? 1)
194
+ const limit = Number(query.get('limit') ?? 100)
195
+ const kind = query.get('kind') ?? 'all'
196
+ const search = (query.get('q') ?? '').trim()
197
+ const repo = query.get('repo') ?? ''
198
+ const actor = query.get('actor') ?? ''
199
+ if (!Number.isSafeInteger(page) || page < 1 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100
200
+ || !['all', 'triage', 'upload', 'visibility', 'access', 'repository', 'delete'].includes(kind) || search.length > 500
201
+ || repo.length > 500 || actor.length > 500) {
202
+ sendJson(res, 400, { error: 'bad-request' }); return
203
+ }
204
+ let contexts: ActivityContext[] | null = null
205
+ if (s.user.role !== 'admin') {
206
+ const findings = new Map<string, ActivityContext>()
207
+ const reports = kind === 'all' || kind === 'triage' ? await deps.db.listActivityReports(s.user.id) : []
208
+ for (const report of reports) {
209
+ for (const finding of await visibleFindingIds(deps, s.user, report.reportId, s.session.id, null)) {
210
+ if (!findings.has(finding)) findings.set(finding, { finding, ...report })
211
+ }
212
+ }
213
+ contexts = [...findings.values()]
214
+ }
215
+ sendJson(res, 200, await deps.db.listActivity({ page, limit, kind, query: search, repo, actor, contexts, userId: s.user.id }))
216
+ }
217
+
218
+ export interface ManagedHttpDeps {
219
+ config: ManagedConfig
220
+ db: ManagedDb
221
+ avatarStore: AvatarStore
222
+ reportStore: BlobStore
223
+ bundleStore: BundleStore
224
+ bundleCache?: BundleCache
225
+ reportSourcesCache?: ReportSourcesCache
226
+ uploadStore?: BlobStore
227
+ originGate: OriginGate
228
+ isShuttingDown: () => boolean
229
+ track: (p: Promise<unknown>) => void
230
+ // Combined boot delegates unmatched paths to e2e and overrides discovery.
231
+ serveStatic?: (req: IncomingMessage, res: ServerResponse) => boolean
232
+ next?: Handler
233
+ serverInfo?: ServerInfo
234
+ }
235
+
236
+ type Handler = (req: IncomingMessage, res: ServerResponse) => void | Promise<void>
237
+
238
+ function send405(res: ServerResponse, allow: string): void {
239
+ sendJson(res, 405, { error: 'method-not-allowed' }, { allow })
240
+ }
241
+
242
+ function firstHeader(v: string | string[] | undefined): string | null {
243
+ if (typeof v === 'string') return v
244
+ if (Array.isArray(v) && v.length > 0) return v[0] ?? null
245
+ return null
246
+ }
247
+
248
+ async function serveAvatar(res: ServerResponse, avatarStore: AvatarStore, userId: string): Promise<void> {
249
+ const avatar = await avatarStore.get(userId)
250
+ if (avatar == null) { sendJson(res, 404, { error: 'no-avatar' }); return }
251
+ res.writeHead(200, {
252
+ 'content-type': avatar.contentType,
253
+ 'content-length': String(avatar.bytes.length),
254
+ 'x-content-type-options': 'nosniff',
255
+ 'cache-control': 'no-store',
256
+ })
257
+ res.end(avatar.bytes)
258
+ }
259
+
260
+ const MAX_JSON_BODY_BYTES = 4096
261
+
262
+ // Buffer the request body, aborting (and throwing 'too-large') once it exceeds
263
+ // `maxBytes` — bounds memory on the JSON mutations (small) and the report
264
+ // upload (config.maxReportBytes) alike.
265
+ async function readBodyBytes(req: IncomingMessage, maxBytes: number): Promise<Buffer> {
266
+ const chunks: Buffer[] = []
267
+ let size = 0
268
+ for await (const chunk of req) {
269
+ const c = chunk as Buffer
270
+ size += c.length
271
+ if (size > maxBytes) { req.destroy(); throw new Error('too-large') }
272
+ chunks.push(c)
273
+ }
274
+ return Buffer.concat(chunks)
275
+ }
276
+
277
+ function readUploadBody(req: IncomingMessage, deps: ManagedHttpDeps, session: string, kind: UploadKind) {
278
+ const maxBytes = kind === 'reports' ? deps.config.maxReportBytes : deps.config.maxBundleBytes
279
+ if (req.headers['x-upload-id'] == null) return readBodyBytes(req, maxBytes)
280
+ if (!deps.uploadStore) throw new Error('bad-upload')
281
+ return readUpload(deps.uploadStore, req, session, kind, maxBytes)
282
+ }
283
+
284
+ async function handleUploadPart(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, path: string) {
285
+ const cancel = req.method === 'DELETE'
286
+ if (req.method !== 'POST' && !cancel) { send405(res, 'POST, DELETE'); return }
287
+ const s = await checkMutation(req, res, deps, cookie)
288
+ if (!s || !requireManageRole(res, s.user)) return
289
+ if (!deps.uploadStore) { sendJson(res, 404, { error: 'not-found' }); return }
290
+ const match = /^\/api\/admin\/uploads\/(reports|bundles)\/([^/]+)(?:\/(0|[1-9][0-9]*))?$/u.exec(path)
291
+ if (!match || (cancel ? match[3] !== undefined : match[3] === undefined)) { sendJson(res, 400, { error: 'bad-upload' }); return }
292
+ const id = match[2]!, kind = match[1] as UploadKind
293
+ if (cancel) {
294
+ const count = Number(req.headers['x-upload-parts'])
295
+ if (!validUpload(id, count)) { sendJson(res, 400, { error: 'bad-upload' }); return }
296
+ await deleteUpload(deps.uploadStore, s.session.id, kind, id, count)
297
+ sendJson(res, 200, { ok: true }); return
298
+ }
299
+ const index = Number(match[3])
300
+ const maxBytes = kind === 'reports' ? deps.config.maxReportBytes : deps.config.maxBundleBytes
301
+ const discard = async () => {
302
+ if (validUpload(id, index + 1)) {
303
+ await deleteUpload(deps.uploadStore!, s.session.id, kind, id, index + 1).catch(err => console.warn('managed: upload cleanup failed:', err))
304
+ }
305
+ }
306
+ if (!validUploadPart(id, index, maxBytes)) { await discard(); sendJson(res, 400, { error: 'bad-upload' }); return }
307
+ let bytes
308
+ try { bytes = await readBodyBytes(req, Math.min(UPLOAD_CHUNK_BYTES, maxBytes)) }
309
+ catch { await discard(); sendJson(res, 413, { error: 'too-large' }); return }
310
+ if (bytes.length === 0) { await discard(); sendJson(res, 400, { error: 'empty' }); return }
311
+ if (await manageMutation(req, res, deps, cookie) == null) return
312
+ await putUploadPart(deps.uploadStore, s.session.id, kind, id, index, bytes)
313
+ sendJson(res, 200, { ok: true })
314
+ }
315
+
316
+ // `maxBytes` overrides the small default for the one endpoint whose JSON body
317
+ // legitimately grows (triage entry batches).
318
+ async function readJsonBody(req: IncomingMessage, maxBytes = MAX_JSON_BODY_BYTES): Promise<unknown> {
319
+ const buf = await readBodyBytes(req, maxBytes)
320
+ return JSON.parse(buf.toString('utf8') || 'null')
321
+ }
322
+
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.
326
+ async function checkMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
327
+ const s = await readSession(deps.config, deps.db, cookie, Date.now())
328
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
329
+ const csrf = firstHeader(req.headers['x-csrf-token'])
330
+ if (csrf == null) { sendJson(res, 403, { error: 'csrf-missing' }); return null }
331
+ if (!safeEqual(csrf, s.session.csrfToken)) { sendJson(res, 403, { error: 'csrf-mismatch' }); return null }
332
+ return s
333
+ }
334
+
335
+ // POST /api/auth/logout — drop the session (same-origin + CSRF).
336
+ async function handleLogout(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
337
+ if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
338
+ const s = await checkMutation(req, res, deps, cookie)
339
+ if (s == null) return
340
+ await endSession(deps.config, deps.db, cookie)
341
+ res.writeHead(204, { 'set-cookie': clearCookie(deps.config.sessionCookieName, deps.config.cookieSecure), 'cache-control': 'no-store' })
342
+ res.end()
343
+ }
344
+
345
+ // POST /api/admin/set-role — an admin sets ANOTHER user's role. Admin-only, and
346
+ // refused for the caller's own id so an admin can't drop their own admin (keeps
347
+ // at least one admin).
348
+ async function handleSetRole(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
349
+ if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
350
+ const s = await checkMutation(req, res, deps, cookie)
351
+ if (s == null) return
352
+ if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
353
+ let body: unknown
354
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
355
+ const userId = (body as { userId?: unknown } | null)?.userId
356
+ const role = (body as { role?: unknown } | null)?.role
357
+ if (typeof userId !== 'string' || !isRole(role)) { sendJson(res, 400, { error: 'bad-request' }); return }
358
+ if (userId === s.user.id) { sendJson(res, 403, { error: 'cannot-change-own-role' }); return }
359
+ const target = (await deps.db.listUsers()).find(user => user.id === userId)
360
+ // A caller can hold the body open while their admin access is revoked.
361
+ if (await readAdminSession(res, deps, cookie) == null) return
362
+ const ok = await deps.db.setUserRole(userId, role)
363
+ if (!ok) { sendJson(res, 404, { error: 'not-found' }); return }
364
+ if (target?.role !== role) await activity(deps, s.user, 'access', `changed ${target?.login ?? userId}'s role to ${role}`)
365
+ sendJson(res, 200, { ok: true })
366
+ }
367
+
368
+ // admin OR manage — the roles allowed into the management pages (repositories,
369
+ // reports), matching the sidebar account-menu gate. Sends 403 + returns false
370
+ // otherwise.
371
+ function requireManageRole(res: ServerResponse, user: StoredUser): boolean {
372
+ if (user.role === 'admin' || user.role === 'manage') return true
373
+ sendJson(res, 403, { error: 'forbidden' })
374
+ return false
375
+ }
376
+
377
+ // A read endpoint open to admin|manage: resolve the session (401 if absent),
378
+ // then gate on the role (403). Returns the session, or null after the response
379
+ // was already sent. No CSRF — reads aren't mutations (cf. /api/admin/users).
380
+ async function readManageSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
381
+ const s = await readSession(deps.config, deps.db, cookie, Date.now())
382
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
383
+ if (!requireManageRole(res, s.user)) return null
384
+ return s
385
+ }
386
+
387
+ async function readAdminSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
388
+ const s = await readSession(deps.config, deps.db, cookie, Date.now())
389
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
390
+ if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
391
+ return s
392
+ }
393
+
394
+ async function readWorkspaceSession(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
395
+ const s = await readSession(deps.config, deps.db, cookie, Date.now())
396
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return null }
397
+ if (!roleAtLeast(s.user.role, 'view')) { sendJson(res, 403, { error: 'forbidden' }); return null }
398
+ return s
399
+ }
400
+
401
+ // GET /api/admin/scan/models — the server's canonical scan model ids and effort
402
+ // levels. Names are intentionally absent; the client derives them from ids.
403
+ async function handleListModels(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
404
+ const s = await readManageSession(res, deps, cookie)
405
+ if (s == null) return
406
+ sendJson(res, 200, { models: MANAGED_SCAN_MODELS, defaultModel: DEFAULT_MANAGED_SCAN_MODEL })
407
+ }
408
+
409
+ // GET /api/admin/repositories — the connected repositories by default. The
410
+ // potentially large GitHub discovery lists are opt-in (`scope=installed` or
411
+ // `scope=public`). Return the complete catalogue for local search and organization
412
+ // filtering. Admin-only. No CSRF.
413
+ async function handleListRepositories(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, discovery: RepositoryDiscovery): Promise<void> {
414
+ if ((req.method ?? 'GET') !== 'GET') { send405(res, 'GET'); return }
415
+ const s = await readAdminSession(res, deps, cookie)
416
+ if (s == null) return
417
+ const url = new URL(req.url ?? '/', 'http://localhost')
418
+ // Keep the original unparameterized response shape for older clients. The
419
+ // admin UI always sends an explicit scope, which opts into the connected-first
420
+ // discovery flow below.
421
+ if ([...url.searchParams.keys()].length === 0) {
422
+ const token = await ensureUserAccessToken(deps.config, deps.db, s.user.id, Date.now())
423
+ const listing = await collectRepos(deps.config, token)
424
+ const selected = new Set((await deps.db.listSelectedRepos()).map((repo) => repo.repoId))
425
+ sendJson(res, 200, {
426
+ installUrl: installUrl(deps.config),
427
+ repositories: listing.repositories.map((repo) => ({
428
+ id: repo.id, fullName: repo.fullName, private: repo.private, visibility: repo.visibility, htmlUrl: repo.htmlUrl,
429
+ installed: repo.installationId != null, selected: selected.has(repo.id),
430
+ })),
431
+ tokenMissing: listing.tokenMissing,
432
+ })
433
+ return
434
+ }
435
+ const scope = url.searchParams.get('scope') ?? 'connected'
436
+ if (scope !== 'connected' && scope !== 'installed' && scope !== 'public') { sendJson(res, 400, { error: 'bad-scope' }); return }
437
+ const selectedRows = await deps.db.listSelectedRepos()
438
+ const allRows = scope === 'connected' ? await deps.db.listAllRepos() : []
439
+ const selected = new Set(selectedRows.map((r) => r.repoId))
440
+ let tokenMissing = false
441
+ let repositories
442
+ if (scope === 'connected') {
443
+ repositories = allRows.map((r) => ({
444
+ id: r.repoId, fullName: r.fullName, private: r.private, htmlUrl: r.htmlUrl,
445
+ installed: r.installationId != null, selected: r.active, active: r.active,
446
+ }))
447
+ } else {
448
+ const showAll = scope === 'installed' && url.searchParams.get('showAll') === 'true'
449
+ const token = showAll ? null : await ensureUserAccessToken(deps.config, deps.db, s.user.id, Date.now())
450
+ let listing
451
+ try {
452
+ listing = await discovery.list(scope, s.user.id, token, showAll, url.searchParams.get('refresh') === 'true')
453
+ } catch (err) {
454
+ if (!(err instanceof GithubApiError)) throw err
455
+ sendJson(res, 502, { error: err.message })
456
+ return
457
+ }
458
+ tokenMissing = listing.tokenMissing
459
+ repositories = listing.repositories
460
+ .map((r) => ({
461
+ id: r.id, fullName: r.fullName, private: r.private, visibility: r.visibility, htmlUrl: r.htmlUrl,
462
+ installed: r.installationId != null, selected: selected.has(r.id),
463
+ }))
464
+ }
465
+ repositories.sort((a, b) => a.fullName.localeCompare(b.fullName))
466
+ const total = repositories.length
467
+ sendJson(res, 200, {
468
+ installUrl: installUrl(deps.config),
469
+ canAddAnyPublicRepository: canAddAnyPublicRepository(s.user, await deps.db.getUserGithubId(s.user.id)),
470
+ repositories,
471
+ connectedCount: selectedRows.length,
472
+ inactiveCount: allRows.filter((r) => !r.active).length,
473
+ total,
474
+ tokenMissing,
475
+ })
476
+ }
477
+
478
+ // The select half of the toggle: re-list the caller's reachable repos to VERIFY
479
+ // access (never trust a client-supplied id) and capture the server-derived read
480
+ // context (installation id, default branch). A PRIVATE repo with no installation
481
+ // can't be read server-side → 409. Stores via selectRepo (upsert).
482
+ async function selectRepository(res: ServerResponse, deps: ManagedHttpDeps, user: StoredUser, repoId: number, cookie: string | undefined): Promise<void> {
483
+ if (!canAddRepositories(user)) { sendJson(res, 403, { error: 'forbidden' }); return }
484
+ const userId = user.id
485
+ const token = await ensureUserAccessToken(deps.config, deps.db, userId, Date.now())
486
+ const { repositories } = await collectRepos(deps.config, token)
487
+ let repo = repositories.find((r) => r.id === repoId)
488
+ // A WHITEHAT addition may not appear in /user/repos. Revalidate the stored
489
+ // canonical name when reactivating it, with the same permission gates.
490
+ if (repo == null && canAddAnyPublicRepository(user, await deps.db.getUserGithubId(userId))) {
491
+ const stored = (await deps.db.listAllRepos()).find(r => r.repoId === repoId)
492
+ if (stored && !stored.private && stored.installationId == null) {
493
+ try { repo = await fetchPublicRepository(stored.fullName) } catch (err) {
494
+ if (!(err instanceof GithubApiError)) throw err
495
+ sendJson(res, err.status, { error: err.message }); return
496
+ }
497
+ if (repo.id !== repoId) { sendJson(res, 409, { error: 'repo-identity-changed' }); return }
498
+ }
499
+ }
500
+ if (repo == null) { sendJson(res, 404, { error: 'repo-not-accessible' }); return }
501
+ // (0) Installed or explicitly public. For (3a), an admin may choose any App
502
+ // installation; for (3b), /user/repos is the public involvement evidence.
503
+ if (repo.installationId == null) {
504
+ if (repo.private || repo.visibility !== 'public') { sendJson(res, 409, { error: 'repo-not-readable' }); return }
505
+ if (!passesPublicRepositorySafeguard(await deps.db.getUserGithubId(userId), repositories.some(r => r.id === repoId))) {
506
+ sendJson(res, 403, { error: 'forbidden' }); return
507
+ }
508
+ }
509
+ if (await readAdminSession(res, deps, cookie) == null) return
510
+ await connectRepository(res, deps, user, repo)
511
+ }
512
+
513
+ async function connectRepository(res: ServerResponse, deps: ManagedHttpDeps, user: StoredUser, repo: ConnectedRepo): Promise<void> {
514
+ const alreadySelected = (await deps.db.listSelectedRepos()).some(row => row.repoId === repo.id)
515
+ await deps.db.selectRepo({
516
+ repoId: repo.id, fullName: repo.fullName, private: repo.private,
517
+ installationId: repo.installationId, defaultBranch: repo.defaultBranch,
518
+ htmlUrl: repo.htmlUrl, addedBy: user.id,
519
+ }, Date.now())
520
+ if (!alreadySelected) await activity(deps, user, 'repository', 'connected a repository', { repo: repo.fullName })
521
+ sendJson(res, 200, { ok: true, selected: true })
522
+ }
523
+
524
+ // Separate from ordinary discovery: WHITEHAT bypasses only public involvement.
525
+ // Authentication, server admin permission, origin and CSRF remain mandatory.
526
+ async function handleAddPublicRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
527
+ if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
528
+ const s = await adminMutation(req, res, deps, cookie)
529
+ if (s == null) return
530
+ if (!canAddAnyPublicRepository(s.user, await deps.db.getUserGithubId(s.user.id))) { sendJson(res, 403, { error: 'forbidden' }); return }
531
+ let body: unknown
532
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
533
+ const fullName = publicRepositoryName((body as { repository?: unknown } | null)?.repository)
534
+ if (fullName == null) { sendJson(res, 400, { error: 'bad-repository' }); return }
535
+ let repo
536
+ try { repo = await fetchPublicRepository(fullName) } catch (err) {
537
+ if (!(err instanceof GithubApiError)) throw err
538
+ sendJson(res, err.status, { error: err.message }); return
539
+ }
540
+ // A GitHub lookup can outlive a role change or logout.
541
+ if (await readAdminSession(res, deps, cookie) == null) return
542
+ // Only public access was verified. Keep its null installation context rather
543
+ // than restoring a stored App installation that may no longer cover this repo.
544
+ await connectRepository(res, deps, s.user, repo)
545
+ }
546
+
547
+ // Associate an existing connection with this App without changing its active
548
+ // state, ownership, team grants, or stored data. GitHub grants remain separate
549
+ // from the per-user checks required for browsing source.
550
+ async function handleConnectRepositoryApp(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
551
+ if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
552
+ const s = await adminMutation(req, res, deps, cookie)
553
+ if (s == null) return
554
+ let body: unknown
555
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
556
+ const repoId = (body as { repoId?: unknown } | null)?.repoId
557
+ if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || repoId <= 0) { sendJson(res, 400, { error: 'bad-request' }); return }
558
+ const repo = (await deps.db.listAllRepos()).find(row => row.repoId === repoId)
559
+ if (repo == null) { sendJson(res, 404, { error: 'repo-not-connected' }); return }
560
+ if (repo.installationId != null) { sendJson(res, 200, { connected: true }); return }
561
+ let installationId
562
+ try { installationId = await repositoryInstallation(deps.config, repo.repoId, repo.fullName) } catch (err) {
563
+ if (!(err instanceof GithubApiError)) throw err
564
+ // These credentials belong to the server App, not the user's session.
565
+ sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }); return
566
+ }
567
+ if (await readAdminSession(res, deps, cookie) == null) return
568
+ if (installationId == null) {
569
+ const current = (await deps.db.listAllRepos()).find(row => row.repoId === repoId)
570
+ if (current == null || current.fullName !== repo.fullName || current.addedAt !== repo.addedAt || current.installationId != null) {
571
+ sendJson(res, 409, { error: 'repo-connection-changed' }); return
572
+ }
573
+ const url = installUrl(deps.config)
574
+ if (url == null) { sendJson(res, 503, { error: 'github-app-not-configured' }); return }
575
+ sendJson(res, 200, { connected: false, installUrl: url }); return
576
+ }
577
+ if (!await deps.db.connectRepoInstallation(repo, installationId, s.session.id, Date.now())) {
578
+ sendJson(res, 409, { error: 'repo-connection-changed' }); return
579
+ }
580
+ await activity(deps, s.user, 'repository', 'connected GitHub App access', { repo: repo.fullName })
581
+ sendJson(res, 200, { connected: true })
582
+ }
583
+
584
+ // POST /api/admin/repositories/select — an admin toggles whether a repo is
585
+ // active in the operate-on set. Mutation: same-origin + CSRF. Body { repoId,
586
+ // selected }. selected:true verifies + records the read context; selected:false
587
+ // deactivates the row while retaining its reports, bundles, and metadata.
588
+ async function handleSelectRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
589
+ if ((req.method ?? 'GET') !== 'POST') { send405(res, 'POST'); return }
590
+ const s = await adminMutation(req, res, deps, cookie)
591
+ if (s == null) return
592
+ let body: unknown
593
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
594
+ const repoId = (body as { repoId?: unknown } | null)?.repoId
595
+ const selected = (body as { selected?: unknown } | null)?.selected
596
+ if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || typeof selected !== 'boolean') {
597
+ sendJson(res, 400, { error: 'bad-request' }); return
598
+ }
599
+ if (!selected) {
600
+ const repo = (await deps.db.listSelectedRepos()).find(row => row.repoId === repoId)
601
+ if (await readAdminSession(res, deps, cookie) == null) return
602
+ await deps.db.deactivateRepo(repoId)
603
+ if (repo) await activity(deps, s.user, 'repository', 'deactivated a repository', { repo: repo.fullName })
604
+ sendJson(res, 200, { ok: true, selected: false }); return
605
+ }
606
+ await selectRepository(res, deps, s.user, repoId, cookie)
607
+ }
608
+
609
+ async function repositoryFindingIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[]): Promise<Set<string>> {
610
+ const ids = new Set<string>()
611
+ for (const report of reports) {
612
+ const bytes = await deps.reportStore.get(report.id)
613
+ if (bytes == null) throw new Error(`Cannot establish repository triage overlap: report ${report.id} is unavailable`)
614
+ const parsed = await loadManagedFindings(bytes.toString('utf8'), report.filename)
615
+ if (parsed == null) throw new Error(`Cannot establish repository triage overlap: report ${report.id} is unreadable`)
616
+ for (const finding of parsed.findings) {
617
+ const id = (finding as { id?: unknown })?.id
618
+ if (typeof id === 'string' && id !== '') ids.add(id)
619
+ }
620
+ }
621
+ return ids
622
+ }
623
+
624
+ async function repositoryImpact(deps: ManagedHttpDeps, repoId: number) {
625
+ const allReports = await deps.db.listReports()
626
+ const reports = allReports.filter((report) => report.repoId === repoId)
627
+ const bundles = await deps.db.listBundlesForRepo(repoId)
628
+ const triageIds = await repositoryExclusiveTriageIds(deps, reports, allReports.filter((report) => report.repoId !== repoId))
629
+ return {
630
+ reports: reports.map((report) => ({ id: report.id, filename: report.filename, repoDirectory: report.repoDirectory })),
631
+ bundles,
632
+ triageCount: triageIds.length,
633
+ }
634
+ }
635
+
636
+ async function repositoryExclusiveTriageIds(deps: ManagedHttpDeps, reports: { id: string, filename: string }[], otherReports: { id: string, filename: string }[], annotationsOnly = true): Promise<string[]> {
637
+ const targetIds = await repositoryFindingIds(deps, reports)
638
+ const triage = annotationsOnly ? await deps.db.listTriage([...targetIds]) : []
639
+ const commentIds = annotationsOnly ? await deps.db.listCommentedFindingIds([...targetIds]) : []
640
+ // Removal includes every exclusive finding, including annotations created
641
+ // during the blob scan; impact only needs the currently annotated subset.
642
+ const annotatedIds = annotationsOnly ? new Set([...triage.map(entry => entry.findingId), ...commentIds]) : targetIds
643
+ if (annotatedIds.size === 0) return []
644
+ // Impact can skip unannotated findings; deletion must also cover new writes.
645
+ // TODO(managed): Persist finding IDs per report at upload time and maintain
646
+ // the index on report deletion. Use it for repository impact and triage
647
+ // cleanup so overlap checks do not fetch and parse every other report blob.
648
+ // Deferred for production; the preview still performs the blob scan below.
649
+ const otherIds = await repositoryFindingIds(deps, otherReports)
650
+ return [...annotatedIds].filter((id) => !otherIds.has(id))
651
+ }
652
+
653
+ // GET /api/admin/repositories/impact — show the data a permanent repository
654
+ // removal would destroy. This is intentionally separate from the repository
655
+ // list so the normal page stays cheap even with many reports.
656
+ async function handleRepositoryImpact(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
657
+ const s = await readAdminSession(res, deps, cookie)
658
+ if (s == null) return
659
+ const repoId = Number(new URL(req.url ?? '/', 'http://localhost').searchParams.get('repoId'))
660
+ if (!Number.isSafeInteger(repoId)) { sendJson(res, 400, { error: 'bad-repo' }); return }
661
+ const repo = (await deps.db.listAllRepos()).find((candidate) => candidate.repoId === repoId)
662
+ if (repo == null) { sendJson(res, 404, { error: 'no-repo' }); return }
663
+ sendJson(res, 200, { repoId, fullName: repo.fullName, ...await repositoryImpact(deps, repoId) })
664
+ }
665
+
666
+ // POST /api/admin/repositories/remove — permanently remove a repository and
667
+ // attached reports/bundles. The exact name + explicit acknowledgement are
668
+ // checked server-side too; the UI's dialog is a usability guard, not the policy.
669
+ async function handleRemoveRepository(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
670
+ const s = await checkMutation(req, res, deps, cookie)
671
+ if (s == null) return
672
+ if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
673
+ let body: unknown
674
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
675
+ const repoId = (body as { repoId?: unknown } | null)?.repoId
676
+ const fullName = (body as { fullName?: unknown } | null)?.fullName
677
+ const acknowledge = (body as { acknowledge?: unknown } | null)?.acknowledge
678
+ const deleteTriage = (body as { deleteTriage?: unknown } | null)?.deleteTriage
679
+ if (typeof repoId !== 'number' || !Number.isSafeInteger(repoId) || typeof fullName !== 'string' || acknowledge !== true || typeof deleteTriage !== 'boolean') {
680
+ sendJson(res, 400, { error: 'confirmation-required' }); return
681
+ }
682
+ const repo = (await deps.db.listAllRepos()).find((candidate) => candidate.repoId === repoId)
683
+ if (repo == null) { sendJson(res, 404, { error: 'no-repo' }); return }
684
+ if (repo.fullName !== fullName) { sendJson(res, 400, { error: 'repo-name-mismatch' }); return }
685
+ const allReports = deleteTriage ? await deps.db.listReports() : []
686
+ const triageIds = deleteTriage
687
+ ? await repositoryExclusiveTriageIds(deps, allReports.filter(report => report.repoId === repoId), allReports.filter(report => report.repoId !== repoId), false)
688
+ : []
689
+ const { reports, bundles, deletedReports, deletedBundles, deletedTriage } = await deps.db.removeRepository(s.session.id, repo,
690
+ deleteTriage ? { reports: reportReferenceSnapshot(allReports), ids: triageIds } : null)
691
+ // Remove metadata first so a blob-store failure leaves an orphaned blob for
692
+ // later cleanup, rather than a live row pointing at missing report data.
693
+ for (const report of reports) {
694
+ await deps.reportSourcesCache?.deleteReport(report).catch((err) => { console.warn('managed: report sources delete failed:', err) })
695
+ await deps.reportStore.delete(report.id).catch(() => {})
696
+ }
697
+ for (const bundle of bundles) {
698
+ // Best-effort derivative cleanup follows the committed metadata removal.
699
+ await deps.bundleCache?.delete(bundle.id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
700
+ await deps.reportSourcesCache?.deleteBundle(bundle.id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
701
+ await deps.bundleStore.delete(bundle.id).catch(() => {})
702
+ }
703
+ sendJson(res, 200, { ok: true, deletedReports, deletedBundles, deletedTriage })
704
+ }
705
+
706
+ // Strip a client-supplied upload filename to a safe display string. The bytes
707
+ // are keyed by a server uuid. This name is used for display, Content-Disposition,
708
+ // and format detection: decode URLs, remove controls/paths, and cap the basename
709
+ // without discarding the extension that the report reader needs.
710
+ // Falls back to `fallback` when nothing usable remains.
711
+ function sanitizeFilename(raw: string | null, fallback: string): string {
712
+ if (raw == null || raw === '') return fallback
713
+ let decoded = raw
714
+ try { decoded = decodeURIComponent(raw) } catch { decoded = raw }
715
+ // Build the cleaned name char-by-char: drop control chars, fold path
716
+ // separators to '_' (avoids a control-character regex).
717
+ let cleaned = ''
718
+ for (const ch of decoded) {
719
+ const code = ch.codePointAt(0) ?? 0
720
+ if (code < 0x20 || code === 0x7f) continue
721
+ cleaned += ch === '/' || ch === '\\' ? '_' : ch
722
+ }
723
+ cleaned = cleaned.trim()
724
+ if (cleaned.length <= 200) return cleaned || fallback
725
+ const dot = cleaned.lastIndexOf('.')
726
+ const suffix = dot > 0 ? cleaned.slice(dot) : ''
727
+ // An extension that alone exceeds the cap cannot fit; ordinary extensions
728
+ // (including case-sensitive display names such as .CSV) remain unchanged.
729
+ const extension = suffix.length < 200 ? suffix : ''
730
+ return cleaned.slice(0, 200 - extension.length) + extension
731
+ }
732
+
733
+ // The selected repos a report / bundle can be linked to, for the upload UI's
734
+ // repo picker. Minimal shape (id + full name).
735
+ function selectableRepos(repos: { repoId: number; fullName: string }[]): { repoId: number; fullName: string }[] {
736
+ return repos.map((r) => ({ repoId: r.repoId, fullName: r.fullName }))
737
+ }
738
+
739
+ // Resolve the optional X-Repo-Id upload header to a selected repo id, or null
740
+ // when absent. Validates the id is currently in the selected-repos set (so the
741
+ // FK can't dangle); on a bad / unknown id sends 400 and returns { ok: false }.
742
+ async function resolveUploadRepoId(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps): Promise<{ ok: true; repoId: number | null } | { ok: false }> {
743
+ const raw = firstHeader(req.headers['x-repo-id'])
744
+ if (raw == null || raw === '') return { ok: true, repoId: null }
745
+ const n = Number(raw)
746
+ if (!Number.isSafeInteger(n)) { sendJson(res, 400, { error: 'bad-repo' }); return { ok: false } }
747
+ const selected = await deps.db.listSelectedRepos()
748
+ if (!selected.some((r) => r.repoId === n)) { sendJson(res, 400, { error: 'repo-not-selected' }); return { ok: false } }
749
+ return { ok: true, repoId: n }
750
+ }
751
+
752
+ // POST /api/admin/reports/set-repo — attach / detach a stored report's repo
753
+ // + directory link. Mutation: same-origin + CSRF, admin|manage. Body
754
+ // { reportId, repoId, directory }, where repoId is null (detach) or a
755
+ // currently-selected repo id. Directory is relative to the repository root.
756
+ async function handleSetReportRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
757
+ const s = await manageMutation(req, res, deps, cookie)
758
+ if (s == null) return
759
+ let body: unknown
760
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
761
+ const reportId = (body as { reportId?: unknown } | null)?.reportId
762
+ const repoId = (body as { repoId?: unknown } | null)?.repoId ?? null
763
+ const directory = (body as { directory?: unknown } | null)?.directory ?? ''
764
+ if (typeof reportId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
765
+ if (repoId !== null && (typeof repoId !== 'number' || !Number.isSafeInteger(repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
766
+ const normalized = normalizeTeamPath(directory)
767
+ if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
768
+ const { report, user } = await deps.db.mutateReport(s.session.id, reportId, { type: 'repo', repoId, directory: normalized.path ?? '' })
769
+ if (report.repoId !== repoId || report.repoDirectory !== (repoId == null ? '' : normalized.path ?? '')) {
770
+ await activity(deps, user, 'repository', repoId == null ? 'detached a report from its repository' : `assigned a report to repository path ${normalized.path || '/'}`, { reportId, report: report.filename, repo: await repositoryName(deps, repoId ?? report.repoId) })
771
+ }
772
+ sendJson(res, 200, { ok: true, repoId, repoDirectory: normalized.path ?? '' })
773
+ }
774
+
775
+ // POST /api/admin/reports/set-visible — publish or hide a stored report. New
776
+ // reports start hidden so an admin can inspect the parsed metadata first.
777
+ async function handleSetReportVisible(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
778
+ const s = await manageMutation(req, res, deps, cookie)
779
+ if (s == null) return
780
+ let body: unknown
781
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
782
+ const reportId = (body as { reportId?: unknown } | null)?.reportId
783
+ const visible = (body as { visible?: unknown } | null)?.visible
784
+ if (typeof reportId !== 'string' || typeof visible !== 'boolean') { sendJson(res, 400, { error: 'bad-request' }); return }
785
+ const { report, user } = await deps.db.mutateReport(s.session.id, reportId, { type: 'visibility', visible })
786
+ if (report.visible !== visible) await activity(deps, user, 'visibility', visible ? 'published a report' : 'hid a report', { reportId, report: report.filename, repo: await repositoryName(deps, report.repoId) })
787
+ sendJson(res, 200, { ok: true, visible })
788
+ }
789
+
790
+ // POST /api/admin/bundles/set-repo — attach / detach a stored bundle's repo link
791
+ // and directory (same shape as reports). Body { bundleId, repoId, directory }.
792
+ async function handleSetBundleRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
793
+ const s = await manageMutation(req, res, deps, cookie)
794
+ if (s == null) return
795
+ let body: unknown
796
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
797
+ const bundleId = (body as { bundleId?: unknown } | null)?.bundleId
798
+ const repoId = (body as { repoId?: unknown } | null)?.repoId ?? null
799
+ const normalized = normalizeTeamPath((body as { directory?: unknown } | null)?.directory ?? '')
800
+ if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
801
+ const directory = repoId == null ? '' : normalized.path ?? ''
802
+ if (typeof bundleId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
803
+ if (repoId !== null && (typeof repoId !== 'number' || !Number.isSafeInteger(repoId))) { sendJson(res, 400, { error: 'bad-repo' }); return }
804
+ const { bundle, user } = await deps.db.mutateBundle(s.session.id, bundleId, { type: 'repo', repoId, directory })
805
+ if (bundle.repoId !== repoId || bundle.repoDirectory !== directory) await activity(deps, user, 'repository', repoId == null ? 'detached a bundle from its repository' : `assigned a bundle to repository path ${directory || '/'}`, { bundleId, report: bundle.filename, repo: await repositoryName(deps, repoId ?? bundle.repoId) })
806
+ sendJson(res, 200, { ok: true, repoId, repoDirectory: directory })
807
+ }
808
+
809
+ // GET /api/admin/reports — the uploaded reports for the "Manage reports" page.
810
+ // Visible to admin|manage. Read-only, so no CSRF (like /api/admin/users).
811
+ // `maxBytes` lets the page show / pre-check the upload size cap; `repos` feeds
812
+ // the upload repo picker.
813
+ async function handleListReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession): Promise<void> {
814
+ sendJson(res, 200, {
815
+ ...await deps.db.getReportCatalog(session.id, Date.now()),
816
+ maxBytes: deps.config.maxReportBytes,
817
+ })
818
+ }
819
+
820
+ // Resolve a report's bundle link from its embedded `bundleHashes`: the first
821
+ // declared integrity that has a stored bundle wins (bundleId set). When none is
822
+ // stored yet, keep the first declared integrity so a later bundle upload of it
823
+ // re-links (see linkReportsToBundle). Returns the (bundleId, integrity) to store.
824
+ async function resolveReportBundle(deps: ManagedHttpDeps, user: StoredUser, bytes: Buffer): Promise<{ bundleId: string | null; integrity: string | null }> {
825
+ const hashes = reportBundleHashes(bytes)
826
+ for (const h of hashes) {
827
+ const b = await deps.db.getBundleByIntegrity(h)
828
+ if (b != null && await canAccessBundle(deps, user, b.id)) return { bundleId: b.id, integrity: h }
829
+ }
830
+ return { bundleId: null, integrity: hashes[0] ?? null }
831
+ }
832
+
833
+ async function sendUploadedReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, report: ReportRecord, deduped: boolean): Promise<void> {
834
+ const current = await checkMutation(req, res, deps, cookie)
835
+ if (!current || !requireManageRole(res, current.user)) return
836
+ // Uploading known bytes cannot reveal another team's report or its source
837
+ // bundle. The stored location/ownership wins over the upload headers.
838
+ if (!(await canViewReport(deps, current.user, report.id))) { sendJson(res, 409, { error: 'report-conflict' }); return }
839
+ const bundleId = report.bundleId != null && await canAccessBundle(deps, current.user, report.bundleId) ? report.bundleId : null
840
+ const { id, slug, filename, byteSize, sha256, repoId, repoDirectory, repoEmbedded, analyzer, visible } = report
841
+ sendJson(res, deduped ? 200 : 201, { id, slug, filename, byteSize, sha256, repoId, repoDirectory, repoEmbedded, analyzer, visible, bundleId, ...(deduped ? { deduped: true } : {}) })
842
+ }
843
+
844
+ // POST /api/admin/reports — upload a report. Mutation: same-origin + CSRF,
845
+ // admin|manage. The body is the raw report bytes (any findings format — JSON /
846
+ // markdown / CSV — archived as-is, like the e2e objstore; the server parses them
847
+ // downstream). Display name rides X-Report-Filename; the repository and
848
+ // directory come from the report header, or from optional repository/directory
849
+ // headers when the report has no repository metadata. The bundle link is
850
+ // auto-resolved from the report's bundleHashes. New reports start hidden until
851
+ // published. Identical content reuses its stored identity and metadata. Bytes
852
+ // are written first (keyed by a fresh uuid) then the metadata row. A definite
853
+ // insert failure or concurrent duplicate drops the orphan blob; an uncertain
854
+ // commit retains it. 413 over the cap, 400 on empty.
855
+ async function handleUploadReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
856
+ let s = await checkMutation(req, res, deps, cookie)
857
+ if (s == null) return
858
+ if (!requireManageRole(res, s.user)) return
859
+ let bytes: Buffer
860
+ try {
861
+ bytes = await readUploadBody(req, deps, s.session.id, 'reports')
862
+ } catch (err) {
863
+ const tooLarge = err instanceof Error && err.message === 'too-large'
864
+ sendJson(res, tooLarge ? 413 : 400, { error: tooLarge ? 'too-large' : 'bad-body' })
865
+ return
866
+ }
867
+ // Shared storage reads may outlast a session or role change.
868
+ s = await checkMutation(req, res, deps, cookie)
869
+ if (!s || !requireManageRole(res, s.user)) return
870
+ if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
871
+ const filename = sanitizeFilename(firstHeader(req.headers['x-report-filename']), 'report.json')
872
+ const parsed = readManagedReport(bytes.toString('utf8'), filename)
873
+ const repoGithub = parsed.data == null ? null : reportRepoGithub(parsed.data)
874
+ const repoEmbedded = repoGithub != null
875
+ const rawHeaderDirectory = firstHeader(req.headers['x-repo-directory']) ?? ''
876
+ let headerDirectory = rawHeaderDirectory
877
+ try { headerDirectory = decodeURIComponent(rawHeaderDirectory) } catch { sendJson(res, 400, { error: 'bad-directory' }); return }
878
+ // Validate the raw header, not repoDirectory()'s display/link normalization,
879
+ // which trims characters and turns invalid paths into the repository root.
880
+ const requestedDirectory = repoEmbedded ? parsed.data?.repo?.directory : headerDirectory
881
+ const normalizedDirectory = normalizeTeamPath(requestedDirectory)
882
+ if (!normalizedDirectory.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
883
+ const directory = normalizedDirectory.path ?? ''
884
+ const analyzer = parsed.data != null && typeof parsed.data.source === 'string' ? parsed.data.source : null
885
+ const selected = await deps.db.listSelectedRepos()
886
+ let matchedRepo = repoGithub == null ? null : selected.find((repo) => repo.fullName.toLocaleLowerCase() === repoGithub.toLocaleLowerCase())
887
+ if (repoGithub != null && matchedRepo == null) { sendJson(res, 400, { error: 'repo-not-connected', repo: repoGithub }); return }
888
+ // When the report has no repository header, X-Repo-Id lets the managed uploader
889
+ // assign it at upload time. Clients may omit it and attach the report later.
890
+ if (repoGithub == null) {
891
+ const legacyRepo = await resolveUploadRepoId(req, res, deps)
892
+ if (!legacyRepo.ok) return
893
+ matchedRepo = legacyRepo.repoId == null ? null : selected.find((repo) => repo.repoId === legacyRepo.repoId) ?? null
894
+ }
895
+ if (matchedRepo && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, matchedRepo.repoId, directory))) { sendJson(res, 403, { error: 'repo-forbidden' }); return }
896
+ const sha256 = createHash('sha256').update(bytes).digest('base64url')
897
+ // The analyzer also distinguishes a CSV from the same bytes previously
898
+ // uploaded under a filename that did not identify it as a report.
899
+ const existing = await deps.db.getReportByHash(sha256, analyzer)
900
+ if (existing) { await sendUploadedReport(req, res, deps, cookie, existing, true); return }
901
+ const id = randomUUID()
902
+ const contentType = (firstHeader(req.headers['content-type']) ?? '').split(';', 1)[0]!.trim() || 'application/json'
903
+ const { bundleId, integrity } = await resolveReportBundle(deps, s.user, bytes)
904
+ const dataKey = await deps.reportStore.put(id, bytes)
905
+ let report: ReportRecord
906
+ try {
907
+ report = await deps.db.insertOrReuseReport({
908
+ id, filename, contentType, byteSize: bytes.length, sha256, dataKey,
909
+ uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId: matchedRepo?.repoId ?? null,
910
+ repoDirectory: directory, repoEmbedded, analyzer, visible: false, bundleId, bundleIntegrity: integrity,
911
+ }, Date.now(), s.session.id)
912
+ } catch (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 }
920
+ }
921
+ if (report.id !== id) await deps.reportStore.delete(id).catch(() => {})
922
+ await sendUploadedReport(req, res, deps, cookie, report, report.id !== id)
923
+ }
924
+
925
+ // GET /api/admin/reports/<id> — download a stored report (admin|manage). Serves
926
+ // the bytes with the recorded content-type + filename. The recorded content-type
927
+ // is uploader-supplied, so the response is forced to download
928
+ // (Content-Disposition: attachment) AND marked `nosniff` — it can't be sniffed
929
+ // or rendered inline against the app origin. 404 when there's no such report;
930
+ // 503 when the row exists but the bytes don't (store desync).
931
+ async function handleGetReport(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
932
+ const s = await readManageSession(res, deps, cookie)
933
+ if (s == null) return
934
+ if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
935
+ const rec = await deps.db.getReport(id)
936
+ if (rec == null) { sendJson(res, 404, { error: 'no-report' }); return }
937
+ const bytes = await deps.reportStore.get(id)
938
+ if (bytes == null) { sendJson(res, 503, { error: 'unavailable' }); return }
939
+ const current = await readSession(deps.config, deps.db, cookie, Date.now())
940
+ if (!current || !roleAtLeast(current.user.role, 'manage') || !(await canViewReport(deps, current.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
941
+ // The stored filename is already control-/path-stripped (sanitizeReportFilename
942
+ // at upload); only a double-quote could break the quoted Content-Disposition.
943
+ const dispoName = rec.filename.replaceAll('"', '')
944
+ res.writeHead(200, {
945
+ 'content-type': rec.contentType,
946
+ 'content-length': String(bytes.length),
947
+ 'content-disposition': `attachment; filename="${dispoName}"`,
948
+ 'x-content-type-options': 'nosniff',
949
+ 'cache-control': 'no-store',
950
+ })
951
+ writeResponse(res, bytes)
952
+ }
953
+
954
+ // DELETE /api/admin/reports/<id> — remove a report (admin|manage). Mutation:
955
+ // same-origin + CSRF. Drops the row, then best-effort the bytes; 404 when there
956
+ // was no such report.
957
+ async function handleDeleteReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
958
+ const s = await checkMutation(req, res, deps, cookie)
959
+ if (s == null) return
960
+ if (!requireManageRole(res, s.user)) return
961
+ const { report, user } = await deps.db.mutateReport(s.session.id, id, { type: 'delete' })
962
+ await deps.reportSourcesCache?.deleteReport(report).catch((err) => { console.warn('managed: report sources delete failed:', err) })
963
+ await deps.reportStore.delete(id).catch((err) => { console.warn('managed: report bytes delete failed:', err) })
964
+ await activity(deps, user, 'delete', 'deleted a report', { repoId: report.repoId, repoDirectory: report.repoDirectory, reportId: id, report: report.filename, repo: await repositoryName(deps, report.repoId) })
965
+ sendJson(res, 200, { ok: true })
966
+ }
967
+
968
+ // Apply the same access rule to inventory, cached derivatives, raw downloads
969
+ // and mutations. Ownership is a manager exception; lower roles require teams.
970
+ async function canAccessBundle(deps: ManagedHttpDeps, user: StoredUser, id: string): Promise<boolean> {
971
+ if (!roleAtLeast(user.role, 'view')) return false
972
+ const rec = await deps.db.getBundle(id)
973
+ if (!rec) return false
974
+ if (user.role === 'admin') return true
975
+ if (user.role === 'manage') return deps.db.userCanReadBundle(user.id, id)
976
+ return rec.repoId !== null && deps.db.userCanReadRepoPath(user.id, rec.repoId, rec.repoDirectory)
977
+ }
978
+
979
+ async function bundleRepos(deps: ManagedHttpDeps, user: StoredUser) {
980
+ const repos = await deps.db.listSelectedRepos()
981
+ const access = await contentAccess(deps.db, user)
982
+ return repos.filter(access.bundle)
983
+ }
984
+
985
+ async function repositoryBrowserUser(deps: ManagedHttpDeps, userId: string) {
986
+ const [githubUserId, token] = await Promise.all([
987
+ deps.db.getUserGithubId(userId), ensureUserAccessToken(deps.config, deps.db, userId, Date.now()),
988
+ ])
989
+ return { githubUserId, token }
990
+ }
991
+
992
+ // Listing connected repository names uses managed access only. Verify GitHub
993
+ // access after selection, before returning any refs or source directory data.
994
+ async function handleBrowsableRepositories(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
995
+ const s = await readManageSession(res, deps, cookie)
996
+ if (!s) return
997
+ sendJson(res, 200, { repos: selectableRepos(await bundleRepos(deps, s.user)) })
998
+ }
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
+
1034
+ // Recheck repository/path grants after upstream reads, before returning names.
1035
+ async function handleRepositoryBrowser(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, query: URLSearchParams, refs: boolean): Promise<void> {
1036
+ const repoId = Number(query.get('repoId'))
1037
+ const normalized = normalizeTeamPath(query.get('path') ?? '')
1038
+ const ref = query.get('ref') ?? ''
1039
+ if (!Number.isSafeInteger(repoId) || repoId <= 0 || !normalized.ok || ref.length > 1024 || /\p{Cc}/u.test(ref)) {
1040
+ sendJson(res, 400, { error: 'bad-request' }); return
1041
+ }
1042
+ const withDefault = refs && query.get('withDefault') === 'true'
1043
+ const path = refs ? '' : normalized.path ?? ''
1044
+ const authorize = async () => {
1045
+ const s = await readManageSession(res, deps, cookie)
1046
+ if (!s) return null
1047
+ const repo = (await bundleRepos(deps, s.user)).find(item => item.repoId === repoId)
1048
+ if (!repo) { sendJson(res, 404, { error: 'no-repository' }); return null }
1049
+ const scopes = s.user.role === 'admin' ? [null] : (await deps.db.listRepoScopesForUser(s.user.id)).filter(scope => scope.repoId === repoId).map(scope => scope.path)
1050
+ const virtualEntries = scopedDirectory(path, scopes)
1051
+ if ((!refs || withDefault) && virtualEntries?.length === 0) { sendJson(res, 404, { error: 'no-directory' }); return null }
1052
+ return { repo, virtualEntries, user: s.user }
1053
+ }
1054
+ const access = await authorize()
1055
+ if (!access) return
1056
+ try {
1057
+ const browser = createRepositoryBrowser(deps.config, await repositoryBrowserUser(deps, access.user.id))
1058
+ const reader = await browser.reader(access.repo)
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)
1061
+ await reader.recheckAccess()
1062
+ const current = await authorize()
1063
+ if (!current) return
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))
1072
+ } catch (err) {
1073
+ if (err instanceof GithubApiError) {
1074
+ sendJson(res, err.status === 401 ? 502 : err.status, { error: err.message }, err.retryAfter == null ? {} : { 'retry-after': String(err.retryAfter) })
1075
+ return
1076
+ }
1077
+ throw err
1078
+ }
1079
+ }
1080
+
1081
+ function prebuildBundle(deps: ManagedHttpDeps, id: string) {
1082
+ if (!deps.bundleCache || deps.config.serverless) return
1083
+ deps.track((async () => {
1084
+ const record = await deps.db.getBundle(id)
1085
+ if (record?.kind) await deps.bundleCache!.prebuild(record)
1086
+ })().catch(err => console.warn('managed: bundle cache build failed:', err)))
1087
+ }
1088
+
1089
+ // GET /api/bundles/:id/{metadata,contents}. Source/cache bytes are already encoded;
1090
+ // the browser's HTTP stack decompresses them without a Brotli JS dependency.
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
1095
+ if (!rec) { sendJson(res, 404, { error: 'no-bundle' }); return }
1096
+ if (!deps.bundleCache) { sendJson(res, 503, { error: 'unavailable' }); return }
1097
+ let cached
1098
+ try { cached = await deps.bundleCache.open(rec, part) }
1099
+ catch { sendJson(res, 422, { error: 'bundle-unavailable' }); return }
1100
+ // A cold build can outlast a session or membership change.
1101
+ const current = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
1102
+ if (!current?.bundle) {
1103
+ cached.stream.destroy()
1104
+ sendJson(res, current ? 404 : 401, { error: current ? 'no-bundle' : 'unauthenticated' })
1105
+ return
1106
+ }
1107
+ res.writeHead(200, {
1108
+ 'content-type': 'application/json', 'content-encoding': 'br',
1109
+ ...(cached.size == null ? {} : { 'content-length': String(cached.size) }), 'cache-control': 'private, no-store',
1110
+ 'x-content-type-options': 'nosniff',
1111
+ })
1112
+ if (req.method === 'HEAD') { cached.stream.destroy(); res.end(); return }
1113
+ try { await pipeline(cached.stream, res) } catch { res.destroy() }
1114
+ }
1115
+
1116
+ // Published dependency advisories require security access, independently of access to
1117
+ // unpublished dependency findings. Managers retain their normal bundle access.
1118
+ async function handleBundleAdvisories(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string, teamId: string | null, reason: string, repoAdvisories: boolean) {
1119
+ const authorize = async () => {
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
1125
+ }
1126
+ return access.bundle
1127
+ }
1128
+ const record = await authorize()
1129
+ if (!record) return
1130
+ if (record.kind !== 'stasis') { sendJson(res, 422, { error: 'unsupported-bundle' }); return }
1131
+ if (!deps.bundleCache) { sendJson(res, 503, { error: 'unavailable' }); return }
1132
+ let inventory: BundleAdvisoryInventory | null | undefined
1133
+ try { inventory = await deps.bundleCache.advisoryInventory(record, reason) }
1134
+ catch { sendJson(res, 422, { error: 'bundle-unavailable' }); return }
1135
+ if (!(await authorize())) 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 }
1139
+ const controller = new AbortController()
1140
+ const onClose = () => { if (!res.writableEnded) controller.abort() }
1141
+ res.on('close', onClose)
1142
+ const timer = setTimeout(() => controller.abort(), ADVISORIES_TIMEOUT_MS)
1143
+ try {
1144
+ if (res.destroyed) return
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 })
1151
+ if (res.destroyed || !(await authorize())) return
1152
+ // Return just inventory and public advisories, never source or report data.
1153
+ sendJson(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body)
1154
+ } finally {
1155
+ clearTimeout(timer)
1156
+ res.off('close', onClose)
1157
+ }
1158
+ }
1159
+
1160
+ // GET /api/admin/bundles — the uploaded bundles for the "Manage bundles" page.
1161
+ // admin|manage, read-only (no CSRF). `maxBytes` is the upload cap; `repos` feeds
1162
+ // the upload repo picker.
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)
1166
+ // Even cached storage reads may outlast changes to access or locations.
1167
+ const catalog = await deps.db.getBundleCatalog(session.id, Date.now())
1168
+ sendJson(res, 200, {
1169
+ ...catalog,
1170
+ bundles: catalog.bundles.map(bundle => ({
1171
+ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }),
1172
+ })),
1173
+ maxBytes: deps.config.maxBundleBytes,
1174
+ })
1175
+ await backfillBundleSummaries(catalog.bundles, deps.bundleCache)
1176
+ }
1177
+
1178
+ type UploadedBundle = Pick<ManagedBundle, 'id' | 'slug' | 'integrity' | 'filename' | 'byteSize' | 'repoId' | 'repoDirectory'>
1179
+ async function sendUploadedBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, bundle: UploadedBundle, deduped: boolean): Promise<void> {
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, slug, 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, slug, 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) }
1237
+ }
1238
+
1239
+ // POST /api/admin/bundles — upload a bundle. Mutation: same-origin + CSRF,
1240
+ // admin|manage. Raw bytes; X-Bundle-Filename names it, optional X-Repo-Id and
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
1244
+ // re-upload of identical bytes dedupes to the existing row (no second copy).
1245
+ // After storing, any reports that declared this integrity but weren't linked yet
1246
+ // get attached (auto-link). 413 over the cap, 400 on empty.
1247
+ async function handleUploadBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1248
+ let s = await checkMutation(req, res, deps, cookie)
1249
+ if (s == null) return
1250
+ if (!requireManageRole(res, s.user)) return
1251
+ let bytes: Buffer
1252
+ try {
1253
+ bytes = await readUploadBody(req, deps, s.session.id, 'bundles')
1254
+ } catch (err) {
1255
+ const tooLarge = err instanceof Error && err.message === 'too-large'
1256
+ sendJson(res, tooLarge ? 413 : 400, { error: tooLarge ? 'too-large' : 'bad-body' })
1257
+ return
1258
+ }
1259
+ // Shared storage reads may outlast a session or role change.
1260
+ s = await checkMutation(req, res, deps, cookie)
1261
+ if (!s || !requireManageRole(res, s.user)) return
1262
+ if (bytes.length === 0) { sendJson(res, 400, { error: 'empty' }); return }
1263
+ const repo = await resolveUploadRepoId(req, res, deps)
1264
+ if (!repo.ok) return
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
1273
+ try { rawDirectory = decodeURIComponent(firstHeader(req.headers['x-repo-directory']) ?? '') }
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
+ }
1290
+ const normalized = normalizeTeamPath(rawDirectory)
1291
+ if (!normalized.ok) { sendJson(res, 400, { error: 'bad-directory' }); return }
1292
+ let directory = repo.repoId == null ? '' : normalized.path ?? ''
1293
+ if (repo.repoId !== null && s.user.role !== 'admin' && !(await deps.db.userCanReadRepoPath(s.user.id, repo.repoId, directory))) {
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 = ''
1299
+ }
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> {
1305
+ const existing = await deps.db.getBundleByIntegrity(integrity)
1306
+ if (existing) { await sendUploadedBundle(req, res, deps, cookie, existing, true); return }
1307
+ const id = randomUUID()
1308
+ const kind = bundleKind(filename)
1309
+ const dataKey = await deps.bundleStore.put(id, bytes, kind)
1310
+ try {
1311
+ await deps.db.insertBundle({
1312
+ id, integrity, filename, kind, dataKey,
1313
+ byteSize: bytes.length, uploadedBy: s.user.id, uploadedByLogin: s.user.login, repoId, repoDirectory: directory,
1314
+ }, Date.now(), s.session.id)
1315
+ } catch (err) {
1316
+ // A concurrent upload of identical bytes can insert this integrity (UNIQUE)
1317
+ // between our dedup check and this insert — treat that as a dedup, not a 500.
1318
+ // Any other failure rethrows.
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.
1329
+ }
1330
+ const stored = await deps.db.getBundle(id)
1331
+ if (!stored) { sendJson(res, 404, { error: 'no-bundle' }); return }
1332
+ await sendUploadedBundle(req, res, deps, cookie, stored, false)
1333
+ }
1334
+
1335
+ // GET /api/admin/bundles/<id> — download a stored bundle (admin|manage). Bytes
1336
+ // are opaque archives, so served as application/octet-stream. Sourcemaps use
1337
+ // HTTP Brotli decoding to restore the uploaded .map bytes. 404 no such
1338
+ // bundle; 503 row-without-bytes (store desync).
1339
+ async function handleGetBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, id: string): Promise<void> {
1340
+ const access = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
1341
+ if (!access) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1342
+ const rec = access.bundle
1343
+ if (rec == null) { sendJson(res, 404, { error: 'no-bundle' }); return }
1344
+ const stored = await deps.bundleStore.open(id, rec.kind)
1345
+ if (stored == null) { sendJson(res, 503, { error: 'unavailable' }); return }
1346
+ const current = await deps.db.getBundleAccessSnapshot(session.id, Date.now(), id)
1347
+ if (!current?.bundle) {
1348
+ stored.stream.destroy()
1349
+ sendJson(res, current ? 404 : 401, { error: 'no-bundle' }); return
1350
+ }
1351
+ const dispoName = rec.filename.replaceAll('"', '')
1352
+ res.writeHead(200, {
1353
+ 'content-type': 'application/octet-stream',
1354
+ ...(rec.kind === 'sourcemap' ? { 'content-encoding': 'br' } : {}),
1355
+ ...(stored.size == null ? {} : { 'content-length': String(stored.size) }),
1356
+ 'content-disposition': `attachment; filename="${dispoName}"`,
1357
+ 'x-content-type-options': 'nosniff',
1358
+ 'cache-control': 'no-store',
1359
+ })
1360
+ if (req.method === 'HEAD') { stored.stream.destroy(); res.end(); return }
1361
+ try { await pipeline(stored.stream, res) } catch { res.destroy() }
1362
+ }
1363
+
1364
+ // DELETE /api/admin/bundles/<id> — remove a bundle (admin|manage). Mutation:
1365
+ // same-origin + CSRF. Drops the row (referencing reports' bundle_id null out via
1366
+ // the FK; their bundle_integrity stays, so a re-upload re-links), then the bytes
1367
+ // best-effort. 404 when there was no such bundle.
1368
+ async function handleDeleteBundle(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1369
+ const s = await checkMutation(req, res, deps, cookie)
1370
+ if (s == null) return
1371
+ if (!requireManageRole(res, s.user)) return
1372
+ const { bundle, user } = await deps.db.mutateBundle(s.session.id, id, { type: 'delete' })
1373
+ await deps.bundleCache?.delete(id).catch((err) => { console.warn('managed: bundle cache delete failed:', err) })
1374
+ await deps.reportSourcesCache?.deleteBundle(id).catch((err) => { console.warn('managed: report sources delete failed:', err) })
1375
+ await deps.bundleStore.delete(id).catch((err) => { console.warn('managed: bundle bytes delete failed:', err) })
1376
+ await activity(deps, user, 'delete', 'deleted a bundle', { repoId: bundle.repoId, repoDirectory: bundle.repoDirectory, bundleId: id, report: bundle.filename, repo: await repositoryName(deps, bundle.repoId) })
1377
+ sendJson(res, 200, { ok: true })
1378
+ }
1379
+
1380
+ // admin|manage mutation guard: same-origin + CSRF + role, in one step. Returns
1381
+ // the session, or null after the 401/403 was sent.
1382
+ async function manageMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
1383
+ const s = await checkMutation(req, res, deps, cookie)
1384
+ if (s == null) return null
1385
+ if (!requireManageRole(res, s.user)) return null
1386
+ return s
1387
+ }
1388
+
1389
+ // Workspace access and repository connections are administered separately
1390
+ // from content management. Managers cannot change their own access via teams.
1391
+ async function adminMutation(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<{ session: ManagedSession; user: StoredUser } | null> {
1392
+ const s = await checkMutation(req, res, deps, cookie)
1393
+ if (s == null) return null
1394
+ if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return null }
1395
+ return s
1396
+ }
1397
+
1398
+ // GET /api/teams — the CURRENT user's teams, each with the reports and bundles
1399
+ // attached to the team's repos, for the sidebar's per-user Teams section. Any approved
1400
+ // user (not just admin|manage); a user only ever sees their own teams.
1401
+ async function handleMyTeams(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession): Promise<void> {
1402
+ let snapshot = await deps.db.getUserTeamFeedSnapshot(session.id, Date.now())
1403
+ if (!snapshot) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1404
+ const summaries = await bundleSummaries(snapshot.teams.flatMap(team => team.bundles), deps.bundleCache)
1405
+ const current = await deps.db.getFeedState(session.id, Date.now())
1406
+ if (!current) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1407
+ // Keep the post-storage authorization fence, but reload the catalog only
1408
+ // when a concurrent mutation actually invalidated its consistent snapshot.
1409
+ if (current.user.id !== snapshot.user.id || current.user.role !== snapshot.user.role || current.catalog !== snapshot.catalog) {
1410
+ snapshot = await deps.db.getUserTeamFeedSnapshot(session.id, Date.now())
1411
+ if (!snapshot) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1412
+ }
1413
+ const { teams, revision } = snapshot
1414
+ sendJson(res, 200, {
1415
+ teams: teams.map(team => ({ ...team, bundles: team.bundles.map(bundle => ({ ...bundle, ...(summaries.get(bundle.integrity) ?? { summary: null, summaryRetryAt: null }) })) })),
1416
+ revision,
1417
+ })
1418
+ await backfillBundleSummaries(teams.flatMap(team => team.bundles), deps.bundleCache)
1419
+ }
1420
+
1421
+ // Admins read all reports. Managers read their uploads or reports inside their
1422
+ // team repository paths, including drafts they may manage. View/triage users
1423
+ // need a published report in their teams; ownership never overrides role none.
1424
+ async function canViewReport(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
1425
+ if (!roleAtLeast(user.role, 'view')) return false
1426
+ const report = await deps.db.getReport(reportId)
1427
+ if (report == null) return false
1428
+ if (user.role === 'admin') return true
1429
+ if (user.role === 'manage') return report.uploadedBy === user.id || deps.db.userCanReadReport(user.id, reportId)
1430
+ if (!report.visible) return false
1431
+ return deps.db.userCanReadReport(user.id, reportId)
1432
+ }
1433
+
1434
+ // GET/HEAD /api/reports/:id/sources. Only the linked bundle's files cited by
1435
+ // visible findings (location AND evidence) are returned. Missing/unavailable
1436
+ // bundles are a quiet no-op; cached gzip bodies stream directly from disk.
1437
+ async function handleReportSources(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null) {
1438
+ const s = await readSession(deps.config, deps.db, cookie, Date.now())
1439
+ if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1440
+ if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1441
+ const sourcePaths = roleAtLeast(s.user.role, 'manage') ? undefined : await teamSourcePaths(deps.db, deps.reportStore, s.session.id, teamId ?? '', id)
1442
+ const report = await deps.db.getReport(id)
1443
+ const bundle = report?.bundleId ? await deps.db.getBundle(report.bundleId) : null
1444
+ const empty = () => { res.writeHead(204, { 'cache-control': 'private, no-store' }); res.end() }
1445
+ if (!report || !bundle || !['stasis', 'sourcemap'].includes(bundle.kind ?? '') || !(await canAccessBundle(deps, s.user, bundle.id))) { empty(); return }
1446
+ if (!deps.reportSourcesCache) { sendJson(res, 503, { error: 'unavailable' }); return }
1447
+ let cached
1448
+ try { cached = await deps.reportSourcesCache.open(report, bundle, { dependencies: true, security: true }, sourcePaths) }
1449
+ catch { empty(); return }
1450
+ if (!cached) { empty(); return }
1451
+ // Cold parsing can outlast role/team changes, report deletion, or relinking.
1452
+ const current = await readSession(deps.config, deps.db, cookie, Date.now())
1453
+ const latest = await deps.db.getReport(id)
1454
+ let latestPaths: Set<string> | undefined
1455
+ try { latestPaths = current && !roleAtLeast(current.user.role, 'manage') ? await teamSourcePaths(deps.db, deps.reportStore, current.session.id, teamId ?? '', id) : undefined }
1456
+ catch (error) { cached.stream.destroy(); throw error }
1457
+ if (!current || current.user.role !== s.user.role || sourcePaths?.size !== latestPaths?.size || [...sourcePaths ?? []].some(path => !latestPaths?.has(path)) || !(await canViewReport(deps, current.user, id)) || !(await canAccessBundle(deps, current.user, bundle.id))
1458
+ || latest?.bundleId !== bundle.id || latest.sha256 !== report.sha256
1459
+ || latest.repoId !== report.repoId || await repositoryName(deps, latest.repoId) !== cached.repo.github) {
1460
+ cached.stream.destroy()
1461
+ sendJson(res, current ? 404 : 401, { error: current ? 'no-report' : 'unauthenticated' })
1462
+ return
1463
+ }
1464
+ res.writeHead(200, {
1465
+ 'content-type': 'application/json', 'content-encoding': 'gzip',
1466
+ ...(cached.size == null ? {} : { 'content-length': String(cached.size) }), 'cache-control': 'private, no-store',
1467
+ 'x-content-type-options': 'nosniff',
1468
+ })
1469
+ if (req.method === 'HEAD') { cached.stream.destroy(); res.end(); return }
1470
+ try { await pipeline(cached.stream, res) } catch { res.destroy() }
1471
+ }
1472
+
1473
+ // GET /api/reports/<id> — admin/manager preview, within management access.
1474
+ // Accept: application/json includes parsed, filtered data and the server's repo
1475
+ // assignment. Other callers retain the raw text/plain response. The
1476
+ // client renders either without caching to OPFS. 404 covers "no such report" AND
1477
+ // "not authorized" (so neither existence nor membership is probeable); 503 = row
1478
+ // without bytes (store desync).
1479
+ async function handleViewReport(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1480
+ const s = await readManageSession(res, deps, cookie)
1481
+ if (s == null) return
1482
+ if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1483
+ const bytes = await deps.reportStore.get(id)
1484
+ if (bytes == null) { sendJson(res, 503, { error: 'unavailable' }); return }
1485
+ const current = await readSession(deps.config, deps.db, cookie, Date.now())
1486
+ if (!current || !roleAtLeast(current.user.role, 'manage') || !(await canViewReport(deps, current.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1487
+ if (acceptsReportMetadata(req.headers.accept)) {
1488
+ const report = await deps.db.getReport(id)
1489
+ if (report == null) { sendJson(res, 404, { error: 'no-report' }); return }
1490
+ const repo = { github: await repositoryName(deps, report.repoId), directory: report.repoDirectory }
1491
+ const { data } = readManagedReport(bytes.toString('utf8'), report.filename)
1492
+ if (data == null) { sendJson(res, 422, { error: 'unreadable-report' }); return }
1493
+ sendJson(res, 200, {
1494
+ data,
1495
+ repo,
1496
+ }, { vary: 'Accept', 'x-content-type-options': 'nosniff' })
1497
+ return
1498
+ }
1499
+ const out = bytes
1500
+ res.writeHead(200, {
1501
+ 'content-type': 'text/plain; charset=utf-8',
1502
+ 'content-length': String(out.length),
1503
+ 'x-content-type-options': 'nosniff',
1504
+ 'cache-control': 'no-store',
1505
+ vary: 'Accept',
1506
+ })
1507
+ writeResponse(res, out)
1508
+ }
1509
+
1510
+ // A read-only POST avoids URL-length limits when a workspace has many reports.
1511
+ // Authorize every requested id before reading blobs; never return a partial
1512
+ // workspace, or let one authorized report grant access to another in the batch.
1513
+ const REPORT_QUERY_CONCURRENCY = 8
1514
+ const REPORT_QUERY_IN_FLIGHT_BYTES = 64 * 1024 * 1024
1515
+ async function handleQueryReports(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1516
+ const s = await readManageSession(res, deps, cookie)
1517
+ if (!s) return
1518
+ let body
1519
+ try { body = await readJsonBody(req, 1024 * 1024) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1520
+ const raw = (body as { ids?: unknown } | null)?.ids
1521
+ if (!Array.isArray(raw) || raw.some(id => typeof id !== 'string' || !id || id.length > 256)) {
1522
+ sendJson(res, 400, { error: 'bad-ids' }); return
1523
+ }
1524
+ const ids = [...new Set(raw as string[])]
1525
+ if (ids.length > MAX_REPORT_QUERY_COUNT) { sendJson(res, 413, { error: 'batch-too-large' }); return }
1526
+ const snapshot = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), ids)
1527
+ if (!snapshot || snapshot.user.id !== s.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1528
+ if (!roleAtLeast(snapshot.user.role, 'manage')) { sendJson(res, 404, { error: 'no-report' }); return }
1529
+ const reports = new Map(snapshot.reports.map(report => [report.id, report]))
1530
+ if (reports.size !== ids.length) { sendJson(res, 404, { error: 'no-report' }); return }
1531
+ const storedBytes = snapshot.reports.reduce((total, report) => total + report.byteSize, 0)
1532
+ if (storedBytes > MAX_REPORT_QUERY_BYTES) { sendJson(res, 413, { error: 'batch-too-large' }); return }
1533
+ // Overlap remote blob reads, retaining only each report's encoded response
1534
+ // after processing. Check actual bytes too if storage and metadata disagree.
1535
+ const parts = [Buffer.from('{"reports":[')]
1536
+ let inputBytes = 0, outputBytes = parts[0]!.length + 2
1537
+ let next = 0, stopped = false
1538
+ let inFlightBytes = 0
1539
+ let capacity = Promise.withResolvers<void>()
1540
+ const fail = (status: number, error: string) => { stopped = true; return { status, error } }
1541
+ const workers = await Promise.allSettled(Array.from({ length: Math.min(REPORT_QUERY_CONCURRENCY, ids.length) }, async () => {
1542
+ try {
1543
+ while (next < ids.length) {
1544
+ if (stopped) return
1545
+ const index = next
1546
+ const id = ids[index]!
1547
+ const access = reports.get(id)!
1548
+ const reservedBytes = Math.max(1, access.byteSize)
1549
+ // Reserve stored bytes before starting remote reads, through encoding.
1550
+ // A report larger than the concurrent budget is still allowed alone.
1551
+ if (inFlightBytes > 0 && inFlightBytes + reservedBytes > REPORT_QUERY_IN_FLIGHT_BYTES) {
1552
+ await capacity.promise
1553
+ continue
1554
+ }
1555
+ next++
1556
+ inFlightBytes += reservedBytes
1557
+ try {
1558
+ const bytes = await deps.reportStore.get(id)
1559
+ if (stopped) return
1560
+ if (bytes == null) return fail(503, 'unavailable')
1561
+ inputBytes += bytes.length
1562
+ if (inputBytes > MAX_REPORT_QUERY_BYTES) return fail(413, 'batch-too-large')
1563
+ const { data } = readManagedReport(bytes.toString('utf8'), access.filename)
1564
+ if (!data) return fail(422, 'unreadable-report')
1565
+ const part = Buffer.from(`${index > 0 ? ',' : ''}${JSON.stringify({ id, data: filterReportData(data, access.permissions, access.repo), repo: access.repo })}`)
1566
+ outputBytes += part.length
1567
+ if (outputBytes > MAX_REPORT_QUERY_BYTES) return fail(413, 'batch-too-large')
1568
+ // Completion order may differ from the requested report order.
1569
+ parts[index + 1] = part
1570
+ } finally {
1571
+ inFlightBytes -= reservedBytes
1572
+ capacity.resolve()
1573
+ capacity = Promise.withResolvers<void>()
1574
+ }
1575
+ }
1576
+ } catch (error) {
1577
+ if (stopped) return
1578
+ stopped = true
1579
+ throw error
1580
+ }
1581
+ return undefined
1582
+ }))
1583
+ // Stop scheduling on any failure, and drain already-started reads before
1584
+ // responding so none outlive the tracked request or reject unhandled.
1585
+ for (const result of workers) {
1586
+ if (result.status === 'rejected') throw result.reason
1587
+ if (result.value) { sendJson(res, result.value.status, { error: result.value.error }); return }
1588
+ }
1589
+ // Access to an earlier report may change while a later blob is fetched.
1590
+ // Reject the whole answer if any content/access/assignment snapshot changed.
1591
+ const current = await deps.db.getReportAccessSnapshot(s.session.id, Date.now(), ids)
1592
+ if (!current || current.user.id !== snapshot.user.id) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1593
+ if (current.user.role !== snapshot.user.role || current.reports.length !== reports.size
1594
+ || current.reports.some(report => JSON.stringify(report) !== JSON.stringify(reports.get(report.id)))) {
1595
+ sendJson(res, 404, { error: 'no-report' }); return
1596
+ }
1597
+ parts.push(Buffer.from(']}'))
1598
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
1599
+ writeResponse(res, Buffer.concat(parts, outputBytes))
1600
+ }
1601
+
1602
+ // The server selects the complete workspace; clients cannot omit a report
1603
+ // or links file to evade classification through the rest of their team.
1604
+ async function handleTeamReports(res: ServerResponse, deps: ManagedHttpDeps, session: ManagedSession, teamId: string): Promise<void> {
1605
+ const snapshot = await teamSnapshot(deps.db, session.id, teamId)
1606
+ const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
1607
+ await recheckTeam(deps.db, session.id, snapshot)
1608
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'x-content-type-options': 'nosniff' })
1609
+ writeResponse(res, body)
1610
+ }
1611
+
1612
+ // Triage requires both a writing role and access to the report itself.
1613
+ async function canTriageReport(deps: ManagedHttpDeps, user: StoredUser, reportId: string): Promise<boolean> {
1614
+ return roleAtLeast(user.role, 'triage') && await canViewReport(deps, user, reportId)
1615
+ }
1616
+
1617
+ // Ordinary users authorize annotations through the complete team's filtered
1618
+ // workspace. Privileged users retain report-scoped access and the same stable
1619
+ // ID derivation. Both paths store/read annotations by finding ID alone.
1620
+ const VISIBLE_IDS_CACHE_MAX = 256
1621
+ const visibleIdsCaches = new WeakMap<ManagedHttpDeps, Map<string, Set<string>>>()
1622
+ async function visibleFindingIds(deps: ManagedHttpDeps, user: StoredUser, reportId: string, sessionId: string, teamId: string | null): Promise<Set<string>> {
1623
+ if (!roleAtLeast(user.role, 'manage')) {
1624
+ if (!teamId) throw new TeamReportsError(404, 'no-team')
1625
+ return teamFindingIds(deps.db, deps.reportStore, sessionId, teamId, reportId)
1626
+ }
1627
+ const rec = await deps.db.getReport(reportId)
1628
+ if (rec == null) return new Set()
1629
+ const repo = { github: await repositoryName(deps, rec.repoId) }
1630
+ const key = JSON.stringify([reportId, rec.sha256, rec.filename, repo])
1631
+ let cache = visibleIdsCaches.get(deps)
1632
+ if (cache == null) { cache = new Map(); visibleIdsCaches.set(deps, cache) }
1633
+ const hit = cache.get(key)
1634
+ if (hit != null) return hit
1635
+ const ids = new Set<string>()
1636
+ const bytes = await deps.reportStore.get(reportId)
1637
+ if (bytes == null) return ids
1638
+ const text = bytes.toString('utf8')
1639
+ const { data } = readManagedReport(text, rec.filename)
1640
+ if (data == null) return ids
1641
+ const report = await loadManagedFindings(JSON.stringify(data), rec.filename)
1642
+ if (report == null) return ids
1643
+ for (const f of report.findings) {
1644
+ const id = (f as { id?: unknown }).id
1645
+ if (typeof id === 'string' && id !== '') ids.add(id)
1646
+ }
1647
+ while (cache.size >= VISIBLE_IDS_CACHE_MAX) {
1648
+ const oldest = cache.keys().next().value
1649
+ if (oldest === undefined) break
1650
+ cache.delete(oldest)
1651
+ }
1652
+ cache.set(key, ids)
1653
+ return ids
1654
+ }
1655
+
1656
+ // GET /api/reports/<id>/triage — the stored triage entries for the findings of
1657
+ // a report the caller may view (canViewReport; 404 hides existence and denial
1658
+ // alike, matching handleViewReport). Entries are keyed by finding id and shared
1659
+ // by every report carrying the finding; a viewer only receives entries for
1660
+ // findings their visibility permissions keep in THIS report — an entry on a
1661
+ // stripped finding must not leak that the finding exists. A cleared entry is
1662
+ // sent as null (the server's tombstone), distinct from one never set.
1663
+ async function handleGetReportTriage(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
1664
+ const s = await readSession(deps.config, deps.db, cookie, Date.now())
1665
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1666
+ if (!(await canViewReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1667
+ await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
1668
+ const current = await readSession(deps.config, deps.db, cookie, Date.now())
1669
+ if (!current || !(await canViewReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
1670
+ const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
1671
+ const entries: Record<string, TriageEntryPatch | null> = {}
1672
+ for (const row of await deps.db.listTriage([...visible])) entries[row.findingId] = triageWireEntry(row)
1673
+ sendJson(res, 200, { entries })
1674
+ }
1675
+
1676
+ // GET /api/reports/<id>/triage/history?finding=<fid> — one finding's triage
1677
+ // trail (newest first, capped). The caller must have at least triage access
1678
+ // to the report, and the finding is one their visibility permissions
1679
+ // keep in it (a stripped or foreign id 404s without revealing whether it
1680
+ // exists). Each event is the entry as written then (null = a clear), who
1681
+ // wrote it and when; "what changed" is the diff against the next-older event.
1682
+ async function handleGetReportTriageHistory(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, query: URLSearchParams, teamId: string | null): Promise<void> {
1683
+ const s = await readSession(deps.config, deps.db, cookie, Date.now())
1684
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
1685
+ if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1686
+ const finding = query.get('finding') ?? ''
1687
+ if (finding === '' || finding.length > MAX_FINDING_ID) { sendJson(res, 400, { error: 'bad-request' }); return }
1688
+ await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
1689
+ const current = await readSession(deps.config, deps.db, cookie, Date.now())
1690
+ if (!current || !(await canTriageReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
1691
+ const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
1692
+ if (!visible.has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
1693
+ const events = (await deps.db.listTriageHistory(finding, MAX_TRIAGE_HISTORY)).map((row: TriageEventRow) => ({
1694
+ seq: row.seq, at: row.at, actorId: row.actorId, actorLogin: row.actorLogin, actorName: row.actorName,
1695
+ batchId: row.batchId, entry: triageWireEntry(row, true),
1696
+ }))
1697
+ // A database read can outlive a role, membership, or visibility change.
1698
+ const latest = await readSession(deps.config, deps.db, cookie, Date.now())
1699
+ if (!latest || latest.user.role !== current.user.role || !(await canTriageReport(deps, latest.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1700
+ if (!(await visibleFindingIds(deps, latest.user, id, latest.session.id, teamId)).has(finding)) { sendJson(res, 404, { error: 'no-finding' }); return }
1701
+ sendJson(res, 200, { finding, events })
1702
+ }
1703
+
1704
+ // POST /api/reports/<id>/triage — write triage entries for a report's findings.
1705
+ // Mutation: same-origin + CSRF, then canTriageReport (404 on any failure — role
1706
+ // too low, no membership, or no such report). Body { entries: { <findingId>:
1707
+ // entry|null } } — whole-entry replace, last write wins, null clears the entry
1708
+ // (a tombstone, so a later reader adopts the clear); each entry validates
1709
+ // through parseTriageEntryPatch (400 on a malformed one). Every id must be a
1710
+ // finding the writer sees in THIS report — the report is the authorization
1711
+ // scope for rows that are themselves per finding id — so a stripped (or
1712
+ // foreign) finding id 404s without revealing whether it exists.
1713
+ async function handleSetReportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string, teamId: string | null): Promise<void> {
1714
+ const s = await checkMutation(req, res, deps, cookie)
1715
+ if (s == null) return
1716
+ if (!(await canTriageReport(deps, s.user, id))) { sendJson(res, 404, { error: 'no-report' }); return }
1717
+ let body: unknown
1718
+ try { body = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1719
+ const raw = (body as { entries?: unknown } | null)?.entries
1720
+ if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) { sendJson(res, 400, { error: 'bad-request' }); return }
1721
+ const pairs = Object.entries(raw)
1722
+ if (pairs.length > MAX_TRIAGE_ENTRIES) { sendJson(res, 400, { error: 'too-many-entries' }); return }
1723
+ const parsed: [string, TriageEntryPatch | null][] = []
1724
+ for (const [findingId, value] of pairs) {
1725
+ const patch = parseTriageEntryPatch(value)
1726
+ if (value != null && typeof value === 'object' && Object.hasOwn(value, 'comment')) {
1727
+ sendJson(res, 400, { error: 'use-comments-endpoint' }); return
1728
+ }
1729
+ if (patch === 'invalid' || findingId === '' || findingId.length > MAX_FINDING_ID) {
1730
+ sendJson(res, 400, { error: 'bad-entry' }); return
1731
+ }
1732
+ parsed.push([findingId, patch])
1733
+ }
1734
+ await visibleFindingIds(deps, s.user, id, s.session.id, teamId)
1735
+ const current = await readSession(deps.config, deps.db, cookie, Date.now())
1736
+ if (!current || !(await canViewReport(deps, current.user, id)) || current.user.role !== s.user.role) { sendJson(res, 404, { error: 'no-report' }); return }
1737
+ const visible = await visibleFindingIds(deps, current.user, id, current.session.id, teamId)
1738
+ if (parsed.some(([findingId]) => !visible.has(findingId))) {
1739
+ sendJson(res, 404, { error: 'no-finding' }); return
1740
+ }
1741
+ await deps.db.setTriageEntries(parsed, s.user.id, s.user.login, Date.now(), id)
1742
+ sendJson(res, 200, { ok: true })
1743
+ }
1744
+
1745
+ // GET only: clients match local annotations against these IDs without sending
1746
+ // their local finding IDs or annotation bodies to discover which reports exist.
1747
+ async function handleImportFindingIds(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, search: URLSearchParams): Promise<void> {
1748
+ const s = await readAdminSession(res, deps, cookie)
1749
+ if (!s) return
1750
+ const after = search.get('after') ?? ''
1751
+ if (after && !/^[a-f\d-]{36}$/iu.test(after)) { sendJson(res, 400, { error: 'bad-cursor' }); return }
1752
+ const stored = await deps.db.listFindingCatalogReports(after, FINDING_CATALOG_PAGE_COUNT + 1)
1753
+ const reports = []
1754
+ let bytes = 0, cursor = after, processed = 0
1755
+ for (const report of stored) {
1756
+ if (processed >= FINDING_CATALOG_PAGE_COUNT || (processed > 0 && bytes + report.byteSize > FINDING_CATALOG_PAGE_BYTES)) break
1757
+ bytes += report.byteSize
1758
+ processed++
1759
+ cursor = report.id
1760
+ const findingIds = [...await visibleFindingIds(deps, s.user, report.id, s.session.id, null)]
1761
+ if (findingIds.length > 0) reports.push({ id: report.id, findingIds })
1762
+ }
1763
+ if (await readAdminSession(res, deps, cookie) == null) return
1764
+ sendJson(res, 200, { reports, nextCursor: processed < stored.length ? cursor : null })
1765
+ }
1766
+
1767
+ // Admin imports compare the exact snapshot shown by the conflict dialog before
1768
+ // writing. The store commits shared finding triage and imported comments together.
1769
+ async function handleImportTriage(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, id: string): Promise<void> {
1770
+ const s = await adminMutation(req, res, deps, cookie)
1771
+ if (!s) return
1772
+ let body: { findingIds?: unknown; entries?: unknown; expected?: unknown }
1773
+ try { body = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) as typeof body }
1774
+ catch { sendJson(res, 400, { error: 'bad-body' }); return }
1775
+ if (!body || typeof body !== 'object') { sendJson(res, 400, { error: 'bad-body' }); return }
1776
+ const reading = body.entries === undefined
1777
+ const raw = body.entries
1778
+ if (!reading && (!raw || typeof raw !== 'object' || Array.isArray(raw))) { sendJson(res, 400, { error: 'bad-entries' }); return }
1779
+ const ids = reading ? body.findingIds : Object.keys(raw!)
1780
+ if (!Array.isArray(ids) || ids.length > MAX_TRIAGE_ENTRIES || ids.some(value => typeof value !== 'string' || !value || value.length > MAX_FINDING_ID)) {
1781
+ sendJson(res, 400, { error: 'bad-finding-ids' }); return
1782
+ }
1783
+ const parsed: [string, TriageEntryPatch | null][] = []
1784
+ const expected = body.expected as Record<string, string> | null
1785
+ if (!reading) {
1786
+ if (!expected || typeof expected !== 'object' || Array.isArray(expected)) { sendJson(res, 400, { error: 'bad-expected' }); return }
1787
+ for (const [findingId, value] of Object.entries(raw!)) {
1788
+ const patch = parseTriageEntryPatch(value)
1789
+ if (patch === 'invalid' || !/^[a-f\d]{64}$/u.test(expected[findingId] ?? '')) { sendJson(res, 400, { error: 'bad-entry' }); return }
1790
+ parsed.push([findingId, patch])
1791
+ }
1792
+ }
1793
+ if (!(await deps.db.getReport(id))) { sendJson(res, 404, { error: 'no-report' }); return }
1794
+ const visible = await visibleFindingIds(deps, s.user, id, s.session.id, null)
1795
+ if (await readAdminSession(res, deps, cookie) == null) return
1796
+ if (ids.some(findingId => !visible.has(findingId))) { sendJson(res, 404, { error: 'no-finding' }); return }
1797
+ if (reading) { sendJson(res, 200, { snapshots: await deps.db.getImportTriage(ids) }); return }
1798
+ const applied = await deps.db.importTriage(parsed, expected!, { id: s.user.id, login: s.user.login }, id, Date.now())
1799
+ sendJson(res, applied ? 200 : 409, { ok: applied })
1800
+ }
1801
+
1802
+ // A report grants access to its visible findings; comments themselves are
1803
+ // shared by finding ID. Author IDs always come from the authenticated session.
1804
+ async function handleReportComments(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, reportId: string, commentId: string | null, teamId: string | null): Promise<void> {
1805
+ const method = req.method ?? 'GET'
1806
+ if (commentId == null ? method !== 'GET' && method !== 'POST' : method !== 'PATCH' && method !== 'DELETE') {
1807
+ send405(res, commentId == null ? 'GET, POST' : 'PATCH, DELETE'); return
1808
+ }
1809
+ const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
1810
+ if (s == null) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
1811
+ const allowed = method === 'GET' ? await canViewReport(deps, s.user, reportId) : await canTriageReport(deps, s.user, reportId)
1812
+ if (!allowed) { sendJson(res, 404, { error: 'no-report' }); return }
1813
+ let raw: { body?: unknown; findingId?: unknown; version?: unknown } | null
1814
+ if (method === 'GET') raw = null
1815
+ else {
1816
+ try { raw = await readJsonBody(req, MAX_TRIAGE_BODY_BYTES) as typeof raw } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1817
+ }
1818
+ // Body delivery and a cold report read can outlive a permission change.
1819
+ // Match triage's recheck before exposing or changing any annotation.
1820
+ await visibleFindingIds(deps, s.user, reportId, s.session.id, teamId)
1821
+ const session = await readSession(deps.config, deps.db, cookie, Date.now())
1822
+ if (!session || session.user.role !== s.user.role || !(await canViewReport(deps, session.user, reportId))) {
1823
+ sendJson(res, 404, { error: 'no-report' }); return
1824
+ }
1825
+ const visible = await visibleFindingIds(deps, session.user, reportId, session.session.id, teamId)
1826
+ if (method === 'GET') {
1827
+ sendJson(res, 200, { comments: await deps.db.listComments([...visible]) }); return
1828
+ }
1829
+ const body = parseCommentBody(raw?.body)
1830
+ if (body == null && method !== 'DELETE') { sendJson(res, 400, { error: 'bad-comment' }); return }
1831
+ if (commentId == null) {
1832
+ if (typeof raw?.findingId !== 'string' || !visible.has(raw.findingId)) { sendJson(res, 404, { error: 'no-finding' }); return }
1833
+ const comment = await deps.db.createComment({ findingId: raw.findingId, body: body!, authorId: session.user.id, authorLogin: session.user.login, reportId }, Date.now())
1834
+ sendJson(res, 201, { comment }); return
1835
+ }
1836
+ const current = await deps.db.getComment(commentId)
1837
+ if (current == null || !visible.has(current.findingId)) { sendJson(res, 404, { error: 'no-comment' }); return }
1838
+ const allowedComment = method === 'DELETE' ? canDeleteComment(current, session.user) : current.authorId === session.user.id
1839
+ if (!allowedComment) { sendJson(res, 403, { error: 'not-comment-author' }); return }
1840
+ if (typeof raw?.version !== 'number' || !Number.isSafeInteger(raw.version) || raw.version < 1) {
1841
+ sendJson(res, 400, { error: 'bad-version' }); return
1842
+ }
1843
+ const comment = method === 'DELETE'
1844
+ ? await deps.db.deleteComment(commentId, session.user.id, session.user.login, raw.version, reportId, Date.now())
1845
+ : await deps.db.editComment(commentId, session.user.id, session.user.login, body!, raw.version, reportId, Date.now())
1846
+ if (comment === 'conflict') { sendJson(res, 409, { error: 'comment-changed' }); return }
1847
+ if (comment === 'forbidden') { sendJson(res, 403, { error: 'not-comment-author' }); return }
1848
+ if (comment == null) { sendJson(res, 404, { error: 'no-comment' }); return }
1849
+ if (comment === 'deleted') { res.writeHead(204, { 'cache-control': 'no-store' }); res.end(); return }
1850
+ sendJson(res, 200, { comment })
1851
+ }
1852
+
1853
+ // GET /api/admin/teams — every team (members + repos inlined) plus the pickers
1854
+ // the page needs: all users (member dropdown), selected repos (repo dropdown),
1855
+ // and the visibility-permission keys. admin, read-only (no CSRF).
1856
+ async function handleListTeams(res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1857
+ const s = await readAdminSession(res, deps, cookie)
1858
+ if (s == null) return
1859
+ sendJson(res, 200, {
1860
+ teams: await deps.db.listTeams(),
1861
+ users: await deps.db.listUserOptions(),
1862
+ repos: selectableRepos(await deps.db.listSelectedRepos()),
1863
+ permissions: VISIBILITY_PERMISSIONS,
1864
+ })
1865
+ }
1866
+
1867
+ // POST /api/admin/teams — create a team. Body { name }. 409 if the name's taken.
1868
+ async function handleCreateTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1869
+ const s = await adminMutation(req, res, deps, cookie)
1870
+ if (s == null) return
1871
+ let body: unknown
1872
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1873
+ const rawName = (body as { name?: unknown } | null)?.name
1874
+ const name = typeof rawName === 'string' ? rawName.trim() : ''
1875
+ if (name === '' || name.length > MAX_TEAM_NAME) { sendJson(res, 400, { error: 'bad-name' }); return }
1876
+ const id = randomUUID()
1877
+ if (await readAdminSession(res, deps, cookie) == null) return
1878
+ if (!(await deps.db.createTeam(id, name, Date.now()))) { sendJson(res, 409, { error: 'name-taken' }); return }
1879
+ await activity(deps, s.user, 'access', `created team ${name}`)
1880
+ sendJson(res, 201, await deps.db.getTeam(id))
1881
+ }
1882
+
1883
+ // POST /api/admin/teams/rename — rename a team. Body { teamId, name }. 404 if no
1884
+ // such team; 409 if the new name is already taken by another team.
1885
+ async function handleRenameTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1886
+ const s = await adminMutation(req, res, deps, cookie)
1887
+ if (s == null) return
1888
+ let body: unknown
1889
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1890
+ const teamId = (body as { teamId?: unknown } | null)?.teamId
1891
+ const rawName = (body as { name?: unknown } | null)?.name
1892
+ const name = typeof rawName === 'string' ? rawName.trim() : ''
1893
+ if (typeof teamId !== 'string' || name === '' || name.length > MAX_TEAM_NAME) { sendJson(res, 400, { error: 'bad-name' }); return }
1894
+ const team = await deps.db.getTeam(teamId)
1895
+ if (await readAdminSession(res, deps, cookie) == null) return
1896
+ const result = await deps.db.renameTeam(teamId, name, Date.now())
1897
+ if (result === 'not-found') { sendJson(res, 404, { error: 'no-team' }); return }
1898
+ if (result === 'name-taken') { sendJson(res, 409, { error: 'name-taken' }); return }
1899
+ if (team?.name !== name) await activity(deps, s.user, 'access', `renamed team ${team?.name ?? teamId} to ${name}`)
1900
+ sendJson(res, 200, { ok: true, name })
1901
+ }
1902
+
1903
+ // POST /api/admin/teams/delete — drop a team (its links cascade). Body { teamId }.
1904
+ async function handleDeleteTeam(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1905
+ const s = await adminMutation(req, res, deps, cookie)
1906
+ if (s == null) return
1907
+ let body: unknown
1908
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1909
+ const teamId = (body as { teamId?: unknown } | null)?.teamId
1910
+ if (typeof teamId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
1911
+ const team = await deps.db.getTeam(teamId)
1912
+ if (await readAdminSession(res, deps, cookie) == null) return
1913
+ if (!(await deps.db.deleteTeam(teamId))) { sendJson(res, 404, { error: 'no-team' }); return }
1914
+ await activity(deps, s.user, 'access', `deleted team ${team?.name ?? teamId}`)
1915
+ sendJson(res, 200, { ok: true })
1916
+ }
1917
+
1918
+ // POST /api/admin/teams/set-repo — add a repo path. Empty path replaces all
1919
+ // path links with the whole repository. Body { teamId, repoId, path? }.
1920
+ // Repo must be in the selected set.
1921
+ async function handleSetTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1922
+ const s = await adminMutation(req, res, deps, cookie)
1923
+ if (s == null) return
1924
+ let body: unknown
1925
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1926
+ const teamId = (body as { teamId?: unknown } | null)?.teamId
1927
+ const repoId = (body as { repoId?: unknown } | null)?.repoId
1928
+ if (typeof teamId !== 'string' || typeof repoId !== 'number' || !Number.isSafeInteger(repoId)) {
1929
+ sendJson(res, 400, { error: 'bad-request' }); return
1930
+ }
1931
+ const path = normalizeTeamPath((body as { path?: unknown }).path)
1932
+ if (!path.ok) { sendJson(res, 400, { error: 'bad-path' }); return }
1933
+ const team = (await deps.db.listTeams()).find(row => row.id === teamId)
1934
+ if (team == null) { sendJson(res, 404, { error: 'no-team' }); return }
1935
+ if (!(await deps.db.listSelectedRepos()).some((r) => r.repoId === repoId)) { sendJson(res, 400, { error: 'repo-not-selected' }); return }
1936
+ if (await readAdminSession(res, deps, cookie) == null) return
1937
+ await deps.db.setTeamRepo(teamId, repoId, path.path)
1938
+ if (!team.repos.some(repo => repo.repoId === repoId && (!repo.path || repo.path === (path.path ?? '')))) {
1939
+ await activity(deps, s.user, 'access', `granted team ${team.name} access to ${path.path || '/'}`, { repo: await repositoryName(deps, repoId) })
1940
+ }
1941
+ sendJson(res, 200, { ok: true })
1942
+ }
1943
+
1944
+ // POST /api/admin/teams/remove-repo — unlink one path. Omitting path retains
1945
+ // the legacy remove-all behavior. Body { teamId, repoId, path? }.
1946
+ async function handleRemoveTeamRepo(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1947
+ const s = await adminMutation(req, res, deps, cookie)
1948
+ if (s == null) return
1949
+ let body: unknown
1950
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1951
+ const teamId = (body as { teamId?: unknown } | null)?.teamId
1952
+ const repoId = (body as { repoId?: unknown } | null)?.repoId
1953
+ if (typeof teamId !== 'string' || typeof repoId !== 'number' || !Number.isSafeInteger(repoId)) { sendJson(res, 400, { error: 'bad-request' }); return }
1954
+ const rawPath = (body as { path?: unknown }).path
1955
+ if (rawPath !== undefined && rawPath !== null && typeof rawPath !== 'string') { sendJson(res, 400, { error: 'bad-path' }); return }
1956
+ const path = normalizeTeamPath(rawPath)
1957
+ if (!path.ok) { sendJson(res, 400, { error: 'bad-path' }); return }
1958
+ if (await readAdminSession(res, deps, cookie) == null) return
1959
+ if (!(await deps.db.removeTeamRepo(teamId, repoId, rawPath === undefined ? undefined : path.path))) { sendJson(res, 404, { error: 'not-linked' }); return }
1960
+ await activity(deps, s.user, 'access', `removed team ${(await deps.db.getTeam(teamId))?.name ?? teamId}'s access to ${rawPath === undefined ? 'all paths' : path.path || '/'}`, { repo: await repositoryName(deps, repoId) })
1961
+ sendJson(res, 200, { ok: true })
1962
+ }
1963
+
1964
+ // POST /api/admin/teams/set-member — add/update a member + their visibility
1965
+ // permissions. Body { teamId, userId, dependencies?, security? }.
1966
+ async function handleSetTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1967
+ const s = await adminMutation(req, res, deps, cookie)
1968
+ if (s == null) return
1969
+ let body: unknown
1970
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1971
+ const teamId = (body as { teamId?: unknown } | null)?.teamId
1972
+ const userId = (body as { userId?: unknown } | null)?.userId
1973
+ if (typeof teamId !== 'string' || typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
1974
+ const team = (await deps.db.listTeams()).find(row => row.id === teamId)
1975
+ if (team == null) { sendJson(res, 404, { error: 'no-team' }); return }
1976
+ const user = (await deps.db.listUserOptions()).find(row => row.id === userId)
1977
+ if (user == null) { sendJson(res, 404, { error: 'no-user' }); return }
1978
+ const permissions = parseTeamUserPermissions(body)
1979
+ const member = team.members.find(row => row.userId === userId)
1980
+ if (await readAdminSession(res, deps, cookie) == null) return
1981
+ await deps.db.setTeamMember(teamId, userId, permissions)
1982
+ if (!member || member.dependencies !== permissions.dependencies || member.security !== permissions.security) {
1983
+ await activity(deps, s.user, 'access', `set ${user.login}'s membership in ${team.name} (dependencies: ${permissions.dependencies ? 'on' : 'off'}, security: ${permissions.security ? 'on' : 'off'})`)
1984
+ }
1985
+ sendJson(res, 200, { ok: true })
1986
+ }
1987
+
1988
+ // POST /api/admin/teams/remove-member — remove a membership. Body { teamId, userId }.
1989
+ async function handleRemoveTeamMember(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined): Promise<void> {
1990
+ const s = await adminMutation(req, res, deps, cookie)
1991
+ if (s == null) return
1992
+ let body: unknown
1993
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
1994
+ const teamId = (body as { teamId?: unknown } | null)?.teamId
1995
+ const userId = (body as { userId?: unknown } | null)?.userId
1996
+ if (typeof teamId !== 'string' || typeof userId !== 'string') { sendJson(res, 400, { error: 'bad-request' }); return }
1997
+ if (await readAdminSession(res, deps, cookie) == null) return
1998
+ if (!(await deps.db.removeTeamMember(teamId, userId))) { sendJson(res, 404, { error: 'not-member' }); return }
1999
+ const team = await deps.db.getTeam(teamId)
2000
+ const user = (await deps.db.listUserOptions()).find(row => row.id === userId)
2001
+ await activity(deps, s.user, 'access', `removed ${user?.login ?? userId} from team ${team?.name ?? teamId}`)
2002
+ sendJson(res, 200, { ok: true })
2003
+ }
2004
+
2005
+ async function handleWorkspaceShare(req: IncomingMessage, res: ServerResponse, deps: ManagedHttpDeps, cookie: string | undefined, teamId: string, id?: string) {
2006
+ if (!deps.config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
2007
+ const method = req.method ?? 'GET'
2008
+ const allowed = id ? ['PATCH', 'DELETE'] : ['GET', 'POST', 'DELETE']
2009
+ if (!allowed.includes(method)) { send405(res, allowed.join(', ')); return }
2010
+ const s = method === 'GET' ? await readSession(deps.config, deps.db, cookie, Date.now()) : await checkMutation(req, res, deps, cookie)
2011
+ if (!s) { if (method === 'GET') sendJson(res, 401, { error: 'unauthenticated' }); return }
2012
+ if (!requireManageRole(res, s.user)) return
2013
+ const { db } = deps
2014
+ if (method === 'GET') {
2015
+ const shares = await db.listWorkspaceShares(s.session.id, Date.now(), teamId)
2016
+ sendJson(res, shares ? 200 : 404, shares ? { shares } : { error: 'no-team' }); return
2017
+ }
2018
+ let body
2019
+ if (method === 'POST' || method === 'PATCH') {
2020
+ try { body = await readJsonBody(req) } catch { sendJson(res, 400, { error: 'bad-body' }); return }
2021
+ }
2022
+ const permissions = parseTeamUserPermissions(body), token = method === 'POST' ? randomToken() : ''
2023
+ const shareId = id ?? hashToken(token)
2024
+ const ok = method === 'POST' ? await db.createWorkspaceShare(s.session.id, Date.now(), teamId, shareId, permissions)
2025
+ : method === 'PATCH' ? await db.updateWorkspaceShare(s.session.id, Date.now(), teamId, shareId, permissions)
2026
+ : await db.revokeWorkspaceShares(s.session.id, Date.now(), teamId, id)
2027
+ if (!ok) { sendJson(res, 404, { error: 'no-share' }); return }
2028
+ const team = await db.getTeam(teamId)
2029
+ if (!team) { sendJson(res, 404, { error: 'no-team' }); return }
2030
+ const action = method === 'POST' ? 'created a public link' : method === 'PATCH' ? 'updated a public link' : id ? 'revoked a public link' : 'revoked public links'
2031
+ const access = method === 'DELETE' ? '' : ` (dependencies: ${permissions.dependencies ? 'on' : 'off'}, security: ${permissions.security ? 'on' : 'off'})`
2032
+ await activity(deps, s.user, 'access', `${action} for team ${team.name}${access}`)
2033
+ sendJson(res, 200, method === 'POST' ? { id: shareId, path: `/team/${team.slug}#public=${shareId.slice(0, 8)}.${token}` } : { ok: true })
2034
+ }
2035
+
2036
+ export function createManagedRequestHandler(deps: ManagedHttpDeps): Handler {
2037
+ const { config, db, avatarStore, isShuttingDown, track } = deps
2038
+ const repositoryDiscovery = new RepositoryDiscovery(config)
2039
+
2040
+ async function route(req: IncomingMessage, res: ServerResponse): Promise<void> {
2041
+ const url = new URL(req.url ?? '/', 'http://localhost')
2042
+ const path = url.pathname
2043
+ const method = req.method ?? 'GET'
2044
+ const cookie = req.headers.cookie
2045
+
2046
+ if (req.headers['x-deepview-share'] !== undefined || path.startsWith('/api/shares/')) {
2047
+ await handlePublicWorkspace(req, res, deps, url); return
2048
+ }
2049
+
2050
+ // Public mode probe — lets a client detect the managed protocol up front.
2051
+ if (path === CONFIG_PATH) {
2052
+ if (method !== 'GET') { send405(res, 'GET'); return }
2053
+ const info = deps.serverInfo ?? { mode: 'managed', managed: { loginPath: LOGIN_PATH, cookieName: config.sessionCookieName } }
2054
+ sendJson(res, 200, { ...info, ...(config.githubNewIssueLabels ? { githubNewIssueLabels: config.githubNewIssueLabels } : {}),
2055
+ managed: { ...info.managed, ...(config.allowShare ? { allowShare: true } : {}), ...(deps.uploadStore ? {
2056
+ uploadChunkBytes: UPLOAD_CHUNK_BYTES, uploadMaxBytes: { reports: config.maxReportBytes, bundles: config.maxBundleBytes },
2057
+ } : {}) } })
2058
+ return
2059
+ }
2060
+ // OAuth: start → redirect to GitHub with the CSRF state cookie.
2061
+ if (path === LOGIN_PATH) {
2062
+ if (method !== 'GET') { send405(res, 'GET'); return }
2063
+ const { location, setCookie } = buildLoginRedirect(config)
2064
+ res.writeHead(302, { location, 'set-cookie': setCookie, 'cache-control': 'no-store' })
2065
+ res.end()
2066
+ return
2067
+ }
2068
+ // OAuth: callback (the GitHub hook) → mint a session, land on the app.
2069
+ if (path === CALLBACK_PATH) {
2070
+ if (method !== 'GET') { send405(res, 'GET'); return }
2071
+ try {
2072
+ if (isIssueOAuthCallback(config, url.searchParams, cookie)) {
2073
+ const s = await readWorkspaceSession(res, deps, cookie)
2074
+ if (!s) return
2075
+ const result = await issueOAuthCallback(config, db, s.session, url.searchParams, cookie)
2076
+ res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
2077
+ res.end(); return
2078
+ }
2079
+ const result = await handleCallback(url.searchParams, cookie, { config, db, avatarStore })
2080
+ res.writeHead(302, { location: result.location, 'set-cookie': result.setCookies, 'cache-control': 'no-store' })
2081
+ res.end()
2082
+ } catch (err) {
2083
+ if (err instanceof OAuthError) sendJson(res, err.status, { error: err.message })
2084
+ else throw err
2085
+ }
2086
+ return
2087
+ }
2088
+ if (path === ISSUE_LOGIN_PATH) {
2089
+ if (method !== 'GET') { send405(res, 'GET'); return }
2090
+ const s = await readWorkspaceSession(res, deps, cookie)
2091
+ if (!s) return
2092
+ const result = issueLoginRedirect(config, s.session)
2093
+ res.writeHead(302, { location: result.location, 'set-cookie': result.setCookie, 'cache-control': 'no-store' })
2094
+ res.end(); return
2095
+ }
2096
+ // Who am I?
2097
+ if (path === SESSION_PATH) {
2098
+ if (method !== 'GET') { send405(res, 'GET'); return }
2099
+ const s = await readSession(config, db, cookie, Date.now())
2100
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2101
+ sendJson(res, 200, {
2102
+ user: { id: s.user.id, login: s.user.login, name: s.user.name, role: s.user.role },
2103
+ csrfToken: s.session.csrfToken,
2104
+ })
2105
+ return
2106
+ }
2107
+ // Authentication is not workspace access. Keep bootstrap/session/logout
2108
+ // available so blocked accounts can see their role and sign out, but deny
2109
+ // every managed data route before reading bodies or looking up resources.
2110
+ const managedDataPath = ['/api/admin', '/api/reports', '/api/bundles', '/api/teams', '/api/avatar', '/api/github']
2111
+ .some(prefix => path === prefix || path.startsWith(prefix + '/'))
2112
+ const workspaceSession = managedDataPath ? await readWorkspaceSession(res, deps, cookie) : null
2113
+ if (managedDataPath && !workspaceSession) return
2114
+ const issueRoute = /^\/api\/teams\/([^/]+)\/issues$/u.exec(path)
2115
+ if (issueRoute) {
2116
+ await handleWorkspaceIssue(req, res, deps, cookie, decodeURIComponent(issueRoute[1]!), url.searchParams); return
2117
+ }
2118
+ if (path === '/api/admin/links') {
2119
+ if (!config.allowShare) { sendJson(res, 404, { error: 'sharing-disabled' }); return }
2120
+ if (method !== 'GET') { send405(res, 'GET'); return }
2121
+ const s = await readSession(config, db, cookie, Date.now())
2122
+ if (!s) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2123
+ if (!requireManageRole(res, s.user)) return
2124
+ const shares = await db.listManagedWorkspaceShares(s.session.id, Date.now())
2125
+ sendJson(res, shares ? 200 : 403, shares ? { shares } : { error: 'forbidden' }); return
2126
+ }
2127
+ const shareRoute = /^\/api\/teams\/([^/]+)\/share(?:\/([A-Za-z0-9_-]{43}))?$/u.exec(path)
2128
+ if (shareRoute) {
2129
+ await handleWorkspaceShare(req, res, deps, cookie, shareRoute[1]!, shareRoute[2]); return
2130
+ }
2131
+ if (path.startsWith('/api/admin/uploads/')) { await handleUploadPart(req, res, deps, cookie, path); return }
2132
+ // Cached avatar by user id, served same-origin (the page CSP forbids the
2133
+ // github CDN). The id in the path keys the browser cache per user, so a user
2134
+ // switch never serves a stale avatar. Workspace access is required.
2135
+ if (path.startsWith(AVATAR_PREFIX)) {
2136
+ if (method !== 'GET') { send405(res, 'GET'); return }
2137
+ const s = await readSession(config, db, cookie, Date.now())
2138
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2139
+ await serveAvatar(res, avatarStore, path.slice(AVATAR_PREFIX.length))
2140
+ return
2141
+ }
2142
+ // Admin: list users (admin-only).
2143
+ if (path === ADMIN_HISTORY_PATH) {
2144
+ if (method !== 'GET') { send405(res, 'GET'); return }
2145
+ await handleHistory(res, deps, cookie, url.searchParams); return
2146
+ }
2147
+ if (path === ADMIN_USERS_PATH) {
2148
+ if (method !== 'GET') { send405(res, 'GET'); return }
2149
+ const s = await readSession(config, db, cookie, Date.now())
2150
+ if (s == null) { sendJson(res, 401, { error: 'unauthenticated' }); return }
2151
+ if (s.user.role !== 'admin') { sendJson(res, 403, { error: 'forbidden' }); return }
2152
+ sendJson(res, 200, { users: await db.listUsers() })
2153
+ return
2154
+ }
2155
+ if (path === ADMIN_SCAN_MODELS_PATH) {
2156
+ if (method !== 'GET') { send405(res, 'GET'); return }
2157
+ await handleListModels(res, deps, cookie); return
2158
+ }
2159
+ if (path === SET_ROLE_PATH) { await handleSetRole(req, res, deps, cookie); return }
2160
+ if (path === REPO_IMPACT_PATH) {
2161
+ if (method !== 'GET') { send405(res, 'GET'); return }
2162
+ await handleRepositoryImpact(req, res, deps, cookie); return
2163
+ }
2164
+ if (path === REMOVE_REPO_PATH) {
2165
+ if (method !== 'POST') { send405(res, 'POST'); return }
2166
+ await handleRemoveRepository(req, res, deps, cookie); return
2167
+ }
2168
+ if (path === BROWSABLE_REPOS_PATH) {
2169
+ if (method !== 'GET') { send405(res, 'GET'); return }
2170
+ await handleBrowsableRepositories(res, deps, cookie); return
2171
+ }
2172
+ if (path === REPO_REFS_PATH || path === REPO_CONTENTS_PATH) {
2173
+ if (method !== 'GET') { send405(res, 'GET'); return }
2174
+ await handleRepositoryBrowser(res, deps, cookie, url.searchParams, path === REPO_REFS_PATH); return
2175
+ }
2176
+ if (path === ADMIN_REPOS_PATH) { await handleListRepositories(req, res, deps, cookie, repositoryDiscovery); return }
2177
+ if (path === SELECT_REPO_PATH) { await handleSelectRepository(req, res, deps, cookie); return }
2178
+ if (path === ADD_PUBLIC_REPO_PATH) { await handleAddPublicRepository(req, res, deps, cookie); return }
2179
+ if (path === CONNECT_REPO_APP_PATH) { await handleConnectRepositoryApp(req, res, deps, cookie); return }
2180
+ // Reports: list / upload on the exact path, download / delete per-id on the
2181
+ // prefix. Method-dispatched here since each path carries two verbs.
2182
+ if (path === ADMIN_REPORTS_PATH) {
2183
+ if (method === 'GET') { await handleListReports(res, deps, workspaceSession!.session); return }
2184
+ if (method === 'POST') { await handleUploadReport(req, res, deps, cookie); return }
2185
+ send405(res, 'GET, POST'); return
2186
+ }
2187
+ // set-repo is an exact sub-path; check it before the per-id prefix (a report
2188
+ // id is a uuid, so it never collides with "set-repo").
2189
+ if (path === REPORT_SET_REPO_PATH) {
2190
+ if (method !== 'POST') { send405(res, 'POST'); return }
2191
+ await handleSetReportRepo(req, res, deps, cookie); return
2192
+ }
2193
+ if (path === REPORT_SET_VISIBLE_PATH) {
2194
+ if (method !== 'POST') { send405(res, 'POST'); return }
2195
+ await handleSetReportVisible(req, res, deps, cookie); return
2196
+ }
2197
+ if (path === '/api/admin/reports/finding-ids') {
2198
+ if (method !== 'GET') { send405(res, 'GET'); return }
2199
+ await handleImportFindingIds(res, deps, cookie, url.searchParams); return
2200
+ }
2201
+ const importTriage = /^\/api\/admin\/reports\/([a-f\d-]{36})\/import-triage$/iu.exec(path)
2202
+ if (importTriage) {
2203
+ if (method !== 'POST') { send405(res, 'POST'); return }
2204
+ await handleImportTriage(req, res, deps, cookie, importTriage[1]!); return
2205
+ }
2206
+ if (path.startsWith(REPORT_PREFIX)) {
2207
+ const id = path.slice(REPORT_PREFIX.length)
2208
+ if (method === 'GET') { await handleGetReport(res, deps, cookie, id); return }
2209
+ if (method === 'DELETE') { await handleDeleteReport(req, res, deps, cookie, id); return }
2210
+ send405(res, 'GET, DELETE'); return
2211
+ }
2212
+ // Bundles: list / upload on the exact path, download / delete per-id on the
2213
+ // prefix (same shape as reports).
2214
+ const bundleAdvisories = /^\/api\/bundles\/([a-f\d-]{36})\/advisories$/iu.exec(path)
2215
+ if (bundleAdvisories) {
2216
+ if (method !== 'GET') { send405(res, 'GET'); return }
2217
+ await handleBundleAdvisories(res, deps, workspaceSession!.session, bundleAdvisories[1]!, url.searchParams.get('team'), url.searchParams.get('reason') ?? '', url.searchParams.get('repoAdvisories') === 'true')
2218
+ return
2219
+ }
2220
+ const bundleRead = /^\/api\/bundles\/([a-f\d-]{36})\/(metadata|contents|download)$/iu.exec(path)
2221
+ if (bundleRead) {
2222
+ if (method !== 'GET' && method !== 'HEAD') { send405(res, 'GET, HEAD'); return }
2223
+ const id = bundleRead[1]!
2224
+ if (bundleRead[2] === 'download') await handleGetBundle(req, res, deps, workspaceSession!.session, id)
2225
+ else await handleBundleCache(req, res, deps, workspaceSession!.session, id, bundleRead[2] as BundleCachePart)
2226
+ return
2227
+ }
2228
+ if (path === BUNDLE_CREATE_PATH) {
2229
+ if (method !== 'POST') { send405(res, 'POST'); return }
2230
+ await handleCreateBundle(req, res, deps, cookie); return
2231
+ }
2232
+ if (path === ADMIN_BUNDLES_PATH) {
2233
+ if (method === 'GET') { await handleListBundles(res, deps, workspaceSession!.session); return }
2234
+ if (method === 'POST') { await handleUploadBundle(req, res, deps, cookie); return }
2235
+ send405(res, 'GET, POST'); return
2236
+ }
2237
+ if (path === BUNDLE_SET_REPO_PATH) {
2238
+ if (method !== 'POST') { send405(res, 'POST'); return }
2239
+ await handleSetBundleRepo(req, res, deps, cookie); return
2240
+ }
2241
+ if (path.startsWith(BUNDLE_PREFIX)) {
2242
+ const id = path.slice(BUNDLE_PREFIX.length)
2243
+ if (method === 'GET') { await handleGetBundle(req, res, deps, workspaceSession!.session, id); return }
2244
+ if (method === 'DELETE') { await handleDeleteBundle(req, res, deps, cookie, id); return }
2245
+ send405(res, 'GET, DELETE'); return
2246
+ }
2247
+ const teamFeed = /^\/api\/teams\/([^/]+)\/feed$/u.exec(path)
2248
+ if (teamFeed || path === '/api/teams/feed') {
2249
+ if (method !== 'GET') { send405(res, 'GET'); return }
2250
+ const s = await readWorkspaceSession(res, deps, cookie)
2251
+ if (!s) return
2252
+ const teamId = teamFeed?.[1] ?? null
2253
+ if (teamId) await teamSnapshot(db, s.session.id, teamId)
2254
+ await serveUserTeamFeed(res, deps, s.session.id, s.user, teamId); return
2255
+ }
2256
+ const teamFixes = /^\/api\/teams\/([^/]+)\/fixes$/u.exec(path)
2257
+ if (teamFixes) {
2258
+ if (method !== 'GET') { send405(res, 'GET'); return }
2259
+ await handleWorkspaceFixes(res, deps, cookie, teamFixes[1]!); return
2260
+ }
2261
+ const teamAnnotations = /^\/api\/teams\/([^/]+)\/annotations$/u.exec(path)
2262
+ if (teamAnnotations) {
2263
+ if (method !== 'GET') { send405(res, 'GET'); return }
2264
+ const snapshot = await teamSnapshot(db, workspaceSession!.session.id, teamAnnotations[1]!)
2265
+ const annotations = await loadTeamAnnotations(db, deps.reportStore, snapshot, url.searchParams.get('reportId'))
2266
+ await recheckTeam(db, workspaceSession!.session.id, snapshot)
2267
+ sendJson(res, 200, annotations); return
2268
+ }
2269
+ const teamReports = /^\/api\/teams\/([^/]+)\/reports$/u.exec(path)
2270
+ if (teamReports) {
2271
+ if (method !== 'GET') { send405(res, 'GET'); return }
2272
+ await handleTeamReports(res, deps, workspaceSession!.session, teamReports[1]!); return
2273
+ }
2274
+ if (path === '/api/reports/query') {
2275
+ if (method !== 'POST') { send405(res, 'POST'); return }
2276
+ await handleQueryReports(req, res, deps, cookie); return
2277
+ }
2278
+ const sourcesRoute = /^\/api\/reports\/([^/]+)\/sources$/u.exec(path)
2279
+ if (sourcesRoute) {
2280
+ if (method !== 'GET' && method !== 'HEAD') { send405(res, 'GET, HEAD'); return }
2281
+ await handleReportSources(req, res, deps, cookie, sourcesRoute[1]!, url.searchParams.get('team')); return
2282
+ }
2283
+ const commentsRoute = /^\/api\/reports\/([^/]+)\/comments(?:\/([^/]+))?$/u.exec(path)
2284
+ if (commentsRoute) {
2285
+ await handleReportComments(req, res, deps, cookie, commentsRoute[1]!, commentsRoute[2] ?? null, url.searchParams.get('team')); return
2286
+ }
2287
+ // Per-finding triage on a viewable report. The '/triage' suffixes are
2288
+ // matched before the bare per-id slice below (a report id is a uuid, so it
2289
+ // never ends in them) — same trick as REPORT_SET_REPO_PATH above; the
2290
+ // longer '/triage/history' goes first.
2291
+ if (path.startsWith(MY_REPORT_PREFIX) && path.endsWith(MY_REPORT_TRIAGE_HISTORY_SUFFIX)) {
2292
+ const id = path.slice(MY_REPORT_PREFIX.length, -MY_REPORT_TRIAGE_HISTORY_SUFFIX.length)
2293
+ if (method !== 'GET') { send405(res, 'GET'); return }
2294
+ await handleGetReportTriageHistory(res, deps, cookie, id, url.searchParams, url.searchParams.get('team')); return
2295
+ }
2296
+ if (path.startsWith(MY_REPORT_PREFIX) && path.endsWith(MY_REPORT_TRIAGE_SUFFIX)) {
2297
+ const id = path.slice(MY_REPORT_PREFIX.length, -MY_REPORT_TRIAGE_SUFFIX.length)
2298
+ if (method === 'GET') { await handleGetReportTriage(res, deps, cookie, id, url.searchParams.get('team')); return }
2299
+ if (method === 'POST') { await handleSetReportTriage(req, res, deps, cookie, id, url.searchParams.get('team')); return }
2300
+ send405(res, 'GET, POST'); return
2301
+ }
2302
+ // Individual report previews are reserved for admins/managers.
2303
+ if (path.startsWith(MY_REPORT_PREFIX)) {
2304
+ if (method !== 'GET') { send405(res, 'GET'); return }
2305
+ await handleViewReport(req, res, deps, cookie, path.slice(MY_REPORT_PREFIX.length)); return
2306
+ }
2307
+ // The signed-in user's own team memberships (any approved user).
2308
+ if (path === MY_TEAMS_PATH) {
2309
+ if (method !== 'GET') { send405(res, 'GET'); return }
2310
+ await handleMyTeams(res, deps, workspaceSession!.session); return
2311
+ }
2312
+ // Teams: list / create on the exact path; the link mutations are POST-only
2313
+ // action sub-paths (each carries its ids in the JSON body).
2314
+ if (path === ADMIN_TEAMS_PATH) {
2315
+ if (method === 'GET') { await handleListTeams(res, deps, cookie); return }
2316
+ if (method === 'POST') { await handleCreateTeam(req, res, deps, cookie); return }
2317
+ send405(res, 'GET, POST'); return
2318
+ }
2319
+ if (path === TEAM_DELETE_PATH || path === TEAM_RENAME_PATH || path === TEAM_SET_REPO_PATH || path === TEAM_REMOVE_REPO_PATH
2320
+ || path === TEAM_SET_MEMBER_PATH || path === TEAM_REMOVE_MEMBER_PATH) {
2321
+ if (method !== 'POST') { send405(res, 'POST'); return }
2322
+ if (path === TEAM_DELETE_PATH) { await handleDeleteTeam(req, res, deps, cookie); return }
2323
+ if (path === TEAM_RENAME_PATH) { await handleRenameTeam(req, res, deps, cookie); return }
2324
+ if (path === TEAM_SET_REPO_PATH) { await handleSetTeamRepo(req, res, deps, cookie); return }
2325
+ if (path === TEAM_REMOVE_REPO_PATH) { await handleRemoveTeamRepo(req, res, deps, cookie); return }
2326
+ if (path === TEAM_SET_MEMBER_PATH) { await handleSetTeamMember(req, res, deps, cookie); return }
2327
+ await handleRemoveTeamMember(req, res, deps, cookie); return
2328
+ }
2329
+ if (path === LOGOUT_PATH) { await handleLogout(req, res, deps, cookie); return }
2330
+ if (deps.serveStatic?.(req, res)) return
2331
+ if (deps.next) { deps.next(req, res); return }
2332
+ sendJson(res, 404, { error: 'not-found' }, { connection: 'close' })
2333
+ }
2334
+
2335
+ return (req, res) => {
2336
+ if (isShuttingDown()) { sendJson(res, 503, { error: 'shutting-down' }, { connection: 'close' }); return }
2337
+ if (!deps.originGate.isOriginAllowed(req)) { sendJson(res, 403, { error: 'origin-denied' }); return }
2338
+ // Feeds lease a connection per poll, never across streaming sleeps. This
2339
+ // also covers capability-authenticated feeds handled by public-workspace.
2340
+ const feed = /^\/api\/teams(?:\/[^/?]+)?\/feed(?:\?|$)/u.test(req.url ?? '')
2341
+ const work = (db.withRequest && !feed ? db.withRequest(() => route(req, res)) : route(req, res)).catch((err) => {
2342
+ if ((err instanceof TeamReportsError || err instanceof IssueError || err instanceof OAuthError || err instanceof ManagedMutationError) && !res.headersSent) { sendJson(res, err.status, { error: err.message }); return }
2343
+ console.warn('managed: request handler error:', err)
2344
+ if (res.headersSent) { try { res.destroy() } catch {} }
2345
+ else sendJson(res, 500, { error: 'internal' })
2346
+ })
2347
+ track(work)
2348
+ return work
2349
+ }
2350
+ }