@preventive/triage 1.0.0-alpha.0 → 1.0.0-alpha.10
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 +17 -10
- package/out/graph.js +5 -4
- package/out/index.html +43 -38
- package/out/prism.js +2 -2
- package/out/terminal.js +32 -28
- package/out/view.css +1 -1
- package/out/view.js +78 -51
- package/package.json +70 -49
- package/{server → server-common}/origin.ts +5 -5
- package/{server → server-e2e}/auth.ts +16 -1
- package/server-e2e/bus-receiver.ts +95 -0
- package/server-e2e/cli.js +22 -0
- package/{server → server-e2e}/config.ts +21 -8
- package/{server → server-e2e}/db-neon.ts +41 -25
- package/{server → server-e2e}/db-revision-sql.ts +15 -9
- package/{server → server-e2e}/db-stmt.ts +2 -2
- package/{server → server-e2e}/db.ts +113 -135
- package/server-e2e/http.ts +266 -0
- package/{server → server-e2e}/hub.ts +27 -8
- package/{server → server-e2e}/index.ts +185 -52
- package/{server → server-e2e}/lifecycle.ts +36 -5
- package/server-e2e/npm-proxy.ts +348 -0
- 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 +25 -15
- package/{server → server-e2e}/objstore/init.ts +52 -12
- 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 +119 -84
- 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-e2e/pubsub.ts +394 -0
- package/{server → server-e2e}/sign.ts +12 -14
- package/server-e2e/sse-server.ts +384 -0
- package/server-e2e/sse-session.ts +216 -0
- package/{server → server-e2e}/static.ts +22 -17
- package/server-e2e/sync-handlers.ts +382 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/server-e2e/ws-server.ts +276 -0
- package/strip-types-loader.js +94 -0
- package/server/http.ts +0 -142
- package/server/sync-handlers.ts +0 -311
- package/server/ws-server.ts +0 -245
- /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
|
@@ -0,0 +1,382 @@
|
|
|
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 { IncomingMessage, ServerResponse } from 'node:http'
|
|
9
|
+
import { Buffer } from 'node:buffer'
|
|
10
|
+
import type { WebSocket } from 'ws'
|
|
11
|
+
import { SAVE_ERROR_REASONS, type SaveErrorReason } from '../common/save-error-reason.ts'
|
|
12
|
+
import { type Handle, type RevisionRow, chainFrom, commitRevision, revisionExists } from './db.ts'
|
|
13
|
+
import { type SaveMsg, type SubscribeMsg, canonicalSave, computeRevisionIdFromCanonical, verifyEd25519, verifySubscribeSig } from './sign.ts'
|
|
14
|
+
import { MAX_CIPHERTEXT_LEN, MAX_FIELD_LEN, validCiphertextShape, validNonce, validTagSigBase } from './validation.ts'
|
|
15
|
+
import { debugTag } from './util.ts'
|
|
16
|
+
import type { UnauthorizedContext } from './auth.ts'
|
|
17
|
+
|
|
18
|
+
// Wire shape `chainForWire` produces from a `chainFrom` row, with
|
|
19
|
+
// `keyframe` normalised from the SQLite INTEGER 0/1 to a strict boolean.
|
|
20
|
+
type WireRevision = {
|
|
21
|
+
base: string | null
|
|
22
|
+
id: string
|
|
23
|
+
keyframe: boolean
|
|
24
|
+
nonce: string
|
|
25
|
+
ciphertext: string
|
|
26
|
+
signature: string
|
|
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
|
+
|
|
70
|
+
export type SyncHandlersDeps = {
|
|
71
|
+
handle: Handle
|
|
72
|
+
send: (socket: WebSocket, msg: object) => void
|
|
73
|
+
broadcast: (tag: string, msg: object, except: WebSocket | null) => void
|
|
74
|
+
// Cross-instance pub/sub for live broadcasts. Fired alongside the
|
|
75
|
+
// local `broadcast` after a successful commit so peers on OTHER
|
|
76
|
+
// server instances see the new revision in real time. Carries only
|
|
77
|
+
// `(tag, revisionId)` — the receiver re-fetches the row from
|
|
78
|
+
// workspace_revision because the ciphertext can exceed the bus's
|
|
79
|
+
// payload budget. Optional: a SQLite deployment passes a no-op.
|
|
80
|
+
publishRevision: (tag: string, revisionId: string) => void
|
|
81
|
+
subscribe: (socket: WebSocket, tag: string) => void
|
|
82
|
+
getNonce: (socket: WebSocket) => string | undefined
|
|
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
|
|
89
|
+
sendUnauthorized: (socket: WebSocket, ctx: UnauthorizedContext) => void
|
|
90
|
+
workspaceExists: (tag: string) => Promise<boolean>
|
|
91
|
+
// Objstore inventory snapshot for a workspace tag, as wire rows. The
|
|
92
|
+
// `workspace-subscribed` ack carries it. Injected — the objstore store
|
|
93
|
+
// has its own richer `Handle`, so index.ts wires `listLive(
|
|
94
|
+
// objstoreHandle, tag).then(rows => rows.map(objectMetaWire))` rather
|
|
95
|
+
// than coupling this module to that store type. Returns [] for a
|
|
96
|
+
// triage-only tag.
|
|
97
|
+
objstoreResources: (tag: string) => Promise<object[]>
|
|
98
|
+
debug: boolean
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export type SyncHandlers = {
|
|
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>
|
|
107
|
+
handleSubscribe: (socket: WebSocket, msg: SubscribeMsg) => Promise<void>
|
|
108
|
+
// Exported because the dispatcher's inflight-cap `busy` NACK path
|
|
109
|
+
// emits a save-error too (the only emit site outside this module).
|
|
110
|
+
sendSaveError: (socket: WebSocket, workspaceTag: string, base: string | null, reason: SaveErrorReason) => void
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
114
|
+
const { handle, send, broadcast, publishRevision, subscribe, getNonce, requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
|
|
115
|
+
|
|
116
|
+
// Typed wrapper for the three `workspace-save-error` emit sites
|
|
117
|
+
// (too-large at handleSave, stale-base after the catch-up, busy at
|
|
118
|
+
// the inflight-cap drop). Forces `reason` to be a member of
|
|
119
|
+
// `SaveErrorReason` so a typo or a server-side addition that didn't
|
|
120
|
+
// update `common/save-error-reason.ts` fails at compile time rather
|
|
121
|
+
// than turning into a wire-level surprise. The shared taxonomy is
|
|
122
|
+
// pinned by `tests/save-error-reason-taxonomy.test.js`.
|
|
123
|
+
function sendSaveError(
|
|
124
|
+
socket: WebSocket,
|
|
125
|
+
workspaceTag: string,
|
|
126
|
+
base: string | null,
|
|
127
|
+
reason: SaveErrorReason,
|
|
128
|
+
): void {
|
|
129
|
+
// Runtime guard alongside the compile-time `SaveErrorReason` union —
|
|
130
|
+
// covers the case where the `reason` argument is a variable (not a
|
|
131
|
+
// string literal) and TS's narrowing can't enforce taxonomy
|
|
132
|
+
// membership at the call site. Throws because a server emitting a
|
|
133
|
+
// typo'd reason would be a wire-protocol break the client can't
|
|
134
|
+
// recover from; better to fail fast in the test suite than to
|
|
135
|
+
// silently land bytes the client coerces to `'rejected'`.
|
|
136
|
+
if (!SAVE_ERROR_REASONS.has(reason)) {
|
|
137
|
+
throw new Error(`sendSaveError: reason '${reason}' is not in SAVE_ERROR_REASONS — update common/save-error-reason.ts`)
|
|
138
|
+
}
|
|
139
|
+
send(socket, { type: 'workspace-save-error', workspaceTag, base, reason })
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Normalise `keyframe` to a strict boolean on outbound chain entries.
|
|
143
|
+
// SQLite stores the column as INTEGER (0/1) and `chainFrom` returns
|
|
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.
|
|
148
|
+
function chainForWire(revisions: RevisionRow[]): WireRevision[] {
|
|
149
|
+
return revisions.map((r) => ({ ...r, keyframe: r.keyframe === 1 }))
|
|
150
|
+
}
|
|
151
|
+
|
|
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> {
|
|
168
|
+
// `base` is `string | null`; null is the keyframe-root marker.
|
|
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' }
|
|
170
|
+
// Compute canonical bytes + content-addressed id ONCE, then thread
|
|
171
|
+
// both through the precheck → sig verify → commit pipeline:
|
|
172
|
+
// 1. canonicalSave (sync, throws on lone-surrogate input)
|
|
173
|
+
// 2. SHA-256 → id
|
|
174
|
+
// 3. precheck: revisionExists → short-circuit ack on replay
|
|
175
|
+
// (skips Ed25519 verify; closes the round-9 H1 CPU-DoS vector
|
|
176
|
+
// where a passive observer floods captured saves)
|
|
177
|
+
// 4. verifyEd25519 against the SAME canonical bytes the id was
|
|
178
|
+
// hashed from — provably tied
|
|
179
|
+
// 5. ciphertext size policy (post-sig so the explicit error only
|
|
180
|
+
// reaches a legit signer)
|
|
181
|
+
// 6. commitRevision — re-checks dup + base + inserts via a single
|
|
182
|
+
// gated INSERT (dup gate + head-equals-base gate + the
|
|
183
|
+
// server-assigned seq folded into one statement) — NO write
|
|
184
|
+
// lock. See `commitRevisionSqlite` / `tryCommitNeon` in db*.ts.
|
|
185
|
+
let canonical: Uint8Array<ArrayBuffer>
|
|
186
|
+
try { canonical = canonicalSave(msg) } catch { return { kind: 'rejected' } }
|
|
187
|
+
const id = await computeRevisionIdFromCanonical(canonical)
|
|
188
|
+
const tag = msg.workspaceTag
|
|
189
|
+
const baseNorm = msg.base ?? null
|
|
190
|
+
if (await revisionExists(handle, tag, id)) {
|
|
191
|
+
if (debug) console.log(`save (precheck dup ${id.slice(0, 8)}…) → ack-only`)
|
|
192
|
+
return { kind: 'duplicate', id, base: baseNorm }
|
|
193
|
+
}
|
|
194
|
+
if (!await verifyEd25519(tag, canonical, msg.signature)) {
|
|
195
|
+
if (debug) console.warn('reject save: bad signature', debugTag(tag))
|
|
196
|
+
return { kind: 'rejected' }
|
|
197
|
+
}
|
|
198
|
+
// Size policy — emit an explicit error so the client can surface
|
|
199
|
+
// the failure to the user. Without this, an oversized save hangs
|
|
200
|
+
// forever in the client's `pending` slot (no ack, no rebase).
|
|
201
|
+
if (msg.ciphertext.length > MAX_CIPHERTEXT_LEN) {
|
|
202
|
+
if (debug) console.warn(`reject save: ciphertext too large (${msg.ciphertext.length} > ${MAX_CIPHERTEXT_LEN})`)
|
|
203
|
+
return { kind: 'too-large', base: baseNorm }
|
|
204
|
+
}
|
|
205
|
+
// Auth gate for the FIRST action against a workspace tag that
|
|
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)) {
|
|
213
|
+
if (debug) console.warn(`reject save: unauthorized (new workspace ${debugTag(tag)})`)
|
|
214
|
+
return { kind: 'unauthorized', base: baseNorm }
|
|
215
|
+
}
|
|
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
|
+
//
|
|
221
|
+
// `keyframe === true` is what canonicalSave bound the signature to
|
|
222
|
+
// (strict equality); the signer's intent is unambiguous here.
|
|
223
|
+
const keyframe = msg.keyframe === true
|
|
224
|
+
const commit = await commitRevision(handle, {
|
|
225
|
+
tag, id, base: baseNorm, keyframe,
|
|
226
|
+
nonce: msg.nonce, ciphertext: msg.ciphertext, signature: msg.signature,
|
|
227
|
+
})
|
|
228
|
+
if (commit.kind === 'duplicate') {
|
|
229
|
+
if (debug) console.log(`save (duplicate id ${id.slice(0, 8)}…) → ack-only`)
|
|
230
|
+
return { kind: 'duplicate', id, base: baseNorm }
|
|
231
|
+
}
|
|
232
|
+
if (commit.kind === 'stale-base') {
|
|
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).
|
|
241
|
+
//
|
|
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.
|
|
249
|
+
const revisions = chainForWire(await chainFrom(handle, tag, baseNorm))
|
|
250
|
+
if (debug) console.log(`save (stale base ${baseNorm} vs head ${commit.head}) → chain ${revisions.length}`)
|
|
251
|
+
return { kind: 'stale-base', base: baseNorm, revisions }
|
|
252
|
+
}
|
|
253
|
+
if (debug) console.log(`save${keyframe ? ' [keyframe]' : ''} → revision ${id.slice(0, 8)}… for ${debugTag(tag)}`)
|
|
254
|
+
// Carry `keyframe` as a strict boolean on the broadcast wire — peers
|
|
255
|
+
// strict-compare `=== true` (matching the canonical-payload contract).
|
|
256
|
+
broadcast(tag, {
|
|
257
|
+
type: 'workspace-state',
|
|
258
|
+
workspaceTag: tag,
|
|
259
|
+
revisions: [{
|
|
260
|
+
base: baseNorm,
|
|
261
|
+
id,
|
|
262
|
+
keyframe,
|
|
263
|
+
nonce: msg.nonce,
|
|
264
|
+
ciphertext: msg.ciphertext,
|
|
265
|
+
signature: msg.signature,
|
|
266
|
+
}],
|
|
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.
|
|
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' })
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {
|
|
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
|
|
328
|
+
// The challenge nonce we issued on this socket is bound into the
|
|
329
|
+
// signed canonical, blocking cross-connection replay of a captured
|
|
330
|
+
// subscribe frame. A subscribe arriving before we sent the
|
|
331
|
+
// challenge (impossible from the legitimate client) has no nonce to
|
|
332
|
+
// verify against — drop. Audit round-9 H2.
|
|
333
|
+
const nonce = getNonce(socket)
|
|
334
|
+
if (typeof nonce !== 'string') return
|
|
335
|
+
const ok = await verifySubscribeSig(msg, nonce)
|
|
336
|
+
if (!ok) {
|
|
337
|
+
if (debug) console.warn('reject subscribe: bad signature', debugTag(msg.workspaceTag))
|
|
338
|
+
return
|
|
339
|
+
}
|
|
340
|
+
// Bail if the socket closed during the verify await. The close
|
|
341
|
+
// handler's `unsubscribeAll(socket)` already ran (when there was
|
|
342
|
+
// nothing to remove yet), and `subscribe()` below would add the
|
|
343
|
+
// dead socket to `subscribers[tag]` — a permanent leak: broadcasts
|
|
344
|
+
// no-op via `send`'s readyState gate, but the Set entry pins the
|
|
345
|
+
// socket reference past close, blocking GC. Audit round-12.
|
|
346
|
+
if (socket.readyState !== socket.OPEN) {
|
|
347
|
+
if (debug) console.warn('reject subscribe: socket closed mid-verify', debugTag(msg.workspaceTag))
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
const tag = msg.workspaceTag
|
|
351
|
+
subscribe(socket, tag)
|
|
352
|
+
// Explicit ack — distinguishes "the server processed my subscribe
|
|
353
|
+
// and registered me as a peer" from "the WebSocket is open". A
|
|
354
|
+
// client that sent a malformed / bad-sig subscribe never gets this;
|
|
355
|
+
// a client that did gets one before the chain arrives. Lets the UI
|
|
356
|
+
// surface a `connecting → online` transition based on real handshake
|
|
357
|
+
// completion, not just socket state.
|
|
358
|
+
//
|
|
359
|
+
// The ack also carries the objstore inventory snapshot: the same
|
|
360
|
+
// subscribe that registers this socket for objstore-put / -deleted
|
|
361
|
+
// broadcasts seeds the client's initial inventory in one handshake.
|
|
362
|
+
// The client keeps it live thereafter from those broadcasts.
|
|
363
|
+
// Returns [] for a triage-only workspace. A failing inventory
|
|
364
|
+
// lookup must NOT sink the subscribe — degrade to an empty snapshot
|
|
365
|
+
// (broadcasts will fill it in) so the ack + chain still go out.
|
|
366
|
+
let resources: object[] = []
|
|
367
|
+
try { resources = await objstoreResources(tag) }
|
|
368
|
+
catch (err) { if (debug) console.warn('subscribe: objstore inventory lookup failed', debugTag(tag), err) }
|
|
369
|
+
send(socket, { type: 'workspace-subscribed', workspaceTag: tag, resources })
|
|
370
|
+
// `from` is the last revision id the client claims to have applied —
|
|
371
|
+
// now a base64url string, not an integer. We send only revisions
|
|
372
|
+
// after that. Client lying about `from` just means they get a
|
|
373
|
+
// smaller catch-up — their subsequent saves will reveal stale state
|
|
374
|
+
// on the usual base-mismatch path. Null / missing → full chain.
|
|
375
|
+
const fromId = typeof msg.from === 'string' ? msg.from : null
|
|
376
|
+
const revisions = chainForWire(await chainFrom(handle, tag, fromId))
|
|
377
|
+
if (debug) console.log(`subscribe ${debugTag(tag)} from=${fromId?.slice(0, 8) ?? 'null'} → chain ${revisions.length}`)
|
|
378
|
+
send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
return { handleSave, handleSaveRest, handleSubscribe, sendSaveError }
|
|
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
|