@preventive/triage 1.0.0-alpha.17 → 1.0.0-alpha.19

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 (44) hide show
  1. package/api/reap.ts +13 -75
  2. package/common/server-info.ts +6 -4
  3. package/out/client-managed-import.js +81 -0
  4. package/out/client-managed.js +68 -41
  5. package/out/client-sync.js +14 -14
  6. package/out/graph.js +10 -10
  7. package/out/index.html +1 -0
  8. package/out/view.css +1 -1
  9. package/out/view.js +179 -142
  10. package/package.json +16 -6
  11. package/report/index.js +2 -1
  12. package/report/src/finding.js +12 -0
  13. package/report/src/security.js +63 -0
  14. package/server-common/database-config.ts +16 -0
  15. package/server-common/initialize.ts +18 -0
  16. package/server-common/npm-advisories.ts +101 -0
  17. package/server-common/reap.ts +48 -0
  18. package/server-common/standalone.ts +29 -0
  19. package/server-common/storage-log.ts +34 -0
  20. package/server-common/vercel-blob.ts +110 -0
  21. package/server-e2e/app.ts +485 -0
  22. package/server-e2e/cli.js +7 -2
  23. package/server-e2e/config.ts +36 -40
  24. package/server-e2e/db-neon.ts +1 -1
  25. package/server-e2e/db.ts +1 -1
  26. package/server-e2e/http.ts +12 -2
  27. package/server-e2e/index.ts +12 -481
  28. package/server-e2e/lifecycle.ts +59 -172
  29. package/server-e2e/neon-driver.ts +2 -2
  30. package/server-e2e/npm-proxy.ts +10 -143
  31. package/server-e2e/objstore/blob-vercel.ts +4 -108
  32. package/server-e2e/objstore/init.ts +15 -23
  33. package/server-e2e/objstore/store-neon.ts +5 -9
  34. package/server-e2e/static.ts +11 -17
  35. package/out/provider-icons/claude.svg +0 -1
  36. package/out/provider-icons/deepseek.svg +0 -1
  37. package/out/provider-icons/google.svg +0 -1
  38. package/out/provider-icons/grok.svg +0 -1
  39. package/out/provider-icons/moonshot.svg +0 -1
  40. package/out/provider-icons/nvidia.svg +0 -1
  41. package/out/provider-icons/openai.svg +0 -1
  42. package/out/provider-icons/openrouter.svg +0 -1
  43. package/out/provider-icons/qwen.svg +0 -1
  44. package/out/provider-icons/zai.svg +0 -1
@@ -2,16 +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'
14
13
  import { configuredScanServer } from '../server-common/scan-config.ts'
14
+ import { databaseUrls } from '../server-common/database-config.ts'
15
15
 
16
16
  export type Config = {
17
17
  port: number
@@ -30,7 +30,7 @@ export type Config = {
30
30
  deepviewScanServer: string | null
31
31
  }
32
32
 
33
- // Parse + range-validate an integer env var, exiting with a clear
33
+ // Parse + range-validate an integer env var, throwing a clear
34
34
  // up-front message on a malformed value — a NaN from `Number("abc")`
35
35
  // otherwise surfaces as a confusing crash deep inside `node:net`
36
36
  // (`WebSocketServer({ port: NaN })`) or a 0-ms `setInterval` loop. An
@@ -41,13 +41,12 @@ function intEnv(name: string, def: number, min: number, max: number, hint = ''):
41
41
  const raw = env[name]
42
42
  const n = raw == null ? def : Number(raw)
43
43
  if (!Number.isSafeInteger(n) || n < min || n > max) {
44
- console.error(`Invalid ${name}: ${raw} — must be an integer in [${min}, ${max}].${hint ? ` ${hint}` : ''}`)
45
- process.exit(1)
44
+ throw new Error(`Invalid ${name}: ${raw} — must be an integer in [${min}, ${max}].${hint ? ` ${hint}` : ''}`)
46
45
  }
47
46
  return n
48
47
  }
49
48
 
50
- const HELP = `Usage: node server-e2e/index.ts
49
+ export const HELP = `Usage: node server-e2e/index.ts
51
50
  Environment:
52
51
  PORT listen port (default 8765)
53
52
  HOST bind host (default 127.0.0.1)
@@ -55,9 +54,15 @@ Environment:
55
54
  in /api/config and allowed by the UI CSP.
56
55
  Unset by default (no external scan service).
57
56
  DB_PATH sqlite file (default: server-e2e/data/data.db);
