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