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

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 (40) hide show
  1. package/common/server-info.ts +1 -0
  2. package/out/client-managed-import.js +18 -18
  3. package/out/client-managed.js +66 -65
  4. package/out/prism.js +2 -2
  5. package/out/view.css +1 -1
  6. package/out/view.js +182 -182
  7. package/package.json +12 -2
  8. package/server-common/storage-crypto.ts +182 -0
  9. package/server-common/storage-log.ts +5 -1
  10. package/server-common/vercel-blob.ts +6 -3
  11. package/server-e2e/config.ts +4 -0
  12. package/server-managed/avatar-store.ts +1 -40
  13. package/server-managed/blob-store.ts +3 -52
  14. package/server-managed/bundle-cache.ts +0 -28
  15. package/server-managed/bundle-store.ts +3 -7
  16. package/server-managed/cache-storage.ts +0 -25
  17. package/server-managed/cli.js +10 -2
  18. package/server-managed/config.ts +14 -2
  19. package/server-managed/db-methods.ts +56 -25
  20. package/server-managed/db-neon.ts +9 -4
  21. package/server-managed/db.ts +7 -1
  22. package/server-managed/http.ts +93 -62
  23. package/server-managed/import-triage.ts +6 -3
  24. package/server-managed/index.ts +32 -9
  25. package/server-managed/object-storage-disk.ts +144 -0
  26. package/server-managed/object-storage-vercel.ts +60 -0
  27. package/server-managed/object-storage.ts +71 -0
  28. package/server-managed/report-query.ts +6 -0
  29. package/server-managed/repository-browser.ts +5 -1
  30. package/server-managed/solidity-entry-points.ts +69 -0
  31. package/server-managed/sql.ts +0 -5
  32. package/server-managed/storage-command.ts +16 -0
  33. package/server-managed/storage-db.ts +168 -0
  34. package/server-managed/storage-encryption.ts +211 -0
  35. package/server-managed/storage-maintenance.ts +182 -0
  36. package/server-managed/storage-payload.ts +60 -0
  37. package/server-managed/storage-stores.ts +81 -0
  38. package/server-managed/storage.ts +46 -24
  39. package/server-managed/uploads.ts +30 -12
  40. package/server-managed/blob-vercel.ts +0 -125
