@preventive/triage 1.0.0-alpha.3 → 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 +2 -2
- package/out/view.css +1 -1
- package/out/view.js +52 -50
- 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/blob-fs.ts +2 -2
- package/server/objstore/blob-vercel.ts +5 -5
- package/server/objstore/blob.ts +8 -1
- package/server/objstore/init.ts +26 -1
- package/server/objstore/reaper.ts +30 -10
- package/server/objstore/rest.ts +57 -32
- 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/server/util.ts +9 -0
package/server/sync-handlers.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// entrypoint so the protocol logic (the save pipeline, the
|
|
6
6
|
// subscribe/catch-up path) is one cohesive, testable unit.
|
|
7
7
|
|
|
8
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
9
|
+
import { Buffer } from 'node:buffer'
|
|
8
10
|
import type { WebSocket } from 'ws'
|
|
9
11
|
import { SAVE_ERROR_REASONS, type SaveErrorReason } from '../common/save-error-reason.ts'
|
|
10
12
|
import { type Handle, type RevisionRow, chainFrom, commitRevision, revisionExists } from './db.ts'
|
|
@@ -24,6 +26,47 @@ type WireRevision = {
|
|
|
24
26
|
signature: string
|
|
25
27
|
}
|
|
26
28
|
|
|
29
|
+
// Structured result of the shared save pipeline (`commitSave`), rendered
|
|
30
|
+
// per transport: WS → protocol frames; REST → JSON + HTTP status.
|
|
31
|
+
type SaveOutcome =
|
|
32
|
+
| { kind: 'rejected' } // malformed / bad sig → WS drop / REST 400
|
|
33
|
+
| { kind: 'ack'; id: string; base: string | null } // committed → save-ack / 200
|
|
34
|
+
| { kind: 'duplicate'; id: string; base: string | null } // replay → ack-only / 200
|
|
35
|
+
| { kind: 'too-large'; base: string | null } // ciphertext over cap → save-error / 413
|
|
36
|
+
| { kind: 'unauthorized'; base: string | null } // new-workspace gate → unauthorized / 401
|
|
37
|
+
| { kind: 'stale-base'; base: string | null; revisions: WireRevision[] } // conflict → state+error / 409
|
|
38
|
+
|
|
39
|
+
// Hard cap on a `POST /api/sync/save` JSON body. The save frame is the small
|
|
40
|
+
// fields + a base64 ciphertext capped at MAX_CIPHERTEXT_LEN (2 MiB); 4 MiB
|
|
41
|
+
// (the WS plane's maxPayload / the SSE plane's maxBodyBytes) leaves headroom
|
|
42
|
+
// for the envelope so the in-pipeline size policy — not the reader — decides
|
|
43
|
+
// `too-large`. Bounds the read so a hostile client can't stream an unbounded
|
|
44
|
+
// body into memory before the parse.
|
|
45
|
+
const SAVE_BODY_MAX = 4 * 1024 * 1024
|
|
46
|
+
|
|
47
|
+
// Read a JSON request body up to `SAVE_BODY_MAX`, returning the parsed value
|
|
48
|
+
// or null on overflow / parse failure / read error (mirrors the objstore
|
|
49
|
+
// mint reader). The REST save body is the only body this module reads.
|
|
50
|
+
async function readSaveBody(req: IncomingMessage): Promise<unknown> {
|
|
51
|
+
const chunks: Buffer[] = []
|
|
52
|
+
let total = 0
|
|
53
|
+
try {
|
|
54
|
+
for await (const chunk of req) {
|
|
55
|
+
const buf = chunk as Buffer
|
|
56
|
+
total += buf.length
|
|
57
|
+
if (total > SAVE_BODY_MAX) return null
|
|
58
|
+
chunks.push(buf)
|
|
59
|
+
}
|
|
60
|
+
} catch { return null }
|
|
61
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) }
|
|
62
|
+
catch { return null }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function respondJson(res: ServerResponse, status: number, obj: object): void {
|
|
66
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
67
|
+
res.end(JSON.stringify(obj))
|
|
68
|
+
}
|
|
69
|
+
|
|
27
70
|
export type SyncHandlersDeps = {
|
|
28
71
|
handle: Handle
|
|
29
72
|
send: (socket: WebSocket, msg: object) => void
|
|
@@ -38,6 +81,11 @@ export type SyncHandlersDeps = {
|
|
|
38
81
|
subscribe: (socket: WebSocket, tag: string) => void
|
|
39
82
|
getNonce: (socket: WebSocket) => string | undefined
|
|
40
83
|
requiresAuth: (socket: WebSocket) => boolean
|
|
84
|
+
// Whether an operator password is configured. The REST save plane's
|
|
85
|
+
// new-workspace gate (it has no socket to read operator-auth state from)
|
|
86
|
+
// collapses to `passwordConfigured && workspace-new` — the socket-less
|
|
87
|
+
// analog of `requiresAuth`, matching the objstore `restPutGate`.
|
|
88
|
+
passwordConfigured: boolean
|
|
41
89
|
sendUnauthorized: (socket: WebSocket, ctx: UnauthorizedContext) => void
|
|
42
90
|
workspaceExists: (tag: string) => Promise<boolean>
|
|
43
91
|
// Objstore inventory snapshot for a workspace tag, as wire rows. The
|
|
@@ -52,6 +100,10 @@ export type SyncHandlersDeps = {
|
|
|
52
100
|
|
|
53
101
|
export type SyncHandlers = {
|
|
54
102
|
handleSave: (socket: WebSocket, msg: SaveMsg) => Promise<void>
|
|
103
|
+
// Session-independent REST save plane (`POST /api/sync/save`), wired into
|
|
104
|
+
// the HTTP dispatcher in server/http.ts. Runs the same pipeline as
|
|
105
|
+
// `handleSave` and renders the outcome as a JSON response.
|
|
106
|
+
handleSaveRest: (req: IncomingMessage, res: ServerResponse) => Promise<void>
|
|
55
107
|
handleSubscribe: (socket: WebSocket, msg: SubscribeMsg) => Promise<void>
|
|
56
108
|
// Exported because the dispatcher's inflight-cap `busy` NACK path
|
|
57
109
|
// emits a save-error too (the only emit site outside this module).
|
|
@@ -59,7 +111,7 @@ export type SyncHandlers = {
|
|
|
59
111
|
}
|
|
60
112
|
|
|
61
113
|
export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
62
|
-
const { handle, send, broadcast, publishRevision, subscribe, getNonce, requiresAuth, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
|
|
114
|
+
const { handle, send, broadcast, publishRevision, subscribe, getNonce, requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
|
|
63
115
|
|
|
64
116
|
// Typed wrapper for the three `workspace-save-error` emit sites
|
|
65
117
|
// (too-large at handleSave, stale-base after the catch-up, busy at
|
|
@@ -97,9 +149,24 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
97
149
|
return revisions.map((r) => ({ ...r, keyframe: r.keyframe === 1 }))
|
|
98
150
|
}
|
|
99
151
|
|
|
100
|
-
|
|
152
|
+
// Transport-agnostic save pipeline, shared by the WS `handleSave` renderer
|
|
153
|
+
// and the REST `handleSaveRest` renderer. Runs the full validate → precheck
|
|
154
|
+
// → sig-verify → size/auth gates → commit → broadcast pipeline and returns a
|
|
155
|
+
// structured `SaveOutcome` the caller renders for its transport. Two params
|
|
156
|
+
// abstract the transport:
|
|
157
|
+
// - `authRequired`: the new-workspace gate decision (WS: requiresAuth(
|
|
158
|
+
// socket); REST: passwordConfigured — a REST request can't be operator-
|
|
159
|
+
// authorised, so the gate collapses to "password set AND workspace new").
|
|
160
|
+
// - `except`: the broadcast exclusion — the originating socket on the WS
|
|
161
|
+
// path (so it isn't echoed its own save), or null on the REST path (the
|
|
162
|
+
// request isn't a subscriber socket; the originator's own echo lands on
|
|
163
|
+
// its subscription stream and is an idempotent no-op — applyChainToBase
|
|
164
|
+
// skips a revision whose id already equals the client's baseRevision,
|
|
165
|
+
// and a same-content re-apply converges. Matches objstore-deleted's
|
|
166
|
+
// `except: null`).
|
|
167
|
+
async function commitSave(msg: SaveMsg, authRequired: boolean, except: WebSocket | null): Promise<SaveOutcome> {
|
|
101
168
|
// `base` is `string | null`; null is the keyframe-root marker.
|
|
102
|
-
if (!validTagSigBase(msg.workspaceTag, MAX_FIELD_LEN) || !validNonce(msg.nonce, MAX_FIELD_LEN) || !validCiphertextShape(msg.ciphertext) || !validTagSigBase(msg.signature, MAX_FIELD_LEN) || (msg.base != null && !validTagSigBase(msg.base, MAX_FIELD_LEN))) return
|
|
169
|
+
if (!validTagSigBase(msg.workspaceTag, MAX_FIELD_LEN) || !validNonce(msg.nonce, MAX_FIELD_LEN) || !validCiphertextShape(msg.ciphertext) || !validTagSigBase(msg.signature, MAX_FIELD_LEN) || (msg.base != null && !validTagSigBase(msg.base, MAX_FIELD_LEN))) return { kind: 'rejected' }
|
|
103
170
|
// Compute canonical bytes + content-addressed id ONCE, then thread
|
|
104
171
|
// both through the precheck → sig verify → commit pipeline:
|
|
105
172
|
// 1. canonicalSave (sync, throws on lone-surrogate input)
|
|
@@ -114,72 +181,43 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
114
181
|
// 6. commitRevision — re-checks dup + base + inserts via a single
|
|
115
182
|
// gated INSERT (dup gate + head-equals-base gate + the
|
|
116
183
|
// server-assigned seq folded into one statement) — NO write
|
|
117
|
-
// lock.
|
|
118
|
-
// collapse into that one statement, whose head-check and
|
|
119
|
-
// MAX(seq) read one snapshot; the `UNIQUE(workspace_tag, seq)`
|
|
120
|
-
// PK rejects any racer that computed the same seq. So two
|
|
121
|
-
// concurrent saves with the same `base` and different ids
|
|
122
|
-
// can't both insert (the loser's `head IS base` gate fails →
|
|
123
|
-
// `stale-base`, no chain fork even though UNIQUE is on id, not
|
|
124
|
-
// base), and two concurrent same-id retransmits resolve to one
|
|
125
|
-
// `inserted` + one `duplicate` with no UNIQUE throw escaping.
|
|
126
|
-
// See `commitRevisionSqlite` / `tryCommitNeon` in db*.ts.
|
|
184
|
+
// lock. See `commitRevisionSqlite` / `tryCommitNeon` in db*.ts.
|
|
127
185
|
let canonical: Uint8Array<ArrayBuffer>
|
|
128
|
-
try { canonical = canonicalSave(msg) } catch { return }
|
|
186
|
+
try { canonical = canonicalSave(msg) } catch { return { kind: 'rejected' } }
|
|
129
187
|
const id = await computeRevisionIdFromCanonical(canonical)
|
|
130
188
|
const tag = msg.workspaceTag
|
|
189
|
+
const baseNorm = msg.base ?? null
|
|
131
190
|
if (await revisionExists(handle, tag, id)) {
|
|
132
191
|
if (debug) console.log(`save (precheck dup ${id.slice(0, 8)}…) → ack-only`)
|
|
133
|
-
|
|
134
|
-
return
|
|
192
|
+
return { kind: 'duplicate', id, base: baseNorm }
|
|
135
193
|
}
|
|
136
194
|
if (!await verifyEd25519(tag, canonical, msg.signature)) {
|
|
137
195
|
if (debug) console.warn('reject save: bad signature', debugTag(tag))
|
|
138
|
-
return
|
|
196
|
+
return { kind: 'rejected' }
|
|
139
197
|
}
|
|
140
198
|
// Size policy — emit an explicit error so the client can surface
|
|
141
199
|
// the failure to the user. Without this, an oversized save hangs
|
|
142
200
|
// forever in the client's `pending` slot (no ack, no rebase).
|
|
143
201
|
if (msg.ciphertext.length > MAX_CIPHERTEXT_LEN) {
|
|
144
202
|
if (debug) console.warn(`reject save: ciphertext too large (${msg.ciphertext.length} > ${MAX_CIPHERTEXT_LEN})`)
|
|
145
|
-
|
|
146
|
-
return
|
|
203
|
+
return { kind: 'too-large', base: baseNorm }
|
|
147
204
|
}
|
|
148
205
|
// Auth gate for the FIRST action against a workspace tag that
|
|
149
|
-
// doesn't yet exist on the server
|
|
150
|
-
//
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
//
|
|
157
|
-
// RACE: `workspaceExists` reads at a different moment than the
|
|
158
|
-
// commit's gated INSERT (a plain TOCTOU — there is no lock spanning
|
|
159
|
-
// the two). Under concurrent saves on a fresh tag, an
|
|
160
|
-
// unauthenticated socket whose `workspaceExists` observes "true"
|
|
161
|
-
// (because an authenticated peer's commit landed between this
|
|
162
|
-
// socket's check and its commit) skips the gate and commits as the
|
|
163
|
-
// second writer. Accepted: the unauthenticated peer still had to
|
|
164
|
-
// produce a valid Ed25519 signature (= holds the workspace seed),
|
|
165
|
-
// and "two concurrent writes both authorising" is the worst case.
|
|
166
|
-
// Tightening would require folding the gate into the commit
|
|
167
|
-
// statement itself and is not worth the layer crossing for the
|
|
168
|
-
// soft-policy guarantee.
|
|
169
|
-
if (requiresAuth(socket) && !await workspaceExists(tag)) {
|
|
206
|
+
// doesn't yet exist on the server. Checked AFTER sig verify so the
|
|
207
|
+
// `unauthorized` outcome only reaches a legitimate signer; shape /
|
|
208
|
+
// sig attacks still drop silently. The TOCTOU between `workspaceExists`
|
|
209
|
+
// and the commit's gated INSERT is an accepted soft-policy race — a
|
|
210
|
+
// racer still had to produce a valid Ed25519 signature (= holds the
|
|
211
|
+
// seed). Same gate the objstore put-begin applies.
|
|
212
|
+
if (authRequired && !await workspaceExists(tag)) {
|
|
170
213
|
if (debug) console.warn(`reject save: unauthorized (new workspace ${debugTag(tag)})`)
|
|
171
|
-
|
|
172
|
-
return
|
|
214
|
+
return { kind: 'unauthorized', base: baseNorm }
|
|
173
215
|
}
|
|
174
|
-
// Save does NOT subscribe the
|
|
175
|
-
//
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
// ack-only, doesn't reject the socket). Explicit `workspace-subscribe`
|
|
180
|
-
// (signs the per-connection challenge nonce) is the ONLY attach path.
|
|
181
|
-
// Audit round-9 H1.
|
|
182
|
-
const baseNorm = msg.base ?? null
|
|
216
|
+
// Save does NOT subscribe the sender — that would be a replay vector
|
|
217
|
+
// (a captured frame replayed from any connection would attach as a
|
|
218
|
+
// subscriber without holding the seed). Explicit `workspace-subscribe`
|
|
219
|
+
// (signs the per-connection nonce) is the ONLY attach path. Round-9 H1.
|
|
220
|
+
//
|
|
183
221
|
// `keyframe === true` is what canonicalSave bound the signature to
|
|
184
222
|
// (strict equality); the signer's intent is unambiguous here.
|
|
185
223
|
const keyframe = msg.keyframe === true
|
|
@@ -189,43 +227,24 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
189
227
|
})
|
|
190
228
|
if (commit.kind === 'duplicate') {
|
|
191
229
|
if (debug) console.log(`save (duplicate id ${id.slice(0, 8)}…) → ack-only`)
|
|
192
|
-
|
|
193
|
-
return
|
|
230
|
+
return { kind: 'duplicate', id, base: baseNorm }
|
|
194
231
|
}
|
|
195
232
|
if (commit.kind === 'stale-base') {
|
|
196
|
-
// Client claimed a base that's no longer head.
|
|
197
|
-
// computed OUTSIDE
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
// catch-up's handler clears `session.pending`; the subsequent
|
|
205
|
-
// error frame's `handleSaveError` then early-returns on the
|
|
206
|
-
// missing pending and does NOT mark the session errored — exactly
|
|
207
|
-
// what we want, since stale-base is a recoverable race (client
|
|
208
|
-
// rebases + re-saves). The typed frame is for protocol clarity
|
|
209
|
-
// (debug surfaces / explicit rejection signal), not for triggering
|
|
210
|
-
// an error transition. Audit follow-up to round-15 —
|
|
211
|
-
// `sync-server-races.test.js:1105`.
|
|
233
|
+
// Client claimed a base that's no longer head. The catch-up chain is
|
|
234
|
+
// computed OUTSIDE any lock — a concurrent commit landing here only
|
|
235
|
+
// means the catch-up is fresher (benign; clients tolerate extra
|
|
236
|
+
// revisions). The WS renderer sends `workspace-state` (catch-up) FIRST
|
|
237
|
+
// then the typed `stale-base` error; the REST renderer returns the
|
|
238
|
+
// chain in the 409 body. Either way the catch-up clears the client's
|
|
239
|
+
// pending and the error is a no-op on the now-missing pending (a
|
|
240
|
+
// recoverable race — client rebases + re-saves).
|
|
212
241
|
const revisions = chainForWire(await chainFrom(handle, tag, baseNorm))
|
|
213
242
|
if (debug) console.log(`save (stale base ${baseNorm} vs head ${commit.head}) → chain ${revisions.length}`)
|
|
214
|
-
|
|
215
|
-
sendSaveError(socket, tag, baseNorm, 'stale-base')
|
|
216
|
-
return
|
|
243
|
+
return { kind: 'stale-base', base: baseNorm, revisions }
|
|
217
244
|
}
|
|
218
245
|
if (debug) console.log(`save${keyframe ? ' [keyframe]' : ''} → revision ${id.slice(0, 8)}… for ${debugTag(tag)}`)
|
|
219
|
-
send(socket, {
|
|
220
|
-
type: 'workspace-save-ack',
|
|
221
|
-
workspaceTag: tag,
|
|
222
|
-
base: baseNorm,
|
|
223
|
-
id,
|
|
224
|
-
})
|
|
225
246
|
// Carry `keyframe` as a strict boolean on the broadcast wire — peers
|
|
226
|
-
// strict-compare `=== true` (matching the canonical-payload
|
|
227
|
-
// contract). An integer 0/1 here would make a keyframe look like a
|
|
228
|
-
// regular delta on broadcast paths.
|
|
247
|
+
// strict-compare `=== true` (matching the canonical-payload contract).
|
|
229
248
|
broadcast(tag, {
|
|
230
249
|
type: 'workspace-state',
|
|
231
250
|
workspaceTag: tag,
|
|
@@ -237,16 +256,51 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
237
256
|
ciphertext: msg.ciphertext,
|
|
238
257
|
signature: msg.signature,
|
|
239
258
|
}],
|
|
240
|
-
},
|
|
241
|
-
// Cross-instance fan-out. The bus payload carries only the revision
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
// for the bus's 8 KB payload budget, which can't carry a 2 MiB
|
|
245
|
-
// ciphertext. SQLite mode passes a no-op; Neon mode publishes via
|
|
246
|
-
// pg_notify. Best-effort: a dropped publish only means peers on
|
|
247
|
-
// other instances miss the live push, but they still catch up via
|
|
248
|
-
// the shared DB on their next subscribe / reconnect.
|
|
259
|
+
}, except)
|
|
260
|
+
// Cross-instance fan-out. The bus payload carries only the revision id —
|
|
261
|
+
// peers on OTHER instances re-fetch the row. SQLite mode passes a no-op;
|
|
262
|
+
// Neon mode publishes via pg_notify. Best-effort.
|
|
249
263
|
publishRevision(tag, id)
|
|
264
|
+
return { kind: 'ack', id, base: baseNorm }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// WS renderer: run the shared pipeline against this socket and render the
|
|
268
|
+
// outcome as the protocol frames the client expects on its stream. A
|
|
269
|
+
// `rejected` outcome drops silently (matches the prior malformed/bad-sig
|
|
270
|
+
// behaviour). For every non-rejected outcome the tag was validated inside
|
|
271
|
+
// `commitSave`, so the cast to string is sound.
|
|
272
|
+
async function handleSave(socket: WebSocket, msg: SaveMsg): Promise<void> {
|
|
273
|
+
const outcome = await commitSave(msg, requiresAuth(socket), socket)
|
|
274
|
+
if (outcome.kind === 'rejected') return
|
|
275
|
+
const tag = msg.workspaceTag as string
|
|
276
|
+
if (outcome.kind === 'unauthorized') { sendUnauthorized(socket, { kind: 'gated', workspaceTag: tag, base: outcome.base }); return }
|
|
277
|
+
if (outcome.kind === 'too-large') { sendSaveError(socket, tag, outcome.base, 'too-large'); return }
|
|
278
|
+
if (outcome.kind === 'stale-base') {
|
|
279
|
+
// State FIRST (its handler clears pending), then the typed error.
|
|
280
|
+
send(socket, { type: 'workspace-state', workspaceTag: tag, revisions: outcome.revisions })
|
|
281
|
+
sendSaveError(socket, tag, outcome.base, 'stale-base')
|
|
282
|
+
return
|
|
283
|
+
}
|
|
284
|
+
// ack | duplicate
|
|
285
|
+
send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: outcome.base, id: outcome.id })
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// REST renderer: the session-independent `POST /api/sync/save` plane. Reads
|
|
289
|
+
// the save frame from the JSON body, runs the SAME pipeline (no socket;
|
|
290
|
+
// new-workspace gate = passwordConfigured; broadcast except = null), and
|
|
291
|
+
// maps the outcome to a JSON + HTTP status the client switches on. SSE-mode
|
|
292
|
+
// clients POST here so a save doesn't take over their event-stream; a 401
|
|
293
|
+
// routes them to the in-band frame (which runs the operator auth flow).
|
|
294
|
+
// Mounted + gated (same-origin, shutdown, idle-timeout) in server/http.ts.
|
|
295
|
+
async function handleSaveRest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
296
|
+
const body = await readSaveBody(req)
|
|
297
|
+
if (!body || typeof body !== 'object') { respondJson(res, 400, { reason: 'bad-request' }); return }
|
|
298
|
+
const outcome = await commitSave(body as SaveMsg, passwordConfigured, null)
|
|
299
|
+
if (outcome.kind === 'ack' || outcome.kind === 'duplicate') { respondJson(res, 200, { ok: true, id: outcome.id }); return }
|
|
300
|
+
if (outcome.kind === 'stale-base') { respondJson(res, 409, { reason: 'stale-base', revisions: outcome.revisions }); return }
|
|
301
|
+
if (outcome.kind === 'too-large') { respondJson(res, 413, { reason: 'too-large' }); return }
|
|
302
|
+
if (outcome.kind === 'unauthorized') { respondJson(res, 401, { reason: 'unauthorized' }); return }
|
|
303
|
+
respondJson(res, 400, { reason: 'bad-request' })
|
|
250
304
|
}
|
|
251
305
|
|
|
252
306
|
async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {
|
|
@@ -316,5 +370,5 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
316
370
|
send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
|
|
317
371
|
}
|
|
318
372
|
|
|
319
|
-
return { handleSave, handleSubscribe, sendSaveError }
|
|
373
|
+
return { handleSave, handleSaveRest, handleSubscribe, sendSaveError }
|
|
320
374
|
}
|
package/server/util.ts
CHANGED
|
@@ -8,6 +8,15 @@ import { randomBytes } from 'node:crypto'
|
|
|
8
8
|
// is an Ed25519 public key; operator logs shouldn't carry it verbatim.
|
|
9
9
|
export function debugTag(s: string): string { return `${s.slice(0, 12)}…` }
|
|
10
10
|
|
|
11
|
+
// Truncated view of a content hash / staging id for logs (objstore GC +
|
|
12
|
+
// 503 diagnostics). Same 12-char prefix convention as `debugTag`; named
|
|
13
|
+
// separately so call sites read as "this is a blob id, not a workspace
|
|
14
|
+
// tag". Tolerates a non-string (logs a placeholder) so a diagnostic
|
|
15
|
+
// path can't itself throw on bad input.
|
|
16
|
+
export function debugId(s: unknown): string {
|
|
17
|
+
return typeof s === 'string' ? `${s.slice(0, 12)}…` : '<no-id>'
|
|
18
|
+
}
|
|
19
|
+
|
|
11
20
|
// 16 random bytes → 22 base64url chars (no padding). The shared shape
|
|
12
21
|
// for per-socket challenge nonces and staging ids — collision is
|
|
13
22
|
// 1/2^128. `isValidStagingId` in objstore/store.ts validates exactly
|