@preventive/triage 1.0.0-alpha.4 → 1.0.0-alpha.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/client-sync.js +14 -11
- package/out/graph.js +1 -1
- package/out/view.css +1 -1
- package/out/view.js +6 -6
- package/package.json +1 -1
- package/server/auth.ts +5 -1
- package/server/config.ts +14 -1
- package/server/http.ts +77 -7
- package/server/index.ts +12 -9
- package/server/lifecycle.ts +8 -4
- package/server/objstore/init.ts +26 -1
- package/server/objstore/rest.ts +26 -25
- package/server/objstore/sign.ts +105 -0
- package/server/sse-server.ts +42 -35
- package/server/sse-session.ts +28 -8
- package/server/sync-handlers.ts +145 -91
package/server/sse-server.ts
CHANGED
|
@@ -52,6 +52,24 @@ import { errMsg, randomId } from './util.ts'
|
|
|
52
52
|
// path so the same `location` block routes both.
|
|
53
53
|
export const SSE_OPEN_PATH = '/api/sync/sse'
|
|
54
54
|
|
|
55
|
+
// Cadence of the server-driven keepalive sweep: every tick we write a `:`
|
|
56
|
+
// comment to each open session's downstream so intermediary proxies don't
|
|
57
|
+
// idle-close it (nginx et al. default to a ~60s read timeout). This is the
|
|
58
|
+
// server's own liveness upkeep — the client no longer POSTs a periodic ping
|
|
59
|
+
// (which forced a stream takeover every tick).
|
|
60
|
+
//
|
|
61
|
+
// Reaping model (replaces the old POST-driven idle timer): a session is
|
|
62
|
+
// dropped on its downstream response `close` — clean disconnect, or a
|
|
63
|
+
// half-open socket the per-session TCP keepalive forces closed (see
|
|
64
|
+
// SseSession.SOCKET_KEEPALIVE_MS) — NOT by this sweep. We intentionally
|
|
65
|
+
// trust connection-level liveness. The one topology this can't see is a
|
|
66
|
+
// buffering / TLS-terminating proxy that holds the upstream open after the
|
|
67
|
+
// real client vanished (keepalive then probes the proxy hop, not the
|
|
68
|
+
// client); such a session lingers until `maxSessions`, the hard backstop.
|
|
69
|
+
// This is the same exposure the WS heartbeat already has (its ping only
|
|
70
|
+
// proves the proxy↔server hop too), not a new class of leak.
|
|
71
|
+
const KEEPALIVE_SWEEP_MS = 30_000
|
|
72
|
+
|
|
55
73
|
export type SseServerDeps = {
|
|
56
74
|
// The WS dispatch is the cohesive unit; SSE just provides another
|
|
57
75
|
// transport into it. Closure over the same handler / hub / objstore
|
|
@@ -70,12 +88,6 @@ export type SseServerDeps = {
|
|
|
70
88
|
// the per-frame budget is the same as the WS plane after the
|
|
71
89
|
// dispatcher splits them.
|
|
72
90
|
maxBodyBytes: number
|
|
73
|
-
// Idle timeout for a session with no inbound POSTs. Detects the
|
|
74
|
-
// wandered-off browser tab the WS heartbeat sweep handles via
|
|
75
|
-
// ping/pong on real sockets. The client's JSON ping/pong heartbeat
|
|
76
|
-
// (every 15s) is the steady-state liveness signal; this is the
|
|
77
|
-
// hard ceiling.
|
|
78
|
-
sessionIdleMs: number
|
|
79
91
|
debug: boolean
|
|
80
92
|
}
|
|
81
93
|
|
|
@@ -86,6 +98,9 @@ export type SseServer = {
|
|
|
86
98
|
// Iterates active sessions. Lifecycle's graceful-shutdown loop
|
|
87
99
|
// reads this to close SSE sessions alongside WS clients.
|
|
88
100
|
sessions: () => Iterable<SseSession>
|
|
101
|
+
// The keepalive-sweep timer. Lifecycle clears it on shutdown (parity
|
|
102
|
+
// with the WS heartbeat timer) so a tick can't fire mid-teardown.
|
|
103
|
+
keepaliveTimer: ReturnType<typeof setInterval>
|
|
89
104
|
}
|
|
90
105
|
|
|
91
106
|
// Inbound POST body. Every field optional — an empty-body POST is a
|
|
@@ -98,7 +113,7 @@ type SseBody = {
|
|
|
98
113
|
}
|
|
99
114
|
|
|
100
115
|
export function installSseServer(deps: SseServerDeps): SseServer {
|
|
101
|
-
const { peerDeps, isShuttingDown, maxSessions, maxBodyBytes,
|
|
116
|
+
const { peerDeps, isShuttingDown, maxSessions, maxBodyBytes, debug } = deps
|
|
102
117
|
|
|
103
118
|
// Active SSE sessions, keyed by the random session id `createSession`
|
|
104
119
|
// mints on the first POST that lacks a `?id=` (or whose id this
|
|
@@ -108,26 +123,9 @@ export function installSseServer(deps: SseServerDeps): SseServer {
|
|
|
108
123
|
// mint a fresh session instead, so a multi-replica deployment doesn't
|
|
109
124
|
// require sticky LB routing to recover.
|
|
110
125
|
const sessions = new Map<string, SseSession>()
|
|
111
|
-
// Per-session idle timer handle. Reset on every inbound POST; fires
|
|
112
|
-
// after `sessionIdleMs` of silence to close a stranded session.
|
|
113
|
-
const idleTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
114
|
-
|
|
115
|
-
function armIdleTimer(sid: string, session: SseSession): void {
|
|
116
|
-
clearTimeout(idleTimers.get(sid))
|
|
117
|
-
if (sessionIdleMs <= 0) return
|
|
118
|
-
const t = setTimeout(() => {
|
|
119
|
-
if (debug) console.warn(`sse: session ${sid.slice(0, 8)}… idle ${sessionIdleMs}ms → close`)
|
|
120
|
-
try { session.terminate() } catch {}
|
|
121
|
-
}, sessionIdleMs)
|
|
122
|
-
t.unref?.()
|
|
123
|
-
idleTimers.set(sid, t)
|
|
124
|
-
}
|
|
125
126
|
|
|
126
127
|
function dropSession(sid: string): void {
|
|
127
128
|
sessions.delete(sid)
|
|
128
|
-
const t = idleTimers.get(sid)
|
|
129
|
-
if (t) clearTimeout(t)
|
|
130
|
-
idleTimers.delete(sid)
|
|
131
129
|
}
|
|
132
130
|
|
|
133
131
|
function writeSseHeaders(res: ServerResponse): void {
|
|
@@ -149,7 +147,7 @@ export function installSseServer(deps: SseServerDeps): SseServer {
|
|
|
149
147
|
res.write('retry: 1000\n\n')
|
|
150
148
|
}
|
|
151
149
|
|
|
152
|
-
function createSession(res: ServerResponse, req: HttpRequest):
|
|
150
|
+
function createSession(res: ServerResponse, req: HttpRequest): SseSession | null {
|
|
153
151
|
if (sessions.size >= maxSessions) {
|
|
154
152
|
if (debug) console.warn(`sse: refused open — sessions ${sessions.size} >= ${maxSessions}`)
|
|
155
153
|
return null
|
|
@@ -158,7 +156,6 @@ export function installSseServer(deps: SseServerDeps): SseServer {
|
|
|
158
156
|
const sid = randomId()
|
|
159
157
|
const session = new SseSession(res)
|
|
160
158
|
sessions.set(sid, session)
|
|
161
|
-
armIdleTimer(sid, session)
|
|
162
159
|
session.on('close', () => { dropSession(sid) })
|
|
163
160
|
// Announce the continuation token BEFORE the dispatcher emits its
|
|
164
161
|
// `challenge` frame so the client latches the id first and the
|
|
@@ -171,7 +168,7 @@ export function installSseServer(deps: SseServerDeps): SseServer {
|
|
|
171
168
|
// `setupPeerConnection`'s first action is the protocol `challenge`
|
|
172
169
|
// frame, on the default-named SSE channel.
|
|
173
170
|
setupPeerConnection(session as unknown as WebSocket, req, peerDeps)
|
|
174
|
-
return
|
|
171
|
+
return session
|
|
175
172
|
}
|
|
176
173
|
|
|
177
174
|
// Drives a POST body's `password` and `frames` through the shared
|
|
@@ -268,19 +265,16 @@ export function installSseServer(deps: SseServerDeps): SseServer {
|
|
|
268
265
|
// through to createSession with `res` still header-virgin, so
|
|
269
266
|
// the new session can writeSseHeaders without ERR_HTTP_HEADERS_SENT.
|
|
270
267
|
let session: SseSession | null = null
|
|
271
|
-
let sid: string | null = null
|
|
272
268
|
if (sidFromUrl) {
|
|
273
269
|
const existing = sessions.get(sidFromUrl)
|
|
274
270
|
if (existing && existing.readyState === existing.OPEN && existing.attachResponse(res)) {
|
|
275
271
|
writeSseHeaders(res)
|
|
276
272
|
session = existing
|
|
277
|
-
sid = sidFromUrl
|
|
278
|
-
armIdleTimer(sid, session)
|
|
279
273
|
}
|
|
280
274
|
}
|
|
281
275
|
if (!session) {
|
|
282
|
-
|
|
283
|
-
if (!
|
|
276
|
+
session = createSession(res, req)
|
|
277
|
+
if (!session) {
|
|
284
278
|
// Cap exceeded; createSession already logged. Response
|
|
285
279
|
// headers not yet written by writeSseHeaders, so send a
|
|
286
280
|
// 503 JSON instead.
|
|
@@ -288,8 +282,6 @@ export function installSseServer(deps: SseServerDeps): SseServer {
|
|
|
288
282
|
res.end(JSON.stringify({ error: 'too-many-sessions' }))
|
|
289
283
|
return
|
|
290
284
|
}
|
|
291
|
-
session = created.session
|
|
292
|
-
sid = created.sid
|
|
293
285
|
}
|
|
294
286
|
dispatchBody(session, body)
|
|
295
287
|
// Do NOT res.end() — the response stays open as the session's
|
|
@@ -323,7 +315,22 @@ export function installSseServer(deps: SseServerDeps): SseServer {
|
|
|
323
315
|
return true
|
|
324
316
|
}
|
|
325
317
|
|
|
326
|
-
|
|
318
|
+
// Server-driven keepalive sweep. Writes a `:` comment to every open
|
|
319
|
+
// session's downstream so proxies don't idle-close it. `unref` so it can't
|
|
320
|
+
// by itself hold the event loop open (parity with the WS heartbeat timer);
|
|
321
|
+
// skipped during shutdown so a tick can't write to a session the close
|
|
322
|
+
// loop is tearing down. Dead-session reaping is the response `close` event
|
|
323
|
+
// (see SseSession.wireResponse + the per-session TCP keepalive), NOT this
|
|
324
|
+
// sweep — so a half-open client is dropped without ever POSTing.
|
|
325
|
+
const keepaliveTimer = setInterval(() => {
|
|
326
|
+
if (isShuttingDown()) return
|
|
327
|
+
for (const session of sessions.values()) {
|
|
328
|
+
try { session.ping() } catch {}
|
|
329
|
+
}
|
|
330
|
+
}, KEEPALIVE_SWEEP_MS)
|
|
331
|
+
keepaliveTimer.unref?.()
|
|
332
|
+
|
|
333
|
+
return { handle, sessions: () => sessions.values(), keepaliveTimer }
|
|
327
334
|
}
|
|
328
335
|
|
|
329
336
|
// Bare-bones query parse for `id=<base64url>`. Avoids URLSearchParams
|
package/server/sse-session.ts
CHANGED
|
@@ -28,6 +28,20 @@ import { EventEmitter } from 'node:events'
|
|
|
28
28
|
import type { Buffer } from 'node:buffer'
|
|
29
29
|
import type { ServerResponse } from 'node:http'
|
|
30
30
|
|
|
31
|
+
// TCP keepalive idle delay for the downstream socket. With the client no
|
|
32
|
+
// longer POSTing a periodic ping (see client/sync/socket-transport.ts), a
|
|
33
|
+
// QUIET session whose client vanished without a FIN (crash, NAT/idle drop)
|
|
34
|
+
// has no application-level liveness signal — so we lean on the kernel:
|
|
35
|
+
// after this much idle the kernel starts probing, and a dead half-open
|
|
36
|
+
// socket surfaces as a `close`/`error` here. Node's `setKeepAlive` sets
|
|
37
|
+
// only TCP_KEEPIDLE (the delay to the FIRST probe), not the probe
|
|
38
|
+
// interval/count — so full teardown is this delay PLUS the OS's
|
|
39
|
+
// TCP_KEEPINTVL × TCP_KEEPCNT (~minutes on Linux defaults), still bounded
|
|
40
|
+
// and far below the kernel's hours-long default-off behaviour. `maxSessions`
|
|
41
|
+
// is the hard backstop. Behind a TLS-terminating / buffering proxy this
|
|
42
|
+
// probes the proxy hop, not the client — see the reaping note in sse-server.ts.
|
|
43
|
+
const SOCKET_KEEPALIVE_MS = 30_000
|
|
44
|
+
|
|
31
45
|
export class SseSession extends EventEmitter {
|
|
32
46
|
static readonly CONNECTING = 0
|
|
33
47
|
static readonly OPEN = 1
|
|
@@ -62,6 +76,10 @@ export class SseSession extends EventEmitter {
|
|
|
62
76
|
// spurious session 'error' that operators read as a real transport
|
|
63
77
|
// failure on a healthy session.
|
|
64
78
|
private wireResponse(res: ServerResponse): void {
|
|
79
|
+
// Probe half-open downstreams at the kernel level (see SOCKET_KEEPALIVE_MS).
|
|
80
|
+
// A failed probe trips the `close`/`error` handlers below, which is how a
|
|
81
|
+
// dead-but-quiet SSE client is reaped now that there's no client ping.
|
|
82
|
+
try { res.socket?.setKeepAlive(true, SOCKET_KEEPALIVE_MS) } catch {}
|
|
65
83
|
res.on('close', () => {
|
|
66
84
|
if (res !== this.currentRes) return // current-response guard (see above)
|
|
67
85
|
if (this.readyState === SseSession.CLOSED) return
|
|
@@ -142,9 +160,8 @@ export class SseSession extends EventEmitter {
|
|
|
142
160
|
// double-emit), which means without the explicit emit here neither
|
|
143
161
|
// sse-server's dropSession cleanup nor setupPeerConnection's
|
|
144
162
|
// unsubscribeAll/peers.delete would run on any server-initiated
|
|
145
|
-
// teardown — sessions / hub.subscribers
|
|
146
|
-
//
|
|
147
|
-
// is a no-op.
|
|
163
|
+
// teardown — the sessions map / hub.subscribers would leak per close.
|
|
164
|
+
// The wireResponse guard then ensures the later async fire is a no-op.
|
|
148
165
|
close(code?: number, reason?: string): void {
|
|
149
166
|
if (this.readyState === SseSession.CLOSED) return
|
|
150
167
|
const res = this.currentRes
|
|
@@ -173,11 +190,14 @@ export class SseSession extends EventEmitter {
|
|
|
173
190
|
this.emit('close')
|
|
174
191
|
}
|
|
175
192
|
|
|
176
|
-
//
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
193
|
+
// Server-driven keepalive, called on the SSE keepalive sweep in
|
|
194
|
+
// sse-server.ts. SSE has no `pong` equivalent, so we write a `:` comment
|
|
195
|
+
// line — ignored by the client parser — that keeps the downstream from
|
|
196
|
+
// being idle-closed by intermediary proxies (nginx et al. default to a
|
|
197
|
+
// ~60s read timeout). Dead-client detection is the response `close` event
|
|
198
|
+
// (clean disconnect, or a half-open socket the TCP keepalive in
|
|
199
|
+
// `wireResponse` forces closed), NOT this write: a `:`-comment write to a
|
|
200
|
+
// half-open socket just buffers, it doesn't synchronously throw.
|
|
181
201
|
ping(): void {
|
|
182
202
|
if (this.readyState !== SseSession.OPEN) return
|
|
183
203
|
const res = this.currentRes
|
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
|
}
|