@preventive/triage 1.0.0-alpha.37 → 1.0.0-alpha.38

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 (175) hide show
  1. package/README.md +16 -0
  2. package/common/bundle-compare-diff.d.ts +3 -0
  3. package/common/bundle-compare-diff.js +522 -0
  4. package/common/bundle-compare-inputs.d.ts +2 -0
  5. package/common/bundle-compare-inputs.js +65 -0
  6. package/common/bundle-metadata.d.ts +6 -2
  7. package/common/bundle-metadata.js +63 -20
  8. package/common/bundle-pkg-of.d.ts +1 -0
  9. package/common/bundle-pkg-of.js +90 -0
  10. package/common/bundle-source-links.js +7 -0
  11. package/common/bundle-sourcemap.d.ts +10 -0
  12. package/common/bundle-sourcemap.js +100 -0
  13. package/common/bundle-sources.d.ts +3 -1
  14. package/common/bundle-sources.js +47 -19
  15. package/common/code-language.js +16 -2
  16. package/common/default-scan-models.ts +2 -1
  17. package/common/dependency-reviews.d.ts +23 -0
  18. package/common/dependency-reviews.js +81 -0
  19. package/common/finding-duplicates.js +58 -0
  20. package/common/finding-filters.js +274 -0
  21. package/common/github-pr.ts +1 -1
  22. package/common/gzip.js +59 -0
  23. package/common/line-diff.d.ts +8 -0
  24. package/common/line-diff.js +56 -0
  25. package/common/linked-workspace-groups.js +63 -0
  26. package/common/managed/npm-packages.d.ts +5 -0
  27. package/common/managed/npm-packages.js +34 -0
  28. package/common/managed/permissions.ts +1 -5
  29. package/common/managed/routes.js +123 -12
  30. package/common/managed/team-app.d.ts +2 -0
  31. package/common/managed/team-app.js +31 -0
  32. package/common/managed/triage.ts +1 -1
  33. package/common/managed/upload-seal.ts +126 -0
  34. package/common/pretty-print.d.ts +5 -0
  35. package/common/pretty-print.js +24 -0
  36. package/common/revalidation-conflicts.js +48 -0
  37. package/common/revalidation-input-groups.js +56 -0
  38. package/common/workspace-app.js +23 -0
  39. package/common/workspace-groups.js +176 -0
  40. package/out/brotli-fallback.js +3 -3
  41. package/out/chunk-2KBF5TZ7.js +2 -0
  42. package/out/chunk-2XUEZHIP.js +28 -0
  43. package/out/chunk-35VE6CDH.js +2 -0
  44. package/out/chunk-3BZ7JERB.js +1 -0
  45. package/out/chunk-4XCDFGDP.js +10 -0
  46. package/out/chunk-5FKRIJGF.js +1 -0
  47. package/out/chunk-6Z5XLSPJ.js +1 -0
  48. package/out/chunk-ABW5FVSN.js +1 -0
  49. package/out/chunk-BJKRVROW.js +1 -0
  50. package/out/chunk-BYUEDJOM.js +7 -0
  51. package/out/chunk-C4Z6ZBXV.js +10 -0
  52. package/out/chunk-CMQA4DM6.js +1 -0
  53. package/out/chunk-CVVJAXVY.js +2 -0
  54. package/out/chunk-DCQX2J3C.js +223 -0
  55. package/out/chunk-DIJJNTRH.js +10 -0
  56. package/out/chunk-F4OMDNEY.js +1 -0
  57. package/out/chunk-FU4MTW7F.js +1 -0
  58. package/out/chunk-GJ4IUCNS.js +1 -0
  59. package/out/chunk-GMFXFEUA.js +2 -0
  60. package/out/chunk-GNXOKIGP.js +80 -0
  61. package/out/chunk-GZ4MS3TE.js +13 -0
  62. package/out/chunk-HIV7SVI7.js +1 -0
  63. package/out/chunk-IU3CCINR.js +3 -0
  64. package/out/chunk-JAKWIZWL.js +2 -0
  65. package/out/chunk-K3X5EJBQ.js +2 -0
  66. package/out/chunk-KEX2ATEW.js +2 -0
  67. package/out/chunk-KYCNJVB7.js +1 -0
  68. package/out/chunk-NRPC74AM.js +1 -0
  69. package/out/chunk-OJ5DGP5E.js +33 -0
  70. package/out/chunk-OOJM4CTU.js +1 -0
  71. package/out/chunk-PNHBZGDD.js +1 -0
  72. package/out/chunk-R5O6FAW4.js +2 -0
  73. package/out/chunk-RFZQQXSG.js +17 -0
  74. package/out/chunk-SD2WJAXN.js +48 -0
  75. package/out/chunk-TUII34K2.js +1 -0
  76. package/out/chunk-TZ5KB2Y5.js +10 -0
  77. package/out/chunk-UNTRGE26.js +2 -0
  78. package/out/chunk-UYAXVRVL.js +1 -0
  79. package/out/chunk-V3ME6FI5.js +2 -0
  80. package/out/chunk-V5O7M2YT.js +1 -0
  81. package/out/chunk-VIHGO77Y.js +1 -0
  82. package/out/chunk-W4J3MGLB.js +1 -0
  83. package/out/chunk-WGG7KQUH.js +1 -0
  84. package/out/chunk-XYFXXB2G.js +10 -0
  85. package/out/chunk-YVZH5SHP.js +1 -0
  86. package/out/chunk-ZPD5R5RP.js +3 -0
  87. package/out/client-managed-import.js +3 -87
  88. package/out/client-managed.js +26 -338
  89. package/out/client-sync.js +8 -14
  90. package/out/graph.js +17 -30
  91. package/out/index.html +34 -0
  92. package/out/prism.js +6 -2
  93. package/out/terminal.js +241 -257
  94. package/out/view.css +1 -1
  95. package/out/view.js +76 -520
  96. package/package.json +88 -7
  97. package/risk/files/characters.js +196 -0
  98. package/risk/files/hosts.js +205 -0
  99. package/risk/files/readability.js +164 -0
  100. package/risk/files/runs-code.js +329 -0
  101. package/risk/files/script.js +112 -0
  102. package/risk/files/stamps.js +46 -0
  103. package/risk/index.d.ts +142 -0
  104. package/risk/index.js +7 -0
  105. package/risk/npm/assess.js +219 -0
  106. package/risk/npm/combined.js +65 -0
  107. package/risk/npm/compare.js +222 -0
  108. package/risk/npm/dependencies.js +153 -0
  109. package/risk/npm/diff.js +43 -0
  110. package/risk/npm/downloads.js +84 -0
  111. package/risk/npm/files.js +34 -0
  112. package/risk/npm/held.js +60 -0
  113. package/risk/npm/install-scripts.js +33 -0
  114. package/risk/npm/malware.js +36 -0
  115. package/risk/npm/manifest.js +65 -0
  116. package/risk/npm/new-dependency.js +30 -0
  117. package/risk/npm/old-version.js +108 -0
  118. package/risk/npm/registry.js +282 -0
  119. package/risk/npm/release.js +85 -0
  120. package/risk/npm/specs.js +124 -0
  121. package/risk/npm/tarball.js +113 -0
  122. package/server-e2e/http.ts +7 -7
  123. package/server-e2e/npm-proxy.ts +1 -1
  124. package/server-e2e/objstore/fetch-mint-guard.ts +2 -2
  125. package/server-e2e/objstore/handlers.ts +1 -1
  126. package/server-e2e/objstore/tokens.ts +1 -1
  127. package/server-e2e/sign.ts +1 -1
  128. package/server-managed/bundle-advisories.ts +68 -11
  129. package/server-managed/bundle-cache.ts +37 -4
  130. package/server-managed/bundle-commits.ts +140 -0
  131. package/server-managed/bundle-summary-cache.ts +24 -10
  132. package/server-managed/config.ts +4 -0
  133. package/server-managed/db-methods.ts +98 -11
  134. package/server-managed/db-neon.ts +39 -4
  135. package/server-managed/db-schema.ts +14 -2
  136. package/server-managed/db-table-names.ts +1 -1
  137. package/server-managed/db.ts +7 -3
  138. package/server-managed/dependency-reviews.ts +248 -0
  139. package/server-managed/github-issue-oauth.ts +5 -6
  140. package/server-managed/github-metadata.ts +115 -0
  141. package/server-managed/github-oauth.ts +38 -2
  142. package/server-managed/http-response.ts +26 -0
  143. package/server-managed/http.ts +943 -52
  144. package/server-managed/index.ts +11 -5
  145. package/server-managed/kept.ts +28 -0
  146. package/server-managed/locked-packages.ts +85 -0
  147. package/server-managed/lru.ts +30 -0
  148. package/server-managed/npm-insights.ts +440 -0
  149. package/server-managed/npm-kept.ts +65 -0
  150. package/server-managed/npm-loads.ts +167 -0
  151. package/server-managed/npm-manifest.ts +115 -0
  152. package/server-managed/npm-packages.ts +378 -0
  153. package/server-managed/npm-provenance.ts +123 -0
  154. package/server-managed/npm-risk.ts +177 -0
  155. package/server-managed/pretty-print.ts +352 -0
  156. package/server-managed/public-workspace.ts +78 -7
  157. package/server-managed/rename-diff-worker.js +26 -0
  158. package/server-managed/rename-diff.ts +270 -0
  159. package/server-managed/report-sources.ts +13 -2
  160. package/server-managed/repository-browser.ts +19 -5
  161. package/server-managed/repository-risk.ts +413 -0
  162. package/server-managed/session.ts +16 -2
  163. package/server-managed/sql.ts +5 -2
  164. package/server-managed/storage-encryption.ts +5 -3
  165. package/server-managed/storage-payload.ts +4 -0
  166. package/server-managed/storage-stores.ts +2 -0
  167. package/server-managed/storage.ts +6 -3
  168. package/server-managed/team-app.ts +94 -0
  169. package/server-managed/team-feed.ts +1 -1
  170. package/server-managed/team-npm-scopes.ts +77 -0
  171. package/server-managed/type-only-worker.js +19 -0
  172. package/server-managed/type-only.ts +103 -0
  173. package/server-managed/uploads.ts +25 -4
  174. package/server-managed/upstream-cache.ts +147 -5
  175. package/server-managed/view-as.ts +58 -0
