@preventive/triage 1.0.0-alpha.33 → 1.0.0-alpha.35

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 (48) hide show
  1. package/client/linked-findings.js +5 -4
  2. package/common/bundle-metadata.js +4 -3
  3. package/common/bundle-source-links.d.ts +2 -1
  4. package/common/bundle-source-links.js +133 -6
  5. package/common/bundle-sources.d.ts +2 -0
  6. package/common/bundle-sources.js +2 -4
  7. package/common/managed/repository-alias.ts +65 -0
  8. package/common/managed/routes.js +20 -1
  9. package/common/managed/triage.ts +2 -2
  10. package/out/client-managed-import.js +51 -46
  11. package/out/client-managed.js +252 -72
  12. package/out/client-sync.js +14 -14
  13. package/out/graph.js +11 -11
  14. package/out/index.html +1 -0
  15. package/out/terminal.js +320 -212
  16. package/out/view.css +1 -1
  17. package/out/view.js +410 -202
  18. package/package.json +22 -17
  19. package/server-managed/activity.ts +15 -1
  20. package/server-managed/bundle-advisories.ts +8 -3
  21. package/server-managed/bundle-advisory-inventory.ts +27 -25
  22. package/server-managed/bundle-build-worker.js +45 -9
  23. package/server-managed/bundle-build.ts +35 -11
  24. package/server-managed/bundle-cache.ts +1 -1
  25. package/server-managed/bundle.ts +17 -1
  26. package/server-managed/config.ts +5 -0
  27. package/server-managed/db-methods.ts +168 -49
  28. package/server-managed/db-neon.ts +59 -4
  29. package/server-managed/db-schema.ts +12 -3
  30. package/server-managed/db.ts +9 -1
  31. package/server-managed/github-issue-links.ts +127 -0
  32. package/server-managed/github-issues.ts +1 -1
  33. package/server-managed/http-response.ts +7 -1
  34. package/server-managed/http.ts +248 -80
  35. package/server-managed/index.ts +4 -0
  36. package/server-managed/link-reports.ts +158 -0
  37. package/server-managed/managed-issues.ts +31 -3
  38. package/server-managed/management.ts +17 -4
  39. package/server-managed/public-workspace.ts +5 -2
  40. package/server-managed/report-sources.ts +6 -5
  41. package/server-managed/repository-aliases.ts +91 -0
  42. package/server-managed/revisions.ts +2 -2
  43. package/server-managed/sql.ts +3 -2
  44. package/server-managed/storage.ts +17 -0
  45. package/server-managed/team-feed.ts +3 -3
  46. package/server-managed/team-reports.ts +24 -15
  47. package/server-managed/upstream-cache.ts +73 -0
  48. package/server-managed/workspace-shares.ts +16 -12
@@ -12,6 +12,7 @@ import { type ManagedConfig, loadManagedConfig } from './config.ts'
12
12
  import { type ManagedHttpDeps, createManagedRequestHandler } from './http.ts'
13
13
  import { loadManagedStatic } from './static.ts'
14
14
  import { openManagedStorage } from './storage.ts'
15
+ import { setCacheDir } from '@preventive/upstream/npm.js'
15
16
 
16
17
  // Housekeeping runs on ordinary traffic too, including serverless instances
17
18
  // without a timer or cron. Failed sweeps retry on later traffic after a minute.
@@ -21,6 +22,9 @@ const REAP_RETRY_MS = 60_000
21
22
  type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown'>>
22
23
 
