@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,10 +1,11 @@
1
1
  import { Buffer } from 'node:buffer'
2
2
  import { setImmediate } from 'node:timers/promises'
3
- import { BUNDLE_METADATA_VERSION } from '../common/bundle-metadata.js'
3
+ import { CacheMissError } from './cache-storage.ts'
4
4
  import { decodeUtf8 } from '../common/utf8.js'
5
5
  import type { BundleCacheRecord, BundleCacheStorage, BundleSummary } from './bundle-cache.ts'
6
6
 
7
- export const SUMMARY_FILENAME = `v${BUNDLE_METADATA_VERSION}-summary.json`
7
+ // Origin metadata does not change these counts; retain the byte-sized v2 cache.
8
+ export const SUMMARY_FILENAME = 'v2-summary.json'
8
9
  const RETRY_MS = 5 * 60_000
9
10
  const BACKFILL_LIMIT = 4
10
11
  type CachedSummary = BundleSummary | { retryAt: number }
@@ -30,19 +31,33 @@ async function readSummary(storage: BundleCacheStorage, id: string): Promise<Cac
30
31
 
31
32
  export function createBundleSummaryCache(storage: BundleCacheStorage, build: (record: BundleCacheRecord) => Promise<BundleSummary>, exists: (id: string) => Promise<unknown>) {
32
33
  const entries = new Map<string, { id: string; value: CachedSummary }>()
34
+ const reads = new Map<string, { id: string; job: Promise<CachedSummary | null> }>()
33
35
  const active = new Map<string, Promise<void>>()
34
36
  let pending: Promise<void> | null = null
35
37
  function remember(record: BundleCacheRecord, value: CachedSummary) {
36
- if (entries.size >= 256) entries.delete(entries.keys().next().value!)
38
+ // A completed build supersedes any older storage read still in flight.
39
+ reads.delete(record.integrity)
40
+ // Summaries are tiny and immutable by integrity. Refuse overflow admission
41
+ // instead of evicting the next hit during a scan larger than the cache.
42
+ if (entries.size >= 4096 && !entries.has(record.integrity)) return
37
43
  entries.set(record.integrity, { id: record.id, value })
38
44
  }
39
- async function read(record: BundleCacheRecord) {
45
+ function read(record: BundleCacheRecord): Promise<CachedSummary | null> {
40
46
  const known = entries.get(record.integrity)
41
- if (known) return known.value
42
- if (!await storage.exists(record.id, SUMMARY_FILENAME)) return null
43
- const value = await readSummary(storage, record.id)
44
- remember(record, value)
45
- return value
47
+ if (known) return Promise.resolve(known.value)
48
+ const existing = reads.get(record.integrity)
49
+ if (existing) return existing.job
50
+ // Concurrent catalogs share the decoded summary, never the response stream.
51
+ // Only in-flight work is shared: misses and failures remain retryable.
52
+ const job: Promise<CachedSummary | null> = readSummary(storage, record.id).then(value => {
53
+ if (reads.get(record.integrity)?.job === job) remember(record, value)
54
+ return entries.get(record.integrity)?.value ?? value
55
+ }).catch(error => {
56
+ if (error instanceof CacheMissError) return null
57
+ throw error
58
+ }).finally(() => { if (reads.get(record.integrity)?.job === job) reads.delete(record.integrity) })
59
+ reads.set(record.integrity, { id: record.id, job })
60
+ return job
46
61
  }
47
62
  async function publish(record: BundleCacheRecord, value: CachedSummary) {
48
63
  if (!await exists(record.id)) return
@@ -92,6 +107,9 @@ export function createBundleSummaryCache(storage: BundleCacheStorage, build: (re
92
107
  // backfill elsewhere must not delay removing these bytes or responding.
93
108
  await active.get(id)
94
109
  for (const [hash, entry] of entries) if (entry.id === id) entries.delete(hash)
110
+ // Do not wait for remote reads during deletion. Their callers still own
111
+ // and drain them, but a late response must not repopulate this cache.
112
+ for (const [hash, entry] of reads) if (entry.id === id) reads.delete(hash)
95
113
  },
96
114
  }
97
115
  }
@@ -7,6 +7,8 @@
7
7
  // report auto-links to its bundle.
8
8
  import { createHash } from 'node:crypto'
9
9
  import type { Buffer } from 'node:buffer'
10
+ import { StringDecoder } from 'node:string_decoder'
11
+ import { createBrotliDecompress } from 'node:zlib'
10
12
 
11
13
  // `sha512-<base64>` identity for a bundle's bytes. MUST stay byte-identical to
12
14
  // the client's common/integrity.js (SHA-512 → standard base64 WITH padding) so
@@ -21,12 +23,75 @@ export function bundleIntegrity(bytes: Buffer): string {
21
23
  // `bundleKind`: sourcemap (.map) or stasis (stasis.code.br / .stasis.code.br).
22
24
  // null for anything else (stored anyway; the kind is informational).
23
25
  export function bundleKind(filename: string): 'sourcemap' | 'stasis' | null {
24
- const lower = filename.toLowerCase()
26
+ // Browsers insert a duplicate-download counter before the final extension.
27
+ const lower = filename.toLowerCase().replace(/ \(\d+\)(?=\.[^.]*$)/u, '')
25
28
  if (lower.endsWith('.map')) return 'sourcemap'
26
29
  if (lower === 'stasis.code.br' || lower.endsWith('.stasis.code.br')) return 'stasis'
27
30
  return null
28
31
  }
29
32
 
33
+ interface BundleRepo { github?: unknown; directory?: unknown }
34
+ const HEADER_KEYS = new Set(['version', 'config', 'repo', 'package'])
35
+ const MAX_HEADER_BYTES = 64 * 1024
36
+
37
+ // Stasis writes origin metadata before entries, formats and source bodies.
38
+ // undefined means another chunk is needed; null means the header has no repo.
39
+ function headerRepo(text: string): BundleRepo | null | undefined {
40
+ let depth = 0, escaped = false, expectKey = false, quoted = false
41
+ let key = '', start = 0, valueStart = 0
42
+ for (let i = 0; i < text.length; i++) {
43
+ const c = text[i]
44
+ if (quoted) {
45
+ if (escaped) { escaped = false; continue }
46
+ if (c === '\\') { escaped = true; continue }
47
+ if (c !== '"') continue
48
+ quoted = false
49
+ if (depth === 1 && expectKey) {
50
+ key = JSON.parse(text.slice(start, i + 1)) as string
51
+ if (!HEADER_KEYS.has(key)) return null
52
+ expectKey = false
53
+ }
54
+ continue
55
+ }
56
+ if (c === '"') { quoted = true; start = i; continue }
57
+ if (c === '{' || c === '[') {
58
+ if (depth === 0 && c !== '{') return null
59
+ depth++
60
+ if (depth === 1) expectKey = true
61
+ } else if (depth === 1 && (c === ',' || c === '}')) {
62
+ if (key === 'repo') {
63
+ const repo: unknown = JSON.parse(text.slice(valueStart, i))
64
+ return repo != null && typeof repo === 'object' && !Array.isArray(repo) ? repo as BundleRepo : null
65
+ }
66
+ if (c === '}') return null
67
+ expectKey = true
68
+ } else if (c === '}' || c === ']') depth--
69
+ else if (depth === 1 && c === ':') valueStart = i + 1
70
+ else if (depth === 0 && c?.trim()) return null
71
+ }
72
+ return undefined
73
+ }
74
+
75
+ // Decode only a bounded prefix, stopping as soon as repo is read or the
76
+ // header ends. Unknown/malformed archives remain uploadable as opaque bytes.
77
+ export async function bundleRepo(bytes: Buffer): Promise<BundleRepo | null> {
78
+ const stream = createBrotliDecompress({ chunkSize: 4096 })
79
+ const decoder = new StringDecoder('utf8')
80
+ let size = 0, text = ''
81
+ stream.end(bytes)
82
+ try {
83
+ for await (const chunk of stream) {
84
+ size += chunk.length
85
+ if (size > MAX_HEADER_BYTES) return null
86
+ text += decoder.write(chunk)
87
+ const repo = headerRepo(text)
88
+ if (repo !== undefined) return repo
89
+ }
90
+ return null
91
+ } catch { return null }
92
+ finally { stream.destroy() }
93
+ }
94
+
30
95
  // Extract the bundle integrities a report declares (its top-level
31
96
  // `bundleHashes`). Non-JSON reports (markdown / CSV) or a missing field → [].
32
97
  export function reportBundleHashes(bytes: Buffer): string[] {
@@ -1,6 +1,10 @@
1
1
  import type { Buffer } from 'node:buffer'
2
2
  import type { OpenedBlob } from './blob-store.ts'
3
3
 
4
+ export class CacheMissError extends Error {
5
+ constructor() { super('Managed cache unavailable') }
6
+ }
7
+
4
8
  // Keys are internal relative paths; delete removes a whole directory of variants.
5
9
  export interface CacheStorage {
6
10
  exists(key: string): Promise<boolean>
@@ -6,7 +6,7 @@ type Content = { repoId: number | null; repoDirectory?: string }
6
6
  // Read grants for each request: neither the manager role nor a cached page
7
7
  // confers repository access. Bundles have repository scope; reports also have
8
8
  // a directory scope. Unassigned content is only available to administrators.
9
- export async function contentAccess(db: ManagedDb, user: StoredUser) {
9
+ export async function contentAccess(db: Pick<ManagedDb, 'listRepoScopesForUser'>, user: StoredUser) {
10
10
  const scopes = user.role === 'admin' ? null : await db.listRepoScopesForUser(user.id)
11
11
  const bundle = (item: Content) => scopes == null || scopes.some(scope => scope.repoId === item.repoId)
12
12
  const report = (item: Content) => scopes == null || scopes.some(scope => scope.repoId === item.repoId
@@ -8,10 +8,12 @@ import { type CommentStore, commentMethods } from './comments.ts'
8
8
  import { type ActivityStore, activityMethods } from './activity.ts'
9
9
  import { type GithubMetadataStore, githubMetadataMethods } from './github-metadata.ts'
10
10
  import { type ManagedIssueStore, managedIssueMethods } from './managed-issues.ts'
11
+ import { type BundleBuildLeaseStore, bundleBuildLeaseMethods } from './bundle-build-leases.ts'
11
12
  import { type ImportTriageStore, importTriageMethods } from './import-triage.ts'
12
13
  import type { ManagedSql } from './sql.ts'
13
14
  import { type WorkspaceShareStore, workspaceShareMethods } from './workspace-shares.ts'
14
15
  import { ManagedMutationError, type ManagementStore, managementMethods } from './management.ts'
16
+ import { type ManagementCatalogStore, managementCatalogMethods } from './management-catalog.ts'
15
17
  import { type StorageKey } from '../server-common/storage-crypto.ts'
16
18
  import { type StorageDb, type StoredTokens, decodeStorageTokens, encodeStorageTokens, storageMethods, storageState, validateStorageUpload } from './storage-db.ts'
17
19
 
@@ -72,6 +74,9 @@ export interface SelectedRepo {
72
74
  private: boolean
73
75
  installationId: number | null
74
76
  defaultBranch: string
77
+ // Last live default observed by the repository browser, independent of the
78
+ // selection/discovery snapshot above. NULL means unknown or unavailable.
79
+ cachedDefaultBranch: string | null
75
80
  htmlUrl: string
76
81
  addedBy: string | null
77
82
  addedAt: number
@@ -86,7 +91,7 @@ export interface ManagedRepo extends SelectedRepo {
86
91
 
87
92
  // What a caller supplies to select (upsert) a repo; the store stamps the
88
93
  // timestamps.
89
- export type SelectedRepoInput = Omit<SelectedRepo, 'addedAt'>
94
+ export type SelectedRepoInput = Omit<SelectedRepo, 'addedAt' | 'cachedDefaultBranch'>
90
95
 
91
96
  // A stored report's metadata. The bytes live in the blob-store keyed by `id`;
92
97
  // `contentType` + `filename` ride here so a download can label them, `sha256`
@@ -313,7 +318,12 @@ export interface UserTeam {
313
318
  }
314
319
 
315
320
  // Backend-agnostic store surface (SQLite + PostgreSQL implementations).
316
- export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, StorageDb {
321
+ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, BundleBuildLeaseStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, ManagementCatalogStore, StorageDb {
322
+ claimMaintenanceLease(owner: string, now: number, until: number, migration?: boolean): Promise<boolean>
323
+ finishMaintenanceLease(owner: string, until: number): Promise<void>
324
+ getFeedState(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; catalog: number; annotations: number } | null>
325
+ // Reuse connections within a finite request or one feed polling iteration.
326
+ withRequest?<T>(work: () => Promise<T>): Promise<T>
317
327
  // Upsert the identity; returns the user's opaque id (stable across logins).
318
328
  // Initial-admin approval comes only from trusted login configuration. It
319
329
  // promotes a matching No access identity only while it is the sole user.
@@ -333,6 +343,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
333
343
  // mutable context while keeping the original added_by/added_at; deselectRepo
334
344
  // resolves true iff a row was removed.
335
345
  selectRepo(repo: SelectedRepoInput, now: number): Promise<void>
346
+ cacheRepoDefaultBranch(repo: SelectedRepo, branch: string | null): Promise<boolean>
336
347
  connectRepoInstallation(repo: SelectedRepo, installationId: number, sessionId: string, now: number): Promise<boolean>
337
348
  deselectRepo(repoId: number): Promise<boolean>
338
349
  listSelectedRepos(): Promise<SelectedRepo[]>
@@ -383,6 +394,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
383
394
  // stamp nor the trail. Every change also appends to managed_finding_triage_event;
384
395
  // listTriageHistory walks one finding's trail, newest first.
385
396
  listTriage(findingIds: readonly string[]): Promise<TriageRow[]>
397
+ getAnnotations(findingIds: readonly string[]): Promise<{ triage: TriageRow[]; comments: Awaited<ReturnType<CommentStore['listComments']>> }>
386
398
  // A content-free fingerprint of current annotations, read in one snapshot.
387
399
  getAnnotationRevision(findingIds: readonly string[]): Promise<string>
388
400
  setTriage(findingId: string, entry: TriageEntryPatch | null, updatedBy: string | null, updatedByLogin: string | null, now: number): Promise<void>
@@ -399,7 +411,13 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
399
411
  getBundleByIntegrity(integrity: string): Promise<ManagedBundle | null>
400
412
  resolveBundleUpload(integrity: string): Promise<ManagedBundle | null>
401
413
  getBundle(id: string): Promise<ManagedBundle | null>
414
+ // One read snapshot of the live session, bundle and grants. Passing a team
415
+ // (or null for any team) also checks advisory security access; omission skips it.
416
+ getBundleAccessSnapshot(sessionId: string, now: number, id: string, advisoriesTeamId?: string | null): Promise<{
417
+ bundle: ManagedBundle | null; canReadAdvisories: boolean
418
+ } | null>
402
419
  listBundles(userId?: string): Promise<AdminBundle[]>
420
+ listReadableBundleIds(userId: string, ids: readonly string[]): Promise<string[]>
403
421
  userCanReadBundle(userId: string, id: string): Promise<boolean>
404
422
  userCanReadRepo(userId: string, repoId: number): Promise<boolean>
405
423
  userCanReadBundleAdvisories(userId: string, bundleId: string, teamId: string | null): Promise<boolean>
@@ -430,7 +448,7 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
430
448
  // bundles attached to that team's repos — for that user's own sidebar Teams section.
431
449
  // Any user; only their own memberships.
432
450
  listTeamsForUser(userId: string): Promise<UserTeam[]>
433
- getUserTeamFeedSnapshot(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; revision: string } | null>
451
+ getUserTeamFeedSnapshot(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; revision: string; teams: UserTeam[]; catalog: number } | null>
434
452
  // Whether `userId` may read `reportId`: true iff the report's repo belongs to
435
453
  // a team the user is a member of. Backs the team-scoped report view endpoint.
436
454
  userCanReadReport(userId: string, reportId: string): Promise<boolean>
@@ -528,6 +546,8 @@ function prepareStatements(db: ManagedSql) {
528
546
  WHERE repo_id = ? AND full_name = ? AND added_at = ? AND installation_id IS NULL
529
547
  AND EXISTS (SELECT 1 FROM managed_session s JOIN managed_user u ON u.id = s.user_id
530
548
  WHERE s.id = ? AND s.expires_at > ? AND u.role = 'admin')`),
549
+ cacheRepoDefaultBranchStmt: db.prepare(`UPDATE managed_selected_repo SET cached_default_branch = ?
550
+ WHERE repo_id = ? AND full_name = ? AND added_at = ? AND active = 1`),
531
551
  deleteRepoStmt: db.prepare(`DELETE FROM managed_selected_repo WHERE repo_id = ?`),
532
552
  deactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 0 WHERE repo_id = ? AND active = 1`),
533
553
  reactivateRepoStmt: db.prepare(`UPDATE managed_selected_repo SET active = 1 WHERE repo_id = ? AND active = 0`),
@@ -545,13 +565,13 @@ function prepareStatements(db: ManagedSql) {
545
565
  UNION SELECT finding_id FROM managed_finding_comment_event WHERE finding_id IN (SELECT value FROM json_each(?))) AS annotations`),
546
566
  selectReposStmt: db.prepare(
547
567
  `SELECT repo_id AS repoId, full_name AS fullName, is_private AS priv,
548
- installation_id AS installId, default_branch AS branch, html_url AS htmlUrl,
568
+ installation_id AS installId, default_branch AS branch, cached_default_branch AS cachedDefaultBranch, html_url AS htmlUrl,
549
569
  added_by AS addedBy, added_at AS addedAt, active AS active
550
570
  FROM managed_selected_repo WHERE active = 1 ORDER BY full_name ASC`,
551
571
  ),
552
572
  selectAllReposStmt: db.prepare(
553
573
  `SELECT repo_id AS repoId, full_name AS fullName, is_private AS priv,
554
- installation_id AS installId, default_branch AS branch, html_url AS htmlUrl,
574
+ installation_id AS installId, default_branch AS branch, cached_default_branch AS cachedDefaultBranch, html_url AS htmlUrl,
555
575
  added_by AS addedBy, added_at AS addedAt, active AS active
556
576
  FROM managed_selected_repo ORDER BY full_name ASC`,
557
577
  ),
@@ -717,6 +737,11 @@ function prepareStatements(db: ManagedSql) {
717
737
  SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
718
738
  WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?))`,
719
739
  ),
740
+ selectReadableBundleIdsStmt: db.prepare(
741
+ `SELECT b.id FROM managed_bundle b WHERE b.id IN (SELECT value FROM json_each(?)) AND (b.uploaded_by = ? OR EXISTS (
742
+ SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
743
+ WHERE tr.repo_id = b.repo_id AND ${BUNDLE_IN_TEAM_PATH_SQL} AND tu.user_id = ?))`,
744
+ ),
720
745
  selectRepoReadableStmt: db.prepare(
721
746
  `SELECT 1 FROM managed_team_repo tr JOIN managed_team_user tu ON tu.team_id = tr.team_id
722
747
  WHERE tr.repo_id = ? AND tu.user_id = ? LIMIT 1`,
@@ -842,7 +867,7 @@ function prepareStatements(db: ManagedSql) {
842
867
 
843
868
  type RepoRow = {
844
869
  repoId: number; fullName: string; priv: number; installId: number | null
845
- branch: string; htmlUrl: string; addedBy: string | null; addedAt: number; active: number
870
+ branch: string; cachedDefaultBranch: string | null; htmlUrl: string; addedBy: string | null; addedAt: number; active: number
846
871
  }
847
872
 
848
873
  // The repo-selection slice of ManagedDb, split out to keep openSqliteManagedDb
@@ -852,7 +877,7 @@ function selectedRepoMethods(stmts: ReturnType<typeof prepareStatements>) {
852
877
  selectReportsForRepoStmt, selectBundlesForRepoStmt, deleteReportsForRepoStmt, deleteBundlesForRepoStmt } = stmts
853
878
  const readRepo = (r: RepoRow): SelectedRepo => ({
854
879
  repoId: r.repoId, fullName: r.fullName, private: r.priv === 1,
855
- installationId: r.installId, defaultBranch: r.branch, htmlUrl: r.htmlUrl,
880
+ installationId: r.installId, defaultBranch: r.branch, cachedDefaultBranch: r.cachedDefaultBranch, htmlUrl: r.htmlUrl,
856
881
  addedBy: r.addedBy, addedAt: r.addedAt,
857
882
  })
858
883
  const readManagedRepo = (r: RepoRow): ManagedRepo => ({ ...readRepo(r), active: r.active === 1 })
@@ -863,6 +888,9 @@ function selectedRepoMethods(stmts: ReturnType<typeof prepareStatements>) {
863
888
  repo.defaultBranch, repo.htmlUrl, repo.addedBy, now, now,
864
889
  )
865
890
  },
891
+ async cacheRepoDefaultBranch(repo: SelectedRepo, branch: string | null): Promise<boolean> {
892
+ return Number((await stmts.cacheRepoDefaultBranchStmt.run(branch, repo.repoId, repo.fullName, repo.addedAt)).changes) > 0
893
+ },
866
894
  async deselectRepo(repoId: number): Promise<boolean> {
867
895
  return Number((await deleteRepoStmt.run(repoId)).changes) > 0
868
896
  },
@@ -1046,7 +1074,7 @@ type TriageEventDbRow = TriageStateDbRow & {
1046
1074
  // current row is skipped altogether, so a client re-pushing what already
1047
1075
  // stands neither re-stamps the writer nor echoes into the trail. `historyLimit`
1048
1076
  // > 0 keeps only that many events per finding; 0 keeps everything.
1049
- function triageMethods( stmts: ReturnType<typeof prepareStatements>, historyLimit: number) {
1077
+ function triageMethods(stmts: ReturnType<typeof prepareStatements>, historyLimit: number, db: ManagedSql) {
1050
1078
  const { upsertTriageStmt, selectTriageStmt, selectTriageStateStmt, insertTriageEventStmt, trimTriageEventsStmt, selectTriageHistoryStmt,
1051
1079
  deleteTriageStmt, deleteTriageHistoryStmt, deleteCommentsStmt, deleteCommentHistoryStmt, countAnnotationsStmt } = stmts
1052
1080
  async function writeEntry(findingId: string, entry: TriageEntryPatch | null, batchId: string, updatedBy: string | null, updatedByLogin: string | null, now: number, reportId: string | null = null): Promise<void> {
@@ -1112,7 +1140,43 @@ function triageMethods( stmts: ReturnType<typeof prepareStatements>, historyLimi
1112
1140
  // (and costs one fsync under synchronous = FULL, not one per row); one
1113
1141
  // batch id groups its rows in the trail.
1114
1142
  const batchId = randomUUID()
1115
- for (const [findingId, entry] of entries) await writeEntry(findingId, entry, batchId, updatedBy, updatedByLogin, now, reportId)
1143
+ if (new Set(entries.map(([id]) => id)).size !== entries.length) {
1144
+ // Preserve the ordered history of callers that supply repeated IDs.
1145
+ for (const [id, entry] of entries) await writeEntry(id, entry, batchId, updatedBy, updatedByLogin, now, reportId)
1146
+ return
1147
+ }
1148
+ const current = new Map((await selectTriageStmt.all(JSON.stringify(entries.map(([id]) => id))) as TriageDbRow[])
1149
+ .map(row => [row.findingId, row]))
1150
+ const changed = entries.flatMap(([id, entry]) => {
1151
+ const e = entry ?? {}, previous = current.get(id)
1152
+ const next = { color: e.color ?? null, triage: e.triage ?? null, comment: e.comment ?? null,
1153
+ fix: e.fix ?? null, flagged: e.flagged == null ? null : e.flagged ? 1 : 0 }
1154
+ if (previous && Object.entries(next).every(([key, value]) => previous[key as keyof TriageStateDbRow] === value)) return []
1155
+ return [[id, next.color, next.triage, next.comment, next.fix, next.flagged]]
1156
+ })
1157
+ // Bound SQL parameters while keeping the entire batch/history atomic.
1158
+ for (let offset = 0; offset < changed.length; offset += 200) {
1159
+ const rows = changed.slice(offset, offset + 200), values = rows.flat()
1160
+ const input = `WITH changes(finding_id, color, triage, comment, fix, flagged, ordinal) AS
1161
+ (VALUES ${rows.map((_, index) => `(?, ?, ?, ?, ?, CAST(? AS INTEGER), ${index})`).join(',')})`
1162
+ await db.prepare(`${input}
1163
+ INSERT INTO managed_finding_triage (finding_id, color, triage, comment, fix, flagged, updated_by, updated_by_login, updated_at)
1164
+ SELECT finding_id, color, triage, comment, fix, flagged, ?, ?, CAST(? AS BIGINT) FROM changes WHERE TRUE
1165
+ ON CONFLICT(finding_id) DO UPDATE SET color = excluded.color, triage = excluded.triage,
1166
+ comment = excluded.comment, fix = excluded.fix, flagged = excluded.flagged, updated_by = excluded.updated_by,
1167
+ updated_by_login = excluded.updated_by_login, updated_at = excluded.updated_at`).run(...values, updatedBy, updatedByLogin, now)
1168
+ await db.prepare(`${input}
1169
+ INSERT INTO managed_finding_triage_event (finding_id, batch_id, color, triage, comment, fix, flagged, actor_id, actor_login, at, report_id, report, repo)
1170
+ SELECT c.finding_id, ?, c.color, c.triage, c.comment, c.fix, c.flagged, ?, ?, CAST(? AS BIGINT), ?, r.filename, p.full_name
1171
+ FROM changes c LEFT JOIN managed_report r ON r.id = ? LEFT JOIN managed_selected_repo p ON p.repo_id = r.repo_id ORDER BY c.ordinal`)
1172
+ .run(...values, batchId, updatedBy, updatedByLogin, now, reportId ?? null, reportId ?? null)
1173
+ if (historyLimit > 0) {
1174
+ await db.prepare(`DELETE FROM managed_finding_triage_event WHERE seq IN (
1175
+ SELECT seq FROM (SELECT seq, ROW_NUMBER() OVER (PARTITION BY finding_id ORDER BY seq DESC) AS position
1176
+ FROM managed_finding_triage_event WHERE finding_id IN (SELECT value FROM json_each(?))) ranked WHERE position > ?)`)
1177
+ .run(JSON.stringify(rows.map(row => row[0])), historyLimit)
1178
+ }
1179
+ }
1116
1180
  },
1117
1181
  }
1118
1182
  }
@@ -1159,6 +1223,23 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1159
1223
  const row = (await selectBundleStmt.get(id)) as BundleRow | undefined
1160
1224
  return row == null ? null : mapBundle(row)
1161
1225
  },
1226
+ async getBundleAccessSnapshot(sessionId: string, now: number, id: string, advisoriesTeamId?: string | null) {
1227
+ const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1228
+ if (!session) return null
1229
+ const denied = { bundle: null, canReadAdvisories: false }
1230
+ if (!roleAtLeast(session.role, 'view')) return denied
1231
+ const row = await selectBundleStmt.get(id) as BundleRow | undefined
1232
+ if (!row) return denied
1233
+ const manager = roleAtLeast(session.role, 'manage')
1234
+ if (session.role !== 'admin') {
1235
+ const allowed = manager ? await selectBundleReadableStmt.get(id, session.uid, session.uid)
1236
+ : row.repoId !== null && await selectRepoPathReadableStmt.get(row.repoDirectory, row.repoId, session.uid)
1237
+ if (!allowed) return denied
1238
+ }
1239
+ const canReadAdvisories = manager || (advisoriesTeamId !== undefined
1240
+ && await stmts.selectBundleSecurityStmt.get(id, session.uid, advisoriesTeamId, advisoriesTeamId) != null)
1241
+ return { bundle: mapBundle(row), canReadAdvisories }
1242
+ },
1162
1243
  async listBundles(userId?: string): Promise<AdminBundle[]> {
1163
1244
  const rows = (await selectBundlesStmt.all(userId ?? null, userId ?? null, userId ?? null)) as BundleListRow[]
1164
1245
  return rows.map((r) => ({
@@ -1170,6 +1251,11 @@ function bundleMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedS
1170
1251
  async userCanReadBundle(userId: string, id: string): Promise<boolean> {
1171
1252
  return (await selectBundleReadableStmt.get(id, userId, userId)) != null
1172
1253
  },
1254
+ async listReadableBundleIds(userId: string, ids: readonly string[]): Promise<string[]> {
1255
+ if (ids.length === 0) return []
1256
+ const rows = await stmts.selectReadableBundleIdsStmt.all(JSON.stringify([...new Set(ids)]), userId, userId) as { id: string }[]
1257
+ return rows.map(row => row.id)
1258
+ },
1173
1259
  async userCanReadRepo(userId: string, repoId: number): Promise<boolean> {
1174
1260
  return (await selectRepoReadableStmt.get(repoId, userId)) != null
1175
1261
  },
@@ -1195,7 +1281,7 @@ type TeamMemberRow = { teamId: string; userId: string; login: string; viewDepend
1195
1281
  // The team slice of ManagedDb. listTeams reads the three tables in full and
1196
1282
  // groups in JS (3 queries, not N+1) — fine for the handful of teams a managed
1197
1283
  // workspace has.
1198
- function teamMethods( stmts: ReturnType<typeof prepareStatements>) {
1284
+ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql) {
1199
1285
  const {
1200
1286
  insertTeamStmt, selectTeamByNameStmt, renameTeamStmt, deleteTeamStmt, selectTeamStmt,
1201
1287
  selectUserRepoScopesStmt, selectTeamsStmt, selectTeamsForUserStmt, selectUserTeamReportsStmt, selectUserTeamBundlesStmt, selectReportReadableStmt,
@@ -1258,15 +1344,19 @@ function teamMethods( stmts: ReturnType<typeof prepareStatements>) {
1258
1344
  bundles: bundlesByTeam.get(t.id) ?? [],
1259
1345
  }))
1260
1346
  },
1347
+ async getFeedState(sessionId: string, now: number) {
1348
+ const row = await db.prepare(`SELECT u.id, u.role, r.catalog, r.annotations FROM managed_session s
1349
+ JOIN managed_user u ON u.id = s.user_id JOIN managed_change_revision r ON r.id = 1
1350
+ WHERE s.id = ? AND s.expires_at > ?`).get(sessionId, now) as { id: string; role: Role; catalog: number; annotations: number } | undefined
1351
+ return row ? { user: { id: row.id, role: row.role }, catalog: row.catalog, annotations: row.annotations } : null
1352
+ },
1261
1353
  async getUserTeamFeedSnapshot(sessionId: string, now: number) {
1262
1354
  // One short read-only transaction, shared by SQLite and Postgres. Never
1263
1355
  // touch last-seen timestamps or include other users' memberships.
1264
- const session = await stmts.selectSessionStmt.get(sessionId, now) as SessionRow | undefined
1265
- if (!session) return null
1266
- const teams = session.role === 'none' ? [] : await methods.listTeamsForUser(session.uid)
1267
- return { user: { id: session.uid, role: session.role },
1268
- revision: teamCatalogRevision(teams),
1269
- }
1356
+ const state = await methods.getFeedState(sessionId, now)
1357
+ if (!state) return null
1358
+ const teams = state.user.role === 'none' ? [] : await methods.listTeamsForUser(state.user.id)
1359
+ return { user: state.user, catalog: state.catalog, teams, revision: teamCatalogRevision(teams) }
1270
1360
  },
1271
1361
  async userCanReadReport(userId: string, reportId: string): Promise<boolean> {
1272
1362
  return (await selectReportReadableStmt.get(reportId, userId)) != null
@@ -1331,14 +1421,23 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1331
1421
  const activity = activityMethods(db)
1332
1422
  const stmts = prepareStatements(db)
1333
1423
  const reports = reportMethods(stmts, db, key)
1334
- const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0)
1424
+ const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0, db)
1335
1425
  const {
1336
1426
  upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectSessionStmt, selectUsersStmt,
1337
1427
  touchUserSeenStmt, updateRoleStmt, updateTokensStmt, selectTokensStmt, deleteSessionStmt, deleteExpiredStmt,
1338
1428
  } = stmts
1339
1429
 
1340
- const methods: Omit<ManagedDb, keyof ManagementStore> = {
1430
+ const methods: Omit<ManagedDb, keyof ManagementStore | keyof ManagementCatalogStore> = {
1341
1431
  ...storageMethods(db, key),
1432
+ ...bundleBuildLeaseMethods(db),
1433
+ async claimMaintenanceLease(owner, now, until, migration = false) {
1434
+ return !!await db.prepare(`INSERT INTO managed_maintenance_lease (id, owner, expires_at) VALUES (?, ?, ?)
1435
+ ON CONFLICT(id) DO UPDATE SET owner = excluded.owner, expires_at = excluded.expires_at
1436
+ WHERE managed_maintenance_lease.expires_at <= ? RETURNING id`).get(migration ? 2 : 1, owner, until, now)
1437
+ },
1438
+ async finishMaintenanceLease(owner, until) {
1439
+ await db.prepare('UPDATE managed_maintenance_lease SET expires_at = ? WHERE owner = ?').run(until, owner)
1440
+ },
1342
1441
  async upsertUser(user, now, initialAdminGithubId = null) {
1343
1442
  // New row → a fresh id; ON CONFLICT(github_user_id) keeps an existing
1344
1443
  // user's id (DO UPDATE leaves it untouched), so re-read to return it.
@@ -1401,15 +1500,18 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1401
1500
  ...selectedRepoMethods(stmts),
1402
1501
  ...activity,
1403
1502
  ...comments,
1503
+ async getAnnotations(ids) {
1504
+ return { triage: await triage.listTriage(ids), comments: await comments.listComments(ids) }
1505
+ },
1404
1506
  ...workspaceShareMethods(db),
1405
1507
  ...reports,
1406
1508
  ...triage,
1407
1509
  ...importTriageMethods({ getReport: reports.getReport, ...triage, ...comments }),
1408
1510
  ...bundleMethods(stmts, db, key),
1409
- ...teamMethods(stmts),
1511
+ ...teamMethods(stmts, db),
1410
1512
  async close() {
1411
1513
  await db.close()
1412
1514
  },
1413
1515
  }
1414
- return { ...methods, ...managementMethods(methods) }
1516
+ return { ...methods, ...managementMethods(methods), ...managementCatalogMethods(methods) }
1415
1517
  }