@preventive/triage 1.0.0-alpha.27 → 1.0.0-alpha.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/client/linked-findings.js +1 -1
  3. package/common/bundle-metadata.js +12 -10
  4. package/common/code-language.js +17 -2
  5. package/common/default-scan-models.ts +3 -3
  6. package/common/finding-id.js +1 -1
  7. package/common/managed/report-content.ts +1 -1
  8. package/common/managed/report-filter.ts +1 -1
  9. package/out/client-managed-import.js +16 -16
  10. package/out/client-managed.js +93 -68
  11. package/out/graph.js +11 -11
  12. package/out/index.html +1 -1
  13. package/out/prism.js +2 -2
  14. package/out/stasis.svg +0 -25
  15. package/out/terminal.js +163 -163
  16. package/out/view.css +1 -1
  17. package/out/view.js +223 -190
  18. package/package.json +21 -28
  19. package/server-e2e/static.ts +3 -4
  20. package/server-managed/bundle-advisories.ts +53 -0
  21. package/server-managed/bundle-advisory-inventory.ts +75 -0
  22. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  23. package/server-managed/bundle-build-diagnostics.js +23 -0
  24. package/server-managed/bundle-build-leases.ts +41 -0
  25. package/server-managed/bundle-build-worker.js +54 -0
  26. package/server-managed/bundle-build.ts +168 -0
  27. package/server-managed/bundle-cache.ts +22 -13
  28. package/server-managed/bundle-summary-cache.ts +27 -9
  29. package/server-managed/bundle.ts +66 -1
  30. package/server-managed/cache-storage.ts +4 -0
  31. package/server-managed/content-access.ts +1 -1
  32. package/server-managed/db-methods.ts +122 -20
  33. package/server-managed/db-neon.ts +121 -32
  34. package/server-managed/db-schema.ts +2 -0
  35. package/server-managed/db.ts +5 -1
  36. package/server-managed/github-app.ts +10 -15
  37. package/server-managed/github-errors.ts +21 -0
  38. package/server-managed/github-issues.ts +1 -1
  39. package/server-managed/github-metadata.ts +34 -1
  40. package/server-managed/github-pulls.ts +142 -30
  41. package/server-managed/http.ts +244 -131
  42. package/server-managed/index.ts +28 -11
  43. package/server-managed/management-catalog.ts +45 -0
  44. package/server-managed/object-storage-vercel.ts +1 -1
  45. package/server-managed/public-workspace.ts +20 -14
  46. package/server-managed/report-migration.ts +1 -1
  47. package/server-managed/report-sources.ts +14 -4
  48. package/server-managed/repository-browser.ts +3 -0
  49. package/server-managed/repository-discovery.ts +1 -1
  50. package/server-managed/revisions.ts +35 -0
  51. package/server-managed/sql-postgres.ts +1 -0
  52. package/server-managed/sql.ts +14 -3
  53. package/server-managed/storage-db.ts +12 -5
  54. package/server-managed/storage-maintenance.ts +47 -22
  55. package/server-managed/storage-payload.ts +16 -4
  56. package/server-managed/storage-stores.ts +2 -2
  57. package/server-managed/storage.ts +6 -4
  58. package/server-managed/team-feed.ts +50 -5
  59. package/server-managed/team-reports.ts +85 -22
  60. package/server-managed/workspace-shares.ts +15 -3
  61. package/report/index.js +0 -260
  62. package/report/src/finding-id.js +0 -80
  63. package/report/src/finding.js +0 -314
  64. package/report/src/labels.js +0 -35
  65. package/report/src/md-structure.js +0 -471
  66. package/report/src/md-text.js +0 -167
  67. package/report/src/meta.js +0 -76
  68. package/report/src/parse-codex.js +0 -149
  69. package/report/src/parse-deepsec.js +0 -199
  70. package/report/src/parse-deepview-fields.js +0 -375
  71. package/report/src/parse-deepview-md.js +0 -186
  72. package/report/src/parse-md-id.js +0 -137
  73. package/report/src/parse-md.js +0 -323
  74. package/report/src/parse-piolium-id.js +0 -79
  75. package/report/src/parse-piolium-rows.js +0 -126
  76. package/report/src/parse-piolium-tokens.js +0 -185
  77. package/report/src/parse-piolium.js +0 -399
  78. package/report/src/security.js +0 -63
  79. package/report/src/severity.js +0 -18
  80. package/report/src/utf8.js +0 -21
  81. package/report/src/write-md-finding.js +0 -273
  82. package/report/src/write-md.js +0 -291
  83. /package/{LICENSE → LICENSE-MIT} +0 -0
