@preventive/triage 1.0.0-alpha.3 → 1.0.0-alpha.31

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 (183) hide show
  1. package/LICENSE-APACHE +202 -0
  2. package/api/reap.ts +17 -0
  3. package/cli.js +6 -0
  4. package/client/finding-link.js +305 -0
  5. package/client/linked-findings.d.ts +1 -0
  6. package/client/linked-findings.js +111 -0
  7. package/common/bundle-commit.d.ts +1 -0
  8. package/common/bundle-commit.js +4 -0
  9. package/common/bundle-metadata.d.ts +10 -0
  10. package/common/bundle-metadata.js +182 -0
  11. package/common/bundle-reasons.d.ts +2 -0
  12. package/common/bundle-reasons.js +21 -0
  13. package/common/bundle-source-links.d.ts +5 -0
  14. package/common/bundle-source-links.js +82 -0
  15. package/common/bundle-sources.d.ts +3 -0
  16. package/common/bundle-sources.js +284 -0
  17. package/common/bundle-stats.js +41 -0
  18. package/common/bundle-tabs.js +1 -0
  19. package/common/code-language.js +51 -0
  20. package/common/default-scan-models.ts +30 -0
  21. package/common/finding-id.js +47 -0
  22. package/common/github-issue-labels.d.ts +1 -0
  23. package/common/github-issue-labels.js +11 -0
  24. package/common/github-pr.ts +56 -0
  25. package/common/managed/comments.ts +34 -0
  26. package/common/managed/permissions.ts +35 -0
  27. package/common/managed/report-content.ts +42 -0
  28. package/common/managed/report-filter.ts +108 -0
  29. package/common/managed/roles.ts +28 -0
  30. package/common/managed/routes.d.ts +2 -0
  31. package/common/managed/routes.js +124 -0
  32. package/common/managed/scan-models.ts +6 -0
  33. package/common/managed/triage.ts +83 -0
  34. package/common/save-error-reason.ts +20 -7
  35. package/common/scan-server.ts +13 -0
  36. package/common/server-info.ts +35 -0
  37. package/common/utf8.d.ts +3 -0
  38. package/common/utf8.js +45 -0
  39. package/out/brotli-fallback.js +3 -3
  40. package/out/client-managed-import.js +83 -0
  41. package/out/client-managed.js +169 -0
  42. package/out/client-sync.js +16 -13
  43. package/out/github-issue-authorized.html +3 -0
  44. package/out/github-issue-authorized.js +1 -0
  45. package/out/graph.js +30 -4
  46. package/out/index.html +55 -8
  47. package/out/prism.js +2 -2
  48. package/out/stasis.svg +20 -0
  49. package/out/terminal.js +273 -39
  50. package/out/view.css +1 -1
  51. package/out/view.js +259 -63
  52. package/package.json +205 -57
  53. package/server-common/database-config.ts +16 -0
  54. package/server-common/initialize.ts +18 -0
  55. package/server-common/npm-advisories.ts +101 -0
  56. package/{server → server-common}/origin.ts +5 -5
  57. package/server-common/reap.ts +48 -0
  58. package/server-common/scan-config.ts +19 -0
  59. package/server-common/standalone.ts +29 -0
  60. package/server-common/storage-crypto.ts +182 -0
  61. package/server-common/storage-log.ts +38 -0
  62. package/server-common/vercel-blob.ts +103 -0
  63. package/server-e2e/app.ts +486 -0
  64. package/{server → server-e2e}/auth.ts +5 -1
  65. package/{server → server-e2e}/bus-receiver.ts +2 -1
  66. package/{server → server-e2e}/cli.js +9 -4
  67. package/{server → server-e2e}/config.ts +56 -35
  68. package/{server → server-e2e}/db-neon.ts +2 -2
  69. package/{server → server-e2e}/db-revision-sql.ts +1 -1
  70. package/{server → server-e2e}/db-stmt.ts +2 -2
  71. package/{server → server-e2e}/db.ts +7 -7
  72. package/{server → server-e2e}/http.ts +110 -12
  73. package/{server → server-e2e}/hub.ts +40 -9
  74. package/server-e2e/index.ts +17 -0
  75. package/server-e2e/lifecycle.ts +95 -0
  76. package/{server → server-e2e}/neon-driver.ts +2 -2
  77. package/{server → server-e2e}/npm-proxy.ts +11 -144
  78. package/{server → server-e2e}/objstore/blob-fs.ts +2 -2
  79. package/{server → server-e2e}/objstore/blob-vercel.ts +13 -117
  80. package/{server → server-e2e}/objstore/blob.ts +9 -2
  81. package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
  82. package/{server → server-e2e}/objstore/handlers.ts +9 -8
  83. package/{server → server-e2e}/objstore/init.ts +35 -18
  84. package/{server → server-e2e}/objstore/reaper.ts +31 -11
  85. package/server-e2e/objstore/rest-deny.ts +28 -0
  86. package/server-e2e/objstore/rest-mint.ts +224 -0
  87. package/{server → server-e2e}/objstore/rest.ts +58 -33
  88. package/{server → server-e2e}/objstore/sign.ts +105 -0
  89. package/{server → server-e2e}/objstore/store-neon.ts +6 -10
  90. package/{server → server-e2e}/objstore/store.ts +7 -7
  91. package/{server → server-e2e}/objstore/tokens.ts +1 -1
  92. package/{server → server-e2e}/pubsub.ts +12 -9
  93. package/{server → server-e2e}/sign.ts +4 -4
  94. package/{server → server-e2e}/sse-server.ts +102 -68
  95. package/{server → server-e2e}/sse-session.ts +28 -8
  96. package/{server → server-e2e}/static.ts +31 -21
  97. package/server-e2e/sync-handlers.ts +408 -0
  98. package/{server → server-e2e}/util.ts +9 -0
  99. package/{server → server-e2e}/ws-server.ts +20 -9
  100. package/server-managed/activity.ts +231 -0
  101. package/server-managed/avatar-store.ts +12 -0
  102. package/server-managed/blob-store.ts +17 -0
  103. package/server-managed/brotli.ts +10 -0
  104. package/server-managed/bundle-advisories.ts +53 -0
  105. package/server-managed/bundle-advisory-inventory.ts +75 -0
  106. package/server-managed/bundle-build-diagnostics.d.ts +10 -0
  107. package/server-managed/bundle-build-diagnostics.js +23 -0
  108. package/server-managed/bundle-build-leases.ts +41 -0
  109. package/server-managed/bundle-build-worker.js +54 -0
  110. package/server-managed/bundle-build.ts +168 -0
  111. package/server-managed/bundle-cache.ts +166 -0
  112. package/server-managed/bundle-catalog.ts +29 -0
  113. package/server-managed/bundle-store.ts +24 -0
  114. package/server-managed/bundle-summary-cache.ts +116 -0
  115. package/server-managed/bundle.ts +104 -0
  116. package/server-managed/cache-storage.ts +19 -0
  117. package/server-managed/cli.js +21 -0
  118. package/server-managed/combined.ts +46 -0
  119. package/server-managed/comments.ts +151 -0
  120. package/server-managed/config.ts +149 -0
  121. package/server-managed/content-access.ts +15 -0
  122. package/server-managed/crypto.ts +25 -0
  123. package/server-managed/db-methods.ts +1517 -0
  124. package/server-managed/db-neon.ts +277 -0
  125. package/server-managed/db-schema.ts +206 -0
  126. package/server-managed/db-table-names.ts +22 -0
  127. package/server-managed/db.ts +120 -0
  128. package/server-managed/github-app.ts +385 -0
  129. package/server-managed/github-errors.ts +49 -0
  130. package/server-managed/github-issue-oauth.ts +48 -0
  131. package/server-managed/github-issues.ts +152 -0
  132. package/server-managed/github-metadata.ts +98 -0
  133. package/server-managed/github-oauth.ts +199 -0
  134. package/server-managed/github-pulls.ts +227 -0
  135. package/server-managed/http-response.ts +18 -0
  136. package/server-managed/http.ts +2350 -0
  137. package/server-managed/import-triage.ts +51 -0
  138. package/server-managed/index.ts +175 -0
  139. package/server-managed/managed-issues.ts +47 -0
  140. package/server-managed/management-catalog.ts +45 -0
  141. package/server-managed/management.ts +86 -0
  142. package/server-managed/object-storage-disk.ts +144 -0
  143. package/server-managed/object-storage-vercel.ts +63 -0
  144. package/server-managed/object-storage.ts +71 -0
  145. package/server-managed/package-entry-points.ts +51 -0
  146. package/server-managed/public-workspace.ts +146 -0
  147. package/server-managed/repo-path.ts +21 -0
  148. package/server-managed/report-migration.ts +35 -0
  149. package/server-managed/report-query.ts +10 -0
  150. package/server-managed/report-response.ts +16 -0
  151. package/server-managed/report-sources.ts +166 -0
  152. package/server-managed/repository-browser.ts +139 -0
  153. package/server-managed/repository-discovery.ts +82 -0
  154. package/server-managed/repository-policy.ts +27 -0
  155. package/server-managed/revisions.ts +35 -0
  156. package/server-managed/session.ts +78 -0
  157. package/server-managed/slugs.ts +38 -0
  158. package/server-managed/solidity-entry-points.ts +69 -0
  159. package/server-managed/sql-postgres.ts +31 -0
  160. package/server-managed/sql.ts +72 -0
  161. package/server-managed/static.ts +28 -0
  162. package/server-managed/storage-command.ts +16 -0
  163. package/server-managed/storage-db.ts +175 -0
  164. package/server-managed/storage-encryption.ts +211 -0
  165. package/server-managed/storage-maintenance.ts +223 -0
  166. package/server-managed/storage-payload.ts +72 -0
  167. package/server-managed/storage-stores.ts +81 -0
  168. package/server-managed/storage.ts +58 -0
  169. package/server-managed/team-catalog.ts +7 -0
  170. package/server-managed/team-feed.ts +173 -0
  171. package/server-managed/team-reports.ts +219 -0
  172. package/server-managed/triage-response.ts +16 -0
  173. package/server-managed/uploads.ts +65 -0
  174. package/server-managed/workspace-shares.ts +162 -0
  175. package/server.ts +50 -0
  176. package/server/index.ts +0 -477
  177. package/server/lifecycle.ts +0 -204
  178. package/server/sync-handlers.ts +0 -320
  179. /package/{LICENSE → LICENSE-MIT} +0 -0
  180. /package/{server → server-e2e}/config.example.json +0 -0
  181. /package/{server → server-e2e}/objstore/fs.ts +0 -0
  182. /package/{server → server-e2e}/peer.ts +0 -0
  183. /package/{server → server-e2e}/validation.ts +0 -0