23
24
  export async function createManagedApp(config: ManagedConfig, options: ManagedAppOptions = {}) {
25
+ // Audits keep upstream's records on disk where the config has a cache
26
+ // directory; bundle builds set it in their own worker.
27
+ setCacheDir(config.upstreamCacheDir ?? false)
24
28
  // Load the optional peer only for serverless managed deployments. Embedding
25
29
  // hosts can discard listener promises, so retain maintenance inside the app.
26
30
  const waitUntil = config.serverless ? (await import('@vercel/functions')).waitUntil : undefined
@@ -0,0 +1,158 @@
1
+ // Global deduplication reports live entirely in SQL. The payload is authenticated
2
+ // ciphertext bound to its row, using the deployment's existing storage key.
3
+ import { Buffer } from 'node:buffer'
4
+ import { createHash, randomUUID } from 'node:crypto'
5
+ import { type StorageKey, unwrapStorageValue, wrapStorageValue } from '../server-common/storage-crypto.ts'
6
+ import { parseLinkedFindings } from '../client/linked-findings.js'
7
+ import type { ManagedSql } from './sql.ts'
8
+ import { ManagedMutationError } from './management.ts'
9
+
10
+ export const LINK_REPORT_SCHEMA = `
11
+ CREATE TABLE IF NOT EXISTS managed_link_report (
12
+ id TEXT PRIMARY KEY, filename TEXT NOT NULL, sha256 TEXT NOT NULL UNIQUE,
13
+ encrypted_groups TEXT NOT NULL, enabled INTEGER NOT NULL DEFAULT 1,
14
+ group_count INTEGER NOT NULL, finding_count INTEGER NOT NULL,
15
+ uploaded_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
16
+ uploaded_by_login TEXT, uploaded_at INTEGER NOT NULL
17
+ ) STRICT;
18
+ `
19
+ export interface LinkReport {
20
+ id: string; filename: string; enabled: boolean; groupCount: number; findingCount: number
21
+ uploadedByLogin: string | null; uploadedAt: number
22
+ }
23
+ export interface LinkReportStore {
24
+ listLegacyLinkReports(): Promise<{ id: string; filename: string }[]>
25
+ migrateLinkReport(id: string, content: string): Promise<boolean>
26
+ listLinkReports(): Promise<LinkReport[]>
27
+ getLinkReport(sessionId: string, id: string): Promise<LinkReport & { groups: string[][] }>
28
+ getLinkRevision(): Promise<string>
29
+ getEnabledLinkGroups(revision: string): Promise<string[][]>
30
+ importLinkReport(sessionId: string, filename: string, content: string): Promise<{ report: LinkReport; reused: boolean }>
31
+ setLinkReportEnabled(sessionId: string, id: string, enabled: boolean): Promise<void>
32
+ }
33
+ const columns = `id, filename, enabled, group_count AS groupCount, finding_count AS findingCount,
34
+ uploaded_by_login AS uploadedByLogin, uploaded_at AS uploadedAt`
35
+ const identity = (id: string) => `managed_link_report.encrypted_groups:${id}`
36
+ function decryptGroups(key: StorageKey | null, id: string, payload: string): string[][] {
37
+ if (!key) throw new ManagedMutationError(503, 'storage-encryption-required')
38
+ const plain = unwrapStorageValue(key, identity(id), payload)
39
+ try { return JSON.parse(plain.toString('utf8')) as string[][] } finally { plain.fill(0) }
40
+ }
41
+ export async function linkRevision(db: ManagedSql): Promise<string> {
42
+ const rows = await db.prepare('SELECT id, sha256 FROM managed_link_report WHERE enabled = 1 ORDER BY id').all()
43
+ return rows.length > 0 ? createHash('sha256').update(JSON.stringify(rows)).digest('base64url') : ''
44
+ }
45
+ function parseLinkReport(content: string) {
46
+ const parsed = parseLinkedFindings(content)
47
+ if (parsed) return parsed
48
+ // Older managed exports may carry the normalized links envelope.
49
+ try {
50
+ const data = JSON.parse(content)
51
+ if (data?.source !== 'links' || !Array.isArray(data.links) || !Array.isArray(data.findings) || data.findings.length > 0) return null
52
+ return data.links.length === 0 ? { groups: [], skipped: 0 } : parseLinkedFindings(JSON.stringify(data.links))
53
+ } catch { return null }
54
+ }
55
+ export function linkReportMethods(db: ManagedSql, key: StorageKey | null): LinkReportStore {
56
+ const map = (row: Omit<LinkReport, 'enabled'> & { enabled: number }): LinkReport => ({ ...row, enabled: row.enabled === 1 })
57
+ async function authorize(sessionId: string) {
58
+ const user = await db.prepare(`SELECT u.id, u.login, u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
59
+ WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, Date.now()) as { id: string; login: string; role: string } | undefined
60
+ if (!user) throw new ManagedMutationError(401, 'unauthenticated')
61
+ if (user.role !== 'admin') throw new ManagedMutationError(403, 'forbidden')
62
+ return user
63
+ }
64
+ return {
65
+ async listLegacyLinkReports() {
66
+ return await db.prepare("SELECT id, filename FROM managed_report WHERE analyzer = 'links' ORDER BY id").all() as { id: string; filename: string }[]
67
+ },
68
+ async migrateLinkReport(id, content) {
69
+ const row = await db.prepare(`SELECT filename, sha256, visible, uploaded_by AS uploadedBy,
70
+ uploaded_by_login AS uploadedByLogin, uploaded_at AS uploadedAt FROM managed_report WHERE id = ? AND analyzer = 'links'`).get(id) as {
71
+ filename: string; sha256: string; visible: number; uploadedBy: string | null; uploadedByLogin: string | null; uploadedAt: number
72
+ } | undefined
73
+ if (!row) return false
74
+ if (!key) throw new Error('Managed link reports require MANAGED_STORAGE_ENCRYPTION_KEY')
75
+ if (createHash('sha256').update(content).digest('base64url') !== row.sha256) throw new Error('Legacy link report content changed')
76
+ const parsed = parseLinkReport(content)
77
+ if (!parsed) throw new Error('Invalid legacy link report')
78
+ const plain = Buffer.from(JSON.stringify(parsed.groups))
79
+ try {
80
+ const sha256 = createHash('sha256').update(plain).digest('base64url')
81
+ await db.prepare(`INSERT INTO managed_link_report
82
+ (id, filename, sha256, encrypted_groups, enabled, group_count, finding_count, uploaded_by, uploaded_by_login, uploaded_at)
83
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(sha256) DO NOTHING`).run(
84
+ id, row.filename, sha256, wrapStorageValue(key, identity(id), plain), row.visible,
85
+ parsed.groups.length, new Set(parsed.groups.flat()).size, row.uploadedBy, row.uploadedByLogin, row.uploadedAt)
86
+ await db.prepare('DELETE FROM managed_report WHERE id = ?').run(id)
87
+ } finally { plain.fill(0) }
88
+ return true
89
+ },
90
+ async listLinkReports() {
91
+ return (await db.prepare(`SELECT ${columns} FROM managed_link_report ORDER BY uploaded_at DESC, id`).all() as Parameters<typeof map>[0][]).map(map)
92
+ },
93
+ async getLinkReport(sessionId, id) {
94
+ await authorize(sessionId)
95
+ const row = await db.prepare(`SELECT ${columns}, encrypted_groups AS payload FROM managed_link_report WHERE id = ?`).get(id) as (Parameters<typeof map>[0] & { payload: string }) | undefined
96
+ if (!row) throw new ManagedMutationError(404, 'no-link-report')
97
+ const { payload, ...report } = row
98
+ return { ...map(report), groups: decryptGroups(key, id, payload) }
99
+ },
100
+ getLinkRevision: () => linkRevision(db),
101
+ async getEnabledLinkGroups(revision) {
102
+ if (await linkRevision(db) !== revision) throw new ManagedMutationError(409, 'deduplication-changed')
103
+ const rows = await db.prepare('SELECT id, encrypted_groups AS payload FROM managed_link_report WHERE enabled = 1 ORDER BY id').all() as { id: string; payload: string }[]
104
+ if (rows.length > 0 && !key) throw new Error('Managed link reports require MANAGED_STORAGE_ENCRYPTION_KEY')
105
+ return rows.flatMap(row => decryptGroups(key, row.id, row.payload))
106
+ },
107
+ async importLinkReport(sessionId, filename, content) {
108
+ const user = await authorize(sessionId)
109
+ if (!key) throw new ManagedMutationError(503, 'storage-encryption-required')
110
+ const parsed = parseLinkReport(content)
111
+ if (!parsed || parsed.skipped) throw new ManagedMutationError(400, 'invalid-link-report')
112
+ const plain = Buffer.from(JSON.stringify(parsed.groups))
113
+ const sha256 = createHash('sha256').update(plain).digest('base64url')
114
+ try {
115
+ const existing = await db.prepare(`SELECT ${columns} FROM managed_link_report WHERE sha256 = ?`).get(sha256) as Parameters<typeof map>[0] | undefined
116
+ if (existing) return { report: map(existing), reused: true }
117
+ const id = randomUUID(), uploadedAt = Date.now()
118
+ const findingCount = new Set(parsed.groups.flat()).size, groupCount = parsed.groups.length
119
+ await db.prepare(`INSERT INTO managed_link_report
120
+ (id, filename, sha256, encrypted_groups, group_count, finding_count, uploaded_by, uploaded_by_login, uploaded_at)
121
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(id, filename, sha256, wrapStorageValue(key, identity(id), plain), groupCount, findingCount, user.id, user.login, uploadedAt)
122
+ return { report: { id, filename, enabled: true, groupCount, findingCount, uploadedByLogin: user.login, uploadedAt }, reused: false }
123
+ } finally { plain.fill(0) }
124
+ },
125
+ async setLinkReportEnabled(sessionId, id, enabled) {
126
+ await authorize(sessionId)
127
+ if (!(await db.prepare('UPDATE managed_link_report SET enabled = ? WHERE id = ?').run(+enabled, id)).changes) throw new ManagedMutationError(404, 'no-link-report')
128
+ },
129
+ }
130
+ }
131
+
132
+ // Merge complete components BEFORE projecting them onto the response. An ID
133
+ // absent from every returned report can still bridge two visible findings.
134
+ export function mergeLinkGroups(groups: readonly string[][], present?: ReadonlySet<string>): string[][] {
135
+ const parents = new Map<string, string>()
136
+ const root = (id: string): string => {
137
+ if (!parents.has(id)) parents.set(id, id)
138
+ let value = id
139
+ while (parents.get(value) !== value) {
140
+ parents.set(value, parents.get(parents.get(value)!)!)
141
+ value = parents.get(value)!
142
+ }
143
+ return value
144
+ }
145
+ for (const ids of groups) {
146
+ if (ids.length === 0) continue
147
+ const first = root(ids[0]!)
148
+ for (const id of ids.slice(1)) { const other = root(id); if (other !== first) parents.set(other, first) }
149
+ }
150
+ const merged = new Map<string, string[]>()
151
+ for (const id of parents.keys()) {
152
+ if (present && !present.has(id)) continue
153
+ const key = root(id)
154
+ const ids = merged.get(key) ?? []
155
+ ids.push(id); merged.set(key, ids)
156
+ }
157
+ return [...merged.values()].filter(ids => ids.length >= 2)
158
+ }
@@ -1,4 +1,6 @@
1
1
  import type { ManagedSql } from './sql.ts'