@@ -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
- function reap(): Promise<void> {
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 = runReapers({
57
- sessions: async () => { sessions = await db.deleteExpiredSessions(Date.now()) },
58
- ...(storage.reapUploads ? { uploads: async () => { uploads = await storage.reapUploads!() } } : {}),
59
- ...('reapStorage' in storage ? { storage: () => storage.reapStorage() } : {}),
60
- }).then(() => {
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,15 @@ 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 maintenance = automaticReap()
76
- // Send the normal response without waiting for storage housekeeping, but
77
- // keep the invocation alive until both finish. No detached serverless work.
90
+ const pendingMaintenance = automaticReap()
91
+ // Send the normal response without waiting for storage housekeeping. The
92
+ // Vercel entrypoint registers this whole promise with waitUntil; awaiting
93
+ // work after res.end() alone cannot extend a serverless invocation.
78
94
  try { await routeRequest(req, res) }
79
- finally { await maintenance }
95
+ finally { await pendingMaintenance }
80
96
  }
81
97
  const gcTimer = config.serverless ? null : setInterval(() => {
82
98
  void automaticReap()
@@ -85,6 +101,7 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
85
101
 
86
102
  function stop(): void {
87
103
  shuttingDown = true
104
+ maintenance.abort()
88
105
  if (gcTimer) clearInterval(gcTimer)
89
106
  }
90
107
 
@@ -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, loadTeamReports, teamReportVisibility } from './team-reports.ts'
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 { NPM_ADVISORIES_TIMEOUT_MS, fetchNpmAdvisories } from '../server-common/npm-advisories.ts'
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
- await send({ reports: await loadTeamReports(deps.db, deps.reportStore, snapshot) }); return
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 packages = await deps.bundleCache.packageVersions(bundle, url.searchParams.get('reason') ?? '')
112
+ const inventory = await deps.bundleCache.advisoryInventory(bundle, url.searchParams.get('reason') ?? '')
107
113
  await recheck()
108
- if (packages === null) { json(res, 413, { error: 'payload-too-large' }); return }
109
- if (packages === undefined) { json(res, 400, { error: 'unknown-reason' }); return }
110
- const body = Buffer.from(JSON.stringify(packages))
111
- if (body.length > MAX_PACKAGE_INVENTORY_BYTES) { json(res, 413, { error: 'payload-too-large' }); return }
112
- const result = Object.keys(packages).length === 0 ? { status: 200, body: {} }
113
- : await fetchNpmAdvisories(body, AbortSignal.timeout(NPM_ADVISORIES_TIMEOUT_MS), deps.config.debug)
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 ? { packages, advisories: result.body } : result.body); return
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 '../report/index.js'
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 { CacheStorage } from './cache-storage.ts'
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-v5', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
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 imports = [...bundleSourceImports(details, new Map(selection.files))].map(([parent, targets]) => [parent, [...targets]])
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, this.fetchImpl)),
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')
@@ -1,5 +1,5 @@
1
1
  // Each public store operation owns a transaction. SQLite serializes access to