@@ -2,15 +2,16 @@
2
2
  // vars, the optional config.json) into one immutable `Config`, failing
3
3
  // loud on malformed values so a typo surfaces at startup rather than
4
4
  // deep in `node:net` / at the first token verification. Pure parsing —
5
- // no backends opened, no crypto keys derived, no side effects beyond
6
- // `--help` / fail-fast `process.exit`. index.ts destructures the
7
- // result and does the wiring (backend selection, password HMAC, …).
5
+ // no backends opened or process termination. Invalid configuration throws so
6
+ // embedding hosts can recover; app.ts handles backend selection and wiring.
8
7
 
9
8
  import { Buffer } from 'node:buffer'
10
9
  import { readFileSync } from 'node:fs'
11
- import { argv, env } from 'node:process'
10
+ import { env } from 'node:process'
12
11
  import { fileURLToPath } from 'node:url'
13
12
  import { dirname, join } from 'node:path'
13
+ import { configuredScanServer } from '../server-common/scan-config.ts'
14
+ import { databaseUrls } from '../server-common/database-config.ts'
14
15
 
15
16
  export type Config = {
16
17
  port: number
@@ -18,6 +19,7 @@ export type Config = {
18
19
  dbPath: string
19
20
  objstoreDir: string
20
21
  reapIntervalMs: number
22
+ reapDisabled: boolean
21
23
  maxInflightPerSocket: number
22
24
  debug: boolean
23
25
  neonUrl: string | null
@@ -25,9 +27,11 @@ export type Config = {
25
27
  tokenSecret: Uint8Array<ArrayBuffer> | null
26
28
  password: string | null
27
29
  trustProxyEnv: string | undefined
30
+ deepviewScanServer: string | null
31
+ githubNewIssueLabels?: string
28
32
  }
29
33
 
30
- // Parse + range-validate an integer env var, exiting with a clear
34
+ // Parse + range-validate an integer env var, throwing a clear
31
35
  // up-front message on a malformed value — a NaN from `Number("abc")`
32
36
  // otherwise surfaces as a confusing crash deep inside `node:net`
33
37
  // (`WebSocketServer({ port: NaN })`) or a 0-ms `setInterval` loop. An
@@ -38,20 +42,32 @@ function intEnv(name: string, def: number, min: number, max: number, hint = ''):
38
42
  const raw = env[name]
39
43
  const n = raw == null ? def : Number(raw)
40
44
  if (!Number.isSafeInteger(n) || n < min || n > max) {
41
- console.error(`Invalid ${name}: ${raw} — must be an integer in [${min}, ${max}].${hint ? ` ${hint}` : ''}`)
42
- process.exit(1)
45
+ throw new Error(`Invalid ${name}: ${raw} — must be an integer in [${min}, ${max}].${hint ? ` ${hint}` : ''}`)
43
46
  }
44
47
  return n
45
48
  }
46
49
 
47
- const HELP = `Usage: node server/index.ts
50
+ export const HELP = `Usage: node server-e2e/index.ts
48
51
  Environment:
49
52
  PORT listen port (default 8765)
50
53
  HOST bind host (default 127.0.0.1)
51
- DB_PATH sqlite file (default: server/data/data.db);
52
- ignored when DATABASE_URL is set
53
- DATABASE_URL Neon Postgres connection string; if set,
54
- selects the Neon backend instead of
54
+ DEEPVIEW_SCAN_SERVER optional HTTP(S) scan-service URL; advertised
55
+ in /api/config and allowed by the UI CSP.
56
+ Unset by default (no external scan service).
57
+ GITHUB_NEW_ISSUE_LABELS optional comma-separated labels for the client's
58
+ prefilled GitHub issue form, in addition to
59
+ deepview and security (for security findings).
60
+ Advertised in /api/config and sync connections.
61
+ DB_PATH sqlite file (default: server-e2e/data/data.db);
62
+ ignored when an e2e database URL is set
63
+ DATABASE_URL shared Neon Postgres URL for enabled modes.
64
+ Cannot be combined with E2E_DATABASE_URL
65
+ or MANAGED_DATABASE_URL.
66
+ E2E_DATABASE_URL e2e-only Neon URL, used without DATABASE_URL.
67
+ Combined mode also requires
68
+ MANAGED_DATABASE_URL; mixing Neon and
69
+ SQLite backends is rejected.
70
+ Either e2e URL selects Neon instead of
55
71
  SQLite. Requires the optional peer dep
56
72
  @neondatabase/serverless. The Neon
57
73
  pairing additionally requires
@@ -60,12 +76,12 @@ Environment:
60
76
  local-FS bytes cannot back a multi-
61
77
  replica DB plane.
62
78
  BLOB_READ_WRITE_TOKEN Vercel Blob R/W token (private store).
63
- Required when DATABASE_URL is set;
79
+ Required when an e2e database URL is set;
64
80
  ignored otherwise. Requires the optional
65
81
  peer dep @vercel/blob.
66
82
  OBJSTORE_TOKEN_SECRET Base64 (32 bytes) HMAC secret for REST
67
- bearer tokens. REQUIRED when DATABASE_URL
68
- is set (multi-replica deployments: a
83
+ bearer tokens. REQUIRED in Neon mode
84
+ (multi-replica deployments: a
69
85
  token minted on one replica's WS plane
70
86
  must validate on another replica's REST
71
87
  plane). Optional under SQLite (a fresh
@@ -75,9 +91,15 @@ Environment:
75
91
  OBJSTORE_DIR object store root (default: ./objstore
76
92
  next to DB_PATH). Used by the local-FS
77
93
  byte plane only; ignored when
78
- DATABASE_URL + BLOB_READ_WRITE_TOKEN
79
- are set (bytes live in Vercel Blob).
94
+ an e2e database URL selects Neon
95
+ (bytes live in Vercel Blob).
80
96
  OBJSTORE_REAP_INTERVAL_MS orphan reaper period (default 600000)
97
+ OBJSTORE_REAP_DISABLED set '1' / 'true' to disable automatic orphan
98
+ sweeps (boot and periodic). Explicit /api/reap
99
+ remains available. Schedule cleanup externally
100
+ to avoid accumulating orphaned bytes.
101
+ CRON_SECRET bearer secret for GET /api/reap (fails closed
102
+ when unset). Runs cleanup for enabled modes.
81
103
  TRUST_PROXY set '1' / 'true' to honour X-Forwarded-
82
104
  Host / X-Forwarded-Proto when computing
83
105
  the same-origin gate's expected origin.
@@ -95,7 +117,7 @@ Environment:
95
117
  that need to deterministically
96
118
  exercise the cap.
97
119
  CONFIG_PATH operator config JSON path (default:
98
- server/config.json). Currently the
120
+ server-e2e/config.json). Currently the
99
121
  only field is { "password": "..." }
100
122
  which gates first-action creation of
101
123
  a new workspace on the
@@ -113,11 +135,11 @@ function readServerConfigFile(path: string): ServerConfigFile {
113
135
  let raw: string
114
136
  try { raw = readFileSync(path, 'utf8') } catch (err) {
115
137
  if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return {}
116
- console.error(`Failed to read ${path}:`, (err as Error)?.message ?? err); process.exit(1)
138
+ throw new Error(`Failed to read ${path}: ${(err as Error)?.message ?? err}`, { cause: err })
117
139
  }
118
140
  try { return JSON.parse(raw) as ServerConfigFile }
119
141
  catch (err) {
120
- console.error(`Failed to parse ${path} as JSON:`, (err as Error)?.message ?? err); process.exit(1)
142
+ throw new Error(`Failed to parse ${path} as JSON: ${(err as Error)?.message ?? err}`, { cause: err })
121
143
  }
122
144
  }
123
145
 
@@ -132,20 +154,17 @@ function decodeTokenSecret(raw: string): Uint8Array<ArrayBuffer> {
132
154
  // typo-detector below would fail with a misleading message.
133
155
  const trimmed = raw.trim()
134
156
  if (trimmed.length === 0) {
135
- console.error('OBJSTORE_TOKEN_SECRET is empty after trimming whitespace')
136
- process.exit(1)
157
+ throw new Error('OBJSTORE_TOKEN_SECRET is empty after trimming whitespace')
137
158
  }
138
159
  const decoded = Buffer.from(trimmed, 'base64')
139
160
  const reencoded = decoded.toString('base64')
140
161
  const norm = (s: string): string => s.replace(/=+$/u, '')
141
162
  if (norm(reencoded) !== norm(trimmed)) {
142
- console.error('OBJSTORE_TOKEN_SECRET contains non-base64 characters (likely a typo, e.g. base64url chars in a base64 secret).')
143
- console.error('Regenerate with: node -e \'console.log(require("crypto").randomBytes(32).toString("base64"))\'')
144
- process.exit(1)
163
+ throw new Error('OBJSTORE_TOKEN_SECRET contains non-base64 characters (likely a typo, e.g. base64url chars in a base64 secret).\n' +
164
+ 'Regenerate with: node -e \'console.log(require("crypto").randomBytes(32).toString("base64"))\'')
145
165
  }
146
166
  if (decoded.byteLength !== 32) {
147
- console.error(`OBJSTORE_TOKEN_SECRET must decode to 32 bytes (got ${decoded.byteLength})`)
148
- process.exit(1)
167
+ throw new Error(`OBJSTORE_TOKEN_SECRET must decode to 32 bytes (got ${decoded.byteLength})`)
149
168
  }
150
169
  // Copy into a fresh ArrayBuffer so the type matches
151
170
  // `Uint8Array<ArrayBuffer>` (Buffer may be SharedArrayBuffer-backed).
@@ -163,13 +182,18 @@ export function loadConfig(): Config {
163
182
  const objstoreDir = env['OBJSTORE_DIR'] ?? join(dirname(dbPath), 'objstore')
164
183
  // No practical upper bound beyond the safe-integer range.
165
184
  const reapIntervalMs = intEnv('OBJSTORE_REAP_INTERVAL_MS', 10 * 60 * 1000, 1, Number.MAX_SAFE_INTEGER)
185
+ // Off-switch for automatic orphan reaping (both the boot sweep AND the
186
+ // periodic timer). '1' / 'true' (case-insensitive) → disabled; anything
187
+ // else, including unset, leaves it ON. Same boolean shape as TRUST_PROXY.
188
+ const reapDisabledEnv = env['OBJSTORE_REAP_DISABLED']
189
+ const reapDisabled = reapDisabledEnv === '1' || reapDisabledEnv?.toLowerCase() === 'true'
166
190
  const debug = env['DEBUG'] === '1'
167
191
 
168
192
  const configPath = env['CONFIG_PATH'] ?? fileURLToPath(new URL('./config.json', import.meta.url))
169
193
  const serverConfig = readServerConfigFile(configPath)
170
194
  const rawPassword = serverConfig.password
171
195
  if (rawPassword != null && typeof rawPassword !== 'string') {
172
- console.error(`Invalid ${configPath}: "password" must be a string or null`); process.exit(1)
196
+ throw new Error(`Invalid ${configPath}: "password" must be a string or null`)
173
197
  }
174
198
  const password = rawPassword ?? null
175
199
  // Upper bound 65_536 — bounds memory under hostile load; a deployer
@@ -178,19 +202,16 @@ export function loadConfig(): Config {
178
202
  // error-precedence order.
179
203
  const maxInflightPerSocket = intEnv('MAX_INFLIGHT_PER_SOCKET', 64, 1, 65_536)
180
204
 
181
- if (argv.includes('--help') || argv.includes('-h')) {
182
- console.log(HELP)
183
- process.exit(0)
184
- }
185
-
186
- const neonUrl = env['DATABASE_URL'] ?? null
205
+ const neonUrl = databaseUrls().e2e
187
206
  const blobToken = env['BLOB_READ_WRITE_TOKEN'] ?? null
188
207
  const tokenSecretB64 = env['OBJSTORE_TOKEN_SECRET'] ?? null
189
208
  const tokenSecret = tokenSecretB64 ? decodeTokenSecret(tokenSecretB64) : null
190
209
 
191
210
  return {
192
- port, host, dbPath, objstoreDir, reapIntervalMs, maxInflightPerSocket,
211
+ port, host, dbPath, objstoreDir, reapIntervalMs, reapDisabled, maxInflightPerSocket,
193
212
  debug, neonUrl, blobToken, tokenSecret, password,
194
213
  trustProxyEnv: env['TRUST_PROXY'],
214
+ deepviewScanServer: configuredScanServer(env['DEEPVIEW_SCAN_SERVER']),
215
+ githubNewIssueLabels: env['GITHUB_NEW_ISSUE_LABELS'] ?? '',
195
216
  }
196
217
  }
@@ -4,7 +4,7 @@
4
4
  // dialect on the wire.
5
5
  //
6
6
  // `@neondatabase/serverless` is an OPTIONAL peer dep — selected by
7
- // the `DATABASE_URL` branch in `server/index.ts`. The peer dep
7
+ // the configured database URL branch in `server-e2e/index.ts`. The peer dep
8
8
  // itself is loaded lazily via the dynamic `import()` inside
9
9
  // `openNeonDb` below, so a SQLite-only deployment never installs it
10
10
  // (`autoInstallPeers: false` in `pnpm-workspace.yaml`) and never
@@ -340,7 +340,7 @@ export async function openNeonDb(connectionString: string): Promise<Handle> {
340
340
  // in-process Postgres (PGlite) via `mock.module`: that hook can only
341
341
  // intercept a specifier it can RESOLVE, and the optional peer dep
342
342
  // isn't installed in a SQLite-only checkout. The wrapper path always
343
- // resolves — see `server/neon-driver.ts`. Cast through `unknown`
343
+ // resolves — see `server-e2e/neon-driver.ts`. Cast through `unknown`
344
344
  // because the wrapper's `export *` re-exports a `@ts-ignore`'d
345
345
  // (possibly-absent) module, so tsc can't see `neon`'s type here.
346
346
  const mod = (await import('./neon-driver.ts')) as unknown as { neon: (url: string) => NeonSql }
@@ -106,7 +106,7 @@ export const CHAIN_FROM_SQL =
106
106
  export const REVISION_EXISTS_SQL =
107
107
  `SELECT 1 AS one FROM workspace_revision WHERE workspace_tag = $1 AND id = $2`
108
108
  // Fetch a single revision row by content-addressed id. The cross-instance
109
- // pubsub (server/pubsub.ts) NOTIFY payload carries only `(tag, revisionId)`
109
+ // pubsub (server-e2e/pubsub.ts) NOTIFY payload carries only `(tag, revisionId)`
110
110
  // because the full `workspace-state` broadcast envelope is bounded by
111
111
  // `MAX_CIPHERTEXT_LEN` (2 MiB) and Postgres NOTIFY caps payloads at ~8 KB.
112
112
  // The receiver re-fetches the row from this shared table to construct the
@@ -1,5 +1,5 @@
1
- // Shared async-statement primitives. Both `server/db.ts` (workspace_revision
2
- // chain) and `server/objstore/store.ts` (objstore tables) expose Handles
1
+ // Shared async-statement primitives. Both `server-e2e/db.ts` (workspace_revision
2
+ // chain) and `server-e2e/objstore/store.ts` (objstore tables) expose Handles
3
3
  // whose statements look like `{ get(...) → Promise<…>, all(...) → Promise<[…]>,
4
4
  // run(...) → Promise<void> }`. The underlying `node:sqlite` driver is
5
5
  // synchronous; the wrappers below catch sync errors and route them through
@@ -13,7 +13,7 @@
13
13
  // `keyframe` is `1` for a revision the client emits with full state
14
14
  // baked in (rather than a delta). The wire flag is covered by the
15
15
  // signature, so the column value MUST match the signed canonical
16
- // bytes: `canonicalSave` (server/sign.ts, via `handleSave`) encodes
16
+ // bytes: `canonicalSave` (server-e2e/sign.ts, via `handleSave`) encodes
17
17
  // `keyframe ? '1' : ''` into the bytes `verifyEd25519` checks, so a
18
18
  // mismatched wire flag fails verify and never reaches this column.
19
19
  // Client-driven: the server stores what the client sent and treats
@@ -84,7 +84,7 @@ const SCHEMA = `
84
84
 
85
85
  // Row shape from the chain queries. `keyframe` is stored as INTEGER
86
86
  // (0 / 1); the raw row carries the integer — `chainForWire` in
87
- // server/index.ts normalises to a strict boolean before broadcasting.
87
+ // server-e2e/index.ts normalises to a strict boolean before broadcasting.
88
88
  // `base` is nullable on the very first revision.
89
89
  export type RevisionRow = {
90
90
  base: string | null
@@ -125,8 +125,8 @@ export type CommitResult =
125
125
  // (`./db-neon.ts`) constructs a Handle with `db` unset. Callers that
126
126
  // reach into `db` directly (e.g. `openObjstore`, test-only fixture
127
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/index.ts`.
128
+ // Handle is the operator's mistake to catch at the database URL
129
+ // switch in `server-e2e/index.ts`.
130
130
  //
131
131
  // `tryCommit` is the backend-specific atomic-commit primitive
132
132
  // `commitRevision` dispatches through. SQLite runs one synchronous
@@ -154,7 +154,7 @@ export type Handle = {
154
154
  revisionExists: GetStmt<[string, string], unknown>
155
155
  // Single-revision fetch by content-addressed id. The cross-instance
156
156
  // pubsub receiver uses this to assemble a `workspace-state` from a
157
- // NOTIFY hint (see `server/pubsub.ts`).
157
+ // NOTIFY hint (see `server-e2e/pubsub.ts`).
158
158
  revisionById: GetStmt<[string, string], RevisionRow>
159
159
  gatedInsert?: GetStmt<[string, string, string | null, number, string, string, string, number], { seq: number }>
160
160
  tryCommit: (input: RevisionInsert) => Promise<CommitResult>
@@ -164,11 +164,11 @@ export type Handle = {
164
164
  // Narrowing alias for the SQLite-backed Handle: `db` is guaranteed
165
165
  // set. `openDb` returns this so call sites needing direct
166
166
  // `DatabaseSync` access (e.g. `openObjstore(handle.db, …)` in
167
- // `server/index.ts`'s SQLite branch) reach `handle.db` without an
167
+ // `server-e2e/index.ts`'s SQLite branch) reach `handle.db` without an
168
168
  // optional-chain or non-null assertion. A Neon-backed Handle
169
169
  // (`openNeonDb`) keeps the wider `db?: DatabaseSync` shape, so routing
170
170
  // one into a SQLite-coupled call site is a compile-time error. Mirrors
171
- // `server/objstore/store.ts`.
171
+ // `server-e2e/objstore/store.ts`.
172
172
  export type SqliteHandle = Handle & { db: DatabaseSync }
173
173
 
174
174
  export function openDb(path: string): SqliteHandle {
@@ -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,19 +53,88 @@ 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
54
121
  // the `/api/objstore/...` REST branch.
55
- const handleStatic = loadStatic(fileURLToPath(new URL('../out', import.meta.url)))
122
+ const handleStatic = loadStatic(fileURLToPath(new URL('../out', import.meta.url)), serverInfo.deepviewScanServer ?? null)
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
  }
@@ -142,7 +230,19 @@ export function createHttpServer(deps: HttpServerDeps): Server {
142
230
  res.end(NOT_FOUND_BODY)
143
231
  })
144
232
 
233
+ installUpgradeHandler(httpServer, wss, isOriginAllowed)
234
+
235
+ return httpServer
236
+ }
237
+
238
+ function installUpgradeHandler(httpServer: Server, wss: WebSocketServer, isOriginAllowed: HttpServerDeps['isOriginAllowed']): void {
145
239
  httpServer.on('upgrade', (req, socket, head) => {
240
+ // Combined deployments must not let a managed share capability enter the
241
+ // unscoped sync transport, which bypasses the normal HTTP router.
242
+ if (req.headers['x-deepview-share'] !== undefined) {
243
+ socket.end('HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n')
244
+ return
245
+ }
146
246
  // RFC 6455: the WS upgrade IS an HTTP request; reject with a normal
147
247
  // HTTP response so a misconfigured client sees the JSON body instead
148
248
  // of ECONNRESET. `socket.end(body)` flushes before sending FIN.
@@ -173,6 +273,4 @@ export function createHttpServer(deps: HttpServerDeps): Server {
173
273
  }
174
274
  wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req) })
175
275
  })
176
-
177
- return httpServer
178
276
  }
@@ -7,9 +7,13 @@
7
7
  // testable and reasoned about on its own.
8
8
 
9
9
  import type { WebSocket } from 'ws'
10
+ import { Buffer } from 'node:buffer'
10
11
  import type { PeerRegistry } from './peer.ts'
11
12
 
12
13
  export type Hub = {
14
+ // Buffer broadcasts during async subscription snapshots. The returned
15
+ // release flushes them after the snapshot/chain replies, preserving order.
16
+ pauseBroadcasts(socket: WebSocket): () => void
13
17
  subscribe(socket: WebSocket, tag: string): void
14
18
  unsubscribeAll(socket: WebSocket): void
15
19
  send(socket: WebSocket, msg: object): void
@@ -21,13 +25,13 @@ export type Hub = {
21
25
  // WS-originated broadcasts pass the originator so it doesn't see its
22
26
  // own message echoed back. Local-only: callers that ALSO want a
23
27
  // cross-instance fan-out are expected to publish to the pubsub bus
24
- // alongside this call (server/pubsub.ts). The hub deliberately stays
28
+ // alongside this call (server-e2e/pubsub.ts). The hub deliberately stays
25
29
  // ignorant of the bus so its transport invariants (backpressure cap,
26
30
  // stringify-once, terminate-on-overflow) are unchanged.
27
31
  broadcast(tag: string, msg: object, except: WebSocket | null): void
28
32
  // Broadcasts an ALREADY-SERIALISED payload to every local subscriber
29
33
  // for `tag`. Used by the pubsub bus receiver
30
- // (server/bus-receiver.ts, wired up from server/index.ts) to relay
34
+ // (server-e2e/bus-receiver.ts, wired up from server-e2e/index.ts) to relay
31
35
  // a remote-instance event into this instance's fan-out. No `except`:
32
36
  // the originator is on a different instance by construction.
33
37
  broadcastLocalRaw(tag: string, payload: string): void
@@ -38,18 +42,34 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
38
42
  // workspaceTag → Set<WebSocket>. The per-socket reverse index lives on
39
43
  // `Peer.tags` (see ./peer.ts) and is read by `unsubscribeAll` on close.
40
44
  const subscribers = new Map<string, Set<WebSocket>>()
45
+ const paused = new WeakMap<WebSocket, { depth: number; payloads: string[]; bytes: number }>()
46
+
47
+ function pauseBroadcasts(socket: WebSocket): () => void {
48
+ const pending = paused.get(socket) ?? { depth: 0, payloads: [], bytes: 0 }
49
+ pending.depth++
50
+ paused.set(socket, pending)
51
+ let released = false
52
+ return () => {
53
+ if (released) return
54
+ released = true
55
+ if (--pending.depth > 0 || paused.get(socket) !== pending) return
56
+ paused.delete(socket)
57
+ for (const payload of pending.payloads) sendRaw(socket, payload)
58
+ pending.payloads.length = 0
59
+ }
60
+ }
41
61
 
42
62
  function subscribe(socket: WebSocket, tag: string): void {
43
63
  let set = subscribers.get(tag)
44
- if (!set) {
45
- set = new Set()
46
- subscribers.set(tag, set)
47
- }
64
+ if (!set) { set = new Set(); subscribers.set(tag, set) }
48
65
  set.add(socket)
49
66
  peers.get(socket)?.tags.add(tag)
50
67
  }
51
68
 
52
69
  function unsubscribeAll(socket: WebSocket): void {
70
+ const pending = paused.get(socket)
71
+ if (pending) pending.payloads.length = 0
72
+ paused.delete(socket)
53
73
  const tags = peers.get(socket)?.tags
54
74
  if (!tags) return
55
75
  for (const tag of tags) {
@@ -71,7 +91,7 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
71
91
  // kernel yet — a slow / blackholed peer accumulates them unboundedly
72
92
  // during fan-out broadcasts. Drop above the cap and terminate the
73
93
  // socket so the heartbeat doesn't keep it alive on ping/pong while
74
- // every broadcast piles up. Transport audit `server/index.ts:225`.
94
+ // every broadcast piles up. Transport audit `server-e2e/index.ts:225`.
75
95
  if (socket.bufferedAmount > maxBufferedBytes) {
76
96
  if (debug) console.warn(`drop broadcast: socket buffered ${socket.bufferedAmount}B > cap`)
77
97
  try { socket.terminate() } catch {}
@@ -102,6 +122,7 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
102
122
  }
103
123
 
104
124
  function fanOut(set: Set<WebSocket>, payload: string, except: WebSocket | null): void {
125
+ const payloadBytes = Buffer.byteLength(payload)
105
126
  // Snapshot before iterating — a socket transitioning to CLOSED
106
127
  // mid-broadcast triggers `unsubscribeAll` from the 'close' handler,
107
128
  // mutating `set` while we walk it. The snapshot also keeps a future
@@ -109,9 +130,19 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
109
130
  // subscribers. Audit M4 round-3.
110
131
  for (const s of [...set]) {
111
132
  if (s === except) continue
112
- sendRaw(s, payload)
133
+ const pending = paused.get(s)
134
+ if (!pending) { sendRaw(s, payload); continue }
135
+ pending.bytes += payloadBytes
136
+ if (pending.bytes > maxBufferedBytes) {
137
+ // Snapshot waits must obey the same memory bound as socket writes.
138
+ pending.payloads.length = 0
139
+ paused.delete(s)
140
+ try { s.terminate() } catch {}
141
+ continue
142
+ }
143
+ pending.payloads.push(payload)
113
144
  }
114
145
  }
115
146
 
116
- return { subscribe, unsubscribeAll, send, sendRaw, broadcast, broadcastLocalRaw }
147
+ return { pauseBroadcasts, subscribe, unsubscribeAll, send, sendRaw, broadcast, broadcastLocalRaw }
117
148
  }
@@ -0,0 +1,17 @@
1
+ // Compatibility entry point for the published e2e server. Importing exposes
2
+ // one unbound instance; the combined launcher uses the factory directly.
3
+ import { startServer } from '../server-common/standalone.ts'
4
+ import { createE2eApp } from './app.ts'
5
+ import { HELP, loadConfig } from './config.ts'
6
+
7
+ if (import.meta.main && (process.argv.includes('--help') || process.argv.includes('-h'))) {
8
+ console.log(HELP)
9
+ process.exit(0)
10
+ }
11
+
12
+ const config = loadConfig()
13
+ export const { httpServer, wss, isShuttingDown, reap, handleRequest, onShutdown } = await createE2eApp(config)
14
+
15
+ export function start(): void { startServer(httpServer, config) }
16
+
17
+ if (import.meta.main) start()