@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.
Files changed (168) hide show
  1. package/api/reap.ts +17 -0
  2. package/cli.js +6 -0
  3. package/client/finding-link.js +305 -0
  4. package/client/linked-findings.d.ts +1 -0
  5. package/client/linked-findings.js +111 -0
  6. package/common/bundle-metadata.d.ts +10 -0
  7. package/common/bundle-metadata.js +177 -0
  8. package/common/bundle-reasons.d.ts +2 -0
  9. package/common/bundle-reasons.js +21 -0
  10. package/common/bundle-sources.d.ts +3 -0
  11. package/common/bundle-sources.js +284 -0
  12. package/common/bundle-stats.js +41 -0
  13. package/common/bundle-tabs.js +1 -0
  14. package/common/code-language.js +36 -0
  15. package/common/default-scan-models.ts +30 -0
  16. package/common/finding-id.js +47 -0
  17. package/common/github-pr.ts +56 -0
  18. package/common/managed/comments.ts +34 -0
  19. package/common/managed/permissions.ts +35 -0
  20. package/common/managed/report-content.ts +42 -0
  21. package/common/managed/report-filter.ts +108 -0
  22. package/common/managed/roles.ts +28 -0
  23. package/common/managed/routes.d.ts +2 -0
  24. package/common/managed/routes.js +121 -0
  25. package/common/managed/scan-models.ts +6 -0
  26. package/common/managed/triage.ts +83 -0
  27. package/common/save-error-reason.ts +20 -7
  28. package/common/scan-server.ts +13 -0
  29. package/common/server-info.ts +33 -0
  30. package/common/utf8.d.ts +3 -0
  31. package/common/utf8.js +45 -0
  32. package/out/brotli-fallback.js +3 -3
  33. package/out/client-managed-import.js +81 -0
  34. package/out/client-managed.js +110 -0
  35. package/out/client-sync.js +16 -13
  36. package/out/graph.js +30 -4
  37. package/out/index.html +55 -8
  38. package/out/prism.js +2 -2
  39. package/out/stasis.svg +45 -0
  40. package/out/terminal.js +273 -39
  41. package/out/view.css +1 -1
  42. package/out/view.js +198 -62
  43. package/package.json +179 -55
  44. package/report/index.js +254 -0
  45. package/report/src/finding-id.js +80 -0
  46. package/report/src/finding.js +312 -0
  47. package/report/src/labels.js +33 -0
  48. package/report/src/md-structure.js +471 -0
  49. package/report/src/md-text.js +167 -0
  50. package/report/src/meta.js +76 -0
  51. package/report/src/parse-codex.js +147 -0
  52. package/report/src/parse-deepsec.js +197 -0
  53. package/report/src/parse-deepview-fields.js +375 -0
  54. package/report/src/parse-deepview-md.js +185 -0
  55. package/report/src/parse-md-id.js +137 -0
  56. package/report/src/parse-md.js +322 -0
  57. package/report/src/parse-piolium-id.js +79 -0
  58. package/report/src/parse-piolium-rows.js +131 -0
  59. package/report/src/parse-piolium-tokens.js +175 -0
  60. package/report/src/parse-piolium.js +400 -0
  61. package/report/src/security.js +63 -0
  62. package/report/src/utf8.js +21 -0
  63. package/report/src/write-md-finding.js +273 -0
  64. package/report/src/write-md.js +291 -0
  65. package/server-common/database-config.ts +16 -0
  66. package/server-common/initialize.ts +18 -0
  67. package/server-common/npm-advisories.ts +101 -0
  68. package/{server → server-common}/origin.ts +5 -5
  69. package/server-common/reap.ts +48 -0
  70. package/server-common/scan-config.ts +19 -0
  71. package/server-common/standalone.ts +29 -0
  72. package/server-common/storage-log.ts +34 -0
  73. package/server-common/vercel-blob.ts +110 -0
  74. package/server-e2e/app.ts +485 -0
  75. package/{server → server-e2e}/auth.ts +5 -1
  76. package/{server → server-e2e}/bus-receiver.ts +9 -8
  77. package/{server → server-e2e}/cli.js +9 -4
  78. package/{server → server-e2e}/config.ts +54 -39
  79. package/{server → server-e2e}/db-neon.ts +2 -2
  80. package/{server → server-e2e}/db-revision-sql.ts +7 -10
  81. package/{server → server-e2e}/db-stmt.ts +2 -2
  82. package/{server → server-e2e}/db.ts +96 -135
  83. package/{server → server-e2e}/http.ts +110 -12
  84. package/{server → server-e2e}/hub.ts +44 -14
  85. package/server-e2e/index.ts +17 -0
  86. package/server-e2e/lifecycle.ts +95 -0
  87. package/{server → server-e2e}/neon-driver.ts +2 -2
  88. package/{server → server-e2e}/npm-proxy.ts +11 -144
  89. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  90. package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
  91. package/{server → server-e2e}/objstore/blob.ts +24 -9
  92. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  93. package/{server → server-e2e}/objstore/handlers.ts +19 -20
  94. package/{server → server-e2e}/objstore/init.ts +53 -27
  95. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  96. package/server-e2e/objstore/rest-deny.ts +28 -0
  97. package/server-e2e/objstore/rest-mint.ts +224 -0
  98. package/{server → server-e2e}/objstore/rest.ts +110 -93
  99. package/{server → server-e2e}/objstore/sign.ts +105 -0
  100. package/{server → server-e2e}/objstore/store-neon.ts +10 -14
  101. package/{server → server-e2e}/objstore/store.ts +98 -118
  102. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  103. package/{server → server-e2e}/peer.ts +7 -9
  104. package/{server → server-e2e}/pubsub.ts +29 -36
  105. package/{server → server-e2e}/sign.ts +12 -14
  106. package/{server → server-e2e}/sse-server.ts +105 -73
  107. package/{server → server-e2e}/sse-session.ts +30 -16
  108. package/{server → server-e2e}/static.ts +36 -29
  109. package/server-e2e/sync-handlers.ts +408 -0
  110. package/{server → server-e2e}/util.ts +9 -0
  111. package/{server → server-e2e}/ws-server.ts +29 -23
  112. package/server-managed/activity.ts +231 -0
  113. package/server-managed/avatar-store.ts +51 -0
  114. package/server-managed/blob-store.ts +66 -0
  115. package/server-managed/blob-vercel.ts +125 -0
  116. package/server-managed/brotli.ts +10 -0
  117. package/server-managed/bundle-cache.ts +185 -0
  118. package/server-managed/bundle-catalog.ts +29 -0
  119. package/server-managed/bundle-store.ts +28 -0
  120. package/server-managed/bundle-summary-cache.ts +97 -0
  121. package/server-managed/bundle.ts +39 -0
  122. package/server-managed/cache-storage.ts +40 -0
  123. package/server-managed/cli.js +13 -0
  124. package/server-managed/combined.ts +46 -0
  125. package/server-managed/comments.ts +151 -0
  126. package/server-managed/config.ts +144 -0
  127. package/server-managed/content-access.ts +15 -0
  128. package/server-managed/crypto.ts +25 -0
  129. package/server-managed/db-methods.ts +1330 -0
  130. package/server-managed/db-neon.ts +171 -0
  131. package/server-managed/db-schema.ts +203 -0
  132. package/server-managed/db-table-names.ts +22 -0
  133. package/server-managed/db.ts +109 -0
  134. package/server-managed/github-app.ts +332 -0
  135. package/server-managed/github-metadata.ts +65 -0
  136. package/server-managed/github-oauth.ts +215 -0
  137. package/server-managed/github-pulls.ts +115 -0
  138. package/server-managed/http-response.ts +18 -0
  139. package/server-managed/http.ts +2043 -0
  140. package/server-managed/import-triage.ts +48 -0
  141. package/server-managed/index.ts +135 -0
  142. package/server-managed/public-workspace.ts +150 -0
  143. package/server-managed/repo-path.ts +21 -0
  144. package/server-managed/report-migration.ts +35 -0
  145. package/server-managed/report-query.ts +4 -0
  146. package/server-managed/report-response.ts +16 -0
  147. package/server-managed/report-sources.ts +154 -0
  148. package/server-managed/repository-discovery.ts +82 -0
  149. package/server-managed/repository-policy.ts +25 -0
  150. package/server-managed/session.ts +78 -0
  151. package/server-managed/slugs.ts +38 -0
  152. package/server-managed/sql-postgres.ts +30 -0
  153. package/server-managed/sql.ts +61 -0
  154. package/server-managed/static.ts +28 -0
  155. package/server-managed/storage.ts +34 -0
  156. package/server-managed/team-catalog.ts +7 -0
  157. package/server-managed/team-feed.ts +128 -0
  158. package/server-managed/team-reports.ts +156 -0
  159. package/server-managed/triage-response.ts +16 -0
  160. package/server-managed/uploads.ts +47 -0
  161. package/server-managed/workspace-shares.ts +150 -0
  162. package/server.ts +50 -0
  163. package/server/index.ts +0 -481
  164. package/server/lifecycle.ts +0 -204
  165. package/server/sync-handlers.ts +0 -327
  166. /package/{server → server-e2e}/config.example.json +0 -0
  167. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  168. /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
