@preventive/triage 1.0.0-alpha.0

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 (49) hide show
  1. package/LICENSE +21 -0
  2. package/common/save-error-reason.ts +53 -0
  3. package/common/utf8.d.ts +13 -0
  4. package/common/utf8.js +57 -0
  5. package/out/brotli-fallback.js +3 -0
  6. package/out/client-sync.js +15 -0
  7. package/out/graph.js +4 -0
  8. package/out/icon-maskable.svg +5 -0
  9. package/out/icon.svg +5 -0
  10. package/out/index.html +78 -0
  11. package/out/manifest.webmanifest +30 -0
  12. package/out/prism.js +14 -0
  13. package/out/terminal.js +39 -0
  14. package/out/view.css +1 -0
  15. package/out/view.html +12 -0
  16. package/out/view.js +138 -0
  17. package/package.json +129 -0
  18. package/server/auth.ts +99 -0
  19. package/server/config.example.json +3 -0
  20. package/server/config.ts +196 -0
  21. package/server/db-neon.ts +374 -0
  22. package/server/db-revision-sql.ts +152 -0
  23. package/server/db-stmt.ts +53 -0
  24. package/server/db.ts +577 -0
  25. package/server/http.ts +142 -0
  26. package/server/hub.ts +98 -0
  27. package/server/index.ts +353 -0
  28. package/server/lifecycle.ts +177 -0
  29. package/server/neon-driver.ts +26 -0
  30. package/server/objstore/blob-fs.ts +164 -0
  31. package/server/objstore/blob-vercel.ts +508 -0
  32. package/server/objstore/blob.ts +169 -0
  33. package/server/objstore/fs.ts +67 -0
  34. package/server/objstore/handlers.ts +235 -0
  35. package/server/objstore/init.ts +118 -0
  36. package/server/objstore/reaper.ts +199 -0
  37. package/server/objstore/rest.ts +484 -0
  38. package/server/objstore/sign.ts +164 -0
  39. package/server/objstore/store-neon.ts +351 -0
  40. package/server/objstore/store.ts +799 -0
  41. package/server/objstore/tokens.ts +168 -0
  42. package/server/origin.ts +68 -0
  43. package/server/peer.ts +38 -0
  44. package/server/sign.ts +231 -0
  45. package/server/static.ts +374 -0
  46. package/server/sync-handlers.ts +311 -0
  47. package/server/util.ts +27 -0
  48. package/server/validation.ts +36 -0
  49. package/server/ws-server.ts +245 -0
