@preventive/triage 1.0.0-alpha.1 → 1.0.0-alpha.3
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 +11 -11
- package/out/graph.js +3 -3
- package/out/index.html +3 -15
- package/out/view.css +1 -1
- package/out/view.js +51 -48
- package/package.json +11 -4
- package/server/bus-receiver.ts +7 -7
- package/server/cli.js +22 -0
- package/server/config.ts +4 -4
- package/server/db-neon.ts +29 -23
- package/server/db-revision-sql.ts +6 -9
- package/server/db.ts +95 -134
- package/server/hub.ts +4 -5
- package/server/index.ts +42 -27
- package/server/lifecycle.ts +6 -1
- package/server/objstore/blob-fs.ts +4 -6
- package/server/objstore/blob-vercel.ts +66 -33
- package/server/objstore/blob.ts +16 -8
- package/server/objstore/handlers.ts +11 -13
- package/server/objstore/init.ts +18 -9
- package/server/objstore/rest.ts +60 -68
- package/server/objstore/store-neon.ts +16 -16
- package/server/objstore/store.ts +92 -112
- package/server/objstore/tokens.ts +9 -12
- package/server/peer.ts +7 -9
- package/server/pubsub.ts +17 -27
- package/server/sign.ts +8 -10
- package/server/sse-server.ts +10 -12
- package/server/sse-session.ts +2 -8
- package/server/static.ts +8 -12
- package/server/sync-handlers.ts +32 -39
- package/server/ws-server.ts +9 -14
- package/strip-types-loader.js +94 -0
package/server/hub.ts
CHANGED
|
@@ -102,12 +102,11 @@ export function createHub(deps: { peers: PeerRegistry; maxBufferedBytes: number;
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
function fanOut(set: Set<WebSocket>, payload: string, except: WebSocket | null): void {
|
|
105
|
-
// Snapshot before iterating —
|
|
106
|
-
// socket.send errors, but a socket transitioning to CLOSED
|
|
105
|
+
// Snapshot before iterating — a socket transitioning to CLOSED
|
|
107
106
|
// mid-broadcast triggers `unsubscribeAll` from the 'close' handler,
|
|
108
|
-
//
|
|
109
|
-
//
|
|
110
|
-
//
|
|
107
|
+
// mutating `set` while we walk it. The snapshot also keeps a future
|
|
108
|
+
// refactor (different collection, async send) from silently skipping
|
|
109
|
+
// subscribers. Audit M4 round-3.
|
|
111
110
|
for (const s of [...set]) {
|
|
112
111
|
if (s === except) continue
|
|
113
112
|
sendRaw(s, payload)
|
package/server/index.ts
CHANGED
|
@@ -97,8 +97,8 @@ import {
|
|
|
97
97
|
import { createBusReceiver } from './bus-receiver.ts'
|
|
98
98
|
|
|
99
99
|
// All external inputs (env vars + optional config.json) are parsed
|
|
100
|
-
// and validated in ./config.ts
|
|
101
|
-
//
|
|
100
|
+
// and validated in ./config.ts; destructure into the uppercase names
|
|
101
|
+
// the rest of this module uses.
|
|
102
102
|
const config = loadConfig()
|
|
103
103
|
const {
|
|
104
104
|
port: PORT, host: HOST, dbPath: DB_PATH, objstoreDir: OBJSTORE_DIR,
|
|
@@ -109,8 +109,8 @@ const {
|
|
|
109
109
|
} = config
|
|
110
110
|
|
|
111
111
|
// Same-origin gate for the WS upgrade and REST data plane (see
|
|
112
|
-
// ./origin.ts). `TRUST_PROXY_ENV`
|
|
113
|
-
//
|
|
112
|
+
// ./origin.ts). `TRUST_PROXY_ENV` also feeds the boot-time
|
|
113
|
+
// misconfiguration fail-fast below.
|
|
114
114
|
const { trustProxy: TRUST_PROXY, isOriginAllowed } = createOriginGate(HOST, TRUST_PROXY_ENV)
|
|
115
115
|
|
|
116
116
|
// Per-socket buffered-bytes cap. `socket.send` returns synchronously
|
|
@@ -131,10 +131,9 @@ const MAX_BUFFERED_BYTES = 16 * 1024 * 1024
|
|
|
131
131
|
|
|
132
132
|
// Per-connection state registry. One `Peer` per accepted socket holds
|
|
133
133
|
// the challenge nonce, auth flag, heartbeat liveness, in-flight count,
|
|
134
|
-
// and subscribed tags (see ./peer.ts)
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
// via `peers.get(socket)`.
|
|
134
|
+
// and subscribed tags (see ./peer.ts). The connection handler holds
|
|
135
|
+
// the Peer in a closure for the hot paths; cross-function call sites
|
|
136
|
+
// resolve it via `peers.get(socket)`.
|
|
138
137
|
const peers: PeerRegistry = new WeakMap()
|
|
139
138
|
|
|
140
139
|
// REST PUT idle-body timeout. A slow-loris client trickling bytes
|
|
@@ -163,8 +162,8 @@ const REST_PUT_IDLE_TIMEOUT_MS = 30_000
|
|
|
163
162
|
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
164
163
|
|
|
165
164
|
// Backend selection. Both planes (workspace_revision DB + the
|
|
166
|
-
// v1.objstore byte store) are picked from config at boot. Two
|
|
167
|
-
// pairings:
|
|
165
|
+
// v1.objstore byte store) are picked from config at boot. Two
|
|
166
|
+
// supported pairings:
|
|
168
167
|
// 1. DATABASE_URL set → Neon (workspace_revision + objstore
|
|
169
168
|
// tables) + Vercel Blob Private Storage (bytes). Requires
|
|
170
169
|
// BLOB_READ_WRITE_TOKEN — fail fast at boot if missing, since
|
|
@@ -174,11 +173,10 @@ const HEARTBEAT_INTERVAL_MS = 30_000
|
|
|
174
173
|
// process; the only pairing the SQLite plane supports.
|
|
175
174
|
// The Neon / Vercel files import their peer deps lazily inside the
|
|
176
175
|
// open functions, so static imports here are safe even on a SQLite-
|
|
177
|
-
// only install where the optional peer deps aren't present.
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
// without a non-null assertion.
|
|
176
|
+
// only install where the optional peer deps aren't present. Explicit
|
|
177
|
+
// branch (not a ternary) so the SQLite path keeps its `SqliteHandle`
|
|
178
|
+
// narrowing — `sqliteHandle.db` is a non-optional `DatabaseSync` that
|
|
179
|
+
// `openObjstore` accepts without a non-null assertion.
|
|
182
180
|
let handle: Handle
|
|
183
181
|
let objstoreHandle: ObjstoreHandle
|
|
184
182
|
let objstoreBanner: string
|
|
@@ -238,8 +236,7 @@ async function workspaceExists(tag: string): Promise<boolean> {
|
|
|
238
236
|
}
|
|
239
237
|
|
|
240
238
|
// WS fan-out hub: subscriber registry + backpressure-aware send /
|
|
241
|
-
// broadcast (see ./hub.ts).
|
|
242
|
-
// handlers / dispatcher / objstore wiring below read unchanged.
|
|
239
|
+
// broadcast (see ./hub.ts).
|
|
243
240
|
const hub = createHub({ peers, maxBufferedBytes: MAX_BUFFERED_BYTES, debug: DEBUG })
|
|
244
241
|
const { send, broadcast, subscribe, unsubscribeAll, broadcastLocalRaw } = hub
|
|
245
242
|
|
|
@@ -276,7 +273,7 @@ if (NEON_URL) {
|
|
|
276
273
|
}
|
|
277
274
|
|
|
278
275
|
// Password gate (see ./auth.ts) — HMAC derivation + the `authenticate`
|
|
279
|
-
// handshake.
|
|
276
|
+
// handshake.
|
|
280
277
|
const auth = createAuth({ peers, password: CONFIG_PASSWORD, send, debug: DEBUG })
|
|
281
278
|
const { requiresAuth, handleAuthenticate, sendUnauthorized } = auth
|
|
282
279
|
|
|
@@ -320,8 +317,8 @@ const { handlers: objstore, restDeps: objstoreRestDeps, startupReap, stopReaper
|
|
|
320
317
|
sendUnauthorized,
|
|
321
318
|
// `tokenSecret` is set only when OBJSTORE_TOKEN_SECRET was
|
|
322
319
|
// provided in env (see TOKEN_SECRET resolution above). Omitted
|
|
323
|
-
// → initObjstore mints a fresh per-process secret (
|
|
324
|
-
//
|
|
320
|
+
// → initObjstore mints a fresh per-process secret (fine for
|
|
321
|
+
// single-replica).
|
|
325
322
|
...(TOKEN_SECRET ? { tokenSecret: TOKEN_SECRET } : {}),
|
|
326
323
|
})
|
|
327
324
|
|
|
@@ -452,11 +449,29 @@ installLifecycle({
|
|
|
452
449
|
|
|
453
450
|
// Bind only after the startup orphan sweep finishes — otherwise a
|
|
454
451
|
// fresh boot could serve traffic against tags whose on-disk state
|
|
455
|
-
// still has residue from a prior crash.
|
|
456
|
-
//
|
|
457
|
-
//
|
|
458
|
-
//
|
|
459
|
-
// failure unconditionally and returns void), so no outer `.catch`
|
|
460
|
-
// is needed here.
|
|
452
|
+
// still has residue from a prior crash. `startupReap` already
|
|
453
|
+
// resolves on any error (the reaper's own catch logs the failure
|
|
454
|
+
// unconditionally and returns void), so no outer `.catch` is
|
|
455
|
+
// needed here.
|
|
461
456
|
await startupReap
|
|
462
|
-
|
|
457
|
+
|
|
458
|
+
// Bind the HTTP/WS plane on the configured PORT/HOST. Exported so a
|
|
459
|
+
// launcher (server/cli.js — the triage-server bin) or any `import`er can
|
|
460
|
+
// start serving. The top-level `await startupReap` above means the server
|
|
461
|
+
// is fully ready — DB open, DDL bootstrapped, objstore reaper swept — by
|
|
462
|
+
// the time the import resolves.
|
|
463
|
+
export function start(): void {
|
|
464
|
+
httpServer.listen(PORT, HOST)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
export { httpServer, wss }
|
|
468
|
+
|
|
469
|
+
// Library mode: when this module is `import`ed (rather than run as the
|
|
470
|
+
// entry script) skip the auto-start so consumers can own the bind — e.g.
|
|
471
|
+
// wrap CF Access / framework-preset shims around the assembled
|
|
472
|
+
// `httpServer`, or just call `start()` when ready. Direct invocation via
|
|
473
|
+
// `node server/index.ts` (or the `triage-server` bin) still listens.
|
|
474
|
+
if (import.meta.main) {
|
|
475
|
+
start()
|
|
476
|
+
}
|
|
477
|
+
|
package/server/lifecycle.ts
CHANGED
|
@@ -46,7 +46,12 @@ export function createLifecycle(): Lifecycle {
|
|
|
46
46
|
const inFlight = new Set<Promise<unknown>>()
|
|
47
47
|
function track(promise: Promise<unknown>): void {
|
|
48
48
|
inFlight.add(promise)
|
|
49
|
-
|
|
49
|
+
// Trailing `.catch(() => {})` swallows the rejection that `.finally`
|
|
50
|
+
// propagates through its returned promise — without it, a tracked
|
|
51
|
+
// handler that rejects (or whose caller's catch handler itself
|
|
52
|
+
// throws) trips the `unhandledRejection` catchall below and crashes
|
|
53
|
+
// the process via `fireShutdown(1)`.
|
|
54
|
+
promise.finally(() => inFlight.delete(promise)).catch(() => {})
|
|
50
55
|
}
|
|
51
56
|
let shuttingDown = false
|
|
52
57
|
// Live exit code the in-progress shutdown will pass to `process.exit`.
|
|
@@ -90,12 +90,10 @@ export function openFsBlobBackend(dir: string): BlobBackend {
|
|
|
90
90
|
// closed by Node's stream machinery on 'finish'.
|
|
91
91
|
finalize: async () => {},
|
|
92
92
|
// `destroy(err)` synchronously starts tearing the stream
|
|
93
|
-
// down; the WriteStream emits 'close' on the next tick.
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// the no-await-in-async — the function signature is
|
|
98
|
-
// dictated by the BlobBackend contract.
|
|
93
|
+
// down; the WriteStream emits 'close' on the next tick. No
|
|
94
|
+
// remote upload to wait for on FS, so we resolve immediately
|
|
95
|
+
// — the REST layer awaits but doesn't block on anything real.
|
|
96
|
+
// Async signature is dictated by the BlobBackend contract.
|
|
99
97
|
// eslint-disable-next-line require-await
|
|
100
98
|
abort: async (err) => { writable.destroy(err as Error) },
|
|
101
99
|
}
|
|
@@ -119,21 +119,19 @@ type VercelBlobSdk = {
|
|
|
119
119
|
|
|
120
120
|
// Recognise "blob is gone" errors uniformly across read/write/delete
|
|
121
121
|
// paths so callers can treat them as success (delete) or
|
|
122
|
-
// not-found (read). The SDK exposes BlobNotFoundError as a class
|
|
123
|
-
//
|
|
124
|
-
//
|
|
125
|
-
//
|
|
122
|
+
// not-found (read). The SDK exposes BlobNotFoundError as a class with
|
|
123
|
+
// `.name === 'BlobNotFoundError'`; checking the name string avoids
|
|
124
|
+
// importing the class at the top level (which would force the optional
|
|
125
|
+
// peer dep to resolve).
|
|
126
126
|
//
|
|
127
|
-
// Class-name check ONLY
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
135
|
-
// lets BlobStoreNotFoundError / other classes propagate as real
|
|
136
|
-
// exceptions.
|
|
127
|
+
// Class-name check ONLY — the SDK's internal mapper turns every API
|
|
128
|
+
// `not_found` into BlobNotFoundError-by-name, so a bare-404 transport
|
|
129
|
+
// leak doesn't reach here. A broader `/does not exist|\b404\b/` match
|
|
130
|
+
// is DANGEROUS: it also matches BlobStoreNotFoundError's "This store
|
|
131
|
+
// does not exist.", so a config fault (revoked token, deleted store)
|
|
132
|
+
// would silently surface as every-blob-missing across reads/unlinks,
|
|
133
|
+
// masking the fatal misconfiguration. The tight name check lets
|
|
134
|
+
// BlobStoreNotFoundError / other classes propagate as real exceptions.
|
|
137
135
|
function isNotFound(err: unknown): boolean {
|
|
138
136
|
if (err == null || typeof err !== 'object') return false
|
|
139
137
|
const name = (err as { name?: unknown }).name
|
|
@@ -242,20 +240,17 @@ function buildOpenStagingWriter(sdk: VercelBlobSdk, token: string): BlobBackend[
|
|
|
242
240
|
abortSignal: ac.signal,
|
|
243
241
|
})
|
|
244
242
|
// Defuse a possible unhandled-rejection if abort() is called
|
|
245
|
-
// BEFORE finalize() (the REST
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
//
|
|
249
|
-
// (the .catch returns a separate chain that doesn't replace
|
|
250
|
-
// putPromise's state).
|
|
243
|
+
// BEFORE finalize() (the REST error path). The detached `.catch`
|
|
244
|
+
// gives an early rejection a handler; finalize() still awaits
|
|
245
|
+
// `putPromise` directly and re-throws the original rejection (the
|
|
246
|
+
// .catch is a separate chain, not a replacement of putPromise).
|
|
251
247
|
putPromise.catch(() => {})
|
|
252
248
|
return {
|
|
253
249
|
writable: pt,
|
|
254
250
|
// Await the upload's completion. After pipeline(req, counter,
|
|
255
|
-
// pt) resolves, pt has emitted 'end'
|
|
256
|
-
//
|
|
257
|
-
//
|
|
258
|
-
// pipeline-to-WriteStream gives the FS backend.
|
|
251
|
+
// pt) resolves, pt has emitted 'end' and `put` is finalising the
|
|
252
|
+
// last multipart part. Awaiting here gives the same "bytes
|
|
253
|
+
// durable" guarantee pipeline-to-WriteStream gives the FS backend.
|
|
259
254
|
finalize: async () => { await putPromise },
|
|
260
255
|
// Await the SDK's put-promise settlement (rejected via the
|
|
261
256
|
// AbortController). Without this await, a slow upload that
|
|
@@ -315,11 +310,11 @@ function buildPromoteStagingToLive(sdk: VercelBlobSdk, token: string): BlobBacke
|
|
|
315
310
|
//
|
|
316
311
|
// `allowOverwrite: true` because the content-addressed live
|
|
317
312
|
// pathname `${tag}/${contentHash}.bin` can be (re)written by a
|
|
318
|
-
// retried or racing promote of the same blob.
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
313
|
+
// retried or racing promote of the same blob. Destination bytes
|
|
314
|
+
// are identical by construction (the path IS the hash), so the
|
|
315
|
+
// overwrite is idempotent — never a clobber of DIFFERENT bytes.
|
|
316
|
+
// Without the flag the SDK sends `x-allow-overwrite: 0` and
|
|
317
|
+
// Vercel rejects the re-promote with BlobAccessError.
|
|
323
318
|
await sdk.copy(from, to, {
|
|
324
319
|
access: 'private',
|
|
325
320
|
allowOverwrite: true,
|
|
@@ -356,17 +351,55 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
|
|
|
356
351
|
// origin truth. Origin fetch is the right default for a store
|
|
357
352
|
// where freshness > latency.
|
|
358
353
|
try { res = await sdk.get(path, { access: 'private', useCache: false, token }) } catch (err) {
|
|
359
|
-
|
|
354
|
+
// A missing blob HERE is never "the resource doesn't exist" — the
|
|
355
|
+
// REST layer (rest.ts openLiveSnapshot) already confirmed a live row
|
|
356
|
+
// whose (version, incarnation) matches the GET token before calling
|
|
357
|
+
// us. So BlobNotFoundError means the bytes for a still-live row are
|
|
358
|
+
// momentarily gone: the reaper GC'd a hash a racing version-bump just
|
|
359
|
+
// unreferenced, or Vercel's read-after-write / edge propagation hasn't
|
|
360
|
+
// caught up to a freshly-promoted private blob. That is the documented
|
|
361
|
+
// `unavailable` (HTTP 503) transient — reconciled by reaper /
|
|
362
|
+
// propagation, retried by the client — NOT a 404. Returning
|
|
363
|
+
// `not-found` would emit a 404 the FS backend never emits for the
|
|
364
|
+
// same condition (blob-fs.ts maps ENOENT → `unavailable`), telling
|
|
365
|
+
// the client the resource is gone for good when it should refetch.
|
|
366
|
+
// See server/README.md's GET status table.
|
|
367
|
+
if (isNotFound(err)) return { ok: false, reason: 'unavailable' }
|
|
360
368
|
throw err
|
|
361
369
|
}
|
|
362
|
-
|
|
370
|
+
// SDK returned null (no blob) — same "bytes missing for a live row"
|
|
371
|
+
// transient as the BlobNotFoundError branch above → `unavailable`, not
|
|
372
|
+
// `not-found`.
|
|
373
|
+
if (res == null) return { ok: false, reason: 'unavailable' }
|
|
363
374
|
// statusCode 304 doesn't reach here in practice — the REST
|
|
364
375
|
// GET layer doesn't pass If-None-Match — but a future call
|
|
365
376
|
// site could. Treat as unavailable rather than streaming a
|
|
366
377
|
// null body.
|
|
367
|
-
if (res.statusCode !== 200 || res.stream == null
|
|
378
|
+
if (res.statusCode !== 200 || res.stream == null) {
|
|
368
379
|
return { ok: false, reason: 'unavailable' }
|
|
369
380
|
}
|
|
381
|
+
// `@vercel/blob@2.x`'s streaming `get()` for private blobs returns
|
|
382
|
+
// the body but does NOT populate `blob.size` nor pass a
|
|
383
|
+
// `content-length` header (verified empirically: get.size=0,
|
|
384
|
+
// content-length-hdr=null, while head.size reports the true count).
|
|
385
|
+
// The REST layer needs a size to set `content-length` and for the
|
|
386
|
+
// integrity check against the DB row, so on 0/null we fall back to
|
|
387
|
+
// head(). Two round-trips per private read until the SDK is fixed —
|
|
388
|
+
// small price vs. 503ing every read.
|
|
389
|
+
let size: number | null | undefined = res.blob?.size
|
|
390
|
+
if (size == null || size === 0) {
|
|
391
|
+
try {
|
|
392
|
+
const h = await sdk.head(path, { token })
|
|
393
|
+
size = (h as { size?: number })?.size
|
|
394
|
+
} catch (headErr) {
|
|
395
|
+
// Blob vanished between get() and the head() size fallback (a
|
|
396
|
+
// racing reaper GC) — still the "live row present, bytes gone"
|
|
397
|
+
// transient, so `unavailable` (503), matching the get() path above.
|
|
398
|
+
if (isNotFound(headErr)) return { ok: false, reason: 'unavailable' }
|
|
399
|
+
throw headErr
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (size == null) return { ok: false, reason: 'unavailable' }
|
|
370
403
|
// SDK returns a web ReadableStream<Uint8Array>; the REST layer
|
|
371
404
|
// expects a Node Readable for pipeline(). Convert via
|
|
372
405
|
// Readable.fromWeb — built-in and zero-copy where possible.
|
|
@@ -376,7 +409,7 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
|
|
|
376
409
|
ok: true,
|
|
377
410
|
reader: {
|
|
378
411
|
stream: nodeStream,
|
|
379
|
-
size
|
|
412
|
+
size,
|
|
380
413
|
// eslint-disable-next-line require-await
|
|
381
414
|
close: async () => {
|
|
382
415
|
// Destroying the Node wrapper also cancels the underlying
|
package/server/objstore/blob.ts
CHANGED
|
@@ -83,13 +83,20 @@ export type LiveReader = {
|
|
|
83
83
|
close(): Promise<void>
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
86
|
+
// A missing blob is always `unavailable` (→ HTTP 503), never a 404. The
|
|
87
|
+
// byte plane has NO view of the metadata row, so it can't decide whether
|
|
88
|
+
// a resource "doesn't exist" — only whether specific bytes are present
|
|
89
|
+
// right now. The authoritative "this resource/version is gone" 404 is the
|
|
90
|
+
// REST layer's call, made from the live row BEFORE it opens a reader
|
|
91
|
+
// (rest.ts openLiveSnapshot). By the time `openLiveReader` runs the row is
|
|
92
|
+
// already confirmed, so an absent blob means a transient bytes/metadata
|
|
93
|
+
// desync the reaper (or store propagation) reconciles — exactly the
|
|
94
|
+
// `unavailable`/503 contract, which the client retries. Both backends MUST
|
|
95
|
+
// map a missing blob to `unavailable` (FS: ENOENT; Vercel: BlobNotFoundError
|
|
96
|
+
// / null get()). No 404-mapping variant exists here so that bug can't recur.
|
|
90
97
|
export type OpenLiveResult =
|
|
91
98
|
| { ok: true; reader: LiveReader }
|
|
92
|
-
| { ok: false; reason: '
|
|
99
|
+
| { ok: false; reason: 'unavailable' }
|
|
93
100
|
|
|
94
101
|
export type BlobBackend = {
|
|
95
102
|
// Per-workspace setup. FS creates the on-disk staging directory;
|
|
@@ -128,9 +135,10 @@ export type BlobBackend = {
|
|
|
128
135
|
promoteStagingToLive(tag: string, stagingId: string, contentHash: string): Promise<boolean>
|
|
129
136
|
|
|
130
137
|
// Open a streaming reader for the content-addressed live blob.
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
138
|
+
// Called only after the REST layer has confirmed the live row, so a
|
|
139
|
+
// missing blob is the transient "row present, bytes gone" state →
|
|
140
|
+
// `unavailable` (HTTP 503), which the reaper reconciles and the client
|
|
141
|
+
// retries. Never a 404 from here — see OpenLiveResult above.
|
|
134
142
|
openLiveReader(tag: string, contentHash: string): Promise<OpenLiveResult>
|
|
135
143
|
|
|
136
144
|
// Idempotent deletes. Backends MUST tolerate "already gone" as
|
|
@@ -68,11 +68,11 @@ function urlPathFor(tag: string, resourceTag: string): string {
|
|
|
68
68
|
// Shared gate every objstore handler runs after its message-specific
|
|
69
69
|
// field checks: fetch the socket's challenge nonce, verify the signed
|
|
70
70
|
// message against it, then re-confirm the socket is still OPEN — the
|
|
71
|
-
// close handler may have fired during the verify await, and
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
71
|
+
// close handler may have fired during the verify await, and replying
|
|
72
|
+
// on a closed socket is the half-handshake leak case (PR #4 review
|
|
73
|
+
// F4). Centralising the post-await readyState recheck keeps that
|
|
74
|
+
// invariant in one auditable place. Returns true when the caller may
|
|
75
|
+
// proceed.
|
|
76
76
|
async function verified<M extends { workspaceTag?: unknown }>(
|
|
77
77
|
deps: ObjstoreDeps, socket: WebSocket, msg: M, label: string,
|
|
78
78
|
verify: (m: M, nonce: string) => Promise<boolean>,
|
|
@@ -96,12 +96,10 @@ async function handlePutBegin(deps: ObjstoreDeps, socket: WebSocket, msg: Objsto
|
|
|
96
96
|
// signature to fail. Cheaper to reject up-front, and consistent
|
|
97
97
|
// with `verifyObjstorePutSig`'s `isSafeNonNegativeInt` gate.
|
|
98
98
|
if (!Number.isSafeInteger(msg.expectedLength) || (msg.expectedLength as number) < 0 || (msg.expectedLength as number) > MAX_CONTENT_LENGTH) return
|
|
99
|
-
//
|
|
100
|
-
// `verifyObjstorePutSig`'s `isSafeIntOrNull`
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
// hash + Ed25519 round-trip on a guaranteed-fail input. Input-
|
|
104
|
-
// validation audit `server/objstore/handlers.ts:76`.
|
|
99
|
+
// Same up-front reject for `prevVersion` (symmetric with handleDelete
|
|
100
|
+
// and `verifyObjstorePutSig`'s `isSafeIntOrNull`): a non-safe-integer
|
|
101
|
+
// (NaN, 2^53+1, ...) would pass the typeof check below and reach sig
|
|
102
|
+
// verify, burning a hash + Ed25519 round-trip on a guaranteed fail.
|
|
105
103
|
if (msg.prevVersion != null && (typeof msg.prevVersion !== 'number' || !Number.isSafeInteger(msg.prevVersion))) return
|
|
106
104
|
if (!await verified(deps, socket, msg, 'put-begin', verifyObjstorePutSig)) return
|
|
107
105
|
const tag = msg.workspaceTag
|
|
@@ -110,8 +108,8 @@ async function handlePutBegin(deps: ObjstoreDeps, socket: WebSocket, msg: Objsto
|
|
|
110
108
|
// workspace tag (no rows in workspace_revision AND none in
|
|
111
109
|
// workspace_object). Mirrors handleSave in server/index.ts; runs
|
|
112
110
|
// AFTER sig verify so `unauthorized` only reaches a legitimate
|
|
113
|
-
// signer.
|
|
114
|
-
//
|
|
111
|
+
// signer. Config-driven (server/config.json `password`), no-op when
|
|
112
|
+
// unconfigured.
|
|
115
113
|
if (deps.authGate && deps.sendUnauthorized && await deps.authGate(socket, tag)) {
|
|
116
114
|
if (socket.readyState !== socket.OPEN) return
|
|
117
115
|
if (deps.debug) console.warn(`reject objstore-put-begin: unauthorized (new workspace ${debugTag(tag)})`)
|
package/server/objstore/init.ts
CHANGED
|
@@ -56,6 +56,16 @@ export type ObjstoreInit = {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
|
|
59
|
+
// Fail loud on a lopsided auth config. The put-begin gate in
|
|
60
|
+
// handlers.ts only fires when BOTH authGate and sendUnauthorized are
|
|
61
|
+
// present (it needs the reporter to emit the `unauthorized` frame),
|
|
62
|
+
// so wiring authGate WITHOUT sendUnauthorized silently fails OPEN —
|
|
63
|
+
// unauthenticated first-writes to unknown workspaces would be accepted
|
|
64
|
+
// despite the operator's intent to gate them. Reject at boot rather
|
|
65
|
+
// than regress access control silently.
|
|
66
|
+
if (deps.authGate && !deps.sendUnauthorized) {
|
|
67
|
+
throw new Error('initObjstore: authGate requires sendUnauthorized (the put-begin gate needs it to emit the unauthorized frame)')
|
|
68
|
+
}
|
|
59
69
|
const handle = deps.handle
|
|
60
70
|
const secret = deps.tokenSecret ?? newTokenSecret()
|
|
61
71
|
const handlers = createObjstoreHandlers({
|
|
@@ -93,15 +103,14 @@ export function initObjstore(deps: ObjstoreInitDeps): ObjstoreInit {
|
|
|
93
103
|
// on-disk state still has stranded files from a prior crash.
|
|
94
104
|
const startupReap = enqueueSweep()
|
|
95
105
|
// Jittered start of the periodic timer. Multi-replica deploys
|
|
96
|
-
// (Neon + Vercel Blob) commonly boot N replicas in tight lock-
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
// sweeps stay at exactly `reapIntervalMs` apart.
|
|
106
|
+
// (Neon + Vercel Blob) commonly boot N replicas in tight lock-step
|
|
107
|
+
// (deploy rollout, cluster restart), which would otherwise sync every
|
|
108
|
+
// replica's reaper to the same wall-clock tick and hammer the DB +
|
|
109
|
+
// blob store with N×readdir+lock-acquire bursts. A random
|
|
110
|
+
// first-interval delay spreads the cluster out without changing the
|
|
111
|
+
// long-term cadence. Jitter is 0…1× reapIntervalMs (first sweep at
|
|
112
|
+
// [interval, 2×interval] after boot); subsequent sweeps stay exactly
|
|
113
|
+
// `reapIntervalMs` apart.
|
|
105
114
|
let reapTimer: ReturnType<typeof setInterval> | null = null
|
|
106
115
|
const jitterMs = Math.floor(Math.random() * deps.reapIntervalMs)
|
|
107
116
|
const firstTimer = setTimeout(() => {
|