@@ -0,0 +1,81 @@
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 { type CacheStorage, validateCacheKey } from './cache-storage.ts'
9
+ import { type ObjectStorage, isBlobId } from './object-storage.ts'
10
+
11
+ function id(value: string): string {
12
+ if (!isBlobId(value)) throw new Error('Invalid managed blob id')
13
+ return value
14
+ }
15
+
16
+ function blobStore(objects: ObjectStorage, name: string, suffix = ''): BlobStore {
17
+ const path = (value: string) => `${name}/${id(value)}${suffix}`
18
+ return {
19
+ put: (value, bytes) => objects.put(path(value), bytes),
20
+ get: value => objects.get(path(value)),
21
+ open: value => objects.open(path(value)),
22
+ delete: value => objects.delete(path(value)),
23
+ }
24
+ }
25
+
26
+ function cacheStore(objects: ObjectStorage, name: string): CacheStorage {
27
+ const path = (key: string) => `cache/${name}/${validateCacheKey(key)}`
28
+ return {
29
+ exists: key => objects.exists(path(key)),
30
+ put: async (key, bytes) => { await objects.put(path(key), bytes) },
31
+ async open(key) {
32
+ const value = await objects.open(path(key))
33
+ if (!value) throw new Error('Managed cache unavailable')
34
+ return value
35
+ },
36
+ delete: prefix => objects.deletePrefix(`${path(prefix)}/`),
37
+ }
38
+ }
39
+
40
+ function avatarStore(objects: ObjectStorage, sidecar: boolean): AvatarStore {
41
+ return {
42
+ async put(value, contentType, bytes) {
43
+ const path = `avatars/${id(value)}`, type = contentType.split(';', 1)[0]!.trim()
44
+ if (!/^image\/[a-z0-9.+-]+$/iu.test(type)) throw new Error('Invalid avatar type')
45
+ if (sidecar) {
46
+ await objects.put(path, bytes)
47
+ await objects.put(`${path}.type`, Buffer.from(type))
48
+ } else await objects.put(path, Buffer.concat([Buffer.from(`${type}\n`), bytes]))
49
+ },
50
+ async get(value) {
51
+ const path = `avatars/${id(value)}`
52
+ const bytes = await objects.get(path)
53
+ if (!bytes) return null
54
+ if (sidecar) {
55
+ const type = await objects.get(`${path}.type`)
56
+ return type ? { bytes, contentType: type.toString().trim() || 'application/octet-stream' } : null
57
+ }
58
+ const split = bytes.indexOf(10)
59
+ if (split < 0 || split > 100) throw new Error('Invalid cached avatar')
60
+ return { contentType: bytes.subarray(0, split).toString(), bytes: bytes.subarray(split + 1) }
61
+ },
62
+ }
63
+ }
64
+
65
+ export function createManagedStores(objects: ObjectStorage, avatarSidecar: boolean) {
66
+ const bundles = cacheStore(objects, 'bundles')
67
+ const cacheStorage: BundleCacheStorage = {
68
+ exists: (value, file) => bundles.exists(`${id(value)}/${file}`),
69
+ put: (value, file, bytes) => bundles.put(`${id(value)}/${file}`, bytes),
70
+ open: (value, file) => bundles.open(`${id(value)}/${file}`),
71
+ delete: value => bundles.delete(id(value)),
72
+ }
73
+ return {
74
+ reportStore: blobStore(objects, 'reports'),
75
+ bundleStore: createBundleStore(blobStore(objects, 'bundles'), blobStore(objects, 'bundles', '.map.br')),
76
+ uploadStore: blobStore(objects, 'uploads'),
77
+ avatarStore: avatarStore(objects, avatarSidecar),
78
+ cacheStorage,
79
+ reportSourcesStorage: cacheStore(objects, 'report-sources'),
80
+ }
81
+ }
@@ -1,34 +1,56 @@
1
1
  // Backend selection is shared by standalone, combined and function entrypoints.
2
- import { dirname, join } from 'node:path'
3
- import { createDiskAvatarStore } from './avatar-store.ts'
4
- import { createDiskBlobStore } from './blob-store.ts'
5
- import { createDiskBundleStore } from './bundle-store.ts'
6
- import { createBundleCache, createDiskBundleCache } from './bundle-cache.ts'
7
- import { createDiskCacheStorage } from './cache-storage.ts'
2
+ import { dirname } from 'node:path'
3
+ import { parseStorageKey } from '../server-common/storage-crypto.ts'
4
+ import { createBundleCache } from './bundle-cache.ts'
8
5
  import { createReportSourcesCache } from './report-sources.ts'
9
- import { openManagedVercelStorage } from './blob-vercel.ts'
10
6
  import { openNeonManagedDb } from './db-neon.ts'
11
7
  import type { ManagedConfig } from './config.ts'
8
+ import { createEncryptedObjectStorage } from './storage-encryption.ts'
9
+ import { createManagedStores } from './storage-stores.ts'
10
+ import { createDiskObjectStorage } from './object-storage-disk.ts'
11
+ import { openVercelObjectStorage } from './object-storage-vercel.ts'
12
+ import { migrateStorage, reapStorageUploads } from './storage-maintenance.ts'
13
+
14
+ // Database-only callers (notably status) must not activate byte encryption.
15
+ export async function openManagedStorageDb(config: ManagedConfig) {
16
+ const options = { triageHistoryLimit: config.triageHistoryLimit, storageEncryptionKey: parseStorageKey(config.storageEncryptionKey) }
17
+ return config.neonUrl ? openNeonManagedDb(config.neonUrl, options)
18
+ : (await import('./db.ts')).openSqliteManagedDb(config.dbPath, options)
19
+ }
12
20
 
