@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.20

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