2
+ import type { TeamReportAccessSnapshot } from './db.ts'
3
+ import { parseGithubPrUrl } from '../common/github-pr.ts'
2
4
 
3
5
  // One immutable reference per finding, shared across reports and teams. Keep
4
6
  // reservations across crashes/uncertain GitHub responses to prevent duplicates.
@@ -10,6 +12,8 @@ CREATE TABLE IF NOT EXISTS managed_finding_issue (
10
12
  repository TEXT NOT NULL,
11
13
  request_id TEXT NOT NULL,
12
14
  issue_url TEXT,
15
+ auto_fix_url TEXT,
16
+ auto_fix_checked_at INTEGER,
13
17
  created_by TEXT REFERENCES managed_user(id) ON DELETE SET NULL,
14
18
  created_at INTEGER NOT NULL
15
19
  ) STRICT;
@@ -20,24 +24,48 @@ export interface ManagedIssue {
20
24
  repository: string
21
25
  requestId: string
22
26
  issueUrl: string | null
27
+ autoFixUrl: string | null
28
+ autoFixCheckedAt: number | null
23
29
  createdBy: string | null
24
30
  createdAt: number
25
31
  }
26
32
  export interface ManagedIssueStore {
27
33
  getManagedIssue(findingId: string): Promise<ManagedIssue | null>
28
- claimManagedIssue(issue: Omit<ManagedIssue, 'issueUrl'>): Promise<boolean>
34
+ listManagedIssues(findingIds: readonly string[]): Promise<ManagedIssue[]>
35
+ claimManagedIssue(issue: Omit<ManagedIssue, 'issueUrl' | 'autoFixUrl' | 'autoFixCheckedAt'>): Promise<boolean>
29
36
  finishManagedIssue(findingId: string, requestId: string, url: string): Promise<boolean>
30
37
  releaseManagedIssue(findingId: string, requestId: string): Promise<void>
38
+ setManagedIssueAutoFix(findingId: string, issueUrl: string, previous: string | null, next: string | null, checkedAt: number): Promise<boolean>
39
+ }
40
+
41
+ // A repeated finding ID never grants access to another team's repository.
42
+ export function visibleManagedIssues(issues: ManagedIssue[], snapshot: TeamReportAccessSnapshot) {
43
+ if (snapshot.user.id.startsWith('share:')) return []
44
+ const repos = new Set(snapshot.repositories.map(repo => repo.repoId))
45
+ const names = new Set(snapshot.repositories.map(repo => repo.github.toLowerCase()))
46
+ return issues.filter(issue => issue.issueUrl && repos.has(issue.repoId)).map(issue => {
47
+ const ref = parseGithubPrUrl(issue.autoFixUrl)
48
+ return { ...issue, autoFixUrl: ref && names.has(ref.repo.toLowerCase()) ? issue.autoFixUrl : null }
49
+ })
31
50
  }