58
- ignored when DATABASE_URL is set
59
- DATABASE_URL Neon Postgres connection string; if set,
60
- selects the Neon backend instead of
57
+ ignored when an e2e database URL is set
58
+ DATABASE_URL shared Neon Postgres URL for enabled modes.
59
+ Cannot be combined with E2E_DATABASE_URL
60
+ or MANAGED_DATABASE_URL.
61
+ E2E_DATABASE_URL e2e-only Neon URL, used without DATABASE_URL.
62
+ Combined mode also requires
63
+ MANAGED_DATABASE_URL; mixing Neon and
64
+ SQLite backends is rejected.
65
+ Either e2e URL selects Neon instead of
61
66
  SQLite. Requires the optional peer dep
62
67
  @neondatabase/serverless. The Neon
63
68
  pairing additionally requires
@@ -66,12 +71,12 @@ Environment:
66
71
  local-FS bytes cannot back a multi-
67
72
  replica DB plane.
68
73
  BLOB_READ_WRITE_TOKEN Vercel Blob R/W token (private store).
69
- Required when DATABASE_URL is set;
74
+ Required when an e2e database URL is set;
70
75
  ignored otherwise. Requires the optional
71
76
  peer dep @vercel/blob.
72
77
  OBJSTORE_TOKEN_SECRET Base64 (32 bytes) HMAC secret for REST
