@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/out/client-sync.js +11 -11
- package/out/view.js +49 -48
- package/package.json +2 -2
- package/server/bus-receiver.ts +7 -7
- package/server/config.ts +4 -4
- package/server/db-revision-sql.ts +6 -9
- package/server/db.ts +95 -134
- package/server/hub.ts +4 -5
- package/server/index.ts +22 -26
- package/server/objstore/blob-fs.ts +4 -6
- package/server/objstore/blob-vercel.ts +54 -42
- package/server/objstore/blob.ts +16 -8
- package/server/objstore/handlers.ts +11 -13
- package/server/objstore/init.ts +18 -9
- package/server/objstore/rest.ts +60 -68
- package/server/objstore/store-neon.ts +4 -4
- package/server/objstore/store.ts +92 -112
- package/server/objstore/tokens.ts +9 -12
- package/server/peer.ts +7 -9
- package/server/pubsub.ts +17 -27
- package/server/sign.ts +8 -10
- package/server/sse-server.ts +10 -12
- package/server/sse-session.ts +2 -8
- package/server/static.ts +8 -12
- package/server/sync-handlers.ts +32 -39
- package/server/ws-server.ts +9 -14
package/server/sync-handlers.ts
CHANGED
|
@@ -13,9 +13,8 @@ import { MAX_CIPHERTEXT_LEN, MAX_FIELD_LEN, validCiphertextShape, validNonce, va
|
|
|
13
13
|
import { debugTag } from './util.ts'
|
|
14
14
|
import type { UnauthorizedContext } from './auth.ts'
|
|
15
15
|
|
|
16
|
-
// `chainForWire`
|
|
17
|
-
// `keyframe`
|
|
18
|
-
// with `keyframe` normalised to a strict boolean for the wire.
|
|
16
|
+
// Wire shape `chainForWire` produces from a `chainFrom` row, with
|
|
17
|
+
// `keyframe` normalised from the SQLite INTEGER 0/1 to a strict boolean.
|
|
19
18
|
type WireRevision = {
|
|
20
19
|
base: string | null
|
|
21
20
|
id: string
|
|
@@ -88,14 +87,12 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
88
87
|
send(socket, { type: 'workspace-save-error', workspaceTag, base, reason })
|
|
89
88
|
}
|
|
90
89
|
|
|
91
|
-
// Normalise `keyframe` on outbound chain entries
|
|
90
|
+
// Normalise `keyframe` to a strict boolean on outbound chain entries.
|
|
92
91
|
// SQLite stores the column as INTEGER (0/1) and `chainFrom` returns
|
|
93
|
-
// raw rows; the wire contract (and
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
//
|
|
97
|
-
// fragile if a future client (or test harness) ever strict-compares.
|
|
98
|
-
// Convert once on the send side.
|
|
92
|
+
// raw rows; the wire contract (and canonical signing payload) uses
|
|
93
|
+
// strict `=== true`. Convert once on the send side — forwarding the
|
|
94
|
+
// integer relies on clients coercing via `Boolean()` before rebuilding
|
|
95
|
+
// canonical bytes, fragile against any client that strict-compares.
|
|
99
96
|
function chainForWire(revisions: RevisionRow[]): WireRevision[] {
|
|
100
97
|
return revisions.map((r) => ({ ...r, keyframe: r.keyframe === 1 }))
|
|
101
98
|
}
|
|
@@ -174,19 +171,14 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
174
171
|
sendUnauthorized(socket, { kind: 'gated', workspaceTag: tag, base: msg.base ?? null })
|
|
175
172
|
return
|
|
176
173
|
}
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
//
|
|
184
|
-
//
|
|
185
|
-
// The legitimate client always sends an explicit
|
|
186
|
-
// `workspace-subscribe` (see `trySendSubscribe` in
|
|
187
|
-
// `client/triage-sync.js` — fires on key derivation, on socket
|
|
188
|
-
// open, on continuity-break recovery, on dismissError). The
|
|
189
|
-
// subscribe path remains the only way to attach as a subscriber.
|
|
174
|
+
// Save does NOT subscribe the sending socket — that would be a
|
|
175
|
+
// replay vector: a passive observer who captured one valid
|
|
176
|
+
// `workspace-save` frame could replay it from any TCP connection to
|
|
177
|
+
// attach as a subscriber and mirror every future encrypted
|
|
178
|
+
// broadcast, without holding the seed (the duplicate-id path returns
|
|
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.
|
|
190
182
|
const baseNorm = msg.base ?? null
|
|
191
183
|
// `keyframe === true` is what canonicalSave bound the signature to
|
|
192
184
|
// (strict equality); the signer's intent is unambiguous here.
|
|
@@ -230,13 +222,10 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
230
222
|
base: baseNorm,
|
|
231
223
|
id,
|
|
232
224
|
})
|
|
233
|
-
// Carry `keyframe` as a strict boolean on the broadcast wire —
|
|
234
|
-
//
|
|
235
|
-
// contract).
|
|
236
|
-
//
|
|
237
|
-
// keyframe look like a regular delta on broadcast paths even though
|
|
238
|
-
// the chain-fetch path (chainFrom → SQLite integer) DID round-trip
|
|
239
|
-
// correctly.
|
|
225
|
+
// 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.
|
|
240
229
|
broadcast(tag, {
|
|
241
230
|
type: 'workspace-state',
|
|
242
231
|
workspaceTag: tag,
|
|
@@ -261,15 +250,19 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
261
250
|
}
|
|
262
251
|
|
|
263
252
|
async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {
|
|
264
|
-
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
253
|
+
// Mirror handleSave's wire gate: length-cap + base64url-alphabet on
|
|
254
|
+
// workspaceTag / signature / from BEFORE canonicalSubscribe UTF-8-
|
|
255
|
+
// encodes them and verifyEd25519 base64-decodes the signature.
|
|
256
|
+
// Without it a peer can submit up-to-maxPayload strings and force
|
|
257
|
+
// O(n) encode + decode per subscribe before the 32/64-byte length
|
|
258
|
+
// gates reject — a CPU-DoS surface handleSave is already hardened
|
|
259
|
+
// against. `from` keeps the `string | null` contract (null = full-
|
|
260
|
+
// chain catch-up); a legit signer's `from` is a base64url revision
|
|
261
|
+
// id, so any non-string / over-long / wrong-alphabet value can't be
|
|
262
|
+
// one the signature was computed over, and the chain-lookup path
|
|
263
|
+
// (`typeof msg.from === 'string' ? msg.from : null`) still treats a
|
|
264
|
+
// null / absent `from` as the keyframe-fallback.
|
|
265
|
+
if (!validTagSigBase(msg.workspaceTag, MAX_FIELD_LEN) || !validTagSigBase(msg.signature, MAX_FIELD_LEN) || (msg.from != null && !validTagSigBase(msg.from, MAX_FIELD_LEN))) return
|
|
273
266
|
// The challenge nonce we issued on this socket is bound into the
|
|
274
267
|
// signed canonical, blocking cross-connection replay of a captured
|
|
275
268
|
// subscribe frame. A subscribe arriving before we sent the
|
package/server/ws-server.ts
CHANGED
|
@@ -55,23 +55,19 @@ export type WsServerDeps = PeerConnectionDeps & {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
// Per-connection setup: create Peer, send challenge, wire message /
|
|
58
|
-
// close / error / pong listeners.
|
|
59
|
-
//
|
|
60
|
-
// `
|
|
61
|
-
// `WebSocket`, or an `SseSession` cast through `as unknown as
|
|
62
|
-
// WebSocket`); the function only uses the subset both expose.
|
|
58
|
+
// close / error / pong listeners. `socket` is the WebSocket-shaped
|
|
59
|
+
// surface — a real `ws` `WebSocket`, or an `SseSession` cast through
|
|
60
|
+
// `as unknown as WebSocket`; only the subset both expose is used.
|
|
63
61
|
export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: PeerConnectionDeps): void {
|
|
64
62
|
const {
|
|
65
63
|
peers, send, unsubscribeAll, handleSave, handleSubscribe, handleAuthenticate,
|
|
66
64
|
sendSaveError, objstore, track, isShuttingDown, maxInflightPerSocket, debug,
|
|
67
65
|
} = deps
|
|
68
66
|
if (debug) console.log(`connect from ${req.socket.remoteAddress}`)
|
|
69
|
-
// One Peer holds this connection's state
|
|
70
|
-
//
|
|
71
|
-
//
|
|
72
|
-
//
|
|
73
|
-
// flips it back, and a socket still false on the next sweep is
|
|
74
|
-
// terminated — the only thing closing FDs for an idle peer.
|
|
67
|
+
// One Peer holds this connection's state, created before any client
|
|
68
|
+
// frame can arrive (`socket.on('message')` is wired below). The
|
|
69
|
+
// heartbeat sweep's two-tick liveness check (see below) is the only
|
|
70
|
+
// thing closing FDs for an otherwise-idle peer.
|
|
75
71
|
const peer = new Peer(randomId())
|
|
76
72
|
peers.set(socket, peer)
|
|
77
73
|
socket.on('pong', () => { peer.alive = true })
|
|
@@ -228,9 +224,8 @@ export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: P
|
|
|
228
224
|
// Surface socket-level errors instead of swallowing — these are
|
|
229
225
|
// the signals operators want under abuse / network flakiness
|
|
230
226
|
// (TLS handshake failures, frame-decode errors, ws-protocol
|
|
231
|
-
// violations).
|
|
232
|
-
//
|
|
233
|
-
// unsubscribe cleanup, so logging here doesn't risk leaking.
|
|
227
|
+
// violations). `close` fires after `error` and runs the unsubscribe
|
|
228
|
+
// cleanup, so logging here doesn't risk leaking.
|
|
234
229
|
socket.on('error', (err: Error) => { console.warn('Socket error:', errMsg(err)) })
|
|
235
230
|
}
|
|
236
231
|
|