32
51
  export function managedIssueMethods(db: ManagedSql): ManagedIssueStore {
33
- const get = db.prepare(`SELECT finding_id AS findingId, repo_id AS repoId, repository, request_id AS requestId,
34
- issue_url AS issueUrl, created_by AS createdBy, created_at AS createdAt FROM managed_finding_issue WHERE finding_id = ?`)
52
+ const fields = `finding_id AS findingId, repo_id AS repoId, repository, request_id AS requestId,
53
+ issue_url AS issueUrl, auto_fix_url AS autoFixUrl, auto_fix_checked_at AS autoFixCheckedAt, created_by AS createdBy, created_at AS createdAt`
54
+ const get = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id = ?`)
55
+ const list = db.prepare(`SELECT ${fields} FROM managed_finding_issue WHERE finding_id IN (SELECT value FROM json_each(?)) ORDER BY finding_id`)
56
+ const autoFix = db.prepare(`UPDATE managed_finding_issue SET auto_fix_url = ?, auto_fix_checked_at = ?
57
+ WHERE finding_id = ? AND issue_url = ? AND COALESCE(auto_fix_url, '') = COALESCE(?, '')
58
+ AND (auto_fix_checked_at IS NULL OR auto_fix_checked_at <= ?)`)
35
59
  const claim = db.prepare(`INSERT INTO managed_finding_issue (finding_id, repo_id, repository, request_id, created_by, created_at)
36
60
  VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (finding_id) DO NOTHING`)
37
61
  const finish = db.prepare(`UPDATE managed_finding_issue SET issue_url = ? WHERE finding_id = ? AND request_id = ? AND issue_url IS NULL`)
38
62
  const release = db.prepare(`DELETE FROM managed_finding_issue WHERE finding_id = ? AND request_id = ? AND issue_url IS NULL`)
39
63
  return {
40
64
  async getManagedIssue(findingId) { return (await get.get(findingId) as ManagedIssue | undefined) ?? null },
65
+ async listManagedIssues(ids) { return await list.all(JSON.stringify(ids)) as ManagedIssue[] },
66
+ async setManagedIssueAutoFix(findingId, issueUrl, previous, next, checkedAt) {
67
+ return Number((await autoFix.run(next, checkedAt, findingId, issueUrl, previous, checkedAt)).changes) > 0
68
+ },
41
69
  async claimManagedIssue(issue) {
42
70
  return Number((await claim.run(issue.findingId, issue.repoId, issue.repository, issue.requestId, issue.createdBy, issue.createdAt)).changes) > 0
43
71
  },
@@ -1,4 +1,4 @@
1
- import type { AdminReport, ManagedBundle, ManagedDb, ManagedRepo, RepoDataItem, RepoReportItem, ReportRecord, StoredUser } from './db-methods.ts'
1
+ import type { AdminReport, BundleReuse, ManagedBundle, ManagedDb, ManagedRepo, RepoDataItem, RepoReportItem, ReportRecord, StoredUser } from './db-methods.ts'
2
2
  import { roleAtLeast } from '../common/managed/roles.ts'
3
3
 
4
4
  export class ManagedMutationError extends Error {
@@ -6,20 +6,23 @@ export class ManagedMutationError extends Error {
6
6
  constructor(status: number, message: string) { super(message); this.status = status }
7
7
  }
8
8
 
9
- export type ContentMutation = { type: 'delete' } | { type: 'repo'; repoId: number | null; directory: string }
10
- export type ReportMutation = ContentMutation | { type: 'visibility'; visible: boolean }
9
+ export type ContentMutation = { type: 'delete' } | { type: 'repo'; repoId: number | null; directory: string } | { type: 'visibility'; visible: boolean }
10
+ export type ReportMutation = ContentMutation
11
11
  type RemovalRepo = Pick<ManagedRepo, 'repoId' | 'fullName' | 'addedAt'>
12
12
  type RemovalAnnotations = { reports: string; ids: string[] }
13
13
  export interface ManagementStore {
14
14
  mutateReport(sessionId: string, id: string, change: ReportMutation): Promise<{ report: ReportRecord; user: StoredUser }>
15
15
  mutateBundle(sessionId: string, id: string, change: ContentMutation): Promise<{ bundle: ManagedBundle; user: StoredUser }>
16
+ // A repeated upload/build of stored bytes (see reuseBundle). Renames need the
17
+ // access other bundle mutations need; a server build labels the row anyway.
18
+ reuseBundleUpload(sessionId: string, id: string, reuse: Omit<BundleReuse, 'rename'>): Promise<boolean>
16
19
  removeRepository(sessionId: string, expected: RemovalRepo, annotations: RemovalAnnotations | null): Promise<{
17
20
  reports: RepoReportItem[]; bundles: RepoDataItem[]; deletedReports: number; deletedBundles: number; deletedTriage: number
18
21
  }>
19
22
  }
20
23
  type ManagementDb = Pick<ManagedDb, 'sessionWithUser' | 'getReport' | 'getBundle' | 'listReports' | 'listReportsForRepo'
21
24
  | 'listBundlesForRepo' | 'listAllRepos' | 'listSelectedRepos' | 'userCanReadReport' | 'userCanReadBundle' | 'userCanReadRepoPath'
22
- | 'deleteReport' | 'setReportRepo' | 'setReportVisible' | 'deleteBundle' | 'setBundleRepo'
25
+ | 'deleteReport' | 'setReportRepo' | 'setReportVisible' | 'deleteBundle' | 'setBundleRepo' | 'setBundleVisible' | 'reuseBundle'
23
26
  | 'deleteReportsForRepo' | 'deleteBundlesForRepo' | 'deleteRepo' | 'deleteTriage' | 'recordActivity'>
24
27
 
25
28
  // Only immutable bytes/format and repository membership affect the overlap
@@ -63,9 +66,19 @@ export function managementMethods(db: ManagementDb): ManagementStore {
63
66
  if (user.role !== 'admin' && bundle.repoId !== null && !await db.userCanReadRepoPath(user.id, bundle.repoId, bundle.repoDirectory)) throw new ManagedMutationError(403, 'repo-forbidden')
64
67
  await destination(user, change)
65
68
  if (change.type === 'delete') await db.deleteBundle(id)
69
+ else if (change.type === 'visibility') await db.setBundleVisible(id, change.visible)
66
70
  else await db.setBundleRepo(id, change.repoId, change.directory)
67
71
  return { bundle, user }
68
72
  },
73
+ async reuseBundleUpload(sessionId: string, id: string, reuse: Omit<BundleReuse, 'rename'>) {
74
+ const user = await authorize(sessionId, 'manage')
75
+ const bundle = await db.getBundle(id)
76
+ if (!bundle) return false
77
+ const rename = user.role === 'admin' || (await db.userCanReadBundle(user.id, id)
78
+ && (bundle.repoId === null || await db.userCanReadRepoPath(user.id, bundle.repoId, bundle.repoDirectory)))
79
+ if (!rename && reuse.provenance !== 'build') return false
80
+ return db.reuseBundle(id, { ...reuse, rename })
81
+ },
69
82
  async removeRepository(sessionId: string, expected: RemovalRepo, annotations: RemovalAnnotations | null) {
70
83
  const user = await authorize(sessionId, 'admin')
71
84
  const repo = (await db.listAllRepos()).find(r => r.repoId === expected.repoId)
@@ -12,6 +12,7 @@ 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
14
  import { ADVISORIES_TIMEOUT_MS, fetchBundleAdvisories } from './bundle-advisories.ts'
15
+ import { auditCache } from './upstream-cache.ts'
15
16
  import { serveTeamFeed } from './team-feed.ts'
16
17
 
17
18
  function json(res: ServerResponse, status: number, body: unknown): void {
@@ -114,8 +115,10 @@ async function serveBundle(res: ServerResponse, deps: ManagedHttpDeps, bundle: M
114
115
  if (inventory === null) { json(res, 413, { error: 'payload-too-large' }); return }
115
116
  if (inventory === undefined) { json(res, 400, { error: 'unknown-reason' }); return }
116
117
  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',
118
+ const signal = AbortSignal.timeout(ADVISORIES_TIMEOUT_MS)
119
+ const result = await fetchBundleAdvisories(inventory.packages, signal, {
120
+ debug: deps.config.debug, repoAdvisories: url.searchParams.get('repoAdvisories') === 'true', details: url.searchParams.get('details') === 'true',
121
+ cache: auditCache(deps.config.upstreamCacheDir, deps.db, signal, deps.config.debug),
119
122
  })
120
123
  await recheck()
121
124
  json(res, result.status, result.status === 200 ? { ...inventory, advisories: result.body } : result.body); return
@@ -3,8 +3,8 @@ import { createHash } from 'node:crypto'
3
3
  import { extname } from 'node:path'
4
4
  import { promisify } from 'node:util'
5
5
  import { gzip } from 'node:zlib'
6
- import { bundleSourcesAsMap } from '../common/bundle-sources.js'
7
- import { bundleSourceImports } from '../common/bundle-source-links.js'
6
+ import { bundlePackageDirs, bundleSourcesAsMap } from '../common/bundle-sources.js'
7
+ import { bundleSourceLinkImports } from '../common/bundle-source-links.js'
8
8
  import type { BundleDetails } from '../common/bundle-metadata.js'
9
9
  import { loadManagedFindings, managedFindingSourcePaths } from '../common/managed/report-content.ts'
10
10
  import { type ViewerPermissions, filterReportContent } from '../common/managed/report-filter.ts'
@@ -29,7 +29,7 @@ function formatDirectory(bundleId: string, sha256: string, name: string) {
29
29
  }
30
30
  function sourceCacheFilename(report: ReportRecord, bundle: ManagedBundle, permissions: ViewerPermissions, repo: { github: string | null }, sourcePaths?: Set<string>) {
31
31
  const key = createHash('sha256').update(JSON.stringify([
32
- 'finding-access-v6', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
32
+ 'finding-access-v8', sourcePaths ? [...sourcePaths].toSorted() : null, bundle.integrity, bundle.kind, permissions.dependencies, permissions.security, repo,
33
33
  ])).digest('hex')
34
34
  return `${formatDirectory(bundle.id, report.sha256, report.filename)}/${key}.json.gz`
35
35
  }
@@ -63,10 +63,11 @@ function selectSources(sourcePaths: Set<string>, sources: Map<string, string>) {
63
63
 
64
64
  function encodeSources(integrity: string, details: BundleDetails, selection: ReturnType<typeof selectSources>) {
65
65
  const sources = new Map(selection.files)
66
- const imports = [...bundleSourceImports(details, sources)].map(([parent, targets]) => [parent, [...targets]])
66
+ const imports = [...bundleSourceLinkImports(details, sources)].map(([parent, targets]) => [parent, [...targets]])
67
67
  const bundle = details.kind === 'stasis' ? details.bundle as { formats: Map<string, string> } : null
68
68
  const formats = [...(bundle?.formats ?? [])].filter(([file]) => sources.has(file))
69
- return compress(Buffer.from(JSON.stringify({ integrity, ...selection, imports, formats })), { level: 6 })
69
+ const packageDirs = [...(bundlePackageDirs(details) ?? [])].filter(([file]) => sources.has(file))
70
+ return compress(Buffer.from(JSON.stringify({ integrity, ...selection, imports, formats, packageDirs })), { level: 6 })
70
71
  }
71
72
 
72
73
  // Immutable report hashes share a derivative across duplicate uploads. Bundle
@@ -0,0 +1,91 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { reportRepoGithub } from '@preventive/report'
3
+ import { type RepositoryAliasInput, matchRepositoryAlias, repositoryAliasNeedsFilePrefix } from '../common/managed/repository-alias.ts'
4
+ import type { ManagedDb } from './db.ts'
5
+ import { ManagedMutationError } from './management.ts'
6
+ import { normalizeTeamPath } from './repo-path.ts'
7
+ import type { ManagedSql } from './sql.ts'
8
+
9
+ export const REPOSITORY_ALIAS_SCHEMA = `
10
+ CREATE TABLE IF NOT EXISTS managed_repository_alias (
11
+ id TEXT PRIMARY KEY,
12
+ old_repo TEXT NOT NULL,
13
+ old_path TEXT NOT NULL DEFAULT '',
14
+ repo_id INTEGER NOT NULL REFERENCES managed_selected_repo(repo_id) ON DELETE CASCADE,
15
+ new_path TEXT NOT NULL DEFAULT '',
16
+ UNIQUE(old_repo, old_path)
17
+ ) STRICT;
18
+ `
19
+ export interface RepositoryAlias extends RepositoryAliasInput { id: string }
20
+ export interface RepositoryAliasStore {
21
+ listRepositoryAliases(): Promise<RepositoryAlias[]>
22
+ saveRepositoryAlias(sessionId: string, id: string | null, input: unknown): Promise<RepositoryAlias>
23
+ deleteRepositoryAlias(sessionId: string, id: string): Promise<void>
24
+ getRepositoryImportLocation(github: string, directory: string, filePrefix?: string): Promise<{ repoId: number | null; directory: string }>
25
+ }
26
+
27
+ // Keep expensive inventory reads outside database transactions and skip them
28
+ // unless a currently connected alias can use the bundle's existing paths.
29
+ export async function resolveRepositoryImportLocation(db: ManagedDb, github: string, directory: string, loadFilePrefix: () => Promise<string>) {
30
+ const aliases = await db.listRepositoryAliases()
31
+ let filePrefix = ''
32
+ if (repositoryAliasNeedsFilePrefix(github, directory, aliases)) {
33
+ const active = new Set((await db.listSelectedRepos()).map(repo => repo.repoId))
34
+ if (repositoryAliasNeedsFilePrefix(github, directory, aliases.filter(alias => active.has(alias.repoId)))) filePrefix = await loadFilePrefix()
35
+ }
36
+ return db.getRepositoryImportLocation(github, directory, filePrefix)
37
+ }
38
+
39
+ function parseAlias(input: unknown): RepositoryAliasInput {
40
+ if (!input || typeof input !== 'object') throw new ManagedMutationError(400, 'bad-alias')
41
+ const { oldRepo, oldPath, repoId, newPath } = input as RepositoryAliasInput
42
+ const github = typeof oldRepo === 'string' ? reportRepoGithub({ repo: { github: oldRepo.trim() } }) : null
43
+ if (!github) throw new ManagedMutationError(400, 'bad-old-repo')
44
+ if (typeof oldPath !== 'string' || typeof newPath !== 'string') throw new ManagedMutationError(400, 'bad-directory')
45
+ const old = normalizeTeamPath(oldPath), target = normalizeTeamPath(newPath)
46
+ if (!old.ok || !target.ok) throw new ManagedMutationError(400, 'bad-directory')
47
+ if (!Number.isSafeInteger(repoId) || repoId <= 0) throw new ManagedMutationError(400, 'bad-repo')
48
+ return { oldRepo: github.toLowerCase(), oldPath: old.path ?? '', repoId, newPath: target.path ?? '' }
49
+ }
50
+
51
+ export function repositoryAliasMethods(db: ManagedSql): RepositoryAliasStore {
52
+ async function authorize(sessionId: string) {
53
+ const user = await db.prepare(`SELECT u.role FROM managed_session s JOIN managed_user u ON u.id = s.user_id
54
+ WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, Date.now()) as { role: string } | undefined
55
+ if (!user) throw new ManagedMutationError(401, 'unauthenticated')
56
+ if (user.role !== 'admin') throw new ManagedMutationError(403, 'forbidden')
57
+ }
58
+ async function listRepositoryAliases() {
59
+ return await db.prepare(`SELECT id, old_repo AS oldRepo, old_path AS oldPath, repo_id AS repoId, new_path AS newPath
60
+ FROM managed_repository_alias ORDER BY old_repo, old_path, id`).all() as RepositoryAlias[]
61
+ }
62
+ return {
63
+ listRepositoryAliases,
64
+ async saveRepositoryAlias(sessionId, id, input) {
65
+ await authorize(sessionId)
66
+ const alias = parseAlias(input)
67
+ if (!await db.prepare('SELECT repo_id FROM managed_selected_repo WHERE repo_id = ? AND active = 1').get(alias.repoId)) throw new ManagedMutationError(400, 'bad-repo')
68
+ if (id && !await db.prepare('SELECT id FROM managed_repository_alias WHERE id = ?').get(id)) throw new ManagedMutationError(404, 'no-alias')
69
+ const duplicate = await db.prepare('SELECT id FROM managed_repository_alias WHERE old_repo = ? AND old_path = ?').get(alias.oldRepo, alias.oldPath) as { id: string } | undefined
70
+ if (duplicate && duplicate.id !== id) throw new ManagedMutationError(409, 'alias-exists')
71
+ const key = id ?? randomUUID()
72
+ await db.prepare(`INSERT INTO managed_repository_alias (id, old_repo, old_path, repo_id, new_path) VALUES (?, ?, ?, ?, ?)
73
+ ON CONFLICT(id) DO UPDATE SET old_repo = excluded.old_repo, old_path = excluded.old_path, repo_id = excluded.repo_id, new_path = excluded.new_path`)
74
+ .run(key, alias.oldRepo, alias.oldPath, alias.repoId, alias.newPath)
75
+ return { id: key, ...alias }
76
+ },
77
+ async deleteRepositoryAlias(sessionId, id) {
78
+ await authorize(sessionId)
79
+ if (!(await db.prepare('DELETE FROM managed_repository_alias WHERE id = ?').run(id)).changes) throw new ManagedMutationError(404, 'no-alias')
80
+ },
81
+ async getRepositoryImportLocation(github, directory, filePrefix = '') {
82
+ const alias = matchRepositoryAlias(github, directory, await listRepositoryAliases(), filePrefix)
83
+ const normalized = normalizeTeamPath(alias?.directory ?? directory)
84
+ if (!normalized.ok) throw new ManagedMutationError(400, 'bad-directory')
85
+ const repo = await (alias
86
+ ? db.prepare('SELECT repo_id AS repoId FROM managed_selected_repo WHERE active = 1 AND repo_id = ?').get(alias.repoId)
87
+ : db.prepare('SELECT repo_id AS repoId FROM managed_selected_repo WHERE active = 1 AND LOWER(full_name) = ?').get(github.toLowerCase())) as { repoId: number } | undefined
88
+ return { repoId: repo?.repoId ?? null, directory: normalized.path ?? '' }
89
+ },
90
+ }
91
+ }
@@ -1,7 +1,7 @@
1
1
  // Internal invalidation counters. Never expose these global values to clients:
