@preventive/triage 1.0.0-alpha.30 → 1.0.0-alpha.32
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 +45 -43
- package/out/client-managed.js +75 -73
- package/out/graph.js +1 -1
- package/out/view.css +1 -1
- package/out/view.js +181 -181
- package/package.json +3 -3
- package/server-common/vercel-blob.ts +1 -0
- package/server-managed/index.ts +8 -6
- package/server-managed/object-storage-vercel.ts +4 -1
- package/server-managed/object-storage.ts +2 -0
- package/server-managed/storage-maintenance.ts +27 -9
- package/server-managed/storage-payload.ts +27 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.32",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT OR Apache-2.0",
|
|
6
6
|
"author": {
|
|
@@ -265,8 +265,8 @@
|
|
|
265
265
|
"dependencies": {
|
|
266
266
|
"@exodus/stasis": "1.0.0-beta.5",
|
|
267
267
|
"@exodus/stasis-core": "1.0.0-beta.5",
|
|
268
|
-
"@preventive/report": "1.0.0-alpha.
|
|
269
|
-
"@preventive/upstream": "1.0.0-alpha.
|
|
268
|
+
"@preventive/report": "1.0.0-alpha.5",
|
|
269
|
+
"@preventive/upstream": "1.0.0-alpha.7",
|
|
270
270
|
"ws": "^8.20.1"
|
|
271
271
|
},
|
|
272
272
|
"peerDependencies": {
|
package/server-managed/index.ts
CHANGED
|
@@ -21,10 +21,13 @@ const REAP_RETRY_MS = 60_000
|
|
|
21
21
|
type ManagedAppOptions = Partial<Pick<ManagedHttpDeps, 'next' | 'serverInfo' | 'isShuttingDown'>>
|
|
22
22
|
|
|
23
23
|
export async function createManagedApp(config: ManagedConfig, options: ManagedAppOptions = {}) {
|
|
24
|
-
|
|
24
|
+
// Load the optional peer only for serverless managed deployments. Embedding
|
|
25
|
+
// hosts can discard listener promises, so retain maintenance inside the app.
|
|
26
|
+
const waitUntil = config.serverless ? (await import('@vercel/functions')).waitUntil : undefined
|
|
27
|
+
return await initializeApp(rollback => assembleManagedApp(config, options, rollback, waitUntil))
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOptions, rollback: AsyncDisposableStack) {
|
|
30
|
+
async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOptions, rollback: AsyncDisposableStack, waitUntil?: (promise: Promise<unknown>) => void) {
|
|
28
31
|
const storage = await openManagedStorage(config)
|
|
29
32
|
const { db, avatarStore, reportStore, bundleStore, bundleCache, reportSourcesCache } = storage
|
|
30
33
|
rollback.defer(() => db.close())
|
|
@@ -87,10 +90,9 @@ async function assembleManagedApp(config: ManagedConfig, options: ManagedAppOpti
|
|
|
87
90
|
return reap(true).catch(err => console.warn('managed-reaper: cleanup failed:', err))
|
|
88
91
|
}
|
|
89
92
|
async function handleRequest(req: Parameters<typeof routeRequest>[0], res: Parameters<typeof routeRequest>[1]): Promise<void> {
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
// work after res.end() alone cannot extend a serverless invocation.
|
|
93
|
+
// Register before routing can end the response, including when init()'s
|
|
94
|
+
// listener is mounted in Fastify rather than using api/managed.ts.
|
|
95
|
+
const pendingMaintenance = automaticReap(); waitUntil?.(pendingMaintenance)
|
|
94
96
|
try { await routeRequest(req, res) }
|
|
95
97
|
finally { await pendingMaintenance }
|
|
96
98
|
}
|
|
@@ -16,7 +16,10 @@ export async function openVercelObjectStorage(token: string, sdk?: VercelBlobSdk
|
|
|
16
16
|
},
|
|
17
17
|
async open(key, signal) {
|
|
18
18
|
try {
|
|
19
|
-
|
|
19
|
+
// Read the stored representation: transport compression can weaken its
|
|
20
|
+
// ETag, which cannot satisfy ifMatch, and changes the advertised size.
|
|
21
|
+
const result = await blobs.get(path(key), { token, access: 'private', useCache: false,
|
|
22
|
+
headers: { 'accept-encoding': 'identity' }, ...(signal ? { abortSignal: signal } : {}) })
|
|
20
23
|
if (!result) return null
|
|
21
24
|
if (result.statusCode !== 200 || !result.stream || !result.blob.etag) {
|
|
22
25
|
await result.stream?.cancel()
|
|
@@ -16,6 +16,8 @@ export interface RawObjectStorage {
|
|
|
16
16
|
// Persist an observed disk replacement before its SQL migration checkpoint.
|
|
17
17
|
sync?(key: string, signal?: AbortSignal): Promise<void>
|
|
18
18
|
// A plaintext size hint selects multipart uploads without buffering streams.
|
|
19
|
+
// Publish atomically only after consuming the body through clean EOF; a body
|
|
20
|
+
// error must preserve the existing object (migration verifies while streaming).
|
|
19
21
|
put(key: string, bytes: Buffer | Readable, signal?: AbortSignal, expected?: string, sizeHint?: number): Promise<boolean>
|
|
20
22
|
// Return false if the original version changed; do not delete its replacement.
|
|
21
23
|
delete(key: string, version?: string, signal?: AbortSignal): Promise<boolean>
|
|
@@ -2,7 +2,7 @@ import { type StorageKey, decryptStorageStream, encryptStorageStream } from '../
|
|
|
2
2
|
import { ENCRYPTED_CACHE_PREFIX, type RawObjectStorage, deleteObjects, isBlobId, openObjectVersion } from './object-storage.ts'
|
|
3
3
|
import { type StorageDb, type StorageEncryptionState, type StorageMigrationRow, unwrapDataKey } from './storage-db.ts'
|
|
4
4
|
import { STORAGE_UPLOAD_TTL_MS } from './storage-encryption.ts'
|
|
5
|
-
import { StoragePayloadError, inspectStorageObject, storageOwner, storageRowPath, verifyStoragePayload } from './storage-payload.ts'
|
|
5
|
+
import { StoragePayloadError, inspectStorageObject, storageOwner, storageRowPath, verifiedStoragePayload, verifyStoragePayload } from './storage-payload.ts'
|
|
6
6
|
|
|
7
7
|
async function replacementFailure(raw: RawObjectStorage, identity: string, version: string, signal: AbortSignal): Promise<string> {
|
|
8
8
|
const format = (value: string) => value.startsWith('W/') ? 'weak' : value.startsWith('"') && value.endsWith('"') ? 'quoted' : 'unquoted'
|
|
@@ -20,12 +20,16 @@ async function replacementFailure(raw: RawObjectStorage, identity: string, versi
|
|
|
20
20
|
return `${reason}; metadata=${format(current.version)}; comparison=${comparison}`
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
type MigrationPhase = 'prepare-key' | 'open-payload' | 'verify-existing' | 'encrypt-upload' | 'verify-replacement' | 'checkpoint'
|
|
24
|
+
async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey, item: StorageMigrationRow, signal: AbortSignal,
|
|
25
|
+
progress: (phase: MigrationPhase) => void): Promise<string | void> {
|
|
24
26
|
if (item.type === 'user') { await db.migrateStorageUserTokens(item.id); return }
|
|
27
|
+
progress('prepare-key')
|
|
25
28
|
const row = await db.ensureStorageDataKey(item.type, item.id)
|
|
26
29
|
if (!row || row.encrypted) return
|
|
27
30
|
const dataKey = unwrapDataKey(key, item.type, row), identity = storageRowPath(item.type, row)
|
|
28
31
|
try {
|
|
32
|
+
progress('open-payload')
|
|
29
33
|
const source = await raw.open(identity, signal)
|
|
30
34
|
if (!source) {
|
|
31
35
|
if (!await db.getStorageRow(item.type, item.id)) return
|
|
@@ -33,6 +37,7 @@ async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
|
|
|
33
37
|
}
|
|
34
38
|
let inspected = await inspectStorageObject(source)
|
|
35
39
|
if (inspected.encrypted) {
|
|
40
|
+
progress('verify-existing')
|
|
36
41
|
// A previous PUT may have completed before a timeout or lost SQL ack.
|
|
37
42
|
try {
|
|
38
43
|
const decoded = await decryptStorageStream(inspected.stream, dataKey, identity)
|
|
@@ -47,17 +52,19 @@ async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
|
|
|
47
52
|
}
|
|
48
53
|
}
|
|
49
54
|
if (!inspected.encrypted) {
|
|
50
|
-
|
|
55
|
+
progress('encrypt-upload')
|
|
51
56
|
signal.throwIfAborted()
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
const encrypted = encryptStorageStream(current.stream, dataKey, identity, current.size)
|
|
57
|
+
const verified = verifiedStoragePayload(item.type, row, inspected.stream, signal)
|
|
58
|
+
const encrypted = encryptStorageStream(verified, dataKey, identity, inspected.size)
|
|
55
59
|
let replaced: boolean
|
|
56
|
-
try { replaced = await raw.put(identity, encrypted, signal, source.version,
|
|
57
|
-
|
|
60
|
+
try { replaced = await raw.put(identity, encrypted, signal, source.version, inspected.size ?? undefined) }
|
|
61
|
+
// A provider may wrap a request-body error; keep safe payload diagnostics.
|
|
62
|
+
catch (err) { throw verified.errored ?? err }
|
|
63
|
+
finally { encrypted.destroy(); verified.destroy(); inspected.stream.destroy(); source.stream.destroy() }
|
|
58
64
|
if (!replaced) return await replacementFailure(raw, identity, source.version, signal)
|
|
59
65
|
// Do not mark the row encrypted until the stored bytes authenticate and
|
|
60
66
|
// match the original upload (including decompression for sourcemaps).
|
|
67
|
+
progress('verify-replacement')
|
|
61
68
|
const replacement = await raw.open(identity, signal)
|
|
62
69
|
if (!replacement) {
|
|
63
70
|
if (!await db.getStorageRow(item.type, item.id)) return
|
|
@@ -69,6 +76,7 @@ async function migrateRow(raw: RawObjectStorage, db: StorageDb, key: StorageKey,
|
|
|
69
76
|
signal.throwIfAborted()
|
|
70
77
|
// Another process may have renamed ciphertext but stopped before syncing
|
|
71
78
|
// the directory. Persist the observed rename even on the resume path.
|
|
79
|
+
progress('checkpoint')
|
|
72
80
|
await raw.sync?.(identity, signal)
|
|
73
81
|
await db.markStorageEncrypted(item.type, item.id, row.dataKey!)
|
|
74
82
|
} finally { dataKey.fill(0) }
|
|
@@ -176,8 +184,17 @@ export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key:
|
|
|
176
184
|
for (const row of rows) {
|
|
177
185
|
if (Date.now() >= deadline || signal.aborted) break
|
|
178
186
|
console.info('managed-storage-migration-row-start:', JSON.stringify({ type: row.type, id: row.id }))
|
|
187
|
+
const startedAt = Date.now()
|
|
188
|
+
let phase: MigrationPhase | 'tokens' = 'tokens'
|
|
189
|
+
const logPhase = (aborted = false) => console.info('managed-storage-migration-row-phase:', JSON.stringify({
|
|
190
|
+
type: row.type, id: row.id, phase, elapsedMs: Date.now() - startedAt, ...(aborted ? { aborted: true } : {}),
|
|
191
|
+
}))
|
|
192
|
+
// Log directly on cancellation even if an underlying operation stalls
|
|
193
|
+
// while unwinding. These fixed phases contain no object/provider data.
|
|
194
|
+
const aborted = () => logPhase(true)
|
|
195
|
+
signal.addEventListener('abort', aborted, { once: true })
|
|
179
196
|
try {
|
|
180
|
-
const pending = await migrateRow(raw, db, key, row, signal)
|
|
197
|
+
const pending = await migrateRow(raw, db, key, row, signal, next => { phase = next; logPhase() })
|
|
181
198
|
if (pending) failures.push(rowFailure(row, pending))
|
|
182
199
|
}
|
|
183
200
|
catch (err) {
|
|
@@ -192,6 +209,7 @@ export async function migrateStorage(raw: RawObjectStorage, db: StorageDb, key:
|
|
|
192
209
|
errors.push(new Error(`Could not migrate ${row.type} ${row.id}`, { cause: err }))
|
|
193
210
|
} else failures.push(rowFailure(row, failure))
|
|
194
211
|
}
|
|
212
|
+
finally { signal.removeEventListener('abort', aborted) }
|
|
195
213
|
if (stopping?.aborted) break
|
|
196
214
|
await db.advanceStorageMigration(state.cursor, row.position)
|
|
197
215
|
processed++
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { Buffer } from 'node:buffer'
|
|
2
2
|
import { createHash } from 'node:crypto'
|
|
3
|
-
import { Readable, Writable } from 'node:stream'
|
|
3
|
+
import { PassThrough, Readable, Writable } from 'node:stream'
|
|
4
4
|
import { pipeline } from 'node:stream/promises'
|
|
5
5
|
import { createBrotliDecompress } from 'node:zlib'
|
|
6
6
|
import { STORAGE_MAGIC } from '../server-common/storage-crypto.ts'
|
|
@@ -70,3 +70,29 @@ export async function verifyStoragePayload(type: StorageRowKind, row: StorageRow
|
|
|
70
70
|
const actual = type === 'report' ? hash.digest('base64url') : `sha512-${hash.digest('base64')}`
|
|
71
71
|
if (actual !== row.hash) throw new StoragePayloadError('Stored payload does not match its upload hash')
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
// Forward the stored representation with backpressure, but withhold EOF until
|
|
75
|
+
// its upload hash passes. Encryption cannot emit its final frame, and an atomic
|
|
76
|
+
// PUT cannot commit, until verification (including sourcemap decompression) ends.
|
|
77
|
+
export function verifiedStoragePayload(type: StorageRowKind, row: StorageRow, source: Readable, signal?: AbortSignal): Readable {
|
|
78
|
+
const input = new PassThrough(), output = new PassThrough()
|
|
79
|
+
const verification = verifyStoragePayload(type, row, input, signal)
|
|
80
|
+
// Propagate asynchronous decompression errors even if the source is stalled.
|
|
81
|
+
void verification.catch(err => { source.destroy(err); output.destroy(err) })
|
|
82
|
+
const forwarding = pipeline(source, async function* (chunks) {
|
|
83
|
+
try {
|
|
84
|
+
for await (const chunk of chunks) {
|
|
85
|
+
await new Promise<void>((resolve, reject) => { input.write(chunk, err => err ? reject(err) : resolve()) })
|
|
86
|
+
yield chunk
|
|
87
|
+
}
|
|
88
|
+
input.end()
|
|
89
|
+
await verification
|
|
90
|
+
} finally {
|
|
91
|
+
input.destroy()
|
|
92
|
+
await verification.catch(() => {})
|
|
93
|
+
}
|
|
94
|
+
}, output, signal ? { signal } : {})
|
|
95
|
+
// pipeline forwards errors to output and tears down input on early cancellation.
|
|
96
|
+
void forwarding.catch(() => {})
|
|
97
|
+
return output
|
|
98
|
+
}
|