@preventive/triage 1.0.0-alpha.1 → 1.0.0-alpha.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api/reap.ts +79 -0
- package/common/save-error-reason.ts +20 -7
- package/common/server-info.ts +30 -0
- package/out/brotli-fallback.js +1 -1
- package/out/client-admin.js +28 -0
- package/out/client-managed.js +1 -0
- package/out/client-sync.js +16 -13
- package/out/graph.js +5 -4
- package/out/index.html +4 -15
- package/out/prism.js +2 -2
- package/out/terminal.js +32 -28
- package/out/view.css +1 -1
- package/out/view.js +89 -52
- package/package.json +70 -54
- package/{server → server-common}/origin.ts +5 -5
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +8 -8
- package/server-e2e/cli.js +22 -0
- package/{server → server-e2e}/config.ts +21 -8
- package/{server → server-e2e}/db-neon.ts +31 -25
- package/{server → server-e2e}/db-revision-sql.ts +7 -10
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +96 -135
- package/{server → server-e2e}/http.ts +97 -9
- package/{server → server-e2e}/hub.ts +7 -8
- package/{server → server-e2e}/index.ts +67 -43
- package/{server → server-e2e}/lifecycle.ts +14 -5
- package/{server → server-e2e}/npm-proxy.ts +1 -1
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +69 -36
- package/{server → server-e2e}/objstore/blob.ts +24 -9
- package/server-e2e/objstore/fetch-mint-guard.ts +74 -0
- package/{server → server-e2e}/objstore/handlers.ts +13 -15
- package/{server → server-e2e}/objstore/init.ts +47 -13
- package/{server → server-e2e}/objstore/reaper.ts +31 -11
- package/server-e2e/objstore/rest-deny.ts +28 -0
- package/server-e2e/objstore/rest-mint.ts +224 -0
- package/{server → server-e2e}/objstore/rest.ts +110 -93
- package/{server → server-e2e}/objstore/sign.ts +105 -0
- package/{server → server-e2e}/objstore/store-neon.ts +19 -19
- package/{server → server-e2e}/objstore/store.ts +98 -118
- package/{server → server-e2e}/objstore/tokens.ts +9 -12
- package/{server → server-e2e}/peer.ts +7 -9
- package/{server → server-e2e}/pubsub.ts +21 -31
- package/{server → server-e2e}/sign.ts +12 -14
- package/{server → server-e2e}/sse-server.ts +105 -73
- package/{server → server-e2e}/sse-session.ts +30 -16
- package/{server → server-e2e}/static.ts +22 -17
- package/{server → server-e2e}/sync-handlers.ts +172 -117
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/strip-types-loader.js +94 -0
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/neon-driver.ts +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -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'
|
|
@@ -13,9 +15,8 @@ import { MAX_CIPHERTEXT_LEN, MAX_FIELD_LEN, validCiphertextShape, validNonce, va
|
|
|
13
15
|
import { debugTag } from './util.ts'
|
|
14
16
|
import type { UnauthorizedContext } from './auth.ts'
|
|
15
17
|
|
|
16
|
-
// `chainForWire`
|
|
17
|
-
// `keyframe`
|
|
18
|
-
// with `keyframe` normalised to a strict boolean for the wire.
|
|
18
|
+
// Wire shape `chainForWire` produces from a `chainFrom` row, with
|
|
19
|
+
// `keyframe` normalised from the SQLite INTEGER 0/1 to a strict boolean.
|
|
19
20
|
type WireRevision = {
|
|
20
21
|
base: string | null
|
|
21
22
|
id: string
|
|
@@ -25,6 +26,47 @@ type WireRevision = {
|
|
|
25
26
|
signature: string
|
|
26
27
|
}
|
|
27
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
|
+
|
|
28
70
|
export type SyncHandlersDeps = {
|
|
29
71
|
handle: Handle
|
|
30
72
|
send: (socket: WebSocket, msg: object) => void
|
|
@@ -39,6 +81,11 @@ export type SyncHandlersDeps = {
|
|
|
39
81
|
subscribe: (socket: WebSocket, tag: string) => void
|
|
40
82
|
getNonce: (socket: WebSocket) => string | undefined
|
|
41
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
|
|
42
89
|
sendUnauthorized: (socket: WebSocket, ctx: UnauthorizedContext) => void
|
|
43
90
|
workspaceExists: (tag: string) => Promise<boolean>
|
|
44
91
|
// Objstore inventory snapshot for a workspace tag, as wire rows. The
|
|
@@ -53,6 +100,10 @@ export type SyncHandlersDeps = {
|
|
|
53
100
|
|
|
54
101
|
export type SyncHandlers = {
|
|
55
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-e2e/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>
|
|
56
107
|
handleSubscribe: (socket: WebSocket, msg: SubscribeMsg) => Promise<void>
|
|
57
108
|
// Exported because the dispatcher's inflight-cap `busy` NACK path
|
|
58
109
|
// emits a save-error too (the only emit site outside this module).
|
|
@@ -60,7 +111,7 @@ export type SyncHandlers = {
|
|
|
60
111
|
}
|
|
61
112
|
|
|
62
113
|
export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
63
|
-
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
|
|
64
115
|
|
|
65
116
|
// Typed wrapper for the three `workspace-save-error` emit sites
|
|
66
117
|
// (too-large at handleSave, stale-base after the catch-up, busy at
|
|
@@ -88,21 +139,34 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
88
139
|
send(socket, { type: 'workspace-save-error', workspaceTag, base, reason })
|
|
89
140
|
}
|
|
90
141
|
|
|
91
|
-
// Normalise `keyframe` on outbound chain entries
|
|
142
|
+
// Normalise `keyframe` to a strict boolean on outbound chain entries.
|
|
92
143
|
// 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.
|
|
144
|
+
// raw rows; the wire contract (and canonical signing payload) uses
|
|
145
|
+
// strict `=== true`. Convert once on the send side — forwarding the
|
|
146
|
+
// integer relies on clients coercing via `Boolean()` before rebuilding
|
|
147
|
+
// canonical bytes, fragile against any client that strict-compares.
|
|
99
148
|
function chainForWire(revisions: RevisionRow[]): WireRevision[] {
|
|
100
149
|
return revisions.map((r) => ({ ...r, keyframe: r.keyframe === 1 }))
|
|
101
150
|
}
|
|
102
151
|
|
|
103
|
-
|
|
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> {
|
|
104
168
|
// `base` is `string | null`; null is the keyframe-root marker.
|
|
105
|
-
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' }
|
|
106
170
|
// Compute canonical bytes + content-addressed id ONCE, then thread
|
|
107
171
|
// both through the precheck → sig verify → commit pipeline:
|
|
108
172
|
// 1. canonicalSave (sync, throws on lone-surrogate input)
|
|
@@ -117,77 +181,43 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
117
181
|
// 6. commitRevision — re-checks dup + base + inserts via a single
|
|
118
182
|
// gated INSERT (dup gate + head-equals-base gate + the
|
|
119
183
|
// server-assigned seq folded into one statement) — NO write
|
|
120
|
-
// lock.
|
|
121
|
-
// collapse into that one statement, whose head-check and
|
|
122
|
-
// MAX(seq) read one snapshot; the `UNIQUE(workspace_tag, seq)`
|
|
123
|
-
// PK rejects any racer that computed the same seq. So two
|
|
124
|
-
// concurrent saves with the same `base` and different ids
|
|
125
|
-
// can't both insert (the loser's `head IS base` gate fails →
|
|
126
|
-
// `stale-base`, no chain fork even though UNIQUE is on id, not
|
|
127
|
-
// base), and two concurrent same-id retransmits resolve to one
|
|
128
|
-
// `inserted` + one `duplicate` with no UNIQUE throw escaping.
|
|
129
|
-
// See `commitRevisionSqlite` / `tryCommitNeon` in db*.ts.
|
|
184
|
+
// lock. See `commitRevisionSqlite` / `tryCommitNeon` in db*.ts.
|
|
130
185
|
let canonical: Uint8Array<ArrayBuffer>
|
|
131
|
-
try { canonical = canonicalSave(msg) } catch { return }
|
|
186
|
+
try { canonical = canonicalSave(msg) } catch { return { kind: 'rejected' } }
|
|
132
187
|
const id = await computeRevisionIdFromCanonical(canonical)
|
|
133
188
|
const tag = msg.workspaceTag
|
|
189
|
+
const baseNorm = msg.base ?? null
|
|
134
190
|
if (await revisionExists(handle, tag, id)) {
|
|
135
191
|
if (debug) console.log(`save (precheck dup ${id.slice(0, 8)}…) → ack-only`)
|
|
136
|
-
|
|
137
|
-
return
|
|
192
|
+
return { kind: 'duplicate', id, base: baseNorm }
|
|
138
193
|
}
|
|
139
194
|
if (!await verifyEd25519(tag, canonical, msg.signature)) {
|
|
140
195
|
if (debug) console.warn('reject save: bad signature', debugTag(tag))
|
|
141
|
-
return
|
|
196
|
+
return { kind: 'rejected' }
|
|
142
197
|
}
|
|
143
198
|
// Size policy — emit an explicit error so the client can surface
|
|
144
199
|
// the failure to the user. Without this, an oversized save hangs
|
|
145
200
|
// forever in the client's `pending` slot (no ack, no rebase).
|
|
146
201
|
if (msg.ciphertext.length > MAX_CIPHERTEXT_LEN) {
|
|
147
202
|
if (debug) console.warn(`reject save: ciphertext too large (${msg.ciphertext.length} > ${MAX_CIPHERTEXT_LEN})`)
|
|
148
|
-
|
|
149
|
-
return
|
|
203
|
+
return { kind: 'too-large', base: baseNorm }
|
|
150
204
|
}
|
|
151
205
|
// Auth gate for the FIRST action against a workspace tag that
|
|
152
|
-
// doesn't yet exist on the server
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
//
|
|
160
|
-
// RACE: `workspaceExists` reads at a different moment than the
|
|
161
|
-
// commit's gated INSERT (a plain TOCTOU — there is no lock spanning
|
|
162
|
-
// the two). Under concurrent saves on a fresh tag, an
|
|
163
|
-
// unauthenticated socket whose `workspaceExists` observes "true"
|
|
164
|
-
// (because an authenticated peer's commit landed between this
|
|
165
|
-
// socket's check and its commit) skips the gate and commits as the
|
|
166
|
-
// second writer. Accepted: the unauthenticated peer still had to
|
|
167
|
-
// produce a valid Ed25519 signature (= holds the workspace seed),
|
|
168
|
-
// and "two concurrent writes both authorising" is the worst case.
|
|
169
|
-
// Tightening would require folding the gate into the commit
|
|
170
|
-
// statement itself and is not worth the layer crossing for the
|
|
171
|
-
// soft-policy guarantee.
|
|
172
|
-
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)) {
|
|
173
213
|
if (debug) console.warn(`reject save: unauthorized (new workspace ${debugTag(tag)})`)
|
|
174
|
-
|
|
175
|
-
return
|
|
214
|
+
return { kind: 'unauthorized', base: baseNorm }
|
|
176
215
|
}
|
|
177
|
-
//
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
//
|
|
181
|
-
// mirror every future encrypted broadcast for the workspace,
|
|
182
|
-
// without ever holding the seed (the duplicate-id path returns
|
|
183
|
-
// ack-only and doesn't reject the socket). Audit round-9 H1.
|
|
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.
|
|
184
220
|
//
|
|
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.
|
|
190
|
-
const baseNorm = msg.base ?? null
|
|
191
221
|
// `keyframe === true` is what canonicalSave bound the signature to
|
|
192
222
|
// (strict equality); the signer's intent is unambiguous here.
|
|
193
223
|
const keyframe = msg.keyframe === true
|
|
@@ -197,46 +227,32 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
197
227
|
})
|
|
198
228
|
if (commit.kind === 'duplicate') {
|
|
199
229
|
if (debug) console.log(`save (duplicate id ${id.slice(0, 8)}…) → ack-only`)
|
|
200
|
-
|
|
201
|
-
return
|
|
230
|
+
return { kind: 'duplicate', id, base: baseNorm }
|
|
202
231
|
}
|
|
203
232
|
if (commit.kind === 'stale-base') {
|
|
204
|
-
// Client claimed a base that's no longer head.
|
|
205
|
-
// computed OUTSIDE
|
|
206
|
-
//
|
|
207
|
-
//
|
|
208
|
-
//
|
|
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).
|
|
209
241
|
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
//
|
|
216
|
-
//
|
|
217
|
-
// (debug surfaces / explicit rejection signal), not for triggering
|
|
218
|
-
// an error transition. Audit follow-up to round-15 —
|
|
219
|
-
// `sync-server-races.test.js:1105`.
|
|
242
|
+
// The catch-up CAN be empty: a client holding a base from a chain
|
|
243
|
+
// this deployment no longer has (wiped / moved DB, SQLite→Neon
|
|
244
|
+
// migration — head=null, no keyframe, no rows) gets `revisions: []`.
|
|
245
|
+
// The client detects that shape (pending survives the catch-up) and
|
|
246
|
+
// answers with a full-state push re-anchored at base=null, which
|
|
247
|
+
// commits as the new chain root — see the client's
|
|
248
|
+
// `handleSaveError` stale-base branch.
|
|
220
249
|
const revisions = chainForWire(await chainFrom(handle, tag, baseNorm))
|
|
221
250
|
if (debug) console.log(`save (stale base ${baseNorm} vs head ${commit.head}) → chain ${revisions.length}`)
|
|
222
|
-
|
|
223
|
-
sendSaveError(socket, tag, baseNorm, 'stale-base')
|
|
224
|
-
return
|
|
251
|
+
return { kind: 'stale-base', base: baseNorm, revisions }
|
|
225
252
|
}
|
|
226
253
|
if (debug) console.log(`save${keyframe ? ' [keyframe]' : ''} → revision ${id.slice(0, 8)}… for ${debugTag(tag)}`)
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
workspaceTag: tag,
|
|
230
|
-
base: baseNorm,
|
|
231
|
-
id,
|
|
232
|
-
})
|
|
233
|
-
// Carry `keyframe` as a strict boolean on the broadcast wire —
|
|
234
|
-
// peers strict-compare `=== true` (matching the canonical-payload
|
|
235
|
-
// contract). The previous shape emitted `keyframe ? 1 : 0` which a
|
|
236
|
-
// strict check would treat as non-keyframe, making a replayed
|
|
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.
|
|
254
|
+
// Carry `keyframe` as a strict boolean on the broadcast wire — peers
|
|
255
|
+
// strict-compare `=== true` (matching the canonical-payload contract).
|
|
240
256
|
broadcast(tag, {
|
|
241
257
|
type: 'workspace-state',
|
|
242
258
|
workspaceTag: tag,
|
|
@@ -248,28 +264,67 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
248
264
|
ciphertext: msg.ciphertext,
|
|
249
265
|
signature: msg.signature,
|
|
250
266
|
}],
|
|
251
|
-
},
|
|
252
|
-
// Cross-instance fan-out. The bus payload carries only the revision
|
|
253
|
-
//
|
|
254
|
-
//
|
|
255
|
-
// for the bus's 8 KB payload budget, which can't carry a 2 MiB
|
|
256
|
-
// ciphertext. SQLite mode passes a no-op; Neon mode publishes via
|
|
257
|
-
// pg_notify. Best-effort: a dropped publish only means peers on
|
|
258
|
-
// other instances miss the live push, but they still catch up via
|
|
259
|
-
// the shared DB on their next subscribe / reconnect.
|
|
267
|
+
}, except)
|
|
268
|
+
// Cross-instance fan-out. The bus payload carries only the revision id —
|
|
269
|
+
// peers on OTHER instances re-fetch the row. SQLite mode passes a no-op;
|
|
270
|
+
// Neon mode publishes via pg_notify. Best-effort.
|
|
260
271
|
publishRevision(tag, id)
|
|
272
|
+
return { kind: 'ack', id, base: baseNorm }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// WS renderer: run the shared pipeline against this socket and render the
|
|
276
|
+
// outcome as the protocol frames the client expects on its stream. A
|
|
277
|
+
// `rejected` outcome drops silently (matches the prior malformed/bad-sig
|
|
278
|
+
// behaviour). For every non-rejected outcome the tag was validated inside
|
|
279
|
+
// `commitSave`, so the cast to string is sound.
|
|
280
|
+
async function handleSave(socket: WebSocket, msg: SaveMsg): Promise<void> {
|
|
281
|
+
const outcome = await commitSave(msg, requiresAuth(socket), socket)
|
|
282
|
+
if (outcome.kind === 'rejected') return
|
|
283
|
+
const tag = msg.workspaceTag as string
|
|
284
|
+
if (outcome.kind === 'unauthorized') { sendUnauthorized(socket, { kind: 'gated', workspaceTag: tag, base: outcome.base }); return }
|
|
285
|
+
if (outcome.kind === 'too-large') { sendSaveError(socket, tag, outcome.base, 'too-large'); return }
|
|
286
|
+
if (outcome.kind === 'stale-base') {
|
|
287
|
+
// State FIRST (its handler clears pending), then the typed error.
|
|
288
|
+
send(socket, { type: 'workspace-state', workspaceTag: tag, revisions: outcome.revisions })
|
|
289
|
+
sendSaveError(socket, tag, outcome.base, 'stale-base')
|
|
290
|
+
return
|
|
291
|
+
}
|
|
292
|
+
// ack | duplicate
|
|
293
|
+
send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: outcome.base, id: outcome.id })
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// REST renderer: the session-independent `POST /api/sync/save` plane. Reads
|
|
297
|
+
// the save frame from the JSON body, runs the SAME pipeline (no socket;
|
|
298
|
+
// new-workspace gate = passwordConfigured; broadcast except = null), and
|
|
299
|
+
// maps the outcome to a JSON + HTTP status the client switches on. SSE-mode
|
|
300
|
+
// clients POST here so a save doesn't take over their event-stream; a 401
|
|
301
|
+
// routes them to the in-band frame (which runs the operator auth flow).
|
|
302
|
+
// Mounted + gated (same-origin, shutdown, idle-timeout) in server-e2e/http.ts.
|
|
303
|
+
async function handleSaveRest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
304
|
+
const body = await readSaveBody(req)
|
|
305
|
+
if (!body || typeof body !== 'object') { respondJson(res, 400, { reason: 'bad-request' }); return }
|
|
306
|
+
const outcome = await commitSave(body as SaveMsg, passwordConfigured, null)
|
|
307
|
+
if (outcome.kind === 'ack' || outcome.kind === 'duplicate') { respondJson(res, 200, { ok: true, id: outcome.id }); return }
|
|
308
|
+
if (outcome.kind === 'stale-base') { respondJson(res, 409, { reason: 'stale-base', revisions: outcome.revisions }); return }
|
|
309
|
+
if (outcome.kind === 'too-large') { respondJson(res, 413, { reason: 'too-large' }); return }
|
|
310
|
+
if (outcome.kind === 'unauthorized') { respondJson(res, 401, { reason: 'unauthorized' }); return }
|
|
311
|
+
respondJson(res, 400, { reason: 'bad-request' })
|
|
261
312
|
}
|
|
262
313
|
|
|
263
314
|
async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {
|
|
264
|
-
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
|
|
315
|
+
// Mirror handleSave's wire gate: length-cap + base64url-alphabet on
|
|
316
|
+
// workspaceTag / signature / from BEFORE canonicalSubscribe UTF-8-
|
|
317
|
+
// encodes them and verifyEd25519 base64-decodes the signature.
|
|
318
|
+
// Without it a peer can submit up-to-maxPayload strings and force
|
|
319
|
+
// O(n) encode + decode per subscribe before the 32/64-byte length
|
|
320
|
+
// gates reject — a CPU-DoS surface handleSave is already hardened
|
|
321
|
+
// against. `from` keeps the `string | null` contract (null = full-
|
|
322
|
+
// chain catch-up); a legit signer's `from` is a base64url revision
|
|
323
|
+
// id, so any non-string / over-long / wrong-alphabet value can't be
|
|
324
|
+
// one the signature was computed over, and the chain-lookup path
|
|
325
|
+
// (`typeof msg.from === 'string' ? msg.from : null`) still treats a
|
|
326
|
+
// null / absent `from` as the keyframe-fallback.
|
|
327
|
+
if (!validTagSigBase(msg.workspaceTag, MAX_FIELD_LEN) || !validTagSigBase(msg.signature, MAX_FIELD_LEN) || (msg.from != null && !validTagSigBase(msg.from, MAX_FIELD_LEN))) return
|
|
273
328
|
// The challenge nonce we issued on this socket is bound into the
|
|
274
329
|
// signed canonical, blocking cross-connection replay of a captured
|
|
275
330
|
// subscribe frame. A subscribe arriving before we sent the
|
|
@@ -323,5 +378,5 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
323
378
|
send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
|
|
324
379
|
}
|
|
325
380
|
|
|
326
|
-
return { handleSave, handleSubscribe, sendSaveError }
|
|
381
|
+
return { handleSave, handleSaveRest, handleSubscribe, sendSaveError }
|
|
327
382
|
}
|
|
@@ -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
|
|
@@ -16,6 +16,7 @@ import { Buffer } from 'node:buffer'
|
|
|
16
16
|
import type { IncomingMessage as HttpRequest } from 'node:http'
|
|
17
17
|
import { decodeUtf8 } from '../common/utf8.js'
|
|
18
18
|
import type { SaveErrorReason } from '../common/save-error-reason.ts'
|
|
19
|
+
import type { ServerInfo } from '../common/server-info.ts'
|
|
19
20
|
import { Peer, type PeerRegistry } from './peer.ts'
|
|
20
21
|
import { errMsg, errStack, randomId } from './util.ts'
|
|
21
22
|
import type { SaveMsg, SubscribeMsg } from './sign.ts'
|
|
@@ -36,6 +37,9 @@ type IncomingMessage = {
|
|
|
36
37
|
// SSE path doesn't need them.
|
|
37
38
|
export type PeerConnectionDeps = {
|
|
38
39
|
peers: PeerRegistry
|
|
40
|
+
// The mode this server advertises, emitted as the first `server-info`
|
|
41
|
+
// frame on every connection (see setupPeerConnection).
|
|
42
|
+
serverInfo: ServerInfo
|
|
39
43
|
send: (socket: WebSocket, msg: object) => void
|
|
40
44
|
unsubscribeAll: (socket: WebSocket) => void
|
|
41
45
|
handleSave: (socket: WebSocket, msg: SaveMsg) => Promise<void>
|
|
@@ -55,33 +59,36 @@ export type WsServerDeps = PeerConnectionDeps & {
|
|
|
55
59
|
}
|
|
56
60
|
|
|
57
61
|
// 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.
|
|
62
|
+
// close / error / pong listeners. `socket` is the WebSocket-shaped
|
|
63
|
+
// surface — a real `ws` `WebSocket`, or an `SseSession` cast through
|
|
64
|
+
// `as unknown as WebSocket`; only the subset both expose is used.
|
|
63
65
|
export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: PeerConnectionDeps): void {
|
|
64
66
|
const {
|
|
65
|
-
peers, send, unsubscribeAll, handleSave, handleSubscribe, handleAuthenticate,
|
|
67
|
+
peers, serverInfo, send, unsubscribeAll, handleSave, handleSubscribe, handleAuthenticate,
|
|
66
68
|
sendSaveError, objstore, track, isShuttingDown, maxInflightPerSocket, debug,
|
|
67
69
|
} = deps
|
|
68
70
|
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.
|
|
71
|
+
// One Peer holds this connection's state, created before any client
|
|
72
|
+
// frame can arrive (`socket.on('message')` is wired below). The
|
|
73
|
+
// heartbeat sweep's two-tick liveness check (see below) is the only
|
|
74
|
+
// thing closing FDs for an otherwise-idle peer.
|
|
75
75
|
const peer = new Peer(randomId())
|
|
76
76
|
peers.set(socket, peer)
|
|
77
77
|
socket.on('pong', () => { peer.alive = true })
|
|
78
|
-
// Issue the per-connection challenge nonce
|
|
79
|
-
// send anything that needs it
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
78
|
+
// Issue the per-connection challenge nonce FIRST — before the client can
|
|
79
|
+
// send anything that needs it, AND as frame #0 so clients/tests that
|
|
80
|
+
// positionally read the first frame as the challenge keep working. The
|
|
81
|
+
// client signs it into every `workspace-subscribe` (see canonicalSubscribe
|
|
82
|
+
// in server-e2e/sign.ts); a captured subscribe frame can't be replayed from
|
|
83
|
+
// a different connection because that connection's nonce differs and the
|
|
83
84
|
// signature won't verify against the new canonical bytes. Round-9 H2.
|
|
84
85
|
send(socket, { type: 'challenge', nonce: peer.challenge })
|
|
86
|
+
// Then advertise the sync protocol (the mode probe), right after the
|
|
87
|
+
// challenge — the WS plane and the SSE fallback share this path. A client
|
|
88
|
+
// uses it to detect the mode, cache it, and refuse a cross-mode switch.
|
|
89
|
+
// Unauthenticated + mode-agnostic; an older client ignores it (predicate
|
|
90
|
+
// readers skip it; the workspaceTag demux drops it).
|
|
91
|
+
send(socket, { type: 'server-info', ...serverInfo })
|
|
85
92
|
// Per-socket handlers are DELIBERATELY NOT serialized (vs the
|
|
86
93
|
// client-side `messageQueue = messageQueue.then(...)` Promise
|
|
87
94
|
// chain inside `client/triage-sync.ts:onTransportMessage`).
|
|
@@ -90,10 +97,10 @@ export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: P
|
|
|
90
97
|
// boundaries inside the handlers. Per-resource correctness needs
|
|
91
98
|
// no in-process lock: `commitRevision` resolves concurrent saves
|
|
92
99
|
// via its single gated INSERT (one snapshot + the
|
|
93
|
-
// `UNIQUE(workspace_tag, seq)` PK — see `server/db.ts`), and the
|
|
100
|
+
// `UNIQUE(workspace_tag, seq)` PK — see `server-e2e/db.ts`), and the
|
|
94
101
|
// objstore handlers (`commitPut` / `beginPut` / `deleteObject`)
|
|
95
102
|
// via the version compare-and-set + content-addressing (see
|
|
96
|
-
// `server/objstore/store.ts`), backed by post-await
|
|
103
|
+
// `server-e2e/objstore/store.ts`), backed by post-await
|
|
97
104
|
// `readyState === OPEN` rechecks in every objstore handler. The
|
|
98
105
|
// unbounded fan-out is capped by `maxInflightPerSocket` (see
|
|
99
106
|
// also the `'busy'` NACK at the cap below). Concurrent dispatch
|
|
@@ -133,7 +140,7 @@ export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: P
|
|
|
133
140
|
// shedding load. Pings go through a fast inline `send(pong)`
|
|
134
141
|
// path BELOW that doesn't bump the per-socket inflight counter,
|
|
135
142
|
// so a ping-spam at the cap can't outrun the gate. Transport
|
|
136
|
-
// audit `server/index.ts:590` + post-#58 audit follow-up.
|
|
143
|
+
// audit `server-e2e/index.ts:590` + post-#58 audit follow-up.
|
|
137
144
|
if (parsed.type === 'ping') {
|
|
138
145
|
send(socket, { type: 'pong' })
|
|
139
146
|
return
|
|
@@ -228,9 +235,8 @@ export function setupPeerConnection(socket: WebSocket, req: HttpRequest, deps: P
|
|
|
228
235
|
// Surface socket-level errors instead of swallowing — these are
|
|
229
236
|
// the signals operators want under abuse / network flakiness
|
|
230
237
|
// (TLS handshake failures, frame-decode errors, ws-protocol
|
|
231
|
-
// violations).
|
|
232
|
-
//
|
|
233
|
-
// unsubscribe cleanup, so logging here doesn't risk leaking.
|
|
238
|
+
// violations). `close` fires after `error` and runs the unsubscribe
|
|
239
|
+
// cleanup, so logging here doesn't risk leaking.
|
|
234
240
|
socket.on('error', (err: Error) => { console.warn('Socket error:', errMsg(err)) })
|
|
235
241
|
}
|
|
236
242
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Module customization hook that erases TypeScript types using Node's
|
|
2
|
+
// built-in `module.stripTypeScriptTypes()` — the same eraser that powers
|
|
3
|
+
// Node's unflagged `.ts` execution, exposed here as an explicit loader so
|
|
4
|
+
// this package can run its own `.ts` source after it's installed as a
|
|
5
|
+
// dependency. Built on Node's bundled eraser, it needs no third-party dep
|
|
6
|
+
// (cf. `amaro`, which is just the library Node already bundles for this).
|
|
7
|
+
//
|
|
8
|
+
// Why this is needed at all — Node DISABLES built-in `.ts` stripping for
|
|
9
|
+
// any file under `node_modules` (it throws ERR_UNSUPPORTED_NODE_MODULES_
|
|
10
|
+
// TYPE_STRIPPING), which is exactly where this package's `.ts` files live
|
|
11
|
+
// once it's a dependency. A registered `load` hook is not bound by that
|
|
12
|
+
// rule, so it restores `.ts` execution from inside `node_modules`.
|
|
13
|
+
//
|
|
14
|
+
// Registered as an entry point — `@preventive/triage/strip-types-loader` —
|
|
15
|
+
// so consumers can run the server (or import `@preventive/triage/server`)
|
|
16
|
+
// with the hook active:
|
|
17
|
+
// node --import @preventive/triage/strip-types-loader <entry>
|
|
18
|
+
//
|
|
19
|
+
// `registerHooks` (synchronous, in-thread) is used over the async,
|
|
20
|
+
// worker-thread `module.register`: stripping is a pure synchronous string
|
|
21
|
+
// transform, so there's no reason to round-trip every load through a
|
|
22
|
+
// worker — and the read below is synchronous too.
|
|
23
|
+
//
|
|
24
|
+
// We must read + strip the source OURSELVES rather than delegate to
|
|
25
|
+
// `nextLoad`: under `node_modules`, `nextLoad`'s default format detection
|
|
26
|
+
// is what throws ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING, before our
|
|
27
|
+
// hook ever sees the source. Reading the bytes and short-circuiting with
|
|
28
|
+
// an explicit format sidesteps that gate.
|
|
29
|
+
//
|
|
30
|
+
// `mode: 'strip'` (types elided, no enum/namespace transform) suffices —
|
|
31
|
+
// the repo is `erasableSyntaxOnly` (tsconfig.json). Strip mode replaces
|
|
32
|
+
// each elided type with equal-width whitespace, so line AND column
|
|
33
|
+
// positions survive 1:1 and stack traces stay exact without a source map.
|
|
34
|
+
|
|
35
|
+
import { isUtf8 } from 'node:buffer'
|
|
36
|
+
import { readFileSync } from 'node:fs'
|
|
37
|
+
import { registerHooks, stripTypeScriptTypes } from 'node:module'
|
|
38
|
+
import { fileURLToPath } from 'node:url'
|
|
39
|
+
|
|
40
|
+
// `.ts` / `.mts` -> ESM, `.cts` -> CommonJS. The `kind` group is the
|
|
41
|
+
// optional `m`/`c` infix; absent (plain `.ts`) defaults to ESM, which is
|
|
42
|
+
// correct for this `"type": "module"` package.
|
|
43
|
+
const TS_RE = /\.(?<kind>[cm])?ts$/u
|
|
44
|
+
|
|
45
|
+
// Defence-in-depth: assert strip mode only ERASED — every output character
|
|
46
|
+
// is either unchanged, replaced by whitespace, or replaced by a semicolon
|
|
47
|
+
// (swc's ASI guard — swc-project/swc#9331). This enforces at runtime what
|
|
48
|
+
// tsconfig's `erasableSyntaxOnly` asserts at type-check time: nothing but
|
|
49
|
+
// type syntax was removed, so the bytes Node runs can't silently diverge
|
|
50
|
+
// from the bytes we wrote.
|
|
51
|
+
//
|
|
52
|
+
// The comparison is per-CODE-POINT, not per-byte. swc preserves byte
|
|
53
|
+
// offsets by swapping each elided multi-byte character for a *same-width*
|
|
54
|
+
// Unicode whitespace char — e.g. an em-dash (U+2014) in a type-level
|
|
55
|
+
// comment becomes U+2002 EN SPACE, not ASCII 0x20 — so a raw-byte check
|
|
56
|
+
// would false-positive on the many such comments in this codebase. `\s`
|
|
57
|
+
// matches those Unicode spaces, and strip preserves code-unit length too,
|
|
58
|
+
// so string indices stay aligned.
|
|
59
|
+
const WHITESPACE = /\s/u
|
|
60
|
+
|
|
61
|
+
function assertOnlyErased(url, source, stripped) {
|
|
62
|
+
if (source.length !== stripped.length) {
|
|
63
|
+
throw new Error(`strip-types-loader: ${url} changed length ${source.length} -> ${stripped.length}; not a pure type erasure`)
|
|
64
|
+
}
|
|
65
|
+
for (let i = 0; i < stripped.length; i++) {
|
|
66
|
+
const ch = stripped[i]
|
|
67
|
+
if (ch === source[i] || ch === ';' || WHITESPACE.test(ch)) continue
|
|
68
|
+
throw new Error(`strip-types-loader: ${url} char ${i} changed ${JSON.stringify(source[i])} -> ${JSON.stringify(ch)}; not a pure type erasure`)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
registerHooks({
|
|
73
|
+
load(url, context, nextLoad) {
|
|
74
|
+
const match = TS_RE.exec(new URL(url).pathname)
|
|
75
|
+
if (match) {
|
|
76
|
+
const bytes = readFileSync(fileURLToPath(url))
|
|
77
|
+
// Assert the source is valid UTF-8 before decoding: a mis-encoded
|
|
78
|
+
// file (latin-1, UTF-16, a stray binary) would otherwise decode with
|
|
79
|
+
// U+FFFD replacement chars and we'd strip / execute silent garbage.
|
|
80
|
+
if (!isUtf8(bytes)) {
|
|
81
|
+
throw new Error(`strip-types-loader: ${url} is not valid UTF-8`)
|
|
82
|
+
}
|
|
83
|
+
const raw = bytes.toString('utf8')
|
|
84
|
+
const stripped = stripTypeScriptTypes(raw, { mode: 'strip' })
|
|
85
|
+
assertOnlyErased(url, raw, stripped)
|
|
86
|
+
return {
|
|
87
|
+
format: match.groups.kind === 'c' ? 'commonjs' : 'module',
|
|
88
|
+
source: stripped,
|
|
89
|
+
shortCircuit: true,
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return nextLoad(url, context)
|
|
93
|
+
},
|
|
94
|
+
})
|
|
File without changes
|
|
File without changes
|
|
File without changes
|