@preventive/triage 1.0.0-alpha.2 → 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/view.js +49 -48
- package/package.json +2 -2
- package/server/bus-receiver.ts +7 -7
- package/server/config.ts +4 -4
- package/server/db-revision-sql.ts +6 -9
- package/server/db.ts +95 -134
- package/server/hub.ts +4 -5
- package/server/index.ts +22 -26
- package/server/objstore/blob-fs.ts +4 -6
- package/server/objstore/blob-vercel.ts +54 -42
- 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 +4 -4
- 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
|
@@ -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,10 +351,26 @@ 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
|
|
@@ -367,23 +378,24 @@ function buildOpenLiveReader(sdk: VercelBlobSdk, token: string): BlobBackend['op
|
|
|
367
378
|
if (res.statusCode !== 200 || res.stream == null) {
|
|
368
379
|
return { ok: false, reason: 'unavailable' }
|
|
369
380
|
}
|
|
370
|
-
// `@vercel/blob@2.x`'s streaming `get()` for private blobs
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
// Two round-trips per private read on Vercel until the SDK is
|
|
379
|
-
// fixed — small price vs. the alternative of 503ing every read.
|
|
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.
|
|
380
389
|
let size: number | null | undefined = res.blob?.size
|
|
381
390
|
if (size == null || size === 0) {
|
|
382
391
|
try {
|
|
383
392
|
const h = await sdk.head(path, { token })
|
|
384
393
|
size = (h as { size?: number })?.size
|
|
385
394
|
} catch (headErr) {
|
|
386
|
-
|
|
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' }
|
|
387
399
|
throw headErr
|
|
388
400
|
}
|
|
389
401
|
}
|
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(() => {
|
package/server/objstore/rest.ts
CHANGED
|
@@ -45,16 +45,14 @@ import { errStack } from '../util.ts'
|
|
|
45
45
|
|
|
46
46
|
// Server-side fault codes that should surface as 500 `io-error`
|
|
47
47
|
// rather than 400 `aborted`. `pipeline(req, ws)` rejects with the
|
|
48
|
-
// first stream error;
|
|
49
|
-
//
|
|
50
|
-
// `createWriteStream`
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
// the default `aborted` branch and the operator-facing log line
|
|
57
|
-
// carries the SDK's error message.
|
|
48
|
+
// first stream error; client-side codes (socket close, the manual
|
|
49
|
+
// `overrun`) are everything else. ENOENT is included because
|
|
50
|
+
// `createWriteStream` rejects with it if the `${tag}/.staging` dir
|
|
51
|
+
// was removed out from under us (operator / external fs activity) —
|
|
52
|
+
// a server-side state, not a client-fixable abort. Vercel-blob
|
|
53
|
+
// failures arrive without a `code` (rejected put promise → plain
|
|
54
|
+
// Error); those land in the default `aborted` branch, with the SDK
|
|
55
|
+
// error in the operator log line.
|
|
58
56
|
const IO_FAULT_CODES = new Set(['ENOSPC', 'EACCES', 'EROFS', 'EIO', 'EMFILE', 'ENFILE', 'EDQUOT', 'EPERM', 'ENOENT'])
|
|
59
57
|
|
|
60
58
|
export type ObjstoreRestDeps = {
|
|
@@ -71,35 +69,34 @@ export type ObjstoreRestDeps = {
|
|
|
71
69
|
debug: boolean
|
|
72
70
|
}
|
|
73
71
|
|
|
74
|
-
// Concurrent commits need no lock
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// slot; it never makes distinct commits wait.)
|
|
72
|
+
// Concurrent commits need no lock: the live blob is content-addressed
|
|
73
|
+
// (`${tag}/${contentHash}.bin`) so two racing commits write to
|
|
74
|
+
// DIFFERENT immutable addresses, and the commit is an atomic version
|
|
75
|
+
// compare-and-set on the live row (see commitPut in store.ts). Exactly
|
|
76
|
+
// one racer wins the CAS; the loser gets a 409 `conflict` and rebases.
|
|
77
|
+
// Holds within a process and across replicas — no in-process mutex
|
|
78
|
+
// serialises commits. (The PUT *body* takes a per-process single-
|
|
79
|
+
// writer reservation — `inFlightSids` below — but that only rejects a
|
|
80
|
+
// duplicate upload of one staging slot; distinct commits never wait.)
|
|
84
81
|
|
|
85
82
|
// Per-process single-writer guard for the REST PUT body. A put-token is
|
|
86
83
|
// a REUSABLE bearer capability (tokens.ts) valid for its whole TTL, so a
|
|
87
|
-
// client replaying it on overlapping PUTs
|
|
88
|
-
// the in-flight request, a proxy re-issuing the PUT, a double-submit
|
|
84
|
+
// client replaying it on overlapping PUTs (a retry that doesn't cancel
|
|
85
|
+
// the in-flight request, a proxy re-issuing the PUT, a double-submit)
|
|
89
86
|
// would otherwise have two requests stream into the SAME staging file
|
|
90
|
-
// (same sid). On
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
87
|
+
// (same sid). On FS, `createWriteStream(…, { flags: 'w' })` truncates,
|
|
88
|
+
// so the two writers clobber each other's bytes/size and BOTH can fail
|
|
89
|
+
// (size-mismatch 400 + promote-race 500) with neither committing.
|
|
90
|
+
// `inFlightSids` admits exactly ONE in-flight upload per staging id; a
|
|
91
|
+
// concurrent same-token PUT is rejected (409) before it can open a
|
|
92
|
+
// second writer. The slot is held only across body + commit, released
|
|
93
|
+
// in a `finally`, and bounded by the REST idle-body timeout in
|
|
94
|
+
// server/http.ts so a slow-loris can't pin it. NOT a commit lock —
|
|
95
|
+
// commits stay lock-free on the version-CAS above. Per-process: the
|
|
96
|
+
// Vercel multi-replica path leans on `allowOverwrite: true` + the
|
|
97
|
+
// commit CAS + content-addressing for cross-replica correctness (a
|
|
98
|
+
// duplicate landing on another replica can't clobber a shared local
|
|
99
|
+
// file and still loses the CAS), not on this set.
|
|
103
100
|
const inFlightSids = new Set<string>()
|
|
104
101
|
|
|
105
102
|
// `/api/objstore/${workspaceTag}/${resourceTag}` — base64url
|
|
@@ -121,14 +118,13 @@ export function matchRoute(url: string | undefined): RouteMatch | null {
|
|
|
121
118
|
|
|
122
119
|
function deny(res: ServerResponse, status: number, body: string): void {
|
|
123
120
|
// Uniform `{ error: <reason> }` JSON envelope for every failure so
|
|
124
|
-
// clients
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
// there's no signal here a probe couldn't otherwise enumerate.
|
|
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.
|
|
132
128
|
res.writeHead(status, { 'content-type': 'application/json' })
|
|
133
129
|
res.end(JSON.stringify({ error: body }))
|
|
134
130
|
}
|
|
@@ -209,21 +205,20 @@ async function handleRestPut(
|
|
|
209
205
|
// No lock: the commit's version-CAS arbitrates concurrent commits
|
|
210
206
|
// (the live blob is content-addressed, so racers can't desync
|
|
211
207
|
// metadata vs bytes — the loser surfaces a 409 `conflict`). The
|
|
212
|
-
// staging row is protected from the reaper
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
208
|
+
// staging row is protected from the reaper by its `begun_at`, not a
|
|
209
|
+
// lock: an upload under the staging TTL stays fresh through the body,
|
|
210
|
+
// and the after-body `refreshStagingBegunAt` re-extends the TTL across
|
|
211
|
+
// the commit. (An upload exceeding the TTL during the body can be
|
|
212
|
+
// reaped mid-flight → commit 410s; documented accepted tradeoff —
|
|
213
|
+
// see commitPut in store.ts.)
|
|
218
214
|
await handleRestPutBody(deps, req, res, route, payload, declared)
|
|
219
215
|
}
|
|
220
216
|
|
|
221
217
|
// Map a `commitPut` failure to its wire response. Extracted from
|
|
222
|
-
// `handleRestPutBody` to keep it under the per-function line cap
|
|
223
|
-
//
|
|
224
|
-
// exhaustiveness `never` guard at the bottom catches a
|
|
225
|
-
//
|
|
226
|
-
// updating this dispatch.
|
|
218
|
+
// `handleRestPutBody` to keep it under the per-function line cap and
|
|
219
|
+
// to make the wire-mapping ladder its own audit surface — the
|
|
220
|
+
// exhaustiveness `never` guard at the bottom catches a new
|
|
221
|
+
// `CommitPutResult` reason landing without updating this dispatch.
|
|
227
222
|
function denyCommitFailure(res: ServerResponse, result: Exclude<CommitPutResult, { ok: true }>): void {
|
|
228
223
|
if (result.reason === 'conflict') {
|
|
229
224
|
denyConflict(res, result.conflict?.version ?? null, result.conflict?.incarnation ?? null)
|
|
@@ -273,10 +268,9 @@ async function handleRestPutBody(
|
|
|
273
268
|
}
|
|
274
269
|
inFlightSids.add(payload.sid)
|
|
275
270
|
try {
|
|
276
|
-
// Stream the upload + commit, no lock
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// mutex. See the rationale in handleRestPut above.
|
|
271
|
+
// Stream the upload + commit, no lock — version-CAS arbitrates
|
|
272
|
+
// commits, the staging row stays fresh for the reaper via its
|
|
273
|
+
// `begun_at` (+ after-body refresh). See handleRestPut above.
|
|
280
274
|
const result = await runUploadAndCommit(deps, route, payload, declared, req, res)
|
|
281
275
|
if (result.handled) return
|
|
282
276
|
if (!result.commit.ok) { denyCommitFailure(res, result.commit); return }
|
|
@@ -294,15 +288,13 @@ async function handleRestPutBody(
|
|
|
294
288
|
...objectMetaWire(row),
|
|
295
289
|
}, null)
|
|
296
290
|
// Cross-instance fan-out (Neon mode). The bus payload carries only
|
|
297
|
-
// (tag, resourceTag); peers on other instances re-fetch the live
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
// notification; bus-receiver.ts drops that case silently. SQLite
|
|
305
|
-
// mode publishes to a no-op.
|
|
291
|
+
// (tag, resourceTag); peers on other instances re-fetch the live row
|
|
292
|
+
// from workspace_object to compose their broadcast. The committed
|
|
293
|
+
// row is durable by here (commitPut's version-CAS landed), so the
|
|
294
|
+
// receiver sees THIS version or a STRICTLY newer one (also valid —
|
|
295
|
+
// clients are idempotent on (resourceTag, version)), or no live row
|
|
296
|
+
// at all if a subsequent delete races the notification (bus-
|
|
297
|
+
// receiver.ts drops that silently). SQLite mode publishes to a no-op.
|
|
306
298
|
deps.publishObjPut(route.tag, route.resourceTag)
|
|
307
299
|
if (deps.debug) console.log(`objstore put → ${route.tag.slice(0, 12)}…/${route.resourceTag.slice(0, 8)}… v${row.version}`)
|
|
308
300
|
} finally {
|
|
@@ -451,7 +443,7 @@ async function openLiveSnapshot(
|
|
|
451
443
|
// bytes behind `live.content_hash` can never change underneath us —
|
|
452
444
|
// the worst a race can do is have the reaper GC an already-superseded
|
|
453
445
|
// hash just before we open it, which surfaces as openLiveReader
|
|
454
|
-
//
|
|
446
|
+
// returning `unavailable` → 503, and the client refetches. We can
|
|
455
447
|
// never serve torn or wrong bytes. (For the FS backend the open also
|
|
456
448
|
// returns a pinned fd; for the Vercel backend a fetch-backed stream.)
|
|
457
449
|
const live = await deps.handle.selectLiveOne.get(route.tag, route.resourceTag)
|
|
@@ -46,10 +46,10 @@ const DDL_LOCK_KEY_OBJSTORE_SUB = 0x6f62_6a73 // 'objs'
|
|
|
46
46
|
// defend the commitPut conflict arithmetic — a manual `UPDATE
|
|
47
47
|
// workspace_object SET version = -1` would otherwise round-trip
|
|
48
48
|
// through `num()` (which only rejects non-safe-integers) and corrupt
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
49
|
+
// version monotonicity. SQLite carries the identical CHECKs (see
|
|
50
|
+
// `store.ts`): STRICT there enforces column TYPE but NOT this value
|
|
51
|
+
// domain (`version = -1` is a valid integer STRICT accepts), so both
|
|
52
|
+
// backends need the explicit CHECKs.
|
|
53
53
|
const SCHEMA_PG = [
|
|
54
54
|
`CREATE TABLE IF NOT EXISTS workspace_object (
|
|
55
55
|
workspace_tag TEXT NOT NULL,
|