73
- bearer tokens. REQUIRED when DATABASE_URL
74
- is set (multi-replica deployments: a
78
+ bearer tokens. REQUIRED in Neon mode
79
+ (multi-replica deployments: a
75
80
  token minted on one replica's WS plane
76
81
  must validate on another replica's REST
77
82
  plane). Optional under SQLite (a fresh
@@ -81,16 +86,15 @@ Environment:
81
86
  OBJSTORE_DIR object store root (default: ./objstore
82
87
  next to DB_PATH). Used by the local-FS
83
88
  byte plane only; ignored when
84
- DATABASE_URL + BLOB_READ_WRITE_TOKEN
85
- are set (bytes live in Vercel Blob).
89
+ an e2e database URL selects Neon
90
+ (bytes live in Vercel Blob).
86
91
  OBJSTORE_REAP_INTERVAL_MS orphan reaper period (default 600000)
87
- OBJSTORE_REAP_DISABLED set '1' / 'true' to disable the orphan
88
- reaper ENTIRELY — no boot sweep, no
89
- periodic GC. Orphaned/superseded blobs and
90
- stale staging rows then accumulate
91
- unbounded; only set this if an external job
92
- handles GC (e.g. a cron calling reapOrphans).
93
- Default OFF (reaper runs).
92
+ OBJSTORE_REAP_DISABLED set '1' / 'true' to disable automatic orphan
93
+ sweeps (boot and periodic). Explicit /api/reap
94
+ remains available. Schedule cleanup externally
95
+ to avoid accumulating orphaned bytes.
96
+ CRON_SECRET bearer secret for GET /api/reap (fails closed
97
+ when unset). Runs cleanup for enabled modes.
94
98
  TRUST_PROXY set '1' / 'true' to honour X-Forwarded-
95
99
  Host / X-Forwarded-Proto when computing
96
100
  the same-origin gate's expected origin.
@@ -126,11 +130,11 @@ function readServerConfigFile(path: string): ServerConfigFile {
126
130
  let raw: string
127
131
  try { raw = readFileSync(path, 'utf8') } catch (err) {
128
132
  if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return {}
129
- console.error(`Failed to read ${path}:`, (err as Error)?.message ?? err); process.exit(1)
133
+ throw new Error(`Failed to read ${path}: ${(err as Error)?.message ?? err}`, { cause: err })
130
134
  }
131
135
  try { return JSON.parse(raw) as ServerConfigFile }
132
136
  catch (err) {
133
- console.error(`Failed to parse ${path} as JSON:`, (err as Error)?.message ?? err); process.exit(1)
137
+ throw new Error(`Failed to parse ${path} as JSON: ${(err as Error)?.message ?? err}`, { cause: err })
134
138
  }
135
139
  }
136
140
 
@@ -145,20 +149,17 @@ function decodeTokenSecret(raw: string): Uint8Array<ArrayBuffer> {
145
149
  // typo-detector below would fail with a misleading message.
146
150
  const trimmed = raw.trim()
147
151
  if (trimmed.length === 0) {
148
- console.error('OBJSTORE_TOKEN_SECRET is empty after trimming whitespace')
149
- process.exit(1)
152
+ throw new Error('OBJSTORE_TOKEN_SECRET is empty after trimming whitespace')
150
153
  }
151
154
  const decoded = Buffer.from(trimmed, 'base64')
152
155
  const reencoded = decoded.toString('base64')
153
156
  const norm = (s: string): string => s.replace(/=+$/u, '')
154
157
  if (norm(reencoded) !== norm(trimmed)) {
155
- console.error('OBJSTORE_TOKEN_SECRET contains non-base64 characters (likely a typo, e.g. base64url chars in a base64 secret).')
156
- console.error('Regenerate with: node -e \'console.log(require("crypto").randomBytes(32).toString("base64"))\'')
157
- process.exit(1)
158
+ throw new Error('OBJSTORE_TOKEN_SECRET contains non-base64 characters (likely a typo, e.g. base64url chars in a base64 secret).\n' +
159
+ 'Regenerate with: node -e \'console.log(require("crypto").randomBytes(32).toString("base64"))\'')
158
160
  }
159
161
  if (decoded.byteLength !== 32) {
160
- console.error(`OBJSTORE_TOKEN_SECRET must decode to 32 bytes (got ${decoded.byteLength})`)
161
- process.exit(1)
162
+ throw new Error(`OBJSTORE_TOKEN_SECRET must decode to 32 bytes (got ${decoded.byteLength})`)
162
163
  }
163
164
  // Copy into a fresh ArrayBuffer so the type matches
164
165
  // `Uint8Array<ArrayBuffer>` (Buffer may be SharedArrayBuffer-backed).
@@ -176,7 +177,7 @@ export function loadConfig(): Config {
176
177
  const objstoreDir = env['OBJSTORE_DIR'] ?? join(dirname(dbPath), 'objstore')
177
178
  // No practical upper bound beyond the safe-integer range.
178
179
  const reapIntervalMs = intEnv('OBJSTORE_REAP_INTERVAL_MS', 10 * 60 * 1000, 1, Number.MAX_SAFE_INTEGER)
179
- // Hard off-switch for the orphan reaper (both the boot sweep AND the
180
+ // Off-switch for automatic orphan reaping (both the boot sweep AND the
180
181
  // periodic timer). '1' / 'true' (case-insensitive) → disabled; anything
181
182
  // else, including unset, leaves it ON. Same boolean shape as TRUST_PROXY.
182
183
  const reapDisabledEnv = env['OBJSTORE_REAP_DISABLED']
@@ -187,7 +188,7 @@ export function loadConfig(): Config {
187
188
  const serverConfig = readServerConfigFile(configPath)
188
189
  const rawPassword = serverConfig.password
189
190
  if (rawPassword != null && typeof rawPassword !== 'string') {
190
- console.error(`Invalid ${configPath}: "password" must be a string or null`); process.exit(1)
191
+ throw new Error(`Invalid ${configPath}: "password" must be a string or null`)
191
192
  }
192
193
  const password = rawPassword ?? null
193
194
  // Upper bound 65_536 — bounds memory under hostile load; a deployer
@@ -196,12 +197,7 @@ export function loadConfig(): Config {
196
197
  // error-precedence order.
197
198
  const maxInflightPerSocket = intEnv('MAX_INFLIGHT_PER_SOCKET', 64, 1, 65_536)
198
199
 
199
- if (argv.includes('--help') || argv.includes('-h')) {
200
- console.log(HELP)
201
- process.exit(0)
202
- }
203
-
204
- const neonUrl = env['DATABASE_URL'] ?? null
200
+ const neonUrl = databaseUrls().e2e
205
201
  const blobToken = env['BLOB_READ_WRITE_TOKEN'] ?? null
206
202
  const tokenSecretB64 = env['OBJSTORE_TOKEN_SECRET'] ?? null
207
203
  const tokenSecret = tokenSecretB64 ? decodeTokenSecret(tokenSecretB64) : null
@@ -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-e2e/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
package/server-e2e/db.ts CHANGED
@@ -125,7 +125,7 @@ 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)`
128
+ // Handle is the operator's mistake to catch at the database URL
129
129
  // switch in `server-e2e/index.ts`.
130
130
  //
131
131
  // `tryCommit` is the backend-specific atomic-commit primitive
@@ -230,7 +230,19 @@ export function createHttpServer(deps: HttpServerDeps): Server {
230
230
  res.end(NOT_FOUND_BODY)
231
231
  })
232
232
 
233
+ installUpgradeHandler(httpServer, wss, isOriginAllowed)
234
+
235
+ return httpServer
236
+ }
237
+
238
+ function installUpgradeHandler(httpServer: Server, wss: WebSocketServer, isOriginAllowed: HttpServerDeps['isOriginAllowed']): void {
233
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
+ }
234
246
  // RFC 6455: the WS upgrade IS an HTTP request; reject with a normal
235
247
  // HTTP response so a misconfigured client sees the JSON body instead
236
248
  // of ECONNRESET. `socket.end(body)` flushes before sending FIN.
@@ -261,6 +273,4 @@ export function createHttpServer(deps: HttpServerDeps): Server {
261
273
  }
262
274
  wss.handleUpgrade(req, socket, head, (ws) => { wss.emit('connection', ws, req) })
263
275
  })
264
-
265
- return httpServer
266
276
  }