@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.20
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/api/reap.ts +17 -0
- package/cli.js +6 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +111 -0
- package/common/bundle-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +177 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-sources.d.ts +3 -0
- package/common/bundle-sources.js +284 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +36 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -0
- package/common/github-pr.ts +56 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/permissions.ts +35 -0
- package/common/managed/report-content.ts +42 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +121 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/triage.ts +83 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +33 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/out/brotli-fallback.js +3 -3
- package/out/client-managed-import.js +81 -0
- package/out/client-managed.js +110 -0
- package/out/client-sync.js +16 -13
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +45 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +198 -62
- package/package.json +179 -55
- package/report/index.js +254 -0
- package/report/src/finding-id.js +80 -0
- package/report/src/finding.js +312 -0
- package/report/src/labels.js +33 -0
- package/report/src/md-structure.js +471 -0
- package/report/src/md-text.js +167 -0
- package/report/src/meta.js +76 -0
- package/report/src/parse-codex.js +147 -0
- package/report/src/parse-deepsec.js +197 -0
- package/report/src/parse-deepview-fields.js +375 -0
- package/report/src/parse-deepview-md.js +185 -0
- package/report/src/parse-md-id.js +137 -0
- package/report/src/parse-md.js +322 -0
- package/report/src/parse-piolium-id.js +79 -0
- package/report/src/parse-piolium-rows.js +131 -0
- package/report/src/parse-piolium-tokens.js +175 -0
- package/report/src/parse-piolium.js +400 -0
- package/report/src/security.js +63 -0
- package/report/src/utf8.js +21 -0
- package/report/src/write-md-finding.js +273 -0
- package/report/src/write-md.js +291 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-log.ts +34 -0
- package/server-common/vercel-blob.ts +110 -0
- package/server-e2e/app.ts +485 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +9 -8
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +54 -39
- package/{server → server-e2e}/db-neon.ts +2 -2
- package/{server → server-e2e}/db-revision-sql.ts +7 -10
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +96 -135
- package/{server → server-e2e}/http.ts +110 -12
- package/{server → server-e2e}/hub.ts +44 -14
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +11 -144
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +19 -20
- package/{server → server-e2e}/objstore/init.ts +53 -27
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +110 -93
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +10 -14
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/{server → server-e2e}/pubsub.ts +29 -36
- package/{server → server-e2e}/sign.ts +12 -14
- package/{server → server-e2e}/sse-server.ts +105 -73
- package/{server → server-e2e}/sse-session.ts +30 -16
- package/{server → server-e2e}/static.ts +36 -29
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +51 -0
- package/server-managed/blob-store.ts +66 -0
- package/server-managed/blob-vercel.ts +125 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-cache.ts +185 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +28 -0
- package/server-managed/bundle-summary-cache.ts +97 -0
- package/server-managed/bundle.ts +39 -0
- package/server-managed/cache-storage.ts +40 -0
- package/server-managed/cli.js +13 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +144 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1330 -0
- package/server-managed/db-neon.ts +171 -0
- package/server-managed/db-schema.ts +203 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +109 -0
- package/server-managed/github-app.ts +332 -0
- package/server-managed/github-metadata.ts +65 -0
- package/server-managed/github-oauth.ts +215 -0
- package/server-managed/github-pulls.ts +115 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2043 -0
- package/server-managed/import-triage.ts +48 -0
- package/server-managed/index.ts +135 -0
- package/server-managed/public-workspace.ts +150 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +4 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +154 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +25 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/sql-postgres.ts +30 -0
- package/server-managed/sql.ts +61 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage.ts +34 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +128 -0
- package/server-managed/team-reports.ts +156 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +47 -0
- package/server-managed/workspace-shares.ts +150 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -481
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -327
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -48,13 +48,13 @@ async function fsOpenLiveReader(dir: string, tag: string, contentHash: string):
|
|
|
48
48
|
const path = liveFilePath(dir, tag, contentHash)
|
|
49
49
|
let fh
|
|
50
50
|
try { fh = await open(path, 'r') } catch (err: unknown) {
|
|
51
|
-
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: false, reason: 'unavailable' }
|
|
51
|
+
if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return { ok: false, reason: 'unavailable', detail: 'fs-enoent' }
|
|
52
52
|
throw err
|
|
53
53
|
}
|
|
54
54
|
let size: number
|
|
55
55
|
try { size = (await fh.stat()).size } catch {
|
|
56
56
|
await fh.close().catch(() => {})
|
|
57
|
-
return { ok: false, reason: 'unavailable' }
|
|
57
|
+
return { ok: false, reason: 'unavailable', detail: 'fs-stat-failed' }
|
|
58
58
|
}
|
|
59
59
|
const stream = fh.createReadStream()
|
|
60
60
|
let closed = false
|
|
@@ -90,12 +90,10 @@ export function openFsBlobBackend(dir: string): BlobBackend {
|
|
|
90
90
|
// closed by Node's stream machinery on 'finish'.
|
|
91
91
|
finalize: async () => {},
|
|
92
92
|
// `destroy(err)` synchronously starts tearing the stream
|
|
93
|
-
// down; the WriteStream emits 'close' on the next tick.
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// the no-await-in-async — the function signature is
|
|
98
|
-
// dictated by the BlobBackend contract.
|
|
93
|
+
// down; the WriteStream emits 'close' on the next tick. No
|
|
94
|
+
// remote upload to wait for on FS, so we resolve immediately
|
|
95
|
+
// — the REST layer awaits but doesn't block on anything real.
|
|
96
|
+
// Async signature is dictated by the BlobBackend contract.
|
|
99
97
|
// eslint-disable-next-line require-await
|
|
100
98
|
abort: async (err) => { writable.destroy(err as Error) },
|
|
101
99
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Vercel Blob Private Storage BlobBackend. Paired with the Neon DB
|
|
2
|
-
// plane in `server/index.ts` when `BLOB_READ_WRITE_TOKEN` is set;
|
|
2
|
+
// plane in `server-e2e/index.ts` when `BLOB_READ_WRITE_TOKEN` is set;
|
|
3
3
|
// the combination is the supported multi-replica deployment shape
|
|
4
4
|
// (Neon for metadata, Vercel Blob for bytes — both serverless, both
|
|
5
5
|
// HTTP-backed, no shared filesystem required).
|
|
@@ -35,110 +35,10 @@
|
|
|
35
35
|
// any single resource), and the stale-staging TTL sweep drops orphan
|
|
36
36
|
// staging blobs.
|
|
37
37
|
|
|
38
|
-
import { PassThrough
|
|
39
|
-
import { Buffer } from 'node:buffer'
|
|
38
|
+
import { PassThrough } from 'node:stream'
|
|
40
39
|
import type { BlobBackend, OpenLiveResult, StagingWriter } from './blob.ts'
|
|
41
40
|
import { errMsg } from '../util.ts'
|
|
42
|
-
|
|
43
|
-
// Minimal structural shape of the bits of `@vercel/blob` we use.
|
|
44
|
-
// Kept local so the optional peer dep doesn't have to type-resolve
|
|
45
|
-
// for SQLite-only deployments. Real type details live in the
|
|
46
|
-
// installed package; the fields/parameters we touch here are stable
|
|
47
|
-
// per the SDK v2 public surface.
|
|
48
|
-
//
|
|
49
|
-
// `put` accepts the SDK's full `PutBody` union (string | Readable |
|
|
50
|
-
// Buffer | Blob | ArrayBuffer | ReadableStream | File). We only ever
|
|
51
|
-
// pass a Node `PassThrough` (a Readable), but the wider type lets
|
|
52
|
-
// callers reuse this signature for future buffer/blob bodies without
|
|
53
|
-
// type gymnastics. `copy` accepts `allowOverwrite` — REQUIRED for
|
|
54
|
-
// version bumps since the live pathname is reused on re-upload.
|
|
55
|
-
type VercelBlobBody = Readable | Buffer | string | Blob | ArrayBuffer | ReadableStream<Uint8Array>
|
|
56
|
-
type VercelBlobSdk = {
|
|
57
|
-
put: (
|
|
58
|
-
pathname: string,
|
|
59
|
-
body: VercelBlobBody,
|
|
60
|
-
options: {
|
|
61
|
-
access: 'private' | 'public'
|
|
62
|
-
allowOverwrite?: boolean
|
|
63
|
-
contentType?: string
|
|
64
|
-
token?: string
|
|
65
|
-
multipart?: boolean
|
|
66
|
-
abortSignal?: AbortSignal
|
|
67
|
-
cacheControlMaxAge?: number
|
|
68
|
-
},
|
|
69
|
-
) => Promise<{ url: string; pathname: string }>
|
|
70
|
-
head: (
|
|
71
|
-
pathname: string,
|
|
72
|
-
options?: { token?: string; abortSignal?: AbortSignal },
|
|
73
|
-
) => Promise<{ size: number; pathname: string; url: string }>
|
|
74
|
-
get: (
|
|
75
|
-
pathname: string,
|
|
76
|
-
options: {
|
|
77
|
-
access: 'private' | 'public'
|
|
78
|
-
token?: string
|
|
79
|
-
useCache?: boolean
|
|
80
|
-
abortSignal?: AbortSignal
|
|
81
|
-
},
|
|
82
|
-
) => Promise<{
|
|
83
|
-
statusCode: 200 | 304
|
|
84
|
-
stream: ReadableStream<Uint8Array> | null
|
|
85
|
-
blob: { size: number | null }
|
|
86
|
-
} | null>
|
|
87
|
-
copy: (
|
|
88
|
-
fromPathname: string,
|
|
89
|
-
toPathname: string,
|
|
90
|
-
options: {
|
|
91
|
-
access: 'private' | 'public'
|
|
92
|
-
allowOverwrite?: boolean
|
|
93
|
-
token?: string
|
|
94
|
-
contentType?: string
|
|
95
|
-
cacheControlMaxAge?: number
|
|
96
|
-
},
|
|
97
|
-
) => Promise<{ url: string; pathname: string }>
|
|
98
|
-
del: (
|
|
99
|
-
urlOrPathname: string | string[],
|
|
100
|
-
options?: { token?: string; abortSignal?: AbortSignal },
|
|
101
|
-
) => Promise<void>
|
|
102
|
-
list: (options: {
|
|
103
|
-
prefix?: string
|
|
104
|
-
cursor?: string
|
|
105
|
-
limit?: number
|
|
106
|
-
mode?: 'expanded' | 'folded'
|
|
107
|
-
token?: string
|
|
108
|
-
}) => Promise<{
|
|
109
|
-
// `uploadedAt` (a Date per the SDK v2 surface) is the blob's
|
|
110
|
-
// creation time — used by the reaper's GC grace window. Optional
|
|
111
|
-
// in the type because the `folded` listing path ignores it (only
|
|
112
|
-
// `listLiveBlobs` reads it, and it uses the default expanded mode).
|
|
113
|
-
blobs: Array<{ pathname: string; size: number; uploadedAt?: Date | string | number }>
|
|
114
|
-
folders?: string[]
|
|
115
|
-
cursor?: string
|
|
116
|
-
hasMore: boolean
|
|
117
|
-
}>
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
// Recognise "blob is gone" errors uniformly across read/write/delete
|
|
121
|
-
// paths so callers can treat them as success (delete) or
|
|
122
|
-
// not-found (read). The SDK exposes BlobNotFoundError as a class
|
|
123
|
-
// with `.name === 'BlobNotFoundError'`; checking the name string
|
|
124
|
-
// avoids importing the class at the top level (which would force
|
|
125
|
-
// the optional peer dep to resolve).
|
|
126
|
-
//
|
|
127
|
-
// Class-name check ONLY. The SDK's internal mapper translates every
|
|
128
|
-
// API `not_found` code into BlobNotFoundError-by-name; a bare-404
|
|
129
|
-
// transport leak doesn't reach here. A prior version of this
|
|
130
|
-
// function had a `/does not exist|\b404\b/` fallback that
|
|
131
|
-
// DANGEROUSLY matched BlobStoreNotFoundError's message "This store
|
|
132
|
-
// does not exist." — a config fault (revoked token, deleted store)
|
|
133
|
-
// would silently surface as every-blob-missing across reads and
|
|
134
|
-
// unlinks, masking the fatal misconfiguration. The tight name check
|
|
135
|
-
// lets BlobStoreNotFoundError / other classes propagate as real
|
|
136
|
-
// exceptions.
|
|
137
|
-
function isNotFound(err: unknown): boolean {
|
|
138
|
-
if (err == null || typeof err !== 'object') return false
|
|
139
|
-
const name = (err as { name?: unknown }).name
|
|
140
|
-
return typeof name === 'string' && name === 'BlobNotFoundError'
|
|
141
|
-
}
|
|
41
|
+
import { type VercelBlobSdk, isNotFound, loadVercelBlobSdk } from '../../server-common/vercel-blob.ts'
|
|
142
42
|
|
|
143
43
|
function liveBlobPath(tag: string, contentHash: string): string {
|
|
144
44
|
return `${tag}/${contentHash}.bin`
|
|
@@ -242,20 +142,17 @@ function buildOpenStagingWriter(sdk: VercelBlobSdk, token: string): BlobBackend[
|
|
|
242
142
|
abortSignal: ac.signal,
|
|
243
143
|
})
|
|
244
144
|
// Defuse a possible unhandled-rejection if abort() is called
|
|
245
|
-
// BEFORE finalize() (the REST
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
// (the .catch returns a separate chain that doesn't replace
|
|
250
|
-
// putPromise's state).
|
|
145
|
+
// BEFORE finalize() (the REST error path). The detached `.catch`
|
|
146
|
+
// gives an early rejection a handler; finalize() still awaits
|
|
147
|
+
// `putPromise` directly and re-throws the original rejection (the
|
|
148
|
+
// .catch is a separate chain, not a replacement of putPromise).
|
|
251
149
|
putPromise.catch(() => {})
|
|
252
150
|
return {
|
|
253
151
|
writable: pt,
|
|
254
152
|
// Await the upload's completion. After pipeline(req, counter,
|
|
255
|
-
// pt) resolves, pt has emitted 'end'
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
// pipeline-to-WriteStream gives the FS backend.
|
|
153
|
+
// pt) resolves, pt has emitted 'end' and `put` is finalising the
|
|
154
|
+
// last multipart part. Awaiting here gives the same "bytes
|
|
155
|
+
// durable" guarantee pipeline-to-WriteStream gives the FS backend.
|
|
259
156
|
finalize: async () => { await putPromise },
|
|
260
157
|
// Await the SDK's put-promise settlement (rejected via the
|
|
261
158
|
// AbortController). Without this await, a slow upload that
|
|
@@ -315,11 +212,11 @@ function buildPromoteStagingToLive(sdk: VercelBlobSdk, token: string): BlobBacke
|
|
|
315
212
|
//
|
|
316
213
|
// `allowOverwrite: true` because the content-addressed live
|
|
317
214
|
// pathname `${tag}/${contentHash}.bin` can be (re)written by a
|
|
318
|
-
// retried or racing promote of the same blob.
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
215
|
+
// retried or racing promote of the same blob. Destination bytes
|
|
216
|
+
// are identical by construction (the path IS the hash), so the
|
|
217
|
+
// overwrite is idempotent — never a clobber of DIFFERENT bytes.
|
|
218
|
+
// Without the flag the SDK sends `x-allow-overwrite: 0` and
|
|
219
|
+
// Vercel rejects the re-promote with BlobAccessError.
|
|
323
220
|
await sdk.copy(from, to, {
|
|
324
221
|
access: 'private',
|
|
325
222
|
allowOverwrite: true,
|
|
@@ -356,38 +253,55 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
|
|
|
356
253
|
// origin truth. Origin fetch is the right default for a store
|
|
357
254
|
// where freshness > latency.
|
|
358
255
|
try { res = await sdk.get(path, { access: 'private', useCache: false, token }) } catch (err) {
|
|
359
|
-
|
|
256
|
+
// A missing blob HERE is never "the resource doesn't exist" — the
|
|
257
|
+
// REST layer (rest.ts openLiveSnapshot) already confirmed a live row
|
|
258
|
+
// whose (version, incarnation) matches the GET token before calling
|
|
259
|
+
// us. So BlobNotFoundError means the bytes for a still-live row are
|
|
260
|
+
// momentarily gone: the reaper GC'd a hash a racing version-bump just
|
|
261
|
+
// unreferenced, or Vercel's read-after-write / edge propagation hasn't
|
|
262
|
+
// caught up to a freshly-promoted private blob. That is the documented
|
|
263
|
+
// `unavailable` (HTTP 503) transient — reconciled by reaper /
|
|
264
|
+
// propagation, retried by the client — NOT a 404. Returning
|
|
265
|
+
// `not-found` would emit a 404 the FS backend never emits for the
|
|
266
|
+
// same condition (blob-fs.ts maps ENOENT → `unavailable`), telling
|
|
267
|
+
// the client the resource is gone for good when it should refetch.
|
|
268
|
+
// See server-e2e/README.md's GET status table.
|
|
269
|
+
if (isNotFound(err)) return { ok: false, reason: 'unavailable', detail: 'vercel-get-not-found' }
|
|
360
270
|
throw err
|
|
361
271
|
}
|
|
362
|
-
|
|
272
|
+
// SDK returned null (no blob) — same "bytes missing for a live row"
|
|
273
|
+
// transient as the BlobNotFoundError branch above → `unavailable`, not
|
|
274
|
+
// `not-found`.
|
|
275
|
+
if (res == null) return { ok: false, reason: 'unavailable', detail: 'vercel-get-null' }
|
|
363
276
|
// statusCode 304 doesn't reach here in practice — the REST
|
|
364
277
|
// GET layer doesn't pass If-None-Match — but a future call
|
|
365
278
|
// site could. Treat as unavailable rather than streaming a
|
|
366
279
|
// null body.
|
|
367
280
|
if (res.statusCode !== 200 || res.stream == null) {
|
|
368
|
-
return { ok: false, reason: 'unavailable' }
|
|
281
|
+
return { ok: false, reason: 'unavailable', detail: `vercel-get-status-${res.statusCode}` }
|
|
369
282
|
}
|
|
370
|
-
// `@vercel/blob@2.x`'s streaming `get()` for private blobs
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
// Two round-trips per private read on Vercel until the SDK is
|
|
379
|
-
// fixed — small price vs. the alternative of 503ing every read.
|
|
283
|
+
// `@vercel/blob@2.x`'s streaming `get()` for private blobs returns
|
|
284
|
+
// the body but does NOT populate `blob.size` nor pass a
|
|
285
|
+
// `content-length` header (verified empirically: get.size=0,
|
|
286
|
+
// content-length-hdr=null, while head.size reports the true count).
|
|
287
|
+
// The REST layer needs a size to set `content-length` and for the
|
|
288
|
+
// integrity check against the DB row, so on 0/null we fall back to
|
|
289
|
+
// head(). Two round-trips per private read until the SDK is fixed —
|
|
290
|
+
// small price vs. 503ing every read.
|
|
380
291
|
let size: number | null | undefined = res.blob?.size
|
|
381
292
|
if (size == null || size === 0) {
|
|
382
293
|
try {
|
|
383
294
|
const h = await sdk.head(path, { token })
|
|
384
295
|
size = (h as { size?: number })?.size
|
|
385
296
|
} catch (headErr) {
|
|
386
|
-
|
|
297
|
+
// Blob vanished between get() and the head() size fallback (a
|
|
298
|
+
// racing reaper GC) — still the "live row present, bytes gone"
|
|
299
|
+
// transient, so `unavailable` (503), matching the get() path above.
|
|
300
|
+
if (isNotFound(headErr)) return { ok: false, reason: 'unavailable', detail: 'vercel-head-not-found' }
|
|
387
301
|
throw headErr
|
|
388
302
|
}
|
|
389
303
|
}
|
|
390
|
-
if (size == null) return { ok: false, reason: 'unavailable' }
|
|
304
|
+
if (size == null) return { ok: false, reason: 'unavailable', detail: 'vercel-no-size' }
|
|
391
305
|
// SDK returns a web ReadableStream<Uint8Array>; the REST layer
|
|
392
306
|
// expects a Node Readable for pipeline(). Convert via
|
|
393
307
|
// Readable.fromWeb — built-in and zero-copy where possible.
|
|
@@ -481,7 +395,7 @@ export type VercelBlobBackendOptions = {
|
|
|
481
395
|
// Vercel Blob R/W token, typically from BLOB_READ_WRITE_TOKEN.
|
|
482
396
|
// The SDK also reads it from process.env, but passing it
|
|
483
397
|
// explicitly here keeps the env-var → boot config path single-
|
|
484
|
-
// sourced through server/index.ts (matches the Neon
|
|
398
|
+
// sourced through server-e2e/index.ts (matches the Neon database URL
|
|
485
399
|
// handling — env-read at boot, threaded as a parameter).
|
|
486
400
|
token: string
|
|
487
401
|
// Test seam: inject a stub of the @vercel/blob module to avoid
|
|
@@ -495,7 +409,7 @@ export async function openVercelBlobBackend(opts: VercelBlobBackendOptions): Pro
|
|
|
495
409
|
// dep. `@ts-ignore` (not `@ts-expect-error`) so an operator who
|
|
496
410
|
// DOES install `@vercel/blob` doesn't trip TS2578 "unused
|
|
497
411
|
// directive" — same pattern as db-neon.ts.
|
|
498
|
-
const sdk: VercelBlobSdk = opts.sdk ?? (await
|
|
412
|
+
const sdk: VercelBlobSdk = opts.sdk ?? (await loadVercelBlobSdk())
|
|
499
413
|
const token = opts.token
|
|
500
414
|
return {
|
|
501
415
|
// No-op: Vercel Blob has no folder concept. The pathname's
|
|
@@ -521,9 +435,3 @@ export async function openVercelBlobBackend(opts: VercelBlobBackendOptions): Pro
|
|
|
521
435
|
listStagingIds: buildListStagingIds(sdk, token, (tag) => `${tag}/.staging/`),
|
|
522
436
|
}
|
|
523
437
|
}
|
|
524
|
-
|
|
525
|
-
async function loadSdk(): Promise<VercelBlobSdk> {
|
|
526
|
-
// @ts-ignore optional peer dep: '@vercel/blob'
|
|
527
|
-
const mod = (await import('@vercel/blob')) as VercelBlobSdk
|
|
528
|
-
return mod
|
|
529
|
-
}
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// the Neon DB plane for multi-replica
|
|
12
12
|
// deployments)
|
|
13
13
|
//
|
|
14
|
-
// Selected at boot in `server/index.ts` and passed to `openObjstore`
|
|
14
|
+
// Selected at boot in `server-e2e/index.ts` and passed to `openObjstore`
|
|
15
15
|
// / `openNeonObjstore`. The DB-plane code in ./store.ts, ./rest.ts,
|
|
16
16
|
// and ./reaper.ts goes through `handle.blob.*` and is backend-
|
|
17
17
|
// agnostic — no `if (vercel) … else` branching in consumers.
|
|
@@ -83,13 +83,27 @@ export type LiveReader = {
|
|
|
83
83
|
close(): Promise<void>
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
86
|
+
// A missing blob is always `unavailable` (→ HTTP 503), never a 404. The
|
|
87
|
+
// byte plane has NO view of the metadata row, so it can't decide whether
|
|
88
|
+
// a resource "doesn't exist" — only whether specific bytes are present
|
|
89
|
+
// right now. The authoritative "this resource/version is gone" 404 is the
|
|
90
|
+
// REST layer's call, made from the live row BEFORE it opens a reader
|
|
91
|
+
// (rest.ts openLiveSnapshot). By the time `openLiveReader` runs the row is
|
|
92
|
+
// already confirmed, so an absent blob means a transient bytes/metadata
|
|
93
|
+
// desync the reaper (or store propagation) reconciles — exactly the
|
|
94
|
+
// `unavailable`/503 contract, which the client retries. Both backends MUST
|
|
95
|
+
// map a missing blob to `unavailable` (FS: ENOENT; Vercel: BlobNotFoundError
|
|
96
|
+
// / null get()). No 404-mapping variant exists here so that bug can't recur.
|
|
97
|
+
//
|
|
98
|
+
// `detail` is a short, NON-SENSITIVE machine tag for the specific cause
|
|
99
|
+
// (e.g. 'vercel-get-not-found', 'fs-enoent', 'vercel-no-size'). Every
|
|
100
|
+
// byte-side failure collapses to the same 503 on the wire, so a permanent
|
|
101
|
+
// loss (reaper GC'd the bytes) and a transient read fault are otherwise
|
|
102
|
+
// indistinguishable — the REST layer logs `detail` so an operator can tell
|
|
103
|
+
// them apart. Purely diagnostic; the REST status is unchanged.
|
|
90
104
|
export type OpenLiveResult =
|
|
91
105
|
| { ok: true; reader: LiveReader }
|
|
92
|
-
| { ok: false; reason: '
|
|
106
|
+
| { ok: false; reason: 'unavailable'; detail?: string }
|
|
93
107
|
|
|
94
108
|
export type BlobBackend = {
|
|
95
109
|
// Per-workspace setup. FS creates the on-disk staging directory;
|
|
@@ -128,9 +142,10 @@ export type BlobBackend = {
|
|
|
128
142
|
promoteStagingToLive(tag: string, stagingId: string, contentHash: string): Promise<boolean>
|
|
129
143
|
|
|
130
144
|
// Open a streaming reader for the content-addressed live blob.
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
145
|
+
// Called only after the REST layer has confirmed the live row, so a
|
|
146
|
+
// missing blob is the transient "row present, bytes gone" state →
|
|
147
|
+
// `unavailable` (HTTP 503), which the reaper reconciles and the client
|
|
148
|
+
// retries. Never a 404 from here — see OpenLiveResult above.
|
|
134
149
|
openLiveReader(tag: string, contentHash: string): Promise<OpenLiveResult>
|
|
135
150
|
|
|
136
151
|
// Idempotent deletes. Backends MUST tolerate "already gone" as
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Anti-replay guard for the REST fetch-mint endpoint (POST
|
|
2
|
+
// /api/objstore/{tag}/{res}, see ./rest.ts). The WS fetch handshake binds
|
|
3
|
+
// the per-connection challenge nonce so a captured frame can't be
|
|
4
|
+
// replayed; the REST mint has no connection, so it binds a client
|
|
5
|
+
// timestamp instead and this guard supplies the matching freshness +
|
|
6
|
+
// dedup the nonce gave for free:
|
|
7
|
+
//
|
|
8
|
+
// - FRESHNESS: reject a request whose `ts` is outside ±`windowMs` of
|
|
9
|
+
// server time (an old captured request, or a future-dated one).
|
|
10
|
+
// - DEDUP: within the window, a signature is accepted at most once — a
|
|
11
|
+
// captured-and-replayed request (same `ts` ⇒ same signature) is
|
|
12
|
+
// rejected on the second presentation.
|
|
13
|
+
//
|
|
14
|
+
// Memory: each accepted signature is held for `windowMs` then pruned.
|
|
15
|
+
// Entries share a uniform TTL and the Map preserves insertion order, so
|
|
16
|
+
// the oldest entries expire first and a cheap front-prune (amortised
|
|
17
|
+
// O(1) per entry) keeps the set bounded; a hard `maxEntries` cap drops
|
|
18
|
+
// the oldest beyond it as a flood backstop.
|
|
19
|
+
//
|
|
20
|
+
// Scope: per-process. In a multi-replica deployment a captured request
|
|
21
|
+
// could be replayed once per replica that hasn't seen its signature yet
|
|
22
|
+
// (bounded by the replica count, within the window) — acceptable because
|
|
23
|
+
// the mint only ever yields a short-TTL GET token over AEAD ciphertext
|
|
24
|
+
// the relay can't read. A shared-store dedup (Redis/Neon) would close
|
|
25
|
+
// that residual gap if ever needed.
|
|
26
|
+
|
|
27
|
+
export type FetchMintVerdict = 'ok' | 'stale' | 'replay'
|
|
28
|
+
|
|
29
|
+
export type FetchMintGuard = {
|
|
30
|
+
// `signature` is the request's Ed25519 signature (unique per
|
|
31
|
+
// (tag, res, ts) tuple, so it doubles as the dedup key). `ts` is the
|
|
32
|
+
// client epoch-ms timestamp the signature commits to; `now` is the
|
|
33
|
+
// server clock (injectable for tests).
|
|
34
|
+
admit: (signature: string, ts: number, now?: number) => FetchMintVerdict
|
|
35
|
+
size: () => number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_FETCH_MINT_WINDOW_MS = 60_000
|
|
39
|
+
export const DEFAULT_FETCH_MINT_MAX_ENTRIES = 50_000
|
|
40
|
+
|
|
41
|
+
export function createFetchMintGuard(
|
|
42
|
+
{ windowMs = DEFAULT_FETCH_MINT_WINDOW_MS, maxEntries = DEFAULT_FETCH_MINT_MAX_ENTRIES }:
|
|
43
|
+
{ windowMs?: number; maxEntries?: number } = {},
|
|
44
|
+
): FetchMintGuard {
|
|
45
|
+
// signature → expiry (ms). Insertion-ordered; uniform TTL ⇒ the head is
|
|
46
|
+
// always the soonest to expire.
|
|
47
|
+
const seen = new Map<string, number>()
|
|
48
|
+
|
|
49
|
+
function admit(signature: string, ts: number, now: number = Date.now()): FetchMintVerdict {
|
|
50
|
+
// Freshness first — a stale/future request never touches the cache, so
|
|
51
|
+
// it can't be used to grow the set.
|
|
52
|
+
if (!Number.isFinite(ts) || Math.abs(now - ts) > windowMs) return 'stale'
|
|
53
|
+
// Front-prune expired entries (contiguous at the head under the
|
|
54
|
+
// uniform TTL). Breaks at the first live entry.
|
|
55
|
+
for (const [key, exp] of seen) {
|
|
56
|
+
if (exp > now) break
|
|
57
|
+
seen.delete(key)
|
|
58
|
+
}
|
|
59
|
+
// Post-prune, any remaining entry is live, so a hit is a genuine replay.
|
|
60
|
+
if (seen.has(signature)) return 'replay'
|
|
61
|
+
seen.set(signature, now + windowMs)
|
|
62
|
+
// Flood backstop: drop the oldest beyond the cap. Those are the
|
|
63
|
+
// closest to expiry anyway; dropping them only shortens their dedup
|
|
64
|
+
// window (still freshness-gated).
|
|
65
|
+
while (seen.size > maxEntries) {
|
|
66
|
+
const oldest = seen.keys().next().value
|
|
67
|
+
if (oldest === undefined) break
|
|
68
|
+
seen.delete(oldest)
|
|
69
|
+
}
|
|
70
|
+
return 'ok'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { admit, size: () => seen.size }
|
|
74
|
+
}
|
|
@@ -48,9 +48,9 @@ export type ObjstoreDeps = {
|
|
|
48
48
|
// Cross-instance pub/sub for objstore-deleted. Fired alongside the
|
|
49
49
|
// local `broadcast` after a successful delete so peers on OTHER
|
|
50
50
|
// server instances see the version drop in real time. Carries the
|
|
51
|
-
// full (tag, resourceTag, version) tuple inline — the workspace_object
|
|
51
|
+
// full (tag, resourceTag, version, incarnation) tuple inline — the workspace_object
|
|
52
52
|
// row is gone post-delete, so the bus payload IS the wire data.
|
|
53
|
-
publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
|
|
53
|
+
publishObjDeleted: (tag: string, resourceTag: string, version: number, incarnation: string) => void
|
|
54
54
|
getNonce: (socket: WebSocket) => string | undefined
|
|
55
55
|
debug: boolean
|
|
56
56
|
// Auth gate for the FIRST put-begin against a never-before-seen
|
|
@@ -68,11 +68,11 @@ function urlPathFor(tag: string, resourceTag: string): string {
|
|
|
68
68
|
// Shared gate every objstore handler runs after its message-specific
|
|
69
69
|
// field checks: fetch the socket's challenge nonce, verify the signed
|
|
70
70
|
// message against it, then re-confirm the socket is still OPEN — the
|
|
71
|
-
// close handler may have fired during the verify await, and
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
71
|
+
// close handler may have fired during the verify await, and replying
|
|
72
|
+
// on a closed socket is the half-handshake leak case (PR #4 review
|
|
73
|
+
// F4). Centralising the post-await readyState recheck keeps that
|
|
74
|
+
// invariant in one auditable place. Returns true when the caller may
|
|
75
|
+
// proceed.
|
|
76
76
|
async function verified<M extends { workspaceTag?: unknown }>(
|
|
77
77
|
deps: ObjstoreDeps, socket: WebSocket, msg: M, label: string,
|
|
78
78
|
verify: (m: M, nonce: string) => Promise<boolean>,
|
|
@@ -96,22 +96,20 @@ async function handlePutBegin(deps: ObjstoreDeps, socket: WebSocket, msg: Objsto
|
|
|
96
96
|
// signature to fail. Cheaper to reject up-front, and consistent
|
|
97
97
|
// with `verifyObjstorePutSig`'s `isSafeNonNegativeInt` gate.
|
|
98
98
|
if (!Number.isSafeInteger(msg.expectedLength) || (msg.expectedLength as number) < 0 || (msg.expectedLength as number) > MAX_CONTENT_LENGTH) return
|
|
99
|
-
//
|
|
100
|
-
// `verifyObjstorePutSig`'s `isSafeIntOrNull`
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
// hash + Ed25519 round-trip on a guaranteed-fail input. Input-
|
|
104
|
-
// validation audit `server/objstore/handlers.ts:76`.
|
|
99
|
+
// Same up-front reject for `prevVersion` (symmetric with handleDelete
|
|
100
|
+
// and `verifyObjstorePutSig`'s `isSafeIntOrNull`): a non-safe-integer
|
|
101
|
+
// (NaN, 2^53+1, ...) would pass the typeof check below and reach sig
|
|
102
|
+
// verify, burning a hash + Ed25519 round-trip on a guaranteed fail.
|
|
105
103
|
if (msg.prevVersion != null && (typeof msg.prevVersion !== 'number' || !Number.isSafeInteger(msg.prevVersion))) return
|
|
106
104
|
if (!await verified(deps, socket, msg, 'put-begin', verifyObjstorePutSig)) return
|
|
107
105
|
const tag = msg.workspaceTag
|
|
108
106
|
const resourceTag = msg.resourceTag
|
|
109
107
|
// Auth gate for the FIRST action against a never-before-seen
|
|
110
108
|
// workspace tag (no rows in workspace_revision AND none in
|
|
111
|
-
// workspace_object). Mirrors handleSave in server/index.ts; runs
|
|
109
|
+
// workspace_object). Mirrors handleSave in server-e2e/index.ts; runs
|
|
112
110
|
// AFTER sig verify so `unauthorized` only reaches a legitimate
|
|
113
|
-
// signer.
|
|
114
|
-
//
|
|
111
|
+
// signer. Config-driven (server-e2e/config.json `password`), no-op when
|
|
112
|
+
// unconfigured.
|
|
115
113
|
if (deps.authGate && deps.sendUnauthorized && await deps.authGate(socket, tag)) {
|
|
116
114
|
if (socket.readyState !== socket.OPEN) return
|
|
117
115
|
if (deps.debug) console.warn(`reject objstore-put-begin: unauthorized (new workspace ${debugTag(tag)})`)
|
|
@@ -193,13 +191,14 @@ async function handleDelete(deps: ObjstoreDeps, socket: WebSocket, msg: Objstore
|
|
|
193
191
|
// observes its own PUTs as echo events. The same symmetry on
|
|
194
192
|
// `onDeleted` lets `session.onDeleted` fire for the session's
|
|
195
193
|
// own deletes — pinned by `tests/objstore-client-races.test.js`.
|
|
196
|
-
|
|
194
|
+
// A successful nonempty delete matched this authenticated incarnation.
|
|
195
|
+
deps.broadcast(tag, { type: 'objstore-deleted', workspaceTag: tag, resourceTag, version: result.deletedVersion, incarnation: prevIncarnation! }, null)
|
|
197
196
|
// Cross-instance fan-out (Neon mode). The workspace_object row is
|
|
198
197
|
// gone post-delete so the bus payload carries (tag, resourceTag,
|
|
199
|
-
// version) inline; the receiver builds its `objstore-deleted`
|
|
198
|
+
// version, incarnation) inline; the receiver builds its `objstore-deleted`
|
|
200
199
|
// broadcast directly from the bus envelope. SQLite mode publishes
|
|
201
200
|
// to a no-op.
|
|
202
|
-
deps.publishObjDeleted(tag, resourceTag, result.deletedVersion)
|
|
201
|
+
deps.publishObjDeleted(tag, resourceTag, result.deletedVersion, prevIncarnation!)
|
|
203
202
|
if (deps.debug) console.log(`objstore delete → ${debugTag(tag)}/${resourceTag.slice(0, 8)}…`)
|
|
204
203
|
}
|
|
205
204
|
|
|
@@ -230,7 +229,7 @@ async function handleFetch(deps: ObjstoreDeps, socket: WebSocket, msg: ObjstoreF
|
|
|
230
229
|
// the reaper's TTL pass within `STAGING_TTL_MS_DEFAULT` — no per-
|
|
231
230
|
// socket bookkeeping is needed on disconnect today. If that ever
|
|
232
231
|
// changes, wire a `cleanupSocket(socket)` into both this bundle and
|
|
233
|
-
// server/index.ts's close handler.
|
|
232
|
+
// server-e2e/index.ts's close handler.
|
|
234
233
|
|
|
235
234
|
export type ObjstoreHandlers = {
|
|
236
235
|
handlePutBegin: (s: WebSocket, m: ObjstorePutBeginMsg) => Promise<void>
|