@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.40

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 (311) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/README.md +16 -0
  3. package/api/reap.ts +17 -0
  4. package/cli.js +6 -0
  5. package/client/dependency-paths.d.ts +2 -0
  6. package/client/dependency-paths.js +24 -0
  7. package/client/finding-link.js +305 -0
  8. package/client/linked-findings.d.ts +1 -0
  9. package/client/linked-findings.js +112 -0
  10. package/common/bundle-commit.d.ts +1 -0
  11. package/common/bundle-commit.js +4 -0
  12. package/common/bundle-compare-diff.d.ts +3 -0
  13. package/common/bundle-compare-diff.js +522 -0
  14. package/common/bundle-compare-inputs.d.ts +2 -0
  15. package/common/bundle-compare-inputs.js +65 -0
  16. package/common/bundle-metadata.d.ts +14 -0
  17. package/common/bundle-metadata.js +226 -0
  18. package/common/bundle-pkg-of.d.ts +1 -0
  19. package/common/bundle-pkg-of.js +90 -0
  20. package/common/bundle-reasons.d.ts +2 -0
  21. package/common/bundle-reasons.js +21 -0
  22. package/common/bundle-source-links.d.ts +6 -0
  23. package/common/bundle-source-links.js +216 -0
  24. package/common/bundle-sourcemap.d.ts +10 -0
  25. package/common/bundle-sourcemap.js +100 -0
  26. package/common/bundle-sources.d.ts +7 -0
  27. package/common/bundle-sources.js +310 -0
  28. package/common/bundle-stats.js +41 -0
  29. package/common/bundle-tabs.js +1 -0
  30. package/common/code-language.js +65 -0
  31. package/common/default-scan-models.ts +31 -0
  32. package/common/dependency-reviews.d.ts +23 -0
  33. package/common/dependency-reviews.js +81 -0
  34. package/common/finding-duplicates.js +58 -0
  35. package/common/finding-filters.js +274 -0
  36. package/common/finding-id.js +47 -0
  37. package/common/github-issue-labels.d.ts +1 -0
  38. package/common/github-issue-labels.js +11 -0
  39. package/common/github-pr.ts +56 -0
  40. package/common/gzip.js +59 -0
  41. package/common/line-diff.d.ts +8 -0
  42. package/common/line-diff.js +56 -0
  43. package/common/linked-workspace-groups.js +63 -0
  44. package/common/managed/comments.ts +34 -0
  45. package/common/managed/npm-packages.d.ts +5 -0
  46. package/common/managed/npm-packages.js +34 -0
  47. package/common/managed/permissions.ts +31 -0
  48. package/common/managed/report-content.ts +72 -0
  49. package/common/managed/report-filter.ts +108 -0
  50. package/common/managed/repository-alias.ts +65 -0
  51. package/common/managed/roles.ts +28 -0
  52. package/common/managed/routes.d.ts +2 -0
  53. package/common/managed/routes.js +254 -0
  54. package/common/managed/scan-models.ts +6 -0
  55. package/common/managed/team-app.d.ts +2 -0
  56. package/common/managed/team-app.js +31 -0
  57. package/common/managed/triage.ts +83 -0
  58. package/common/managed/upload-seal.ts +126 -0
  59. package/common/pretty-print.d.ts +5 -0
  60. package/common/pretty-print.js +24 -0
  61. package/common/revalidation-conflicts.js +48 -0
  62. package/common/revalidation-input-groups.js +56 -0
  63. package/common/save-error-reason.ts +20 -7
  64. package/common/scan-server.ts +13 -0
  65. package/common/server-info.ts +35 -0
  66. package/common/utf8.d.ts +3 -0
  67. package/common/utf8.js +45 -0
  68. package/common/workspace-app.js +23 -0
  69. package/common/workspace-groups.js +176 -0
  70. package/out/brotli-fallback.js +3 -3
  71. package/out/chunk-2KBF5TZ7.js +2 -0
  72. package/out/chunk-2XUEZHIP.js +28 -0
  73. package/out/chunk-35VE6CDH.js +2 -0
  74. package/out/chunk-3BZ7JERB.js +1 -0
  75. package/out/chunk-4F6JSF57.js +3 -0
  76. package/out/chunk-4XCDFGDP.js +10 -0
  77. package/out/chunk-AQ75QITE.js +1 -0
  78. package/out/chunk-B2RQY6OP.js +2 -0
  79. package/out/chunk-C4Z6ZBXV.js +10 -0
  80. package/out/chunk-CMQA4DM6.js +1 -0
  81. package/out/chunk-CVVJAXVY.js +2 -0
  82. package/out/chunk-DIJJNTRH.js +10 -0
  83. package/out/chunk-GJ4IUCNS.js +1 -0
  84. package/out/chunk-GNXOKIGP.js +80 -0
  85. package/out/chunk-GZ4MS3TE.js +13 -0
  86. package/out/chunk-IISEF2BZ.js +1 -0
  87. package/out/chunk-IU3CCINR.js +3 -0
  88. package/out/chunk-K3X5EJBQ.js +2 -0
  89. package/out/chunk-KEX2ATEW.js +2 -0
  90. package/out/chunk-KYCNJVB7.js +1 -0
  91. package/out/chunk-MYDDTA7W.js +225 -0
  92. package/out/chunk-NRPC74AM.js +1 -0
  93. package/out/chunk-OOJM4CTU.js +1 -0
  94. package/out/chunk-PNHBZGDD.js +1 -0
  95. package/out/chunk-R5O6FAW4.js +2 -0
  96. package/out/chunk-RFZQQXSG.js +17 -0
  97. package/out/chunk-RSHMVYNS.js +1 -0
  98. package/out/chunk-TGANXVTA.js +1 -0
  99. package/out/chunk-TUII34K2.js +1 -0
  100. package/out/chunk-TZ5KB2Y5.js +10 -0
  101. package/out/chunk-UN5ZSYLE.js +1 -0
  102. package/out/chunk-UNTRGE26.js +2 -0
  103. package/out/chunk-UXNUAIIC.js +33 -0
  104. package/out/chunk-UYAXVRVL.js +1 -0
  105. package/out/chunk-V3ME6FI5.js +2 -0
  106. package/out/chunk-V5O7M2YT.js +1 -0
  107. package/out/chunk-VIHGO77Y.js +1 -0
  108. package/out/chunk-VQJCXGIY.js +7 -0
  109. package/out/chunk-W4J3MGLB.js +1 -0
  110. package/out/chunk-WGG7KQUH.js +1 -0
  111. package/out/chunk-WZDTZE64.js +1 -0
  112. package/out/chunk-XYDGU6BM.js +51 -0
  113. package/out/chunk-XYFXXB2G.js +10 -0
  114. package/out/chunk-YVZH5SHP.js +1 -0
  115. package/out/chunk-ZBBOBQ43.js +1 -0
  116. package/out/chunk-ZMQQCGF6.js +2 -0
  117. package/out/client-managed-import.js +4 -0
  118. package/out/client-managed.js +37 -0
  119. package/out/client-sync.js +10 -13
  120. package/out/github-issue-authorized.html +3 -0
  121. package/out/github-issue-authorized.js +1 -0
  122. package/out/graph.js +17 -4
  123. package/out/index.html +90 -8
  124. package/out/prism.js +6 -2
  125. package/out/stasis.svg +20 -0
  126. package/out/terminal.js +365 -39
  127. package/out/view.css +1 -1
  128. package/out/view.js +83 -117
  129. package/package.json +297 -59
  130. package/risk/files/characters.js +196 -0
  131. package/risk/files/hosts.js +205 -0
  132. package/risk/files/readability.js +171 -0
  133. package/risk/files/runs-code.js +358 -0
  134. package/risk/files/script.js +114 -0
  135. package/risk/files/stamps.js +46 -0
  136. package/risk/index.d.ts +142 -0
  137. package/risk/index.js +7 -0
  138. package/risk/npm/assess.js +219 -0
  139. package/risk/npm/combined.js +65 -0
  140. package/risk/npm/compare.js +228 -0
  141. package/risk/npm/dependencies.js +153 -0
  142. package/risk/npm/diff.js +43 -0
  143. package/risk/npm/downloads.js +84 -0
  144. package/risk/npm/files.js +34 -0
  145. package/risk/npm/held.js +60 -0
  146. package/risk/npm/install-scripts.js +34 -0
  147. package/risk/npm/malware.js +36 -0
  148. package/risk/npm/manifest.js +65 -0
  149. package/risk/npm/new-dependency.js +30 -0
  150. package/risk/npm/old-version.js +108 -0
  151. package/risk/npm/registry.js +282 -0
  152. package/risk/npm/release.js +85 -0
  153. package/risk/npm/specs.js +124 -0
  154. package/risk/npm/tarball.js +113 -0
  155. package/server-common/database-config.ts +16 -0
  156. package/server-common/initialize.ts +18 -0
  157. package/server-common/npm-advisories.ts +101 -0
  158. package/{server → server-common}/origin.ts +5 -5
  159. package/server-common/reap.ts +48 -0
  160. package/server-common/scan-config.ts +19 -0
  161. package/server-common/standalone.ts +29 -0
  162. package/server-common/storage-crypto.ts +182 -0
  163. package/server-common/storage-log.ts +38 -0
  164. package/server-common/vercel-blob.ts +103 -0
  165. package/server-e2e/app.ts +486 -0
  166. package/{server → server-e2e}/auth.ts +5 -1
  167. package/{server → server-e2e}/bus-receiver.ts +2 -1
  168. package/{server → server-e2e}/cli.js +9 -4
  169. package/{server → server-e2e}/config.ts +56 -35
  170. package/{server → server-e2e}/db-neon.ts +2 -2
  171. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  172. package/{server → server-e2e}/db-stmt.ts +2 -2
  173. package/{server → server-e2e}/db.ts +7 -7
  174. package/{server → server-e2e}/http.ts +116 -18
  175. package/{server → server-e2e}/hub.ts +40 -9
  176. package/server-e2e/index.ts +17 -0
  177. package/server-e2e/lifecycle.ts +95 -0
  178. package/{server → server-e2e}/neon-driver.ts +2 -2
  179. package/{server → server-e2e}/npm-proxy.ts +12 -145
  180. package/{server → server-e2e}/objstore/blob-vercel.ts +10 -114
  181. package/{server → server-e2e}/objstore/blob.ts +1 -1
  182. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  183. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  184. package/{server → server-e2e}/objstore/init.ts +35 -18
  185. package/{server → server-e2e}/objstore/reaper.ts +1 -1
  186. package/server-e2e/objstore/rest-deny.ts +28 -0
  187. package/server-e2e/objstore/rest-mint.ts +224 -0
  188. package/{server → server-e2e}/objstore/rest.ts +27 -26
  189. package/{server → server-e2e}/objstore/sign.ts +105 -0
  190. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  191. package/{server → server-e2e}/objstore/store.ts +7 -7
  192. package/{server → server-e2e}/objstore/tokens.ts +2 -2
  193. package/{server → server-e2e}/pubsub.ts +12 -9
  194. package/{server → server-e2e}/sign.ts +4 -4
  195. package/{server → server-e2e}/sse-server.ts +102 -68
  196. package/{server → server-e2e}/sse-session.ts +28 -8
  197. package/{server → server-e2e}/static.ts +31 -21
  198. package/server-e2e/sync-handlers.ts +408 -0
  199. package/{server → server-e2e}/ws-server.ts +20 -9
  200. package/server-managed/activity.ts +245 -0
  201. package/server-managed/avatar-store.ts +12 -0
  202. package/server-managed/blob-store.ts +17 -0
  203. package/server-managed/brotli.ts +10 -0
  204. package/server-managed/bundle-advisories.ts +120 -0
  205. package/server-managed/bundle-advisory-inventory.ts +77 -0
  206. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  207. package/server-managed/bundle-build-diagnostics.js +23 -0
  208. package/server-managed/bundle-build-leases.ts +41 -0
  209. package/server-managed/bundle-build-worker.js +111 -0
  210. package/server-managed/bundle-build.ts +198 -0
  211. package/server-managed/bundle-cache.ts +199 -0
  212. package/server-managed/bundle-catalog.ts +29 -0
  213. package/server-managed/bundle-commits.ts +140 -0
  214. package/server-managed/bundle-store.ts +24 -0
  215. package/server-managed/bundle-summary-cache.ts +130 -0
  216. package/server-managed/bundle.ts +120 -0
  217. package/server-managed/cache-storage.ts +19 -0
  218. package/server-managed/cli.js +21 -0
  219. package/server-managed/combined.ts +46 -0
  220. package/server-managed/comments.ts +151 -0
  221. package/server-managed/config.ts +158 -0
  222. package/server-managed/content-access.ts +15 -0
  223. package/server-managed/crypto.ts +25 -0
  224. package/server-managed/db-methods.ts +1725 -0
  225. package/server-managed/db-neon.ts +367 -0
  226. package/server-managed/db-schema.ts +227 -0
  227. package/server-managed/db-table-names.ts +22 -0
  228. package/server-managed/db.ts +132 -0
  229. package/server-managed/dependency-reviews.ts +248 -0
  230. package/server-managed/github-app.ts +391 -0
  231. package/server-managed/github-errors.ts +68 -0
  232. package/server-managed/github-issue-links.ts +127 -0
  233. package/server-managed/github-issue-oauth.ts +47 -0
  234. package/server-managed/github-issues.ts +152 -0
  235. package/server-managed/github-metadata.ts +213 -0
  236. package/server-managed/github-oauth.ts +235 -0
  237. package/server-managed/github-pulls.ts +227 -0
  238. package/server-managed/http-response.ts +50 -0
  239. package/server-managed/http.ts +3472 -0
  240. package/server-managed/import-triage.ts +51 -0
  241. package/server-managed/index.ts +187 -0
  242. package/server-managed/kept.ts +28 -0
  243. package/server-managed/link-reports.ts +158 -0
  244. package/server-managed/locked-packages.ts +85 -0
  245. package/server-managed/lru.ts +30 -0
  246. package/server-managed/managed-issues.ts +75 -0
  247. package/server-managed/management-catalog.ts +45 -0
  248. package/server-managed/management.ts +99 -0
  249. package/server-managed/npm-insights.ts +440 -0
  250. package/server-managed/npm-kept.ts +65 -0
  251. package/server-managed/npm-loads.ts +167 -0
  252. package/server-managed/npm-manifest.ts +115 -0
  253. package/server-managed/npm-packages.ts +378 -0
  254. package/server-managed/npm-provenance.ts +123 -0
  255. package/server-managed/npm-risk.ts +177 -0
  256. package/server-managed/object-storage-disk.ts +148 -0
  257. package/server-managed/object-storage-vercel.ts +67 -0
  258. package/server-managed/object-storage.ts +74 -0
  259. package/server-managed/package-entry-points.ts +51 -0
  260. package/server-managed/pretty-print.ts +352 -0
  261. package/server-managed/public-workspace.ts +220 -0
  262. package/server-managed/rename-diff-worker.js +26 -0
  263. package/server-managed/rename-diff.ts +270 -0
  264. package/server-managed/repo-path.ts +21 -0
  265. package/server-managed/report-migration.ts +35 -0
  266. package/server-managed/report-query.ts +10 -0
  267. package/server-managed/report-response.ts +16 -0
  268. package/server-managed/report-sources.ts +178 -0
  269. package/server-managed/report-store.ts +47 -0
  270. package/server-managed/repository-aliases.ts +91 -0
  271. package/server-managed/repository-browser.ts +153 -0
  272. package/server-managed/repository-discovery.ts +82 -0
  273. package/server-managed/repository-policy.ts +27 -0
  274. package/server-managed/repository-risk.ts +418 -0
  275. package/server-managed/revisions.ts +35 -0
  276. package/server-managed/session.ts +92 -0
  277. package/server-managed/slugs.ts +38 -0
  278. package/server-managed/solidity-entry-points.ts +69 -0
  279. package/server-managed/sql-postgres.ts +31 -0
  280. package/server-managed/sql.ts +76 -0
  281. package/server-managed/static.ts +28 -0
  282. package/server-managed/storage-command.ts +16 -0
  283. package/server-managed/storage-db.ts +175 -0
  284. package/server-managed/storage-encryption.ts +279 -0
  285. package/server-managed/storage-maintenance.ts +256 -0
  286. package/server-managed/storage-payload.ts +103 -0
  287. package/server-managed/storage-stores.ts +85 -0
  288. package/server-managed/storage.ts +78 -0
  289. package/server-managed/team-app.ts +94 -0
  290. package/server-managed/team-catalog.ts +7 -0
  291. package/server-managed/team-feed.ts +173 -0
  292. package/server-managed/team-npm-scopes.ts +77 -0
  293. package/server-managed/team-reports.ts +228 -0
  294. package/server-managed/triage-response.ts +16 -0
  295. package/server-managed/type-only-worker.js +19 -0
  296. package/server-managed/type-only.ts +103 -0
  297. package/server-managed/uploads.ts +86 -0
  298. package/server-managed/upstream-cache.ts +215 -0
  299. package/server-managed/view-as.ts +58 -0
  300. package/server-managed/workspace-shares.ts +166 -0
  301. package/server.ts +50 -0
  302. package/server/index.ts +0 -477
  303. package/server/lifecycle.ts +0 -204
  304. package/server/sync-handlers.ts +0 -320
  305. /package/{LICENSE → LICENSE-MIT} +0 -0
  306. /package/{server → server-e2e}/config.example.json +0 -0
  307. /package/{server → server-e2e}/objstore/blob-fs.ts +0 -0
  308. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  309. /package/{server → server-e2e}/peer.ts +0 -0
  310. /package/{server → server-e2e}/util.ts +0 -0
  311. /package/{server → server-e2e}/validation.ts +0 -0
