@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
@@ -1,5 +1,5 @@
1
1
  // SQLite + filesystem-backed object store for the v1.objstore
2
- // protocol extension. Sibling of `server/db.ts`; shares the
2
+ // protocol extension. Sibling of `server-e2e/db.ts`; shares the
3
3
  // underlying DatabaseSync handle but keeps its own tables.
4
4
  //
5
5
  // workspace_object — one row per LIVE resource (no
@@ -79,9 +79,8 @@ const SCHEMA = `
79
79
 
80
80
  // One LIVE row, exactly the shape the `workspace-subscribed` ack's
81
81
  // `resources` array carries on the wire, minus `keyframe`-style
82
- // server-only flags. `put_at` is a
83
- // debug aid the wire format doesn't include — operators can inspect
84
- // it via the DB but the server never volunteers it.
82
+ // server-only flags. `put_at` is a debug aid the wire format never
83
+ // includes — inspectable via the DB but the server never volunteers it.
85
84
  export type ObjectRow = {
86
85
  resourceTag: string
87
86
  version: number
@@ -118,13 +117,11 @@ export type BeginPutInput = {
118
117
  // PUT will reference. `workspace-full` is the per-workspace resource-
119
118
  // count cap rejection — see `MAX_RESOURCES_PER_WORKSPACE`.
120
119
  //
121
- // `filePath` is set only for the FS-backed handle (where it's the
122
- // absolute on-disk staging path); the Vercel-Blob handle omits it
123
- // since "path" isn't a meaningful concept against a remote object
124
- // store. Production code (rest.ts) never reads this field — the
125
- // REST layer goes through `handle.blob.openStagingWriter(tag, sid)`.
126
- // Tests for the FS path use it as a convenience to write fixture
127
- // bytes directly to the staging slot.
120
+ // `filePath` is the absolute on-disk staging path, set only for the
121
+ // FS handle; the Vercel handle omits it ("path" is meaningless against
122
+ // a remote store). Production (rest.ts) never reads it — it goes
123
+ // through `handle.blob.openStagingWriter(tag, sid)`. FS-path tests use
124
+ // it as a convenience to write fixture bytes to the staging slot.
128
125
  export type BeginPutResult =
129
126
  | { ok: true; stagingId: string; filePath?: string }
130
127
  | { ok: false; reason: 'conflict'; conflict: ObjectRow | null }
@@ -134,17 +131,14 @@ export type CommitPutInput = {
134
131
  workspaceTag: string
135
132
  resourceTag: string
136
133
  stagingId: string
137
- // Optional: the storage-side byte count the caller already
138
- // verified after the upload landed. When provided, commitPut
139
- // skips the otherwise-redundant `statStaging` round-trip — for
140
- // the Vercel backend that's one fewer HTTP HEAD per PUT.
141
- // Caller must ONLY pass a value it observed for THIS stagingId
142
- // after its upload finished (i.e. the REST PUT path's post-upload
143
- // `statStaging`). Safe without a lock because staging ids are
144
- // random — no other request writes this blob — and the sole writer
145
- // (this PUT) has already completed. Tests that drive commitPut
146
- // directly without the REST layer should omit this and let
147
- // commitPut stat for itself.
134
+ // Optional storage-side byte count the caller already verified
135
+ // post-upload. When provided, commitPut skips the redundant
136
+ // `statStaging` round-trip — one fewer Vercel HEAD per PUT. Caller
137
+ // must ONLY pass a value it observed for THIS stagingId after its
138
+ // upload finished (the REST PUT path's post-upload `statStaging`).
139
+ // Safe without a lock: staging ids are random (no other request
140
+ // writes this blob) and the sole writer (this PUT) has finished.
141
+ // Tests driving commitPut directly omit this and let it stat.
148
142
  observedSize?: number
149
143
  }
150
144
 
@@ -156,7 +150,7 @@ export type DeleteResult =
156
150
  | { ok: true; deletedVersion: number }
157
151
  | { ok: false; reason: 'not-found' | 'conflict'; conflict?: ObjectRow }
158
152
 
159
- // Async statement shapes are shared with server/db.ts via
153
+ // Async statement shapes are shared with server-e2e/db.ts via
160
154
  // ../db-stmt.ts — same `.get/.all/.run` → Promise contract across
161
155
  // both planes.
162
156
 
@@ -181,9 +175,9 @@ type DbRow = {
181
175
  // - the reaper's age grace window + an atomic conditional staging
182
176
  // delete (`deleteStagingIfStale`) so the stale-staging sweep
183
177
  // can't race an upload that just finished.
184
- // These hold both within a single process AND across replicas — the
185
- // old per-(tag, resourceTag) in-process mutex added nothing the CAS +
186
- // content-addressing didn't already give, so it was removed.
178
+ // These hold both within a single process AND across replicas, so no
179
+ // per-(tag, resourceTag) in-process mutex is needed — the CAS +
180
+ // content-addressing already cover everything one would.
187
181
  export type Handle = {
188
182
  // SQLite-only: the underlying `DatabaseSync`. Unset on the Neon
189
183
  // backend (see ./store-neon.ts). Test-only fixture SQL routes
@@ -192,7 +186,7 @@ export type Handle = {
192
186
  db?: DatabaseSync
193
187
  // Byte-plane backend (local FS or Vercel Blob). All bytes-side
194
188
  // operations go through this — there is no direct fs.* call in
195
- // store / rest / reaper. Selected at boot in server/index.ts.
189
+ // store / rest / reaper. Selected at boot in server-e2e/index.ts.
196
190
  blob: BlobBackend
197
191
  // Storage root for the FS backend — set only when `blob` was
198
192
  // constructed from `openFsBlobBackend(dir)`. Production code
@@ -212,15 +206,14 @@ export type Handle = {
212
206
  selectStagingByWsSid: GetStmt<[string, string], unknown>
213
207
  refreshStagingBegunAt: RunStmt<[number, string, string, string]>
214
208
  deleteStaging: RunStmt<[string, string, string]>
215
- // Atomic conditional staging delete used by the reaper's stale-row
209
+ // Atomic conditional staging delete for the reaper's stale-row
216
210
  // sweep. `deleteStagingIfStale(tag, res, sid, staleBefore)` deletes
217
211
  // the row IFF its `begun_at < staleBefore`, returning `{ ok: 1 }`
218
- // when a row was actually removed and `undefined` otherwise. This is
219
- // the lock-free replacement for the old in-lock begun_at re-read
220
- // (PR #4 "F1"): a slow PUT that finishes and calls
221
- // `refreshStagingBegunAt` bumps `begun_at` fresh, so a concurrent
222
- // reaper's conditional delete simply doesn't match (its predicate
223
- // fails atomically) and the row survives for the commit. Mirrors the
212
+ // when a row was actually removed and `undefined` otherwise. Lock-
213
+ // free race guard (PR #4 "F1"): a slow PUT that finishes calls
214
+ // `refreshStagingBegunAt` to bump `begun_at` fresh, so a concurrent
215
+ // reaper's conditional delete doesn't match (predicate fails
216
+ // atomically) and the row survives for the commit. Mirrors the
224
217
  // `insertLiveIfAbsent` RETURNING pattern.
225
218
  deleteStagingIfStale: GetStmt<[string, string, string, number], { ok: number }>
226
219
  selectLive: AllStmt<[string], DbRow>
@@ -254,7 +247,7 @@ export type Handle = {
254
247
  // returned. The reaper passes `Date.now() - stagingTtlMs` so the
255
248
  // index `workspace_object_staging_begun_at_idx` is used and the
256
249
  // sweep is O(stale-rows) instead of O(in-flight-uploads-cluster-
257
- // wide). DB-layout audit `server/objstore/store.ts:312`.
250
+ // wide). DB-layout audit `server-e2e/objstore/store.ts:312`.
258
251
  listAllStaging: AllStmt<[number], { workspace_tag: string; resource_tag: string; staging_id: string; begun_at: number }>
259
252
  listLiveTags: AllStmt<[], { workspace_tag: string }>
260
253
  countLive: GetStmt<[string], { c: number }>
@@ -262,7 +255,7 @@ export type Handle = {
262
255
 
263
256
  // Narrowing alias for the SQLite-backed Handle: `db` is guaranteed
264
257
  // to be set. `openObjstore` returns this so call sites (production
265
- // shutdown plumbing in `server/index.ts` + the entire SQLite-only
258
+ // shutdown plumbing in `server-e2e/index.ts` + the entire SQLite-only
266
259
  // test suite in `tests/server-objstore.test.js`) can reach
267
260
  // `handle.db.prepare(...)` without an optional-chain or non-null
268
261
  // assertion. A Neon-backed Handle (`openNeonObjstore`) keeps the
@@ -279,9 +272,6 @@ export type SqliteHandle = Handle & { db: DatabaseSync }
279
272
  // transient over-shoot under high concurrency across DIFFERENT
280
273
  // resources is bounded by `(parallel new-resource begins - 1)` and is
281
274
  // accepted (the cap is a soft policy bound, not a security invariant).
282
- // This was already the contract under the old per-resource lock —
283
- // that lock keyed on (tag, resourceTag), so concurrent NEW-resource
284
- // begins held DIFFERENT locks and raced the count anyway.
285
275
  export const MAX_RESOURCES_PER_WORKSPACE = 100
286
276
 
287
277
  // Per-upload byte cap, shared by the WS plane (rejects oversize
@@ -362,7 +352,7 @@ export function openObjstore(db: DatabaseSync, dir: string): SqliteHandle {
362
352
  mkdirSync(dir, { recursive: true })
363
353
  db.exec(SCHEMA)
364
354
  // Fail-loud on a pre-existing non-STRICT table — same rationale as
365
- // server/db.ts: `CREATE TABLE IF NOT EXISTS … STRICT` doesn't
355
+ // server-e2e/db.ts: `CREATE TABLE IF NOT EXISTS … STRICT` doesn't
366
356
  // upgrade an existing non-STRICT table, and dropping strict type
367
357
  // affinity opens an operator-attack path. PR #4 review F3.
368
358
  for (const name of ['workspace_object', 'workspace_object_staging']) {
@@ -372,12 +362,10 @@ export function openObjstore(db: DatabaseSync, dir: string): SqliteHandle {
372
362
  const blob = openFsBlobBackend(dir)
373
363
  // No `close` method on the returned Handle: the underlying
374
364
  // `DatabaseSync` is owned by the caller (in production, the
375
- // workspace_revision handle in `server/db.ts`, which closes it
376
- // from `shutdown()`). Exposing `close()` here was misleading —
377
- // a callsite reading `await objstoreHandle.close()` would
378
- // reasonably assume it closes something, when in practice it
379
- // either no-op'd (production) or left the connection open
380
- // (tests construct their own DB and `db.close()` separately).
365
+ // workspace_revision handle in `server-e2e/db.ts`, closed from
366
+ // `shutdown()`; tests close their own DB). A `close()` here would
367
+ // mislead — it could only no-op or leak, never close the caller-
368
+ // owned connection.
381
369
  return {
382
370
  db,
383
371
  blob,
@@ -522,11 +510,10 @@ export async function beginPut(handle: Handle, input: BeginPutInput): Promise<Be
522
510
  if (liveVersion !== input.prevVersion || liveIncarnation !== input.prevIncarnation) {
523
511
  return { ok: false, reason: 'conflict', conflict: live }
524
512
  }
525
- // Per-workspace resource cap. Only enforced for NEW resources —
526
- // re-uploads of an existing resourceTag (live != null) don't
527
- // change the count, so they're always allowed. Not atomic with the
528
- // insert below (see MAX_RESOURCES_PER_WORKSPACE) — a soft policy
529
- // bound, accepted to over-shoot under concurrent NEW-resource begins.
513
+ // Per-workspace resource cap, NEW resources only — re-uploads
514
+ // (live != null) don't change the count, so they're always allowed.
515
+ // Not atomic with the insert below; soft bound, see
516
+ // MAX_RESOURCES_PER_WORKSPACE.
530
517
  if (!live) {
531
518
  const count = await handle.countLive.get(input.workspaceTag) as { c: number } | undefined
532
519
  if ((count?.c ?? 0) >= MAX_RESOURCES_PER_WORKSPACE) {
@@ -557,51 +544,48 @@ export async function beginPut(handle: Handle, input: BeginPutInput): Promise<Be
557
544
  // IF ABSENT; a re-upload bumps the version IFF it still matches the
558
545
  // precondition we read. Exactly one of N racing commits wins the CAS;
559
546
  // the losers get `conflict` (with the current live row) and rebase.
560
- // Because each PUT is content-addressed at its OWN hash (distinct PUTs
561
- // get distinct hashes — random nonce per encrypt), N racing commits
562
- // promote to N DIFFERENT immutable paths: no promote clobbers another's
563
- // bytes, and a loser's blob is just left unreferenced for the GC. There
564
- // is no metadata-vs-bytes desync to guard against. The
565
- // CAS provides the commit's atomicity both within a process and across
566
- // replicas, so NO in-process lock is taken. A crash between the
567
- // promote and the CAS leaves the staging blob/row intact alongside (at
568
- // most) a stranded, unreferenced live blob — the reaper's stale-staging
569
- // sweep cleans the row, and the GC reaps the unreferenced live blob
570
- // once it's past the grace window, matching the "stranded state,
571
- // reaper-cleaned, never row-pointing-at-nothing" crash-safety contract.
547
+ // Each PUT is content-addressed at its OWN hash (distinct PUTs get
548
+ // distinct hashes — random nonce per encrypt), so N racers promote to
549
+ // N DIFFERENT immutable paths: no promote clobbers another's bytes, a
550
+ // loser's blob is just left unreferenced for the GC, and there's no
551
+ // metadata-vs-bytes desync to guard. The CAS gives atomicity both
552
+ // within a process and across replicas, so NO in-process lock is taken.
553
+ // A crash between the promote and the CAS leaves the staging blob/row
554
+ // intact alongside (at most) a stranded, unreferenced live blob — the
555
+ // stale-staging sweep cleans the row, the GC reaps the blob once past
556
+ // the grace window: the "stranded state, reaper-cleaned, never row-
557
+ // pointing-at-nothing" crash-safety contract.
572
558
  //
573
- // ACCEPTED TRADEOFF (lock removal): with no lock, the reaper no longer
574
- // WAITS for an in-flight upload on this key. An upload taking >1h FROM
575
- // BEGIN (i.e. exceeding the staging TTL during the body) can have its
576
- // staging row reaped mid-flight by `deleteStagingIfStale`; this commit
577
- // then sees no staging row and returns `no-staging` → REST 410. The
578
- // previous lock made the reaper block on any in-flight upload
579
- // (unbounded). Sub-1h uploads are unaffected: `begun_at` (set at begin)
580
- // stays within the TTL through the body, so the conditional delete
581
- // can't match, and the after-body `refreshStagingBegunAt` re-extends
582
- // the TTL to cover this commit step. This matches the staging TTL's
583
- // documented intent ("1h, comfortably over a 50 MiB upload on a slow
584
- // line").
559
+ // ACCEPTED TRADEOFF (lock removal): without a lock the reaper no longer
560
+ // waits for an in-flight upload on this key (the old lock blocked it on
561
+ // any in-flight upload, unbounded). An upload taking >1h FROM BEGIN
562
+ // (exceeding the staging TTL during the body) can have its staging row
563
+ // reaped mid-flight by `deleteStagingIfStale` → this commit sees no
564
+ // staging row → `no-staging` → REST 410. Sub-1h uploads are unaffected:
565
+ // `begun_at` (set at begin) stays within the TTL through the body, so
566
+ // the conditional delete can't match, and the after-body
567
+ // `refreshStagingBegunAt` re-extends the TTL to cover this commit step —
568
+ // matching the staging TTL's intent ("1h, comfortably over a 50 MiB
569
+ // upload on a slow line").
585
570
  export async function commitPut(handle: Handle, input: CommitPutInput): Promise<CommitPutResult> {
586
571
  const staging = await handle.selectStaging.get(input.workspaceTag, input.resourceTag, input.stagingId)
587
572
  if (!staging) return { ok: false, reason: 'no-staging' }
588
573
  let stagedSize: number | null
589
574
  // statStaging failure here is a server-side issue (staging file
590
- // was unlinked by a racing abort / reaper, EACCES, EIO, backend
575
+ // unlinked by a racing abort / reaper, EACCES, EIO, backend
591
576
  // unreachable, …) — not a client length-mismatch. Route through
592
577
  // `io-error` so the REST layer returns 5xx, not 400. PR #4 review.
593
578
  //
594
579
  // The REST PUT layer already statted the staging blob post-upload
595
580
  // and threads the result in via `observedSize` — skipping the
596
- // round-trip saves one Vercel HEAD per PUT. The staging blob can't
597
- // have been resized between that stat and here: staging ids are
598
- // 16-byte random, so no other request targets this blob, and the
599
- // sole writer (this PUT's upload pipeline) has already finished
600
- // before the REST stat ran. The only other actor that touches a
601
- // staging blob is the reaper, which UNLINKS (it never resizes); a
602
- // racing reaper unlink surfaces below as statStaging→io-error or a
603
- // promote failure, not a wrong size. WS / test paths that omit
604
- // `observedSize` fall through to the explicit stat.
581
+ // round-trip saves one Vercel HEAD per PUT. The blob can't have been
582
+ // resized between that stat and here: staging ids are 16-byte random
583
+ // (no other request targets this blob) and the sole writer (this
584
+ // PUT's upload pipeline) has finished. The only other actor on a
585
+ // staging blob is the reaper, which UNLINKS (never resizes); a racing
586
+ // reaper unlink surfaces below as statStaging→io-error or a promote
587
+ // failure, not a wrong size. WS / test paths that omit `observedSize`
588
+ // fall through to the explicit stat.
605
589
  if (input.observedSize === undefined) {
606
590
  try { stagedSize = await handle.blob.statStaging(input.workspaceTag, input.stagingId) }
607
591
  catch { return { ok: false, reason: 'io-error' } }
@@ -637,15 +621,14 @@ export async function commitPut(handle: Handle, input: CommitPutInput): Promise<
637
621
  }
638
622
  // Promote to the CONTENT-ADDRESSED live path `${tag}/${hash}.bin`.
639
623
  // `promoteStagingToLive` returns false on any backend error (FS:
640
- // EACCES / ENOSPC / EIO / a racing abort that already unlinked
641
- // the staging file; Vercel: copy failure). 'io-error' is mapped
642
- // to HTTP 500 by the REST layer — it's a server-side fault, not
643
- // a client-fixable one. Because the destination path IS the content
644
- // hash, any write to it is byte-identical BY CONSTRUCTION, so a
645
- // retried or racing promote to the same path is an idempotent
646
- // rewrite, never a clobber. (Distinct PUTs get distinct hashes — a
647
- // fresh random nonce per encrypt makes each ciphertext unique — so
648
- // concurrent commits to the same resource write to DIFFERENT paths.)
624
+ // EACCES / ENOSPC / EIO / a racing abort that already unlinked the
625
+ // staging file; Vercel: copy failure) → 'io-error' → REST HTTP 500
626
+ // (server-side fault, not client-fixable). The destination path IS
627
+ // the content hash, so any write to it is byte-identical BY
628
+ // CONSTRUCTION: a retried or racing promote to the same path is an
629
+ // idempotent rewrite, never a clobber. (Distinct PUTs get distinct
630
+ // hashes — fresh random nonce per encrypt — so concurrent commits to
631
+ // the same resource write to DIFFERENT paths.)
649
632
  if (!await handle.blob.promoteStagingToLive(input.workspaceTag, input.stagingId, staging.content_hash)) {
650
633
  return { ok: false, reason: 'io-error' }
651
634
  }
@@ -658,13 +641,12 @@ export async function commitPut(handle: Handle, input: CommitPutInput): Promise<
658
641
  const committedIncarnation = staging.prev_version == null ? freshIncarnation : staging.prev_incarnation!
659
642
  const putAt = Date.now()
660
643
  // Version-CAS in try/catch so a Neon transient (5xx, network
661
- // hiccup, connection-pool exhaustion) doesn't bypass the abortPut
662
- // ladder by throwing out of commitPut. Without this guard, a thrown
663
- // rejection skips the REST layer's `if (!r.ok) abortPut` branch and
664
- // bubbles to handleRest's outer catch — the live blob is already
665
- // promoted, the staging blob + row stay, and the client sees a 500.
666
- // The reaper reconciles (stale-staging sweep + unreferenced-blob
667
- // GC) but the surface is a 500 the caller can retry.
644
+ // hiccup, pool exhaustion) doesn't throw out of commitPut and bypass
645
+ // the abortPut ladder. A thrown rejection would skip the REST layer's
646
+ // `if (!r.ok) abortPut` branch and bubble to handleRest's outer catch
647
+ // — live blob already promoted, staging blob + row left behind. The
648
+ // reaper reconciles (stale-staging sweep + unreferenced-blob GC); the
649
+ // surface is a 500 the caller can retry.
668
650
  let won: { ok: number } | undefined
669
651
  try {
670
652
  if (staging.prev_version == null) {
@@ -753,30 +735,28 @@ export async function abortPut(handle: Handle, tag: string, resourceTag: string,
753
735
  // `deletedVersion = 0` sentinel tells the broadcast path to skip.
754
736
  //
755
737
  // No lock: the drop is a version-CAS (`deleteLiveCAS` — DELETE WHERE
756
- // version = prev), so every race resolves to exactly one winner, the
757
- // same as the old lock did, with no lost update:
738
+ // version = prev), so every race resolves to exactly one winner with
739
+ // no lost update:
758
740
  // - delete vs. a concurrent COMMIT on the same resource: whichever
759
741
  // CAS lands first wins. A stale delete can NOT remove a row the
760
- // commit just bumped — the `WHERE version = prev` no longer matches,
761
- // so the delete gets `conflict` (re-read sees the bumped version).
762
- // If the delete wins, the commit's `updateLiveCAS` matches no row →
763
- // `conflict`. (The earlier draft used an UNCONDITIONAL delete here,
764
- // which — without the lock — let `getLive` read v1, a commit bump
765
- // to v2 slip in, and the delete then destroy v2: a lost update. The
766
- // version-CAS closes that.)
742
+ // commit just bumped — `WHERE version = prev` no longer matches, so
743
+ // the delete gets `conflict` (re-read sees the bumped version). If
744
+ // the delete wins, the commit's `updateLiveCAS` matches no row →
745
+ // `conflict`. (An UNCONDITIONAL delete here would let `getLive`
746
+ // read v1, a commit bump to v2 slip in, then the delete destroy v2:
747
+ // a lost update. The version-CAS closes that.)
767
748
  // - two concurrent deletes with the same prevVersion: one CAS removes
768
749
  // the row; the other matches no row → re-read → `not-found`.
769
750
  //
770
751
  // On success we ONLY drop the live row — we do NOT unlink the live
771
- // blob. Blob reclamation is deferred to the reaper's grace-window GC
752
+ // blob. Reclamation is deferred to the reaper's grace-window GC
772
753
  // (unlinks once no live row references the hash AND it's older than the
773
- // grace window) rather than done inline, so the drop stays lock-free
774
- // and can't race two things: a concurrent commit's promote→CAS window
775
- // (a just-promoted blob isn't referenced yet — the age grace protects
776
- // it) and an in-flight GET still streaming the bytes. NOT because the
777
- // hash might be shared — distinct PUTs get distinct hashes (random
778
- // nonce per encrypt → unique ciphertext), so the hash↔row mapping is
779
- // effectively 1:1; this delete simply orphans the blob for the GC.
754
+ // grace window) so the drop stays lock-free and can't race a concurrent
755
+ // commit's promote→CAS window (a just-promoted blob isn't referenced
756
+ // yet — the age grace protects it) or an in-flight GET still streaming
757
+ // the bytes. NOT because the hash might be shared — distinct PUTs get
758
+ // distinct hashes (random nonce → unique ciphertext), so the hash↔row
759
+ // mapping is effectively 1:1; this delete simply orphans the blob.
780
760
  export async function deleteObject(
781
761
  handle: Handle, tag: string, resourceTag: string, prevVersion: number | null, prevIncarnation: string | null,
782
762
  ): Promise<DeleteResult> {
@@ -112,16 +112,14 @@ function isValidPayload(v: unknown): v is TokenPayload {
112
112
  const o = v as Record<string, unknown>
113
113
  if (typeof o['tag'] !== 'string' || typeof o['res'] !== 'string') return false
114
114
  // `Number.isSafeInteger` over `Number.isInteger`: `exp` is compared
115
- // to `Date.now()` (which is itself a safe int), `len` is compared
116
- // against the Content-Length the REST layer parsed (which already
117
- // gates on `isSafeInteger`), and `ver` is compared against SQLite's
118
- // INTEGER (capacity 2^63). An unsafe-but-integer value in the token
119
- // round-trips through IEEE-754 and could spoof equality with a
120
- // different actual value, or — for `exp` — let an adversarial
121
- // token claim a `Number.MAX_SAFE_INTEGER + 1` expiry that compares
122
- // ambiguously near the IEEE-754 boundary. Matches the rest of the
123
- // codebase's safe-int gates (server/objstore/sign.ts,
124
- // server/objstore/rest.ts, server/objstore/handlers.ts).
115
+ // to `Date.now()` (a safe int), `len` against the Content-Length the
116
+ // REST layer parsed (itself `isSafeInteger`-gated), and `ver` against
117
+ // SQLite's INTEGER (capacity 2^63). An unsafe-but-integer value in the
118
+ // token round-trips through IEEE-754 and could spoof equality with a
119
+ // different actual value, or — for `exp` — claim a
120
+ // `Number.MAX_SAFE_INTEGER + 1` expiry that compares ambiguously near
121
+ // the IEEE-754 boundary. Matches the codebase's other safe-int gates
122
+ // (server-e2e/objstore/sign.ts, rest.ts, handlers.ts).
125
123
  if (!Number.isSafeInteger(o['exp']) || (o['exp'] as number) < 0) return false
126
124
  if (o['op'] === 'put') {
127
125
  return typeof o['sid'] === 'string'
@@ -138,8 +136,7 @@ function isValidPayload(v: unknown): v is TokenPayload {
138
136
  return false
139
137
  }
140
138
 
141
- // Convenience constructors so the WS handler doesn't have to assemble
142
- // the payload shape inline.
139
+ // Constructors so the WS handler doesn't assemble the payload inline.
143
140
  export function mintPutToken(
144
141
  secret: TokenSecret,
145
142
  tag: string, res: string, sid: string, len: number,
@@ -1,9 +1,8 @@
1
- // Per-connection server state. One `Peer` per accepted WebSocket,
2
- // replacing the former parallel per-socket WeakMaps (challenge /
3
- // authorized / alive / inflight / tags). The connection handler
4
- // constructs one on accept and holds it in a closure, so the hot
5
- // paths (message dispatch, pong, close) touch fields directly; the
6
- // few cross-function call sites look it up via `peers.get(socket)`.
1
+ // Per-connection server state. One `Peer` per accepted WebSocket. The
2
+ // connection handler constructs one on accept and holds it in a
3
+ // closure, so the hot paths (message dispatch, pong, close) touch
4
+ // fields directly; the few cross-function call sites look it up via
5
+ // `peers.get(socket)`.
7
6
  //
8
7
  // Held in a `WeakMap<WebSocket, Peer>` so a closed socket's state GCs
9
8
  // with the socket; the close handler also `delete`s it explicitly
@@ -17,9 +16,8 @@ export class Peer {
17
16
  // `workspace-subscribe` signature — blocks cross-connection replay
18
17
  // of a captured subscribe frame.
19
18
  readonly challenge: string
20
- // Password-gate authorization flag. Once the `authenticate`
21
- // handshake succeeds, every subsequent first-action on this socket
22
- // bypasses the new-workspace gate. `false` until then.
19
+ // Password-gate flag. Once the `authenticate` handshake succeeds,
20
+ // first-actions on this socket bypass the new-workspace gate.
23
21
  authorized = false
24
22
  // Heartbeat liveness. The sweep flips it `false` after each `ping()`;
25
23
  // the `pong` listener flips it back. A socket still `false` on the
@@ -1,5 +1,5 @@
1
1
  // Cross-instance pub/sub for real-time WS broadcasts. The triage-sync
2
- // fan-out is an in-memory subscriber map (server/hub.ts) by design — it
2
+ // fan-out is an in-memory subscriber map (server-e2e/hub.ts) by design — it
3
3
  // routes a commit only to peers on the SAME instance. A multi-instance
4
4
  // deployment behind a load balancer needs commit-landed-on-A to reach
5
5
  // peers-on-B with the same latency the hub gives same-instance peers.
@@ -57,15 +57,15 @@ function newSenderId(): string {
57
57
  // - 'objput': objstore-put broadcast. `res` is the resource tag; the
58
58
  // receiver SELECTs the live row by (tag, res) for the rest of the
59
59
  // metadata fields.
60
- // - 'objdel': objstore-deleted broadcast. `ver` is the deleted version
61
- // — inline because the row is gone from workspace_object after the
62
- // delete commit.
60
+ // - 'objdel': objstore-deleted broadcast. `ver` and `incarnation` identify
61
+ // the deleted row, which is gone from workspace_object after commit.
62
+ // Incarnation is optional only for messages from older relay instances.
63
63
  export type BusMessage =
64
64
  | { kind: 'rev'; tag: string; id: string }
65
65
  | { kind: 'objput'; tag: string; res: string }
66
- | { kind: 'objdel'; tag: string; res: string; ver: number }
66
+ | { kind: 'objdel'; tag: string; res: string; ver: number; incarnation?: string }
67
67
 
68
- // Receiver wired up by the hub layer (see server/index.ts). Each handler
68
+ // Receiver wired up by the hub layer (see server-e2e/index.ts). Each handler
69
69
  // runs once per remote message; failures are logged but don't crash the
70
70
  // LISTEN loop — a missed broadcast surfaces to clients on reconnect
71
71
  // (chain re-pull). Async because the workspace-revision handler does a
@@ -164,14 +164,13 @@ type NeonState = {
164
164
  // In-flight bus-message handler promises. `dispatchNotification`
165
165
  // fires handlers fire-and-forget, but `stop()` awaits this set before
166
166
  // returning so the lifecycle's `handle.close()` (which runs after
167
- // `pubsub.stop()` — see closeDb in server/index.ts) can't race a
167
+ // `pubsub.stop()` — see closeDb in server-e2e/index.ts) can't race a
168
168
  // handler mid-`handle.revisionById.get` / `getLive`.
169
169
  pendingHandlers: Set<Promise<void>>
170
170
  }
171
171
 
172
- // Notification dispatch — closes over `state.senderId` and
173
- // `state.handler`. Filters foreign channels (defensive) and our own
174
- // publish round-trip (Postgres NOTIFY delivers to publishers too).
172
+ // Notification dispatch. Filters foreign channels (defensive) and our
173
+ // own publish round-trip (Postgres NOTIFY delivers to publishers too).
175
174
  function dispatchNotification(state: NeonState, n: { channel: string; payload?: string }): void {
176
175
  if (n.channel !== CHANNEL) return
177
176
  if (typeof n.payload !== 'string') return
@@ -187,24 +186,19 @@ function dispatchNotification(state: NeonState, n: { channel: string; payload?:
187
186
  // notification dispatch (which would queue further notifications
188
187
  // behind it). Errors are logged but don't kill the loop — a missed
189
188
  // broadcast surfaces to clients on reconnect via the chain re-pull.
190
- //
191
- // The promise is also tracked in `state.pendingHandlers` so `stop()`
192
- // can drain in-flight handlers BEFORE the lifecycle teardown closes
193
- // the DB handle. Without the tracking, `onBusMessage`'s DB queries
194
- // (`handle.revisionById.get` / `getLive`) could race
195
- // `handle.close()` and throw inside a half-settled handler.
189
+ // Tracked in `state.pendingHandlers` so `stop()` drains in-flight
190
+ // handlers before the lifecycle closes the DB handle (see that
191
+ // field's doc).
196
192
  const promise: Promise<void> = fn(msg).catch((err) => {
197
193
  console.warn('pubsub: handler error:', errStack(err))
198
194
  }).finally(() => { state.pendingHandlers.delete(promise) })
199
195
  state.pendingHandlers.add(promise)
200
196
  }
201
197
 
202
- // Single connect attempt. Resolves once LISTEN is registered, or
203
- // rejects on transport / LISTEN failure. Assigns `state.client = c`
204
- // EAGERLY (before `c.connect()` is awaited) for the two invariants
205
- // documented on `NeonState.client`: the error handler's equality gate
206
- // must work mid-handshake, and `stop()` must be able to abort a hung
207
- // connect by reading `state.client?.end()`.
198
+ // Single connect attempt. Resolves once LISTEN is registered, rejects
199
+ // on transport / LISTEN failure. Assigns `state.client = c` EAGERLY
200
+ // (before awaiting `c.connect()`) for the invariants on
201
+ // `NeonState.client`.
208
202
  async function tryConnect(state: NeonState): Promise<void> {
209
203
  const c = state.newClient()
210
204
  state.client = c
@@ -221,13 +215,11 @@ async function tryConnect(state: NeonState): Promise<void> {
221
215
  })
222
216
  await c.query(`LISTEN ${CHANNEL}`)
223
217
  } catch (err) {
224
- // Clear `state.client` only if it still points at OUR client (a
218
+ // Clear `state.client` only if it still points at OUR client — a
225
219
  // racing `stop()` may have already null'd it and ended the
226
- // half-connected socket — don't clobber that). The `c.end()`
227
- // below may be a redundant second call in that race (stop already
228
- // ended it); pg-style Client.end() is idempotent so the second
229
- // call is a harmless no-op. The try/catch additionally absorbs
230
- // any rejection from end() itself.
220
+ // half-connected socket; don't clobber that. The `c.end()` below
221
+ // may then be a redundant second call, but pg-style Client.end()
222
+ // is idempotent (and the try/catch absorbs any rejection).
231
223
  if (state.client === c) state.client = null
232
224
  try { await c.end() } catch {}
233
225
  throw err
@@ -236,8 +228,7 @@ async function tryConnect(state: NeonState): Promise<void> {
236
228
 
237
229
  // Outer (re)connect loop. Exits silently on `state.stopped`; otherwise
238
230
  // retries with exponential backoff after a connect / LISTEN failure.
239
- // `tryConnect` assigns `state.client` itself (see the eager-assign
240
- // rationale on `NeonState.client`), so this loop only counts attempts
231
+ // `tryConnect` owns `state.client`, so this loop only counts attempts
241
232
  // and runs the backoff sleep.
242
233
  async function connectAndListen(state: NeonState): Promise<void> {
243
234
  // oxlint-disable-next-line no-unmodified-loop-condition
@@ -262,10 +253,9 @@ async function connectAndListen(state: NeonState): Promise<void> {
262
253
  // default) waiting on the timer. We stash the cancel callback on the
263
254
  // shared state object so `stop()` can fire it; the loop's
264
255
  // `if (state.stopped) return` then exits on the next turn. The
265
- // `settled` flag guards against a race between the timer firing and
266
- // `stop()` racing the same wake-up (Promise resolves are idempotent
267
- // at the runtime layer, but oxlint's `no-multiple-resolved` rule is
268
- // stricter and the guard documents the mutual exclusion explicitly).
256
+ // `settled` flag guards the timer-vs-`stop()` wake-up race: resolves
257
+ // are idempotent at runtime, but it also satisfies oxlint's stricter
258
+ // `no-multiple-resolved` and documents the mutual exclusion.
269
259
  function cancellableSleep(state: NeonState, ms: number): Promise<void> {
270
260
  return new Promise((resolve) => {
271
261
  let settled = false
@@ -343,7 +333,7 @@ export function createNeonPubSub(deps: NeonPubSubDeps): PubSub {
343
333
  if (state.connectAttempt) { try { await state.connectAttempt } catch {} }
344
334
  // Drain in-flight bus-message handlers BEFORE returning. The
345
335
  // lifecycle teardown runs `pubsub.stop()` and THEN
346
- // `handle.close()` (see closeDb in server/index.ts); a handler
336
+ // `handle.close()` (see closeDb in server-e2e/index.ts); a handler
347
337
  // still in `handle.revisionById.get` / `getLive` would otherwise
348
338
  // throw against a closed DB. `allSettled` so one handler's
349
339
  // rejection doesn't abort the drain.
@@ -398,7 +388,10 @@ function parseBusMessage(raw: Record<string, unknown>): BusMessage | null {
398
388
  const ver = raw['ver']
399
389
  if (typeof res !== 'string') return null
400
390
  if (typeof ver !== 'number' || !Number.isSafeInteger(ver)) return null
401
- return { kind: 'objdel', tag, res, ver }
391
+ const incarnation = raw['incarnation']
392
+ if (incarnation === undefined) return { kind: 'objdel', tag, res, ver }
393
+ if (typeof incarnation !== 'string' || incarnation.length === 0) return null
394
+ return { kind: 'objdel', tag, res, ver, incarnation }
402
395
  }
403
396
  return null
404
397
  }