@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.
- package/common/server-info.ts +1 -0
- package/out/client-managed-import.js +18 -18
- package/out/client-managed.js +66 -65
- package/out/prism.js +2 -2
- package/out/view.css +1 -1
- package/out/view.js +182 -182
- 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-e2e/config.ts +4 -0
- 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 +14 -2
- package/server-managed/db-methods.ts +56 -25
- package/server-managed/db-neon.ts +9 -4
- package/server-managed/db.ts +7 -1
- package/server-managed/http.ts +93 -62
- package/server-managed/import-triage.ts +6 -3
- package/server-managed/index.ts +32 -9
- 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/report-query.ts +6 -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 +30 -12
- package/server-managed/blob-vercel.ts +0 -125
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// Keys belong to the existing upload rows. Only installation state and bounded
|
|
2
|
+
// migration progress need a separate table; payload paths never move.
|
|
3
|
+
import { Buffer } from 'node:buffer'
|
|
4
|
+
import { randomBytes } from 'node:crypto'
|
|
5
|
+
import { type StorageKey, unwrapStorageValue, wrapStorageValue } from '../server-common/storage-crypto.ts'
|
|
6
|
+
import type { ManagedSql } from './sql.ts'
|
|
7
|
+
|
|
8
|
+
export const STORAGE_SCHEMA = `
|
|
9
|
+
CREATE TABLE IF NOT EXISTS managed_storage_encryption (
|
|
10
|
+
id INTEGER PRIMARY KEY CHECK (id = 1), test_value TEXT NOT NULL,
|
|
11
|
+
enabled_at INTEGER NOT NULL, cursor TEXT, complete INTEGER NOT NULL DEFAULT 0,
|
|
12
|
+
migrated INTEGER NOT NULL DEFAULT 0, cleanup_cursor TEXT,
|
|
13
|
+
cleanup_found INTEGER NOT NULL DEFAULT 0, cleanup_complete INTEGER NOT NULL DEFAULT 0
|
|
14
|
+
) STRICT;
|
|
15
|
+
`
|
|
16
|
+
const SENTINEL = Buffer.from('deepview.storage.required.v1')
|
|
17
|
+
export type StorageRowKind = 'report' | 'bundle'
|
|
18
|
+
export interface StorageEncryptionState {
|
|
19
|
+
enabledAt: number; cursor: string | null; complete: number; migrated: number
|
|
20
|
+
cleanupCursor: string | null; cleanupFound: number; cleanupComplete: number
|
|
21
|
+
}
|
|
22
|
+
export interface StorageRow {
|
|
23
|
+
id: string; dataKey: string | null; encrypted: number; hash: string; kind: string | null
|
|
24
|
+
}
|
|
25
|
+
export interface StorageMigrationRow { position: string; id: string; type: StorageRowKind | 'user' }
|
|
26
|
+
export interface StorageDb {
|
|
27
|
+
enableStorageEncryption(): Promise<StorageEncryptionState>
|
|
28
|
+
getStorageEncryption(): Promise<StorageEncryptionState | null>
|
|
29
|
+
getStorageRow(type: StorageRowKind, id: string): Promise<StorageRow | null>
|
|
30
|
+
ensureStorageDataKey(type: StorageRowKind, id: string): Promise<StorageRow | null>
|
|
31
|
+
markStorageEncrypted(type: StorageRowKind, id: string, dataKey: string): Promise<void>
|
|
32
|
+
listStorageMigrationRows(after: string | null, limit: number): Promise<StorageMigrationRow[]>
|
|
33
|
+
advanceStorageMigration(expected: string | null, next: string | null): Promise<void>
|
|
34
|
+
advanceStorageCleanup(expected: string | null, next: string | null, removed: number): Promise<void>
|
|
35
|
+
migrateStorageUserTokens(id: string): Promise<void>
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function table(type: StorageRowKind): string {
|
|
39
|
+
if (type !== 'report' && type !== 'bundle') throw new Error('Invalid storage row type')
|
|
40
|
+
return `managed_${type}`
|
|
41
|
+
}
|
|
42
|
+
export const dataKeyIdentity = (type: StorageRowKind, id: string): string => `${table(type)}:${id}`
|
|
43
|
+
export function unwrapDataKey(key: StorageKey, type: StorageRowKind, row: Pick<StorageRow, 'id' | 'dataKey'>): Buffer {
|
|
44
|
+
if (!row.dataKey) throw new Error('Missing storage data key')
|
|
45
|
+
const value = unwrapStorageValue(key, dataKeyIdentity(type, row.id), row.dataKey)
|
|
46
|
+
if (value.length !== 32) { value.fill(0); throw new Error('Invalid storage data key') }
|
|
47
|
+
return value
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function storageState(db: ManagedSql, key: StorageKey | null): Promise<StorageEncryptionState | null> {
|
|
51
|
+
const row = await db.prepare(`SELECT test_value AS testValue, enabled_at AS enabledAt, cursor, complete, migrated,
|
|
52
|
+
cleanup_cursor AS cleanupCursor, cleanup_found AS cleanupFound, cleanup_complete AS cleanupComplete
|
|
53
|
+
FROM managed_storage_encryption WHERE id = 1`).get() as (StorageEncryptionState & { testValue: string }) | undefined
|
|
54
|
+
if (!row) return null
|
|
55
|
+
if (!key) throw new Error('Managed storage requires its configured encryption key')
|
|
56
|
+
const plain = unwrapStorageValue(key, 'managed_storage_encryption:1', row.testValue)
|
|
57
|
+
try { if (!plain.equals(SENTINEL)) throw new Error('Invalid storage encryption test value') }
|
|
58
|
+
finally { plain.fill(0) }
|
|
59
|
+
const { testValue: _testValue, ...state } = row
|
|
60
|
+
return state
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Called inside the insert transaction, fencing an old/plain upload that raced
|
|
64
|
+
// activation. The wrapped key never enters public report/bundle DTOs.
|
|
65
|
+
export async function validateStorageUpload(db: ManagedSql, key: StorageKey | null, type: StorageRowKind,
|
|
66
|
+
id: string, dataKey: string | null | undefined): Promise<void> {
|
|
67
|
+
const state = await storageState(db, key)
|
|
68
|
+
if (state) unwrapDataKey(key!, type, { id, dataKey: dataKey ?? null }).fill(0)
|
|
69
|
+
else if (dataKey) throw new Error('Storage encryption is not enabled')
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface StoredTokens { access: string | null; refresh: string | null; exp: number | null; encrypted: number }
|
|
73
|
+
const tokenIdentity = (id: string, field: string) => `managed_user.gh_${field}_token:${id}`
|
|
74
|
+
export function encodeStorageTokens(key: StorageKey, id: string, access: string | null, refresh: string | null) {
|
|
75
|
+
const encode = (field: string, value: string | null) => value === null ? null : wrapStorageValue(key, tokenIdentity(id, field), Buffer.from(value))
|
|
76
|
+
return { access: encode('access', access), refresh: encode('refresh', refresh) }
|
|
77
|
+
}
|
|
78
|
+
export function decodeStorageTokens(key: StorageKey, id: string, row: StoredTokens) {
|
|
79
|
+
const decode = (field: string, value: string | null) => {
|
|
80
|
+
if (value === null) return null
|
|
81
|
+
const plain = unwrapStorageValue(key, tokenIdentity(id, field), value)
|
|
82
|
+
try { return plain.toString('utf8') } finally { plain.fill(0) }
|
|
83
|
+
}
|
|
84
|
+
return { access: decode('access', row.access), refresh: decode('refresh', row.refresh) }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const PENDING = `SELECT 'bundle:' || id AS position, id, 'bundle' AS type FROM managed_bundle WHERE storage_encrypted = 0
|
|
88
|
+
UNION ALL SELECT 'report:' || id AS position, id, 'report' AS type FROM managed_report WHERE storage_encrypted = 0
|
|
89
|
+
UNION ALL SELECT 'user:' || id AS position, id, 'user' AS type FROM managed_user
|
|
90
|
+
WHERE gh_tokens_encrypted = 0 AND (gh_access_token IS NOT NULL OR gh_refresh_token IS NOT NULL)`
|
|
91
|
+
|
|
92
|
+
export function storageMethods(db: ManagedSql, key: StorageKey | null): StorageDb {
|
|
93
|
+
async function requireEnabled() {
|
|
94
|
+
const state = await storageState(db, key)
|
|
95
|
+
if (!state) throw new Error('Storage encryption is not enabled; configure MANAGED_STORAGE_ENCRYPTION_KEY')
|
|
96
|
+
return state
|
|
97
|
+
}
|
|
98
|
+
async function getRow(type: StorageRowKind, id: string): Promise<StorageRow | null> {
|
|
99
|
+
const row = await db.prepare(`SELECT id, data_key AS dataKey, storage_encrypted AS encrypted,
|
|
100
|
+
${type === 'report' ? 'sha256 AS hash, NULL AS kind' : 'integrity AS hash, kind'} FROM ${table(type)} WHERE id = ?`).get(id) as StorageRow | undefined ?? null
|
|
101
|
+
if (row && row.encrypted !== 0 && row.encrypted !== 1) throw new Error('Invalid storage encryption state')
|
|
102
|
+
if (row?.encrypted && !row.dataKey) throw new Error('Encrypted storage row is missing its data key')
|
|
103
|
+
return row
|
|
104
|
+
}
|
|
105
|
+
return {
|
|
106
|
+
async enableStorageEncryption() {
|
|
107
|
+
if (!key) throw new Error('Set MANAGED_STORAGE_ENCRYPTION_KEY before enabling storage encryption')
|
|
108
|
+
// Called during startup in a writer transaction. Concurrent instances
|
|
109
|
+
// must reuse the marker and validate its key before serving requests.
|
|
110
|
+
const existing = await storageState(db, key)
|
|
111
|
+
if (existing) return existing
|
|
112
|
+
await db.prepare('INSERT INTO managed_storage_encryption (id, test_value, enabled_at) VALUES (1, ?, ?)')
|
|
113
|
+
.run(wrapStorageValue(key, 'managed_storage_encryption:1', SENTINEL), Date.now())
|
|
114
|
+
return (await storageState(db, key))!
|
|
115
|
+
},
|
|
116
|
+
getStorageEncryption: () => storageState(db, key),
|
|
117
|
+
getStorageRow: getRow,
|
|
118
|
+
async ensureStorageDataKey(type, id) {
|
|
119
|
+
await requireEnabled()
|
|
120
|
+
const current = await getRow(type, id)
|
|
121
|
+
if (!current || current.dataKey) return current
|
|
122
|
+
const bytes = randomBytes(32)
|
|
123
|
+
let wrapped: string
|
|
124
|
+
try { wrapped = wrapStorageValue(key!, dataKeyIdentity(type, id), bytes) } finally { bytes.fill(0) }
|
|
125
|
+
await db.prepare(`UPDATE ${table(type)} SET data_key = ? WHERE id = ? AND data_key IS NULL`).run(wrapped, id)
|
|
126
|
+
// Return the persisted winner, never a worker's uncommitted candidate.
|
|
127
|
+
return getRow(type, id)
|
|
128
|
+
},
|
|
129
|
+
async markStorageEncrypted(type, id, wrapped) {
|
|
130
|
+
await requireEnabled()
|
|
131
|
+
const result = await db.prepare(`UPDATE ${table(type)} SET storage_encrypted = 1
|
|
132
|
+
WHERE id = ? AND data_key = ? AND storage_encrypted = 0`).run(id, wrapped)
|
|
133
|
+
if (Number(result.changes)) await db.prepare('UPDATE managed_storage_encryption SET migrated = migrated + 1 WHERE id = 1').run()
|
|
134
|
+
},
|
|
135
|
+
async listStorageMigrationRows(after, limit) {
|
|
136
|
+
await requireEnabled()
|
|
137
|
+
return await db.prepare(`SELECT position, id, type FROM (${PENDING}) AS pending
|
|
138
|
+
WHERE position > ? ORDER BY position LIMIT ?`).all(after ?? '', limit) as StorageMigrationRow[]
|
|
139
|
+
},
|
|
140
|
+
async advanceStorageMigration(expected, next) {
|
|
141
|
+
await requireEnabled()
|
|
142
|
+
if (expected !== null && next !== null && next <= expected) return
|
|
143
|
+
// End-of-pass completion checks every pending row, including failed rows
|
|
144
|
+
// before the cursor. A failure cannot be mistaken for a finished pass.
|
|
145
|
+
await db.prepare(`UPDATE managed_storage_encryption SET cursor = ?, complete =
|
|
146
|
+
CASE WHEN ? IS NULL AND NOT EXISTS (SELECT 1 FROM (${PENDING}) AS pending) THEN 1 ELSE 0 END
|
|
147
|
+
WHERE id = 1 AND (cursor = ? OR (cursor IS NULL AND ? IS NULL))`).run(next, next, expected, expected)
|
|
148
|
+
},
|
|
149
|
+
async advanceStorageCleanup(expected, next, removed) {
|
|
150
|
+
const state = await requireEnabled()
|
|
151
|
+
const found = state.cleanupFound + removed
|
|
152
|
+
await db.prepare(`UPDATE managed_storage_encryption SET cleanup_cursor = ?, cleanup_found = ?, cleanup_complete = ?
|
|
153
|
+
WHERE id = 1 AND (cleanup_cursor = ? OR (cleanup_cursor IS NULL AND ? IS NULL))`).run(next, next === null ? 0 : found, next === null && found === 0 ? 1 : 0, expected, expected)
|
|
154
|
+
},
|
|
155
|
+
async migrateStorageUserTokens(id) {
|
|
156
|
+
await requireEnabled()
|
|
157
|
+
const row = await db.prepare(`SELECT gh_access_token AS access, gh_refresh_token AS refresh, gh_token_expires_at AS exp,
|
|
158
|
+
gh_tokens_encrypted AS encrypted FROM managed_user WHERE id = ?`).get(id) as StoredTokens | undefined
|
|
159
|
+
if (!row || row.encrypted || (row.access === null && row.refresh === null)) return
|
|
160
|
+
const value = encodeStorageTokens(key!, id, row.access, row.refresh)
|
|
161
|
+
// This operation holds the writer transaction; token refresh cannot be
|
|
162
|
+
// overwritten by a migration that read an earlier token value.
|
|
163
|
+
await db.prepare('UPDATE managed_user SET gh_access_token = ?, gh_refresh_token = ?, gh_tokens_encrypted = 1 WHERE id = ?')
|
|
164
|
+
.run(value.access, value.refresh, id)
|
|
165
|
+
await db.prepare('UPDATE managed_storage_encryption SET migrated = migrated + 1 WHERE id = 1').run()
|
|
166
|
+
},
|
|
167
|
+
}
|
|
168
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer'
|
|
2
|
+
import { randomBytes } from 'node:crypto'
|
|
3
|
+
import { Readable } from 'node:stream'
|
|
4
|
+
import { type StorageKey, decryptStorageStream, encryptStorageStream, wrapStorageValue } from '../server-common/storage-crypto.ts'
|
|
5
|
+
import { ENCRYPTED_CACHE_PREFIX, type ObjectStorage, type RawObject, type RawObjectStorage, deleteObjects, objectPath, openObjectVersion } from './object-storage.ts'
|
|
6
|
+
import { type StorageDb, type StorageEncryptionState, type StorageRow, type StorageRowKind, dataKeyIdentity, unwrapDataKey } from './storage-db.ts'
|
|
7
|
+
import { inspectStorageObject, storageOwner, storageRowPath, verifyStoragePayload } from './storage-payload.ts'
|
|
8
|
+
|
|
9
|
+
const STORAGE_WRITE_MS = 180_000
|
|
10
|
+
export const STORAGE_UPLOAD_TTL_MS = 86_400_000
|
|
11
|
+
export const STORAGE_DISABLED_TTL_MS = 5_000
|
|
12
|
+
|
|
13
|
+
async function readBytes(stored: Awaited<ReturnType<ObjectStorage['open']>>): Promise<Buffer | null> {
|
|
14
|
+
if (!stored) return null
|
|
15
|
+
const parts: Buffer[] = []
|
|
16
|
+
try { for await (const part of stored.stream) parts.push(Buffer.from(part)); return Buffer.concat(parts) }
|
|
17
|
+
finally { stored.stream.destroy() }
|
|
18
|
+
}
|
|
19
|
+
function logicalKey(identity: string) {
|
|
20
|
+
objectPath(identity)
|
|
21
|
+
if (identity.startsWith(ENCRYPTED_CACHE_PREFIX)) throw new Error('Encrypted cache paths are internal')
|
|
22
|
+
}
|
|
23
|
+
const encryptedCachePath = (identity: string) => `${ENCRYPTED_CACHE_PREFIX}${identity.slice('cache/'.length)}`
|
|
24
|
+
const deletionPaths = (identity: string) => identity.startsWith('cache/') ? [identity, encryptedCachePath(identity)] : [identity]
|
|
25
|
+
|
|
26
|
+
// A PUT can commit and still throw. Remove only this attempted plaintext,
|
|
27
|
+
// comparing bytes rather than magic: arbitrary uploads can share the header.
|
|
28
|
+
// A concurrent migration's ciphertext must survive this reconciliation.
|
|
29
|
+
async function discardPlaintextWrite(raw: RawObjectStorage, identity: string, bytes: Buffer): Promise<void> {
|
|
30
|
+
// Leave time to report the original failure inside a serverless invocation.
|
|
31
|
+
const signal = AbortSignal.timeout(30_000)
|
|
32
|
+
const current = await raw.open(identity, signal)
|
|
33
|
+
if (!current) return
|
|
34
|
+
try {
|
|
35
|
+
let offset = 0
|
|
36
|
+
for await (const part of current.stream) {
|
|
37
|
+
const chunk = Buffer.from(part)
|
|
38
|
+
if (!chunk.equals(bytes.subarray(offset, offset + chunk.length))) return
|
|
39
|
+
offset += chunk.length
|
|
40
|
+
}
|
|
41
|
+
if (offset === bytes.length) await raw.delete(identity, current.version, signal)
|
|
42
|
+
} finally { current.stream.destroy() }
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function putPlaintext(raw: RawObjectStorage, identity: string, bytes: Buffer, mode: () => Promise<StorageEncryptionState | null>): Promise<null> {
|
|
46
|
+
try {
|
|
47
|
+
await raw.put(identity, bytes, AbortSignal.timeout(STORAGE_WRITE_MS))
|
|
48
|
+
if (!await mode()) return null
|
|
49
|
+
throw new Error('Storage encryption was enabled during upload; retry the upload')
|
|
50
|
+
} catch (err) {
|
|
51
|
+
try { await discardPlaintextWrite(raw, identity, bytes) }
|
|
52
|
+
catch (cleanup) { throw new AggregateError([err, cleanup], 'Storage write and plaintext cleanup failed', { cause: cleanup }) }
|
|
53
|
+
throw err
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function decryptOwned(stored: RawObject, key: StorageKey, type: StorageRowKind, row: StorageRow, identity: string) {
|
|
58
|
+
let dataKey: Buffer | undefined
|
|
59
|
+
try {
|
|
60
|
+
dataKey = unwrapDataKey(key, type, row)
|
|
61
|
+
return await decryptStorageStream(stored.stream, dataKey, identity)
|
|
62
|
+
} catch (err) { stored.stream.destroy(); throw err }
|
|
63
|
+
finally { dataKey?.fill(0) }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function openEncrypted(raw: RawObjectStorage, db: StorageDb, key: StorageKey, identity: string, state: StorageEncryptionState): ReturnType<ObjectStorage['open']> {
|
|
67
|
+
const owner = storageOwner(identity)
|
|
68
|
+
if (!owner) {
|
|
69
|
+
if (!identity.startsWith('uploads/')) throw new Error('Unknown managed storage owner')
|
|
70
|
+
const stored = await raw.open(identity)
|
|
71
|
+
if (!stored) return null
|
|
72
|
+
const inspected = await inspectStorageObject(stored)
|
|
73
|
+
if (inspected.encrypted) return decryptStorageStream(inspected.stream, key.bytes, identity)
|
|
74
|
+
if (stored.modifiedAt <= state.enabledAt && Date.now() < state.enabledAt + STORAGE_UPLOAD_TTL_MS) return inspected
|
|
75
|
+
inspected.stream.destroy()
|
|
76
|
+
throw new Error('Unexpected plaintext upload part')
|
|
77
|
+
}
|
|
78
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
79
|
+
const row = await db.getStorageRow(owner.type, owner.id)
|
|
80
|
+
if (!row) return null
|
|
81
|
+
if (!owner.cache && storageRowPath(owner.type, row) !== identity) return null
|
|
82
|
+
if (owner.cache && !row.dataKey) return null
|
|
83
|
+
if (!owner.cache && !row.encrypted) state = (await db.getStorageEncryption())!
|
|
84
|
+
const stored = await raw.open(owner.cache ? encryptedCachePath(identity) : identity)
|
|
85
|
+
if (!stored) return null
|
|
86
|
+
// Completed uploads and all new caches are always encrypted. Only legacy
|
|
87
|
+
// pending rows need header inspection and hash-verified plaintext fallback.
|
|
88
|
+
if (row.encrypted || owner.cache || state.complete) {
|
|
89
|
+
return decryptOwned(stored, key, owner.type, row, identity)
|
|
90
|
+
}
|
|
91
|
+
let inspected = await inspectStorageObject(stored)
|
|
92
|
+
if (inspected.encrypted && !row.dataKey && (await db.getStorageRow(owner.type, owner.id))?.dataKey) {
|
|
93
|
+
inspected.stream.destroy(); continue
|
|
94
|
+
}
|
|
95
|
+
if (inspected.encrypted && row.dataKey) {
|
|
96
|
+
try { return await decryptOwned(inspected, key, owner.type, row, identity) }
|
|
97
|
+
catch {
|
|
98
|
+
// An arbitrary legacy upload can begin with our magic bytes. Only
|
|
99
|
+
// its original SQL hash can authorize that plaintext interpretation.
|
|
100
|
+
const legacy = await openObjectVersion(raw, identity, stored.version)
|
|
101
|
+
if (!legacy) continue
|
|
102
|
+
inspected = { ...legacy, encrypted: false }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Only a pending row may read plaintext, and only the original upload.
|
|
106
|
+
// Verify first, then reopen that exact version before exposing bytes.
|
|
107
|
+
await verifyStoragePayload(owner.type, row, inspected.stream)
|
|
108
|
+
const current = await openObjectVersion(raw, identity, stored.version)
|
|
109
|
+
if (current) return current
|
|
110
|
+
}
|
|
111
|
+
throw new Error('Storage changed repeatedly during read')
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Startup enables encryption before constructing stores; validate the persisted
|
|
115
|
+
// requirement even when this adapter is opened without a key.
|
|
116
|
+
async function storagePolicy(db: StorageDb, key: StorageKey | null) {
|
|
117
|
+
let enabled = await db.getStorageEncryption()
|
|
118
|
+
let checkedAt = Date.now()
|
|
119
|
+
return async (fresh = false) => {
|
|
120
|
+
// Enabled is irreversible. Keyless instances cache "off" briefly, while
|
|
121
|
+
// writes and detected ciphertext always recheck the activation fence.
|
|
122
|
+
if (!enabled && (fresh || key || Date.now() - checkedAt >= STORAGE_DISABLED_TTL_MS)) {
|
|
123
|
+
const state = await db.getStorageEncryption()
|
|
124
|
+
if (state) enabled = state // A delayed disabled read cannot undo "on".
|
|
125
|
+
checkedAt = Date.now()
|
|
126
|
+
}
|
|
127
|
+
if (enabled && !key) throw new Error('Managed storage requires its configured encryption key')
|
|
128
|
+
return enabled
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export async function createEncryptedObjectStorage(raw: RawObjectStorage, db: StorageDb, key: StorageKey | null): Promise<ObjectStorage> {
|
|
133
|
+
const mode = await storagePolicy(db, key)
|
|
134
|
+
async function open(identity: string): ReturnType<ObjectStorage['open']> {
|
|
135
|
+
logicalKey(identity)
|
|
136
|
+
const state = await mode()
|
|
137
|
+
if (identity.startsWith('avatars/')) return raw.open(identity)
|
|
138
|
+
if (state) return openEncrypted(raw, db, key!, identity, state)
|
|
139
|
+
const stored = await raw.open(identity)
|
|
140
|
+
if (!stored) return null
|
|
141
|
+
const inspected = await inspectStorageObject(stored)
|
|
142
|
+
// Activation/migration can win after the disabled-state read. Never send
|
|
143
|
+
// that ciphertext to a caller expecting the original plaintext bytes.
|
|
144
|
+
if (inspected.encrypted) {
|
|
145
|
+
let current: StorageEncryptionState | null
|
|
146
|
+
try { current = await mode(true) } catch (err) { inspected.stream.destroy(); throw err }
|
|
147
|
+
if (current) { inspected.stream.destroy(); return openEncrypted(raw, db, key!, identity, current) }
|
|
148
|
+
}
|
|
149
|
+
return inspected
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
open,
|
|
153
|
+
get: async identity => readBytes(await open(identity)),
|
|
154
|
+
async exists(identity) {
|
|
155
|
+
logicalKey(identity)
|
|
156
|
+
const owner = storageOwner(identity), state = await mode()
|
|
157
|
+
if (state && owner) {
|
|
158
|
+
const row = await db.getStorageRow(owner.type, owner.id)
|
|
159
|
+
if (!row || (owner.cache ? !row.dataKey : storageRowPath(owner.type, row) !== identity)) return false
|
|
160
|
+
}
|
|
161
|
+
// Existence is metadata only; open() authenticates the actual bytes.
|
|
162
|
+
return await raw.head(state && owner?.cache ? encryptedCachePath(identity) : identity) !== null
|
|
163
|
+
},
|
|
164
|
+
async put(identity, bytes) {
|
|
165
|
+
logicalKey(identity)
|
|
166
|
+
const state = await mode()
|
|
167
|
+
if (identity.startsWith('avatars/')) { await raw.put(identity, bytes); return null }
|
|
168
|
+
if (!state) return putPlaintext(raw, identity, bytes, () => mode(true))
|
|
169
|
+
const owner = storageOwner(identity)
|
|
170
|
+
let dataKey: Buffer, wrapped: string | null = null
|
|
171
|
+
if (owner?.cache) {
|
|
172
|
+
const row = await db.ensureStorageDataKey(owner.type, owner.id)
|
|
173
|
+
if (!row) throw new Error('Cache owner unavailable')
|
|
174
|
+
dataKey = unwrapDataKey(key!, owner.type, row)
|
|
175
|
+
} else if (owner) {
|
|
176
|
+
if (await db.getStorageRow(owner.type, owner.id)) throw new Error('Stored payloads are immutable')
|
|
177
|
+
dataKey = randomBytes(32)
|
|
178
|
+
wrapped = wrapStorageValue(key!, dataKeyIdentity(owner.type, owner.id), dataKey)
|
|
179
|
+
} else {
|
|
180
|
+
if (!identity.startsWith('uploads/')) throw new Error('Unknown managed storage owner')
|
|
181
|
+
dataKey = Buffer.from(key!.bytes)
|
|
182
|
+
}
|
|
183
|
+
const target = owner?.cache ? encryptedCachePath(identity) : identity
|
|
184
|
+
const encrypted = encryptStorageStream(Readable.from([bytes]), dataKey, identity, bytes.length)
|
|
185
|
+
try {
|
|
186
|
+
await raw.put(target, encrypted, AbortSignal.timeout(STORAGE_WRITE_MS), undefined, bytes.length)
|
|
187
|
+
// A late cache builder must not leave a readable cache for a deleted
|
|
188
|
+
// bundle. The missing SQL key also makes failed cleanup unreadable.
|
|
189
|
+
if (owner?.cache && !await db.getStorageRow(owner.type, owner.id)) await raw.delete(target)
|
|
190
|
+
return wrapped
|
|
191
|
+
} finally { encrypted.destroy(); dataKey.fill(0) }
|
|
192
|
+
},
|
|
193
|
+
async delete(identity) {
|
|
194
|
+
logicalKey(identity)
|
|
195
|
+
await mode()
|
|
196
|
+
// Always remove both generations, including caches predating activation
|
|
197
|
+
// when migration is disabled or another instance has just enabled it.
|
|
198
|
+
for (const target of deletionPaths(identity)) await raw.delete(target)
|
|
199
|
+
},
|
|
200
|
+
async deletePrefix(prefix) {
|
|
201
|
+
logicalKey(`${prefix}validate`)
|
|
202
|
+
if (!prefix.endsWith('/')) throw new Error('Invalid storage prefix')
|
|
203
|
+
await mode()
|
|
204
|
+
const signal = AbortSignal.timeout(STORAGE_WRITE_MS)
|
|
205
|
+
for (const target of deletionPaths(prefix)) {
|
|
206
|
+
await deleteObjects(raw, target, signal)
|
|
207
|
+
await raw.prune?.(target, signal)
|
|
208
|
+
}
|
|
209
|
+
},
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -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
|
+
}
|