@preventive/triage 1.0.0-alpha.26 → 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.
- package/out/client-managed-import.js +18 -18
- package/out/client-managed.js +47 -47
- package/out/view.css +1 -1
- package/out/view.js +159 -159
- package/package.json +12 -2
- package/server-common/storage-crypto.ts +182 -0
- package/server-common/storage-log.ts +5 -1
- package/server-common/vercel-blob.ts +6 -3
- package/server-managed/avatar-store.ts +1 -40
- package/server-managed/blob-store.ts +3 -52
- package/server-managed/bundle-cache.ts +0 -28
- package/server-managed/bundle-store.ts +3 -7
- package/server-managed/cache-storage.ts +0 -25
- package/server-managed/cli.js +10 -2
- package/server-managed/config.ts +12 -1
- package/server-managed/db-methods.ts +42 -21
- package/server-managed/db-neon.ts +9 -4
- package/server-managed/db.ts +7 -1
- package/server-managed/http.ts +37 -39
- package/server-managed/index.ts +3 -2
- package/server-managed/object-storage-disk.ts +144 -0
- package/server-managed/object-storage-vercel.ts +60 -0
- package/server-managed/object-storage.ts +71 -0
- package/server-managed/repository-browser.ts +5 -1
- package/server-managed/solidity-entry-points.ts +69 -0
- package/server-managed/sql.ts +0 -5
- package/server-managed/storage-command.ts +16 -0
- package/server-managed/storage-db.ts +168 -0
- package/server-managed/storage-encryption.ts +211 -0
- package/server-managed/storage-maintenance.ts +182 -0
- package/server-managed/storage-payload.ts +60 -0
- package/server-managed/storage-stores.ts +81 -0
- package/server-managed/storage.ts +46 -24
- package/server-managed/uploads.ts +2 -2
- package/server-managed/blob-vercel.ts +0 -127
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { type StorageKey, decryptStorageStream, encryptStorageStream } from '../server-common/storage-crypto.ts'
|
|
2
|
+
import { ENCRYPTED_CACHE_PREFIX, type RawObjectStorage, deleteObjects, isBlobId, openObjectVersion } from './object-storage.ts'
|
|
3
|
+
import { type StorageDb, type StorageEncryptionState, type StorageMigrationRow, unwrapDataKey } from './storage-db.ts'
|
|
4
|
+
import { STORAGE_UPLOAD_TTL_MS } from './storage-encryption.ts'
|
|
5
|
+
import { inspectStorageObject, storageOwner, storageRowPath, verifyStoragePayload } from './storage-payload.ts'
|
|
6
|
+
|
|
7
|
+
async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey, item: StorageMigrationRow, signal: AbortSignal) {
|
|
8
|
+
if (item.type === 'user') { await db.migrateStorageUserTokens(item.id); return }
|
|
9
|
+
const row = await db.ensureStorageDataKey(item.type, item.id)
|
|
10
|
+
if (!row || row.encrypted) return
|
|
11
|
+
const dataKey = unwrapDataKey(key, item.type, row), identity = storageRowPath(item.type, row)
|
|
12
|
+
try {
|
|
13
|
+
const source = await raw.open(identity, signal)
|
|
14
|
+
if (!source) {
|
|
15
|
+
if (!await db.getStorageRow(item.type, item.id)) return
|
|
16
|
+
throw new Error('Migration payload unavailable')
|
|
17
|
+
}
|
|
18
|
+
let inspected = await inspectStorageObject(source)
|
|
19
|
+
if (inspected.encrypted) {
|
|
20
|
+
// A previous PUT may have completed before a timeout or lost SQL ack.
|
|
21
|
+
try {
|
|
22
|
+
const decoded = await decryptStorageStream(inspected.stream, dataKey, identity)
|
|
23
|
+
await verifyStoragePayload(item.type, row, decoded.stream, signal)
|
|
24
|
+
} catch {
|
|
25
|
+
signal.throwIfAborted()
|
|
26
|
+
// Legacy arbitrary bytes can share the magic prefix. A matching
|
|
27
|
+
// original upload hash is required before encrypting them as plaintext.
|
|
28
|
+
const legacy = await openObjectVersion(raw, identity, source.version, signal)
|
|
29
|
+
if (!legacy) return
|
|
30
|
+
inspected = { ...legacy, encrypted: false }
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (!inspected.encrypted) {
|
|
34
|
+
await verifyStoragePayload(item.type, row, inspected.stream, signal)
|
|
35
|
+
signal.throwIfAborted()
|
|
36
|
+
const current = await openObjectVersion(raw, identity, source.version, signal)
|
|
37
|
+
if (!current) return
|
|
38
|
+
const encrypted = encryptStorageStream(current.stream, dataKey, identity, current.size)
|
|
39
|
+
let replaced: boolean
|
|
40
|
+
try { replaced = await raw.put(identity, encrypted, signal, source.version, current.size ?? undefined) }
|
|
41
|
+
finally { encrypted.destroy(); current.stream.destroy() }
|
|
42
|
+
if (!replaced) return
|
|
43
|
+
// Do not mark the row encrypted until the stored bytes authenticate and
|
|
44
|
+
// match the original upload (including decompression for sourcemaps).
|
|
45
|
+
const replacement = await raw.open(identity, signal)
|
|
46
|
+
if (!replacement) {
|
|
47
|
+
if (!await db.getStorageRow(item.type, item.id)) return
|
|
48
|
+
throw new Error('Migrated payload unavailable')
|
|
49
|
+
}
|
|
50
|
+
const decoded = await decryptStorageStream(replacement.stream, dataKey, identity)
|
|
51
|
+
await verifyStoragePayload(item.type, row, decoded.stream, signal)
|
|
52
|
+
}
|
|
53
|
+
signal.throwIfAborted()
|
|
54
|
+
// Another process may have renamed ciphertext but stopped before syncing
|
|
55
|
+
// the directory. Persist the observed rename even on the resume path.
|
|
56
|
+
await raw.sync?.(identity, signal)
|
|
57
|
+
await db.markStorageEncrypted(item.type, item.id, row.dataKey!)
|
|
58
|
+
} finally { dataKey.fill(0) }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const CLEANUP_PREFIXES = ['cache/', 'reports/', 'bundles/', ENCRYPTED_CACHE_PREFIX]
|
|
62
|
+
function cleanupPosition(state: StorageEncryptionState): { prefix: number; cursor: string | null } {
|
|
63
|
+
const value = state.cleanupCursor === null ? { prefix: 0, cursor: null } : JSON.parse(state.cleanupCursor)
|
|
64
|
+
if (!Number.isInteger(value.prefix) || value.prefix < 0 || value.prefix >= CLEANUP_PREFIXES.length
|
|
65
|
+
|| (value.cursor !== null && typeof value.cursor !== 'string')) throw new Error('Invalid legacy cleanup cursor')
|
|
66
|
+
return value
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// The database directory may be shared. Recognize managed paths, including
|
|
70
|
+
// disk atomic-write temps, before treating any listed file as ours to remove.
|
|
71
|
+
function cleanupTarget(identity: string) {
|
|
72
|
+
const temp = /^(.+)\.([^.]+)\.tmp$/u.exec(identity)
|
|
73
|
+
const temporary = !!temp && isBlobId(temp[2]!)
|
|
74
|
+
const path = temporary ? temp![1]! : identity
|
|
75
|
+
const encryptedCache = path.startsWith(ENCRYPTED_CACHE_PREFIX)
|
|
76
|
+
if (encryptedCache && !temporary) return null
|
|
77
|
+
const owner = storageOwner(encryptedCache ? `cache/${path.slice(ENCRYPTED_CACHE_PREFIX.length)}` : path)
|
|
78
|
+
return owner ? { owner, temporary } : null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Referenced-data migration never depends on Blob listings. A separate bounded
|
|
82
|
+
// inventory removes legacy caches and unreferenced plaintext; ciphertext
|
|
83
|
+
// orphans do not hold a recoverable data key and need no manifest-based GC.
|
|
84
|
+
async function cleanupLegacy(raw: RawObjectStorage, db: StorageDb, state: StorageEncryptionState, limit: number, signal: AbortSignal) {
|
|
85
|
+
const position = cleanupPosition(state), prefix = CLEANUP_PREFIXES[position.prefix]!
|
|
86
|
+
const page = await raw.list(prefix, position.cursor, Math.min(limit, 16), signal)
|
|
87
|
+
let found = 0, retryAt: number | null = null
|
|
88
|
+
for (const object of page.objects) {
|
|
89
|
+
signal.throwIfAborted()
|
|
90
|
+
const target = cleanupTarget(object.key)
|
|
91
|
+
if (!target) continue
|
|
92
|
+
const { owner, temporary } = target
|
|
93
|
+
if (!temporary && !owner.cache) {
|
|
94
|
+
const row = await db.getStorageRow(owner.type, owner.id)
|
|
95
|
+
if (row && storageRowPath(owner.type, row) === object.key) continue
|
|
96
|
+
}
|
|
97
|
+
const stored = await raw.head(object.key, signal)
|
|
98
|
+
if (!stored) continue
|
|
99
|
+
// A killed disk writer can leave plaintext or ciphertext in its temp file.
|
|
100
|
+
// Recheck its current age and give live writes a full staging grace window.
|
|
101
|
+
if (temporary && stored.modifiedAt > Date.now() - STORAGE_UPLOAD_TTL_MS) {
|
|
102
|
+
found++
|
|
103
|
+
retryAt = Math.min(retryAt ?? Infinity, stored.modifiedAt + STORAGE_UPLOAD_TTL_MS)
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
// Old pre-activation orphans are legacy even when their arbitrary bytes
|
|
107
|
+
// share the magic prefix. Keep a full staging grace window around enable:
|
|
108
|
+
// Blob Last-Modified has only second precision, and a new encrypted upload
|
|
109
|
+
// may still be waiting for its SQL insert.
|
|
110
|
+
let legacy = temporary || owner.cache || stored.modifiedAt < state.enabledAt - STORAGE_UPLOAD_TTL_MS
|
|
111
|
+
if (!legacy) {
|
|
112
|
+
const source = await raw.open(object.key, signal)
|
|
113
|
+
if (!source) continue
|
|
114
|
+
try {
|
|
115
|
+
if (source.version !== stored.version) throw new Error('Legacy object changed during cleanup')
|
|
116
|
+
const inspected = await inspectStorageObject(source)
|
|
117
|
+
legacy = !inspected.encrypted
|
|
118
|
+
inspected.stream.destroy()
|
|
119
|
+
} finally { source.stream.destroy() }
|
|
120
|
+
}
|
|
121
|
+
if (legacy) {
|
|
122
|
+
found++
|
|
123
|
+
if (!await raw.delete(object.key, stored.version, signal)) throw new Error('Legacy object changed during cleanup')
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
const next = page.cursor === null ? position.prefix + 1 < CLEANUP_PREFIXES.length ? { prefix: position.prefix + 1, cursor: null } : null
|
|
127
|
+
: { ...position, cursor: page.cursor }
|
|
128
|
+
// A full pass without removals closes offset-pagination gaps left by deletes.
|
|
129
|
+
await db.advanceStorageCleanup(state.cleanupCursor, next === null ? null : JSON.stringify(next), found)
|
|
130
|
+
return { processed: page.objects.length || 1, retryAt }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface StorageMigrationResult extends StorageEncryptionState { retryAt: number | null }
|
|
134
|
+
export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
|
|
135
|
+
{ maxObjects = 64, maxMs = 150_000 } = {}): Promise<StorageMigrationResult> {
|
|
136
|
+
if (!Number.isSafeInteger(maxObjects) || maxObjects < 1 || !Number.isSafeInteger(maxMs) || maxMs < 1) throw new Error('Invalid migration budget')
|
|
137
|
+
const deadline = Date.now() + maxMs, signal = AbortSignal.timeout(maxMs)
|
|
138
|
+
const errors: unknown[] = []
|
|
139
|
+
let processed = 0, retryAt: number | null = null, state = await db.getStorageEncryption()
|
|
140
|
+
if (!state) throw new Error('Storage encryption is not enabled; configure MANAGED_STORAGE_ENCRYPTION_KEY')
|
|
141
|
+
// At most one SQL pass per invocation. Checkpoint each row, including a
|
|
142
|
+
// failure: other rows progress, and the failed row is retried next pass.
|
|
143
|
+
while (!state.complete && processed < maxObjects && Date.now() < deadline) {
|
|
144
|
+
const rows = await db.listStorageMigrationRows(state.cursor, Math.min(16, maxObjects - processed))
|
|
145
|
+
if (rows.length === 0) { await db.advanceStorageMigration(state.cursor, null); break }
|
|
146
|
+
for (const row of rows) {
|
|
147
|
+
if (Date.now() >= deadline) break
|
|
148
|
+
try { await migrateRow(raw, db, key, row, signal) }
|
|
149
|
+
catch (err) {
|
|
150
|
+
// A row started late gets a full budget next time. Skip an oversized
|
|
151
|
+
// first row until the next pass so later rows and tokens can advance.
|
|
152
|
+
if (signal.aborted && processed > 0) break
|
|
153
|
+
const message = signal.aborted
|
|
154
|
+
? `Could not migrate ${row.type} ${row.id} within MANAGED_STORAGE_ENCRYPTION_MIGRATE_MAX_MS=${maxMs}; increase the budget`
|
|
155
|
+
: `Could not migrate ${row.type} ${row.id}`
|
|
156
|
+
errors.push(new Error(message, { cause: err }))
|
|
157
|
+
}
|
|
158
|
+
await db.advanceStorageMigration(state.cursor, row.position)
|
|
159
|
+
processed++
|
|
160
|
+
state = (await db.getStorageEncryption())!
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
state = (await db.getStorageEncryption())!
|
|
164
|
+
while (!state.cleanupComplete && processed < maxObjects && Date.now() < deadline) {
|
|
165
|
+
try {
|
|
166
|
+
const cleanup = await cleanupLegacy(raw, db, state, maxObjects - processed, signal)
|
|
167
|
+
processed += cleanup.processed
|
|
168
|
+
retryAt = cleanup.retryAt
|
|
169
|
+
}
|
|
170
|
+
catch (err) { if (!signal.aborted) errors.push(err); break }
|
|
171
|
+
state = (await db.getStorageEncryption())!
|
|
172
|
+
if (retryAt !== null) break // Wait for staging grace rather than spinning over live temp files.
|
|
173
|
+
}
|
|
174
|
+
if (errors.length > 0) throw new AggregateError(errors, 'Storage migration has pending failures', { cause: errors[0] })
|
|
175
|
+
return { ...state, retryAt }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function reapStorageUploads(raw: RawObjectStorage, db: StorageDb, now = Date.now()): Promise<number> {
|
|
179
|
+
const signal = AbortSignal.timeout(150_000)
|
|
180
|
+
await db.getStorageEncryption()
|
|
181
|
+
return deleteObjects(raw, 'uploads/', signal, now - STORAGE_UPLOAD_TTL_MS)
|
|
182
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { createHash } from 'node:crypto'
|
|
3
|
+
import { 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
|
+
export function storageRowPath(type: StorageRowKind, row: Pick<StorageRow, 'id' | 'kind'>): string {
|
|
11
|
+
return `${type === 'report' ? 'reports' : 'bundles'}/${row.id}${type === 'bundle' && row.kind === 'sourcemap' ? '.map.br' : ''}`
|
|
12
|
+
}
|
|
13
|
+
export function storageOwner(identity: string): { type: StorageRowKind; id: string; cache: boolean } | null {
|
|
14
|
+
const match = /^(reports|bundles)\/([^/]+)$/u.exec(identity)
|
|
15
|
+
if (match) {
|
|
16
|
+
const id = match[1] === 'bundles' ? match[2]!.replace(/\.map\.br$/u, '') : match[2]!
|
|
17
|
+
if (isBlobId(id)) return { type: match[1] === 'reports' ? 'report' : 'bundle', id, cache: false }
|
|
18
|
+
}
|
|
19
|
+
const cache = /^cache\/(?:bundles|report-sources)\/([a-f\d-]{36})\//u.exec(identity)
|
|
20
|
+
return cache && isBlobId(cache[1]!) ? { type: 'bundle', id: cache[1]!, cache: true } : null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Peek without buffering the object or consuming bytes from its next reader.
|
|
24
|
+
export async function inspectStorageObject(stored: RawObject): Promise<RawObject & { encrypted: boolean }> {
|
|
25
|
+
const original = stored.stream
|
|
26
|
+
const iterator = original[Symbol.asyncIterator]()
|
|
27
|
+
const first: Buffer[] = [], prefix = Buffer.alloc(STORAGE_MAGIC.length)
|
|
28
|
+
let length = 0
|
|
29
|
+
try {
|
|
30
|
+
while (length < prefix.length) {
|
|
31
|
+
const next = await iterator.next()
|
|
32
|
+
if (next.done) break
|
|
33
|
+
const bytes = Buffer.from(next.value)
|
|
34
|
+
first.push(bytes)
|
|
35
|
+
length += bytes.copy(prefix, length, 0, prefix.length - length)
|
|
36
|
+
}
|
|
37
|
+
} catch (err) { original.destroy(); throw err }
|
|
38
|
+
async function* joined() {
|
|
39
|
+
try {
|
|
40
|
+
yield* first
|
|
41
|
+
for (;;) { const next = await iterator.next(); if (next.done) return; yield next.value }
|
|
42
|
+
} finally { await iterator.return?.(); original.destroy() }
|
|
43
|
+
}
|
|
44
|
+
const stream = Readable.from(joined(), { objectMode: false })
|
|
45
|
+
stream.once('close', () => original.destroy())
|
|
46
|
+
return { ...stored, stream, encrypted: length === prefix.length && prefix.equals(STORAGE_MAGIC) }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Sourcemap integrity describes the original upload, before Brotli storage.
|
|
50
|
+
// Hash incrementally, including decompression, so migration stays streaming.
|
|
51
|
+
export async function verifyStoragePayload(type: StorageRowKind, row: StorageRow, source: Readable, signal?: AbortSignal): Promise<void> {
|
|
52
|
+
const hash = createHash(type === 'report' ? 'sha256' : 'sha512')
|
|
53
|
+
const sink = new Writable({ write(chunk, _encoding, next) { hash.update(chunk); next() } })
|
|
54
|
+
const options = signal ? { signal } : {}
|
|
55
|
+
if (type === 'bundle' && row.kind === 'sourcemap') {
|
|
56
|
+
await pipeline(source, createBrotliDecompress(), sink, options)
|
|
57
|
+
} else await pipeline(source, sink, options)
|
|
58
|
+
const actual = type === 'report' ? hash.digest('base64url') : `sha512-${hash.digest('base64')}`
|
|
59
|
+
if (actual !== row.hash) throw new Error('Stored payload does not match its upload hash')
|
|
60
|
+
}
|
|
@@ -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
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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
|
|
15
|
-
if (config.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
24
|
-
|
|
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
|
}
|
|
@@ -38,8 +38,8 @@ export async function deleteUpload(store: BlobStore, session: string, kind: Uplo
|
|
|
38
38
|
if (errors.length > 0) throw new AggregateError(errors, 'Upload cleanup failed')
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
export function putUploadPart(store: BlobStore, session: string, kind: UploadKind, id: string, index: number, bytes: Buffer): Promise<void> {
|
|
42
|
-
|
|
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)
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
export async function readUpload(store: BlobStore, req: IncomingMessage, session: string, kind: UploadKind, maxBytes: number): Promise<Buffer> {
|
|
@@ -1,127 +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. Automatic maintenance and explicit
|
|
104
|
-
// cleanup remove abandoned parts after a full day.
|
|
105
|
-
async function reapUploads(now = Date.now()) {
|
|
106
|
-
let removed = 0
|
|
107
|
-
await deletePrefix(blobs, token, '.managed/uploads/', async path => { await remove(path); removed++ },
|
|
108
|
-
blob => new Date(blob.uploadedAt ?? now).getTime() < now - 86_400_000)
|
|
109
|
-
return removed
|
|
110
|
-
}
|
|
111
|
-
const sourcesPath = (key: string) => `.managed/cache/report-sources/${validateCacheKey(key)}`
|
|
112
|
-
const reportSourcesStorage: CacheStorage = {
|
|
113
|
-
async exists(key) {
|
|
114
|
-
try { await blobs.head(sourcesPath(key), { token }); return true }
|
|
115
|
-
catch (err) { if (isNotFound(err, blobs)) return false; throw err }
|
|
116
|
-
},
|
|
117
|
-
put: (key, bytes) => put(sourcesPath(key), bytes),
|
|
118
|
-
async open(key) {
|
|
119
|
-
const result = await open(sourcesPath(key))
|
|
120
|
-
if (!result) throw new Error('Report sources cache unavailable')
|
|
121
|
-
return result
|
|
122
|
-
},
|
|
123
|
-
delete: prefix => deletePrefix(blobs, token, `${sourcesPath(prefix)}/`, remove),
|
|
124
|
-
}
|
|
125
|
-
const bundleStore = createBundleStore(store('bundles'), store('bundles', '.map.br'))
|
|
126
|
-
return { reportStore: store('reports'), bundleStore, uploadStore: store('uploads'), avatarStore, cacheStorage, reportSourcesStorage, reapUploads }
|
|
127
|
-
}
|