@@ -0,0 +1,256 @@
1
+ import type { Buffer } from 'node:buffer'
2
+ import { type StorageKey, decryptStorageStream, encryptStorageStream } from '../server-common/storage-crypto.ts'
3
+ import { ENCRYPTED_CACHE_PREFIX, type RawObjectStorage, deleteObjects, isBlobId, openObjectVersion } from './object-storage.ts'
4
+ import { type StorageDb, type StorageEncryptionState, type StorageMigrationRow, type StorageRow, type StorageRowKind, unwrapDataKey } from './storage-db.ts'
5
+ import { STORAGE_UPLOAD_TTL_MS } from './storage-encryption.ts'
6
+ import { StoragePayloadError, inspectStorageObject, storageOwner, storageRowPaths, verifiedStoragePayload, verifyStoragePayload } from './storage-payload.ts'
7
+
8
+ async function replacementFailure(raw: RawObjectStorage, identity: string, version: string, signal: AbortSignal): Promise<string> {
9
+ const format = (value: string) => value.startsWith('W/') ? 'weak' : value.startsWith('"') && value.endsWith('"') ? 'quoted' : 'unquoted'
10
+ const reason = `Conditional replacement rejected (object version precondition failed); download=${format(version)}`
11
+ // Diagnose only failed writes. A metadata read cannot authorize a retry with
12
+ // a different version: the downloaded bytes must remain bound to their own.
13
+ let current
14
+ try { current = await raw.head(identity, signal) }
15
+ catch { return `${reason}; metadata=unavailable` }
16
+ if (!current) return `${reason}; metadata=missing`
17
+ // Normalization is diagnostic only. Never use it for conditional writes or
18
+ // log the actual ETags, which can contain hashes of private payloads.
19
+ const value = (tag: string) => tag.replace(/^W\//u, '').replace(/^"(.*)"$/u, '$1')
20
+ const comparison = current.version === version ? 'same' : value(current.version) === value(version) ? 'format-only' : 'different'
21
+ return `${reason}; metadata=${format(current.version)}; comparison=${comparison}`
22
+ }
23
+
24
+ type MigrationPhase = 'prepare-key' | 'open-payload' | 'verify-existing' | 'encrypt-upload' | 'verify-replacement' | 'checkpoint'
25
+ async function migratePayload(raw: RawObjectStorage, db: StorageDb, type: StorageRowKind, row: StorageRow,
26
+ dataKey: Buffer, identity: string, signal: AbortSignal, progress: (phase: MigrationPhase) => void): Promise<boolean | string | null> {
27
+ progress('open-payload')
28
+ const source = await raw.open(identity, signal)
29
+ if (!source) return false
30
+ let inspected = await inspectStorageObject(source)
31
+ if (inspected.encrypted) {
32
+ progress('verify-existing')
33
+ // A previous PUT may have completed before a timeout or lost SQL ack.
34
+ try {
35
+ const decoded = await decryptStorageStream(inspected.stream, dataKey, identity)
36
+ await verifyStoragePayload(type, row, decoded.stream, signal, identity)
37
+ } catch {
38
+ signal.throwIfAborted()
39
+ // Legacy arbitrary bytes can share the magic prefix. A matching
40
+ // original upload hash is required before encrypting them as plaintext.
41
+ const legacy = await openObjectVersion(raw, identity, source.version, signal)
42
+ if (!legacy) return 'Payload disappeared or changed version before plaintext verification'
43
+ inspected = { ...legacy, encrypted: false }
44
+ }
45
+ }
46
+ if (!inspected.encrypted) {
47
+ progress('encrypt-upload')
48
+ signal.throwIfAborted()
49
+ const verified = verifiedStoragePayload(type, row, inspected.stream, signal, identity)
50
+ const encrypted = encryptStorageStream(verified, dataKey, identity, inspected.size)
51
+ let replaced: boolean
52
+ try { replaced = await raw.put(identity, encrypted, signal, source.version, inspected.size ?? undefined) }
53
+ // A provider may wrap a request-body error; keep safe payload diagnostics.
54
+ catch (err) { throw verified.errored ?? err }
55
+ finally { encrypted.destroy(); verified.destroy(); inspected.stream.destroy(); source.stream.destroy() }
56
+ if (!replaced) return await replacementFailure(raw, identity, source.version, signal)
57
+ // Do not mark the row encrypted until the stored bytes authenticate and
58
+ // match the original upload (including Brotli decompression).
59
+ progress('verify-replacement')
60
+ const replacement = await raw.open(identity, signal)
61
+ if (!replacement) {
62
+ if (!await db.getStorageRow(type, row.id)) return null
63
+ throw new StoragePayloadError('Migrated payload unavailable')
64
+ }
65
+ const decoded = await decryptStorageStream(replacement.stream, dataKey, identity)
66
+ await verifyStoragePayload(type, row, decoded.stream, signal, identity)
67
+ }
68
+ signal.throwIfAborted()
69
+ // Persist observed replacements, including both report representations
70
+ // if they coexist, before marking the whole row encrypted.
71
+ progress('checkpoint')
72
+ await raw.sync?.(identity, signal)
73
+ return true
74
+ }
75
+
76
+ async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey, item: StorageMigrationRow, signal: AbortSignal,
77
+ progress: (phase: MigrationPhase) => void): Promise<string | void> {
78
+ if (item.type === 'user') { await db.migrateStorageUserTokens(item.id); return }
79
+ progress('prepare-key')
80
+ const row = await db.ensureStorageDataKey(item.type, item.id)
81
+ if (!row || row.encrypted) return
82
+ const dataKey = unwrapDataKey(key, item.type, row)
83
+ try {
84
+ let found = false
85
+ for (const identity of storageRowPaths(item.type, row)) {
86
+ const result = await migratePayload(raw, db, item.type, row, dataKey, identity, signal, progress)
87
+ if (result === null) return
88
+ if (typeof result === 'string') return result
89
+ found ||= result
90
+ }
91
+ if (!found) {
92
+ if (!await db.getStorageRow(item.type, item.id)) return
93
+ throw new StoragePayloadError('Migration payload unavailable')
94
+ }
95
+ signal.throwIfAborted()
96
+ await db.markStorageEncrypted(item.type, item.id, row.dataKey!)
97
+ } finally { dataKey.fill(0) }
98
+ }
99
+
100
+ const CLEANUP_PREFIXES = ['cache/', 'reports/', 'bundles/', ENCRYPTED_CACHE_PREFIX]
101
+ function cleanupPosition(state: StorageEncryptionState): { prefix: number; cursor: string | null } {
102
+ const value = state.cleanupCursor === null ? { prefix: 0, cursor: null } : JSON.parse(state.cleanupCursor)
103
+ if (!Number.isInteger(value.prefix) || value.prefix < 0 || value.prefix >= CLEANUP_PREFIXES.length
104
+ || (value.cursor !== null && typeof value.cursor !== 'string')) throw new Error('Invalid legacy cleanup cursor')
105
+ return value
106
+ }
107
+
108
+ // The database directory may be shared. Recognize managed paths, including
109
+ // disk atomic-write temps, before treating any listed file as ours to remove.
110
+ function cleanupTarget(identity: string) {
111
+ const temp = /^(.+)\.([^.]+)\.tmp$/u.exec(identity)
112
+ const temporary = !!temp && isBlobId(temp[2]!)
113
+ const path = temporary ? temp![1]! : identity
114
+ const encryptedCache = path.startsWith(ENCRYPTED_CACHE_PREFIX)
115
+ if (encryptedCache && !temporary) return null
116
+ const owner = storageOwner(encryptedCache ? `cache/${path.slice(ENCRYPTED_CACHE_PREFIX.length)}` : path)
117
+ return owner ? { owner, temporary } : null
118
+ }
119
+
120
+ // Referenced-data migration never depends on Blob listings. A separate bounded
121
+ // inventory removes legacy caches and unreferenced plaintext; ciphertext
122
+ // orphans do not hold a recoverable data key and need no manifest-based GC.
123
+ async function cleanupLegacy(raw: RawObjectStorage, db: StorageDb, state: StorageEncryptionState, limit: number, signal: AbortSignal) {
124
+ const position = cleanupPosition(state), prefix = CLEANUP_PREFIXES[position.prefix]!
125
+ const page = await raw.list(prefix, position.cursor, Math.min(limit, 16), signal)
126
+ let found = 0, retryAt: number | null = null
127
+ for (const object of page.objects) {
128
+ signal.throwIfAborted()
129
+ const target = cleanupTarget(object.key)
130
+ if (!target) continue
131
+ const { owner, temporary } = target
132
+ if (!temporary && !owner.cache) {
133
+ const row = await db.getStorageRow(owner.type, owner.id)
134
+ if (row && storageRowPaths(owner.type, row).includes(object.key)) continue
135
+ }
136
+ const stored = await raw.head(object.key, signal)
137
+ if (!stored) continue
138
+ // A killed disk writer can leave plaintext or ciphertext in its temp file.
139
+ // Recheck its current age and give live writes a full staging grace window.
140
+ if (temporary && stored.modifiedAt > Date.now() - STORAGE_UPLOAD_TTL_MS) {
141
+ found++
142
+ retryAt = Math.min(retryAt ?? Infinity, stored.modifiedAt + STORAGE_UPLOAD_TTL_MS)
143
+ continue
144
+ }
145
+ // Old pre-activation orphans are legacy even when their arbitrary bytes
146
+ // share the magic prefix. Keep a full staging grace window around enable:
147
+ // Blob Last-Modified has only second precision, and a new encrypted upload
148
+ // may still be waiting for its SQL insert.
149
+ let legacy = temporary || owner.cache || stored.modifiedAt < state.enabledAt - STORAGE_UPLOAD_TTL_MS
150
+ if (!legacy) {
151
+ const source = await raw.open(object.key, signal)
152
+ if (!source) continue
153
+ try {
154
+ if (source.version !== stored.version) throw new Error('Legacy object changed during cleanup')
155
+ const inspected = await inspectStorageObject(source)
156
+ legacy = !inspected.encrypted
157
+ inspected.stream.destroy()
158
+ } finally { source.stream.destroy() }
159
+ }
160
+ if (legacy) {
161
+ found++
162
+ if (!await raw.delete(object.key, stored.version, signal)) throw new Error('Legacy object changed during cleanup')
163
+ }
164
+ }
165
+ const next = page.cursor === null ? position.prefix + 1 < CLEANUP_PREFIXES.length ? { prefix: position.prefix + 1, cursor: null } : null
166
+ : { ...position, cursor: page.cursor }
167
+ // A full pass without removals closes offset-pagination gaps left by deletes.
168
+ await db.advanceStorageCleanup(state.cleanupCursor, next === null ? null : JSON.stringify(next), found)
169
+ return { processed: page.objects.length || 1, retryAt }
170
+ }
171
+
172
+ interface StorageMigrationResult extends StorageEncryptionState {
173
+ retryAt: number | null
174
+ failures: { type: StorageMigrationRow['type']; id: string; message: string }[]
175
+ }
176
+ function rowFailure(row: StorageMigrationRow, message: string) {
177
+ const failure = { type: row.type, id: row.id, message }
178
+ // Emit before continuing: a later failure or terminated invocation must not
179
+ // hide earlier diagnostics. Never include provider errors or storage values.
180
+ console.warn('managed-storage-migration-row:', JSON.stringify(failure))
181
+ return failure
182
+ }
183
+ interface MigrationOptions { maxObjects?: number; maxMs?: number | undefined; signal?: AbortSignal | undefined }
184
+ export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
185
+ { maxObjects = 64, maxMs = 150_000, signal: stopping }: MigrationOptions = {}): Promise<StorageMigrationResult> {
186
+ if (!Number.isSafeInteger(maxObjects) || maxObjects < 1 || !Number.isSafeInteger(maxMs) || maxMs < 1) throw new Error('Invalid migration budget')
187
+ const deadline = Date.now() + maxMs, timeout = AbortSignal.timeout(maxMs)
188
+ const signal = stopping ? AbortSignal.any([stopping, timeout]) : timeout
189
+ const errors: unknown[] = []
190
+ const failures: StorageMigrationResult['failures'] = []
191
+ let processed = 0, retryAt: number | null = null, state = await db.getStorageEncryption()
192
+ if (!state) throw new Error('Storage encryption is not enabled; configure MANAGED_STORAGE_ENCRYPTION_KEY')
193
+ // At most one SQL pass per invocation. Checkpoint each row, including a
194
+ // failure: other rows progress, and the failed row is retried next pass.
195
+ while (!state.complete && processed < maxObjects && Date.now() < deadline && !signal.aborted) {
196
+ const rows = await db.listStorageMigrationRows(state.cursor, Math.min(16, maxObjects - processed))
197
+ if (stopping?.aborted) break
198
+ if (rows.length === 0) { await db.advanceStorageMigration(state.cursor, null); break }
199
+ for (const row of rows) {
200
+ if (Date.now() >= deadline || signal.aborted) break
201
+ console.info('managed-storage-migration-row-start:', JSON.stringify({ type: row.type, id: row.id }))
202
+ const startedAt = Date.now()
203
+ let phase: MigrationPhase | 'tokens' = 'tokens'
204
+ const logPhase = (aborted = false) => console.info('managed-storage-migration-row-phase:', JSON.stringify({
205
+ type: row.type, id: row.id, phase, elapsedMs: Date.now() - startedAt, ...(aborted ? { aborted: true } : {}),
206
+ }))
207
+ // Log directly on cancellation even if an underlying operation stalls
208
+ // while unwinding. These fixed phases contain no object/provider data.
209
+ const aborted = () => logPhase(true)
210
+ signal.addEventListener('abort', aborted, { once: true })
211
+ try {
212
+ const pending = await migrateRow(raw, db, key, row, signal, next => { phase = next; logPhase() })
213
+ if (pending) failures.push(rowFailure(row, pending))
214
+ }
215
+ catch (err) {
216
+ // A row started late gets a full budget next time. Skip an oversized
217
+ // first row until the next pass so later rows and tokens can advance.
218
+ if (stopping?.aborted || (signal.aborted && processed > 0)) break
219
+ const failure = signal.aborted
220
+ ? `Could not migrate ${row.type} ${row.id} within MANAGED_STORAGE_ENCRYPTION_MIGRATE_MAX_MS=${maxMs}; increase the budget`
221
+ : err instanceof StoragePayloadError ? err.message : null
222
+ if (failure === null) {
223
+ rowFailure(row, 'Migration operation failed')
224
+ errors.push(new Error(`Could not migrate ${row.type} ${row.id}`, { cause: err }))
225
+ } else failures.push(rowFailure(row, failure))
226
+ }
227
+ finally { signal.removeEventListener('abort', aborted) }
228
+ if (stopping?.aborted) break
229
+ await db.advanceStorageMigration(state.cursor, row.position)
230
+ processed++
231
+ state = (await db.getStorageEncryption())!
232
+ }
233
+ }
234
+ state = (await db.getStorageEncryption())!
235
+ while (!state.cleanupComplete && processed < maxObjects && Date.now() < deadline && !signal.aborted) {
236
+ try {
237
+ const cleanup = await cleanupLegacy(raw, db, state, maxObjects - processed, signal)
238
+ processed += cleanup.processed
239
+ retryAt = cleanup.retryAt
240
+ }
241
+ catch (err) { if (!signal.aborted) errors.push(err); break }
242
+ state = (await db.getStorageEncryption())!
243
+ if (retryAt !== null) break // Wait for staging grace rather than spinning over live temp files.
244
+ }
245
+ if (errors.length > 0) throw new AggregateError(errors, 'Storage migration has pending failures', { cause: errors[0] })
246
+ return { ...state, retryAt, failures }
247
+ }
248
+
249
+ export async function reapStorageUploads(raw: RawObjectStorage, db: StorageDb, now = Date.now(), stopping?: AbortSignal): Promise<number> {
250
+ if (stopping?.aborted) return 0
251
+ const timeout = AbortSignal.timeout(150_000)
252
+ const signal = stopping ? AbortSignal.any([stopping, timeout]) : timeout
253
+ await db.getStorageEncryption()
254
+ try { return await deleteObjects(raw, 'uploads/', signal, now - STORAGE_UPLOAD_TTL_MS) }
255
+ catch (err) { if (stopping?.aborted) return 0; throw err }
256
+ }
@@ -0,0 +1,103 @@
1
+ import { Buffer } from 'node:buffer'
2
+ import { createHash } from 'node:crypto'
3
+ import { PassThrough, Readable, Writable } from 'node:stream'
4
+ import { pipeline } from 'node:stream/promises'
5
+ import { createBrotliDecompress } from 'node:zlib'
6
+ import { STORAGE_MAGIC } from '../server-common/storage-crypto.ts'
7
+ import { type RawObject, isBlobId } from './object-storage.ts'
8
+ import type { StorageRow, StorageRowKind } from './storage-db.ts'
9
+
10
+ // A damaged/missing payload needs repair; retrying the whole cleanup job does
11
+ // not fix it. Keep this distinct from database, storage and cancellation errors.
12
+ export class StoragePayloadError extends Error {}
13
+
14
+ export function storageRowPaths(type: StorageRowKind, row: Pick<StorageRow, 'id' | 'kind'>): string[] {
15
+ return type === 'report' ? [`reports/${row.id}.br`, `reports/${row.id}`]
16
+ : [`bundles/${row.id}${row.kind === 'sourcemap' ? '.map.br' : ''}`]
17
+ }
18
+ // A cache of public content, which no row owns, kept unencrypted as avatars
19
+ // are: public npm versions' pretty-printed files (pretty-print.ts).
20
+ export const PUBLIC_CACHE = 'npm'
21
+ export const PUBLIC_CACHE_PREFIX = `cache/${PUBLIC_CACHE}/`
22
+ export function storageOwner(identity: string): { type: StorageRowKind; id: string; cache: boolean } | null {
23
+ const match = /^(reports|bundles)\/([^/]+)$/u.exec(identity)
24
+ if (match) {
25
+ const id = match[2]!.replace(match[1] === 'bundles' ? /\.map\.br$/u : /\.br$/u, '')
26
+ if (isBlobId(id)) return { type: match[1] === 'reports' ? 'report' : 'bundle', id, cache: false }
27
+ }
28
+ const cache = /^cache\/(?:bundles|report-sources)\/([a-f\d-]{36})\//u.exec(identity)
29
+ return cache && isBlobId(cache[1]!) ? { type: 'bundle', id: cache[1]!, cache: true } : null
30
+ }
31
+
32
+ // Peek without buffering the object or consuming bytes from its next reader.
33
+ export async function inspectStorageObject(stored: RawObject): Promise<RawObject & { encrypted: boolean }> {
34
+ const original = stored.stream
35
+ const iterator = original[Symbol.asyncIterator]()
36
+ const first: Buffer[] = [], prefix = Buffer.alloc(STORAGE_MAGIC.length)
37
+ let length = 0
38
+ try {
39
+ while (length < prefix.length) {
40
+ const next = await iterator.next()
41
+ if (next.done) break
42
+ const bytes = Buffer.from(next.value)
43
+ first.push(bytes)
44
+ length += bytes.copy(prefix, length, 0, prefix.length - length)
45
+ }
46
+ } catch (err) { original.destroy(); throw err }
47
+ async function* joined() {
48
+ try {
49
+ yield* first
50
+ for (;;) { const next = await iterator.next(); if (next.done) return; yield next.value }
51
+ } finally { await iterator.return?.(); original.destroy() }
52
+ }
53
+ const stream = Readable.from(joined(), { objectMode: false })
54
+ stream.once('close', () => original.destroy())
55
+ return { ...stored, stream, encrypted: length === prefix.length && prefix.equals(STORAGE_MAGIC) }
56
+ }
57
+
58
+ // Report hashes and sourcemap integrity describe the original upload, before Brotli storage.
59
+ // Hash incrementally, including decompression, so migration stays streaming.
60
+ export async function verifyStoragePayload(type: StorageRowKind, row: StorageRow, source: Readable, signal?: AbortSignal, identity?: string): Promise<void> {
61
+ const hash = createHash(type === 'report' ? 'sha256' : 'sha512')
62
+ const sink = new Writable({ write(chunk, _encoding, next) { hash.update(chunk); next() } })
63
+ const options = signal ? { signal } : {}
64
+ try {
65
+ if (type === 'bundle' && row.kind === 'sourcemap' || type === 'report' && identity?.endsWith('.br')) {
66
+ await pipeline(source, createBrotliDecompress(), sink, options)
67
+ } else await pipeline(source, sink, options)
68
+ } catch (err) {
69
+ const code = (err as NodeJS.ErrnoException).code ?? ''
70
+ if (['Z_DATA_ERROR', 'Z_BUF_ERROR'].includes(code) || code.startsWith('ERR__ERROR_FORMAT_')) {
71
+ throw new StoragePayloadError(`Stored ${type === 'report' ? 'report' : 'sourcemap'} cannot be decompressed`, { cause: err })
72
+ }
73
+ throw err
74
+ }
75
+ const actual = type === 'report' ? hash.digest('base64url') : `sha512-${hash.digest('base64')}`
76
+ if (actual !== row.hash) throw new StoragePayloadError('Stored payload does not match its upload hash')
77
+ }
78
+
79
+ // Forward the stored representation with backpressure, but withhold EOF until
80
+ // its upload hash passes. Encryption cannot emit its final frame, and an atomic
81
+ // PUT cannot commit, until verification (including Brotli decompression) ends.
82
+ export function verifiedStoragePayload(type: StorageRowKind, row: StorageRow, source: Readable, signal?: AbortSignal, identity?: string): Readable {
83
+ const input = new PassThrough(), output = new PassThrough()
84
+ const verification = verifyStoragePayload(type, row, input, signal, identity)
85
+ // Propagate asynchronous decompression errors even if the source is stalled.
86
+ void verification.catch(err => { source.destroy(err); output.destroy(err) })
87
+ const forwarding = pipeline(source, async function* (chunks) {
88
+ try {
89
+ for await (const chunk of chunks) {
90
+ await new Promise<void>((resolve, reject) => { input.write(chunk, err => err ? reject(err) : resolve()) })
91
+ yield chunk
92
+ }
93
+ input.end()
94
+ await verification
95
+ } finally {
96
+ input.destroy()
97
+ await verification.catch(() => {})
98
+ }
99
+ }, output, signal ? { signal } : {})
100
+ // pipeline forwards errors to output and tears down input on early cancellation.
101
+ void forwarding.catch(() => {})
102
+ return output
103
+ }
@@ -0,0 +1,85 @@
1
+ // Logical stores share one encryption boundary. Compression, identifiers and
2
+ // public response bytes stay the same with either physical backend.
3
+ import { Buffer } from 'node:buffer'
4
+ import type { AvatarStore } from './avatar-store.ts'
5
+ import type { BlobStore } from './blob-store.ts'
6
+ import type { BundleCacheStorage } from './bundle-cache.ts'
7
+ import { createBundleStore } from './bundle-store.ts'
8
+ import { createReportStore } from './report-store.ts'
9
+ import { CacheMissError, type CacheStorage, validateCacheKey } from './cache-storage.ts'
10
+ import { type ObjectStorage, isBlobId } from './object-storage.ts'
11
+ import { PUBLIC_CACHE } from './storage-payload.ts'
12
+
13
+ function id(value: string): string {
14
+ if (!isBlobId(value)) throw new Error('Invalid managed blob id')
15
+ return value
16
+ }
17
+
18
+ function blobStore(objects: ObjectStorage, name: string, suffix = ''): BlobStore {
19
+ const path = (value: string) => `${name}/${id(value)}${suffix}`
20
+ return {
21
+ put: (value, bytes) => objects.put(path(value), bytes),
22
+ get: value => objects.get(path(value)),
23
+ open: value => objects.open(path(value)),
24
+ delete: value => objects.delete(path(value)),
25
+ }
26
+ }
27
+
28
+ function cacheStore(objects: ObjectStorage, name: string): CacheStorage {
29
+ const path = (key: string) => `cache/${name}/${validateCacheKey(key)}`
30
+ return {
31
+ exists: key => objects.exists(path(key)),
32
+ put: async (key, bytes) => { await objects.put(path(key), bytes) },
33
+ async open(key) {
34
+ const value = await objects.open(path(key))
35
+ if (!value) throw new CacheMissError()
36
+ return value
37
+ },
38
+ delete: prefix => objects.deletePrefix(`${path(prefix)}/`),
39
+ }
40
+ }
41
+
42
+ function avatarStore(objects: ObjectStorage, sidecar: boolean): AvatarStore {
43
+ return {
44
+ async put(value, contentType, bytes) {
45
+ const path = `avatars/${id(value)}`, type = contentType.split(';', 1)[0]!.trim()
46
+ if (!/^image\/[a-z0-9.+-]+$/iu.test(type)) throw new Error('Invalid avatar type')
47
+ if (sidecar) {
48
+ await objects.put(path, bytes)
49
+ await objects.put(`${path}.type`, Buffer.from(type))
50
+ } else await objects.put(path, Buffer.concat([Buffer.from(`${type}\n`), bytes]))
51
+ },
52
+ async get(value) {
53
+ const path = `avatars/${id(value)}`
54
+ const bytes = await objects.get(path)
55
+ if (!bytes) return null
56
+ if (sidecar) {
57
+ const type = await objects.get(`${path}.type`)
58
+ return type ? { bytes, contentType: type.toString().trim() || 'application/octet-stream' } : null
59
+ }
60
+ const split = bytes.indexOf(10)
61
+ if (split < 0 || split > 100) throw new Error('Invalid cached avatar')
62
+ return { contentType: bytes.subarray(0, split).toString(), bytes: bytes.subarray(split + 1) }
63
+ },
64
+ }
65
+ }
66
+
67
+ export function createManagedStores(objects: ObjectStorage, avatarSidecar: boolean) {
68
+ const bundles = cacheStore(objects, 'bundles')
69
+ const cacheStorage: BundleCacheStorage = {
70
+ exists: (value, file) => bundles.exists(`${id(value)}/${file}`),
71
+ put: (value, file, bytes) => bundles.put(`${id(value)}/${file}`, bytes),
72
+ open: (value, file) => bundles.open(`${id(value)}/${file}`),
73
+ delete: value => bundles.delete(id(value)),
74
+ }
75
+ return {
76
+ reportStore: createReportStore(blobStore(objects, 'reports'), blobStore(objects, 'reports', '.br'),
77
+ (value, bytes) => objects.put(`reports/${id(value)}.br`, bytes, { replaceReport: true })),
78
+ bundleStore: createBundleStore(blobStore(objects, 'bundles'), blobStore(objects, 'bundles', '.map.br')),
79
+ uploadStore: blobStore(objects, 'uploads'),
80
+ avatarStore: avatarStore(objects, avatarSidecar),
81
+ cacheStorage,
82
+ reportSourcesStorage: cacheStore(objects, 'report-sources'),
83
+ npmCacheStorage: cacheStore(objects, PUBLIC_CACHE),
84
+ }
85
+ }
@@ -0,0 +1,78 @@
1
+ // Backend selection is shared by standalone, combined and function entrypoints.
2
+ import { dirname } from 'node:path'
3
+ import { parseStorageKey } from '../server-common/storage-crypto.ts'
4
+ import { createBundleCache } from './bundle-cache.ts'
5
+ import { createPrettyCache, stalePrettyRemover } from './pretty-print.ts'
6
+ import { createReportSourcesCache } from './report-sources.ts'
7
+ import { openNeonManagedDb } from './db-neon.ts'
8
+ import type { ManagedConfig } from './config.ts'
9
+ import { createEncryptedObjectStorage } from './storage-encryption.ts'
10
+ import { createManagedStores } from './storage-stores.ts'
11
+ import { createDiskObjectStorage } from './object-storage-disk.ts'
12
+ import { openVercelObjectStorage } from './object-storage-vercel.ts'
13
+ import { migrateStorage, reapStorageUploads } from './storage-maintenance.ts'
14
+
15
+ // Database-only callers (notably status) must not activate byte encryption.
16
+ export async function openManagedStorageDb(config: ManagedConfig) {
17
+ const options = { triageHistoryLimit: config.triageHistoryLimit, storageEncryptionKey: parseStorageKey(config.storageEncryptionKey) }
18
+ return config.neonUrl ? openNeonManagedDb(config.neonUrl, options)
19
+ : (await import('./db.ts')).openSqliteManagedDb(config.dbPath, options)
20
+ }
21
+
22
+ export async function openManagedStorage(config: ManagedConfig) {
23
+ const key = parseStorageKey(config.storageEncryptionKey)
24
+ if (config.storageEncryptionMigrate && !key) throw new Error('MANAGED_STORAGE_ENCRYPTION_MIGRATE requires MANAGED_STORAGE_ENCRYPTION_KEY')
25
+ if (config.neonUrl && !config.blobToken) throw new Error('Managed Neon mode requires BLOB_READ_WRITE_TOKEN')
26
+ if (config.serverless && !config.neonUrl) throw new Error('Serverless managed storage requires Neon')
27
+ const db = await openManagedStorageDb(config)
28
+ try {
29
+ const raw = config.neonUrl ? await openVercelObjectStorage(config.blobToken!) : createDiskObjectStorage(dirname(config.dbPath))
30
+ // Enable before exposing stores or token writers. Concurrent first starts
31
+ // serialize activation in the database and validate the winning key.
32
+ if (key && !await db.getStorageEncryption()) {
33
+ if (config.vercelPreview) throw new Error('Storage encryption must be enabled outside a Vercel preview deployment')
34
+ await db.enableStorageEncryption()
35
+ }
36
+ const objects = await createEncryptedObjectStorage(raw, db, key)
37
+ const storage = createManagedStores(objects, !config.neonUrl)
38
+ // Move legacy team link uploads into encrypted SQL before serving catalogs.
39
+ // The DB transaction commits first; a failed blob cleanup cannot lose links.
40
+ for (const report of await db.listLegacyLinkReports()) {
41
+ if (!key) throw new Error('Migrating managed link reports requires MANAGED_STORAGE_ENCRYPTION_KEY')
42
+ let bytes
43
+ try { bytes = await storage.reportStore.get(report.id) } catch (error) {
44
+ if (!await db.getReport(report.id)) continue // another instance completed migration
45
+ throw error
46
+ }
47
+ if (!bytes) {
48
+ if (!await db.getReport(report.id)) continue
49
+ throw new Error(`Missing legacy link report: ${report.id}`)
50
+ }
51
+ if (await db.migrateLinkReport(report.id, bytes.toString('utf8'))) {
52
+ await storage.reportStore.delete(report.id).catch(error => console.warn('managed: legacy link cleanup failed:', error))
53
+ }
54
+ }
55
+ const bundleCache = createBundleCache(storage.cacheStorage, db, storage.bundleStore)
56
+ return { ...storage, db, uploadStore: config.neonUrl ? storage.uploadStore : undefined, bundleCache,
57
+ reportSourcesCache: createReportSourcesCache(storage.reportSourcesStorage, db, storage.reportStore, storage.bundleStore, bundle => bundleCache.sourcemapEdges(bundle)),
58
+ prettyCache: createPrettyCache(storage.cacheStorage, storage.npmCacheStorage, db, storage.bundleStore),
59
+ reapPrettyCopies: stalePrettyRemover(storage.npmCacheStorage),
60
+ async reapStorage(signal?: AbortSignal) {
61
+ if (!config.storageEncryptionMigrate || signal?.aborted) return
62
+ const state = await db.getStorageEncryption()
63
+ if (state?.complete && state.cleanupComplete) return
64
+ console.info('managed-storage-migration-start:', JSON.stringify({ migrated: state?.migrated, cursor: state?.cursor }))
65
+ const result = await migrateStorage(raw, db, key!, { maxMs: config.storageEncryptionMigrateMaxMs, signal })
66
+ console.info('managed-storage-migration:', JSON.stringify({
67
+ complete: result.complete === 1, cleanupComplete: result.cleanupComplete === 1,
68
+ migrated: result.migrated, cursor: result.cursor, retryAt: result.retryAt,
69
+ failed: result.failures.length, cancelled: signal?.aborted === true,
70
+ }))
71
+ },
72
+ ...(config.neonUrl ? { reapUploads: (signal?: AbortSignal) => reapStorageUploads(raw, db, Date.now(), signal) } : {}),
73
+ }
74
+ } catch (err) {
75
+ await db.close()
76
+ throw err
77
+ }
78
+ }
@@ -0,0 +1,94 @@
1
+ // Whether each team's sidebar entry opens collapsed to its App findings,
2
+ // decided before the catalog is sent so the first paint is already final. A
3
+ // team is classified from the same filtered, published workspace its view
4
+ // loads. Results are keyed by the catalog's own content keys: an unchanged
5
+ // catalog reuses them, and every change that can move a result also changes
6
+ // the catalog revision that clients refresh on.
7
+ import { type TeamAppMetadata, managedTeamAppMetadata } from '../common/managed/team-app.js'
8
+ import type { BlobStore } from './blob-store.ts'
9
+ import type { ManagedDb, TeamReportAccessSnapshot, UserTeam } from './db.ts'
10
+ import type { WorkspaceShareSnapshot } from './workspace-shares.ts'
11
+ import { TeamReportsError, loadTeamReportsResponse } from './team-reports.ts'
12
+
13
+ export type { TeamAppMetadata }
14
+
15
+ const CACHE_MAX_ENTRIES = 4096
16
+ // Each workspace build already reads four reports at a time.
17
+ const CLASSIFY_CONCURRENCY = 2
18
+ const caches = new WeakMap<ManagedDb, Map<string, TeamAppMetadata>>()
19
+ const pendingJobs = new WeakMap<ManagedDb, Map<string, Promise<TeamAppMetadata | null>>>()
20
+
21
+ // Published reports with their content keys, and the team's own key (access,
22
+ // scopes and links). Equal keys describe the same filtered workspace, whoever
23
+ // reads it, so accounts with the same access share one classification.
24
+ export function teamAppKey(team: UserTeam): string {
25
+ return JSON.stringify([team.cacheKey ?? null, published(team).map(report => [report.id, report.cacheKey]).toSorted()])
26
+ }
27
+
28
+ function published(team: UserTeam) {
29
+ return team.reports.filter(report => report.visible !== false)
30
+ }
31
+
32
+ function remember(db: ManagedDb, key: string, app: TeamAppMetadata): TeamAppMetadata {
33
+ let cache = caches.get(db)
34
+ if (!cache) { cache = new Map(); caches.set(db, cache) }
35
+ cache.delete(key)
36
+ cache.set(key, app)
37
+ for (const oldest of cache.keys()) {
38
+ if (cache.size <= CACHE_MAX_ENTRIES) break
39
+ cache.delete(oldest)
40
+ }
41
+ return app
42
+ }
43
+
44
+ // Null when the team cannot be classified right now: its catalog went stale,
45
+ // the access snapshot is gone, or storage failed. The sidebar then keeps the
46
+ // team expanded, and a later catalog asks again. Oversized and unreadable
47
+ // workspaces never have an App view, so they are remembered as such.
48
+ async function classify(db: ManagedDb, store: BlobStore, key: string, team: UserTeam,
49
+ snapshot: () => Promise<TeamReportAccessSnapshot | null>): Promise<TeamAppMetadata | null> {
50
+ const cached = caches.get(db)?.get(key)
51
+ if (cached) return remember(db, key, cached)
52
+ const ids = new Set(published(team).map(report => report.id))
53
+ if (ids.size === 0) return remember(db, key, { appMode: false })
54
+ let jobs = pendingJobs.get(db)
55
+ if (!jobs) { jobs = new Map(); pendingJobs.set(db, jobs) }
56
+ const existing = jobs.get(key)
57
+ if (existing) return existing
58
+ const job = (async () => {
59
+ const access = await snapshot()
60
+ // A catalog read before a concurrent publication names another report set.
61
+ if (!access?.teamId || access.reports.length !== ids.size || access.reports.some(report => !ids.has(report.id))) return null
62
+ const body = JSON.parse((await loadTeamReportsResponse(db, store, access)).toString('utf8')) as {
63
+ reports: { filename: string; data: unknown }[]; links?: string[][]
64
+ }
65
+ return remember(db, key, managedTeamAppMetadata(Object.assign(body.reports, { links: body.links ?? [] })))
66
+ })().catch((error: unknown) => {
67
+ if (error instanceof TeamReportsError && [413, 422].includes(error.status)) return remember(db, key, { appMode: false })
68
+ if (!(error instanceof TeamReportsError)) console.warn('managed: team App classification failed:', error)
69
+ return null
70
+ })
71
+ jobs.set(key, job)
72
+ try { return await job } finally { jobs.delete(key) }
73
+ }
74
+
75
+ // Classify a user's catalog, keyed by teamAppKey. `known` carries results from
76
+ // an earlier read of the same request, so only changed teams are classified.
77
+ export async function teamAppStates(db: ManagedDb, store: BlobStore, sessionId: string, teams: readonly UserTeam[],
78
+ known: ReadonlyMap<string, TeamAppMetadata | null> = new Map()): Promise<Map<string, TeamAppMetadata | null>> {
79
+ const states = new Map(known)
80
+ const missing = [...new Map(teams.map(team => [teamAppKey(team), team])).entries()].filter(([key]) => !states.has(key))
81
+ let next = 0
82
+ await Promise.all(Array.from({ length: Math.min(CLASSIFY_CONCURRENCY, missing.length) }, async () => {
83
+ while (next < missing.length) {
84
+ const [key, team] = missing[next++]!
85
+ states.set(key, await classify(db, store, key, team, () => db.getTeamReportAccessSnapshot(sessionId, Date.now(), team.id)))
86
+ }
87
+ }))
88
+ return states
89
+ }
90
+
91
+ // A public link's workspace is its own access snapshot.
92
+ export function sharedTeamApp(db: ManagedDb, store: BlobStore, snapshot: WorkspaceShareSnapshot): Promise<TeamAppMetadata | null> {
93
+ return classify(db, store, JSON.stringify(['share', teamAppKey(snapshot.team)]), snapshot.team, () => Promise.resolve(snapshot))
94
+ }
@@ -0,0 +1,7 @@
1
+ import { createHash } from 'node:crypto'
2
+
3
+ // Version only this user's visible catalog, including access and content keys.
4
+ // REST and the live feed must describe exactly the same snapshot.
5
+ export function teamCatalogRevision(teams: readonly unknown[]): string {
6
+ return createHash('sha256').update(JSON.stringify(teams)).digest('base64url')
7
+ }