@@ -0,0 +1,140 @@
1
+ import { setImmediate } from 'node:timers/promises'
2
+ import { bundleCommitHash } from '../common/bundle-commit.js'
3
+ import type { BundleSummary } from './bundle-cache.ts'
4
+ import type { ManagedDb } from './db.ts'
5
+ import { type GithubCommit, githubCommitKey } from './github-metadata.ts'
6
+
7
+ const MAX_MESSAGE_LENGTH = 65_536
8
+ const MAX_NAME_LENGTH = 256
9
+ // GitHub logins, and the `[bot]` accounts apps commit as.
10
+ const GITHUB_LOGIN = /^[A-Za-z\d][A-Za-z\d-]{0,38}(?:\[bot\])?$/u
11
+ const BACKFILL_LIMIT = 4
12
+ const RETRY_MS = 5 * 60_000
13
+
14
+ export type CommitDetails = Omit<GithubCommit, 'key' | 'fetchedAt'>
15
+ // What viewers see of a cached commit: its subject in place of the message.
16
+ export type CatalogCommitDetails = Omit<CommitDetails, 'message'> & { subject: string }
17
+ // What a catalog sends with a bundle whose summary records a commit, when
18
+ // the cache holds its details, tags pointing to it, or both. `github` is the
19
+ // repository they were cached for: the one the bundle is stored at.
20
+ export interface BundleCommitInfo { sha: string; github: string; tags: string[]; details: CatalogCommitDetails | null }
21
+ interface CatalogBundle { integrity: string; repoId: number | null; repoFullName: string | null }
22
+ type Summaries = ReadonlyMap<string, { summary: BundleSummary | null }>
23
+ export interface CatalogCommit { repoId: number; sha: string; key: string }
24
+ export interface CommitReader { commitDetails(sha: string): Promise<CommitDetails | null> }
25
+
26
+ function truncate(value: string, max: number): string {
27
+ if (value.length <= max) return value
28
+ // Never leave half of a surrogate pair at the end.
29
+ return value.slice(0, /[\uD800-\uDBFF]/u.test(value[max - 1]!) ? max - 1 : max)
30
+ }
31
+
32
+ function name(value: unknown): string | null {
33
+ return typeof value === 'string' && value.trim() ? truncate(value.trim(), MAX_NAME_LENGTH) : null
34
+ }
35
+
36
+ function date(value: unknown): number | null {
37
+ const time = typeof value === 'string' ? Date.parse(value) : NaN
38
+ return Number.isSafeInteger(time) ? time : null
39
+ }
40
+
41
+ // `body` is a GitHub list-commits page that starts at `sha`, so its first
42
+ // commit is that one, without the files GET /commits/:sha would add.
43
+ export function parseGithubCommit(sha: string, body: unknown): CommitDetails | null {
44
+ const item = Array.isArray(body) ? body[0] as {
45
+ sha?: unknown; author?: { login?: unknown } | null
46
+ commit?: { message?: unknown; author?: { name?: unknown; date?: unknown } | null; committer?: { date?: unknown } | null }
47
+ } | null : null
48
+ if (item?.sha !== sha || typeof item.commit?.message !== 'string') return null
49
+ const login = item.author?.login
50
+ return {
51
+ message: truncate(item.commit.message, MAX_MESSAGE_LENGTH),
52
+ authorName: name(item.commit.author?.name),
53
+ authorLogin: typeof login === 'string' && GITHUB_LOGIN.test(login) ? login : null,
54
+ authoredAt: date(item.commit.author?.date),
55
+ committedAt: date(item.commit.committer?.date),
56
+ }
57
+ }
58
+
59
+ // A commit's subject, as `git log --format=%s` gives it: the message's first
60
+ // paragraph on one line, here without the Claude-Session lines Claude Code
61
+ // adds. Viewers see no more of the message, so catalogs send no more.
62
+ export function commitSubject(message: string): string {
63
+ const paragraph = message.replaceAll('\r\n', '\n').trim().split(/\n[ \t]*\n/u, 1)[0] ?? ''
64
+ return paragraph.split('\n').map(line => line.trim()).filter(line => line && !/^Claude-Session:/iu.test(line)).join(' ')
65
+ }
66
+
67
+ // The commit a bundle's own summary records, in the repository it is stored at.
68
+ function catalogCommit(bundle: CatalogBundle, summaries: Summaries): CatalogCommit | null {
69
+ const sha = bundleCommitHash(summaries.get(bundle.integrity)?.summary?.commit)
70
+ return sha && bundle.repoId != null ? { repoId: bundle.repoId, sha, key: githubCommitKey(bundle.repoId, sha) } : null
71
+ }
72
+
73
+ // Reads only the cache: viewing a bundle never requests its commit's tags,
74
+ // and a commit missing from the cache is left for backfillBundleCommits.
75
+ // Call it before the catalog's final access check: `commitInfo` answers for
76
+ // the bundles of that last snapshot without another await, and has nothing
77
+ // for a commit or repository this read did not cover.
78
+ export async function bundleCommits(db: ManagedDb, bundles: readonly CatalogBundle[], summaries: Summaries) {
79
+ const wanted = new Map<string, CatalogCommit>()
80
+ for (const bundle of bundles) {
81
+ const commit = catalogCommit(bundle, summaries)
82
+ if (commit) wanted.set(commit.key, commit)
83
+ }
84
+ const keys = [...wanted.keys()]
85
+ const details = new Map<string, CatalogCommitDetails>()
86
+ const tags = new Map<string, string[]>()
87
+ if (keys.length > 0) {
88
+ for (const { key, fetchedAt: _fetchedAt, message, ...commit } of await db.listGithubCommits(keys)) {
89
+ details.set(key, { subject: commitSubject(message), ...commit })
90
+ }
91
+ for (const tag of await db.listGithubCommitTags(keys)) tags.set(tag.key, [...tags.get(tag.key) ?? [], tag.name])
92
+ }
93
+ return {
94
+ commitInfo(bundle: CatalogBundle): BundleCommitInfo | null {
95
+ const commit = catalogCommit(bundle, summaries)
96
+ if (!commit || !bundle.repoFullName || (!details.has(commit.key) && !tags.has(commit.key))) return null
97
+ return { sha: commit.sha, github: bundle.repoFullName, tags: tags.get(commit.key) ?? [], details: details.get(commit.key) ?? null }
98
+ },
99
+ missing: [...wanted.values()].filter(commit => !details.has(commit.key)),
100
+ }
101
+ }
102
+
103
+ // Store the details of a commit, read with the reader's verified repository
104
+ // access, unless the cache already has them. Best effort: true once cached.
105
+ export async function cacheCommitDetails(db: ManagedDb, reader: CommitReader, repoId: number, sha: string): Promise<boolean> {
106
+ const key = githubCommitKey(repoId, sha)
107
+ try {
108
+ if ((await db.listGithubCommits([key])).length > 0) return true
109
+ const details = await reader.commitDetails(sha)
110
+ if (details) await db.setGithubCommits([{ key, ...details, fetchedAt: Date.now() }])
111
+ return details !== null
112
+ } catch { return false }
113
+ }
114
+
115
+ const active = new Set<string>()
116
+ const retries = new Map<string, number>()
117
+
118
+ // Called after the catalog response is sent: the next one has the details.
119
+ // Reads each commit with the viewer's own repository access, so a viewer who
120
+ // cannot read it on GitHub leaves it for another (retried after a delay for
121
+ // them). Commits never change, so each is stored once and kept.
122
+ export async function backfillBundleCommits(db: ManagedDb, userId: string, missing: readonly CatalogCommit[],
123
+ readerFor: (repoId: number) => Promise<CommitReader | null>): Promise<void> {
124
+ if (missing.length === 0) return
125
+ await setImmediate()
126
+ const now = Date.now()
127
+ for (const [key, retryAt] of retries) if (retryAt <= now) retries.delete(key)
128
+ const batch = missing.filter(commit => !active.has(commit.key) && !retries.has(`${userId}\n${commit.key}`)).slice(0, BACKFILL_LIMIT)
129
+ for (const commit of batch) active.add(commit.key)
130
+ try {
131
+ for (const repoId of new Set(batch.map(commit => commit.repoId))) {
132
+ const reader = await readerFor(repoId).catch(() => null)
133
+ for (const commit of batch.filter(entry => entry.repoId === repoId)) {
134
+ if (!reader || !await cacheCommitDetails(db, reader, repoId, commit.sha)) retries.set(`${userId}\n${commit.key}`, Date.now() + RETRY_MS)
135
+ }
136
+ }
137
+ } finally {
138
+ for (const commit of batch) active.delete(commit.key)
139
+ }
140
+ }
@@ -5,14 +5,27 @@ import { decodeUtf8 } from '../common/utf8.js'
5
5
  import { bundleCommitHash } from '../common/bundle-commit.js'