2
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']
3
+ const catalogTables = ['managed_link_report', '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', 'managed_finding_issue']
5
5
  export function revisionSchema(postgres = false): string {
6
6
  let sql = `CREATE TABLE IF NOT EXISTS managed_maintenance_lease (
7
7
  id INTEGER PRIMARY KEY, owner TEXT NOT NULL, expires_at INTEGER NOT NULL
@@ -18,10 +18,11 @@ export interface ManagedSqlDriver extends ManagedSql {
18
18
 
19
19
  // These operations execute exactly one SQL statement. Postgres already gives
20
20
  // that statement an atomic snapshot; an explicit BEGIN/COMMIT adds only trips.
21
- // Lease writes coordinate through their conditional UPSERT/UPDATE alone.
21
+ // Lease and cache writes coordinate through their conditional UPSERT/UPDATE alone.
22
22
  const SINGLE_STATEMENTS = new Set(['getReport', 'getBundle', 'getStorageRow', 'getStorageEncryption',
23
23
  'listReports', 'listBundles', 'listReadableBundleIds', 'listAllRepos', 'listSelectedRepos', 'listRepoScopesForUser', 'listTriage',
24
- 'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease'])
24
+ 'getFeedState', 'getWorkspaceShareFeedState', 'claimMaintenanceLease', 'finishMaintenanceLease', 'cacheRepoDefaultBranch', 'releaseBundleBuildLease',
25
+ 'getUpstreamCacheEntry', 'setUpstreamCacheEntry'])
25
26
 
26
27
  export function scopeManagedMethods(methods: ManagedDb, driver: ManagedSqlDriver): ManagedDb {
27
28
  const entries = Object.entries(methods).map(([name, method]) => {
@@ -34,6 +34,23 @@ export async function openManagedStorage(config: ManagedConfig) {
34
34
  }
35
35
  const objects = await createEncryptedObjectStorage(raw, db, key)
36
36
  const storage = createManagedStores(objects, !config.neonUrl)
37
+ // Move legacy team link uploads into encrypted SQL before serving catalogs.
38
+ // The DB transaction commits first; a failed blob cleanup cannot lose links.
39
+ for (const report of await db.listLegacyLinkReports()) {
40
+ if (!key) throw new Error('Migrating managed link reports requires MANAGED_STORAGE_ENCRYPTION_KEY')
41
+ let bytes
42
+ try { bytes = await storage.reportStore.get(report.id) } catch (error) {
43
+ if (!await db.getReport(report.id)) continue // another instance completed migration
44
+ throw error
45
+ }
46
+ if (!bytes) {
47
+ if (!await db.getReport(report.id)) continue
48
+ throw new Error(`Missing legacy link report: ${report.id}`)
49
+ }
50
+ if (await db.migrateLinkReport(report.id, bytes.toString('utf8'))) {
51
+ await storage.reportStore.delete(report.id).catch(error => console.warn('managed: legacy link cleanup failed:', error))
52
+ }
53
+ }
37
54
  return { ...storage, db, uploadStore: config.neonUrl ? storage.uploadStore : undefined,
38
55
  bundleCache: createBundleCache(storage.cacheStorage, db, storage.bundleStore),
39
56
  reportSourcesCache: createReportSourcesCache(storage.reportSourcesStorage, db, storage.reportStore, storage.bundleStore),
@@ -47,7 +47,7 @@ export async function serveTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
47
47
  // the focused team needs report parsing or annotation reads; null is the
48
48
  // catalog-only subscription used on landing and management pages.
49
49
  export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDeps,
50
- sessionId: string, user: StoredUser, teamId: string | null, options: FeedOptions = {}): Promise<void> {
50
+ sessionId: string, user: StoredUser, teamId: string | null, options: FeedOptions & { reportId?: string | null } = {}): Promise<void> {
51
51
  let ids: string[] = [], visibilityKey: string | undefined
52
52
  let focusedKey: string | undefined
53
53
  let previousState: { catalog: number; annotations: number } | undefined
@@ -86,7 +86,7 @@ export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDe
86
86
  return
87
87
  }
88
88
  let triage: string | undefined
89
- const snapshot = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
89
+ const snapshot = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, options.reportId)
90
90
  if (!snapshot) throw new TeamReportsError(401, 'unauthenticated')
91
91
  checkUser(snapshot)
92
92
  const key = teamSnapshotKey(snapshot)
@@ -102,7 +102,7 @@ export async function serveUserTeamFeed(res: ServerResponse, deps: ManagedHttpDe
102
102
  }
103
103
  }
104
104
  if (snapshot.teamId) triage = JSON.stringify([key, await deps.db.getAnnotationRevision(ids)])
105
- const current = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId)
105
+ const current = await deps.db.getTeamReportAccessSnapshot(sessionId, Date.now(), teamId, options.reportId)
106
106
  // A concurrent access/content change discards only the annotation read.
107
107
  checkUser(current)
108
108
  if (!current || teamSnapshotKey(current) !== key) return