@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.5
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 +1 -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/init.ts +26 -1
- 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
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
|
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.
|
package/server/objstore/rest.ts
CHANGED
|
@@ -41,6 +41,8 @@ import {
|
|
|
41
41
|
} from './store.ts'
|
|
42
42
|
import type { LiveReader } from './blob.ts'
|
|
43
43
|
import { type TokenSecret, extractBearer, verifyToken } from './tokens.ts'
|
|
44
|
+
import { deny, denyConflict } from './rest-deny.ts'
|
|
45
|
+
import { handleRestMint } from './rest-mint.ts'
|
|
44
46
|
import { debugId, debugTag, errMsg, errStack } from '../util.ts'
|
|
45
47
|
|
|
46
48
|
// Server-side fault codes that should surface as 500 `io-error`
|
|
@@ -66,6 +68,18 @@ export type ObjstoreRestDeps = {
|
|
|
66
68
|
// workspace_object for the full metadata (version, hash, length,
|
|
67
69
|
// signature). SQLite mode passes a no-op.
|
|
68
70
|
publishObjPut: (tag: string, resourceTag: string) => void
|
|
71
|
+
// Cross-instance pub/sub for objstore-deleted — fired alongside the local
|
|
72
|
+
// `broadcast` after a successful REST delete mint, so peers on OTHER
|
|
73
|
+
// instances drop the resource in real time. Carries (tag, resourceTag,
|
|
74
|
+
// version) inline (the row is gone post-delete). SQLite mode passes a no-op.
|
|
75
|
+
publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
|
|
76
|
+
// New-workspace operator gate for the REST put-begin mint — the
|
|
77
|
+
// connection-independent analog of the WS path's `authGate`. Returns
|
|
78
|
+
// `true` to DENY (a password is configured AND the workspace is
|
|
79
|
+
// never-before-seen), which routes the client to its in-band WS
|
|
80
|
+
// put-begin fallback (REST has no socket auth state to consult).
|
|
81
|
+
// No-config default is open (never deny), matching the WS authGate.
|
|
82
|
+
restPutGate: (workspaceTag: string) => Promise<boolean>
|
|
69
83
|
debug: boolean
|
|
70
84
|
}
|
|
71
85
|
|
|
@@ -116,34 +130,21 @@ export function matchRoute(url: string | undefined): RouteMatch | null {
|
|
|
116
130
|
return { tag: tag!, resourceTag: resourceTag! }
|
|
117
131
|
}
|
|
118
132
|
|
|
119
|
-
function deny(res: ServerResponse, status: number, body: string): void {
|
|
120
|
-
// Uniform `{ error: <reason> }` JSON envelope for every failure so
|
|
121
|
-
// clients parse one shape. Status + reason are NOT intentionally
|
|
122
|
-
// indistinguishable across causes — 401/404/405/410/411/500 each map
|
|
123
|
-
// to a documented reason in server/README.md and the client decides
|
|
124
|
-
// recovery from the code. Probe-distinguishing defense isn't a goal:
|
|
125
|
-
// every reason is reachable only after the route + bearer-token check
|
|
126
|
-
// passes (or as 401/404 from the public surface), so a probe gains no
|
|
127
|
-
// signal it couldn't otherwise enumerate.
|
|
128
|
-
res.writeHead(status, { 'content-type': 'application/json' })
|
|
129
|
-
res.end(JSON.stringify({ error: body }))
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// Variant of `deny` that augments the JSON envelope with the live
|
|
133
|
-
// row's `currentVersion` + `currentIncarnation` so a REST PUT 409 lets
|
|
134
|
-
// the caller rebase onto the right precondition token. Without this the
|
|
135
|
-
// client only learns the slot is occupied — not at what (version,
|
|
136
|
-
// incarnation) — and retries blindly against a non-empty slot, looping
|
|
137
|
-
// indefinitely against a live row. Symmetric with the WS plane's
|
|
138
|
-
// `objstore-conflict` envelope.
|
|
139
|
-
function denyConflict(res: ServerResponse, currentVersion: number | null, currentIncarnation: string | null): void {
|
|
140
|
-
res.writeHead(409, { 'content-type': 'application/json' })
|
|
141
|
-
res.end(JSON.stringify({ error: 'conflict', currentVersion, currentIncarnation }))
|
|
142
|
-
}
|
|
143
|
-
|
|
144
133
|
export async function handleRest(deps: ObjstoreRestDeps, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
145
134
|
const route = matchRoute(req.url)
|
|
146
135
|
if (!route) { deny(res, 404, 'not-found'); return }
|
|
136
|
+
// POST = REST mint (fetch or put-begin, by body `op`; signature-authed
|
|
137
|
+
// via the JSON body, no bearer token). Dispatched before the bearer-token
|
|
138
|
+
// gate below, which guards the token-authed GET/PUT byte transfers.
|
|
139
|
+
if (req.method === 'POST') {
|
|
140
|
+
try { await handleRestMint(deps, req, res, route) }
|
|
141
|
+
catch (err: unknown) {
|
|
142
|
+
if (deps.debug) console.warn('objstore POST error:', errStack(err))
|
|
143
|
+
if (res.headersSent) res.destroy()
|
|
144
|
+
else deny(res, 500, 'internal')
|
|
145
|
+
}
|
|
146
|
+
return
|
|
147
|
+
}
|
|
147
148
|
const token = extractBearer(req.headers['authorization'])
|
|
148
149
|
if (!token) { deny(res, 401, 'unauthorized'); return }
|
|
149
150
|
const payload = verifyToken(deps.secret, token)
|
package/server/objstore/sign.ts
CHANGED
|
@@ -10,6 +10,18 @@ import { isValidIncarnation } from './store.ts'
|
|
|
10
10
|
const OBJSTORE_PUT_DOMAIN = 'deepview-objstore.v1.put'
|
|
11
11
|
const OBJSTORE_DELETE_DOMAIN = 'deepview-objstore.v1.delete'
|
|
12
12
|
const OBJSTORE_FETCH_DOMAIN = 'deepview-objstore.v1.fetch'
|
|
13
|
+
// REST fetch-mint domain — MUST match the client's FETCH_REST_DOMAIN
|
|
14
|
+
// (client/sync/objstore-crypto.ts). Distinct from OBJSTORE_FETCH_DOMAIN so
|
|
15
|
+
// a WS-fetch signature can't be replayed against the REST mint endpoint.
|
|
16
|
+
const OBJSTORE_FETCH_REST_DOMAIN = 'deepview-objstore.v1.fetch-rest'
|
|
17
|
+
// REST put-begin domain — MUST match the client's PUT_REST_DOMAIN. Distinct
|
|
18
|
+
// from OBJSTORE_PUT_DOMAIN so a WS put-begin signature can't be replayed
|
|
19
|
+
// against the REST mint endpoint.
|
|
20
|
+
const OBJSTORE_PUT_REST_DOMAIN = 'deepview-objstore.v1.put-rest'
|
|
21
|
+
// REST delete domain — MUST match the client's DELETE_REST_DOMAIN. Distinct
|
|
22
|
+
// from OBJSTORE_DELETE_DOMAIN so a WS delete signature can't be replayed
|
|
23
|
+
// against the REST mint endpoint.
|
|
24
|
+
const OBJSTORE_DELETE_REST_DOMAIN = 'deepview-objstore.v1.delete-rest'
|
|
13
25
|
|
|
14
26
|
// Wire shapes the verifiers accept. Fields land here post-
|
|
15
27
|
// `JSON.parse`, so every value starts life as `unknown` — strict
|
|
@@ -95,6 +107,38 @@ function canonicalObjstoreFetch(msg: ObjstoreFetchMsg, connectionNonce: string):
|
|
|
95
107
|
].join('\n'))
|
|
96
108
|
}
|
|
97
109
|
|
|
110
|
+
// REST fetch-mint canonical. Binds a client epoch-ms timestamp (string-
|
|
111
|
+
// encoded to match the client) in place of the connection nonce; the REST
|
|
112
|
+
// handler enforces the freshness window + replay dedup.
|
|
113
|
+
function canonicalObjstoreFetchRest(workspaceTag: string, resourceTag: string, ts: number): Uint8Array<ArrayBuffer> {
|
|
114
|
+
return encodeUtf8([
|
|
115
|
+
OBJSTORE_FETCH_REST_DOMAIN,
|
|
116
|
+
workspaceTag,
|
|
117
|
+
resourceTag,
|
|
118
|
+
String(ts),
|
|
119
|
+
].join('\n'))
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// REST put-begin canonical — the WS `canonicalObjstorePut` fields in the
|
|
123
|
+
// same order/coercion (intOrEmpty / strOrEmpty), under the put-rest domain
|
|
124
|
+
// and binding the client `ts` in place of the connection nonce.
|
|
125
|
+
function canonicalObjstorePutRest(
|
|
126
|
+
workspaceTag: string, resourceTag: string,
|
|
127
|
+
prevVersion: number | null, prevIncarnation: string | null,
|
|
128
|
+
contentHash: string, expectedLength: number, ts: number,
|
|
129
|
+
): Uint8Array<ArrayBuffer> {
|
|
130
|
+
return encodeUtf8([
|
|
131
|
+
OBJSTORE_PUT_REST_DOMAIN,
|
|
132
|
+
workspaceTag,
|
|
133
|
+
resourceTag,
|
|
134
|
+
intOrEmpty(prevVersion),
|
|
135
|
+
strOrEmpty(prevIncarnation),
|
|
136
|
+
contentHash,
|
|
137
|
+
String(expectedLength),
|
|
138
|
+
String(ts),
|
|
139
|
+
].join('\n'))
|
|
140
|
+
}
|
|
141
|
+
|
|
98
142
|
// `Number.isSafeInteger` rather than `Number.isInteger`: JSON numbers
|
|
99
143
|
// are IEEE-754 and integers above 2^53-1 aren't precisely
|
|
100
144
|
// representable. Accepting non-safe integers would let a signed
|
|
@@ -162,3 +206,64 @@ export function verifyObjstoreFetchSig(msg: ObjstoreFetchMsg, connectionNonce: u
|
|
|
162
206
|
if (typeof msg.resourceTag !== 'string') return Promise.resolve(false)
|
|
163
207
|
return verifyObjstoreSig(msg, connectionNonce, (nonce) => canonicalObjstoreFetch(msg, nonce))
|
|
164
208
|
}
|
|
209
|
+
|
|
210
|
+
// Verify a REST fetch-mint signature. Unlike the WS verifiers this takes
|
|
211
|
+
// the already-validated fields directly (the REST handler parsed +
|
|
212
|
+
// range-checked `ts` and `signature`) rather than a wire `msg` + socket
|
|
213
|
+
// nonce. The workspaceTag IS the Ed25519 public key, so verification is
|
|
214
|
+
// fully self-contained — no session or stored key needed. A throw from
|
|
215
|
+
// the canonical build (lone surrogate, etc.) is treated as a verify
|
|
216
|
+
// failure, never an escaping exception.
|
|
217
|
+
export function verifyObjstoreFetchRestSig(
|
|
218
|
+
workspaceTag: string, resourceTag: string, ts: number, signature: string,
|
|
219
|
+
): Promise<boolean> {
|
|
220
|
+
let payload: Uint8Array<ArrayBuffer>
|
|
221
|
+
try { payload = canonicalObjstoreFetchRest(workspaceTag, resourceTag, ts) }
|
|
222
|
+
catch { return Promise.resolve(false) }
|
|
223
|
+
return verifyEd25519(workspaceTag, payload, signature)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Verify a REST put-begin signature. Same self-contained shape as
|
|
227
|
+
// `verifyObjstoreFetchRestSig` — the caller (rest.ts) has already
|
|
228
|
+
// range-checked the put fields + `ts`. The workspaceTag IS the pubkey.
|
|
229
|
+
export function verifyObjstorePutBeginRestSig(
|
|
230
|
+
fields: { workspaceTag: string; resourceTag: string; prevVersion: number | null; prevIncarnation: string | null; contentHash: string; expectedLength: number },
|
|
231
|
+
ts: number, signature: string,
|
|
232
|
+
): Promise<boolean> {
|
|
233
|
+
let payload: Uint8Array<ArrayBuffer>
|
|
234
|
+
try {
|
|
235
|
+
payload = canonicalObjstorePutRest(
|
|
236
|
+
fields.workspaceTag, fields.resourceTag, fields.prevVersion, fields.prevIncarnation,
|
|
237
|
+
fields.contentHash, fields.expectedLength, ts,
|
|
238
|
+
)
|
|
239
|
+
} catch { return Promise.resolve(false) }
|
|
240
|
+
return verifyEd25519(fields.workspaceTag, payload, signature)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// REST delete-mint canonical. WS `canonicalObjstoreDelete` fields, under the
|
|
244
|
+
// delete-rest domain, binding the client `ts` in place of the nonce.
|
|
245
|
+
function canonicalObjstoreDeleteRest(
|
|
246
|
+
workspaceTag: string, resourceTag: string,
|
|
247
|
+
prevVersion: number | null, prevIncarnation: string | null, ts: number,
|
|
248
|
+
): Uint8Array<ArrayBuffer> {
|
|
249
|
+
return encodeUtf8([
|
|
250
|
+
OBJSTORE_DELETE_REST_DOMAIN,
|
|
251
|
+
workspaceTag,
|
|
252
|
+
resourceTag,
|
|
253
|
+
intOrEmpty(prevVersion),
|
|
254
|
+
strOrEmpty(prevIncarnation),
|
|
255
|
+
String(ts),
|
|
256
|
+
].join('\n'))
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Verify a REST delete-mint signature. Self-contained (workspaceTag IS the
|
|
260
|
+
// pubkey); the caller (rest.ts) has range-checked `ts` + the prev pair.
|
|
261
|
+
export function verifyObjstoreDeleteRestSig(
|
|
262
|
+
fields: { workspaceTag: string; resourceTag: string; prevVersion: number | null; prevIncarnation: string | null },
|
|
263
|
+
ts: number, signature: string,
|
|
264
|
+
): Promise<boolean> {
|
|
265
|
+
let payload: Uint8Array<ArrayBuffer>
|
|
266
|
+
try { payload = canonicalObjstoreDeleteRest(fields.workspaceTag, fields.resourceTag, fields.prevVersion, fields.prevIncarnation, ts) }
|
|
267
|
+
catch { return Promise.resolve(false) }
|
|
268
|
+
return verifyEd25519(fields.workspaceTag, payload, signature)
|
|
269
|
+
}
|