6
6
  import type { BundleCacheRecord, BundleCacheStorage, BundleSummary } from './bundle-cache.ts'
7
7
 
8
- // Version 3 adds the optional source commit; older bundles use bounded backfill.
9
- export const SUMMARY_FILENAME = 'v3-summary.json'
8
+ // Version 3 adds the optional source commit; version 4 counts lines of code
9
+ // without blank lines; version 5 adds a Stasis bundle's format version and
10
+ // versioned package count. Older bundles use bounded backfill; until it
11
+ // reaches one, catalogs serve its version 4 summary, which lacks only those.
12
+ export const SUMMARY_FILENAME = 'v5-summary.json'
13
+ const PREVIOUS_SUMMARY_FILENAME = 'v4-summary.json'
10
14
  const RETRY_MS = 5 * 60_000
11
15
  const BACKFILL_LIMIT = 4
12
- type CachedSummary = BundleSummary | { retryAt: number }
16
+ type CachedSummary = BundleSummary | { retryAt: number } | { previous: BundleSummary }
13
17
 
14
- async function readSummary(storage: BundleCacheStorage, id: string): Promise<CachedSummary> {
15
- const cached = await storage.open(id, SUMMARY_FILENAME)
18
+ async function readCurrentSummary(storage: BundleCacheStorage, id: string): Promise<CachedSummary> {
19
+ try { return await readSummary(storage, id, SUMMARY_FILENAME) }
20
+ catch (error) {
21
+ if (!(error instanceof CacheMissError)) throw error
22
+ const previous = await readSummary(storage, id, PREVIOUS_SUMMARY_FILENAME)
23
+ return 'retryAt' in previous ? previous : { previous }
24
+ }
25
+ }
26
+
27
+ async function readSummary(storage: BundleCacheStorage, id: string, file: string): Promise<BundleSummary | { retryAt: number }> {
28
+ const cached = await storage.open(id, file)
16
29
  try {
17
30
  const chunks: Buffer[] = []
18
31
  let size = 0
@@ -21,11 +34,12 @@ async function readSummary(storage: BundleCacheStorage, id: string): Promise<Cac
21
34
  if (size > 1024) throw new Error('Invalid bundle summary')
22
35
  chunks.push(Buffer.from(chunk))
23
36
  }
24
- const summary = JSON.parse(decodeUtf8(Buffer.concat(chunks))) as CachedSummary
37
+ const summary = JSON.parse(decodeUtf8(Buffer.concat(chunks))) as BundleSummary | { retryAt: number }
25
38
  if (summary && 'retryAt' in summary && Number.isSafeInteger(summary.retryAt) && summary.retryAt > 0) return summary
26
39
  const counts = summary as BundleSummary
27
40
  if (!counts || ![counts.files, counts.codeFiles, counts.lines].every(value => Number.isSafeInteger(value) && value >= 0)
28
- || counts.codeFiles > counts.files || (counts.commit !== undefined && !bundleCommitHash(counts.commit))) throw new Error('Invalid bundle summary')
41
+ || counts.codeFiles > counts.files || (counts.commit !== undefined && !bundleCommitHash(counts.commit))
42
+ || [counts.stasisVersion, counts.versionedPackages].some(value => value !== undefined && !(Number.isSafeInteger(value) && value >= 0))) throw new Error('Invalid bundle summary')
29
43
  return counts
30
44
  } finally { cached.stream.destroy() }