2
- // its single connection across awaits; Neon binds one connection per operation.
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>): 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
- const PENDING = `SELECT 'bundle:' || id AS position, id, 'bundle' AS type FROM managed_bundle WHERE storage_encrypted = 0
88
- UNION ALL SELECT 'report:' || id AS position, id, 'report' AS type FROM managed_report WHERE storage_encrypted = 0
89
- UNION ALL SELECT 'user:' || id AS position, id, 'user' AS type FROM managed_user
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 ?? '', limit) as StorageMigrationRow[]
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 (expected !== null && next !== null && next <= expected) return
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 Error('Migration payload unavailable')
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 Error('Migrated payload unavailable')
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,60 @@ 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 { retryAt: number | null }
133
+ interface StorageMigrationResult extends StorageEncryptionState {
134
+ retryAt: number | null
135
+ failures: { type: StorageMigrationRow['type']; id: string; message: string }[]
136
+ }
137
+ function rowFailure(row: StorageMigrationRow, message: string) {
138
+ const failure = { type: row.type, id: row.id, message }
139
+ // Emit before continuing: a later failure or terminated invocation must not
140
+ // hide earlier diagnostics. Never include provider errors or storage values.
141
+ console.warn('managed-storage-migration-row:', JSON.stringify(failure))
142
+ return failure
143
+ }
144
+ interface MigrationOptions { maxObjects?: number; maxMs?: number | undefined; signal?: AbortSignal | undefined }
134
145
  export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
135
- { maxObjects = 64, maxMs = 150_000 } = {}): Promise<StorageMigrationResult> {
146
+ { maxObjects = 64, maxMs = 150_000, signal: stopping }: MigrationOptions = {}): Promise<StorageMigrationResult> {
136
147
  if (!Number.isSafeInteger(maxObjects) || maxObjects < 1 || !Number.isSafeInteger(maxMs) || maxMs < 1) throw new Error('Invalid migration budget')
137
- const deadline = Date.now() + maxMs, signal = AbortSignal.timeout(maxMs)
148
+ const deadline = Date.now() + maxMs, timeout = AbortSignal.timeout(maxMs)
149
+ const signal = stopping ? AbortSignal.any([stopping, timeout]) : timeout
138
150
  const errors: unknown[] = []
151
+ const failures: StorageMigrationResult['failures'] = []
139
152
  let processed = 0, retryAt: number | null = null, state = await db.getStorageEncryption()
140
153
  if (!state) throw new Error('Storage encryption is not enabled; configure MANAGED_STORAGE_ENCRYPTION_KEY')
141
154
  // At most one SQL pass per invocation. Checkpoint each row, including a
142
155
  // failure: other rows progress, and the failed row is retried next pass.
143
- while (!state.complete && processed < maxObjects && Date.now() < deadline) {
156
+ while (!state.complete && processed < maxObjects && Date.now() < deadline && !signal.aborted) {
144
157
  const rows = await db.listStorageMigrationRows(state.cursor, Math.min(16, maxObjects - processed))
158
+ if (stopping?.aborted) break
145
159
  if (rows.length === 0) { await db.advanceStorageMigration(state.cursor, null); break }
146
160
  for (const row of rows) {
147
- if (Date.now() >= deadline) break
148
- try { await migrateRow(raw, db, key, row, signal) }
161
+ if (Date.now() >= deadline || signal.aborted) break
162
+ console.info('managed-storage-migration-row-start:', JSON.stringify({ type: row.type, id: row.id }))
163
+ try {
164
+ const pending = await migrateRow(raw, db, key, row, signal)
165
+ if (pending) failures.push(rowFailure(row, pending))
166
+ }
149
167
  catch (err) {
150
168
  // A row started late gets a full budget next time. Skip an oversized
151
169
  // first row until the next pass so later rows and tokens can advance.
152
- if (signal.aborted && processed > 0) break
153
- const message = signal.aborted
170
+ if (stopping?.aborted || (signal.aborted && processed > 0)) break
171
+ const failure = signal.aborted
154
172
  ? `Could not migrate ${row.type} ${row.id} within MANAGED_STORAGE_ENCRYPTION_MIGRATE_MAX_MS=${maxMs}; increase the budget`
155
- : `Could not migrate ${row.type} ${row.id}`
156
- errors.push(new Error(message, { cause: err }))
173
+ : err instanceof StoragePayloadError ? err.message : null
174
+ if (failure === null) {
175
+ rowFailure(row, 'Migration operation failed')
176
+ errors.push(new Error(`Could not migrate ${row.type} ${row.id}`, { cause: err }))
177
+ } else failures.push(rowFailure(row, failure))
157
178
  }
179
+ if (stopping?.aborted) break
158
180
  await db.advanceStorageMigration(state.cursor, row.position)
159
181
  processed++
160
182
  state = (await db.getStorageEncryption())!
161
183
  }
162
184
  }
163
185
  state = (await db.getStorageEncryption())!
164
- while (!state.cleanupComplete && processed < maxObjects && Date.now() < deadline) {
186
+ while (!state.cleanupComplete && processed < maxObjects && Date.now() < deadline && !signal.aborted) {
165
187
  try {
166
188
  const cleanup = await cleanupLegacy(raw, db, state, maxObjects - processed, signal)
167
189
  processed += cleanup.processed
@@ -172,11 +194,14 @@ export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key:
172
194
  if (retryAt !== null) break // Wait for staging grace rather than spinning over live temp files.
173
195
  }
174
196
  if (errors.length > 0) throw new AggregateError(errors, 'Storage migration has pending failures', { cause: errors[0] })
175
- return { ...state, retryAt }
197
+ return { ...state, retryAt, failures }
176
198
  }
177
199
 
178
- export async function reapStorageUploads(raw: RawObjectStorage, db: StorageDb, now = Date.now()): Promise<number> {
179
- const signal = AbortSignal.timeout(150_000)
200
+ export async function reapStorageUploads(raw: RawObjectStorage, db: StorageDb, now = Date.now(), stopping?: AbortSignal): Promise<number> {
201
+ if (stopping?.aborted) return 0
202
+ const timeout = AbortSignal.timeout(150_000)
203
+ const signal = stopping ? AbortSignal.any([stopping, timeout]) : timeout
180
204
  await db.getStorageEncryption()
181
- return deleteObjects(raw, 'uploads/', signal, now - STORAGE_UPLOAD_TTL_MS)
205
+ try { return await deleteObjects(raw, 'uploads/', signal, now - STORAGE_UPLOAD_TTL_MS) }
206
+ catch (err) { if (stopping?.aborted) return 0; throw err }
182
207
  }
@@ -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
- if (type === 'bundle' && row.kind === 'sourcemap') {
56
- await pipeline(source, createBrotliDecompress(), sink, options)
57
- } else await pipeline(source, sink, options)
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 Error('Stored payload does not match its upload hash')
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 Error('Managed cache unavailable')
33
+ if (!value) throw new CacheMissError()
34
34
  return value
35
35
  },
36
36
  delete: prefix => objects.deletePrefix(`${path(prefix)}/`),