@preventive/triage 1.0.0-alpha.1 → 1.0.0-alpha.10

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 (56) hide show
  1. package/api/reap.ts +79 -0
  2. package/common/save-error-reason.ts +20 -7
  3. package/common/server-info.ts +30 -0
  4. package/out/brotli-fallback.js +1 -1
  5. package/out/client-admin.js +28 -0
  6. package/out/client-managed.js +1 -0
  7. package/out/client-sync.js +16 -13
  8. package/out/graph.js +5 -4
  9. package/out/index.html +4 -15
  10. package/out/prism.js +2 -2
  11. package/out/terminal.js +32 -28
  12. package/out/view.css +1 -1
  13. package/out/view.js +78 -52
  14. package/package.json +70 -54
  15. package/{server → server-common}/origin.ts +5 -5
  16. package/{server → server-e2e}/auth.ts +5 -1
  17. package/{server → server-e2e}/bus-receiver.ts +8 -8
  18. package/server-e2e/cli.js +22 -0
  19. package/{server → server-e2e}/config.ts +21 -8
  20. package/{server → server-e2e}/db-neon.ts +31 -25
  21. package/{server → server-e2e}/db-revision-sql.ts +7 -10
  22. package/{server → server-e2e}/db-stmt.ts +2 -2
  23. package/{server → server-e2e}/db.ts +96 -135
  24. package/{server → server-e2e}/http.ts +97 -9
  25. package/{server → server-e2e}/hub.ts +7 -8
  26. package/{server → server-e2e}/index.ts +67 -43
  27. package/{server → server-e2e}/lifecycle.ts +14 -5
  28. package/{server → server-e2e}/npm-proxy.ts +1 -1
  29. package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
  30. package/{server → server-e2e}/objstore/blob-vercel.ts +69 -36
  31. package/{server → server-e2e}/objstore/blob.ts +24 -9
  32. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  33. package/{server → server-e2e}/objstore/handlers.ts +13 -15
  34. package/{server → server-e2e}/objstore/init.ts +47 -13
  35. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  36. package/server-e2e/objstore/rest-deny.ts +28 -0
  37. package/server-e2e/objstore/rest-mint.ts +224 -0
  38. package/{server → server-e2e}/objstore/rest.ts +110 -93
  39. package/{server → server-e2e}/objstore/sign.ts +105 -0
  40. package/{server → server-e2e}/objstore/store-neon.ts +19 -19
  41. package/{server → server-e2e}/objstore/store.ts +98 -118
  42. package/{server → server-e2e}/objstore/tokens.ts +9 -12
  43. package/{server → server-e2e}/peer.ts +7 -9
  44. package/{server → server-e2e}/pubsub.ts +21 -31
  45. package/{server → server-e2e}/sign.ts +12 -14
  46. package/{server → server-e2e}/sse-server.ts +105 -73
  47. package/{server → server-e2e}/sse-session.ts +30 -16
  48. package/{server → server-e2e}/static.ts +22 -17
  49. package/{server → server-e2e}/sync-handlers.ts +172 -117
  50. package/{server → server-e2e}/util.ts +9 -0
  51. package/{server → server-e2e}/ws-server.ts +29 -23
  52. package/strip-types-loader.js +94 -0
  53. /package/{server → server-e2e}/config.example.json +0 -0
  54. /package/{server → server-e2e}/neon-driver.ts +0 -0
  55. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  56. /package/{server → server-e2e}/validation.ts +0 -0
@@ -10,49 +10,30 @@
10
10
  // `base` points at the previous revision's `id` (or null for the
11
11
  // first revision in a workspace).
12
12
  //
13
- // `keyframe` is `1` for a revision the client emits with the full
14
- // state baked in (rather than just a delta). The wire-level flag
15
- // is also covered by the signature, so the column value MUST match
16
- // what the signed canonical bytes claim — `canonicalSave` in
17
- // `server/sign.ts` (called from `handleSave` in `server/index.ts`)
18
- // encodes `keyframe ? '1' : ''` into the bytes that `verifyEd25519`
19
- // then checks against the wire-supplied signature, so a wire flag
20
- // that doesn't match what the signer hashed fails verify and never
21
- // reaches this column. Client-driven: the server only stores what
22
- // the client sent and treats keyframes as catch-up roots when a
23
- // from=null subscriber arrives.
13
+ // `keyframe` is `1` for a revision the client emits with full state
14
+ // baked in (rather than a delta). The wire flag is covered by the
15
+ // signature, so the column value MUST match the signed canonical
16
+ // bytes: `canonicalSave` (server-e2e/sign.ts, via `handleSave`) encodes
17
+ // `keyframe ? '1' : ''` into the bytes `verifyEd25519` checks, so a
18
+ // mismatched wire flag fails verify and never reaches this column.
19
+ // Client-driven: the server stores what the client sent and treats
20
+ // keyframes as catch-up roots when a from=null subscriber arrives.
24
21
  //
