@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.6
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/out/client-sync.js +14 -11
- package/out/graph.js +1 -1
- package/out/view.css +1 -1
- package/out/view.js +6 -6
- package/package.json +4 -1
- package/server/auth.ts +5 -1
- package/server/config.ts +14 -1
- package/server/http.ts +77 -7
- package/server/index.ts +12 -9
- package/server/lifecycle.ts +8 -4
- package/server/objstore/fetch-mint-guard.ts +74 -0
- package/server/objstore/init.ts +26 -1
- package/server/objstore/rest-deny.ts +28 -0
- package/server/objstore/rest-mint.ts +224 -0
- package/server/objstore/rest.ts +26 -25
- package/server/objstore/sign.ts +105 -0
- package/server/sse-server.ts +42 -35
- package/server/sse-session.ts +28 -8
- package/server/sync-handlers.ts +145 -91
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@preventive/triage",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.6",
|
|
4
4
|
"description": "Client & relay server for triaging of automated reports",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": {
|
|
@@ -54,10 +54,13 @@
|
|
|
54
54
|
"server/objstore/blob-fs.ts",
|
|
55
55
|
"server/objstore/blob-vercel.ts",
|
|
56
56
|
"server/objstore/blob.ts",
|
|
57
|
+
"server/objstore/fetch-mint-guard.ts",
|
|
57
58
|
"server/objstore/fs.ts",
|
|
58
59
|
"server/objstore/handlers.ts",
|
|
59
60
|
"server/objstore/init.ts",
|
|
60
61
|
"server/objstore/reaper.ts",
|
|
62
|
+
"server/objstore/rest-deny.ts",
|
|
63
|
+
"server/objstore/rest-mint.ts",
|
|
61
64
|
"server/objstore/rest.ts",
|
|
62
65
|
"server/objstore/sign.ts",
|
|
63
66
|
"server/objstore/store-neon.ts",
|
package/server/auth.ts
CHANGED
|
@@ -31,6 +31,10 @@ export type AuthenticateMsg = { password?: unknown }
|
|
|
31
31
|
export type Auth = {
|
|
32
32
|
// True iff the gate is configured AND this socket hasn't authenticated.
|
|
33
33
|
requiresAuth(socket: WebSocket): boolean
|
|
34
|
+
// Static "is a server password configured" flag — the socket-less
|
|
35
|
+
// predicate the REST put-begin gate needs (a REST request can never be
|
|
36
|
+
// operator-authorized, so `requiresAuth` collapses to this there).
|
|
37
|
+
passwordConfigured: boolean
|
|
34
38
|
handleAuthenticate(socket: WebSocket, msg: AuthenticateMsg): void
|
|
35
39
|
sendUnauthorized(socket: WebSocket, ctx: UnauthorizedContext): void
|
|
36
40
|
}
|
|
@@ -106,5 +110,5 @@ export function createAuth(deps: {
|
|
|
106
110
|
send(socket, { type: 'authenticated' })
|
|
107
111
|
}
|
|
108
112
|
|
|
109
|
-
return { requiresAuth, handleAuthenticate, sendUnauthorized }
|
|
113
|
+
return { requiresAuth, passwordConfigured: CONFIGURED_PASSWORD_HMAC != null, handleAuthenticate, sendUnauthorized }
|
|
110
114
|
}
|
package/server/config.ts
CHANGED
|
@@ -18,6 +18,7 @@ export type Config = {
|
|
|
18
18
|
dbPath: string
|
|
19
19
|
objstoreDir: string
|
|
20
20
|
reapIntervalMs: number
|
|
21
|
+
reapDisabled: boolean
|
|
21
22
|
maxInflightPerSocket: number
|
|
22
23
|
debug: boolean
|
|
23
24
|
neonUrl: string | null
|
|
@@ -78,6 +79,13 @@ Environment:
|
|
|
78
79
|
DATABASE_URL + BLOB_READ_WRITE_TOKEN
|
|
79
80
|
are set (bytes live in Vercel Blob).
|
|
80
81
|
OBJSTORE_REAP_INTERVAL_MS orphan reaper period (default 600000)
|
|
82
|
+
OBJSTORE_REAP_DISABLED set '1' / 'true' to disable the orphan
|
|
83
|
+
reaper ENTIRELY — no boot sweep, no
|
|
84
|
+
periodic GC. Orphaned/superseded blobs and
|
|
85
|
+
stale staging rows then accumulate
|
|
86
|
+
unbounded; only set this if an external job
|
|
87
|
+
handles GC (e.g. a cron calling reapOrphans).
|
|
88
|
+
Default OFF (reaper runs).
|
|
81
89
|
TRUST_PROXY set '1' / 'true' to honour X-Forwarded-
|
|
82
90
|
Host / X-Forwarded-Proto when computing
|
|
83
91
|
the same-origin gate's expected origin.
|
|
@@ -163,6 +171,11 @@ export function loadConfig(): Config {
|
|
|
163
171
|
const objstoreDir = env['OBJSTORE_DIR'] ?? join(dirname(dbPath), 'objstore')
|
|
164
172
|
// No practical upper bound beyond the safe-integer range.
|
|
165
173
|
const reapIntervalMs = intEnv('OBJSTORE_REAP_INTERVAL_MS', 10 * 60 * 1000, 1, Number.MAX_SAFE_INTEGER)
|
|
174
|
+
// Hard off-switch for the orphan reaper (both the boot sweep AND the
|
|
175
|
+
// periodic timer). '1' / 'true' (case-insensitive) → disabled; anything
|
|
176
|
+
// else, including unset, leaves it ON. Same boolean shape as TRUST_PROXY.
|
|
177
|
+
const reapDisabledEnv = env['OBJSTORE_REAP_DISABLED']
|
|
178
|
+
const reapDisabled = reapDisabledEnv === '1' || reapDisabledEnv?.toLowerCase() === 'true'
|
|
166
179
|
const debug = env['DEBUG'] === '1'
|
|
167
180
|
|
|
168
181
|
const configPath = env['CONFIG_PATH'] ?? fileURLToPath(new URL('./config.json', import.meta.url))
|
|
@@ -189,7 +202,7 @@ export function loadConfig(): Config {
|
|
|
189
202
|
const tokenSecret = tokenSecretB64 ? decodeTokenSecret(tokenSecretB64) : null
|
|
190
203
|
|
|
191
204
|
return {
|
|
192
|
-
port, host, dbPath, objstoreDir, reapIntervalMs, maxInflightPerSocket,
|
|
205
|
+
port, host, dbPath, objstoreDir, reapIntervalMs, reapDisabled, maxInflightPerSocket,
|
|
193
206
|
debug, neonUrl, blobToken, tokenSecret, password,
|
|
194
207
|
trustProxyEnv: env['TRUST_PROXY'],
|
|
195
208
|
}
|
package/server/http.ts
CHANGED
|
@@ -20,6 +20,12 @@ import { errStack } from './util.ts'
|
|
|
20
20
|
// similar) can route `/api/*` → this process and `/*` → the static UI
|
|
21
21
|
// bundle with a single location block.
|
|
22
22
|
export const WS_UPGRADE_PATH = '/api/sync'
|
|
23
|
+
// Session-independent triage-sync save plane: `POST /api/sync/save`. The
|
|
24
|
+
// SSE-mode alternative to the in-band `workspace-save` frame — a save POSTed
|
|
25
|
+
// here commits + broadcasts WITHOUT taking over the client's SSE event-stream
|
|
26
|
+
// (each in-band POST forces a stream takeover; see sse-server.ts). Sibling of
|
|
27
|
+
// the WS upgrade path so one nginx `/api/*` block routes both.
|
|
28
|
+
export const SAVE_REST_PATH = '/api/sync/save'
|
|
23
29
|
function isUpgradePath(url: string | undefined): boolean {
|
|
24
30
|
if (typeof url !== 'string') return false
|
|
25
31
|
// Strip `?…` so clients can carry build / debug tags. Exact match
|
|
@@ -42,12 +48,68 @@ export type HttpServerDeps = {
|
|
|
42
48
|
isOriginAllowed: (req: HasHeaders) => boolean
|
|
43
49
|
isShuttingDown: () => boolean
|
|
44
50
|
track: (promise: Promise<unknown>) => void
|
|
51
|
+
// Handler for `POST /api/sync/save` (see SAVE_REST_PATH). Owns body parse +
|
|
52
|
+
// the save pipeline + JSON response; this module owns the gates (method,
|
|
53
|
+
// same-origin, shutdown, idle-timeout) and the graceful-drain tracking.
|
|
54
|
+
handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
|
|
45
55
|
restPutIdleTimeoutMs: number
|
|
46
56
|
debug: boolean
|
|
47
57
|
}
|
|
48
58
|
|
|
59
|
+
// `POST /api/sync/save` dispatch. Returns the in-flight handler promise when
|
|
60
|
+
// the request matched the route (the caller `track`s it for graceful drain),
|
|
61
|
+
// or null when it's for a different route. The gate ladder — method →
|
|
62
|
+
// shutdown → same-origin → idle-timeout — mirrors the objstore REST branch; a
|
|
63
|
+
// gate rejection writes its own response and returns an already-settled
|
|
64
|
+
// promise. Kept out of `createHttpServer` so that dispatcher stays compact.
|
|
65
|
+
function dispatchSaveRest(
|
|
66
|
+
req: HttpRequest, res: ServerResponse,
|
|
67
|
+
deps: {
|
|
68
|
+
handleSaveRest: (req: HttpRequest, res: ServerResponse) => Promise<void>
|
|
69
|
+
isOriginAllowed: (req: HasHeaders) => boolean
|
|
70
|
+
isShuttingDown: () => boolean
|
|
71
|
+
restPutIdleTimeoutMs: number
|
|
72
|
+
debug: boolean
|
|
73
|
+
},
|
|
74
|
+
): Promise<void> | null {
|
|
75
|
+
if (typeof req.url !== 'string' || req.url.split('?', 1)[0] !== SAVE_REST_PATH) return null
|
|
76
|
+
if (req.method !== 'POST') {
|
|
77
|
+
res.writeHead(405, { 'content-type': 'application/json', 'allow': 'POST', 'connection': 'close' })
|
|
78
|
+
res.end(JSON.stringify({ error: 'method-not-allowed' }))
|
|
79
|
+
return Promise.resolve()
|
|
80
|
+
}
|
|
81
|
+
// Shutdown gate — parity with the objstore REST branch (a POST on an
|
|
82
|
+
// existing keep-alive socket after SIGTERM but before close() drains).
|
|
83
|
+
if (deps.isShuttingDown()) {
|
|
84
|
+
res.writeHead(503, { 'content-type': 'application/json', 'connection': 'close' })
|
|
85
|
+
res.end(JSON.stringify({ error: 'shutting-down' }))
|
|
86
|
+
return Promise.resolve()
|
|
87
|
+
}
|
|
88
|
+
// Same-origin gate — a hostile cross-origin page would carry an Origin
|
|
89
|
+
// header (browser-set on fetch); same-origin XHR may omit it (allowed).
|
|
90
|
+
if (!deps.isOriginAllowed(req)) {
|
|
91
|
+
res.writeHead(403, { 'content-type': 'application/json' })
|
|
92
|
+
res.end(JSON.stringify({ error: 'origin-denied' }))
|
|
93
|
+
return Promise.resolve()
|
|
94
|
+
}
|
|
95
|
+
// Idle-body timeout — a slow-loris trickling the JSON body would otherwise
|
|
96
|
+
// hold the connection indefinitely.
|
|
97
|
+
req.setTimeout(deps.restPutIdleTimeoutMs, () => {
|
|
98
|
+
if (deps.debug) console.warn(`sync-save REST idle ${deps.restPutIdleTimeoutMs}ms → abort`)
|
|
99
|
+
try { req.destroy(new Error('idle-timeout')) } catch {}
|
|
100
|
+
})
|
|
101
|
+
// Outer catch is the unhandled-rejection guard for a throw OUTSIDE the
|
|
102
|
+
// handler's own try/catch — logs and terminates the response so the TCP
|
|
103
|
+
// socket doesn't dangle (same policy as the objstore handleRest wrapper).
|
|
104
|
+
return deps.handleSaveRest(req, res).catch((err) => {
|
|
105
|
+
console.warn('sync-save REST handler error:', errStack(err))
|
|
106
|
+
if (res.headersSent) { try { res.destroy() } catch {} }
|
|
107
|
+
else { try { res.writeHead(500, { 'content-type': 'application/json' }); res.end(JSON.stringify({ error: 'internal' })) } catch {} }
|
|
108
|
+
})
|
|
109
|
+
}
|
|
110
|
+
|
|
49
111
|
export function createHttpServer(deps: HttpServerDeps): Server {
|
|
50
|
-
const { wss, restDeps, sseServer, isOriginAllowed, isShuttingDown, track, restPutIdleTimeoutMs, debug } = deps
|
|
112
|
+
const { wss, restDeps, sseServer, isOriginAllowed, isShuttingDown, track, handleSaveRest, restPutIdleTimeoutMs, debug } = deps
|
|
51
113
|
// Static-file plane (see ./static.ts). The directory is the
|
|
52
114
|
// `build.js build` output sibling to this file; the loader handles
|
|
53
115
|
// enumeration, pre-compression, and ETag derivation. Plugged in after
|
|
@@ -76,6 +138,12 @@ export function createHttpServer(deps: HttpServerDeps): Server {
|
|
|
76
138
|
// the lifecycle's sseSessions() close loop.
|
|
77
139
|
if (sseServer.handle(req, res)) return
|
|
78
140
|
}
|
|
141
|
+
// Triage-sync save REST plane (see SAVE_REST_PATH) — session-independent
|
|
142
|
+
// `POST /api/sync/save` so an SSE-mode save doesn't take over the
|
|
143
|
+
// event-stream. The dispatch helper owns the gate ladder; we `track` the
|
|
144
|
+
// returned in-flight promise so SIGTERM drains it (mirrors npm-advisories).
|
|
145
|
+
const saveP = dispatchSaveRest(req, res, { handleSaveRest, isOriginAllowed, isShuttingDown, restPutIdleTimeoutMs, debug })
|
|
146
|
+
if (saveP) { track(saveP); return }
|
|
79
147
|
// npm advisories proxy — same-origin + shutdown gates live in the
|
|
80
148
|
// helper so this dispatcher stays compact. `dispatchNpmAdvisories`
|
|
81
149
|
// returns the in-flight promise (or null when the route didn't
|
|
@@ -109,14 +177,16 @@ export function createHttpServer(deps: HttpServerDeps): Server {
|
|
|
109
177
|
res.end(JSON.stringify({ error: 'origin-denied' }))
|
|
110
178
|
return
|
|
111
179
|
}
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
// inFlightSids slot indefinitely.
|
|
115
|
-
//
|
|
180
|
+
// Idle-body timeout for the body-bearing REST methods — a slow-loris
|
|
181
|
+
// client trickling bytes holds the connection (and, for PUT, the
|
|
182
|
+
// staging fd + inFlightSids slot) indefinitely. PUT carries the raw
|
|
183
|
+
// blob within its declared Content-Length; POST carries the small
|
|
184
|
+
// fetch-mint JSON body. `req.setTimeout` fires on inactivity; we
|
|
185
|
+
// destroy the request, aborting the body pipeline.
|
|
116
186
|
// Transport audit `server/objstore/rest.ts:218`.
|
|
117
|
-
if (req.method === 'PUT') {
|
|
187
|
+
if (req.method === 'PUT' || req.method === 'POST') {
|
|
118
188
|
req.setTimeout(restPutIdleTimeoutMs, () => {
|
|
119
|
-
if (debug) console.warn(`REST
|
|
189
|
+
if (debug) console.warn(`REST ${req.method} idle ${restPutIdleTimeoutMs}ms → abort`)
|
|
120
190
|
try { req.destroy(new Error('idle-timeout')) } catch {}
|
|
121
191
|
})
|
|
122
192
|
}
|
package/server/index.ts
CHANGED
|
@@ -102,7 +102,7 @@ import { createBusReceiver } from './bus-receiver.ts'
|
|
|
102
102
|
const config = loadConfig()
|
|
103
103
|
const {
|
|
104
104
|
port: PORT, host: HOST, dbPath: DB_PATH, objstoreDir: OBJSTORE_DIR,
|
|
105
|
-
reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS,
|
|
105
|
+
reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS, reapDisabled: OBJSTORE_REAP_DISABLED,
|
|
106
106
|
maxInflightPerSocket: MAX_INFLIGHT_PER_SOCKET, debug: DEBUG,
|
|
107
107
|
neonUrl: NEON_URL, blobToken: BLOB_TOKEN, tokenSecret: TOKEN_SECRET,
|
|
108
108
|
password: CONFIG_PASSWORD, trustProxyEnv: TRUST_PROXY_ENV,
|
|
@@ -275,7 +275,7 @@ if (NEON_URL) {
|
|
|
275
275
|
// Password gate (see ./auth.ts) — HMAC derivation + the `authenticate`
|
|
276
276
|
// handshake.
|
|
277
277
|
const auth = createAuth({ peers, password: CONFIG_PASSWORD, send, debug: DEBUG })
|
|
278
|
-
const { requiresAuth, handleAuthenticate, sendUnauthorized } = auth
|
|
278
|
+
const { requiresAuth, passwordConfigured, handleAuthenticate, sendUnauthorized } = auth
|
|
279
279
|
|
|
280
280
|
// Triage-sync protocol handlers (see ./sync-handlers.ts). `getNonce`
|
|
281
281
|
// resolves a socket's challenge nonce and is shared with the objstore
|
|
@@ -292,9 +292,9 @@ const publishObjDeleted = (tag: string, resourceTag: string, version: number): v
|
|
|
292
292
|
pubsub.publish({ kind: 'objdel', tag, res: resourceTag, ver: version })
|
|
293
293
|
}
|
|
294
294
|
|
|
295
|
-
const { handleSave, handleSubscribe, sendSaveError } = createSyncHandlers({
|
|
295
|
+
const { handleSave, handleSaveRest, handleSubscribe, sendSaveError } = createSyncHandlers({
|
|
296
296
|
handle, send, broadcast, publishRevision, subscribe, getNonce,
|
|
297
|
-
requiresAuth, sendUnauthorized, workspaceExists,
|
|
297
|
+
requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists,
|
|
298
298
|
// Folds the objstore inventory into the `workspace-subscribed` ack.
|
|
299
299
|
// The objstore store keeps its own richer `Handle`, so we wire the
|
|
300
300
|
// query here where both handles exist rather than coupling
|
|
@@ -305,6 +305,7 @@ const { handleSave, handleSubscribe, sendSaveError } = createSyncHandlers({
|
|
|
305
305
|
|
|
306
306
|
const { handlers: objstore, restDeps: objstoreRestDeps, startupReap, stopReaper } = initObjstore({
|
|
307
307
|
handle: objstoreHandle, reapIntervalMs: OBJSTORE_REAP_INTERVAL_MS,
|
|
308
|
+
reapDisabled: OBJSTORE_REAP_DISABLED,
|
|
308
309
|
send, broadcast, publishObjPut, publishObjDeleted,
|
|
309
310
|
getNonce, debug: DEBUG,
|
|
310
311
|
// Auth gate for the FIRST objstore-put-begin against a workspace
|
|
@@ -315,6 +316,11 @@ const { handlers: objstore, restDeps: objstoreRestDeps, startupReap, stopReaper
|
|
|
315
316
|
// `unauthorized` frame and bails on `true`.
|
|
316
317
|
authGate: async (socket, tag) => requiresAuth(socket) && !await workspaceExists(tag),
|
|
317
318
|
sendUnauthorized,
|
|
319
|
+
// Socket-less analog of `authGate` for the REST put-begin mint: a REST
|
|
320
|
+
// request can never be operator-authorized, so the gate collapses to
|
|
321
|
+
// "password configured AND workspace new". A deny routes the client to
|
|
322
|
+
// its in-band WS put-begin fallback.
|
|
323
|
+
restPutGate: async (tag) => passwordConfigured && !await workspaceExists(tag),
|
|
318
324
|
// `tokenSecret` is set only when OBJSTORE_TOKEN_SECRET was
|
|
319
325
|
// provided in env (see TOKEN_SECRET resolution above). Omitted
|
|
320
326
|
// → initObjstore mints a fresh per-process secret (fine for
|
|
@@ -361,10 +367,6 @@ const sseServer = installSseServer({
|
|
|
361
367
|
// Mirror the WS `maxPayload` so the SSE plane can't accept frames
|
|
362
368
|
// the WS plane would reject.
|
|
363
369
|
maxBodyBytes: 4 * 1024 * 1024,
|
|
364
|
-
// 90s idle ceiling. The client's JSON ping/pong is 15s; this is ~6
|
|
365
|
-
// missed pings before we close — well past any transient network
|
|
366
|
-
// hiccup, much shorter than the kernel's hours-long TCP keepalive.
|
|
367
|
-
sessionIdleMs: 90_000,
|
|
368
370
|
debug: DEBUG,
|
|
369
371
|
})
|
|
370
372
|
|
|
@@ -375,7 +377,7 @@ const sseServer = installSseServer({
|
|
|
375
377
|
// below.
|
|
376
378
|
const httpServer = createHttpServer({
|
|
377
379
|
wss, restDeps: objstoreRestDeps, sseServer, isOriginAllowed,
|
|
378
|
-
isShuttingDown, track,
|
|
380
|
+
isShuttingDown, track, handleSaveRest,
|
|
379
381
|
restPutIdleTimeoutMs: REST_PUT_IDLE_TIMEOUT_MS, debug: DEBUG,
|
|
380
382
|
})
|
|
381
383
|
|
|
@@ -444,6 +446,7 @@ const closeDb = async (): Promise<void> => {
|
|
|
444
446
|
installLifecycle({
|
|
445
447
|
httpServer, wss, heartbeatTimer, stopReaper,
|
|
446
448
|
sseSessions: sseServer.sessions,
|
|
449
|
+
sseKeepaliveTimer: sseServer.keepaliveTimer,
|
|
447
450
|
closeDb,
|
|
448
451
|
})
|
|
449
452
|
|
package/server/lifecycle.ts
CHANGED
|
@@ -14,6 +14,10 @@ export type ShutdownDeps = {
|
|
|
14
14
|
httpServer: Server
|
|
15
15
|
wss: WebSocketServer
|
|
16
16
|
heartbeatTimer: ReturnType<typeof setInterval>
|
|
17
|
+
// The SSE keepalive-sweep timer (server/sse-server.ts). Cleared on
|
|
18
|
+
// shutdown alongside `heartbeatTimer` so a tick can't write a keepalive
|
|
19
|
+
// comment to a session the close loop below is already tearing down.
|
|
20
|
+
sseKeepaliveTimer: ReturnType<typeof setInterval>
|
|
17
21
|
// Stops the periodic reaper AND awaits any in-flight sweep.
|
|
18
22
|
stopReaper: () => Promise<void>
|
|
19
23
|
// Live SSE+POST session iterator (mirrors `wss.clients` for the SSE
|
|
@@ -62,7 +66,7 @@ export function createLifecycle(): Lifecycle {
|
|
|
62
66
|
let pendingExitCode = 0
|
|
63
67
|
|
|
64
68
|
function install(deps: ShutdownDeps): void {
|
|
65
|
-
const { httpServer, wss, heartbeatTimer, stopReaper, sseSessions, closeDb } = deps
|
|
69
|
+
const { httpServer, wss, heartbeatTimer, sseKeepaliveTimer, stopReaper, sseSessions, closeDb } = deps
|
|
66
70
|
|
|
67
71
|
async function shutdown(exitCode: number = 0): Promise<void> {
|
|
68
72
|
// Re-entry: don't restart the teardown, but escalate the pending
|
|
@@ -77,9 +81,9 @@ export function createLifecycle(): Lifecycle {
|
|
|
77
81
|
shuttingDown = true
|
|
78
82
|
pendingExitCode = exitCode
|
|
79
83
|
console.log('Shutting down…')
|
|
80
|
-
// Stop
|
|
81
|
-
//
|
|
82
|
-
|
|
84
|
+
// Stop both periodic timers (WS heartbeat + SSE keepalive) so neither
|
|
85
|
+
// fires mid-shutdown against a peer the close-loop is tearing down.
|
|
86
|
+
for (const timer of [heartbeatTimer, sseKeepaliveTimer]) clearInterval(timer)
|
|
83
87
|
// Send a 1001 (going away) close frame to every open socket BEFORE
|
|
84
88
|
// shutting the listener. Lets clients distinguish a server-initiated
|
|
85
89
|
// graceful shutdown from a network drop, so they can skip their
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// Anti-replay guard for the REST fetch-mint endpoint (POST
|
|
2
|
+
// /api/objstore/{tag}/{res}, see ./rest.ts). The WS fetch handshake binds
|
|
3
|
+
// the per-connection challenge nonce so a captured frame can't be
|
|
4
|
+
// replayed; the REST mint has no connection, so it binds a client
|
|
5
|
+
// timestamp instead and this guard supplies the matching freshness +
|
|
6
|
+
// dedup the nonce gave for free:
|
|
7
|
+
//
|
|
8
|
+
// - FRESHNESS: reject a request whose `ts` is outside ±`windowMs` of
|
|
9
|
+
// server time (an old captured request, or a future-dated one).
|
|
10
|
+
// - DEDUP: within the window, a signature is accepted at most once — a
|
|
11
|
+
// captured-and-replayed request (same `ts` ⇒ same signature) is
|
|
12
|
+
// rejected on the second presentation.
|
|
13
|
+
//
|
|
14
|
+
// Memory: each accepted signature is held for `windowMs` then pruned.
|
|
15
|
+
// Entries share a uniform TTL and the Map preserves insertion order, so
|
|
16
|
+
// the oldest entries expire first and a cheap front-prune (amortised
|
|
17
|
+
// O(1) per entry) keeps the set bounded; a hard `maxEntries` cap drops
|
|
18
|
+
// the oldest beyond it as a flood backstop.
|
|
19
|
+
//
|
|
20
|
+
// Scope: per-process. In a multi-replica deployment a captured request
|
|
21
|
+
// could be replayed once per replica that hasn't seen its signature yet
|
|
22
|
+
// (bounded by the replica count, within the window) — acceptable because
|
|
23
|
+
// the mint only ever yields a short-TTL GET token over AEAD ciphertext
|
|
24
|
+
// the relay can't read. A shared-store dedup (Redis/Neon) would close
|
|
25
|
+
// that residual gap if ever needed.
|
|
26
|
+
|
|
27
|
+
export type FetchMintVerdict = 'ok' | 'stale' | 'replay'
|
|
28
|
+
|
|
29
|
+
export type FetchMintGuard = {
|
|
30
|
+
// `signature` is the request's Ed25519 signature (unique per
|
|
31
|
+
// (tag, res, ts) tuple, so it doubles as the dedup key). `ts` is the
|
|
32
|
+
// client epoch-ms timestamp the signature commits to; `now` is the
|
|
33
|
+
// server clock (injectable for tests).
|
|
34
|
+
admit: (signature: string, ts: number, now?: number) => FetchMintVerdict
|
|
35
|
+
size: () => number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_FETCH_MINT_WINDOW_MS = 60_000
|
|
39
|
+
export const DEFAULT_FETCH_MINT_MAX_ENTRIES = 50_000
|
|
40
|
+
|
|
41
|
+
export function createFetchMintGuard(
|
|
42
|
+
{ windowMs = DEFAULT_FETCH_MINT_WINDOW_MS, maxEntries = DEFAULT_FETCH_MINT_MAX_ENTRIES }:
|
|
43
|
+
{ windowMs?: number; maxEntries?: number } = {},
|
|
44
|
+
): FetchMintGuard {
|
|
45
|
+
// signature → expiry (ms). Insertion-ordered; uniform TTL ⇒ the head is
|
|
46
|
+
// always the soonest to expire.
|
|
47
|
+
const seen = new Map<string, number>()
|
|
48
|
+
|
|
49
|
+
function admit(signature: string, ts: number, now: number = Date.now()): FetchMintVerdict {
|
|
50
|
+
// Freshness first — a stale/future request never touches the cache, so
|
|
51
|
+
// it can't be used to grow the set.
|
|
52
|
+
if (!Number.isFinite(ts) || Math.abs(now - ts) > windowMs) return 'stale'
|
|
53
|
+
// Front-prune expired entries (contiguous at the head under the
|
|
54
|
+
// uniform TTL). Breaks at the first live entry.
|
|
55
|
+
for (const [key, exp] of seen) {
|
|
56
|
+
if (exp > now) break
|
|
57
|
+
seen.delete(key)
|
|
58
|
+
}
|
|
59
|
+
// Post-prune, any remaining entry is live, so a hit is a genuine replay.
|
|
60
|
+
if (seen.has(signature)) return 'replay'
|
|
61
|
+
seen.set(signature, now + windowMs)
|
|
62
|
+
// Flood backstop: drop the oldest beyond the cap. Those are the
|
|
63
|
+
// closest to expiry anyway; dropping them only shortens their dedup
|
|
64
|
+
// window (still freshness-gated).
|
|
65
|
+
while (seen.size > maxEntries) {
|
|
66
|
+
const oldest = seen.keys().next().value
|
|
67
|
+
if (oldest === undefined) break
|
|
68
|
+
seen.delete(oldest)
|
|
69
|
+
}
|
|
70
|
+
return 'ok'
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { admit, size: () => seen.size }
|
|
74
|
+
}
|
package/server/objstore/init.ts
CHANGED
|
@@ -18,6 +18,12 @@ export type ObjstoreInitDeps = {
|
|
|
18
18
|
// the workspace_revision handle in server/db.ts.
|
|
19
19
|
handle: Handle
|
|
20
20
|
reapIntervalMs: number
|
|
21
|
+
// Hard off-switch (OBJSTORE_REAP_DISABLED). When true, NEITHER the boot
|
|
22
|
+
// sweep nor the periodic timer runs: `startupReap` resolves immediately
|
|
23
|
+
// and `stopReaper` is a no-op. Orphaned/superseded blobs and stale
|
|
24
|
+
// staging rows then accumulate unbounded — only safe if an external job
|
|
25
|
+
// handles GC. Omitted/false → reaper runs (the default). See config.ts.
|
|
26
|
+
reapDisabled?: boolean
|
|
21
27
|
send: (socket: WebSocket, msg: object) => void
|
|
22
28
|
broadcast: (tag: string, msg: object, except: WebSocket | null) => void
|
|
23
29
|
// Cross-instance pub/sub publishers. SQLite mode passes no-ops; Neon
|
|
@@ -42,6 +48,12 @@ export type ObjstoreInitDeps = {
|
|
|
42
48
|
// is not load-balancer-pinned). See server/index.ts boot logic
|
|
43
49
|
// for the env var (`OBJSTORE_TOKEN_SECRET`).
|
|
44
50
|
tokenSecret?: TokenSecret
|
|
51
|
+
// New-workspace operator gate for the REST put-begin mint — returns
|
|
52
|
+
// `true` to DENY (password configured AND workspace new). The
|
|
53
|
+
// connection-independent analog of `authGate`; the client falls back to
|
|
54
|
+
// the in-band WS put-begin on a deny. Omitted → open (never deny),
|
|
55
|
+
// matching `authGate`'s no-config default.
|
|
56
|
+
restPutGate?: (workspaceTag: string) => Promise<boolean>
|
|
45
57
|
}
|
|
46
58
|
|
|
47
59
|
export type ObjstoreInit = {
|
|
@@ -77,7 +89,10 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
|
|
|
77
89
|
...(deps.sendUnauthorized ? { sendUnauthorized: deps.sendUnauthorized } : {}),
|
|
78
90
|
})
|
|
79
91
|
const restDeps: ObjstoreRestDeps = {
|
|
80
|
-
handle, secret, broadcast: deps.broadcast,
|
|
92
|
+
handle, secret, broadcast: deps.broadcast,
|
|
93
|
+
publishObjPut: deps.publishObjPut, publishObjDeleted: deps.publishObjDeleted,
|
|
94
|
+
restPutGate: deps.restPutGate ?? (() => Promise.resolve(false)),
|
|
95
|
+
debug: deps.debug,
|
|
81
96
|
}
|
|
82
97
|
// Re-entrancy guard for periodic + startup sweeps. Kicking the
|
|
83
98
|
// startup sweep through the same `enqueueSweep` path means the
|
|
@@ -98,6 +113,16 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
|
|
|
98
113
|
inFlight = p
|
|
99
114
|
return p
|
|
100
115
|
}
|
|
116
|
+
// Hard off-switch (OBJSTORE_REAP_DISABLED). Skip the boot sweep AND the
|
|
117
|
+
// periodic timer entirely: `startupReap` resolves immediately so the
|
|
118
|
+
// index.ts `await startupReap` gate is a no-op, and `stopReaper` has
|
|
119
|
+
// nothing to clear or drain. Loud, unconditional warning — with GC off,
|
|
120
|
+
// orphaned/superseded blobs and stale staging rows are never reclaimed
|
|
121
|
+
// (they accumulate until an external job, if any, collects them).
|
|
122
|
+
if (deps.reapDisabled) {
|
|
123
|
+
console.warn('objstore: reaper DISABLED (OBJSTORE_REAP_DISABLED) — orphaned/superseded blobs and stale staging will NOT be reclaimed')
|
|
124
|
+
return { handlers, restDeps, startupReap: Promise.resolve(), stopReaper: async () => {} }
|
|
125
|
+
}
|
|
101
126
|
// Caller awaits this before accepting traffic so a fresh boot
|
|
102
127
|
// can't hand out list / fetch / put-begin against a tag whose
|
|
103
128
|
// on-disk state still has stranded files from a prior crash.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Shared failure-response helpers for the objstore REST plane
|
|
2
|
+
// (./rest.ts + ./rest-mint.ts). A uniform `{ error: <reason> }` JSON
|
|
3
|
+
// envelope so clients parse one shape; the 409 variant additionally
|
|
4
|
+
// carries the live version/incarnation so a PUT / put-begin can rebase.
|
|
5
|
+
// Reasons (400/401/403/404/405/409/410/411/500/503) are documented in
|
|
6
|
+
// server/README.md and the client decides recovery from the code —
|
|
7
|
+
// probe-distinguishing defense isn't a goal: the richer PUT/GET reasons are
|
|
8
|
+
// reachable only after the bearer-token check, while the public surfaces (an
|
|
9
|
+
// unauthed GET/PUT, or the signature-authed POST mint) expose only
|
|
10
|
+
// 400/401/404 — none of which gives a probe signal it couldn't already
|
|
11
|
+
// enumerate.
|
|
12
|
+
|
|
13
|
+
import type { ServerResponse } from 'node:http'
|
|
14
|
+
|
|
15
|
+
export function deny(res: ServerResponse, status: number, body: string): void {
|
|
16
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
17
|
+
res.end(JSON.stringify({ error: body }))
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Variant of `deny` that augments the envelope with the live row's
|
|
21
|
+
// `currentVersion` + `currentIncarnation` so a 409'd PUT / put-begin can
|
|
22
|
+
// rebase onto the right precondition token rather than retry blindly
|
|
23
|
+
// against an occupied slot. Symmetric with the WS plane's
|
|
24
|
+
// `objstore-conflict` frame.
|
|
25
|
+
export function denyConflict(res: ServerResponse, currentVersion: number | null, currentIncarnation: string | null): void {
|
|
26
|
+
res.writeHead(409, { 'content-type': 'application/json' })
|
|
27
|
+
res.end(JSON.stringify({ error: 'conflict', currentVersion, currentIncarnation }))
|
|
28
|
+
}
|