package/package.json ADDED
@@ -0,0 +1,129 @@
1
+ {
2
+ "name": "@preventive/triage",
3
+ "version": "1.0.0-alpha.0",
4
+ "description": "Client & relay server for triaging of automated reports",
5
+ "license": "MIT",
6
+ "author": {
7
+ "name": "Nikita Skovoroda",
8
+ "email": "chalkerx@gmail.com",
9
+ "url": "https://github.com/ChALkeR"
10
+ },
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/PreventiveMeasures/triage.git"
14
+ },
15
+ "bugs": {
16
+ "url": "https://github.com/PreventiveMeasures/triage/issues"
17
+ },
18
+ "homepage": "https://github.com/PreventiveMeasures/triage",
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "type": "module",
23
+ "engines": {
24
+ "node": ">=24.14.0"
25
+ },
26
+ "imports": {
27
+ "#client/index.js": "./client/index.js",
28
+ "#client/sync.js": "./client/sync.js",
29
+ "#client/sync-host.js": "./client/sync-host.js"
30
+ },
31
+ "bin": {
32
+ "triage-server": "server/index.ts"
33
+ },
34
+ "files": [
35
+ "server/auth.ts",
36
+ "server/config.ts",
37
+ "server/db-neon.ts",
38
+ "server/db-revision-sql.ts",
39
+ "server/db-stmt.ts",
40
+ "server/db.ts",
41
+ "server/http.ts",
42
+ "server/hub.ts",
43
+ "server/index.ts",
44
+ "server/lifecycle.ts",
45
+ "server/neon-driver.ts",
46
+ "server/objstore/blob-fs.ts",
47
+ "server/objstore/blob-vercel.ts",
48
+ "server/objstore/blob.ts",
49
+ "server/objstore/fs.ts",
50
+ "server/objstore/handlers.ts",
51
+ "server/objstore/init.ts",
52
+ "server/objstore/reaper.ts",
53
+ "server/objstore/rest.ts",
54
+ "server/objstore/sign.ts",
55
+ "server/objstore/store-neon.ts",
56
+ "server/objstore/store.ts",
57
+ "server/objstore/tokens.ts",
58
+ "server/origin.ts",
59
+ "server/peer.ts",
60
+ "server/sign.ts",
61
+ "server/static.ts",
62
+ "server/sync-handlers.ts",
63
+ "server/util.ts",
64
+ "server/validation.ts",
65
+ "server/ws-server.ts",
66
+ "server/config.example.json",
67
+ "common/save-error-reason.ts",
68
+ "common/utf8.d.ts",
69
+ "common/utf8.js",
70
+ "out/brotli-fallback.js",
71
+ "out/client-sync.js",
72
+ "out/graph.js",
73
+ "out/icon-maskable.svg",
74
+ "out/icon.svg",
75
+ "out/index.html",
76
+ "out/manifest.webmanifest",
77
+ "out/prism.js",
78
+ "out/terminal.js",
79
+ "out/view.css",
80
+ "out/view.html",
81
+ "out/view.js"
82
+ ],
83
+ "scripts": {
84
+ "lint": "node --run lint:js && node --run lint:css && node --run lint:ts",
85
+ "lint:js": "oxlint -c .oxlintrc.json --ignore-pattern=ui/logs.js ./tests/ ./common/ ./server/ ./client/ ./ui/",
86
+ "lint:css": "stylelint 'ui/**/*.css'",
87
+ "lint:ts": "tsc --noEmit",
88
+ "prepack": "node --run build",
89
+ "build": "node build.js build",
90
+ "serve": "node build.js serve",
91
+ "server": "node server/index.ts",
92
+ "test": "node --js-base-64 --experimental-test-module-mocks --test ./tests/*.test.js"
93
+ },
94
+ "devDependencies": {
95
+ "@electric-sql/pglite": "^0.4.5",
96
+ "@exodus/stasis": "^1.0.0-alpha.1",
97
+ "@noble/ciphers": "^2.2.0",
98
+ "@preventive/terminal": "^1.2.2",
99
+ "@rray/frontend": "^1.0.0",
100
+ "@stylistic/stylelint-plugin": "^5.1.0",
101
+ "@types/node": "^25.6.2",
102
+ "@types/ws": "^8.18.1",
103
+ "brotli": "^1.3.3",
104
+ "esbuild": "^0.27.4",
105
+ "lit": "^3.3.2",
106
+ "minify-html-literals": "^1.3.5",
107
+ "oxlint": "^1.63.0",
108
+ "prismjs": "^1.30.0",
109
+ "stylelint": "^17.11.0",
110
+ "stylelint-config-recommended": "^18.0.0",
111
+ "typescript": "^6.0.3"
112
+ },
113
+ "dependencies": {
114
+ "ws": "^8.20.1"
115
+ },
116
+ "peerDependencies": {
117
+ "@neondatabase/serverless": "^1.0.2",
118
+ "@vercel/blob": "^2.3.3"
119
+ },
120
+ "peerDependenciesMeta": {
121
+ "@neondatabase/serverless": {
122
+ "optional": true
123
+ },
124
+ "@vercel/blob": {
125
+ "optional": true
126
+ }
127
+ },
128
+ "packageManager": "pnpm@10.33.4+sha512.1c67b3b359b2d408119ba1ed289f34b8fc3c6873412bec6fd264fbdc82489e510fcbecb9ce9d22dae7f3b76269d8441046014bdca53b9979cd7a561ad631b800"
129
+ }
package/server/auth.ts ADDED
@@ -0,0 +1,99 @@
1
+ // Operator-side password gate. Optionally gates the FIRST action
2
+ // against a never-before-seen workspace tag behind a shared password
3
+ // (config.json). Once a connection completes the `authenticate
4
+ // { password }` handshake its Peer is marked authorized and every
5
+ // subsequent first-action bypasses the gate; once a workspace exists
6
+ // on the server the gate is off for it regardless of connection.
7
+ //
8
+ // Comparison is HMAC-SHA-256 under a per-process random key derived
9
+ // here: the configured password is HMAC'd once at construction (the
10
+ // raw bytes aren't retained), and each `authenticate` HMACs the
11
+ // submitted password and compares with `timingSafeEqual`. Fixed
12
+ // 32-byte digests mean no length-equal precondition (no length leak),
13
+ // and any residual timing variance reveals only HMAC bytes useless
14
+ // without the per-process key. `null` configured HMAC = no gate.
15
+
16
+ import type { WebSocket } from 'ws'
17
+ import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'
18
+ import type { PeerRegistry } from './peer.ts'
19
+
20
+ // Server → Client `unauthorized` frame context. The explicit `kind`
21
+ // discriminator is the wire contract: callers MUST switch on it rather
22
+ // than infer from field presence. The client's wire dispatcher and the
23
+ // objstore client's put-begin recv predicate both pin on `kind`.
24
+ export type UnauthorizedContext =
25
+ | { kind: 'gated'; workspaceTag: string; base: string | null } // workspace-save gated
26
+ | { kind: 'gated'; workspaceTag: string; resourceTag: string } // objstore-put-begin gated
27
+ | { kind: 'auth-failed' } // authenticate-failed
28
+
29
+ export type AuthenticateMsg = { password?: unknown }
30
+
31
+ export type Auth = {
32
+ // True iff the gate is configured AND this socket hasn't authenticated.
33
+ requiresAuth(socket: WebSocket): boolean
34
+ handleAuthenticate(socket: WebSocket, msg: AuthenticateMsg): void
35
+ sendUnauthorized(socket: WebSocket, ctx: UnauthorizedContext): void
36
+ }
37
+
38
+ // Pre-shape gate: a submitted password must be a non-empty string,
39
+ // length-capped so a peer can't make us HMAC megabytes per frame
40
+ // (this handler is fast-inlined outside the per-socket inflight cap).
41
+ // 4096 is far above any real password, well below the 4 MiB frame cap.
42
+ const MAX_AUTH_PASSWORD_LEN = 4096
43
+
44
+ export function createAuth(deps: {
45
+ peers: PeerRegistry
46
+ password: string | null
47
+ send: (socket: WebSocket, msg: object) => void
48
+ debug: boolean
49
+ }): Auth {
50
+ const { peers, password, send, debug } = deps
51
+ const PASSWORD_HMAC_KEY = new Uint8Array(randomBytes(32))
52
+ // `null` is the "no gate" sentinel every check reads (absent / empty).
53
+ const CONFIGURED_PASSWORD_HMAC: Uint8Array<ArrayBuffer> | null =
54
+ password == null || password === ''
55
+ ? null
56
+ : new Uint8Array(createHmac('sha256', PASSWORD_HMAC_KEY).update(password, 'utf8').digest())
57
+
58
+ function isAuthorized(socket: WebSocket): boolean {
59
+ return peers.get(socket)?.authorized === true
60
+ }
61
+ function markAuthorized(socket: WebSocket): void {
62
+ const peer = peers.get(socket)
63
+ if (peer) peer.authorized = true
64
+ }
65
+ function requiresAuth(socket: WebSocket): boolean {
66
+ if (CONFIGURED_PASSWORD_HMAC == null) return false
67
+ return !isAuthorized(socket)
68
+ }
69
+ function passwordMatches(submitted: string): boolean {
70
+ if (CONFIGURED_PASSWORD_HMAC == null) return false
71
+ const submittedHmac = new Uint8Array(createHmac('sha256', PASSWORD_HMAC_KEY).update(submitted, 'utf8').digest())
72
+ return timingSafeEqual(submittedHmac, CONFIGURED_PASSWORD_HMAC)
73
+ }
74
+ function sendUnauthorized(socket: WebSocket, ctx: UnauthorizedContext): void {
75
+ send(socket, { type: 'unauthorized', ...ctx })
76
+ }
77
+ function handleAuthenticate(socket: WebSocket, msg: AuthenticateMsg): void {
78
+ if (typeof msg.password !== 'string' || msg.password.length === 0 || msg.password.length > MAX_AUTH_PASSWORD_LEN) return
79
+ // No-config short-circuit: when not gating, treat any authenticate
80
+ // as success so a client can cache + replay its password on
81
+ // reconnect even against an un-gated server (wire shape stays
82
+ // consistent).
83
+ if (CONFIGURED_PASSWORD_HMAC == null) {
84
+ markAuthorized(socket)
85
+ send(socket, { type: 'authenticated' })
86
+ return
87
+ }
88
+ if (!passwordMatches(msg.password)) {
89
+ if (debug) console.warn('authenticate: wrong password')
90
+ sendUnauthorized(socket, { kind: 'auth-failed' })
91
+ return
92
+ }
93
+ markAuthorized(socket)
94
+ if (debug) console.log('authenticate: success')
95
+ send(socket, { type: 'authenticated' })
96
+ }
97
+
98
+ return { requiresAuth, handleAuthenticate, sendUnauthorized }
99
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "password": null
3
+ }
@@ -0,0 +1,196 @@
1
+ // Boot configuration: parse + validate every external input (env
2
+ // vars, the optional config.json) into one immutable `Config`, failing
3
+ // loud on malformed values so a typo surfaces at startup rather than
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, …).
8
+
9
+ import { Buffer } from 'node:buffer'
10
+ import { readFileSync } from 'node:fs'
11
+ import { argv, env } from 'node:process'
12
+ import { fileURLToPath } from 'node:url'
13
+ import { dirname, join } from 'node:path'
14
+
15
+ export type Config = {
16
+ port: number
17
+ host: string
18
+ dbPath: string
19
+ objstoreDir: string
20
+ reapIntervalMs: number
21
+ maxInflightPerSocket: number
22
+ debug: boolean
23
+ neonUrl: string | null
24
+ blobToken: string | null
25
+ tokenSecret: Uint8Array<ArrayBuffer> | null
26
+ password: string | null
27
+ trustProxyEnv: string | undefined
28
+ }
29
+
30
+ // Parse + range-validate an integer env var, exiting with a clear
31
+ // up-front message on a malformed value — a NaN from `Number("abc")`
32
+ // otherwise surfaces as a confusing crash deep inside `node:net`
33
+ // (`WebSocketServer({ port: NaN })`) or a 0-ms `setInterval` loop. An
34
+ // absent var falls back to `def` (assumed in-range). One shape for
35
+ // every integer env var so they validate + fail identically; `hint`
36
+ // appends operator guidance (range meaning, default) to the error.
37
+ function intEnv(name: string, def: number, min: number, max: number, hint = ''): number {
38
+ const raw = env[name]
39
+ const n = raw == null ? def : Number(raw)
40
+ 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)
43
+ }
44
+ return n
45
+ }
46
+
47
+ const HELP = `Usage: node server/index.ts
48
+ Environment:
49
+ PORT listen port (default 8765)
50
+ 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
55
+ SQLite. Requires the optional peer dep
56
+ @neondatabase/serverless. The Neon
57
+ pairing additionally requires
58
+ BLOB_READ_WRITE_TOKEN (Vercel Blob
59
+ Private Storage) for the byte plane —
60
+ local-FS bytes cannot back a multi-
61
+ replica DB plane.
62
+ BLOB_READ_WRITE_TOKEN Vercel Blob R/W token (private store).
63
+ Required when DATABASE_URL is set;
64
+ ignored otherwise. Requires the optional
65
+ peer dep @vercel/blob.
66
+ OBJSTORE_TOKEN_SECRET Base64 (32 bytes) HMAC secret for REST
67
+ bearer tokens. REQUIRED when DATABASE_URL
68
+ is set (multi-replica deployments: a
69
+ token minted on one replica's WS plane
70
+ must validate on another replica's REST
71
+ plane). Optional under SQLite (a fresh
72
+ per-process secret is minted at boot).
73
+ Generate one with:
74
+ node -e 'console.log(crypto.randomBytes(32).toString("base64"))'
75
+ OBJSTORE_DIR object store root (default: ./objstore
76
+ next to DB_PATH). Used by the local-FS
77
+ byte plane only; ignored when
78
+ DATABASE_URL + BLOB_READ_WRITE_TOKEN
79
+ are set (bytes live in Vercel Blob).
80
+ OBJSTORE_REAP_INTERVAL_MS orphan reaper period (default 600000)
81
+ TRUST_PROXY set '1' / 'true' to honour X-Forwarded-
82
+ Host / X-Forwarded-Proto when computing
83
+ the same-origin gate's expected origin.
84
+ Default: ON when HOST is a loopback
85
+ (127.0.0.1, ::1, localhost) — the typical
86
+ "behind nginx on same host" deployment.
87
+ OFF for public binds (HOST=0.0.0.0 etc.)
88
+ where a bare X-Forwarded-* would
89
+ otherwise let an attacker page bypass
90
+ the gate.
91
+ MAX_INFLIGHT_PER_SOCKET per-socket in-flight async-handler
92
+ cap; saves dropped past this fire a
93
+ typed 'busy' workspace-save-error
94
+ NACK. Default 64. Lower for tests
95
+ that need to deterministically
96
+ exercise the cap.
97
+ CONFIG_PATH operator config JSON path (default:
98
+ server/config.json). Currently the
99
+ only field is { "password": "..." }
100
+ which gates first-action creation of
101
+ a new workspace on the
102
+ authenticate { password } handshake.
103
+ Missing file / null password →
104
+ no gating (default).
105
+ DEBUG=1 log every message`
106
+
107
+ type ServerConfigFile = { password?: string | null }
108
+
109
+ // Optional operator-side config file. Absence is silently fine
110
+ // (preserves the no-auth default). Parse errors fail loud so a typo
111
+ // doesn't silently fall back to "no auth required".
112
+ function readServerConfigFile(path: string): ServerConfigFile {
113
+ let raw: string
114
+ try { raw = readFileSync(path, 'utf8') } catch (err) {
115
+ if ((err as NodeJS.ErrnoException)?.code === 'ENOENT') return {}
116
+ console.error(`Failed to read ${path}:`, (err as Error)?.message ?? err); process.exit(1)
117
+ }
118
+ try { return JSON.parse(raw) as ServerConfigFile }
119
+ catch (err) {
120
+ console.error(`Failed to parse ${path} as JSON:`, (err as Error)?.message ?? err); process.exit(1)
121
+ }
122
+ }
123
+
124
+ // Decode + length-check the REST-token HMAC secret upfront so a
125
+ // misconfigured secret fails at boot, not at the first verification.
126
+ // `Buffer.from(s, 'base64')` does NOT throw on invalid input — it
127
+ // silently strips non-alphabet chars, so a typo like `+→-` decodes to
128
+ // a DIFFERENT secret. Detect by re-encoding and comparing (modulo `=`).
129
+ function decodeTokenSecret(raw: string): Uint8Array<ArrayBuffer> {
130
+ // Trim whitespace — a copy-pasted env value often ends in `\n` and
131
+ // Buffer.from(..., 'base64') would silently strip it, then the
132
+ // typo-detector below would fail with a misleading message.
133
+ const trimmed = raw.trim()
134
+ if (trimmed.length === 0) {
135
+ console.error('OBJSTORE_TOKEN_SECRET is empty after trimming whitespace')
136
+ process.exit(1)
137
+ }
138
+ const decoded = Buffer.from(trimmed, 'base64')
139
+ const reencoded = decoded.toString('base64')
140
+ const norm = (s: string): string => s.replace(/=+$/u, '')
141
+ 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)
145
+ }
146
+ if (decoded.byteLength !== 32) {
147
+ console.error(`OBJSTORE_TOKEN_SECRET must decode to 32 bytes (got ${decoded.byteLength})`)
148
+ process.exit(1)
149
+ }
150
+ // Copy into a fresh ArrayBuffer so the type matches
151
+ // `Uint8Array<ArrayBuffer>` (Buffer may be SharedArrayBuffer-backed).
152
+ return new Uint8Array(decoded)
153
+ }
154
+
155
+ export function loadConfig(): Config {
156
+ // 0 = OS-assigned ephemeral port (the test harness boots with PORT=0).
157
+ const port = intEnv('PORT', 8765, 0, 65535)
158
+ const host = env['HOST'] ?? '127.0.0.1'
159
+ // `fileURLToPath` decodes percent-escapes / non-ASCII path segments
160
+ // correctly (the older `new URL(...).pathname` left `%20` raw).
161
+ const dbPath = env['DB_PATH'] ?? fileURLToPath(new URL('./data/data.db', import.meta.url))
162
+ // `path.join` so a Windows DB_PATH doesn't get a mixed-separator child.
163
+ const objstoreDir = env['OBJSTORE_DIR'] ?? join(dirname(dbPath), 'objstore')
164
+ // No practical upper bound beyond the safe-integer range.
165
+ const reapIntervalMs = intEnv('OBJSTORE_REAP_INTERVAL_MS', 10 * 60 * 1000, 1, Number.MAX_SAFE_INTEGER)
166
+ const debug = env['DEBUG'] === '1'
167
+
168
+ const configPath = env['CONFIG_PATH'] ?? fileURLToPath(new URL('./config.json', import.meta.url))
169
+ const serverConfig = readServerConfigFile(configPath)
170
+ const rawPassword = serverConfig.password
171
+ if (rawPassword != null && typeof rawPassword !== 'string') {
172
+ console.error(`Invalid ${configPath}: "password" must be a string or null`); process.exit(1)
173
+ }
174
+ const password = rawPassword ?? null
175
+ // Upper bound 65_536 — bounds memory under hostile load; a deployer
176
+ // passing MAX_SAFE_INTEGER would silently defeat the cap. Validated
177
+ // here (after the config.json / password parse) to preserve the
178
+ // pre-split error-precedence order.
179
+ const maxInflightPerSocket = intEnv('MAX_INFLIGHT_PER_SOCKET', 64, 1, 65_536)
180
+
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
187
+ const blobToken = env['BLOB_READ_WRITE_TOKEN'] ?? null
188
+ const tokenSecretB64 = env['OBJSTORE_TOKEN_SECRET'] ?? null
189
+ const tokenSecret = tokenSecretB64 ? decodeTokenSecret(tokenSecretB64) : null
190
+
191
+ return {
192
+ port, host, dbPath, objstoreDir, reapIntervalMs, maxInflightPerSocket,
193
+ debug, neonUrl, blobToken, tokenSecret, password,
194
+ trustProxyEnv: env['TRUST_PROXY'],
195
+ }
196
+ }