25
22
  // `node:sqlite` is the built-in driver (Node ≥ 22 experimental,
26
- // stable in 24+). The driver is synchronous under the hood; the
27
- // Handle wraps each prepared statement so call sites `await`
28
- // uniformly. This is async-ready surface for a future async DB
29
- // backend — every operation today resolves in the current microtask
30
- // off a sync `node:sqlite` call.
23
+ // stable in 24+), synchronous under the hood; the Handle wraps each
24
+ // prepared statement so call sites `await` uniformly — async-ready
25
+ // surface for a future async DB backend (every op resolves in the
26
+ // current microtask off a sync call).
31
27
  //
32
- // Because operations are now async, two handlers can interleave
33
- // across an `await`. `commitRevision` (below) does NOT take an
34
- // in-process lock — it folds the dup recheck, base-equality check,
35
- // MAX(seq) and INSERT into ONE gated INSERT statement
36
- // (`commitRevisionSqlite` below):
37
- // INSERT … SELECT COALESCE(MAX(seq),0)+1 … WHERE NOT EXISTS(dup)
38
- // AND head IS base RETURNING seq
39
- // `node:sqlite` is synchronous, so that single statement runs to
40
- // completion without yielding the event loop — no concurrent commit
41
- // can interleave mid-statement, and the head-check + MAX(seq) read
42
- // from ONE consistent snapshot. That single-snapshot property is
43
- // what makes a per-tag lock redundant: the lock formerly existed
44
- // only to stop a chain fork where a racer read `head` from one
45
- // snapshot but `MAX(seq)` from a LATER one (after a sibling
46
- // committed) and inserted (seq=N+2, base=X) alongside the winner's
47
- // (seq=N+1, base=X) — same base, different seq, no PK conflict. With
48
- // both reads inside one statement that interleaving is impossible:
49
- // a racer's snapshot is either before the winner's commit (→ same
50
- // seq=N+1 → the UNIQUE(workspace_tag, seq) PK rejects the second →
51
- // recovery → stale-base) or after it (→ head ≠ base → no insert →
52
- // stale-base). Exactly one commits; the loser gets stale-base.
53
- // SQLite also serialises writers internally, and the PK backstops
54
- // the unsupported multi-connection case. See `commitRevisionSqlite`
55
- // for the full fork-safety argument.
28
+ // Operations being async, two handlers can interleave across an
29
+ // `await`. `commitRevision` (below) takes NO in-process lock — it
30
+ // folds the dup recheck, base-equality check, MAX(seq) and INSERT
31
+ // into ONE gated INSERT (`commitRevisionSqlite`). `node:sqlite` runs
32
+ // that statement to completion without yielding, so its head-check +
33
+ // MAX(seq) read ONE snapshot, which is what makes a per-tag lock
34
+ // redundant. SQLite also serialises writers internally, and the PK
35
+ // backstops the unsupported multi-connection case. See
36
+ // `commitRevisionSqlite` for the full fork-safety argument.
56
37
 
57
38
  import { DatabaseSync } from 'node:sqlite'
58
39
  import { mkdirSync } from 'node:fs'
@@ -64,24 +45,20 @@ import {
64
45
  mapRevisionRow, toSqlitePlaceholders,
65
46
  } from './db-revision-sql.ts'
66
47
 
67
- // `CHECK (keyframe IN (0, 1))` is the value-domain guard on the
68
- // keyframe column. STRICT (the table marker) enforces the column's
69
- // TYPE — an INTEGER stays an INTEGER — but NOT its value range:
70
- // `keyframe = 2` is a perfectly valid integer that STRICT accepts,
71
- // which `mapRevisionRow`'s `=== 1` check then silently coerces back to
72
- // 0. That divergence between the stored row and the signed canonical
73
- // (which only ever encodes 0 / 1) poisons chain-replay verifies for
74
- // any peer who recomputes — the same operator-with-direct-DB-write
75
- // attack vector the STRICT guard in `openDbInner` catches for the
76
- // column TYPE. The CHECK closes the value-domain half, giving SQLite
77
- // the protection the Neon schema's identical `CHECK (keyframe IN
78
- // (0, 1))` carries (see `db-neon.ts`).
48
+ // `CHECK (keyframe IN (0, 1))` is the value-domain guard. STRICT
49
+ // (the table marker) enforces the column TYPE (an INTEGER stays an
50
+ // INTEGER) but NOT its value range: `keyframe = 2` is a valid integer
51
+ // STRICT accepts, which `mapRevisionRow`'s `=== 1` check then coerces
52
+ // back to 0. That divergence from the signed canonical (only ever
53
+ // 0 / 1) poisons chain-replay verifies for any recomputing peer — the
54
+ // same operator-with-direct-DB-write vector the STRICT guard in
55
+ // `openDbInner` catches for TYPE. The CHECK closes the value-domain
56
+ // half, matching the Neon schema's identical CHECK (see `db-neon.ts`).
79
57
  //
