@preventive/triage 1.0.0-alpha.0
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/LICENSE +21 -0
- package/common/save-error-reason.ts +53 -0
- package/common/utf8.d.ts +13 -0
- package/common/utf8.js +57 -0
- package/out/brotli-fallback.js +3 -0
- package/out/client-sync.js +15 -0
- package/out/graph.js +4 -0
- package/out/icon-maskable.svg +5 -0
- package/out/icon.svg +5 -0
- package/out/index.html +78 -0
- package/out/manifest.webmanifest +30 -0
- package/out/prism.js +14 -0
- package/out/terminal.js +39 -0
- package/out/view.css +1 -0
- package/out/view.html +12 -0
- package/out/view.js +138 -0
- package/package.json +129 -0
- package/server/auth.ts +99 -0
- package/server/config.example.json +3 -0
- package/server/config.ts +196 -0
- package/server/db-neon.ts +374 -0
- package/server/db-revision-sql.ts +152 -0
- package/server/db-stmt.ts +53 -0
- package/server/db.ts +577 -0
- package/server/http.ts +142 -0
- package/server/hub.ts +98 -0
- package/server/index.ts +353 -0
- package/server/lifecycle.ts +177 -0
- package/server/neon-driver.ts +26 -0
- package/server/objstore/blob-fs.ts +164 -0
- package/server/objstore/blob-vercel.ts +508 -0
- package/server/objstore/blob.ts +169 -0
- package/server/objstore/fs.ts +67 -0
- package/server/objstore/handlers.ts +235 -0
- package/server/objstore/init.ts +118 -0
- package/server/objstore/reaper.ts +199 -0
- package/server/objstore/rest.ts +484 -0
- package/server/objstore/sign.ts +164 -0
- package/server/objstore/store-neon.ts +351 -0
- package/server/objstore/store.ts +799 -0
- package/server/objstore/tokens.ts +168 -0
- package/server/origin.ts +68 -0
- package/server/peer.ts +38 -0
- package/server/sign.ts +231 -0
- package/server/static.ts +374 -0
- package/server/sync-handlers.ts +311 -0
- package/server/util.ts +27 -0
- package/server/validation.ts +36 -0
- package/server/ws-server.ts +245 -0
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
// Triage-sync protocol handlers: `workspace-save` and
|
|
2
|
+
// `workspace-subscribe`. Built once at boot with the DB handle plus
|
|
3
|
+
// the transport / auth / registry primitives it needs; the WS message
|
|
4
|
+
// dispatcher in index.ts calls the returned handlers. Kept out of the
|
|
5
|
+
// entrypoint so the protocol logic (the save pipeline, the
|
|
6
|
+
// subscribe/catch-up path) is one cohesive, testable unit.
|
|
7
|
+
|
|
8
|
+
import type { WebSocket } from 'ws'
|
|
9
|
+
import { SAVE_ERROR_REASONS, type SaveErrorReason } from '../common/save-error-reason.ts'
|
|
10
|
+
import { type Handle, type RevisionRow, chainFrom, commitRevision, revisionExists } from './db.ts'
|
|
11
|
+
import { type SaveMsg, type SubscribeMsg, canonicalSave, computeRevisionIdFromCanonical, verifyEd25519, verifySubscribeSig } from './sign.ts'
|
|
12
|
+
import { MAX_CIPHERTEXT_LEN, MAX_FIELD_LEN, validCiphertextShape, validNonce, validTagSigBase } from './validation.ts'
|
|
13
|
+
import { debugTag } from './util.ts'
|
|
14
|
+
import type { UnauthorizedContext } from './auth.ts'
|
|
15
|
+
|
|
16
|
+
// `chainForWire` accepts the row shape from `chainFrom` (where
|
|
17
|
+
// `keyframe` is the SQLite INTEGER 0 / 1) and returns the same fields
|
|
18
|
+
// with `keyframe` normalised to a strict boolean for the wire.
|
|
19
|
+
type WireRevision = {
|
|
20
|
+
base: string | null
|
|
21
|
+
id: string
|
|
22
|
+
keyframe: boolean
|
|
23
|
+
nonce: string
|
|
24
|
+
ciphertext: string
|
|
25
|
+
signature: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type SyncHandlersDeps = {
|
|
29
|
+
handle: Handle
|
|
30
|
+
send: (socket: WebSocket, msg: object) => void
|
|
31
|
+
broadcast: (tag: string, msg: object, except: WebSocket | null) => void
|
|
32
|
+
subscribe: (socket: WebSocket, tag: string) => void
|
|
33
|
+
getNonce: (socket: WebSocket) => string | undefined
|
|
34
|
+
requiresAuth: (socket: WebSocket) => boolean
|
|
35
|
+
sendUnauthorized: (socket: WebSocket, ctx: UnauthorizedContext) => void
|
|
36
|
+
workspaceExists: (tag: string) => Promise<boolean>
|
|
37
|
+
// Objstore inventory snapshot for a workspace tag, as wire rows. The
|
|
38
|
+
// `workspace-subscribed` ack carries it. Injected — the objstore store
|
|
39
|
+
// has its own richer `Handle`, so index.ts wires `listLive(
|
|
40
|
+
// objstoreHandle, tag).then(rows => rows.map(objectMetaWire))` rather
|
|
41
|
+
// than coupling this module to that store type. Returns [] for a
|
|
42
|
+
// triage-only tag.
|
|
43
|
+
objstoreResources: (tag: string) => Promise<object[]>
|
|
44
|
+
debug: boolean
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type SyncHandlers = {
|
|
48
|
+
handleSave: (socket: WebSocket, msg: SaveMsg) => Promise<void>
|
|
49
|
+
handleSubscribe: (socket: WebSocket, msg: SubscribeMsg) => Promise<void>
|
|
50
|
+
// Exported because the dispatcher's inflight-cap `busy` NACK path
|
|
51
|
+
// emits a save-error too (the only emit site outside this module).
|
|
52
|
+
sendSaveError: (socket: WebSocket, workspaceTag: string, base: string | null, reason: SaveErrorReason) => void
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
56
|
+
const { handle, send, broadcast, subscribe, getNonce, requiresAuth, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
|
|
57
|
+
|
|
58
|
+
// Typed wrapper for the three `workspace-save-error` emit sites
|
|
59
|
+
// (too-large at handleSave, stale-base after the catch-up, busy at
|
|
60
|
+
// the inflight-cap drop). Forces `reason` to be a member of
|
|
61
|
+
// `SaveErrorReason` so a typo or a server-side addition that didn't
|
|
62
|
+
// update `common/save-error-reason.ts` fails at compile time rather
|
|
63
|
+
// than turning into a wire-level surprise. The shared taxonomy is
|
|
64
|
+
// pinned by `tests/save-error-reason-taxonomy.test.js`.
|
|
65
|
+
function sendSaveError(
|
|
66
|
+
socket: WebSocket,
|
|
67
|
+
workspaceTag: string,
|
|
68
|
+
base: string | null,
|
|
69
|
+
reason: SaveErrorReason,
|
|
70
|
+
): void {
|
|
71
|
+
// Runtime guard alongside the compile-time `SaveErrorReason` union —
|
|
72
|
+
// covers the case where the `reason` argument is a variable (not a
|
|
73
|
+
// string literal) and TS's narrowing can't enforce taxonomy
|
|
74
|
+
// membership at the call site. Throws because a server emitting a
|
|
75
|
+
// typo'd reason would be a wire-protocol break the client can't
|
|
76
|
+
// recover from; better to fail fast in the test suite than to
|
|
77
|
+
// silently land bytes the client coerces to `'rejected'`.
|
|
78
|
+
if (!SAVE_ERROR_REASONS.has(reason)) {
|
|
79
|
+
throw new Error(`sendSaveError: reason '${reason}' is not in SAVE_ERROR_REASONS — update common/save-error-reason.ts`)
|
|
80
|
+
}
|
|
81
|
+
send(socket, { type: 'workspace-save-error', workspaceTag, base, reason })
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Normalise `keyframe` on outbound chain entries to a strict boolean.
|
|
85
|
+
// SQLite stores the column as INTEGER (0/1) and `chainFrom` returns
|
|
86
|
+
// raw rows; the wire contract (and the canonical signing payload)
|
|
87
|
+
// uses strict `=== true` to mark keyframes. Forwarding the integer
|
|
88
|
+
// shape works only because every shipping client coerces via
|
|
89
|
+
// `Boolean(rev.keyframe)` before reconstructing the canonical bytes —
|
|
90
|
+
// fragile if a future client (or test harness) ever strict-compares.
|
|
91
|
+
// Convert once on the send side.
|
|
92
|
+
function chainForWire(revisions: RevisionRow[]): WireRevision[] {
|
|
93
|
+
return revisions.map((r) => ({ ...r, keyframe: r.keyframe === 1 }))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function handleSave(socket: WebSocket, msg: SaveMsg): Promise<void> {
|
|
97
|
+
// `base` is `string | null`; null is the keyframe-root marker.
|
|
98
|
+
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
|
|
99
|
+
// Compute canonical bytes + content-addressed id ONCE, then thread
|
|
100
|
+
// both through the precheck → sig verify → commit pipeline:
|
|
101
|
+
// 1. canonicalSave (sync, throws on lone-surrogate input)
|
|
102
|
+
// 2. SHA-256 → id
|
|
103
|
+
// 3. precheck: revisionExists → short-circuit ack on replay
|
|
104
|
+
// (skips Ed25519 verify; closes the round-9 H1 CPU-DoS vector
|
|
105
|
+
// where a passive observer floods captured saves)
|
|
106
|
+
// 4. verifyEd25519 against the SAME canonical bytes the id was
|
|
107
|
+
// hashed from — provably tied
|
|
108
|
+
// 5. ciphertext size policy (post-sig so the explicit error only
|
|
109
|
+
// reaches a legit signer)
|
|
110
|
+
// 6. commitRevision — re-checks dup + base + inserts via a single
|
|
111
|
+
// gated INSERT (dup gate + head-equals-base gate + the
|
|
112
|
+
// server-assigned seq folded into one statement) — NO write
|
|
113
|
+
// lock. The dup recheck, headFor, base-match and insert all
|
|
114
|
+
// collapse into that one statement, whose head-check and
|
|
115
|
+
// MAX(seq) read one snapshot; the `UNIQUE(workspace_tag, seq)`
|
|
116
|
+
// PK rejects any racer that computed the same seq. So two
|
|
117
|
+
// concurrent saves with the same `base` and different ids
|
|
118
|
+
// can't both insert (the loser's `head IS base` gate fails →
|
|
119
|
+
// `stale-base`, no chain fork even though UNIQUE is on id, not
|
|
120
|
+
// base), and two concurrent same-id retransmits resolve to one
|
|
121
|
+
// `inserted` + one `duplicate` with no UNIQUE throw escaping.
|
|
122
|
+
// See `commitRevisionSqlite` / `tryCommitNeon` in db*.ts.
|
|
123
|
+
let canonical: Uint8Array<ArrayBuffer>
|
|
124
|
+
try { canonical = canonicalSave(msg) } catch { return }
|
|
125
|
+
const id = await computeRevisionIdFromCanonical(canonical)
|
|
126
|
+
const tag = msg.workspaceTag
|
|
127
|
+
if (await revisionExists(handle, tag, id)) {
|
|
128
|
+
if (debug) console.log(`save (precheck dup ${id.slice(0, 8)}…) → ack-only`)
|
|
129
|
+
send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: msg.base ?? null, id })
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
if (!await verifyEd25519(tag, canonical, msg.signature)) {
|
|
133
|
+
if (debug) console.warn('reject save: bad signature', debugTag(tag))
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
// Size policy — emit an explicit error so the client can surface
|
|
137
|
+
// the failure to the user. Without this, an oversized save hangs
|
|
138
|
+
// forever in the client's `pending` slot (no ack, no rebase).
|
|
139
|
+
if (msg.ciphertext.length > MAX_CIPHERTEXT_LEN) {
|
|
140
|
+
if (debug) console.warn(`reject save: ciphertext too large (${msg.ciphertext.length} > ${MAX_CIPHERTEXT_LEN})`)
|
|
141
|
+
sendSaveError(socket, tag, msg.base == null || typeof msg.base !== 'string' ? null : msg.base, 'too-large')
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
// Auth gate for the FIRST action against a workspace tag that
|
|
145
|
+
// doesn't yet exist on the server (no rows in workspace_revision
|
|
146
|
+
// AND none in workspace_object). Once any row lands, the workspace
|
|
147
|
+
// is established and every signed action flows freely — access
|
|
148
|
+
// control falls back to the Ed25519 signature for the rest of the
|
|
149
|
+
// workspace's lifetime. Checked AFTER sig verify so the
|
|
150
|
+
// `unauthorized` frame only reaches a legitimate signer; shape /
|
|
151
|
+
// sig attacks still drop silently.
|
|
152
|
+
//
|
|
153
|
+
// RACE: `workspaceExists` reads at a different moment than the
|
|
154
|
+
// commit's gated INSERT (a plain TOCTOU — there is no lock spanning
|
|
155
|
+
// the two). Under concurrent saves on a fresh tag, an
|
|
156
|
+
// unauthenticated socket whose `workspaceExists` observes "true"
|
|
157
|
+
// (because an authenticated peer's commit landed between this
|
|
158
|
+
// socket's check and its commit) skips the gate and commits as the
|
|
159
|
+
// second writer. Accepted: the unauthenticated peer still had to
|
|
160
|
+
// produce a valid Ed25519 signature (= holds the workspace seed),
|
|
161
|
+
// and "two concurrent writes both authorising" is the worst case.
|
|
162
|
+
// Tightening would require folding the gate into the commit
|
|
163
|
+
// statement itself and is not worth the layer crossing for the
|
|
164
|
+
// soft-policy guarantee.
|
|
165
|
+
if (requiresAuth(socket) && !await workspaceExists(tag)) {
|
|
166
|
+
if (debug) console.warn(`reject save: unauthorized (new workspace ${debugTag(tag)})`)
|
|
167
|
+
sendUnauthorized(socket, { kind: 'gated', workspaceTag: tag, base: msg.base ?? null })
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
// NOTE: Earlier revisions auto-subscribed the sending socket here.
|
|
171
|
+
// That created a replay vector — a passive observer who captured
|
|
172
|
+
// any single valid `workspace-save` frame could replay it from any
|
|
173
|
+
// TCP connection forever to attach as a subscriber and silently
|
|
174
|
+
// mirror every future encrypted broadcast for the workspace,
|
|
175
|
+
// without ever holding the seed (the duplicate-id path returns
|
|
176
|
+
// ack-only and doesn't reject the socket). Audit round-9 H1.
|
|
177
|
+
//
|
|
178
|
+
// The legitimate client always sends an explicit
|
|
179
|
+
// `workspace-subscribe` (see `trySendSubscribe` in
|
|
180
|
+
// `client/triage-sync.js` — fires on key derivation, on socket
|
|
181
|
+
// open, on continuity-break recovery, on dismissError). The
|
|
182
|
+
// subscribe path remains the only way to attach as a subscriber.
|
|
183
|
+
const baseNorm = msg.base ?? null
|
|
184
|
+
// `keyframe === true` is what canonicalSave bound the signature to
|
|
185
|
+
// (strict equality); the signer's intent is unambiguous here.
|
|
186
|
+
const keyframe = msg.keyframe === true
|
|
187
|
+
const commit = await commitRevision(handle, {
|
|
188
|
+
tag, id, base: baseNorm, keyframe,
|
|
189
|
+
nonce: msg.nonce, ciphertext: msg.ciphertext, signature: msg.signature,
|
|
190
|
+
})
|
|
191
|
+
if (commit.kind === 'duplicate') {
|
|
192
|
+
if (debug) console.log(`save (duplicate id ${id.slice(0, 8)}…) → ack-only`)
|
|
193
|
+
send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: baseNorm, id })
|
|
194
|
+
return
|
|
195
|
+
}
|
|
196
|
+
if (commit.kind === 'stale-base') {
|
|
197
|
+
// Client claimed a base that's no longer head. Catch-up chain is
|
|
198
|
+
// computed OUTSIDE the lock — a concurrent commit landing between
|
|
199
|
+
// lock-release and `chainFrom` only means the catch-up is fresher
|
|
200
|
+
// than the recheck saw, which is benign (clients tolerate extra
|
|
201
|
+
// revisions in the chain).
|
|
202
|
+
//
|
|
203
|
+
// Wire order: send `workspace-state` (catch-up) FIRST, then the
|
|
204
|
+
// typed `workspace-save-error { reason: 'stale-base' }`. The
|
|
205
|
+
// catch-up's handler clears `session.pending`; the subsequent
|
|
206
|
+
// error frame's `handleSaveError` then early-returns on the
|
|
207
|
+
// missing pending and does NOT mark the session errored — exactly
|
|
208
|
+
// what we want, since stale-base is a recoverable race (client
|
|
209
|
+
// rebases + re-saves). The typed frame is for protocol clarity
|
|
210
|
+
// (debug surfaces / explicit rejection signal), not for triggering
|
|
211
|
+
// an error transition. Audit follow-up to round-15 —
|
|
212
|
+
// `sync-server-races.test.js:1105`.
|
|
213
|
+
const revisions = chainForWire(await chainFrom(handle, tag, baseNorm))
|
|
214
|
+
if (debug) console.log(`save (stale base ${baseNorm} vs head ${commit.head}) → chain ${revisions.length}`)
|
|
215
|
+
send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
|
|
216
|
+
sendSaveError(socket, tag, baseNorm, 'stale-base')
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
if (debug) console.log(`save${keyframe ? ' [keyframe]' : ''} → revision ${id.slice(0, 8)}… for ${debugTag(tag)}`)
|
|
220
|
+
send(socket, {
|
|
221
|
+
type: 'workspace-save-ack',
|
|
222
|
+
workspaceTag: tag,
|
|
223
|
+
base: baseNorm,
|
|
224
|
+
id,
|
|
225
|
+
})
|
|
226
|
+
// Carry `keyframe` as a strict boolean on the broadcast wire —
|
|
227
|
+
// peers strict-compare `=== true` (matching the canonical-payload
|
|
228
|
+
// contract). The previous shape emitted `keyframe ? 1 : 0` which a
|
|
229
|
+
// strict check would treat as non-keyframe, making a replayed
|
|
230
|
+
// keyframe look like a regular delta on broadcast paths even though
|
|
231
|
+
// the chain-fetch path (chainFrom → SQLite integer) DID round-trip
|
|
232
|
+
// correctly.
|
|
233
|
+
broadcast(tag, {
|
|
234
|
+
type: 'workspace-state',
|
|
235
|
+
workspaceTag: tag,
|
|
236
|
+
revisions: [{
|
|
237
|
+
base: baseNorm,
|
|
238
|
+
id,
|
|
239
|
+
keyframe,
|
|
240
|
+
nonce: msg.nonce,
|
|
241
|
+
ciphertext: msg.ciphertext,
|
|
242
|
+
signature: msg.signature,
|
|
243
|
+
}],
|
|
244
|
+
}, socket)
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {
|
|
248
|
+
if (typeof msg.workspaceTag !== 'string') return
|
|
249
|
+
// Same `string | null` contract as `base` in handleSave. The signed
|
|
250
|
+
// canonical uses `String(from)`, but the chain-lookup path
|
|
251
|
+
// (`typeof msg.from === 'string' ? msg.from : null`) treats every
|
|
252
|
+
// non-string as null — so a legit signer sending `from: { … }`
|
|
253
|
+
// would silently take the keyframe-fallback path even though the
|
|
254
|
+
// signature was over a different canonical shape. Reject at the
|
|
255
|
+
// wire gate.
|
|
256
|
+
if (msg.from != null && typeof msg.from !== 'string') return
|
|
257
|
+
// The challenge nonce we issued on this socket is bound into the
|
|
258
|
+
// signed canonical, blocking cross-connection replay of a captured
|
|
259
|
+
// subscribe frame. A subscribe arriving before we sent the
|
|
260
|
+
// challenge (impossible from the legitimate client) has no nonce to
|
|
261
|
+
// verify against — drop. Audit round-9 H2.
|
|
262
|
+
const nonce = getNonce(socket)
|
|
263
|
+
if (typeof nonce !== 'string') return
|
|
264
|
+
const ok = await verifySubscribeSig(msg, nonce)
|
|
265
|
+
if (!ok) {
|
|
266
|
+
if (debug) console.warn('reject subscribe: bad signature', debugTag(msg.workspaceTag))
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
// Bail if the socket closed during the verify await. The close
|
|
270
|
+
// handler's `unsubscribeAll(socket)` already ran (when there was
|
|
271
|
+
// nothing to remove yet), and `subscribe()` below would add the
|
|
272
|
+
// dead socket to `subscribers[tag]` — a permanent leak: broadcasts
|
|
273
|
+
// no-op via `send`'s readyState gate, but the Set entry pins the
|
|
274
|
+
// socket reference past close, blocking GC. Audit round-12.
|
|
275
|
+
if (socket.readyState !== socket.OPEN) {
|
|
276
|
+
if (debug) console.warn('reject subscribe: socket closed mid-verify', debugTag(msg.workspaceTag))
|
|
277
|
+
return
|
|
278
|
+
}
|
|
279
|
+
const tag = msg.workspaceTag
|
|
280
|
+
subscribe(socket, tag)
|
|
281
|
+
// Explicit ack — distinguishes "the server processed my subscribe
|
|
282
|
+
// and registered me as a peer" from "the WebSocket is open". A
|
|
283
|
+
// client that sent a malformed / bad-sig subscribe never gets this;
|
|
284
|
+
// a client that did gets one before the chain arrives. Lets the UI
|
|
285
|
+
// surface a `connecting → online` transition based on real handshake
|
|
286
|
+
// completion, not just socket state.
|
|
287
|
+
//
|
|
288
|
+
// The ack also carries the objstore inventory snapshot: the same
|
|
289
|
+
// subscribe that registers this socket for objstore-put / -deleted
|
|
290
|
+
// broadcasts seeds the client's initial inventory in one handshake.
|
|
291
|
+
// The client keeps it live thereafter from those broadcasts.
|
|
292
|
+
// Returns [] for a triage-only workspace. A failing inventory
|
|
293
|
+
// lookup must NOT sink the subscribe — degrade to an empty snapshot
|
|
294
|
+
// (broadcasts will fill it in) so the ack + chain still go out.
|
|
295
|
+
let resources: object[] = []
|
|
296
|
+
try { resources = await objstoreResources(tag) }
|
|
297
|
+
catch (err) { if (debug) console.warn('subscribe: objstore inventory lookup failed', debugTag(tag), err) }
|
|
298
|
+
send(socket, { type: 'workspace-subscribed', workspaceTag: tag, resources })
|
|
299
|
+
// `from` is the last revision id the client claims to have applied —
|
|
300
|
+
// now a base64url string, not an integer. We send only revisions
|
|
301
|
+
// after that. Client lying about `from` just means they get a
|
|
302
|
+
// smaller catch-up — their subsequent saves will reveal stale state
|
|
303
|
+
// on the usual base-mismatch path. Null / missing → full chain.
|
|
304
|
+
const fromId = typeof msg.from === 'string' ? msg.from : null
|
|
305
|
+
const revisions = chainForWire(await chainFrom(handle, tag, fromId))
|
|
306
|
+
if (debug) console.log(`subscribe ${debugTag(tag)} from=${fromId?.slice(0, 8) ?? 'null'} → chain ${revisions.length}`)
|
|
307
|
+
send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { handleSave, handleSubscribe, sendSaveError }
|
|
311
|
+
}
|
package/server/util.ts
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Small cross-cutting helpers shared across the relay (both the
|
|
2
|
+
// triage-sync root plane and the objstore plane). Kept in one module
|
|
3
|
+
// so the one-liners don't scatter / drift across call sites.
|
|
4
|
+
|
|
5
|
+
import { randomBytes } from 'node:crypto'
|
|
6
|
+
|
|
7
|
+
// Truncate a base64url tag for `DEBUG=1` logging. A full workspaceTag
|
|
8
|
+
// is an Ed25519 public key; operator logs shouldn't carry it verbatim.
|
|
9
|
+
export function debugTag(s: string): string { return `${s.slice(0, 12)}…` }
|
|
10
|
+
|
|
11
|
+
// 16 random bytes → 22 base64url chars (no padding). The shared shape
|
|
12
|
+
// for per-socket challenge nonces and staging ids — collision is
|
|
13
|
+
// 1/2^128. `isValidStagingId` in objstore/store.ts validates exactly
|
|
14
|
+
// this shape.
|
|
15
|
+
export function randomId(): string { return randomBytes(16).toString('base64url') }
|
|
16
|
+
|
|
17
|
+
// Log-friendly view of an unknown throw, for use as a `console.*`
|
|
18
|
+
// ARGUMENT (not interpolated into a template literal — see below).
|
|
19
|
+
// `errMsg` for the common one-line warning, `errStack` where a
|
|
20
|
+
// post-mortem wants the throw site. An Error's `.message` / `.stack`
|
|
21
|
+
// is a string; a non-Error throw is returned unchanged so console
|
|
22
|
+
// renders it structurally (util.inspect) instead of coercing it to
|
|
23
|
+
// the useless `[object Object]`. A template-literal caller would
|
|
24
|
+
// coerce the return via `String()` and lose that, so pass these as a
|
|
25
|
+
// separate console argument.
|
|
26
|
+
export function errMsg(err: unknown): unknown { return (err as Error)?.message ?? err }
|
|
27
|
+
export function errStack(err: unknown): unknown { return (err as Error)?.stack ?? err }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Wire-field shape gates for `workspace-save`: base64-or-base64url
|
|
2
|
+
// alphabet, length-bounded. The critical guarantee is "no newlines" —
|
|
3
|
+
// without it, `nonce = "AAA\nBBB"` + `ciphertext = "CCC"` produces the
|
|
4
|
+
// same canonical bytes as `nonce = "AAA"` + `ciphertext = "BBB\nCCC"`
|
|
5
|
+
// (canonicalSave newline-joins), causing same-id collisions across
|
|
6
|
+
// distinct stored fields. Two alphabets:
|
|
7
|
+
//
|
|
8
|
+
// - workspaceTag / signature / base — base64url-no-padding only.
|
|
9
|
+
// Clients always emit these via `toBase64({ alphabet: 'base64url',
|
|
10
|
+
// omitPadding: true })` (client/sync-crypto.ts), and the same
|
|
11
|
+
// workspaceTag must round-trip through objstore's TAG_RE (also
|
|
12
|
+
// base64url-no-padding) for cross-protocol consistency. Accepting
|
|
13
|
+
// `+/=` here would let a buggy or hostile client split its data
|
|
14
|
+
// across two encodings of the same workspace.
|
|
15
|
+
// - nonce / ciphertext — base64 OR base64url (union alphabet).
|
|
16
|
+
// Clients emit these via `toBase64()` with no alphabet hint
|
|
17
|
+
// (standard base64 with `+/=` padding), and the bytes are opaque
|
|
18
|
+
// to the server — no cross-protocol identity is bound to the
|
|
19
|
+
// encoding. The newline-collision guard is the only invariant
|
|
20
|
+
// here; the wider alphabet is acceptable.
|
|
21
|
+
//
|
|
22
|
+
// Short-field length caps bound the canonical and `MAX_CIPHERTEXT_LEN`
|
|
23
|
+
// bounds chain-bloat; the ciphertext size check runs post-sig (in
|
|
24
|
+
// handleSave) so the error response only reaches a legit signer.
|
|
25
|
+
const TAG_SIG_BASE_RE = /^[\w-]+$/u
|
|
26
|
+
const NONCE_CIPHER_RE = /^[\w+/=-]+$/u
|
|
27
|
+
export const MAX_FIELD_LEN = 128
|
|
28
|
+
export const MAX_CIPHERTEXT_LEN = 2 * 1024 * 1024
|
|
29
|
+
|
|
30
|
+
export const validTagSigBase = (s: unknown, max: number): s is string => typeof s === 'string' && s.length > 0 && s.length <= max && TAG_SIG_BASE_RE.test(s)
|
|
31
|
+
export const validNonce = (s: unknown, max: number): s is string => typeof s === 'string' && s.length > 0 && s.length <= max && NONCE_CIPHER_RE.test(s)
|
|
32
|
+
// Ciphertext: same alphabet as nonce but the size cap is checked
|
|
33
|
+
// POST-sig (to avoid leaking the cap to unauthenticated probes).
|
|
34
|
+
// Pre-sig only the shape gate applies; `maxPayload` (4 MiB) already
|
|
35
|
+
// bounds the total frame, so the worst-case bytes are still bounded.
|
|
36
|
+
export const validCiphertextShape = (s: unknown): s is string => typeof s === 'string' && s.length > 0 && NONCE_CIPHER_RE.test(s)
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
// WebSocket runtime: the per-connection handler (Peer setup, message
|
|
2
|
+
// dispatch, close/error) and the server-driven heartbeat sweep. Wired
|
|
3
|
+
// once at boot onto the shared `wss` with the protocol handlers +
|
|
4
|
+
// lifecycle hooks it dispatches to. Kept out of index.ts so the WS
|
|
5
|
+
// message loop — the most concurrency-sensitive surface — is one unit.
|
|
6
|
+
|
|
7
|
+
import type { WebSocket, WebSocketServer } from 'ws'
|
|
8
|
+
import { Buffer } from 'node:buffer'
|
|
9
|
+
import { decodeUtf8 } from '../common/utf8.js'
|
|
10
|
+
import type { SaveErrorReason } from '../common/save-error-reason.ts'
|
|
11
|
+
import { Peer, type PeerRegistry } from './peer.ts'
|
|
12
|
+
import { errMsg, errStack, randomId } from './util.ts'
|
|
13
|
+
import type { SaveMsg, SubscribeMsg } from './sign.ts'
|
|
14
|
+
import type { AuthenticateMsg } from './auth.ts'
|
|
15
|
+
import type { ObjstoreHandlers } from './objstore/handlers.ts'
|
|
16
|
+
import type { ObjstoreDeleteMsg, ObjstoreFetchMsg, ObjstorePutBeginMsg } from './objstore/sign.ts'
|
|
17
|
+
|
|
18
|
+
// Wire-message envelope as it lands post-`JSON.parse`. Every field is
|
|
19
|
+
// `unknown` until a handler narrows it; the type just documents the
|
|
20
|
+
// dispatch surface so call sites can pattern-match on `msg.type`.
|
|
21
|
+
type IncomingMessage = {
|
|
22
|
+
type?: unknown
|
|
23
|
+
[k: string]: unknown
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type WsServerDeps = {
|
|
27
|
+
wss: WebSocketServer
|
|
28
|
+
peers: PeerRegistry
|
|
29
|
+
send: (socket: WebSocket, msg: object) => void
|
|
30
|
+
unsubscribeAll: (socket: WebSocket) => void
|
|
31
|
+
handleSave: (socket: WebSocket, msg: SaveMsg) => Promise<void>
|
|
32
|
+
handleSubscribe: (socket: WebSocket, msg: SubscribeMsg) => Promise<void>
|
|
33
|
+
handleAuthenticate: (socket: WebSocket, msg: AuthenticateMsg) => void
|
|
34
|
+
sendSaveError: (socket: WebSocket, workspaceTag: string, base: string | null, reason: SaveErrorReason) => void
|
|
35
|
+
objstore: ObjstoreHandlers
|
|
36
|
+
track: (promise: Promise<unknown>) => void
|
|
37
|
+
isShuttingDown: () => boolean
|
|
38
|
+
maxInflightPerSocket: number
|
|
39
|
+
heartbeatIntervalMs: number
|
|
40
|
+
debug: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Wires `wss.on('connection')` + starts the heartbeat. Returns the
|
|
44
|
+
// timer so shutdown can clear it.
|
|
45
|
+
export function installWsServer(deps: WsServerDeps): { heartbeatTimer: ReturnType<typeof setInterval> } {
|
|
46
|
+
const {
|
|
47
|
+
wss, peers, send, unsubscribeAll, handleSave, handleSubscribe, handleAuthenticate,
|
|
48
|
+
sendSaveError, objstore, track, isShuttingDown, maxInflightPerSocket, heartbeatIntervalMs, debug,
|
|
49
|
+
} = deps
|
|
50
|
+
|
|
51
|
+
wss.on('connection', (socket: WebSocket, req) => {
|
|
52
|
+
if (debug) console.log(`connect from ${req.socket.remoteAddress}`)
|
|
53
|
+
// One Peer holds this connection's state (challenge / authorized /
|
|
54
|
+
// alive / inflight / tags). Created before any client frame can
|
|
55
|
+
// arrive (`socket.on('message')` is wired below). The heartbeat
|
|
56
|
+
// sweep flips `alive` false after each `ping()`; the `pong` listener
|
|
57
|
+
// flips it back, and a socket still false on the next sweep is
|
|
58
|
+
// terminated — the only thing closing FDs for an idle peer.
|
|
59
|
+
const peer = new Peer(randomId())
|
|
60
|
+
peers.set(socket, peer)
|
|
61
|
+
socket.on('pong', () => { peer.alive = true })
|
|
62
|
+
// Issue the per-connection challenge nonce BEFORE the client can
|
|
63
|
+
// send anything that needs it. The client signs it into every
|
|
64
|
+
// `workspace-subscribe` (see canonicalSubscribe in server/sign.ts);
|
|
65
|
+
// a captured subscribe frame can't be replayed from a different
|
|
66
|
+
// connection because that connection's nonce differs and the
|
|
67
|
+
// signature won't verify against the new canonical bytes. Round-9 H2.
|
|
68
|
+
send(socket, { type: 'challenge', nonce: peer.challenge })
|
|
69
|
+
// Per-socket handlers are DELIBERATELY NOT serialized (vs the
|
|
70
|
+
// client-side `messageQueue = messageQueue.then(...)` Promise
|
|
71
|
+
// chain inside `client/triage-sync.ts:onTransportMessage`).
|
|
72
|
+
// Each inbound frame spawns its own tracked async IIFE; two
|
|
73
|
+
// frames from the same socket can interleave across `await`
|
|
74
|
+
// boundaries inside the handlers. Per-resource correctness needs
|
|
75
|
+
// no in-process lock: `commitRevision` resolves concurrent saves
|
|
76
|
+
// via its single gated INSERT (one snapshot + the
|
|
77
|
+
// `UNIQUE(workspace_tag, seq)` PK — see `server/db.ts`), and the
|
|
78
|
+
// objstore handlers (`commitPut` / `beginPut` / `deleteObject`)
|
|
79
|
+
// via the version compare-and-set + content-addressing (see
|
|
80
|
+
// `server/objstore/store.ts`), backed by post-await
|
|
81
|
+
// `readyState === OPEN` rechecks in every objstore handler. The
|
|
82
|
+
// unbounded fan-out is capped by `maxInflightPerSocket` (see
|
|
83
|
+
// also the `'busy'` NACK at the cap below). Concurrent dispatch
|
|
84
|
+
// is intentional: it lets multi-workspace clients multiplex
|
|
85
|
+
// saves + subscribes over one socket without HOL blocking.
|
|
86
|
+
// Audit follow-up to round-15 concurrency review.
|
|
87
|
+
socket.on('message', (data: Buffer, isBinary: boolean) => {
|
|
88
|
+
// Drop new work once shutdown started — `wss.close()` stops new
|
|
89
|
+
// CONNECTIONS but already-open sockets can still send messages.
|
|
90
|
+
// Without this gate a message arriving between `wss.close()`
|
|
91
|
+
// resolving and the `inFlight` snapshot would spawn a handler
|
|
92
|
+
// that's NOT in the snapshot, then resume against the just-
|
|
93
|
+
// closed DB and throw inside the commit's gated INSERT. Audit
|
|
94
|
+
// round-9.
|
|
95
|
+
if (isShuttingDown()) return
|
|
96
|
+
// Wire protocol is JSON over text frames. A binary frame is
|
|
97
|
+
// either a buggy client or someone probing — drop without
|
|
98
|
+
// attempting to interpret it as text.
|
|
99
|
+
if (isBinary) return
|
|
100
|
+
let msg: IncomingMessage | null = null
|
|
101
|
+
try {
|
|
102
|
+
// `decodeUtf8` is fatal on invalid UTF-8 (vs `Buffer.toString`
|
|
103
|
+
// which silently substitutes U+FFFD). The substitution path
|
|
104
|
+
// would let mangled bytes pass JSON.parse only to fail
|
|
105
|
+
// signature verification deeper in the handler — wasted work
|
|
106
|
+
// and noisier logs. Fail at the gate.
|
|
107
|
+
msg = JSON.parse(decodeUtf8(data)) as IncomingMessage
|
|
108
|
+
} catch { return }
|
|
109
|
+
if (!msg || typeof msg !== 'object') return
|
|
110
|
+
const parsed: IncomingMessage = msg
|
|
111
|
+
// Per-socket inflight cap. Each tracked handler keeps the socket
|
|
112
|
+
// alive in `inFlight`, and a peer who keeps firing valid frames
|
|
113
|
+
// can spawn unbounded handlers — growing SIGTERM drain time and
|
|
114
|
+
// memory. Drop new work above the cap. Heartbeat ping is the
|
|
115
|
+
// exception: it's stateless, synchronous, and we want to KEEP
|
|
116
|
+
// responding so the peer doesn't drop the socket while we're
|
|
117
|
+
// shedding load. Pings go through a fast inline `send(pong)`
|
|
118
|
+
// path BELOW that doesn't bump the per-socket inflight counter,
|
|
119
|
+
// so a ping-spam at the cap can't outrun the gate. Transport
|
|
120
|
+
// audit `server/index.ts:590` + post-#58 audit follow-up.
|
|
121
|
+
if (parsed.type === 'ping') {
|
|
122
|
+
send(socket, { type: 'pong' })
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
// `authenticate` runs synchronously (constant-time bytes compare
|
|
126
|
+
// — no DB, no I/O), so it shares the same fast-inline path as
|
|
127
|
+
// `ping` and bypasses the per-socket inflight counter. Keeping
|
|
128
|
+
// it out of the IIFE pool means an unauthenticated client can
|
|
129
|
+
// still complete the handshake when the socket is otherwise
|
|
130
|
+
// saturated (matching the philosophy of the `busy` NACK path
|
|
131
|
+
// for `workspace-save`: don't strand a recoverable handshake
|
|
132
|
+
// behind the cap).
|
|
133
|
+
if (parsed.type === 'authenticate') {
|
|
134
|
+
handleAuthenticate(socket, parsed as AuthenticateMsg)
|
|
135
|
+
return
|
|
136
|
+
}
|
|
137
|
+
if (peer.inflight >= maxInflightPerSocket) {
|
|
138
|
+
if (debug) console.warn(`drop message: socket inflight ${peer.inflight} >= ${maxInflightPerSocket}`)
|
|
139
|
+
// For workspace-save specifically, send a typed NACK so the
|
|
140
|
+
// client's `pending` slot clears IMMEDIATELY instead of
|
|
141
|
+
// hanging until the next heartbeat (~15–30s). Reason `busy`
|
|
142
|
+
// is server-side overload; safe to retry. Same wire envelope
|
|
143
|
+
// as the existing `too-large` save-error path.
|
|
144
|
+
//
|
|
145
|
+
// Wire-order interaction with `'stale-base'`: the cap-path
|
|
146
|
+
// send is SYNCHRONOUS in this message callback and lands on
|
|
147
|
+
// the wire BEFORE any handler IIFE's `await`-completed reply.
|
|
148
|
+
// If an earlier in-flight `workspace-save` (frame F1) ends up
|
|
149
|
+
// emitting a catch-up `workspace-state` + `'stale-base'` while
|
|
150
|
+
// a later frame F2 hits the cap, the order is `'busy'`(F2) →
|
|
151
|
+
// `workspace-state`(F1) → `'stale-base'`(F1). The client's
|
|
152
|
+
// `handleSaveError` correlates on `base`, and the wire-order
|
|
153
|
+
// trick documented in `common/save-error-reason.ts` (catch-up
|
|
154
|
+
// clears `pending` before the stale-base frame's
|
|
155
|
+
// `handleSaveError` runs) still holds.
|
|
156
|
+
const rawBase = (parsed as SaveMsg).base
|
|
157
|
+
const baseField: string | null = typeof rawBase === 'string' ? rawBase : null
|
|
158
|
+
const rawTag = (parsed as SaveMsg).workspaceTag
|
|
159
|
+
const tagField = typeof rawTag === 'string' ? rawTag : null
|
|
160
|
+
if (parsed.type === 'workspace-save' && tagField != null) {
|
|
161
|
+
// `sendSaveError` runs its taxonomy-guard before the wire
|
|
162
|
+
// send and throws on an unknown reason. Every OTHER emit
|
|
163
|
+
// site lives inside the `handler` IIFE's try/catch — this
|
|
164
|
+
// cap path is the only one outside it. Mirror the same
|
|
165
|
+
// forensic envelope so a future bad reason here surfaces
|
|
166
|
+
// as `Handler error (type=workspace-save): …` rather than
|
|
167
|
+
// escaping to ws's emitter as an uncaught.
|
|
168
|
+
try {
|
|
169
|
+
sendSaveError(socket, tagField, baseField, 'busy')
|
|
170
|
+
} catch (err) {
|
|
171
|
+
console.warn('Handler error (type=workspace-save):', errStack(err))
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return
|
|
175
|
+
}
|
|
176
|
+
peer.inflight += 1
|
|
177
|
+
const handler = (async () => {
|
|
178
|
+
try {
|
|
179
|
+
if (parsed.type === 'workspace-save') await handleSave(socket, parsed as SaveMsg)
|
|
180
|
+
else if (parsed.type === 'workspace-subscribe') await handleSubscribe(socket, parsed as SubscribeMsg)
|
|
181
|
+
// Objstore control plane — bytes ride the REST plane via
|
|
182
|
+
// tokens these handlers mint. The Objstore*Msg types are
|
|
183
|
+
// weak shapes (every field `unknown`); the handlers narrow
|
|
184
|
+
// each field through their own validators on entry.
|
|
185
|
+
else if (parsed.type === 'objstore-put-begin') await objstore.handlePutBegin(socket, parsed as ObjstorePutBeginMsg)
|
|
186
|
+
else if (parsed.type === 'objstore-delete') await objstore.handleDelete(socket, parsed as ObjstoreDeleteMsg)
|
|
187
|
+
else if (parsed.type === 'objstore-fetch') await objstore.handleFetch(socket, parsed as ObjstoreFetchMsg)
|
|
188
|
+
} catch (err) {
|
|
189
|
+
// Forensic logging for unexpected throws — the handlers all
|
|
190
|
+
// have internal narrow catches (e.g. signature reject paths);
|
|
191
|
+
// anything reaching here is unexpected. Include the wire
|
|
192
|
+
// `type` so an operator can correlate to a specific code
|
|
193
|
+
// path, and prefer `.stack` over `.message` so the post-
|
|
194
|
+
// mortem has the throw site.
|
|
195
|
+
const typeStr = typeof parsed.type === 'string' ? parsed.type : '<unknown>'
|
|
196
|
+
console.warn(`Handler error (type=${typeStr}):`, errStack(err))
|
|
197
|
+
} finally {
|
|
198
|
+
peer.inflight -= 1
|
|
199
|
+
}
|
|
200
|
+
})()
|
|
201
|
+
track(handler)
|
|
202
|
+
})
|
|
203
|
+
socket.on('close', () => {
|
|
204
|
+
// `unsubscribeAll` reads `peer.tags` (peer still registered), then
|
|
205
|
+
// we drop the Peer. The Peer's state would GC once the socket is
|
|
206
|
+
// unreachable, but `wss.clients` / `ws` internals hold the socket
|
|
207
|
+
// strongly well past `close`, so the explicit delete frees it
|
|
208
|
+
// immediately. Audit round-10 + round-13.
|
|
209
|
+
unsubscribeAll(socket)
|
|
210
|
+
peers.delete(socket)
|
|
211
|
+
})
|
|
212
|
+
// Surface socket-level errors instead of swallowing — these are
|
|
213
|
+
// the signals operators want under abuse / network flakiness
|
|
214
|
+
// (TLS handshake failures, frame-decode errors, ws-protocol
|
|
215
|
+
// violations). The previous `() => {}` left every per-connection
|
|
216
|
+
// failure invisible. `close` fires after `error` and runs the
|
|
217
|
+
// unsubscribe cleanup, so logging here doesn't risk leaking.
|
|
218
|
+
socket.on('error', (err: Error) => { console.warn('Socket error:', errMsg(err)) })
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
// Periodic heartbeat sweep. Two-tick liveness window: a socket that
|
|
222
|
+
// doesn't `pong` within `heartbeatIntervalMs` of our `ping` sees its
|
|
223
|
+
// tracker flip to `false`; on the NEXT tick we terminate. `try/catch`
|
|
224
|
+
// shrugs at any peer that races us into CLOSING / CLOSED (`ws.ping` /
|
|
225
|
+
// `ws.terminate` throw in that state) — the close event handles
|
|
226
|
+
// cleanup either way.
|
|
227
|
+
//
|
|
228
|
+
// `unref` so the timer alone doesn't keep the event loop alive
|
|
229
|
+
// (parity with `terminateTimer` in shutdown). Cleared in shutdown so
|
|
230
|
+
// a graceful SIGTERM doesn't fire one last ping race after wss.close.
|
|
231
|
+
const heartbeatTimer = setInterval(() => {
|
|
232
|
+
for (const ws of wss.clients) {
|
|
233
|
+
const peer = peers.get(ws)
|
|
234
|
+
if (peer?.alive === false) {
|
|
235
|
+
try { ws.terminate() } catch {}
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
if (peer) peer.alive = false
|
|
239
|
+
try { ws.ping() } catch {}
|
|
240
|
+
}
|
|
241
|
+
}, heartbeatIntervalMs)
|
|
242
|
+
heartbeatTimer.unref?.()
|
|
243
|
+
|
|
244
|
+
return { heartbeatTimer }
|
|
245
|
+
}
|