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