@preventive/triage 1.0.0-alpha.1 → 1.0.0-alpha.11
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/api/reap.ts +79 -0
- package/common/save-error-reason.ts +20 -7
- package/common/server-info.ts +30 -0
- package/out/brotli-fallback.js +1 -1
- package/out/client-admin.js +28 -0
- package/out/client-managed.js +1 -0
- package/out/client-sync.js +16 -13
- package/out/graph.js +5 -4
- package/out/index.html +4 -15
- package/out/prism.js +2 -2
- package/out/terminal.js +32 -28
- package/out/view.css +1 -1
- package/out/view.js +89 -52
- package/package.json +70 -54
- package/{server → server-common}/origin.ts +5 -5
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +8 -8
- package/server-e2e/cli.js +22 -0
- package/{server → server-e2e}/config.ts +21 -8
- package/{server → server-e2e}/db-neon.ts +31 -25
- package/{server → server-e2e}/db-revision-sql.ts +7 -10
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +96 -135
- package/{server → server-e2e}/http.ts +97 -9
- package/{server → server-e2e}/hub.ts +7 -8
- package/{server → server-e2e}/index.ts +67 -43
- package/{server → server-e2e}/lifecycle.ts +14 -5
- package/{server → server-e2e}/npm-proxy.ts +1 -1
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +69 -36
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +13 -15
- package/{server → server-e2e}/objstore/init.ts +47 -13
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +110 -93
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +19 -19
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/{server → server-e2e}/pubsub.ts +21 -31
- package/{server → server-e2e}/sign.ts +12 -14
- package/{server → server-e2e}/sse-server.ts +105 -73
- package/{server → server-e2e}/sse-session.ts +30 -16
- package/{server → server-e2e}/static.ts +22 -17
- package/{server → server-e2e}/sync-handlers.ts +172 -117
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/strip-types-loader.js +94 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/neon-driver.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -41,20 +41,20 @@ 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 {
|
|
44
|
+
import { deny, denyConflict } from './rest-deny.ts'
|
|
45
|
+
import { handleRestMint } from './rest-mint.ts'
|
|
46
|
+
import { debugId, debugTag, errMsg, errStack } from '../util.ts'
|
|
45
47
|
|
|
46
48
|
// Server-side fault codes that should surface as 500 `io-error`
|
|
47
49
|
// 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.
|
|
50
|
+
// first stream error; client-side codes (socket close, the manual
|
|
51
|
+
// `overrun`) are everything else. ENOENT is included because
|
|
52
|
+
// `createWriteStream` rejects with it if the `${tag}/.staging` dir
|
|
53
|
+
// was removed out from under us (operator / external fs activity) —
|
|
54
|
+
// a server-side state, not a client-fixable abort. Vercel-blob
|
|
55
|
+
// failures arrive without a `code` (rejected put promise → plain
|
|
56
|
+
// Error); those land in the default `aborted` branch, with the SDK
|
|
57
|
+
// error in the operator log line.
|
|
58
58
|
const IO_FAULT_CODES = new Set(['ENOSPC', 'EACCES', 'EROFS', 'EIO', 'EMFILE', 'ENFILE', 'EDQUOT', 'EPERM', 'ENOENT'])
|
|
59
59
|
|
|
60
60
|
export type ObjstoreRestDeps = {
|
|
@@ -68,38 +68,49 @@ export type ObjstoreRestDeps = {
|
|
|
68
68
|
// workspace_object for the full metadata (version, hash, length,
|
|
69
69
|
// signature). SQLite mode passes a no-op.
|
|
70
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>
|
|
71
83
|
debug: boolean
|
|
72
84
|
}
|
|
73
85
|
|
|
74
|
-
// Concurrent commits need no lock
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
// slot; it never makes distinct commits wait.)
|
|
86
|
+
// Concurrent commits need no lock: the live blob is content-addressed
|
|
87
|
+
// (`${tag}/${contentHash}.bin`) so two racing commits write to
|
|
88
|
+
// DIFFERENT immutable addresses, and the commit is an atomic version
|
|
89
|
+
// compare-and-set on the live row (see commitPut in store.ts). Exactly
|
|
90
|
+
// one racer wins the CAS; the loser gets a 409 `conflict` and rebases.
|
|
91
|
+
// Holds within a process and across replicas — no in-process mutex
|
|
92
|
+
// serialises commits. (The PUT *body* takes a per-process single-
|
|
93
|
+
// writer reservation — `inFlightSids` below — but that only rejects a
|
|
94
|
+
// duplicate upload of one staging slot; distinct commits never wait.)
|
|
84
95
|
|
|
85
96
|
// Per-process single-writer guard for the REST PUT body. A put-token is
|
|
86
97
|
// 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
|
|
98
|
+
// client replaying it on overlapping PUTs (a retry that doesn't cancel
|
|
99
|
+
// the in-flight request, a proxy re-issuing the PUT, a double-submit)
|
|
89
100
|
// 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
|
-
//
|
|
101
|
+
// (same sid). On FS, `createWriteStream(…, { flags: 'w' })` truncates,
|
|
102
|
+
// so the two writers clobber each other's bytes/size and BOTH can fail
|
|
103
|
+
// (size-mismatch 400 + promote-race 500) with neither committing.
|
|
104
|
+
// `inFlightSids` admits exactly ONE in-flight upload per staging id; a
|
|
105
|
+
// concurrent same-token PUT is rejected (409) before it can open a
|
|
106
|
+
// second writer. The slot is held only across body + commit, released
|
|
107
|
+
// in a `finally`, and bounded by the REST idle-body timeout in
|
|
108
|
+
// server-e2e/http.ts so a slow-loris can't pin it. NOT a commit lock —
|
|
109
|
+
// commits stay lock-free on the version-CAS above. Per-process: the
|
|
110
|
+
// Vercel multi-replica path leans on `allowOverwrite: true` + the
|
|
111
|
+
// commit CAS + content-addressing for cross-replica correctness (a
|
|
112
|
+
// duplicate landing on another replica can't clobber a shared local
|
|
113
|
+
// file and still loses the CAS), not on this set.
|
|
103
114
|
const inFlightSids = new Set<string>()
|
|
104
115
|
|
|
105
116
|
// `/api/objstore/${workspaceTag}/${resourceTag}` — base64url
|
|
@@ -119,35 +130,21 @@ export function matchRoute(url: string | undefined): RouteMatch | null {
|
|
|
119
130
|
return { tag: tag!, resourceTag: resourceTag! }
|
|
120
131
|
}
|
|
121
132
|
|
|
122
|
-
function deny(res: ServerResponse, status: number, body: string): void {
|
|
123
|
-
// Uniform `{ error: <reason> }` JSON envelope for every failure so
|
|
124
|
-
// clients have one shape to parse. Status + reason are NOT
|
|
125
|
-
// intentionally indistinguishable across causes — 401, 404, 405,
|
|
126
|
-
// 410, 411, 500 each map to a documented reason in server/README.md
|
|
127
|
-
// and the client decides recovery from the code. Defense against
|
|
128
|
-
// probe-driven distinguishing isn't a property the relay aims for;
|
|
129
|
-
// every error reason is reachable only after the route + bearer-
|
|
130
|
-
// token check passes (or as 401/404 from the public surface), so
|
|
131
|
-
// there's no signal here a probe couldn't otherwise enumerate.
|
|
132
|
-
res.writeHead(status, { 'content-type': 'application/json' })
|
|
133
|
-
res.end(JSON.stringify({ error: body }))
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// Variant of `deny` that augments the JSON envelope with the live
|
|
137
|
-
// row's `currentVersion` + `currentIncarnation` so a REST PUT 409 lets
|
|
138
|
-
// the caller rebase onto the right precondition token. Without this the
|
|
139
|
-
// client only learns the slot is occupied — not at what (version,
|
|
140
|
-
// incarnation) — and retries blindly against a non-empty slot, looping
|
|
141
|
-
// indefinitely against a live row. Symmetric with the WS plane's
|
|
142
|
-
// `objstore-conflict` envelope.
|
|
143
|
-
function denyConflict(res: ServerResponse, currentVersion: number | null, currentIncarnation: string | null): void {
|
|
144
|
-
res.writeHead(409, { 'content-type': 'application/json' })
|
|
145
|
-
res.end(JSON.stringify({ error: 'conflict', currentVersion, currentIncarnation }))
|
|
146
|
-
}
|
|
147
|
-
|
|
148
133
|
export async function handleRest(deps: ObjstoreRestDeps, req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
149
134
|
const route = matchRoute(req.url)
|
|
150
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
|
+
}
|
|
151
148
|
const token = extractBearer(req.headers['authorization'])
|
|
152
149
|
if (!token) { deny(res, 401, 'unauthorized'); return }
|
|
153
150
|
const payload = verifyToken(deps.secret, token)
|
|
@@ -209,21 +206,20 @@ async function handleRestPut(
|
|
|
209
206
|
// No lock: the commit's version-CAS arbitrates concurrent commits
|
|
210
207
|
// (the live blob is content-addressed, so racers can't desync
|
|
211
208
|
// metadata vs bytes — the loser surfaces a 409 `conflict`). The
|
|
212
|
-
// staging row is protected from the reaper
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
//
|
|
209
|
+
// staging row is protected from the reaper by its `begun_at`, not a
|
|
210
|
+
// lock: an upload under the staging TTL stays fresh through the body,
|
|
211
|
+
// and the after-body `refreshStagingBegunAt` re-extends the TTL across
|
|
212
|
+
// the commit. (An upload exceeding the TTL during the body can be
|
|
213
|
+
// reaped mid-flight → commit 410s; documented accepted tradeoff —
|
|
214
|
+
// see commitPut in store.ts.)
|
|
218
215
|
await handleRestPutBody(deps, req, res, route, payload, declared)
|
|
219
216
|
}
|
|
220
217
|
|
|
221
218
|
// 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.
|
|
219
|
+
// `handleRestPutBody` to keep it under the per-function line cap and
|
|
220
|
+
// to make the wire-mapping ladder its own audit surface — the
|
|
221
|
+
// exhaustiveness `never` guard at the bottom catches a new
|
|
222
|
+
// `CommitPutResult` reason landing without updating this dispatch.
|
|
227
223
|
function denyCommitFailure(res: ServerResponse, result: Exclude<CommitPutResult, { ok: true }>): void {
|
|
228
224
|
if (result.reason === 'conflict') {
|
|
229
225
|
denyConflict(res, result.conflict?.version ?? null, result.conflict?.incarnation ?? null)
|
|
@@ -273,10 +269,9 @@ async function handleRestPutBody(
|
|
|
273
269
|
}
|
|
274
270
|
inFlightSids.add(payload.sid)
|
|
275
271
|
try {
|
|
276
|
-
// Stream the upload + commit, no lock
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// mutex. See the rationale in handleRestPut above.
|
|
272
|
+
// Stream the upload + commit, no lock — version-CAS arbitrates
|
|
273
|
+
// commits, the staging row stays fresh for the reaper via its
|
|
274
|
+
// `begun_at` (+ after-body refresh). See handleRestPut above.
|
|
280
275
|
const result = await runUploadAndCommit(deps, route, payload, declared, req, res)
|
|
281
276
|
if (result.handled) return
|
|
282
277
|
if (!result.commit.ok) { denyCommitFailure(res, result.commit); return }
|
|
@@ -294,15 +289,13 @@ async function handleRestPutBody(
|
|
|
294
289
|
...objectMetaWire(row),
|
|
295
290
|
}, null)
|
|
296
291
|
// 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.
|
|
292
|
+
// (tag, resourceTag); peers on other instances re-fetch the live row
|
|
293
|
+
// from workspace_object to compose their broadcast. The committed
|
|
294
|
+
// row is durable by here (commitPut's version-CAS landed), so the
|
|
295
|
+
// receiver sees THIS version or a STRICTLY newer one (also valid —
|
|
296
|
+
// clients are idempotent on (resourceTag, version)), or no live row
|
|
297
|
+
// at all if a subsequent delete races the notification (bus-
|
|
298
|
+
// receiver.ts drops that silently). SQLite mode publishes to a no-op.
|
|
306
299
|
deps.publishObjPut(route.tag, route.resourceTag)
|
|
307
300
|
if (deps.debug) console.log(`objstore put → ${route.tag.slice(0, 12)}…/${route.resourceTag.slice(0, 8)}… v${row.version}`)
|
|
308
301
|
} finally {
|
|
@@ -439,7 +432,10 @@ async function runUploadAndCommit(
|
|
|
439
432
|
type GetOpened =
|
|
440
433
|
| { reason: 'ok'; reader: LiveReader }
|
|
441
434
|
| { reason: 'not-found' }
|
|
442
|
-
|
|
435
|
+
// `detail` is a short non-sensitive cause tag (backend sub-reason +
|
|
436
|
+
// content-hash prefix) the GET handler logs so a 503 is diagnosable —
|
|
437
|
+
// every byte-side failure collapses to the same wire 503 otherwise.
|
|
438
|
+
| { reason: 'unavailable'; detail: string }
|
|
443
439
|
|
|
444
440
|
async function openLiveSnapshot(
|
|
445
441
|
deps: ObjstoreRestDeps, route: RouteMatch, payload: { ver: number; inc: string },
|
|
@@ -451,22 +447,32 @@ async function openLiveSnapshot(
|
|
|
451
447
|
// bytes behind `live.content_hash` can never change underneath us —
|
|
452
448
|
// the worst a race can do is have the reaper GC an already-superseded
|
|
453
449
|
// hash just before we open it, which surfaces as openLiveReader
|
|
454
|
-
//
|
|
450
|
+
// returning `unavailable` → 503, and the client refetches. We can
|
|
455
451
|
// never serve torn or wrong bytes. (For the FS backend the open also
|
|
456
452
|
// returns a pinned fd; for the Vercel backend a fetch-backed stream.)
|
|
457
453
|
const live = await deps.handle.selectLiveOne.get(route.tag, route.resourceTag)
|
|
458
454
|
if (!live || live.version !== payload.ver || live.incarnation !== payload.inc) return { reason: 'not-found' }
|
|
455
|
+
// Tag the content hash into every `unavailable` detail so an operator
|
|
456
|
+
// can go check the byte store directly for THIS blob (gone → reaper /
|
|
457
|
+
// deletion; present → transient read fault).
|
|
458
|
+
const hashTag = `hash=${debugId(live.content_hash)}`
|
|
459
459
|
let opened
|
|
460
460
|
try { opened = await deps.handle.blob.openLiveReader(route.tag, live.content_hash) }
|
|
461
|
-
|
|
462
|
-
|
|
461
|
+
// Length-cap the thrown error text: today a non-BlobNotFound SDK throw
|
|
462
|
+
// (BlobServiceNotAvailable / store-not-found) carries no credential
|
|
463
|
+
// (the RW token rides the Authorization header, never `.message`), but
|
|
464
|
+
// a future SDK could embed a signed URL / token fragment — bound the
|
|
465
|
+
// log line so it can't dump one verbatim. Mirrors the `.slice(0, 200)`
|
|
466
|
+
// cap used on SDK error text in blob-vercel.ts.
|
|
467
|
+
catch (err) { return { reason: 'unavailable', detail: `open-threw ${hashTag} ${String(errMsg(err)).slice(0, 200)}` } }
|
|
468
|
+
if (!opened.ok) return { reason: 'unavailable', detail: `${opened.detail ?? 'backend'} ${hashTag}` }
|
|
463
469
|
// Size mismatch between the live row and the on-storage bytes
|
|
464
470
|
// is a transient inconsistency — reaper will reconcile. Close
|
|
465
471
|
// the reader before returning so we don't leak the fd / fetch
|
|
466
472
|
// reader. PR #4 review H8.
|
|
467
473
|
if (opened.reader.size !== live.content_length) {
|
|
468
474
|
await opened.reader.close().catch(() => {})
|
|
469
|
-
return { reason: 'unavailable' }
|
|
475
|
+
return { reason: 'unavailable', detail: `size-mismatch row=${live.content_length} blob=${opened.reader.size} ${hashTag}` }
|
|
470
476
|
}
|
|
471
477
|
return { reason: 'ok', reader: opened.reader }
|
|
472
478
|
}
|
|
@@ -486,8 +492,19 @@ async function handleRestGet(
|
|
|
486
492
|
// If the live row is there but the bytes are missing / wrong size,
|
|
487
493
|
// it's a transient inconsistency the reaper will sort out — 503
|
|
488
494
|
// (vs 404) tells the client this is a server-side state, not a
|
|
489
|
-
// "the resource truly isn't there" answer.
|
|
490
|
-
|
|
495
|
+
// "the resource truly isn't there" answer. Log the cause
|
|
496
|
+
// UNCONDITIONALLY (not behind `debug`): a 503 means a live row whose
|
|
497
|
+
// bytes can't be served, and the wire response can't distinguish a
|
|
498
|
+
// permanent loss (reaper GC'd referenced bytes) from a transient read
|
|
499
|
+
// fault. `detail` carries the backend sub-reason + content-hash prefix
|
|
500
|
+
// so an operator can tell which — the only server-side breadcrumb for
|
|
501
|
+
// the "all data turned into 503" failure. (Volume is bounded: a 503 is
|
|
502
|
+
// an error path; a workspace-wide outage is exactly when these are
|
|
503
|
+
// wanted.)
|
|
504
|
+
if (opened.reason === 'unavailable') {
|
|
505
|
+
console.warn(`objstore-get: 503 unavailable ${debugTag(route.tag)}/${route.resourceTag.slice(0, 8)}… v${payload.ver} ${opened.detail}`)
|
|
506
|
+
deny(res, 503, 'unavailable'); return
|
|
507
|
+
}
|
|
491
508
|
res.writeHead(200, {
|
|
492
509
|
'content-type': 'application/octet-stream',
|
|
493
510
|
'content-length': String(opened.reader.size),
|
|
@@ -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
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// dialect on the wire.
|
|
5
5
|
//
|
|
6
6
|
// `@neondatabase/serverless` is an OPTIONAL peer dep — selected by
|
|
7
|
-
// the `DATABASE_URL` branch in `server/index.ts`. The peer dep
|
|
7
|
+
// the `DATABASE_URL` branch in `server-e2e/index.ts`. The peer dep
|
|
8
8
|
// itself is loaded lazily inside `openNeonObjstore` below. A single
|
|
9
9
|
// Neon project / database holds both `workspace_revision` and the
|
|
10
10
|
// two objstore tables; each plane opens its own stateless
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
// release.
|
|
14
14
|
//
|
|
15
15
|
// Byte plane: passed in as a `BlobBackend` (./blob.ts). The
|
|
16
|
-
// supported pairings selected by server/index.ts are:
|
|
16
|
+
// supported pairings selected by server-e2e/index.ts are:
|
|
17
17
|
// - Neon + Vercel Blob Private Storage (multi-replica, the only
|
|
18
18
|
// pairing that survives a replica restart without local-disk
|
|
19
19
|
// coordination). Activated by setting BLOB_READ_WRITE_TOKEN
|
|
@@ -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,
|
|
@@ -121,7 +121,7 @@ type StagingRow = {
|
|
|
121
121
|
|
|
122
122
|
function buildSelectStaging(sql: NeonSql): GetStmt<[string, string, string], StagingRow> {
|
|
123
123
|
return { get: async (tag, resourceTag, stagingId) => {
|
|
124
|
-
const rows = await sql(
|
|
124
|
+
const rows = await sql.query(
|
|
125
125
|
`SELECT prev_version, prev_incarnation, expected_length, content_hash, signature, begun_at
|
|
126
126
|
FROM workspace_object_staging
|
|
127
127
|
WHERE workspace_tag = $1 AND resource_tag = $2 AND staging_id = $3`,
|
|
@@ -162,7 +162,7 @@ function buildDeleteStaging(sql: NeonSql): RunStmt<[string, string, string]> {
|
|
|
162
162
|
// Bind order: (tag, res, sid, staleBefore).
|
|
163
163
|
function buildDeleteStagingIfStale(sql: NeonSql): GetStmt<[string, string, string, number], { ok: number }> {
|
|
164
164
|
return { get: async (tag, resourceTag, stagingId, staleBefore) => {
|
|
165
|
-
const rows = await sql(
|
|
165
|
+
const rows = await sql.query(
|
|
166
166
|
`DELETE FROM workspace_object_staging
|
|
167
167
|
WHERE workspace_tag = $1 AND resource_tag = $2 AND staging_id = $3 AND begun_at < $4
|
|
168
168
|
RETURNING 1 AS ok`,
|
|
@@ -175,7 +175,7 @@ function buildDeleteStagingIfStale(sql: NeonSql): GetStmt<[string, string, strin
|
|
|
175
175
|
|
|
176
176
|
function buildSelectLive(sql: NeonSql): AllStmt<[string], LiveDbRow> {
|
|
177
177
|
return { all: async (tag) => {
|
|
178
|
-
const rows = await sql(
|
|
178
|
+
const rows = await sql.query(
|
|
179
179
|
`SELECT resource_tag, version, incarnation, content_hash, content_length,
|
|
180
180
|
signature, put_at
|
|
181
181
|
FROM workspace_object
|
|
@@ -189,7 +189,7 @@ function buildSelectLive(sql: NeonSql): AllStmt<[string], LiveDbRow> {
|
|
|
189
189
|
|
|
190
190
|
function buildSelectLiveOne(sql: NeonSql): GetStmt<[string, string], LiveDbRow> {
|
|
191
191
|
return { get: async (tag, resourceTag) => {
|
|
192
|
-
const rows = await sql(
|
|
192
|
+
const rows = await sql.query(
|
|
193
193
|
`SELECT resource_tag, version, incarnation, content_hash, content_length,
|
|
194
194
|
signature, put_at
|
|
195
195
|
FROM workspace_object
|
|
@@ -208,7 +208,7 @@ function buildSelectLiveOne(sql: NeonSql): GetStmt<[string, string], LiveDbRow>
|
|
|
208
208
|
// (tag, res, hash, len, sig, put_at); version is the literal 1.
|
|
209
209
|
function buildInsertLiveIfAbsent(sql: NeonSql): GetStmt<[string, string, string, string, number, string, number], { ok: number }> {
|
|
210
210
|
return { get: async (tag, resourceTag, incarnation, contentHash, contentLength, signature, putAt) => {
|
|
211
|
-
const rows = await sql(
|
|
211
|
+
const rows = await sql.query(
|
|
212
212
|
`INSERT INTO workspace_object
|
|
213
213
|
(workspace_tag, resource_tag, version, incarnation, content_hash, content_length,
|
|
214
214
|
signature, put_at)
|
|
@@ -230,7 +230,7 @@ function buildInsertLiveIfAbsent(sql: NeonSql): GetStmt<[string, string, string,
|
|
|
230
230
|
// (tag, res, nextVersion, hash, len, sig, put_at, expectedVersion).
|
|
231
231
|
function buildUpdateLiveCAS(sql: NeonSql): GetStmt<[string, string, number, string, number, string, number, number, string], { ok: number }> {
|
|
232
232
|
return { get: async (tag, resourceTag, nextVersion, contentHash, contentLength, signature, putAt, expectedVersion, expectedIncarnation) => {
|
|
233
|
-
const rows = await sql(
|
|
233
|
+
const rows = await sql.query(
|
|
234
234
|
`UPDATE workspace_object
|
|
235
235
|
SET version = $3,
|
|
236
236
|
content_hash = $4,
|
|
@@ -252,7 +252,7 @@ function buildUpdateLiveCAS(sql: NeonSql): GetStmt<[string, string, number, stri
|
|
|
252
252
|
// not-found, never a lost update.
|
|
253
253
|
function buildDeleteLiveCAS(sql: NeonSql): GetStmt<[string, string, number, string], { ok: number }> {
|
|
254
254
|
return { get: async (tag, resourceTag, expectedVersion, expectedIncarnation) => {
|
|
255
|
-
const rows = await sql(
|
|
255
|
+
const rows = await sql.query(
|
|
256
256
|
`DELETE FROM workspace_object
|
|
257
257
|
WHERE workspace_tag = $1 AND resource_tag = $2 AND version = $3 AND incarnation = $4
|
|
258
258
|
RETURNING 1 AS ok`,
|
|
@@ -268,7 +268,7 @@ function buildListAllStaging(sql: NeonSql): AllStmt<[number], { workspace_tag: s
|
|
|
268
268
|
// `WHERE begun_at < $1` uses workspace_object_staging_begun_at_idx
|
|
269
269
|
// so the reaper sweep is O(stale-rows) cluster-wide. DB-layout
|
|
270
270
|
// audit follow-up.
|
|
271
|
-
const rows = await sql(
|
|
271
|
+
const rows = await sql.query(
|
|
272
272
|
`SELECT workspace_tag, resource_tag, staging_id, begun_at
|
|
273
273
|
FROM workspace_object_staging
|
|
274
274
|
WHERE begun_at < $1`,
|
|
@@ -285,7 +285,7 @@ function buildListAllStaging(sql: NeonSql): AllStmt<[number], { workspace_tag: s
|
|
|
285
285
|
|
|
286
286
|
function buildListLiveTags(sql: NeonSql): AllStmt<[], { workspace_tag: string }> {
|
|
287
287
|
return { all: async () => {
|
|
288
|
-
const rows = await sql(
|
|
288
|
+
const rows = await sql.query(
|
|
289
289
|
`SELECT DISTINCT workspace_tag FROM workspace_object`,
|
|
290
290
|
[],
|
|
291
291
|
) as Array<Record<string, unknown>>
|
|
@@ -295,7 +295,7 @@ function buildListLiveTags(sql: NeonSql): AllStmt<[], { workspace_tag: string }>
|
|
|
295
295
|
|
|
296
296
|
function buildCountLive(sql: NeonSql): GetStmt<[string], { c: number }> {
|
|
297
297
|
return { get: async (tag) => {
|
|
298
|
-
const rows = await sql(
|
|
298
|
+
const rows = await sql.query(
|
|
299
299
|
`SELECT COUNT(*) AS c FROM workspace_object WHERE workspace_tag = $1`,
|
|
300
300
|
[tag],
|
|
301
301
|
) as Array<{ c: number | string | bigint }>
|
|
@@ -309,7 +309,7 @@ export async function openNeonObjstore(connectionString: string, blob: BlobBacke
|
|
|
309
309
|
// local `../neon-driver.ts` re-export wrapper so the peer dep stays
|
|
310
310
|
// optional AND tests can mock the driver (a local path is always
|
|
311
311
|
// resolvable; the bare specifier isn't when the dep is absent). See
|
|
312
|
-
// `server/neon-driver.ts`. Cast through `unknown` because the
|
|
312
|
+
// `server-e2e/neon-driver.ts`. Cast through `unknown` because the
|
|
313
313
|
// wrapper's `export *` re-exports a `@ts-ignore`'d module.
|
|
314
314
|
const mod = (await import('../neon-driver.ts')) as unknown as { neon: (url: string) => NeonSql }
|
|
315
315
|
const sql: NeonSql = mod.neon(connectionString)
|
|
@@ -324,8 +324,8 @@ export async function openNeonObjstore(connectionString: string, blob: BlobBacke
|
|
|
324
324
|
// advisory lock so two replicas booting concurrently serialize
|
|
325
325
|
// their DDL (the advisory lock releases at COMMIT).
|
|
326
326
|
await sql.transaction([
|
|
327
|
-
sql(`SELECT pg_advisory_xact_lock($1, $2)`, [DDL_LOCK_KEY_OBJSTORE, DDL_LOCK_KEY_OBJSTORE_SUB]),
|
|
328
|
-
...SCHEMA_PG.map((stmt) => sql(stmt, [])),
|
|
327
|
+
sql.query(`SELECT pg_advisory_xact_lock($1, $2)`, [DDL_LOCK_KEY_OBJSTORE, DDL_LOCK_KEY_OBJSTORE_SUB]),
|
|
328
|
+
...SCHEMA_PG.map((stmt) => sql.query(stmt, [])),
|
|
329
329
|
])
|
|
330
330
|
|
|
331
331
|
return {
|