@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.
- package/LICENSE +21 -0
- package/common/save-error-reason.ts +53 -0
- package/common/utf8.d.ts +13 -0
- package/common/utf8.js +57 -0
- package/out/brotli-fallback.js +3 -0
- package/out/client-sync.js +15 -0
- package/out/graph.js +4 -0
- package/out/icon-maskable.svg +5 -0
- package/out/icon.svg +5 -0
- package/out/index.html +78 -0
- package/out/manifest.webmanifest +30 -0
- package/out/prism.js +14 -0
- package/out/terminal.js +39 -0
- package/out/view.css +1 -0
- package/out/view.html +12 -0
- package/out/view.js +138 -0
- package/package.json +129 -0
- package/server/auth.ts +99 -0
- package/server/config.example.json +3 -0
- package/server/config.ts +196 -0
- package/server/db-neon.ts +374 -0
- package/server/db-revision-sql.ts +152 -0
- package/server/db-stmt.ts +53 -0
- package/server/db.ts +577 -0
- package/server/http.ts +142 -0
- package/server/hub.ts +98 -0
- package/server/index.ts +353 -0
- package/server/lifecycle.ts +177 -0
- package/server/neon-driver.ts +26 -0
- package/server/objstore/blob-fs.ts +164 -0
- package/server/objstore/blob-vercel.ts +508 -0
- package/server/objstore/blob.ts +169 -0
- package/server/objstore/fs.ts +67 -0
- package/server/objstore/handlers.ts +235 -0
- package/server/objstore/init.ts +118 -0
- package/server/objstore/reaper.ts +199 -0
- package/server/objstore/rest.ts +484 -0
- package/server/objstore/sign.ts +164 -0
- package/server/objstore/store-neon.ts +351 -0
- package/server/objstore/store.ts +799 -0
- package/server/objstore/tokens.ts +168 -0
- package/server/origin.ts +68 -0
- package/server/peer.ts +38 -0
- package/server/sign.ts +231 -0
- package/server/static.ts +374 -0
- package/server/sync-handlers.ts +311 -0
- package/server/util.ts +27 -0
- package/server/validation.ts +36 -0
- 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
|
+
}
|
package/server/config.ts
ADDED
|
@@ -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
|
+
}
|