13
21
  export async function openManagedStorage(config: ManagedConfig) {
14
- const options = { triageHistoryLimit: config.triageHistoryLimit }
15
- if (config.neonUrl) {
16
- if (!config.blobToken) throw new Error('Managed Neon mode requires BLOB_READ_WRITE_TOKEN')
17
- const storage = await openManagedVercelStorage(config.blobToken)
18
- const db = await openNeonManagedDb(config.neonUrl, options)
19
- return { ...storage, db, bundleCache: createBundleCache(storage.cacheStorage, db, storage.bundleStore),
22
+ const key = parseStorageKey(config.storageEncryptionKey)
23
+ if (config.storageEncryptionMigrate && !key) throw new Error('MANAGED_STORAGE_ENCRYPTION_MIGRATE requires MANAGED_STORAGE_ENCRYPTION_KEY')
24
+ if (config.neonUrl && !config.blobToken) throw new Error('Managed Neon mode requires BLOB_READ_WRITE_TOKEN')
25
+ if (config.serverless && !config.neonUrl) throw new Error('Serverless managed storage requires Neon')
26
+ const db = await openManagedStorageDb(config)
27
+ try {
28
+ const raw = config.neonUrl ? await openVercelObjectStorage(config.blobToken!) : createDiskObjectStorage(dirname(config.dbPath))
29
+ // Enable before exposing stores or token writers. Concurrent first starts
30
+ // serialize activation in the database and validate the winning key.
31
+ if (key && !await db.getStorageEncryption()) {
32
+ if (config.vercelPreview) throw new Error('Storage encryption must be enabled outside a Vercel preview deployment')
33
+ await db.enableStorageEncryption()
34
+ }
35
+ const objects = await createEncryptedObjectStorage(raw, db, key)
36
+ const storage = createManagedStores(objects, !config.neonUrl)
37
+ return { ...storage, db, uploadStore: config.neonUrl ? storage.uploadStore : undefined,
38
+ bundleCache: createBundleCache(storage.cacheStorage, db, storage.bundleStore),
20
39
  reportSourcesCache: createReportSourcesCache(storage.reportSourcesStorage, db, storage.reportStore, storage.bundleStore),
40
+ async reapStorage() {
41
+ if (!config.storageEncryptionMigrate) return
42
+ const state = await db.getStorageEncryption()
43
+ if (state?.complete && state.cleanupComplete) return
44
+ const result = await migrateStorage(raw, db, key!, { maxMs: config.storageEncryptionMigrateMaxMs })
45
+ console.info('managed-storage-migration:', JSON.stringify({
46
+ complete: result.complete === 1, cleanupComplete: result.cleanupComplete === 1,
47
+ migrated: result.migrated, cursor: result.cursor, retryAt: result.retryAt,
48
+ }))
49
+ },
50
+ ...(config.neonUrl ? { reapUploads: () => reapStorageUploads(raw, db) } : {}),
21
51
  }
22
- }
23
- if (config.serverless) throw new Error('Serverless managed storage requires Neon')
24
- const { openSqliteManagedDb } = await import('./db.ts')
25
- const db = openSqliteManagedDb(config.dbPath, options)
26
- const dir = dirname(config.dbPath)
27
- const reportStore = createDiskBlobStore(join(dir, 'reports'))
28
- const bundleStore = createDiskBundleStore(join(dir, 'bundles'))
29
- return { db, reportStore, bundleStore,
30
- reportSourcesCache: createReportSourcesCache(createDiskCacheStorage(join(dir, 'cache', 'report-sources')), db, reportStore, bundleStore),
31
- avatarStore: createDiskAvatarStore(join(dir, 'avatars')),
32
- bundleCache: createDiskBundleCache(join(dir, 'cache', 'bundles'), db, bundleStore),
52
+ } catch (err) {
53
+ await db.close()
54
+ throw err
33
55
  }
34
56
  }
@@ -7,6 +7,8 @@ import type { IncomingMessage } from 'node:http'
7
7
  import type { BlobStore } from './blob-store.ts'
8
8
 
9
9
  export const UPLOAD_CHUNK_BYTES = 3 * 1024 * 1024
10
+ export const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024
11
+ const MAX_UPLOAD_PARTS = Math.ceil(MAX_UPLOAD_BYTES / UPLOAD_CHUNK_BYTES)
10
12
  const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
11
13
  export type UploadKind = 'reports' | 'bundles'
12
14
 
@@ -16,24 +18,40 @@ function partId(session: string, kind: UploadKind, id: string, index: number): s
16
18
  }
17
19
 
18
20
  export function validUploadPart(id: string, index: number, maxBytes: number): boolean {
19
- return UUID.test(id) && Number.isSafeInteger(index) && index >= 0 && index < Math.ceil(maxBytes / UPLOAD_CHUNK_BYTES)
21
+ return UUID.test(id) && Number.isSafeInteger(index) && index >= 0 && index < Math.ceil(Math.min(maxBytes, MAX_UPLOAD_BYTES) / UPLOAD_CHUNK_BYTES)
20
22
  }
21
23
 
22
- export function putUploadPart(store: BlobStore, session: string, kind: UploadKind, id: string, index: number, bytes: Buffer): Promise<void> {
23
- return store.put(partId(session, kind, id, index), bytes)
24
+ export function validUpload(id: string, count: number): boolean {
25
+ return UUID.test(id) && Number.isSafeInteger(count) && count > 0
26
+ }
27
+
28
+ // Limit cleanup to indices that any configured server could have accepted,
29
+ // even if the request advertises an enormous count or the limit was lowered.
30
+ export async function deleteUpload(store: BlobStore, session: string, kind: UploadKind, id: string, count: number): Promise<void> {
31
+ if (!validUpload(id, count)) throw new Error('bad-upload')
32
+ const end = Math.min(count, MAX_UPLOAD_PARTS), errors = []
33
+ for (let start = 0; start < end; start += 8) {
34
+ const results = await Promise.allSettled(Array.from({ length: Math.min(8, end - start) },
35
+ (_, index) => store.delete(partId(session, kind, id, start + index))))
36
+ for (const result of results) if (result.status === 'rejected') errors.push(result.reason)
37
+ }
38
+ if (errors.length > 0) throw new AggregateError(errors, 'Upload cleanup failed')
39
+ }
40
+
41
+ export async function putUploadPart(store: BlobStore, session: string, kind: UploadKind, id: string, index: number, bytes: Buffer): Promise<void> {
42
+ await store.put(partId(session, kind, id, index), bytes)
24
43
  }
25
44
 
26
45
  export async function readUpload(store: BlobStore, req: IncomingMessage, session: string, kind: UploadKind, maxBytes: number): Promise<Buffer> {
27
46
  const id = req.headers['x-upload-id']
28
47
  const count = Number(req.headers['x-upload-parts']), size = Number(req.headers['x-upload-size'])
29
- if (typeof id !== 'string' || !UUID.test(id) || !Number.isSafeInteger(size) || size <= 0
30
- || !Number.isSafeInteger(count) || count !== Math.ceil(size / UPLOAD_CHUNK_BYTES)) throw new Error('bad-upload')
31
- if (size > maxBytes) throw new Error('too-large')
32
- const ids = Array.from({ length: count }, (_, index) => partId(session, kind, id, index))
33
- const parts: Buffer[] = []
48
+ if (typeof id !== 'string' || !validUpload(id, count)) throw new Error('bad-upload')
34
49
  try {
35
- for (const [index, key] of ids.entries()) {
36
- const bytes = await store.get(key)
50
+ if (!Number.isSafeInteger(size) || size <= 0 || count !== Math.ceil(size / UPLOAD_CHUNK_BYTES)) throw new Error('bad-upload')
51
+ if (size > maxBytes || size > MAX_UPLOAD_BYTES) throw new Error('too-large')
52
+ const parts: Buffer[] = []
53
+ for (let index = 0; index < count; index++) {
54
+ const bytes = await store.get(partId(session, kind, id, index))
37
55
  const expected = Math.min(UPLOAD_CHUNK_BYTES, size - index * UPLOAD_CHUNK_BYTES)
38
56
  if (!bytes || bytes.length !== expected) throw new Error('bad-upload')
39
57
  parts.push(bytes)
@@ -41,7 +59,7 @@ export async function readUpload(store: BlobStore, req: IncomingMessage, session
41
59
  return Buffer.concat(parts, size)
42
60
  } finally {
43
61
  // These bytes can no longer be finalized. Failed/abandoned chunk uploads
44
- // are also covered by the daily staging sweep.
45
- for (const key of ids) await store.delete(key).catch(err => console.warn('managed: upload cleanup failed:', err))
62
+ // are also covered by automatic staging maintenance.
63
+ await deleteUpload(store, session, kind, id, count).catch(err => console.warn('managed: upload cleanup failed:', err))
46
64
  }
47
65
  }
@@ -1,125 +0,0 @@
1
- import { Buffer } from 'node:buffer'
2
- import { Readable } from 'node:stream'
3
- import { type VercelBlobSdk, isNotFound, loadVercelBlobSdk } from '../server-common/vercel-blob.ts'
4
- import type { AvatarStore } from './avatar-store.ts'
5
- import type { BlobStore } from './blob-store.ts'
6
- import { type CacheStorage, validateCacheKey } from './cache-storage.ts'
7
- import type { BundleCacheStorage } from './bundle-cache.ts'
8
- import { createBundleStore } from './bundle-store.ts'
9
-
10
- const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
11
- function validate(id: string): string {
12
- if (!ID.test(id)) throw new Error('Invalid managed blob id')
13
- return id
14
- }
15
-
16
- type ListedBlob = Awaited<ReturnType<VercelBlobSdk['list']>>['blobs'][number]
17
-
18
- async function deletePrefix(blobs: VercelBlobSdk, token: string, prefix: string, remove: (path: string) => Promise<void>, matches = (_blob: ListedBlob) => true): Promise<void> {
19
- const cursors = new Set<string>(), paths = new Set<string>()
20
- let cursor: string | undefined
21
- // Finish listing before deleting so pagination cannot skip objects as the
22
- // listed set shrinks. Callers supply a trailing slash to isolate a namespace.
23
- do {
24
- const page = await blobs.list({ token, prefix, ...(cursor ? { cursor } : {}) })
25
- for (const blob of page.blobs) if (blob.pathname.startsWith(prefix) && matches(blob)) paths.add(blob.pathname)
26
- if (!page.hasMore) break
27
- if (!page.cursor || cursors.has(page.cursor)) throw new Error('Invalid blob pagination')
28
- cursor = page.cursor
29
- cursors.add(cursor)
30
- } while (cursor)
31
- for (const path of paths) await remove(path)
32
- }
33
-
34
- function createBlobAvatarStore(avatarBlobs: BlobStore): AvatarStore {
35
- return {
36
- put(id, contentType, bytes) {
37
- const type = contentType.split(';', 1)[0]!.trim()
38
- if (!/^image\/[a-z0-9.+-]+$/iu.test(type)) throw new Error('Invalid avatar type')
39
- return avatarBlobs.put(id, Buffer.concat([Buffer.from(`${type}\n`), bytes]))
40
- },
41
- async get(id) {
42
- const bytes = await avatarBlobs.get(id)
43
- if (!bytes) return null
44
- const split = bytes.indexOf(10)
45
- if (split < 0 || split > 100) throw new Error('Invalid cached avatar')
46
- return { contentType: bytes.subarray(0, split).toString(), bytes: bytes.subarray(split + 1) }
47
- },
48
- }
49
- }
50
-
51
- export async function openManagedVercelStorage(token: string, sdk?: VercelBlobSdk) {
52
- const blobs = sdk ?? await loadVercelBlobSdk()
53
- const options = { token, access: 'private' as const, useCache: false }
54
- async function put(path: string, bytes: Buffer) {
55
- await blobs.put(path, bytes, { token, access: 'private', addRandomSuffix: false,
56
- allowOverwrite: true, contentType: 'application/octet-stream', cacheControlMaxAge: 60 })
57
- }
58
- async function open(path: string) {
59
- try {
60
- const result = await blobs.get(path, options)
61
- if (!result) return null
62
- if (result.statusCode !== 200 || !result.stream) throw new Error('Unexpected blob response')
63
- // Private SDK GETs may report zero for a nonempty body. Stream without
64
- // Content-Length in that case so HTTP clients do not truncate the body.
65
- const size = result.blob.size === 0 ? null : result.blob.size
66
- return { size, stream: Readable.fromWeb(result.stream as Parameters<typeof Readable.fromWeb>[0]) }
67
- } catch (err) { if (isNotFound(err, blobs)) return null; throw err }
68
- }
69
- async function get(path: string) {
70
- const result = await open(path)
71
- if (!result) return null
72
- const parts: Buffer[] = []
73
- for await (const chunk of result.stream) parts.push(Buffer.from(chunk))
74
- return Buffer.concat(parts)
75
- }
76
- async function remove(path: string) {
77
- try { await blobs.del(path, { token }) } catch (err) { if (!isNotFound(err, blobs)) throw err }
78
- }
79
- function store(name: string, suffix = ''): BlobStore {
80
- const path = (id: string) => `.managed/${name}/${validate(id)}${suffix}`
81
- return {
82
- async put(id, bytes) { await put(path(id), bytes) },
83
- async get(id) { return await get(path(id)) },
84
- open: id => open(path(id)),
85
- async delete(id) { await remove(path(id)) },
86
- }
87
- }
88
- const avatarStore = createBlobAvatarStore(store('avatars'))
89
- const cachePath = (id: string, file: string) => `.managed/cache/bundles/${validate(id)}/${file}`
90
- const cacheStorage: BundleCacheStorage = {
91
- async exists(id, file) {
92
- try { await blobs.head(cachePath(id, file), { token }); return true }
93
- catch (err) { if (isNotFound(err, blobs)) return false; throw err }
94
- },
95
- put: (id, file, bytes) => put(cachePath(id, file), bytes),
96
- async open(id, file) {
97
- const result = await open(cachePath(id, file))
98
- if (!result) throw new Error('Bundle cache unavailable')
99
- return result
100
- },
101
- delete: id => deletePrefix(blobs, token, cachePath(id, ''), remove),
102
- }
103
- // Staging uploads are never published. An interrupted browser leaves only
104
- // parts here; the authenticated cron removes them after a full day.
105
- function reapUploads(now = Date.now()) {
106
- return deletePrefix(blobs, token, '.managed/uploads/', remove,
107
- blob => new Date(blob.uploadedAt ?? now).getTime() < now - 86_400_000)
108
- }
109
- const sourcesPath = (key: string) => `.managed/cache/report-sources/${validateCacheKey(key)}`
110
- const reportSourcesStorage: CacheStorage = {
111
- async exists(key) {
112
- try { await blobs.head(sourcesPath(key), { token }); return true }
113
- catch (err) { if (isNotFound(err, blobs)) return false; throw err }
114
- },
115
- put: (key, bytes) => put(sourcesPath(key), bytes),
116
- async open(key) {
117
- const result = await open(sourcesPath(key))
118
- if (!result) throw new Error('Report sources cache unavailable')
119
- return result
120
- },
121
- delete: prefix => deletePrefix(blobs, token, `${sourcesPath(prefix)}/`, remove),
122
- }
123
- const bundleStore = createBundleStore(store('bundles'), store('bundles', '.map.br'))
124
- return { reportStore: store('reports'), bundleStore, uploadStore: store('uploads'), avatarStore, cacheStorage, reportSourcesStorage, reapUploads }
125
- }