31
45
  }
@@ -50,7 +64,7 @@ export function createBundleSummaryCache(storage: BundleCacheStorage, build: (re
50
64
  if (existing) return existing.job
51
65
  // Concurrent catalogs share the decoded summary, never the response stream.
52
66
  // Only in-flight work is shared: misses and failures remain retryable.
53
- const job: Promise<CachedSummary | null> = readSummary(storage, record.id).then(value => {
67
+ const job: Promise<CachedSummary | null> = readCurrentSummary(storage, record.id).then(value => {
54
68
  if (reads.get(record.integrity)?.job === job) remember(record, value)
55
69
  return entries.get(record.integrity)?.value ?? value
56
70
  }).catch(error => {
@@ -72,7 +86,7 @@ export function createBundleSummaryCache(storage: BundleCacheStorage, build: (re
72
86
  }
73
87
  async function summaryStatus(record: BundleCacheRecord) {
74
88
  const value = ['stasis', 'sourcemap'].includes(record.kind ?? '') ? await read(record) : null
75
- return { summary: value && !('retryAt' in value) ? value : null,
89
+ return { summary: !value || 'retryAt' in value ? null : 'previous' in value ? value.previous : value,
76
90
  summaryRetryAt: value && 'retryAt' in value ? value.retryAt : null }
77
91
  }
78
92
  async function backfill(records: readonly BundleCacheRecord[]) {
@@ -83,7 +97,7 @@ export function createBundleSummaryCache(storage: BundleCacheStorage, build: (re
83
97
  for (const record of records) {
84
98
  if (!['stasis', 'sourcemap'].includes(record.kind ?? '')) continue
85
99
  const cached = await read(record).catch(() => null)
86
- if (cached && (!('retryAt' in cached) || cached.retryAt > Date.now())) continue
100
+ if (cached && !('previous' in cached) && (!('retryAt' in cached) || cached.retryAt > Date.now())) continue
87
101
  if (!await exists(record.id)) continue
88
102
  if (attempts++ >= BACKFILL_LIMIT) break
89
103
  const job = buildOne(record)
@@ -6,6 +6,7 @@ import { databaseUrls } from '../server-common/database-config.ts'
6
6
  import { MAX_UPLOAD_BYTES } from './uploads.ts'
7
7
  import { parseStorageKey } from '../server-common/storage-crypto.ts'
8
8
  import { defaultCacheDir } from '@preventive/upstream/npm.js'
9
+ import { RESERVED_COOKIES } from './session.ts'
9
10
 
10
11
  const LOOPBACK_HOSTS = new Set(['127.0.0.1', '::1', 'localhost'])
11
12
 
@@ -117,6 +118,9 @@ export function loadManagedConfig({ combined = false } = {}): ManagedConfig {
117
118
  if (sessionCookieName.startsWith('__Host-') && !cookieSecure) {
118
119
  fail(`SESSION_COOKIE_NAME=${sessionCookieName} uses the __Host- prefix but the callback is not https. Use a non-prefixed name for loopback http dev.`)
119
120
  }
121
+ if (RESERVED_COOKIES.includes(sessionCookieName.replace(/^__Host-/u, ''))) {
122
+ fail(`SESSION_COOKIE_NAME=${sessionCookieName} is reserved for another managed cookie. Choose a different name.`)
123
+ }
120
124
  return {
121
125
  neonUrl, blobToken, serverless, storageEncryptionKey, storageEncryptionMigrate,
122
126
  storageEncryptionMigrateMaxMs: intEnv('MANAGED_STORAGE_ENCRYPTION_MIGRATE_MAX_MS', 150_000, 1, serverless ? 240_000 : 3_600_000),
@@ -1,5 +1,7 @@
1
1
  import { type LinkReportStore, linkReportMethods, linkRevision } from './link-reports.ts'
2
2
  import { type RepositoryAliasStore, repositoryAliasMethods } from './repository-aliases.ts'
3
+ import { type DependencyReviewStore, dependencyReviewMethods } from './dependency-reviews.ts'
4
+ import { type TeamNpmScopeStore, teamNpmScopeMethods } from './team-npm-scopes.ts'
3
5
  import { createHash, randomUUID } from 'node:crypto'
4
6
  import { teamCatalogRevision } from './team-catalog.ts'
5
7
  import { type Role, roleAtLeast } from '../common/managed/roles.ts'
@@ -8,7 +10,7 @@ import type { TriageEntryPatch } from '../common/managed/triage.ts'
8
10
  import { preferredSlug } from './slugs.ts'
9
11
  import { type CommentStore, commentMethods } from './comments.ts'
10
12
  import { type ActivityStore, activityMethods } from './activity.ts'
11
- import { type GithubMetadataStore, githubMetadataMethods } from './github-metadata.ts'
13
+ import { type GithubCommitStore, type GithubMetadataStore, githubCommitMethods, githubMetadataMethods } from './github-metadata.ts'
12
14
  import { type ManagedIssueStore, managedIssueMethods } from './managed-issues.ts'
13
15
  import type { IssueFixUpdate } from './github-issue-links.ts'
14
16
  import { type BundleBuildLeaseStore, bundleBuildLeaseMethods } from './bundle-build-leases.ts'
@@ -65,6 +67,16 @@ export interface ManagedSession {
65
67
  userId: string
66
68
  csrfToken: string
67
69
  expiresAt: number
70
+ // Private JWK for sealed uploads; null until the session first asks for it.
71
+ uploadKey?: string | null
72
+ // Set on an administrator's read-only view as `userId` (see view-as.ts).
73
+ viewer?: ManagedViewer
74
+ }
75
+
76
+ export interface ManagedViewer {
77
+ id: string
78
+ login: string
79
+ name: string | null
68
80
  }
69
81
 
70
82
  // A repository selected for the workspace to operate on, with the context to
@@ -343,6 +355,7 @@ export interface UserTeam {
343
355
  id: string
344
356
  slug: string
345
357
  name: string
358
+ dependencyReview?: boolean
346
359
  permissions: TeamUserPermissions
347
360
  cacheKey?: string
348
361
  reports: UserTeamReport[]
@@ -350,7 +363,7 @@ export interface UserTeam {
350
363
  }
351
364
 
352
365
  // Backend-agnostic store surface (SQLite + PostgreSQL implementations).
353
- export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataStore, ManagedIssueStore, BundleBuildLeaseStore, UpstreamCacheStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, ManagementCatalogStore, StorageDb, LinkReportStore, RepositoryAliasStore {
366
+ export interface ManagedDb extends DependencyReviewStore, ActivityStore, CommentStore, GithubMetadataStore, GithubCommitStore, ManagedIssueStore, BundleBuildLeaseStore, UpstreamCacheStore, WorkspaceShareStore, ImportTriageStore, ManagementStore, ManagementCatalogStore, StorageDb, LinkReportStore, RepositoryAliasStore, TeamNpmScopeStore {
354
367
  claimMaintenanceLease(owner: string, now: number, until: number, migration?: boolean): Promise<boolean>
355
368
  finishMaintenanceLease(owner: string, until: number): Promise<void>
356
369
  getFeedState(sessionId: string, now: number): Promise<{ user: Pick<StoredUser, 'id' | 'role'>; catalog: number; annotations: number } | null>
@@ -361,7 +374,20 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
361
374
  // promotes a matching No access identity only while it is the sole user.
362
375
  upsertUser(user: ManagedUser, now: number, initialAdminGithubId?: number | null): Promise<string>
363
376
  createSession(session: ManagedSession, now: number): Promise<void>
377
+ // A sign-in session; never a view session, so a view token in the session
378
+ // cookie authenticates nothing.
364
379
  sessionWithUser(id: string, now: number): Promise<{ session: ManagedSession; user: StoredUser } | null>
380
+ // Replace the view opened by an admin's session with a view as `userId`.
381
+ // False, keeping any earlier view, unless that session is current, its user
382
+ // is still an admin, and `userId` is another existing account. The view
383
+ // expires with that session.
384
+ createViewSession(view: { id: string; viewerSessionId: string; userId: string; csrfToken: string }, now: number): Promise<boolean>
385
+ // A view session opened by `viewerSessionId`, while that session is current
386
+ // and its user is still an admin. Presence is the admin's, not the viewed user's.
387
+ viewSessionWithUser(id: string, viewerSessionId: string, now: number): Promise<{ session: ManagedSession; user: StoredUser } | null>
388
+ // Store `candidate` unless the session already has an upload key; returns
389
+ // the stored key, or null when the session no longer exists.
390
+ ensureSessionUploadKey(id: string, candidate: string): Promise<string | null>
365
391
  deleteSession(id: string): Promise<void>
366
392
  deleteExpiredSessions(now: number): Promise<number>
367
393
  listUsers(): Promise<AdminUser[]>
@@ -506,9 +532,10 @@ export interface ManagedDb extends ActivityStore, CommentStore, GithubMetadataSt
506
532
  }
507
533
 
508
534
  type SessionRow = {
509
- id: string; csrf: string; exp: number
535
+ id: string; csrf: string; exp: number; uploadKey: string | null
510
536
  uid: string; login: string; name: string | null; avatar: string | null; role: Role
511
537
  }
538
+ type ViewSessionRow = SessionRow & { viewerId: string; viewerLogin: string; viewerName: string | null }
512
539
 
513
540
  type UserRow = { id: string; login: string; name: string | null; role: Role; created: number; lastSeen: number | null; lastActivity: number | null }
514
541
 
@@ -552,12 +579,43 @@ function prepareStatements(db: ManagedSql) {
552
579
  `INSERT INTO managed_session (id, user_id, csrf_token, created_at, expires_at) VALUES (?, ?, ?, ?, ?)`,
553
580
  ),
554
581
  selectSessionStmt: db.prepare(
555
- `SELECT s.id AS id, s.csrf_token AS csrf, s.expires_at AS exp,
582
+ `SELECT s.id AS id, s.csrf_token AS csrf, s.expires_at AS exp, s.upload_key AS uploadKey,
556
583
  u.id AS uid, u.login AS login, u.name AS name, u.avatar_url AS avatar, u.role AS role
557
584
  FROM managed_session s
558
585
  JOIN managed_user u ON u.id = s.user_id
559
586
  WHERE s.id = ? AND s.expires_at > ?`,
560
587
  ),
588
+ selectOwnSessionStmt: db.prepare(
589
+ `SELECT s.id AS id, s.csrf_token AS csrf, s.expires_at AS exp, s.upload_key AS uploadKey,
590
+ u.id AS uid, u.login AS login, u.name AS name, u.avatar_url AS avatar, u.role AS role
591
+ FROM managed_session s
592
+ JOIN managed_user u ON u.id = s.user_id
593
+ WHERE s.id = ? AND s.expires_at > ? AND s.viewer_session IS NULL`,
594
+ ),
595
+ selectViewSessionStmt: db.prepare(
596
+ `SELECT s.id AS id, s.csrf_token AS csrf, s.expires_at AS exp, s.upload_key AS uploadKey,
597
+ u.id AS uid, u.login AS login, u.name AS name, u.avatar_url AS avatar, u.role AS role,
598
+ a.id AS viewerId, a.login AS viewerLogin, a.name AS viewerName
599
+ FROM managed_session s
600
+ JOIN managed_user u ON u.id = s.user_id
601
+ JOIN managed_session p ON p.id = s.viewer_session
602
+ JOIN managed_user a ON a.id = p.user_id
603
+ WHERE s.id = ? AND s.viewer_session = ? AND s.expires_at > ?
604
+ AND p.expires_at > ? AND p.viewer_session IS NULL AND a.role = 'admin'`,
605
+ ),
606
+ deleteOtherViewsStmt: db.prepare(`DELETE FROM managed_session WHERE viewer_session = ? AND id <> ?`),
607
+ insertViewSessionStmt: db.prepare(
608
+ `INSERT INTO managed_session (id, user_id, csrf_token, created_at, expires_at, viewer_session)
609
+ SELECT ?, u.id, ?, ?, p.expires_at, p.id
610
+ FROM managed_session p
611
+ JOIN managed_user a ON a.id = p.user_id
612
+ JOIN managed_user u ON u.id = ?
613
+ WHERE p.id = ? AND p.expires_at > ? AND p.viewer_session IS NULL AND a.role = 'admin' AND u.id <> a.id`,
614
+ ),
615
+ // A former admin's views end with the role, even if it is granted again.
616
+ deleteDemotedViewsStmt: db.prepare(
617
+ `DELETE FROM managed_session WHERE viewer_session IN (SELECT id FROM managed_session WHERE user_id = ?)`,
618
+ ),
561
619
  selectUsersStmt: db.prepare(
562
620
  `SELECT u.id, u.login, u.name, u.role, u.created_at AS created, u.last_seen_at AS lastSeen,
563
621
  (SELECT MAX(at) FROM (
@@ -580,6 +638,10 @@ function prepareStatements(db: ManagedSql) {
580
638
  `SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp, gh_tokens_encrypted AS encrypted
581
639
  FROM managed_user WHERE id = ?`,
582
640
  ),
641
+ // Concurrent first requests keep whichever key was stored first.
642
+ ensureUploadKeyStmt: db.prepare(
643
+ `UPDATE managed_session SET upload_key = COALESCE(upload_key, ?) WHERE id = ? RETURNING upload_key AS uploadKey`,
644
+ ),
583
645
  deleteSessionStmt: db.prepare(`DELETE FROM managed_session WHERE id = ?`),
584
646
  deleteExpiredStmt: db.prepare(`DELETE FROM managed_session WHERE expires_at <= ?`),
585
647
  upsertRepoStmt: db.prepare(
@@ -849,7 +911,7 @@ function prepareStatements(db: ManagedSql) {
849
911
  setTeamHiddenStmt: db.prepare(`UPDATE managed_team SET hidden = ?, updated_at = ? WHERE id = ?`),
850
912
  selectTeamsStmt: db.prepare(`SELECT id, slug, name, hidden FROM managed_team ORDER BY name ASC`),
851
913
  selectTeamsForUserStmt: db.prepare(
852
- `SELECT t.id AS id, t.slug AS slug, t.name AS name,
914
+ `SELECT t.id AS id, t.slug AS slug, t.name AS name, t.dependency_review AS dependencyReview,
853
915
  tu.view_dependencies AS dependencies, tu.view_security AS security
854
916
  FROM managed_team_user tu JOIN managed_team t ON t.id = tu.team_id
855
917
  WHERE tu.user_id = ? AND t.hidden = 0 ORDER BY t.name ASC, t.id ASC`,
@@ -1033,7 +1095,7 @@ type ReportRow = {
1033
1095
  async function authorizeUpload(stmts: ReturnType<typeof prepareStatements>, sessionId: string | undefined,
1034
1096
  item: { uploadedBy: string | null; repoId: number | null; repoDirectory?: string }) {
1035
1097
  if (sessionId === undefined) return // Trusted imports and fixtures use the raw store API.
1036
- const user = await stmts.selectSessionStmt.get(sessionId, Date.now()) as SessionRow | undefined
1098
+ const user = await stmts.selectOwnSessionStmt.get(sessionId, Date.now()) as SessionRow | undefined
1037
1099
  if (!user) throw new ManagedMutationError(401, 'unauthenticated')
1038
1100
  if (user.uid !== item.uploadedBy || !roleAtLeast(user.role, 'manage')) throw new ManagedMutationError(403, 'forbidden')
1039
1101
  if (item.repoId === null) return
@@ -1422,7 +1484,7 @@ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql
1422
1484
  return (await selectUserRepoScopesStmt.all(userId)) as { repoId: number; path: string | null }[]
1423
1485
  },
1424
1486
  async listTeamsForUser(userId: string): Promise<UserTeam[]> {
1425
- const teams = (await selectTeamsForUserStmt.all(userId)) as { id: string; slug: string; name: string; dependencies: number; security: number }[]
1487
+ const teams = (await selectTeamsForUserStmt.all(userId)) as { id: string; slug: string; name: string; dependencyReview: number; dependencies: number; security: number }[]
1426
1488
  const scopes = await stmts.selectUserTeamScopesStmt.all(userId) as { teamId: string; repoId: number; path: string; fullName: string }[]
1427
1489
  const links = await linkRevision(db)
1428
1490
  const reportsByTeam = new Map<string, UserTeamReport[]>()
@@ -1446,6 +1508,7 @@ function teamMethods(stmts: ReturnType<typeof prepareStatements>, db: ManagedSql
1446
1508
  }
1447
1509
  return teams.map((t) => ({
1448
1510
  id: t.id, slug: t.slug, name: t.name,
1511
+ ...(t.dependencyReview === 1 ? { dependencyReview: true } : {}),
1449
1512
  permissions: { dependencies: t.dependencies === 1, security: t.security === 1 },
1450
1513
  cacheKey: createHash('sha256').update(JSON.stringify([
1451
1514
  t.dependencies, t.security, links, scopes.filter(scope => scope.teamId === t.id),
@@ -1534,14 +1597,17 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1534
1597
  const reports = reportMethods(stmts, db, key)
1535
1598
  const triage = triageMethods(stmts, options.triageHistoryLimit ?? 0, db)
1536
1599
  const {
1537
- upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectSessionStmt, selectUsersStmt,
1538
- touchUserSeenStmt, updateRoleStmt, updateTokensStmt, selectTokensStmt, deleteSessionStmt, deleteExpiredStmt,
1600
+ upsertUserStmt, selectUserIdStmt, promoteInitialAdminStmt, selectGithubIdStmt, insertSessionStmt, selectOwnSessionStmt, selectUsersStmt,
1601
+ touchUserSeenStmt, updateRoleStmt, updateTokensStmt, selectTokensStmt, ensureUploadKeyStmt, deleteSessionStmt, deleteExpiredStmt,
1602
+ selectViewSessionStmt, deleteOtherViewsStmt, insertViewSessionStmt, deleteDemotedViewsStmt,
1539
1603
  } = stmts
1540
1604
 
1541
1605
  const methods: Omit<ManagedDb, keyof ManagementStore | keyof ManagementCatalogStore> = {
1542
1606
  ...storageMethods(db, key),
1543
1607
  ...linkReportMethods(db, key),
1544
1608
  ...repositoryAliasMethods(db),
1609
+ ...teamNpmScopeMethods(db),
1610
+ ...dependencyReviewMethods(db),
1545
1611
  ...bundleBuildLeaseMethods(db),
1546
1612
  ...upstreamCacheMethods(db),
1547
1613
  async claimMaintenanceLease(owner, now, until, migration = false) {
@@ -1570,14 +1636,33 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1570
1636
  await touchUserSeenStmt.run(now, session.userId, now)
1571
1637
  },
1572
1638
  async sessionWithUser(id, now) {
1573
- const row = (await selectSessionStmt.get(id, now)) as SessionRow | undefined
1639
+ const row = (await selectOwnSessionStmt.get(id, now)) as SessionRow | undefined
1574
1640
  if (row == null) return null
1575
1641
  await touchUserSeenStmt.run(now, row.uid, now)
1576
1642
  return {
1577
- session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp },
1643
+ session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp, uploadKey: row.uploadKey },
1578
1644
  user: { id: row.uid, login: row.login, name: row.name, avatarUrl: row.avatar, role: row.role },
1579
1645
  }
1580
1646
  },
1647
+ async createViewSession(view, now) {
1648
+ if (Number((await insertViewSessionStmt.run(view.id, view.csrfToken, now, view.userId, view.viewerSessionId, now)).changes) === 0) return false
1649
+ await deleteOtherViewsStmt.run(view.viewerSessionId, view.id)
1650
+ return true
1651
+ },
1652
+ async viewSessionWithUser(id, viewerSessionId, now) {
1653
+ const row = (await selectViewSessionStmt.get(id, viewerSessionId, now, now)) as ViewSessionRow | undefined
1654
+ if (row == null) return null
1655
+ await touchUserSeenStmt.run(now, row.viewerId, now)
1656
+ return {
1657
+ session: { id: row.id, userId: row.uid, csrfToken: row.csrf, expiresAt: row.exp, uploadKey: row.uploadKey,
1658
+ viewer: { id: row.viewerId, login: row.viewerLogin, name: row.viewerName } },
1659
+ user: { id: row.uid, login: row.login, name: row.name, avatarUrl: row.avatar, role: row.role },
1660
+ }
1661
+ },
1662
+ async ensureSessionUploadKey(id, candidate) {
1663
+ const row = (await ensureUploadKeyStmt.get(candidate, id)) as { uploadKey: string } | undefined
1664
+ return row?.uploadKey ?? null
1665
+ },
1581
1666
  async deleteSession(id) {
1582
1667
  await deleteSessionStmt.run(id)
1583
1668
  },
@@ -1589,6 +1674,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1589
1674
  return rows.map((r) => ({ id: r.id, login: r.login, name: r.name, role: r.role, createdAt: r.created, lastSeenAt: r.lastSeen, lastActivityAt: r.lastActivity }))
1590
1675
  },
1591
1676
  async setUserRole(id, role) {
1677
+ if (role !== 'admin') await deleteDemotedViewsStmt.run(id)
1592
1678
  return Number((await updateRoleStmt.run(role, Date.now(), id)).changes) > 0
1593
1679
  },
1594
1680
  async setUserTokens(id, tokens) {
@@ -1610,6 +1696,7 @@ export function createManagedMethods(db: ManagedSql, options: ManagedDbOptions =
1610
1696
  return row?.githubId ?? null
1611
1697
  },
1612
1698
  ...githubMetadataMethods(db),
1699
+ ...githubCommitMethods(db),
1613
1700
  ...managedIssueMethods(db),
1614
1701
  ...selectedRepoMethods(stmts),
1615
1702
  ...activity,
@@ -1,12 +1,14 @@
1
+ import { DEPENDENCY_REVIEW_SCHEMA } from './dependency-reviews.ts'
1
2
  import { LINK_REPORT_SCHEMA } from './link-reports.ts'
2
3
  import { REPOSITORY_ALIAS_SCHEMA } from './repository-aliases.ts'
4
+ import { TEAM_NPM_SCOPE_SCHEMA } from './team-npm-scopes.ts'
3
5
  import { AsyncLocalStorage } from 'node:async_hooks'
4
6
  import { WebSocket } from 'ws'
5
7
  import { type ManagedDb, type ManagedDbOptions, createManagedMethods } from './db-methods.ts'
6
8
  import { revisionSchema } from './revisions.ts'
7
- import { MANAGED_SCHEMA } from './db-schema.ts'
9
+ import { MANAGED_SCHEMA, VIEW_SESSION_INDEX } from './db-schema.ts'
8
10
  import { STORAGE_SCHEMA } from './storage-db.ts'
9
- import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
11
+ import { GITHUB_COMMIT_SCHEMA, GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
10
12
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
11
13
  import { BUNDLE_BUILD_LEASE_SCHEMA } from './bundle-build-leases.ts'
12
14
  import { UPSTREAM_CACHE_SCHEMA } from './upstream-cache.ts'
@@ -34,7 +36,7 @@ async function currentSchema(db: PgConnection): Promise<boolean> {
34
36
  const exists = (await db.query("SELECT to_regclass('managed_schema_version') AS name")).rows[0]?.['name']
35
37
  if (!exists) return false
36
38
  const versions = new Set((await db.query('SELECT version FROM managed_schema_version')).rows.map(row => Number(row['version'])))
37
- return Array.from({ length: 22 }, (_, i) => i + 1).every(version => versions.has(version))
39
+ return Array.from({ length: 27 }, (_, i) => i + 1).every(version => versions.has(version))
38
40
  }
39
41
 
40
42
  async function migrateRepositoryDefaultCache(db: PgConnection): Promise<void> {
@@ -107,6 +109,39 @@ async function migrateBundleBuildConditions(db: PgConnection): Promise<void> {
107
109
  await db.query('INSERT INTO managed_schema_version VALUES (22)')
108
110
  }
109
111
 
112
+ // Existing sessions create their upload key on first use.
113
+ async function migrateSessionUploadKeys(db: PgConnection): Promise<void> {
114
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 23')).rows.length > 0) return
115
+ await db.query('ALTER TABLE managed_session ADD COLUMN IF NOT EXISTS upload_key TEXT')
116
+ await db.query('INSERT INTO managed_schema_version VALUES (23)')
117
+ }
118
+
119
+ async function migrateViewSessions(db: PgConnection): Promise<void> {
120
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 24')).rows.length > 0) return
121
+ await db.query('ALTER TABLE managed_session ADD COLUMN IF NOT EXISTS viewer_session TEXT REFERENCES managed_session(id) ON DELETE CASCADE')
122
+ await db.query(VIEW_SESSION_INDEX)
123
+ await db.query('INSERT INTO managed_schema_version VALUES (24)')
124
+ }
125
+
126
+ async function migrateGithubCommits(db: PgConnection): Promise<void> {
127
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 25')).rows.length > 0) return
128
+ await db.query(postgresSchema(GITHUB_COMMIT_SCHEMA))
129
+ await db.query('INSERT INTO managed_schema_version VALUES (25)')
130
+ }
131
+
132
+ async function migrateTeamNpmScopes(db: PgConnection): Promise<void> {
133
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 26')).rows.length > 0) return
134
+ await db.query(postgresSchema(TEAM_NPM_SCOPE_SCHEMA))
135
+ await db.query('INSERT INTO managed_schema_version VALUES (26)')
136
+ }
137
+
138
+ async function migrateDependencyReviews(db: PgConnection): Promise<void> {
139
+ if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 27')).rows.length > 0) return
140
+ await db.query('ALTER TABLE managed_team ADD COLUMN IF NOT EXISTS dependency_review INTEGER NOT NULL DEFAULT 0')
141
+ await db.query(postgresSchema(DEPENDENCY_REVIEW_SCHEMA))
142
+ await db.query('INSERT INTO managed_schema_version VALUES (27)')
143
+ }
144
+
110
145
  async function initialize(db: PgConnection): Promise<void> {
111
146
  if (await currentSchema(db)) return
112
147
  await db.query('BEGIN')
@@ -180,7 +215,7 @@ async function initialize(db: PgConnection): Promise<void> {
180
215
  if ((await db.query('SELECT version FROM managed_schema_version WHERE version = 11')).rows.length === 0) {
181
216
  await db.query(postgresSchema(revisionSchema(true)) + '; INSERT INTO managed_schema_version VALUES (11)')
182
217
  }
183
- for (const migrate of [migrateRepositoryDefaultCache, migrateBundleBuildLeases, migrateBundleVisibility, migrateLinkReports, migrateRepositoryAliases, migrateHiddenTeams, migrateIssueFixes, migrateBundleProvenance, migrateUpstreamCache, migrateBundleBuildConditions]) await migrate(db)
218
+ for (const migrate of [migrateRepositoryDefaultCache, migrateBundleBuildLeases, migrateBundleVisibility, migrateLinkReports, migrateRepositoryAliases, migrateHiddenTeams, migrateIssueFixes, migrateBundleProvenance, migrateUpstreamCache, migrateBundleBuildConditions, migrateSessionUploadKeys, migrateViewSessions, migrateGithubCommits, migrateTeamNpmScopes, migrateDependencyReviews]) await migrate(db)
184
219
  await db.query('COMMIT')
185
220
  } catch (err) {
186
221
  await db.query('ROLLBACK')
@@ -1,6 +1,11 @@
1
+ import { DEPENDENCY_REVIEW_SCHEMA } from './dependency-reviews.ts'
1
2
  import { LINK_REPORT_SCHEMA } from './link-reports.ts'
2
3
  import { REPOSITORY_ALIAS_SCHEMA } from './repository-aliases.ts'
3
4
  import { WORKSPACE_SHARE_SCHEMA } from './workspace-shares.ts'
5
+ import { TEAM_NPM_SCOPE_SCHEMA } from './team-npm-scopes.ts'
6
+
7
+ // Created after the column migration, which predates it on older databases.
8
+ export const VIEW_SESSION_INDEX = 'CREATE INDEX IF NOT EXISTS managed_session_viewer_idx ON managed_session(viewer_session);'
4
9
 
5
10
  export const MANAGED_SCHEMA = `
6
11
  CREATE TABLE IF NOT EXISTS managed_user (
@@ -28,7 +33,13 @@ CREATE TABLE IF NOT EXISTS managed_session (
28
33
  user_id TEXT NOT NULL REFERENCES managed_user(id) ON DELETE CASCADE,
29
34
  csrf_token TEXT NOT NULL,
30
35
  created_at INTEGER NOT NULL,
31
- expires_at INTEGER NOT NULL
36
+ expires_at INTEGER NOT NULL,
37
+ -- Private X25519 JWK that this session's uploads are sealed to (see
38
+ -- common/managed/upload-seal.ts). Created on first use.
39
+ upload_key TEXT,
40
+ -- An administrator's read-only view as user_id: the admin's own session,
41
+ -- which must stay valid. Ending that session ends the view.
42
+ viewer_session TEXT REFERENCES managed_session(id) ON DELETE CASCADE
32
43
  ) STRICT;
33
44
 
34
45
  CREATE INDEX IF NOT EXISTS managed_session_user_idx ON managed_session(user_id);
@@ -183,6 +194,7 @@ CREATE TABLE IF NOT EXISTS managed_team (
183
194
  slug TEXT NOT NULL,
184
195
  name TEXT NOT NULL UNIQUE,
185
196
  hidden INTEGER NOT NULL DEFAULT 0,
197
+ dependency_review INTEGER NOT NULL DEFAULT 0,
186
198
  created_at INTEGER NOT NULL,
187
199
  updated_at INTEGER NOT NULL
188
200
  ) STRICT;
@@ -212,4 +224,4 @@ CREATE TABLE IF NOT EXISTS managed_team_user (
212
224
  ) STRICT;
213
225
 
214
226
  CREATE INDEX IF NOT EXISTS team_user_user_idx ON managed_team_user(user_id);
215
- ` + WORKSPACE_SHARE_SCHEMA + LINK_REPORT_SCHEMA + REPOSITORY_ALIAS_SCHEMA
227
+ ` + WORKSPACE_SHARE_SCHEMA + LINK_REPORT_SCHEMA + REPOSITORY_ALIAS_SCHEMA + TEAM_NPM_SCOPE_SCHEMA + DEPENDENCY_REVIEW_SCHEMA
@@ -1,6 +1,6 @@
1
1
  // Table identifiers are code constants. Renames retain rows and foreign keys;
2
2
  // an ambiguous old/new pair must be resolved before the server can start.
3
- export const MANAGED_TABLE_RENAMES = [
3
+ const MANAGED_TABLE_RENAMES = [
4
4
  ['selected_repo', 'managed_selected_repo'],
5
5
  ['team_repo', 'managed_team_repo'],
6
6
  ['team_user', 'managed_team_user'],
@@ -7,9 +7,9 @@ import { migrateReportLocations } from './report-migration.ts'
7
7
  import { initCommentMethods } from './comments.ts'
8
8
  import { initActivityMethods } from './activity.ts'
9
9
  import { revisionSchema } from './revisions.ts'
10
- import { MANAGED_SCHEMA } from './db-schema.ts'
10
+ import { MANAGED_SCHEMA, VIEW_SESSION_INDEX } from './db-schema.ts'
11
11
  import { STORAGE_SCHEMA } from './storage-db.ts'
12
- import { GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
12
+ import { GITHUB_COMMIT_SCHEMA, GITHUB_METADATA_SCHEMA, GITHUB_STATE_REASON_COLUMN } from './github-metadata.ts'
13
13
  import { MANAGED_ISSUE_SCHEMA } from './managed-issues.ts'
14
14
  import { BUNDLE_BUILD_LEASE_SCHEMA } from './bundle-build-leases.ts'
15
15
  import { UPSTREAM_CACHE_SCHEMA } from './upstream-cache.ts'
@@ -69,8 +69,11 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
69
69
  db.exec('PRAGMA synchronous = FULL;')
70
70
  db.exec('PRAGMA foreign_keys = ON;')
71
71
  migrateManagedTableNames(db)
72
- db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + MANAGED_ISSUE_SCHEMA + STORAGE_SCHEMA + BUNDLE_BUILD_LEASE_SCHEMA + UPSTREAM_CACHE_SCHEMA)
72
+ db.exec(MANAGED_SCHEMA + GITHUB_METADATA_SCHEMA + GITHUB_COMMIT_SCHEMA + MANAGED_ISSUE_SCHEMA + STORAGE_SCHEMA + BUNDLE_BUILD_LEASE_SCHEMA + UPSTREAM_CACHE_SCHEMA)
73
73
  ensureColumn(db, 'managed_selected_repo', 'cached_default_branch', 'TEXT')
74
+ ensureColumn(db, 'managed_session', 'upload_key', 'TEXT')
75
+ ensureColumn(db, 'managed_session', 'viewer_session', 'TEXT REFERENCES managed_session(id) ON DELETE CASCADE')
76
+ db.exec(VIEW_SESSION_INDEX)
74
77
  for (const table of ['managed_report', 'managed_bundle']) {
75
78
  ensureColumn(db, table, 'data_key', 'TEXT')
76
79
  ensureColumn(db, table, 'storage_encrypted', 'INTEGER NOT NULL DEFAULT 0')
@@ -109,6 +112,7 @@ export function openSqliteManagedDb(path: string, options: ManagedDbOptions = {}
109
112
  ensureColumn(db, 'managed_bundle', 'provenance', 'TEXT')
110
113
  ensureColumn(db, 'managed_bundle', 'build_conditions', 'TEXT')
111
114
  ensureColumn(db, 'managed_team', 'hidden', 'INTEGER NOT NULL DEFAULT 0')
115
+ ensureColumn(db, 'managed_team', 'dependency_review', 'INTEGER NOT NULL DEFAULT 0')
112
116
  ensureColumn(db, 'managed_finding_issue', 'auto_fix_url', 'TEXT')
113
117
  ensureColumn(db, 'managed_finding_issue', 'auto_fix_checked_at', 'INTEGER')
114
118
  ensureColumn(db, 'managed_selected_repo', 'active', 'INTEGER NOT NULL DEFAULT 1')