80
- // `WORKSPACE_REVISION_DEF` is the parenthesised column + constraint
81
- // body (plus the STRICT marker), shared by the initial `CREATE TABLE`
82
- // and the `migrateAddKeyframeCheck` rebuild below — so a table the
83
- // rebuild produces is byte-identical in shape to a freshly-created
84
- // one, and a future column edit can't drift the two apart.
58
+ // Parenthesised column + constraint body (plus STRICT marker), shared
59
+ // by the initial `CREATE TABLE` and the `migrateAddKeyframeCheck`
60
+ // rebuild below — so a rebuilt table is byte-identical in shape to a
61
+ // fresh one and a future column edit can't drift the two apart.
85
62
  const WORKSPACE_REVISION_DEF = `(
86
63
  workspace_tag TEXT NOT NULL,
87
64
  seq INTEGER NOT NULL,
@@ -105,10 +82,10 @@ const SCHEMA = `
105
82
  ${WORKSPACE_REVISION_TAG_ID_INDEX};
106
83
  `
107
84
 
108
- // Row shape returned by the chain queries. SQLite stores `keyframe`
109
- // as INTEGER (0 / 1); `chainForWire` in server/index.ts normalises
110
- // to a strict boolean before broadcasting, but the raw row carries
111
- // the integer. `base` is nullable on the very first revision.
85
+ // Row shape from the chain queries. `keyframe` is stored as INTEGER
86
+ // (0 / 1); the raw row carries the integer — `chainForWire` in
87
+ // server-e2e/index.ts normalises to a strict boolean before broadcasting.
88
+ // `base` is nullable on the very first revision.
112
89
  export type RevisionRow = {
113
90
  base: string | null
114
91
  id: string
@@ -119,9 +96,8 @@ export type RevisionRow = {
119
96
  }
120
97
 
121
98
  // Input to `commitRevision`. `keyframe` is a strict boolean here —
122
- // the canonical-payload contract uses `=== true`, and the storage
123
- // path coerces to 0 / 1 via `keyframe ? 1 : 0` before hitting the
124
- // STRICT INTEGER column.
99
+ // the canonical-payload contract uses `=== true`; the storage path
100
+ // coerces to 0 / 1 before hitting the STRICT INTEGER column.
125
101
  export type RevisionInsert = {
126
102
  tag: string
127
103
  id: string
@@ -142,37 +118,31 @@ export type CommitResult =
142
118
  | { kind: 'duplicate' }
143
119
  | { kind: 'stale-base'; head: string | null }
144
120
 
145
- // Bag of pre-prepared statements + the underlying connection.
146
- // Held for the process lifetime; `close()` runs from `shutdown()`.
121
+ // Pre-prepared statements + the underlying connection, held for the
122
+ // process lifetime; `close()` runs from `shutdown()`.
147
123
  //
148
- // `db` is the raw `DatabaseSync` and is SQLite-only. The Neon
149
- // backend (`./db-neon.ts`) constructs a Handle with `db` unset.
150
- // Callers that reach into `db` directly (e.g. `openObjstore`,
151
- // test-only fixture SQL) are SQLite-coupled by construction —
152
- // passing them a Neon-backed Handle is the operator's mistake to
153
- // catch at the `if (DATABASE_URL)` switch in `server/index.ts`.
124
+ // `db` is the raw `DatabaseSync`, SQLite-only — the Neon backend
125
+ // (`./db-neon.ts`) constructs a Handle with `db` unset. Callers that
126
+ // reach into `db` directly (e.g. `openObjstore`, test-only fixture
127
+ // SQL) are SQLite-coupled by construction; passing them a Neon-backed
128
+ // Handle is the operator's mistake to catch at the `if (DATABASE_URL)`
129
+ // switch in `server-e2e/index.ts`.
154
130
  //
155
- // `tryCommit` is the backend-specific atomic-commit primitive that
131
+ // `tryCommit` is the backend-specific atomic-commit primitive
156
132
  // `commitRevision` dispatches through. SQLite runs one synchronous
157
- // gated INSERT (no in-process lock — `node:sqlite` doesn't yield
158
- // mid-statement, so the head-check + MAX(seq) read one snapshot;
159
- // see `commitRevisionSqlite`). Neon wraps the dup-check + head-check
160
- // + gated INSERT in a pipelined transaction; it relies on Postgres'
161
- // READ-COMMITTED single-statement snapshot (the gated INSERT's
162
- // head-check and MAX(seq) read one snapshot) plus the
163
- // `UNIQUE(workspace_tag, seq)` PK to keep cross-replica racers from
164
- // forking the chain — see `db-neon.ts`'s `tryCommitNeon`.
133
+ // gated INSERT (see `commitRevisionSqlite`); Neon wraps it in a
134
+ // pipelined transaction (see `db-neon.ts`'s `tryCommitNeon`). Both
135
+ // rely on a single-statement snapshot + the `UNIQUE(workspace_tag,
136
+ // seq)` PK for fork-safety; see those functions for the argument.
165
137
  //
166
138
  // `gatedInsert` is SQLite-only (like `db`): it backs
167
- // `commitRevisionSqlite`'s single gated INSERT (the dup-gate +
168
- // head-equals-base-gate + server-assigned seq folded into one
169
- // statement, mirroring the Neon path's gated INSERT). The Neon
170
- // backend leaves it unset — its gated INSERT lives inside the
171
- // pipelined `sql.transaction([...])`, not a standalone statement
172
- // object. Kept on the Handle (rather than a module-private closure)
173
- // so the SQLite white-box tests can wrap `.get` to inject a
174
- // unique-violation / non-unique failure into the commit, the same
175
- // recovery paths the Neon suite stages via `failNextCommit`.
139
+ // `commitRevisionSqlite`'s single gated INSERT. The Neon backend
140
+ // leaves it unset — its gated INSERT lives inside the pipelined
141
+ // `sql.transaction([...])`, not a standalone statement object. Kept
142
+ // on the Handle (not a module-private closure) so SQLite white-box
143
+ // tests can wrap `.get` to inject a unique-violation / non-unique
144
+ // failure into the commit, exercising the same recovery paths the
145
+ // Neon suite stages via `failNextCommit`.
176
146
  export type Handle = {
177
147
  db?: DatabaseSync
178
148
  headFor: GetStmt<[string], { id: string }>
@@ -184,7 +154,7 @@ export type Handle = {
184
154
  revisionExists: GetStmt<[string, string], unknown>
185
155
  // Single-revision fetch by content-addressed id. The cross-instance
186
156
  // pubsub receiver uses this to assemble a `workspace-state` from a
187
- // NOTIFY hint (see `server/pubsub.ts`).
157
+ // NOTIFY hint (see `server-e2e/pubsub.ts`).
188
158
  revisionById: GetStmt<[string, string], RevisionRow>
189
159
  gatedInsert?: GetStmt<[string, string, string | null, number, string, string, string, number], { seq: number }>
190
160
  tryCommit: (input: RevisionInsert) => Promise<CommitResult>
@@ -192,23 +162,22 @@ export type Handle = {
192
162
  }
193
163
 
194
164
  // Narrowing alias for the SQLite-backed Handle: `db` is guaranteed
195
- // to be set. `openDb` returns this so call sites that need direct
165
+ // set. `openDb` returns this so call sites needing direct
196
166
  // `DatabaseSync` access (e.g. `openObjstore(handle.db, …)` in
197
- // `server/index.ts`'s SQLite branch) can reach `handle.db` without
198
- // an optional-chain or non-null assertion. A Neon-backed Handle
199
- // (`openNeonDb`) keeps the wider `db?: DatabaseSync` shape; routing
200
- // a Neon Handle into a SQLite-coupled call site is a compile-time
201
- // error. Mirrors the same pattern in `server/objstore/store.ts`.
167
+ // `server-e2e/index.ts`'s SQLite branch) reach `handle.db` without an
168
+ // optional-chain or non-null assertion. A Neon-backed Handle
169
+ // (`openNeonDb`) keeps the wider `db?: DatabaseSync` shape, so routing
170
+ // one into a SQLite-coupled call site is a compile-time error. Mirrors
171
+ // `server-e2e/objstore/store.ts`.
202
172
  export type SqliteHandle = Handle & { db: DatabaseSync }
203
173
 
204
174
  export function openDb(path: string): SqliteHandle {
205
175
  mkdirSync(dirname(path), { recursive: true })
206
176
  const db = new DatabaseSync(path)
207
- // Any throw between the DatabaseSync constructor and the return
208
- // would otherwise leak the underlying file / WAL / shm locks until
209
- // process exit — close before re-raising so the operator can fix
210
- // the underlying issue (failed STRICT check, ALTER TABLE error,
211
- // …) and re-run without a stale lock pinning the file.
177
+ // A throw between the DatabaseSync constructor and the return would
178
+ // leak the file / WAL / shm locks until process exit — close before
179
+ // re-raising so the operator can fix the cause (failed STRICT check,
180
+ // ALTER TABLE error, …) and re-run without a stale lock on the file.
212
181
  try {
213
182
  return openDbInner(db)
214
183
  } catch (err) {
@@ -218,35 +187,30 @@ export function openDb(path: string): SqliteHandle {
218
187
  }
219
188
 
220
189
  function openDbInner(db: DatabaseSync): SqliteHandle {
221
- // WAL gives concurrent readers + faster writes and survives
222
- // crashes between commits without corrupting the file. Foreign
223
- // keys aren't strictly needed here (single-table schema) but
224
- // turning them on preserves the option to add referential
225
- // tables later without revisiting init.
190
+ // WAL gives concurrent readers + faster writes and survives crashes
191
+ // between commits without corrupting the file. Foreign keys aren't
192
+ // needed here (single-table schema) but turning them on keeps the
193
+ // option to add referential tables later without revisiting init.
226
194
  db.exec('PRAGMA journal_mode = WAL;')
227
195
  // FULL (not NORMAL): the server emits `workspace-save-ack` BEFORE
228
- // returning to the event loop after `commitRevision`. With NORMAL,
229
- // SQLite only fsyncs at WAL checkpoint, so a power loss between
230
- // ack and the next checkpoint loses the row even though the
231
- // originator and broadcast peers were told the revision committed.
232
- // FULL fsyncs per commit; durability matches the contract the
233
- // ack implies. Trade-off is per-commit fsync latency, acceptable
234
- // for the protocol's edit-driven write pattern (triage edits, not
235
- // streaming throughput). Audit round-9 M1.
196
+ // returning to the event loop after `commitRevision`. NORMAL only
197
+ // fsyncs at WAL checkpoint, so a power loss between ack and the next
198
+ // checkpoint loses a row the originator + peers were told committed.
199
+ // FULL fsyncs per commit, matching the durability the ack implies.
200
+ // Trade-off is per-commit fsync latency, acceptable for the edit-
201
+ // driven write pattern (triage edits, not streaming). Audit round-9 M1.
236
202
  db.exec('PRAGMA synchronous = FULL;')
237
203
  db.exec('PRAGMA foreign_keys = ON;')
238
204
  db.exec(SCHEMA)
239
205
  // Fail-loud on a pre-existing non-STRICT table — `CREATE TABLE IF
240
- // NOT EXISTS … STRICT` is a no-op when the table already exists,
241
- // so a deployment that predates the STRICT marker would silently
242
- // keep its non-STRICT shape. Without STRICT, an operator with
243
- // direct DB write access could insert mis-typed rows (e.g. a
244
- // `keyframe = "1\nfoo"` text value in the INTEGER column) and
245
- // poison the chain — the signed canonical the client originally
246
- // hashed says `keyframe = 1`, but the stored `keyframe = "1\nfoo"`
247
- // round-trips back into the canonical as a different string,
248
- // making every subsequent verify fail. Operator must migrate
249
- // before this server boots.
206
+ // NOT EXISTS … STRICT` is a no-op when the table exists, so a
207
+ // deployment predating the STRICT marker keeps its non-STRICT shape.
208
+ // Without STRICT, an operator with direct DB write access could
209
+ // insert mis-typed rows (e.g. `keyframe = "1\nfoo"` text in the
210
+ // INTEGER column) and poison the chain: the signed canonical says
211
+ // `keyframe = 1`, but the stored text round-trips into the canonical
212
+ // as a different string, failing every subsequent verify. Operator
213
+ // must migrate before this server boots.
250
214
  const meta = db.prepare(
251
215
  `SELECT strict FROM pragma_table_list WHERE schema = 'main' AND name = 'workspace_revision'`,
252
216
  ).get() as { strict: number } | undefined
@@ -254,13 +218,11 @@ function openDbInner(db: DatabaseSync): SqliteHandle {
254
218
  throw new Error('workspace_revision is non-STRICT — migrate via rename+create+copy before booting')
255
219
  }
256
220
  // Idempotent migration for DBs created before the keyframe column
257
- // existed. Inspect the column list rather than catching every
258
- // ALTER error — the previous shape swallowed `try { ALTER } catch
259
- // {}` for ANY failure (lock contention, disk full, corrupt page),
260
- // masking real problems as "column already exists". Now we only
261
- // ALTER when the column is genuinely missing, and any failure of
262
- // the ALTER itself bubbles up as an open-time crash where the
263
- // operator can act on it.
221
+ // existed. Inspect the column list rather than `try { ALTER } catch
222
+ // {}`: a blanket catch swallows ANY failure (lock contention, disk
223
+ // full, corrupt page) as "column already exists". ALTER only when
224
+ // the column is genuinely missing, so an ALTER failure bubbles up as
225
+ // an open-time crash the operator can act on.
264
226
  const columns = db.prepare(`PRAGMA table_info(workspace_revision)`).all() as Array<{ name: string }>
265
227
  if (!columns.some((c) => c.name === 'keyframe')) {
266
228
  // ADD COLUMN carries the CHECK so a legacy DB migrating up lands
@@ -507,9 +469,8 @@ export function commitRevision(handle: Handle, input: RevisionInsert): Promise<C
507
469
  // base gate fails → `stale-base`.
508
470
  // • Two retransmits with the same id: the second's dup gate fails →
509
471
  // `duplicate`.
510
- // These are PROVEN green, unchanged, by the no-fork concurrency tests
511
- // in `tests/server-db.test.js` (two/N concurrent same-base, mixed,
512
- // chainFrom-during-commits) which now pass with no lock present.
472
+ // Covered by the no-fork concurrency tests in `tests/server-db.test.js`
473
+ // (two/N concurrent same-base, mixed, chainFrom-during-commits).
513
474
  //
514
475
  // SQLite serialises writers internally even ACROSS connections, but a
515
476
  // multi-connection deployment is unsupported regardless. The
@@ -10,6 +10,7 @@ import { type IncomingMessage as HttpRequest, type Server, type ServerResponse,
10
10
  import { Buffer } from 'node:buffer'
11
11
  import { fileURLToPath } from 'node:url'
12
12
  import type { WebSocketServer } from 'ws'
13
+ import { CONFIG_PATH, type ServerInfo } from '../common/server-info.ts'
13
14
  import { type ObjstoreRestDeps, handleRest, matchRoute } from './objstore/rest.ts'
14
15
  import { SSE_OPEN_PATH, type SseServer } from './sse-server.ts'
15
16
  import { dispatchNpmAdvisories } from './npm-proxy.ts'
@@ -20,6 +21,12 @@ import { errStack } from './util.ts'
20
21
  // similar) can route `/api/*` → this process and `/*` → the static UI
21
22
  // bundle with a single location block.
22
23
  export const WS_UPGRADE_PATH = '/api/sync'
24
+ // Session-independent triage-sync save plane: `POST /api/sync/save`. The
25
+ // SSE-mode alternative to the in-band `workspace-save` frame — a save POSTed
26
+ // here commits + broadcasts WITHOUT taking over the client's SSE event-stream
27
+ // (each in-band POST forces a stream takeover; see sse-server.ts). Sibling of
28
+ // the WS upgrade path so one nginx `/api/*` block routes both.
29
+ export const SAVE_REST_PATH = '/api/sync/save'
23
30
  function isUpgradePath(url: string | undefined): boolean {
24
31
  if (typeof url !== 'string') return false
25
32
  // Strip `?…` so clients can carry build / debug tags. Exact match
@@ -34,6 +41,10 @@ type HasHeaders = { headers: HttpRequest['headers'] }
34
41
  export type HttpServerDeps = {
35
42
  wss: WebSocketServer
36
43
  restDeps: ObjstoreRestDeps
44
+ // This server's `server-info` (mode advertisement), served as JSON at
45
+ // GET /api/config so a client can detect the protocol without a sync
46
+ // connection (the WS connect frame stays the source of truth).
47
+ serverInfo: ServerInfo
37
48
  // SSE+POST fallback. Owns its own session map + lifecycle; we just
38
49
  // give it first crack at requests that match `SSE_OPEN_PATH`. Same
39
50
  // same-origin / shutdown gates run BEFORE the dispatch so the SSE
@@ -42,12 +53,68 @@ export type HttpServerDeps = {
42
53
  isOriginAllowed: (req: HasHeaders) => boolean
43
54
  isShuttingDown: () => boolean
44
55
  track: (promise: Promise<unknown>) => void
56
+ // Handler for `POST /api/sync/save` (see SAVE_REST_PATH). Owns body parse +
57
+ // the save pipeline + JSON response; this module owns the gates (method,
58
+ // same-origin, shutdown, idle-timeout) and the graceful-drain tracking.
59
+ handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
45
60
  restPutIdleTimeoutMs: number
46
61
  debug: boolean
47
62
  }
48
63
 
64
+ // `POST /api/sync/save` dispatch. Returns the in-flight handler promise when
65
+ // the request matched the route (the caller `track`s it for graceful drain),
66
+ // or null when it's for a different route. The gate ladder — method →
67
+ // shutdown → same-origin → idle-timeout — mirrors the objstore REST branch; a
68
+ // gate rejection writes its own response and returns an already-settled
69
+ // promise. Kept out of `createHttpServer` so that dispatcher stays compact.
70
+ function dispatchSaveRest(
71
+ req: HttpRequest, res: ServerResponse,
72
+ deps: {
73
+ handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
74
+ isOriginAllowed: (req: HasHeaders) => boolean
75
+ isShuttingDown: () => boolean
76
+ restPutIdleTimeoutMs: number
77
+ debug: boolean
78
+ },
79
+ ): Promise<void> | null {
80
+ if (typeof req.url !== 'string' || req.url.split('?', 1)[0] !== SAVE_REST_PATH) return null
81
+ if (req.method !== 'POST') {
82
+ res.writeHead(405, { 'content-type': 'application/json', 'allow': 'POST', 'connection': 'close' })
83
+ res.end(JSON.stringify({ error: 'method-not-allowed' }))
84
+ return Promise.resolve()
85
+ }
86
+ // Shutdown gate — parity with the objstore REST branch (a POST on an
87
+ // existing keep-alive socket after SIGTERM but before close() drains).
88
+ if (deps.isShuttingDown()) {
89
+ res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
90
+ res.end(JSON.stringify({ error: 'shutting-down' }))
91
+ return Promise.resolve()
92
+ }
93
+ // Same-origin gate — a hostile cross-origin page would carry an Origin
94
+ // header (browser-set on fetch); same-origin XHR may omit it (allowed).
95
+ if (!deps.isOriginAllowed(req)) {
96
+ res.writeHead(403, { 'content-type': 'application/json' })
97
+ res.end(JSON.stringify({ error: 'origin-denied' }))
98
+ return Promise.resolve()
99
+ }
100
+ // Idle-body timeout — a slow-loris trickling the JSON body would otherwise
101
+ // hold the connection indefinitely.
102
+ req.setTimeout(deps.restPutIdleTimeoutMs, () => {
103
+ if (deps.debug) console.warn(`sync-save REST idle ${deps.restPutIdleTimeoutMs}ms → abort`)
104
+ try { req.destroy(new Error('idle-timeout')) } catch {}
105
+ })
106
+ // Outer catch is the unhandled-rejection guard for a throw OUTSIDE the
107
+ // handler's own try/catch — logs and terminates the response so the TCP
108
+ // socket doesn't dangle (same policy as the objstore handleRest wrapper).
109
+ return deps.handleSaveRest(req, res).catch((err) => {
110
+ console.warn('sync-save REST handler error:', errStack(err))
111
+ if (res.headersSent) { try { res.destroy() } catch {} }
112
+ else { try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal' })) } catch {} }
113
+ })
114
+ }
115
+
49
116
  export function createHttpServer(deps: HttpServerDeps): Server {
50
- const { wss, restDeps, sseServer, isOriginAllowed, isShuttingDown, track, restPutIdleTimeoutMs, debug } = deps
117
+ const { wss, restDeps, sseServer, serverInfo, isOriginAllowed, isShuttingDown, track, handleSaveRest, restPutIdleTimeoutMs, debug } = deps
51
118
  // Static-file plane (see ./static.ts). The directory is the
52
119
  // `build.js build` output sibling to this file; the loader handles
53
120
  // enumeration, pre-compression, and ETag derivation. Plugged in after
@@ -55,6 +122,19 @@ export function createHttpServer(deps: HttpServerDeps): Server {
55
122
  const handleStatic = loadStatic(fileURLToPath(new URL('../out', import.meta.url)))
56
123
 
57
124
  const httpServer = createServer((req: HttpRequest, res: ServerResponse) => {
125
+ // Static mode probe: GET /api/config → this server's `server-info` as JSON.
126
+ // A client uses it to detect the protocol up front; the WS connect frame
127
+ // stays the source of truth and catches a later mode change. Public, no body.
128
+ if (typeof req.url === 'string' && req.url.split('?', 1)[0] === CONFIG_PATH) {
129
+ if (req.method !== 'GET') {
130
+ res.writeHead(405, { 'content-type': 'application/json', allow: 'GET' })
131
+ res.end(JSON.stringify({ error: 'method-not-allowed' }))
132
+ return
133
+ }
134
+ res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' })
135
+ res.end(JSON.stringify(serverInfo))
136
+ return
137
+ }
58
138
  // SSE+POST fallback plane. Same-origin gate first (Origin header
59
139
  // is set by the browser on cross-origin EventSource + fetch, so
60
140
  // a hostile origin would surface here just like it does on the
@@ -76,6 +156,12 @@ export function createHttpServer(deps: HttpServerDeps): Server {
76
156
  // the lifecycle's sseSessions() close loop.
77
157
  if (sseServer.handle(req, res)) return
78
158
  }
159
+ // Triage-sync save REST plane (see SAVE_REST_PATH) — session-independent
160
+ // `POST /api/sync/save` so an SSE-mode save doesn't take over the
161
+ // event-stream. The dispatch helper owns the gate ladder; we `track` the
162
+ // returned in-flight promise so SIGTERM drains it (mirrors npm-advisories).
163
+ const saveP = dispatchSaveRest(req, res, { handleSaveRest, isOriginAllowed, isShuttingDown, restPutIdleTimeoutMs, debug })
164
+ if (saveP) { track(saveP); return }
79
165
  // npm advisories proxy — same-origin + shutdown gates live in the
80
166
  // helper so this dispatcher stays compact. `dispatchNpmAdvisories`
81
167
  // returns the in-flight promise (or null when the route didn't
@@ -103,20 +189,22 @@ export function createHttpServer(deps: HttpServerDeps): Server {
103
189
  // that holds a valid token (e.g. via XSS that read a freshly-minted
104
190
  // one) would PUT with its own Origin header — caught here.
105
191
  // Same-origin XHR/fetch may omit Origin; that path is allowed (see
106
- // `isOriginAllowed`). Transport audit `server/objstore/rest.ts:103`.
192
+ // `isOriginAllowed`). Transport audit `server-e2e/objstore/rest.ts:103`.
107
193
  if (!isOriginAllowed(req)) {
108
194
  res.writeHead(403, { 'content-type': 'application/json' })
109
195
  res.end(JSON.stringify({ error: 'origin-denied' }))
110
196
  return
111
197
  }
112
- // PUT idle-body timeout — a slow-loris client trickling bytes
113
- // within the declared Content-Length holds the staging fd + an
114
- // inFlightSids slot indefinitely. `req.setTimeout` fires on
115
- // inactivity; we destroy the request, aborting the body pipeline.
116
- // Transport audit `server/objstore/rest.ts:218`.
117
- if (req.method === 'PUT') {
198
+ // Idle-body timeout for the body-bearing REST methods — a slow-loris
199
+ // client trickling bytes holds the connection (and, for PUT, the
200
+ // staging fd + inFlightSids slot) indefinitely. PUT carries the raw
201
+ // blob within its declared Content-Length; POST carries the small
202
+ // fetch-mint JSON body. `req.setTimeout` fires on inactivity; we
203
+ // destroy the request, aborting the body pipeline.
204
+ // Transport audit `server-e2e/objstore/rest.ts:218`.
205
+ if (req.method === 'PUT' || req.method === 'POST') {
118
206
  req.setTimeout(restPutIdleTimeoutMs, () => {
119
- if (debug) console.warn(`REST PUT idle ${restPutIdleTimeoutMs}ms → abort`)
207
+ if (debug) console.warn(`REST ${req.method} idle ${restPutIdleTimeoutMs}ms → abort`)
120
208
  try { req.destroy(new Error('idle-timeout')) } catch {}
121
209
  })
122
210
  }
@@ -21,13 +21,13 @@ export type Hub = {
21
21
  // WS-originated broadcasts pass the originator so it doesn't see its
22
22
  // own message echoed back. Local-only: callers that ALSO want a
23
23
  // cross-instance fan-out are expected to publish to the pubsub bus
24
- // alongside this call (server/pubsub.ts). The hub deliberately stays
24
+ // alongside this call (server-e2e/pubsub.ts). The hub deliberately stays
25
25
  // ignorant of the bus so its transport invariants (backpressure cap,
26
26
  // stringify-once, terminate-on-overflow) are unchanged.
27
27
  broadcast(tag: string, msg: object, except: WebSocket | null): void
28
28
  // Broadcasts an ALREADY-SERIALISED payload to every local subscriber
29
29
  // for `tag`. Used by the pubsub bus receiver
30
- // (server/bus-receiver.ts, wired up from server/index.ts) to relay
30
+ // (server-e2e/bus-receiver.ts, wired up from server-e2e/index.ts) to relay
31
31
  // a remote-instance event into this instance's fan-out. No `except`:
32
32
  // the originator is on a different instance by construction.
33
33
  broadcastLocalRaw(tag: string, payload: string): void
@@ -71,7 +71,7 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
71
71
  // kernel yet — a slow / blackholed peer accumulates them unboundedly
72
72
  // during fan-out broadcasts. Drop above the cap and terminate the
73
73
  // socket so the heartbeat doesn't keep it alive on ping/pong while
74
- // every broadcast piles up. Transport audit `server/index.ts:225`.
74
+ // every broadcast piles up. Transport audit `server-e2e/index.ts:225`.
75
75
  if (socket.bufferedAmount > maxBufferedBytes) {
76
76
  if (debug) console.warn(`drop broadcast: socket buffered ${socket.bufferedAmount}B > cap`)
77
77
  try { socket.terminate() } catch {}
@@ -102,12 +102,11 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
102
102
  }
103
103
 
104
104
  function fanOut(set: Set<WebSocket>, payload: string, except: WebSocket | null): void {
105
- // Snapshot before iterating — `send`'s try/catch swallows
106
- // socket.send errors, but a socket transitioning to CLOSED
105
+ // Snapshot before iterating — a socket transitioning to CLOSED
107
106
  // mid-broadcast triggers `unsubscribeAll` from the 'close' handler,
108
- // which mutates `set` while we're walking it. The snapshot keeps a
109
- // future refactor (different collection, async send) from silently
110
- // skipping subscribers. Audit M4 round-3.
107
+ // mutating `set` while we walk it. The snapshot also keeps a future
108
+ // refactor (different collection, async send) from silently skipping
109
+ // subscribers. Audit M4 round-3.
111
110
  for (const s of [...set]) {
112
111
  if (s === except) continue
113
112
  sendRaw(s, payload)