@preventive/triage 1.0.0-alpha.27 → 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 +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 +18 -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-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 -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 +25 -9
- 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 +37 -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/index.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Managed HTTP app and standalone boot. The combined launcher mounts this
|
|
2
2
|
// same app on e2e's listener; storage, routing and cleanup stay here.
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
3
4
|
import { type Server, createServer } from 'node:http'
|
|
4
5
|
import { fileURLToPath } from 'node:url'
|
|
5
6
|
import { createOriginGate } from '../server-common/origin.ts'
|
|
@@ -48,17 +49,31 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
|
|
|
48
49
|
|
|
49
50
|
let cleanup: Promise<void> | undefined
|
|
50
51
|
let nextCleanupAt = 0
|
|
51
|
-
|
|
52
|
+
const maintenance = new AbortController()
|
|
53
|
+
function reap(automatic = false): Promise<void> {
|
|
52
54
|
if (cleanup) return cleanup
|
|
55
|
+
if (isShuttingDown()) return Promise.resolve()
|
|
53
56
|
const startedAt = Date.now()
|
|
54
57
|
nextCleanupAt = startedAt + REAP_INTERVAL_MS
|
|
55
58
|
let sessions = 0, uploads = 0
|
|
56
|
-
cleanup =
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
cleanup = (async () => {
|
|
60
|
+
const owner = automatic && config.serverless ? randomUUID() : null
|
|
61
|
+
if (owner && !await db.claimMaintenanceLease(owner, startedAt, startedAt + Math.max(300_000, (config.storageEncryptionMigrateMaxMs ?? 0) + 60_000), config.storageEncryptionMigrate)) return false
|
|
62
|
+
let succeeded = false
|
|
63
|
+
try {
|
|
64
|
+
await runReapers({
|
|
65
|
+
sessions: async () => { sessions = await db.deleteExpiredSessions(Date.now()) },
|
|
66
|
+
...(storage.reapUploads ? { uploads: async () => { uploads = await storage.reapUploads!(maintenance.signal) } } : {}),
|
|
67
|
+
...('reapStorage' in storage ? { storage: () => storage.reapStorage(maintenance.signal) } : {}),
|
|
68
|
+
})
|
|
69
|
+
succeeded = true
|
|
70
|
+
return true
|
|
71
|
+
} finally {
|
|
72
|
+
if (owner) await db.finishMaintenanceLease(owner, Date.now() + (succeeded ? REAP_INTERVAL_MS : REAP_RETRY_MS))
|
|
73
|
+
}
|
|
74
|
+
})().then(ran => {
|
|
61
75
|
nextCleanupAt = Date.now() + REAP_INTERVAL_MS
|
|
76
|
+
if (!ran) return
|
|
62
77
|
return console.info(`managed-reaper: removed ${sessions} expired session(s), ${uploads} stale upload part(s) in ${Date.now() - startedAt}ms`)
|
|
63
78
|
}, err => {
|
|
64
79
|
nextCleanupAt = Date.now() + REAP_RETRY_MS
|
|
@@ -69,14 +84,14 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
|
|
|
69
84
|
}
|
|
70
85
|
function automaticReap(): Promise<void> {
|
|
71
86
|
if (isShuttingDown() || cleanup || Date.now() < nextCleanupAt) return Promise.resolve()
|
|
72
|
-
return reap().catch(err => console.warn('managed-reaper: cleanup failed:', err))
|
|
87
|
+
return reap(true).catch(err => console.warn('managed-reaper: cleanup failed:', err))
|
|
73
88
|
}
|
|
74
89
|
async function handleRequest(req: Parameters<typeof routeRequest>[0], res: Parameters<typeof routeRequest>[1]): Promise<void> {
|
|
75
|
-
const
|
|
90
|
+
const pendingMaintenance = automaticReap()
|
|
76
91
|
// Send the normal response without waiting for storage housekeeping, but
|
|
77
92
|
// keep the invocation alive until both finish. No detached serverless work.
|
|
78
93
|
try { await routeRequest(req, res) }
|
|
79
|
-
finally { await
|
|
94
|
+
finally { await pendingMaintenance }
|
|
80
95
|
}
|
|
81
96
|
const gcTimer = config.serverless ? null : setInterval(() => {
|
|
82
97
|
void automaticReap()
|
|
@@ -85,6 +100,7 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
|
|
|
85
100
|
|
|
86
101
|
function stop(): void {
|
|
87
102
|
shuttingDown = true
|
|
103
|
+
maintenance.abort()
|
|
88
104
|
if (gcTimer) clearInterval(gcTimer)
|
|
89
105
|
}
|
|
90
106
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { roleAtLeast } from '../common/managed/roles.ts'
|
|
2
|
+
import { type RepoScope, contentAccess } from './content-access.ts'
|
|
3
|
+
import type { AdminBundle, AdminReport, ManagedDb } from './db-methods.ts'
|
|
4
|
+
import { ManagedMutationError } from './management.ts'
|
|
5
|
+
|
|
6
|
+
type CatalogAccess = { repos: { repoId: number; fullName: string }[]; repoScopes: RepoScope[] | null }
|
|
7
|
+
export interface ManagementCatalogStore {
|
|
8
|
+
getReportCatalog(sessionId: string, now: number): Promise<CatalogAccess & { reports: (AdminReport & { canChangeRepo: boolean })[] }>
|
|
9
|
+
getBundleCatalog(sessionId: string, now: number): Promise<CatalogAccess & { bundles: (AdminBundle & { canChangeRepo: boolean })[] }>
|
|
10
|
+
}
|
|
11
|
+
type CatalogDb = Pick<ManagedDb, 'getReportAccessSnapshot' | 'listRepoScopesForUser' | 'listSelectedRepos'
|
|
12
|
+
| 'listReports' | 'listBundles' | 'listReadableBundleIds'>
|
|
13
|
+
|
|
14
|
+
// The outer store method owns one read snapshot. Use raw methods here so the
|
|
15
|
+
// session, grants, rows and action flags agree without per-row transactions.
|
|
16
|
+
export function managementCatalogMethods(db: CatalogDb): ManagementCatalogStore {
|
|
17
|
+
async function access(sessionId: string, now: number) {
|
|
18
|
+
const snapshot = await db.getReportAccessSnapshot(sessionId, now, [])
|
|
19
|
+
if (!snapshot) throw new ManagedMutationError(401, 'unauthenticated')
|
|
20
|
+
if (!roleAtLeast(snapshot.user.role, 'manage')) throw new ManagedMutationError(403, 'forbidden')
|
|
21
|
+
const scope = await contentAccess(db, snapshot.user)
|
|
22
|
+
const repos = (await db.listSelectedRepos()).filter(scope.bundle).map(({ repoId, fullName }) => ({ repoId, fullName }))
|
|
23
|
+
return { userId: snapshot.user.role === 'admin' ? undefined : snapshot.user.id, scope, repos }
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
async getReportCatalog(sessionId, now) {
|
|
27
|
+
const { userId, scope, repos } = await access(sessionId, now)
|
|
28
|
+
const reports = await db.listReports(userId)
|
|
29
|
+
const linked = reports.flatMap(report => report.bundleId ? [report.bundleId] : [])
|
|
30
|
+
const readable = userId === undefined ? new Set(linked) : new Set(await db.listReadableBundleIds(userId, linked))
|
|
31
|
+
return { repos, repoScopes: scope.scopes, reports: reports.map(report => ({ ...report,
|
|
32
|
+
canChangeRepo: report.repoId === null || scope.report(report),
|
|
33
|
+
bundleId: report.bundleId && readable.has(report.bundleId) ? report.bundleId : null,
|
|
34
|
+
bundleFilename: report.bundleId && readable.has(report.bundleId) ? report.bundleFilename : null,
|
|
35
|
+
})) }
|
|
36
|
+
},
|
|
37
|
+
async getBundleCatalog(sessionId, now) {
|
|
38
|
+
const { userId, scope, repos } = await access(sessionId, now)
|
|
39
|
+
const bundles = await db.listBundles(userId)
|
|
40
|
+
return { repos, repoScopes: scope.scopes, bundles: bundles.map(bundle => ({ ...bundle,
|
|
41
|
+
canChangeRepo: bundle.repoId === null || scope.report(bundle),
|
|
42
|
+
})) }
|
|
43
|
+
},
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -24,7 +24,7 @@ export async function openVercelObjectStorage(token: string, sdk?: VercelBlobSdk
|
|
|
24
24
|
}
|
|
25
25
|
return { size: result.blob.size === 0 ? null : result.blob.size, version: result.blob.etag,
|
|
26
26
|
modifiedAt: new Date(result.blob.uploadedAt ?? Date.now()).getTime(),
|
|
27
|
-
stream: Readable.fromWeb(result.stream as Parameters<typeof Readable.fromWeb>[0]) }
|
|
27
|
+
stream: Readable.fromWeb(result.stream as Parameters<typeof Readable.fromWeb>[0], signal ? { signal } : {}) }
|
|
28
28
|
} catch (err) { if (isNotFound(err, blobs)) return null; throw err }
|
|
29
29
|
},
|
|
30
30
|
async put(key, bytes, signal, expected, sizeHint) {
|
|
@@ -5,13 +5,13 @@ import type { ManagedHttpDeps } from './http.ts'
|
|
|
5
5
|
import type { ManagedBundle } from './db.ts'
|
|
6
6
|
import type { OpenedBlob } from './blob-store.ts'
|
|
7
7
|
import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
|
|
8
|
-
import { sendJson } from './http-response.ts'
|
|
8
|
+
import { sendJson, writeResponse } from './http-response.ts'
|
|
9
9
|
import { hashToken } from './crypto.ts'
|
|
10
|
-
import { TeamReportsError,
|
|
10
|
+
import { TeamReportsError, loadTeamAnnotations, loadTeamReportsResponse, teamReportVisibility } from './team-reports.ts'
|
|
11
11
|
import { triageWireEntry } from './triage-response.ts'
|
|
12
12
|
import { MAX_PACKAGE_INVENTORY_BYTES } from './bundle-cache.ts'
|
|
13
13
|
import { backfillBundleSummaries, bundleSummaries } from './bundle-catalog.ts'
|
|
14
|
-
import {
|
|
14
|
+
import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
|
|
15
15
|
import { serveTeamFeed } from './team-feed.ts'
|
|
16
16
|
|
|
17
17
|
function json(res: ServerResponse, status: number, body: unknown): void {
|
|
@@ -28,7 +28,7 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
28
28
|
const method = req.method ?? 'GET'
|
|
29
29
|
if (method !== 'GET' && method !== 'HEAD') { json(res, 403, { error: 'share-read-only' }); return }
|
|
30
30
|
const shareRoute = /^\/api\/shares\/([A-Za-z0-9_-]+)\/workspace$/u.exec(url.pathname)
|
|
31
|
-
const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed)$/u.exec(url.pathname)
|
|
31
|
+
const teamRoute = /^\/api\/teams\/([^/]+)\/(shared|reports|feed|annotations)$/u.exec(url.pathname)
|
|
32
32
|
const reportRoute = /^\/api\/reports\/([^/]+)\/(triage|comments|sources)$/u.exec(url.pathname)
|
|
33
33
|
const bundleRoute = /^\/api\/bundles\/([^/]+)\/(metadata|contents|download|advisories)$/u.exec(url.pathname)
|
|
34
34
|
if (!shareRoute && !teamRoute && !reportRoute && !bundleRoute) { json(res, 403, { error: 'share-scope-required' }); return }
|
|
@@ -72,10 +72,16 @@ export async function handlePublicWorkspace(req: IncomingMessage, res: ServerRes
|
|
|
72
72
|
if (teamRoute[1] !== snapshot.teamId) { json(res, 404, { error: 'no-team' }); return }
|
|
73
73
|
if (teamRoute[2] === 'feed') {
|
|
74
74
|
if (method !== 'GET') { json(res, 405, { error: 'method-not-allowed' }); return }
|
|
75
|
-
await serveTeamFeed(res, deps, snapshot, recheck); return
|
|
75
|
+
await serveTeamFeed(res, deps, snapshot, recheck, { readState: () => deps.db.getWorkspaceShareFeedState(tokenHash) }); return
|
|
76
|
+
}
|
|
77
|
+
if (teamRoute[2] === 'annotations') {
|
|
78
|
+
await send(await loadTeamAnnotations(deps.db, deps.reportStore, snapshot, url.searchParams.get('reportId'))); return
|
|
76
79
|
}
|
|
77
80
|
if (teamRoute[2] === 'shared') { await sendTeam(); return }
|
|
78
|
-
|
|
81
|
+
const body = await loadTeamReportsResponse(deps.db, deps.reportStore, snapshot)
|
|
82
|
+
await recheck()
|
|
83
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'private, no-store', 'referrer-policy': 'no-referrer' })
|
|
84
|
+
writeResponse(res, body); return
|
|
79
85
|
}
|
|
80
86
|
if (reportRoute) {
|
|
81
87
|
const id = reportRoute[1]!, part = reportRoute[2]!
|
|
@@ -103,16 +109,16 @@ async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: M
|
|
|
103
109
|
if (!deps.bundleCache) { json(res, 503, { error: 'unavailable' }); return }
|
|
104
110
|
if (part === 'advisories') {
|
|
105
111
|
if (bundle.kind !== 'stasis') { json(res, 422, { error: 'unsupported-bundle' }); return }
|
|
106
|
-
const
|
|
112
|
+
const inventory = await deps.bundleCache.advisoryInventory(bundle, url.searchParams.get('reason') ?? '')
|
|
107
113
|
await recheck()
|
|
108
|
-
if (
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
+
if (inventory === null) { json(res, 413, { error: 'payload-too-large' }); return }
|
|
115
|
+
if (inventory === undefined) { json(res, 400, { error: 'unknown-reason' }); return }
|
|
116
|
+
if (Buffer.byteLength(JSON.stringify(inventory)) > MAX_PACKAGE_INVENTORY_BYTES) { json(res, 413, { error: 'payload-too-large' }); return }
|
|
117
|
+
const result = await fetchBundleAdvisories(inventory.packages, AbortSignal.timeout(ADVISORIES_TIMEOUT_MS), {
|
|
118
|
+
debug: deps.config.debug, repoAdvisories: url.searchParams.get('repoAdvisories') === 'true',
|
|
119
|
+
})
|
|
114
120
|
await recheck()
|
|
115
|
-
json(res, result.status, result.status === 200 ? {
|
|
121
|
+
json(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body); return
|
|
116
122
|
}
|
|
117
123
|
let cached
|
|
118
124
|
try { cached = await deps.bundleCache.open(bundle, part as 'metadata' | 'contents') }
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { readFileSync } from 'node:fs'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
6
|
import type { DatabaseSync } from 'node:sqlite'
|
|
7
|
-
import { reportRepoGithub } from '
|
|
7
|
+
import { reportRepoGithub } from '@preventive/report'
|
|
8
8
|
import { readManagedReport } from '../common/managed/report-content.ts'
|
|
9
9
|
import { normalizeTeamPath } from './repo-path.ts'
|
|
10
10
|
|
|
@@ -5,9 +5,10 @@ import { promisify } from 'node:util'
|
|
|
5
5
|
import { gzip } from 'node:zlib'
|
|
6
6
|
import { bundleSourcesAsMap } from '../common/bundle-sources.js'
|
|
7
7
|
import { bundleSourceImports } from '../common/bundle-source-links.js'
|
|
8
|
+
import type { BundleDetails } from '../common/bundle-metadata.js'
|
|
8
9
|
import { loadManagedFindings, managedFindingSourcePaths } from '../common/managed/report-content.ts'
|
|
9
10
|
import { type ViewerPermissions, filterReportContent } from '../common/managed/report-filter.ts'
|
|
10
|
-
import type
|
|
11
|
+
import { CacheMissError, type CacheStorage } from './cache-storage.ts'
|
|
11
12
|
import { readBundleDetails } from './bundle-cache.ts'
|
|
12
13
|
import type { BlobStore } from './blob-store.ts'
|
|
13
14
|
import type { BundleStore } from './bundle-store.ts'
|
|
@@ -28,7 +29,7 @@ function formatDirectory(bundleId: string, sha256: string, name: string) {
|
|
|
28
29
|
}
|
|
29
30
|
function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
|
|
30
31
|
const key = createHash('sha256').update(JSON.stringify([
|
|
31
|
-
'finding-access-
|
|
32
|
+
'finding-access-v6', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
|
|
32
33
|
])).digest('hex')
|
|
33
34
|
return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
|
|
34
35
|
}
|
|
@@ -60,6 +61,14 @@ function selectSources(sourcePaths: Set<string>, sources: Map<string, string>) {
|
|
|
60
61
|
return { files: [...files], paths: [...paths] }
|
|
61
62
|
}
|
|
62
63
|
|
|
64
|
+
function encodeSources(integrity: string, details: BundleDetails, selection: ReturnType<typeof selectSources>) {
|
|
65
|
+
const sources = new Map(selection.files)
|
|
66
|
+
const imports = [...bundleSourceImports(details, sources)].map(([parent, targets]) => [parent, [...targets]])
|
|
67
|
+
const bundle = details.kind === 'stasis' ? details.bundle as { formats: Map<string, string> } : null
|
|
68
|
+
const formats = [...(bundle?.formats ?? [])].filter(([file]) => sources.has(file))
|
|
69
|
+
return compress(Buffer.from(JSON.stringify({ integrity, ...selection, imports, formats })), { level: 6 })
|
|
70
|
+
}
|
|
71
|
+
|
|
63
72
|
// Immutable report hashes share a derivative across duplicate uploads. Bundle
|
|
64
73
|
// identity and visibility are part of the key: a broader viewer's sources must
|
|
65
74
|
// never populate a restricted response. Group derivatives by hash and filename
|
|
@@ -89,8 +98,7 @@ export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, r
|
|
|
89
98
|
const details = await readBundleDetails(bundle, bundles)
|
|
90
99
|
if (!details) return false
|
|
91
100
|
const selection = selectSources(sourcePaths, bundleSourcesAsMap(details))
|
|
92
|
-
const
|
|
93
|
-
const body = await compress(Buffer.from(JSON.stringify({ integrity: bundle.integrity, ...selection, imports })), { level: 6 })
|
|
101
|
+
const body = await encodeSources(bundle.integrity, details, selection)
|
|
94
102
|
// A duplicate with the same hash AND format can use these parsed bytes.
|
|
95
103
|
// Another format must not keep a deleted variant's late build alive.
|
|
96
104
|
if (!(await referenced(report, bundle))) return false
|
|
@@ -128,6 +136,8 @@ export function createReportSourcesCache(storage: CacheStorage, db: ManagedDb, r
|
|
|
128
136
|
async open(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, sourcePaths?: Set<string>) {
|
|
129
137
|
const repo = { github: (await db.listAllRepos()).find(entry => entry.repoId === report.repoId)?.fullName ?? null }
|
|
130
138
|
const target = sourceCacheFilename(report, bundle, permissions, repo, sourcePaths)
|
|
139
|
+
try { return { ...await storage.open(target), repo } }
|
|
140
|
+
catch (error) { if (!(error instanceof CacheMissError)) throw error }
|
|
131
141
|
if (!(await ensure(target, report, bundle, permissions, repo, sourcePaths))) return null
|
|
132
142
|
const opened = await storage.open(target)
|
|
133
143
|
return { ...opened, repo }
|
|
@@ -97,6 +97,9 @@ async function repositoryReader(repo: SelectedRepo, userToken: string | null, in
|
|
|
97
97
|
const defaultBranch = typeof current.default_branch === 'string' ? current.default_branch : ''
|
|
98
98
|
return {
|
|
99
99
|
recheckAccess: () => checkAccess(true),
|
|
100
|
+
// Server-only credential, selected by the same live visibility/permission
|
|
101
|
+
// gate as directory browsing. Never included in an HTTP response.
|
|
102
|
+
readToken: () => token,
|
|
100
103
|
async refs() {
|
|
101
104
|
// Suggestions are bounded; the input also accepts any branch or tag name.
|
|
102
105
|
const results = await Promise.all(['branches', 'tags'].map(async kind => {
|
|
@@ -65,7 +65,7 @@ export class RepositoryDiscovery {
|
|
|
65
65
|
// Cache the two sources separately so failed installation discovery is
|
|
66
66
|
// retried without caching a degraded combined list for another minute.
|
|
67
67
|
const [userRepos, installedRepos] = await Promise.all([
|
|
68
|
-
this.read(key, () => listUserRepos(token
|
|
68
|
+
this.read(key, () => listUserRepos(token)),
|
|
69
69
|
installed().catch((err: unknown) => {
|
|
70
70
|
console.warn('managed: installed repo deduplication failed:', err)
|
|
71
71
|
return []
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Internal invalidation counters. Never expose these global values to clients:
|
|
2
|
+
// feeds still publish only the existing user/visibility-scoped revisions.
|
|
3
|
+
const catalogTables = ['managed_team', 'managed_team_user', 'managed_team_repo', 'managed_report', 'managed_bundle', 'managed_selected_repo']
|
|
4
|
+
const annotationTables = ['managed_finding_triage', 'managed_finding_triage_event', 'managed_finding_comment']
|
|
5
|
+
export function revisionSchema(postgres = false): string {
|
|
6
|
+
let sql = `CREATE TABLE IF NOT EXISTS managed_maintenance_lease (
|
|
7
|
+
id INTEGER PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL
|
|
8
|
+
); CREATE TABLE IF NOT EXISTS managed_change_revision (
|
|
9
|
+
id INTEGER PRIMARY KEY, catalog INTEGER NOT NULL, annotations INTEGER NOT NULL
|
|
10
|
+
); INSERT INTO managed_change_revision VALUES (1, 0, 0) ON CONFLICT(id) DO NOTHING;`
|
|
11
|
+
for (const [column, tables] of [['catalog', catalogTables], ['annotations', annotationTables]] as const) {
|
|
12
|
+
const update = `UPDATE managed_change_revision SET ${column} = ${column} + 1 WHERE id = 1;`
|
|
13
|
+
if (postgres) {
|
|
14
|
+
sql += `CREATE OR REPLACE FUNCTION managed_${column}_changed() RETURNS trigger LANGUAGE plpgsql AS $$
|
|
15
|
+
BEGIN IF EXISTS (SELECT 1 FROM managed_revision_rows) THEN ${update} END IF; RETURN NULL; END $$;`
|
|
16
|
+
}
|
|
17
|
+
for (const table of tables) {
|
|
18
|
+
if (postgres) {
|
|
19
|
+
sql += `DROP TRIGGER IF EXISTS ${table}_revision ON ${table};`
|
|
20
|
+
for (const event of ['INSERT', 'UPDATE', 'DELETE']) {
|
|
21
|
+
sql += `DROP TRIGGER IF EXISTS ${table}_revision_${event} ON ${table};
|
|
22
|
+
CREATE TRIGGER ${table}_revision_${event} AFTER ${event} ON ${table}
|
|
23
|
+
REFERENCING ${event === 'DELETE' ? 'OLD' : 'NEW'} TABLE AS managed_revision_rows
|
|
24
|
+
FOR EACH STATEMENT EXECUTE FUNCTION managed_${column}_changed();`
|
|
25
|
+
}
|
|
26
|
+
} else {
|
|
27
|
+
for (const event of ['INSERT', 'UPDATE', 'DELETE']) {
|
|
28
|
+
sql += `CREATE TRIGGER IF NOT EXISTS ${table}_revision_${event}
|
|
29
|
+
AFTER ${event} ON ${table} BEGIN ${update} END;`
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return sql
|
|
35
|
+
}
|
|
@@ -4,6 +4,7 @@ export function postgresSql(source: string): { sql: string; names: string[] } {
|
|
|
4
4
|
const names: string[] = []
|
|
5
5
|
let position = 0
|
|
6
6
|
let sql = source.replaceAll('json_each(:contexts)', 'jsonb_array_elements(:contexts::jsonb)')
|
|
7
|
+
.replaceAll("CAST(unixepoch('subsec') * 1000 AS INTEGER)", 'CAST(EXTRACT(EPOCH FROM clock_timestamp()) * 1000 AS BIGINT)')
|
|
7
8
|
.replaceAll(/json_extract\((\w+\.value), '\$\.(\w+)'\)/gu, "($1->>'$2')")
|
|
8
9
|
.replaceAll('json_each(?)', 'jsonb_array_elements_text(?::jsonb)')
|
|
9
10
|
.replaceAll('INSERT OR IGNORE INTO', 'INSERT INTO')
|
package/server-managed/sql.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Each public store operation owns a transaction. SQLite serializes access to
|
|
2
|
-
// its single connection across awaits; Neon
|
|
2
|
+
// its single connection across awaits; Neon can reuse a request-owned connection.
|
|
3
3
|
import type { DatabaseSync, SQLInputValue } from 'node:sqlite'
|
|
4
4
|
import type { ManagedDb } from './db-methods.ts'
|
|
5
5
|
|
|
@@ -13,14 +13,25 @@ export interface ManagedSql {
|
|
|
13
13
|
close(): MaybePromise<void>
|
|
14
14
|
}
|
|
15
15
|
export interface ManagedSqlDriver extends ManagedSql {
|
|
16
|
-
scope<T>(write: boolean, work: () => Promise<T
|
|
16
|
+
scope<T>(write: boolean, work: () => Promise<T>, options?: { lock?: boolean; statement?: boolean }): Promise<T>
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// These operations execute exactly one SQL statement. Postgres already gives
|
|
20
|
+
// that statement an atomic snapshot; an explicit BEGIN/COMMIT adds only trips.
|
|
21
|
+
// Lease writes coordinate through their conditional UPSERT/UPDATE alone.
|
|
22
|
+
const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption',
|
|
23
|
+
'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
|
|
24
|
+
'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease'])
|
|
25
|
+
|
|
19
26
|
export function scopeManagedMethods(methods: ManagedDb, driver: ManagedSqlDriver): ManagedDb {
|
|
20
27
|
const entries = Object.entries(methods).map(([name, method]) => {
|
|
21
28
|
if (name === 'close') return [name, async () => { await driver.close() }]
|
|
22
29
|
const write = !/^(?:get|list|userCanRead|reportPermissionsFor)/u.test(name)
|
|
23
|
-
return [name, (...args: unknown[]) => driver.scope(write, () => method(...args)
|
|
30
|
+
return [name, (...args: unknown[]) => driver.scope(write, () => method(...args), {
|
|
31
|
+
// Presence writes only one user's timestamp; they need no application
|
|
32
|
+
// writer lock. Keep the session/user read and timestamp in one transaction.
|
|
33
|
+
lock: write && name !== 'sessionWithUser', statement: SINGLE_STATEMENTS.has(name),
|
|
34
|
+
})]
|
|
24
35
|
})
|
|
25
36
|
return Object.fromEntries(entries) as ManagedDb
|
|
26
37
|
}
|
|
@@ -84,11 +84,18 @@ export function decodeStorageTokens(key: StorageKey, id: string, row: StoredToke
|
|
|
84
84
|
return { access: decode('access', row.access), refresh: decode('refresh', row.refresh) }
|
|
85
85
|
}
|
|
86
86
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
87
|
+
// Fixed-width sizes make the saved position sort by type, then numeric byte
|
|
88
|
+
// size, then ID in both SQLite and Postgres. Sixteen digits cover JS safe sizes.
|
|
89
|
+
const PADDED_SIZE = "substr('0000000000000000', 1, 16 - length(CAST(byte_size AS TEXT))) || CAST(byte_size AS TEXT)"
|
|
90
|
+
const PENDING = `SELECT '0:' || ${PADDED_SIZE} || ':' || id AS position, id, 'report' AS type FROM managed_report WHERE storage_encrypted = 0
|
|
91
|
+
UNION ALL SELECT '1:' || ${PADDED_SIZE} || ':' || id AS position, id, 'bundle' AS type FROM managed_bundle WHERE storage_encrypted = 0
|
|
92
|
+
UNION ALL SELECT '2:0000000000000000:' || id AS position, id, 'user' AS type FROM managed_user
|
|
90
93
|
WHERE gh_tokens_encrypted = 0 AND (gh_access_token IS NOT NULL OR gh_refresh_token IS NOT NULL)`
|
|
91
94
|
|
|
95
|
+
// Restart the pending pass for a cursor saved under the previous ID ordering.
|
|
96
|
+
// Completed rows are excluded by PENDING; the original cursor still fences CAS.
|
|
97
|
+
const orderedCursor = (cursor: string | null): string => cursor && /^[012]:[0-9]{16}:/u.test(cursor) ? cursor : ''
|
|
98
|
+
|
|
92
99
|
export function storageMethods(db: ManagedSql, key: StorageKey | null): StorageDb {
|
|
93
100
|
async function requireEnabled() {
|
|
94
101
|
const state = await storageState(db, key)
|
|
@@ -135,11 +142,11 @@ export function storageMethods(db: ManagedSql, key: StorageKey | null): StorageD
|
|
|
135
142
|
async listStorageMigrationRows(after, limit) {
|
|
136
143
|
await requireEnabled()
|
|
137
144
|
return await db.prepare(`SELECT position, id, type FROM (${PENDING}) AS pending
|
|
138
|
-
WHERE position > ? ORDER BY position LIMIT ?`).all(after
|
|
145
|
+
WHERE position > ? ORDER BY position LIMIT ?`).all(orderedCursor(after), limit) as StorageMigrationRow[]
|
|
139
146
|
},
|
|
140
147
|
async advanceStorageMigration(expected, next) {
|
|
141
148
|
await requireEnabled()
|
|
142
|
-
if (
|
|
149
|
+
if (next !== null && next <= orderedCursor(expected)) return
|
|
143
150
|
// End-of-pass completion checks every pending row, including failed rows
|
|
144
151
|
// before the cursor. A failure cannot be mistaken for a finished pass.
|
|
145
152
|
await db.prepare(`UPDATE managed_storage_encryption SET cursor = ?, complete =
|
|
@@ -2,9 +2,9 @@ import { type StorageKey, decryptStorageStream, encryptStorageStream } from '../
|
|
|
2
2
|
import { ENCRYPTED_CACHE_PREFIX, type RawObjectStorage, deleteObjects, isBlobId, openObjectVersion } from './object-storage.ts'
|
|
3
3
|
import { type StorageDb, type StorageEncryptionState, type StorageMigrationRow, unwrapDataKey } from './storage-db.ts'
|
|
4
4
|
import { STORAGE_UPLOAD_TTL_MS } from './storage-encryption.ts'
|
|
5
|
-
import { inspectStorageObject, storageOwner, storageRowPath, verifyStoragePayload } from './storage-payload.ts'
|
|
5
|
+
import { StoragePayloadError, inspectStorageObject, storageOwner, storageRowPath, verifyStoragePayload } from './storage-payload.ts'
|
|
6
6
|
|
|
7
|
-
async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey, item: StorageMigrationRow, signal: AbortSignal) {
|
|
7
|
+
async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey, item: StorageMigrationRow, signal: AbortSignal): Promise<string | void> {
|
|
8
8
|
if (item.type === 'user') { await db.migrateStorageUserTokens(item.id); return }
|
|
9
9
|
const row = await db.ensureStorageDataKey(item.type, item.id)
|
|
10
10
|
if (!row || row.encrypted) return
|
|
@@ -13,7 +13,7 @@ async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
|
|
|
13
13
|
const source = await raw.open(identity, signal)
|
|
14
14
|
if (!source) {
|
|
15
15
|
if (!await db.getStorageRow(item.type, item.id)) return
|
|
16
|
-
throw new
|
|
16
|
+
throw new StoragePayloadError('Migration payload unavailable')
|
|
17
17
|
}
|
|
18
18
|
let inspected = await inspectStorageObject(source)
|
|
19
19
|
if (inspected.encrypted) {
|
|
@@ -26,7 +26,7 @@ async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
|
|
|
26
26
|
// Legacy arbitrary bytes can share the magic prefix. A matching
|
|
27
27
|
// original upload hash is required before encrypting them as plaintext.
|
|
28
28
|
const legacy = await openObjectVersion(raw, identity, source.version, signal)
|
|
29
|
-
if (!legacy) return
|
|
29
|
+
if (!legacy) return 'Payload disappeared or changed version before plaintext verification'
|
|
30
30
|
inspected = { ...legacy, encrypted: false }
|
|
31
31
|
}
|
|
32
32
|
}
|
|
@@ -34,18 +34,18 @@ async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
|
|
|
34
34
|
await verifyStoragePayload(item.type, row, inspected.stream, signal)
|
|
35
35
|
signal.throwIfAborted()
|
|
36
36
|
const current = await openObjectVersion(raw, identity, source.version, signal)
|
|
37
|
-
if (!current) return
|
|
37
|
+
if (!current) return 'Payload disappeared or changed version before encryption'
|
|
38
38
|
const encrypted = encryptStorageStream(current.stream, dataKey, identity, current.size)
|
|
39
39
|
let replaced: boolean
|
|
40
40
|
try { replaced = await raw.put(identity, encrypted, signal, source.version, current.size ?? undefined) }
|
|
41
41
|
finally { encrypted.destroy(); current.stream.destroy() }
|
|
42
|
-
if (!replaced) return
|
|
42
|
+
if (!replaced) return 'Conditional replacement rejected (object version precondition failed)'
|
|
43
43
|
// Do not mark the row encrypted until the stored bytes authenticate and
|
|
44
44
|
// match the original upload (including decompression for sourcemaps).
|
|
45
45
|
const replacement = await raw.open(identity, signal)
|
|
46
46
|
if (!replacement) {
|
|
47
47
|
if (!await db.getStorageRow(item.type, item.id)) return
|
|
48
|
-
throw new
|
|
48
|
+
throw new StoragePayloadError('Migrated payload unavailable')
|
|
49
49
|
}
|
|
50
50
|
const decoded = await decryptStorageStream(replacement.stream, dataKey, identity)
|
|
51
51
|
await verifyStoragePayload(item.type, row, decoded.stream, signal)
|
|
@@ -130,38 +130,50 @@ async function cleanupLegacy(raw: RawObjectStorage, db: StorageDb, state: Storag
|
|
|
130
130
|
return { processed: page.objects.length || 1, retryAt }
|
|
131
131
|
}
|
|
132
132
|
|
|
133
|
-
interface StorageMigrationResult extends StorageEncryptionState {
|
|
133
|
+
interface StorageMigrationResult extends StorageEncryptionState {
|
|
134
|
+
retryAt: number | null
|
|
135
|
+
failures: { type: StorageMigrationRow['type']; id: string; message: string }[]
|
|
136
|
+
}
|
|
137
|
+
interface MigrationOptions { maxObjects?: number; maxMs?: number | undefined; signal?: AbortSignal | undefined }
|
|
134
138
|
export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
|
|
135
|
-
{ maxObjects = 64, maxMs = 150_000 } = {}): Promise<StorageMigrationResult> {
|
|
139
|
+
{ maxObjects = 64, maxMs = 150_000, signal: stopping }: MigrationOptions = {}): Promise<StorageMigrationResult> {
|
|
136
140
|
if (!Number.isSafeInteger(maxObjects) || maxObjects < 1 || !Number.isSafeInteger(maxMs) || maxMs < 1) throw new Error('Invalid migration budget')
|
|
137
|
-
const deadline = Date.now() + maxMs,
|
|
141
|
+
const deadline = Date.now() + maxMs, timeout = AbortSignal.timeout(maxMs)
|
|
142
|
+
const signal = stopping ? AbortSignal.any([stopping, timeout]) : timeout
|
|
138
143
|
const errors: unknown[] = []
|
|
144
|
+
const failures: StorageMigrationResult['failures'] = []
|
|
139
145
|
let processed = 0, retryAt: number | null = null, state = await db.getStorageEncryption()
|
|
140
146
|
if (!state) throw new Error('Storage encryption is not enabled; configure MANAGED_STORAGE_ENCRYPTION_KEY')
|
|
141
147
|
// At most one SQL pass per invocation. Checkpoint each row, including a
|
|
142
148
|
// failure: other rows progress, and the failed row is retried next pass.
|
|
143
|
-
while (!state.complete && processed < maxObjects && Date.now() < deadline) {
|
|
149
|
+
while (!state.complete && processed < maxObjects && Date.now() < deadline && !signal.aborted) {
|
|
144
150
|
const rows = await db.listStorageMigrationRows(state.cursor, Math.min(16, maxObjects - processed))
|
|
151
|
+
if (stopping?.aborted) break
|
|
145
152
|
if (rows.length === 0) { await db.advanceStorageMigration(state.cursor, null); break }
|
|
146
153
|
for (const row of rows) {
|
|
147
|
-
if (Date.now() >= deadline) break
|
|
148
|
-
try {
|
|
154
|
+
if (Date.now() >= deadline || signal.aborted) break
|
|
155
|
+
try {
|
|
156
|
+
const pending = await migrateRow(raw, db, key, row, signal)
|
|
157
|
+
if (pending) failures.push({ type: row.type, id: row.id, message: pending })
|
|
158
|
+
}
|
|
149
159
|
catch (err) {
|
|
150
160
|
// A row started late gets a full budget next time. Skip an oversized
|
|
151
161
|
// first row until the next pass so later rows and tokens can advance.
|
|
152
|
-
if (signal.aborted && processed > 0) break
|
|
153
|
-
const
|
|
162
|
+
if (stopping?.aborted || (signal.aborted && processed > 0)) break
|
|
163
|
+
const failure = signal.aborted
|
|
154
164
|
? `Could not migrate ${row.type} ${row.id} within MANAGED_STORAGE_ENCRYPTION_MIGRATE_MAX_MS=${maxMs}; increase the budget`
|
|
155
|
-
:
|
|
156
|
-
errors.push(new Error(
|
|
165
|
+
: err instanceof StoragePayloadError ? err.message : null
|
|
166
|
+
if (failure === null) errors.push(new Error(`Could not migrate ${row.type} ${row.id}`, { cause: err }))
|
|
167
|
+
else failures.push({ type: row.type, id: row.id, message: failure })
|
|
157
168
|
}
|
|
169
|
+
if (stopping?.aborted) break
|
|
158
170
|
await db.advanceStorageMigration(state.cursor, row.position)
|
|
159
171
|
processed++
|
|
160
172
|
state = (await db.getStorageEncryption())!
|
|
161
173
|
}
|
|
162
174
|
}
|
|
163
175
|
state = (await db.getStorageEncryption())!
|
|
164
|
-
while (!state.cleanupComplete && processed < maxObjects && Date.now() < deadline) {
|
|
176
|
+
while (!state.cleanupComplete && processed < maxObjects && Date.now() < deadline && !signal.aborted) {
|
|
165
177
|
try {
|
|
166
178
|
const cleanup = await cleanupLegacy(raw, db, state, maxObjects - processed, signal)
|
|
167
179
|
processed += cleanup.processed
|
|
@@ -172,11 +184,14 @@ export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key:
|
|
|
172
184
|
if (retryAt !== null) break // Wait for staging grace rather than spinning over live temp files.
|
|
173
185
|
}
|
|
174
186
|
if (errors.length > 0) throw new AggregateError(errors, 'Storage migration has pending failures', { cause: errors[0] })
|
|
175
|
-
return { ...state, retryAt }
|
|
187
|
+
return { ...state, retryAt, failures }
|
|
176
188
|
}
|
|
177
189
|
|
|
178
|
-
export async function reapStorageUploads(raw: RawObjectStorage, db: StorageDb, now = Date.now()): Promise<number> {
|
|
179
|
-
|
|
190
|
+
export async function reapStorageUploads(raw: RawObjectStorage, db: StorageDb, now = Date.now(), stopping?: AbortSignal): Promise<number> {
|
|
191
|
+
if (stopping?.aborted) return 0
|
|
192
|
+
const timeout = AbortSignal.timeout(150_000)
|
|
193
|
+
const signal = stopping ? AbortSignal.any([stopping, timeout]) : timeout
|
|
180
194
|
await db.getStorageEncryption()
|
|
181
|
-
return deleteObjects(raw, 'uploads/', signal, now - STORAGE_UPLOAD_TTL_MS)
|
|
195
|
+
try { return await deleteObjects(raw, 'uploads/', signal, now - STORAGE_UPLOAD_TTL_MS) }
|
|
196
|
+
catch (err) { if (stopping?.aborted) return 0; throw err }
|
|
182
197
|
}
|
|
@@ -7,6 +7,10 @@ import { STORAGE_MAGIC } from '../server-common/storage-crypto.ts'
|
|
|
7
7
|
import { type RawObject, isBlobId } from './object-storage.ts'
|
|
8
8
|
import type { StorageRow, StorageRowKind } from './storage-db.ts'
|
|
9
9
|
|
|
10
|
+
// A damaged/missing payload needs repair; retrying the whole cleanup job does
|
|
11
|
+
// not fix it. Keep this distinct from database, storage and cancellation errors.
|
|
12
|
+
export class StoragePayloadError extends Error {}
|
|
13
|
+
|
|
10
14
|
export function storageRowPath(type: StorageRowKind, row: Pick<StorageRow, 'id' | 'kind'>): string {
|
|
11
15
|
return `${type === 'report' ? 'reports' : 'bundles'}/${row.id}${type === 'bundle' && row.kind === 'sourcemap' ? '.map.br' : ''}`
|
|
12
16
|
}
|
|
@@ -52,9 +56,17 @@ export async function verifyStoragePayload(type: StorageRowKind, row: StorageRow
|
|
|
52
56
|
const hash = createHash(type === 'report' ? 'sha256' : 'sha512')
|
|
53
57
|
const sink = new Writable({ write(chunk, _encoding, next) { hash.update(chunk); next() } })
|
|
54
58
|
const options = signal ? { signal } : {}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
59
|
+
try {
|
|
60
|
+
if (type === 'bundle' && row.kind === 'sourcemap') {
|
|
61
|
+
await pipeline(source, createBrotliDecompress(), sink, options)
|
|
62
|
+
} else await pipeline(source, sink, options)
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const code = (err as NodeJS.ErrnoException).code ?? ''
|
|
65
|
+
if (['Z_DATA_ERROR', 'Z_BUF_ERROR'].includes(code) || code.startsWith('ERR__ERROR_FORMAT_')) {
|
|
66
|
+
throw new StoragePayloadError('Stored sourcemap cannot be decompressed', { cause: err })
|
|
67
|
+
}
|
|
68
|
+
throw err
|
|
69
|
+
}
|
|
58
70
|
const actual = type === 'report' ? hash.digest('base64url') : `sha512-${hash.digest('base64')}`
|
|
59
|
-
if (actual !== row.hash) throw new
|
|
71
|
+
if (actual !== row.hash) throw new StoragePayloadError('Stored payload does not match its upload hash')
|
|
60
72
|
}
|
|
@@ -5,7 +5,7 @@ import type { AvatarStore } from './avatar-store.ts'
|
|
|
5
5
|
import type { BlobStore } from './blob-store.ts'
|
|
6
6
|
import type { BundleCacheStorage } from './bundle-cache.ts'
|
|
7
7
|
import { createBundleStore } from './bundle-store.ts'
|
|
8
|
-
import { type CacheStorage, validateCacheKey } from './cache-storage.ts'
|
|
8
|
+
import { CacheMissError, type CacheStorage, validateCacheKey } from './cache-storage.ts'
|
|
9
9
|
import { type ObjectStorage, isBlobId } from './object-storage.ts'
|
|
10
10
|
|
|
11
11
|
function id(value: string): string {
|
|
@@ -30,7 +30,7 @@ function cacheStore(objects: ObjectStorage, name: string): CacheStorage {
|
|
|
30
30
|
put: async (key, bytes) => { await objects.put(path(key), bytes) },
|
|
31
31
|
async open(key) {
|
|
32
32
|
const value = await objects.open(path(key))
|
|
33
|
-
if (!value) throw new
|
|
33
|
+
if (!value) throw new CacheMissError()
|
|
34
34
|
return value
|
|
35
35
|
},
|
|
36
36
|
delete: prefix => objects.deletePrefix(`${path(prefix)}/`),
|
|
@@ -37,17 +37,19 @@ export async function openManagedStorage(config: ManagedConfig) {
|
|
|
37
37
|
return { ...storage, db, uploadStore: config.neonUrl ? storage.uploadStore : undefined,
|
|
38
38
|
bundleCache: createBundleCache(storage.cacheStorage, db, storage.bundleStore),
|
|
39
39
|
reportSourcesCache: createReportSourcesCache(storage.reportSourcesStorage, db, storage.reportStore, storage.bundleStore),
|
|
40
|
-
async reapStorage() {
|
|
41
|
-
if (!config.storageEncryptionMigrate) return
|
|
40
|
+
async reapStorage(signal?: AbortSignal) {
|
|
41
|
+
if (!config.storageEncryptionMigrate || signal?.aborted) return
|
|
42
42
|
const state = await db.getStorageEncryption()
|
|
43
43
|
if (state?.complete && state.cleanupComplete) return
|
|
44
|
-
const result = await migrateStorage(raw, db, key!, { maxMs: config.storageEncryptionMigrateMaxMs })
|
|
44
|
+
const result = await migrateStorage(raw, db, key!, { maxMs: config.storageEncryptionMigrateMaxMs, signal })
|
|
45
|
+
for (const failure of result.failures) console.warn('managed-storage-migration-row:', JSON.stringify(failure))
|
|
45
46
|
console.info('managed-storage-migration:', JSON.stringify({
|
|
46
47
|
complete: result.complete === 1, cleanupComplete: result.cleanupComplete === 1,
|
|
47
48
|
migrated: result.migrated, cursor: result.cursor, retryAt: result.retryAt,
|
|
49
|
+
failed: result.failures.length, cancelled: signal?.aborted === true,
|
|
48
50
|
}))
|
|
49
51
|
},
|
|
50
|
-
...(config.neonUrl ? { reapUploads: () => reapStorageUploads(raw, db) } : {}),
|
|
52
|
+
...(config.neonUrl ? { reapUploads: (signal?: AbortSignal) => reapStorageUploads(raw, db, Date.now(), signal) } : {}),
|
|
51
53
|
}
|
|
52
54
|
} catch (err) {
|
|
53
55
|
await db.close()
|