- // For the FS backend there's no remote upload to wait for,
95
- // so we resolve immediately — the REST layer awaits but
96
- // doesn't block on anything real here. eslint-disable for
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, type Readable } from 'node:stream'
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 layer's error path). Attach a
246
- // detached `.catch` on the original promise so an early
247
- // rejection has a handler; finalize() awaits `putPromise`
248
- // directly, which still re-throws the original rejection
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' on the read side and
256
- // `put` is finalising the last multipart part. Awaiting here
257
- // gives us the same "bytes durable" guarantee that
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. The destination
319
- // bytes are identical by construction (the path IS the hash), so
320
- // the overwrite is idempotent — never a clobber of DIFFERENT
321
- // bytes. Without the flag, the SDK sends `x-allow-overwrite: 0`
322
- // and Vercel rejects any such re-promote with BlobAccessError.
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
- if (isNotFound(err)) return { ok: false, reason: 'not-found' }
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
- if (res == null) return { ok: false, reason: 'not-found' }
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
- // returns the body but does NOT populate `blob.size` and does
372
- // NOT pass a `content-length` header through (verified
373
- // empirically: get.size=0, content-length-hdr=null while
374
- // head.size reports the true byte count). The REST layer
375
- // depends on a size to set `content-length` on its response
376
- // and for the integrity check against the DB row, so when
377
- // get() leaves it 0/null we fall back to a head() lookup.
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
- if (isNotFound(headErr)) return { ok: false, reason: 'not-found' }
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 DATABASE_URL
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 loadSdk())
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
- // `not-found` maps to HTTP 404 (the live blob is gone or never
87
- // existed); `unavailable` maps to HTTP 503 (transient backend issue
88
- // the reaper will eventually sort out). The REST layer uses this
89
- // discrimination to set the right status code.
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: 'not-found' | 'unavailable' }
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
- // `not-found` lets the REST layer return 404; `unavailable` returns
132
- // 503 for a transient state (file/blob missing while the row still
133
- // exists — reaper will reconcile on the next sweep).
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 attaching
72
- // to / replying on a closed socket is the half-handshake leak case
73
- // (PR #4 review F4). Returns true when the caller may proceed.
74
- // Centralising the post-await readyState recheck keeps that
75
- // easy-to-forget invariant in one auditable place.
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
- // Symmetric with `handleDelete`'s prevVersion gate (line 116) and
100
- // `verifyObjstorePutSig`'s `isSafeIntOrNull` (sign.ts:119). Without
101
- // this, a non-safe-integer `prevVersion` (NaN, 2^53+1, ...) would
102
- // pass the typeof check below and reach sig verify, burning a
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. The gate is config-driven (server/config.json
114
- // `password`) and is a no-op when no password is configured.
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
- deps.broadcast(tag, { type: 'objstore-deleted', workspaceTag: tag, resourceTag, version: result.deletedVersion }, null)
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>