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