@preventive/triage 1.0.0-alpha.2 → 1.0.0-alpha.20
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 +17 -0
- package/cli.js +6 -0
- package/client/finding-link.js +305 -0
- package/client/linked-findings.d.ts +1 -0
- package/client/linked-findings.js +111 -0
- package/common/bundle-metadata.d.ts +10 -0
- package/common/bundle-metadata.js +177 -0
- package/common/bundle-reasons.d.ts +2 -0
- package/common/bundle-reasons.js +21 -0
- package/common/bundle-sources.d.ts +3 -0
- package/common/bundle-sources.js +284 -0
- package/common/bundle-stats.js +41 -0
- package/common/bundle-tabs.js +1 -0
- package/common/code-language.js +36 -0
- package/common/default-scan-models.ts +30 -0
- package/common/finding-id.js +47 -0
- package/common/github-pr.ts +56 -0
- package/common/managed/comments.ts +34 -0
- package/common/managed/permissions.ts +35 -0
- package/common/managed/report-content.ts +42 -0
- package/common/managed/report-filter.ts +108 -0
- package/common/managed/roles.ts +28 -0
- package/common/managed/routes.d.ts +2 -0
- package/common/managed/routes.js +121 -0
- package/common/managed/scan-models.ts +6 -0
- package/common/managed/triage.ts +83 -0
- package/common/save-error-reason.ts +20 -7
- package/common/scan-server.ts +13 -0
- package/common/server-info.ts +33 -0
- package/common/utf8.d.ts +3 -0
- package/common/utf8.js +45 -0
- package/out/brotli-fallback.js +3 -3
- package/out/client-managed-import.js +81 -0
- package/out/client-managed.js +110 -0
- package/out/client-sync.js +16 -13
- package/out/graph.js +30 -4
- package/out/index.html +55 -8
- package/out/prism.js +2 -2
- package/out/stasis.svg +45 -0
- package/out/terminal.js +273 -39
- package/out/view.css +1 -1
- package/out/view.js +198 -62
- package/package.json +179 -55
- package/report/index.js +254 -0
- package/report/src/finding-id.js +80 -0
- package/report/src/finding.js +312 -0
- package/report/src/labels.js +33 -0
- package/report/src/md-structure.js +471 -0
- package/report/src/md-text.js +167 -0
- package/report/src/meta.js +76 -0
- package/report/src/parse-codex.js +147 -0
- package/report/src/parse-deepsec.js +197 -0
- package/report/src/parse-deepview-fields.js +375 -0
- package/report/src/parse-deepview-md.js +185 -0
- package/report/src/parse-md-id.js +137 -0
- package/report/src/parse-md.js +322 -0
- package/report/src/parse-piolium-id.js +79 -0
- package/report/src/parse-piolium-rows.js +131 -0
- package/report/src/parse-piolium-tokens.js +175 -0
- package/report/src/parse-piolium.js +400 -0
- package/report/src/security.js +63 -0
- package/report/src/utf8.js +21 -0
- package/report/src/write-md-finding.js +273 -0
- package/report/src/write-md.js +291 -0
- package/server-common/database-config.ts +16 -0
- package/server-common/initialize.ts +18 -0
- package/server-common/npm-advisories.ts +101 -0
- package/{server → server-common}/origin.ts +5 -5
- package/server-common/reap.ts +48 -0
- package/server-common/scan-config.ts +19 -0
- package/server-common/standalone.ts +29 -0
- package/server-common/storage-log.ts +34 -0
- package/server-common/vercel-blob.ts +110 -0
- package/server-e2e/app.ts +485 -0
- package/{server → server-e2e}/auth.ts +5 -1
- package/{server → server-e2e}/bus-receiver.ts +9 -8
- package/{server → server-e2e}/cli.js +9 -4
- package/{server → server-e2e}/config.ts +54 -39
- package/{server → server-e2e}/db-neon.ts +2 -2
- 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 +110 -12
- package/{server → server-e2e}/hub.ts +44 -14
- package/server-e2e/index.ts +17 -0
- package/server-e2e/lifecycle.ts +95 -0
- package/{server → server-e2e}/neon-driver.ts +2 -2
- package/{server → server-e2e}/npm-proxy.ts +11 -144
- package/{server → server-e2e}/objstore/blob-fs.ts +6 -8
- package/{server → server-e2e}/objstore/blob-vercel.ts +49 -141
- 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 +19 -20
- package/{server → server-e2e}/objstore/init.ts +53 -27
- 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 +10 -14
- 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 +29 -36
- 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 +36 -29
- package/server-e2e/sync-handlers.ts +408 -0
- package/{server → server-e2e}/util.ts +9 -0
- package/{server → server-e2e}/ws-server.ts +29 -23
- package/server-managed/activity.ts +231 -0
- package/server-managed/avatar-store.ts +51 -0
- package/server-managed/blob-store.ts +66 -0
- package/server-managed/blob-vercel.ts +125 -0
- package/server-managed/brotli.ts +10 -0
- package/server-managed/bundle-cache.ts +185 -0
- package/server-managed/bundle-catalog.ts +29 -0
- package/server-managed/bundle-store.ts +28 -0
- package/server-managed/bundle-summary-cache.ts +97 -0
- package/server-managed/bundle.ts +39 -0
- package/server-managed/cache-storage.ts +40 -0
- package/server-managed/cli.js +13 -0
- package/server-managed/combined.ts +46 -0
- package/server-managed/comments.ts +151 -0
- package/server-managed/config.ts +144 -0
- package/server-managed/content-access.ts +15 -0
- package/server-managed/crypto.ts +25 -0
- package/server-managed/db-methods.ts +1330 -0
- package/server-managed/db-neon.ts +171 -0
- package/server-managed/db-schema.ts +203 -0
- package/server-managed/db-table-names.ts +22 -0
- package/server-managed/db.ts +109 -0
- package/server-managed/github-app.ts +332 -0
- package/server-managed/github-metadata.ts +65 -0
- package/server-managed/github-oauth.ts +215 -0
- package/server-managed/github-pulls.ts +115 -0
- package/server-managed/http-response.ts +18 -0
- package/server-managed/http.ts +2043 -0
- package/server-managed/import-triage.ts +48 -0
- package/server-managed/index.ts +135 -0
- package/server-managed/public-workspace.ts +150 -0
- package/server-managed/repo-path.ts +21 -0
- package/server-managed/report-migration.ts +35 -0
- package/server-managed/report-query.ts +4 -0
- package/server-managed/report-response.ts +16 -0
- package/server-managed/report-sources.ts +154 -0
- package/server-managed/repository-discovery.ts +82 -0
- package/server-managed/repository-policy.ts +25 -0
- package/server-managed/session.ts +78 -0
- package/server-managed/slugs.ts +38 -0
- package/server-managed/sql-postgres.ts +30 -0
- package/server-managed/sql.ts +61 -0
- package/server-managed/static.ts +28 -0
- package/server-managed/storage.ts +34 -0
- package/server-managed/team-catalog.ts +7 -0
- package/server-managed/team-feed.ts +128 -0
- package/server-managed/team-reports.ts +156 -0
- package/server-managed/triage-response.ts +16 -0
- package/server-managed/uploads.ts +47 -0
- package/server-managed/workspace-shares.ts +150 -0
- package/server.ts +50 -0
- package/server/index.ts +0 -481
- package/server/lifecycle.ts +0 -204
- package/server/sync-handlers.ts +0 -327
- /package/{server → server-e2e}/config.example.json +0 -0
- /package/{server → server-e2e}/objstore/fs.ts +0 -0
- /package/{server → server-e2e}/validation.ts +0 -0
|
@@ -0,0 +1,408 @@
|
|
|
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
|
+
type SubscriptionLookup = { resume: () => void }
|
|
30
|
+
|
|
31
|
+
// Structured result of the shared save pipeline (`commitSave`), rendered
|
|
32
|
+
// per transport: WS → protocol frames; REST → JSON + HTTP status.
|
|
33
|
+
type SaveOutcome =
|
|
34
|
+
| { kind: 'rejected' } // malformed / bad sig → WS drop / REST 400
|
|
35
|
+
| { kind: 'ack'; id: string; base: string | null } // committed → save-ack / 200
|
|
36
|
+
| { kind: 'duplicate'; id: string; base: string | null } // replay → ack-only / 200
|
|
37
|
+
| { kind: 'too-large'; base: string | null } // ciphertext over cap → save-error / 413
|
|
38
|
+
| { kind: 'unauthorized'; base: string | null } // new-workspace gate → unauthorized / 401
|
|
39
|
+
| { kind: 'stale-base'; base: string | null; revisions: WireRevision[] } // conflict → state+error / 409
|
|
40
|
+
|
|
41
|
+
// Hard cap on a `POST /api/sync/save` JSON body. The save frame is the small
|
|
42
|
+
// fields + a base64 ciphertext capped at MAX_CIPHERTEXT_LEN (2 MiB); 4 MiB
|
|
43
|
+
// (the WS plane's maxPayload / the SSE plane's maxBodyBytes) leaves headroom
|
|
44
|
+
// for the envelope so the in-pipeline size policy — not the reader — decides
|
|
45
|
+
// `too-large`. Bounds the read so a hostile client can't stream an unbounded
|
|
46
|
+
// body into memory before the parse.
|
|
47
|
+
const SAVE_BODY_MAX = 4 * 1024 * 1024
|
|
48
|
+
|
|
49
|
+
// Read a JSON request body up to `SAVE_BODY_MAX`, returning the parsed value
|
|
50
|
+
// or null on overflow / parse failure / read error (mirrors the objstore
|
|
51
|
+
// mint reader). The REST save body is the only body this module reads.
|
|
52
|
+
async function readSaveBody(req: IncomingMessage): Promise<unknown> {
|
|
53
|
+
const chunks: Buffer[] = []
|
|
54
|
+
let total = 0
|
|
55
|
+
try {
|
|
56
|
+
for await (const chunk of req) {
|
|
57
|
+
const buf = chunk as Buffer
|
|
58
|
+
total += buf.length
|
|
59
|
+
if (total > SAVE_BODY_MAX) return null
|
|
60
|
+
chunks.push(buf)
|
|
61
|
+
}
|
|
62
|
+
} catch { return null }
|
|
63
|
+
try { return JSON.parse(Buffer.concat(chunks).toString('utf8')) }
|
|
64
|
+
catch { return null }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function respondJson(res: ServerResponse, status: number, obj: object): void {
|
|
68
|
+
res.writeHead(status, { 'content-type': 'application/json' })
|
|
69
|
+
res.end(JSON.stringify(obj))
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type SyncHandlersDeps = {
|
|
73
|
+
handle: Handle
|
|
74
|
+
send: (socket: WebSocket, msg: object) => void
|
|
75
|
+
broadcast: (tag: string, msg: object, except: WebSocket | null) => void
|
|
76
|
+
// Cross-instance pub/sub for live broadcasts. Fired alongside the
|
|
77
|
+
// local `broadcast` after a successful commit so peers on OTHER
|
|
78
|
+
// server instances see the new revision in real time. Carries only
|
|
79
|
+
// `(tag, revisionId)` — the receiver re-fetches the row from
|
|
80
|
+
// workspace_revision because the ciphertext can exceed the bus's
|
|
81
|
+
// payload budget. Optional: a SQLite deployment passes a no-op.
|
|
82
|
+
publishRevision: (tag: string, revisionId: string) => void
|
|
83
|
+
subscribe: (socket: WebSocket, tag: string) => void
|
|
84
|
+
pauseBroadcasts: (socket: WebSocket) => () => void
|
|
85
|
+
getNonce: (socket: WebSocket) => string | undefined
|
|
86
|
+
requiresAuth: (socket: WebSocket) => boolean
|
|
87
|
+
// Whether an operator password is configured. The REST save plane's
|
|
88
|
+
// new-workspace gate (it has no socket to read operator-auth state from)
|
|
89
|
+
// collapses to `passwordConfigured && workspace-new` — the socket-less
|
|
90
|
+
// analog of `requiresAuth`, matching the objstore `restPutGate`.
|
|
91
|
+
passwordConfigured: boolean
|
|
92
|
+
sendUnauthorized: (socket: WebSocket, ctx: UnauthorizedContext) => void
|
|
93
|
+
workspaceExists: (tag: string) => Promise<boolean>
|
|
94
|
+
// Objstore inventory snapshot for a workspace tag, as wire rows. The
|
|
95
|
+
// `workspace-subscribed` ack carries it. Injected — the objstore store
|
|
96
|
+
// has its own richer `Handle`, so index.ts wires `listLive(
|
|
97
|
+
// objstoreHandle, tag).then(rows => rows.map(objectMetaWire))` rather
|
|
98
|
+
// than coupling this module to that store type. Returns [] for a
|
|
99
|
+
// triage-only tag.
|
|
100
|
+
objstoreResources: (tag: string) => Promise<object[]>
|
|
101
|
+
debug: boolean
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export type SyncHandlers = {
|
|
105
|
+
handleSave: (socket: WebSocket, msg: SaveMsg) => Promise<void>
|
|
106
|
+
// Session-independent REST save plane (`POST /api/sync/save`), wired into
|
|
107
|
+
// the HTTP dispatcher in server-e2e/http.ts. Runs the same pipeline as
|
|
108
|
+
// `handleSave` and renders the outcome as a JSON response.
|
|
109
|
+
handleSaveRest: (req: IncomingMessage, res: ServerResponse) => Promise<void>
|
|
110
|
+
handleSubscribe: (socket: WebSocket, msg: SubscribeMsg) => Promise<void>
|
|
111
|
+
// Exported because the dispatcher's inflight-cap `busy` NACK path
|
|
112
|
+
// emits a save-error too (the only emit site outside this module).
|
|
113
|
+
sendSaveError: (socket: WebSocket, workspaceTag: string, base: string | null, reason: SaveErrorReason) => void
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
117
|
+
const { handle, send, broadcast, publishRevision, subscribe, getNonce, requiresAuth, passwordConfigured, sendUnauthorized, workspaceExists, objstoreResources, debug } = deps
|
|
118
|
+
// Only the newest lookup for a socket/tag may publish a snapshot.
|
|
119
|
+
// Versions cannot order different object incarnations (or deletions).
|
|
120
|
+
const subscriptionLookups = new WeakMap<WebSocket, Map<string, SubscriptionLookup>>()
|
|
121
|
+
|
|
122
|
+
// Typed wrapper for the three `workspace-save-error` emit sites
|
|
123
|
+
// (too-large at handleSave, stale-base after the catch-up, busy at
|
|
124
|
+
// the inflight-cap drop). Forces `reason` to be a member of
|
|
125
|
+
// `SaveErrorReason` so a typo or a server-side addition that didn't
|
|
126
|
+
// update `common/save-error-reason.ts` fails at compile time rather
|
|
127
|
+
// than turning into a wire-level surprise. The shared taxonomy is
|
|
128
|
+
// pinned by `tests/save-error-reason-taxonomy.test.js`.
|
|
129
|
+
function sendSaveError(
|
|
130
|
+
socket: WebSocket,
|
|
131
|
+
workspaceTag: string,
|
|
132
|
+
base: string | null,
|
|
133
|
+
reason: SaveErrorReason,
|
|
134
|
+
): void {
|
|
135
|
+
// Runtime guard alongside the compile-time `SaveErrorReason` union —
|
|
136
|
+
// covers the case where the `reason` argument is a variable (not a
|
|
137
|
+
// string literal) and TS's narrowing can't enforce taxonomy
|
|
138
|
+
// membership at the call site. Throws because a server emitting a
|
|
139
|
+
// typo'd reason would be a wire-protocol break the client can't
|
|
140
|
+
// recover from; better to fail fast in the test suite than to
|
|
141
|
+
// silently land bytes the client coerces to `'rejected'`.
|
|
142
|
+
if (!SAVE_ERROR_REASONS.has(reason)) {
|
|
143
|
+
throw new Error(`sendSaveError: reason '${reason}' is not in SAVE_ERROR_REASONS — update common/save-error-reason.ts`)
|
|
144
|
+
}
|
|
145
|
+
send(socket, { type: 'workspace-save-error', workspaceTag, base, reason })
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Normalise `keyframe` to a strict boolean on outbound chain entries.
|
|
149
|
+
// SQLite stores the column as INTEGER (0/1) and `chainFrom` returns
|
|
150
|
+
// raw rows; the wire contract (and canonical signing payload) uses
|
|
151
|
+
// strict `=== true`. Convert once on the send side — forwarding the
|
|
152
|
+
// integer relies on clients coercing via `Boolean()` before rebuilding
|
|
153
|
+
// canonical bytes, fragile against any client that strict-compares.
|
|
154
|
+
function chainForWire(revisions: RevisionRow[]): WireRevision[] {
|
|
155
|
+
return revisions.map((r) => ({ ...r, keyframe: r.keyframe === 1 }))
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Transport-agnostic save pipeline, shared by the WS `handleSave` renderer
|
|
159
|
+
// and the REST `handleSaveRest` renderer. Runs the full validate → precheck
|
|
160
|
+
// → sig-verify → size/auth gates → commit → broadcast pipeline and returns a
|
|
161
|
+
// structured `SaveOutcome` the caller renders for its transport. Two params
|
|
162
|
+
// abstract the transport:
|
|
163
|
+
// - `authRequired`: the new-workspace gate decision (WS: requiresAuth(
|
|
164
|
+
// socket); REST: passwordConfigured — a REST request can't be operator-
|
|
165
|
+
// authorised, so the gate collapses to "password set AND workspace new").
|
|
166
|
+
// - `except`: the broadcast exclusion — the originating socket on the WS
|
|
167
|
+
// path (so it isn't echoed its own save), or null on the REST path (the
|
|
168
|
+
// request isn't a subscriber socket; the originator's own echo lands on
|
|
169
|
+
// its subscription stream and is an idempotent no-op — applyChainToBase
|
|
170
|
+
// skips a revision whose id already equals the client's baseRevision,
|
|
171
|
+
// and a same-content re-apply converges. Matches objstore-deleted's
|
|
172
|
+
// `except: null`).
|
|
173
|
+
async function commitSave(msg: SaveMsg, authRequired: boolean, except: WebSocket | null): Promise<SaveOutcome> {
|
|
174
|
+
// `base` is `string | null`; null is the keyframe-root marker.
|
|
175
|
+
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' }
|
|
176
|
+
// Compute canonical bytes + content-addressed id ONCE, then thread
|
|
177
|
+
// both through the precheck → sig verify → commit pipeline:
|
|
178
|
+
// 1. canonicalSave (sync, throws on lone-surrogate input)
|
|
179
|
+
// 2. SHA-256 → id
|
|
180
|
+
// 3. precheck: revisionExists → short-circuit ack on replay
|
|
181
|
+
// (skips Ed25519 verify; closes the round-9 H1 CPU-DoS vector
|
|
182
|
+
// where a passive observer floods captured saves)
|
|
183
|
+
// 4. verifyEd25519 against the SAME canonical bytes the id was
|
|
184
|
+
// hashed from — provably tied
|
|
185
|
+
// 5. ciphertext size policy (post-sig so the explicit error only
|
|
186
|
+
// reaches a legit signer)
|
|
187
|
+
// 6. commitRevision — re-checks dup + base + inserts via a single
|
|
188
|
+
// gated INSERT (dup gate + head-equals-base gate + the
|
|
189
|
+
// server-assigned seq folded into one statement) — NO write
|
|
190
|
+
// lock. See `commitRevisionSqlite` / `tryCommitNeon` in db*.ts.
|
|
191
|
+
let canonical: Uint8Array<ArrayBuffer>
|
|
192
|
+
try { canonical = canonicalSave(msg) } catch { return { kind: 'rejected' } }
|
|
193
|
+
const id = await computeRevisionIdFromCanonical(canonical)
|
|
194
|
+
const tag = msg.workspaceTag
|
|
195
|
+
const baseNorm = msg.base ?? null
|
|
196
|
+
if (await revisionExists(handle, tag, id)) {
|
|
197
|
+
if (debug) console.log(`save (precheck dup ${id.slice(0, 8)}…) → ack-only`)
|
|
198
|
+
return { kind: 'duplicate', id, base: baseNorm }
|
|
199
|
+
}
|
|
200
|
+
if (!await verifyEd25519(tag, canonical, msg.signature)) {
|
|
201
|
+
if (debug) console.warn('reject save: bad signature', debugTag(tag))
|
|
202
|
+
return { kind: 'rejected' }
|
|
203
|
+
}
|
|
204
|
+
// Size policy — emit an explicit error so the client can surface
|
|
205
|
+
// the failure to the user. Without this, an oversized save hangs
|
|
206
|
+
// forever in the client's `pending` slot (no ack, no rebase).
|
|
207
|
+
if (msg.ciphertext.length > MAX_CIPHERTEXT_LEN) {
|
|
208
|
+
if (debug) console.warn(`reject save: ciphertext too large (${msg.ciphertext.length} > ${MAX_CIPHERTEXT_LEN})`)
|
|
209
|
+
return { kind: 'too-large', base: baseNorm }
|
|
210
|
+
}
|
|
211
|
+
// Auth gate for the FIRST action against a workspace tag that
|
|
212
|
+
// doesn't yet exist on the server. Checked AFTER sig verify so the
|
|
213
|
+
// `unauthorized` outcome only reaches a legitimate signer; shape /
|
|
214
|
+
// sig attacks still drop silently. The TOCTOU between `workspaceExists`
|
|
215
|
+
// and the commit's gated INSERT is an accepted soft-policy race — a
|
|
216
|
+
// racer still had to produce a valid Ed25519 signature (= holds the
|
|
217
|
+
// seed). Same gate the objstore put-begin applies.
|
|
218
|
+
if (authRequired && !await workspaceExists(tag)) {
|
|
219
|
+
if (debug) console.warn(`reject save: unauthorized (new workspace ${debugTag(tag)})`)
|
|
220
|
+
return { kind: 'unauthorized', base: baseNorm }
|
|
221
|
+
}
|
|
222
|
+
// Save does NOT subscribe the sender — that would be a replay vector
|
|
223
|
+
// (a captured frame replayed from any connection would attach as a
|
|
224
|
+
// subscriber without holding the seed). Explicit `workspace-subscribe`
|
|
225
|
+
// (signs the per-connection nonce) is the ONLY attach path. Round-9 H1.
|
|
226
|
+
//
|
|
227
|
+
// `keyframe === true` is what canonicalSave bound the signature to
|
|
228
|
+
// (strict equality); the signer's intent is unambiguous here.
|
|
229
|
+
const keyframe = msg.keyframe === true
|
|
230
|
+
const commit = await commitRevision(handle, {
|
|
231
|
+
tag, id, base: baseNorm, keyframe,
|
|
232
|
+
nonce: msg.nonce, ciphertext: msg.ciphertext, signature: msg.signature,
|
|
233
|
+
})
|
|
234
|
+
if (commit.kind === 'duplicate') {
|
|
235
|
+
if (debug) console.log(`save (duplicate id ${id.slice(0, 8)}…) → ack-only`)
|
|
236
|
+
return { kind: 'duplicate', id, base: baseNorm }
|
|
237
|
+
}
|
|
238
|
+
if (commit.kind === 'stale-base') {
|
|
239
|
+
// Client claimed a base that's no longer head. The catch-up chain is
|
|
240
|
+
// computed OUTSIDE any lock — a concurrent commit landing here only
|
|
241
|
+
// means the catch-up is fresher (benign; clients tolerate extra
|
|
242
|
+
// revisions). The WS renderer sends `workspace-state` (catch-up) FIRST
|
|
243
|
+
// then the typed `stale-base` error; the REST renderer returns the
|
|
244
|
+
// chain in the 409 body. Either way the catch-up clears the client's
|
|
245
|
+
// pending and the error is a no-op on the now-missing pending (a
|
|
246
|
+
// recoverable race — client rebases + re-saves).
|
|
247
|
+
//
|
|
248
|
+
// The catch-up CAN be empty: a client holding a base from a chain
|
|
249
|
+
// this deployment no longer has (wiped / moved DB, SQLite→Neon
|
|
250
|
+
// migration — head=null, no keyframe, no rows) gets `revisions: []`.
|
|
251
|
+
// The client detects that shape (pending survives the catch-up) and
|
|
252
|
+
// answers with a full-state push re-anchored at base=null, which
|
|
253
|
+
// commits as the new chain root — see the client's
|
|
254
|
+
// `handleSaveError` stale-base branch.
|
|
255
|
+
const revisions = chainForWire(await chainFrom(handle, tag, baseNorm))
|
|
256
|
+
if (debug) console.log(`save (stale base ${baseNorm} vs head ${commit.head}) → chain ${revisions.length}`)
|
|
257
|
+
return { kind: 'stale-base', base: baseNorm, revisions }
|
|
258
|
+
}
|
|
259
|
+
if (debug) console.log(`save${keyframe ? ' [keyframe]' : ''} → revision ${id.slice(0, 8)}… for ${debugTag(tag)}`)
|
|
260
|
+
// Carry `keyframe` as a strict boolean on the broadcast wire — peers
|
|
261
|
+
// strict-compare `=== true` (matching the canonical-payload contract).
|
|
262
|
+
broadcast(tag, {
|
|
263
|
+
type: 'workspace-state',
|
|
264
|
+
workspaceTag: tag,
|
|
265
|
+
revisions: [{
|
|
266
|
+
base: baseNorm,
|
|
267
|
+
id,
|
|
268
|
+
keyframe,
|
|
269
|
+
nonce: msg.nonce,
|
|
270
|
+
ciphertext: msg.ciphertext,
|
|
271
|
+
signature: msg.signature,
|
|
272
|
+
}],
|
|
273
|
+
}, except)
|
|
274
|
+
// Cross-instance fan-out. The bus payload carries only the revision id —
|
|
275
|
+
// peers on OTHER instances re-fetch the row. SQLite mode passes a no-op;
|
|
276
|
+
// Neon mode publishes via pg_notify. Best-effort.
|
|
277
|
+
publishRevision(tag, id)
|
|
278
|
+
return { kind: 'ack', id, base: baseNorm }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// WS renderer: run the shared pipeline against this socket and render the
|
|
282
|
+
// outcome as the protocol frames the client expects on its stream. A
|
|
283
|
+
// `rejected` outcome drops silently (matches the prior malformed/bad-sig
|
|
284
|
+
// behaviour). For every non-rejected outcome the tag was validated inside
|
|
285
|
+
// `commitSave`, so the cast to string is sound.
|
|
286
|
+
async function handleSave(socket: WebSocket, msg: SaveMsg): Promise<void> {
|
|
287
|
+
const outcome = await commitSave(msg, requiresAuth(socket), socket)
|
|
288
|
+
if (outcome.kind === 'rejected') return
|
|
289
|
+
const tag = msg.workspaceTag as string
|
|
290
|
+
if (outcome.kind === 'unauthorized') { sendUnauthorized(socket, { kind: 'gated', workspaceTag: tag, base: outcome.base }); return }
|
|
291
|
+
if (outcome.kind === 'too-large') { sendSaveError(socket, tag, outcome.base, 'too-large'); return }
|
|
292
|
+
if (outcome.kind === 'stale-base') {
|
|
293
|
+
// State FIRST (its handler clears pending), then the typed error.
|
|
294
|
+
send(socket, { type: 'workspace-state', workspaceTag: tag, revisions: outcome.revisions })
|
|
295
|
+
sendSaveError(socket, tag, outcome.base, 'stale-base')
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
// ack | duplicate
|
|
299
|
+
send(socket, { type: 'workspace-save-ack', workspaceTag: tag, base: outcome.base, id: outcome.id })
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// REST renderer: the session-independent `POST /api/sync/save` plane. Reads
|
|
303
|
+
// the save frame from the JSON body, runs the SAME pipeline (no socket;
|
|
304
|
+
// new-workspace gate = passwordConfigured; broadcast except = null), and
|
|
305
|
+
// maps the outcome to a JSON + HTTP status the client switches on. SSE-mode
|
|
306
|
+
// clients POST here so a save doesn't take over their event-stream; a 401
|
|
307
|
+
// routes them to the in-band frame (which runs the operator auth flow).
|
|
308
|
+
// Mounted + gated (same-origin, shutdown, idle-timeout) in server-e2e/http.ts.
|
|
309
|
+
async function handleSaveRest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
|
310
|
+
const body = await readSaveBody(req)
|
|
311
|
+
if (!body || typeof body !== 'object') { respondJson(res, 400, { reason: 'bad-request' }); return }
|
|
312
|
+
const outcome = await commitSave(body as SaveMsg, passwordConfigured, null)
|
|
313
|
+
if (outcome.kind === 'ack' || outcome.kind === 'duplicate') { respondJson(res, 200, { ok: true, id: outcome.id }); return }
|
|
314
|
+
if (outcome.kind === 'stale-base') { respondJson(res, 409, { reason: 'stale-base', revisions: outcome.revisions }); return }
|
|
315
|
+
if (outcome.kind === 'too-large') { respondJson(res, 413, { reason: 'too-large' }); return }
|
|
316
|
+
if (outcome.kind === 'unauthorized') { respondJson(res, 401, { reason: 'unauthorized' }); return }
|
|
317
|
+
respondJson(res, 400, { reason: 'bad-request' })
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function handleSubscribe(socket: WebSocket, msg: SubscribeMsg): Promise<void> {
|
|
321
|
+
// Mirror handleSave's wire gate: length-cap + base64url-alphabet on
|
|
322
|
+
// workspaceTag / signature / from BEFORE canonicalSubscribe UTF-8-
|
|
323
|
+
// encodes them and verifyEd25519 base64-decodes the signature.
|
|
324
|
+
// Without it a peer can submit up-to-maxPayload strings and force
|
|
325
|
+
// O(n) encode + decode per subscribe before the 32/64-byte length
|
|
326
|
+
// gates reject — a CPU-DoS surface handleSave is already hardened
|
|
327
|
+
// against. `from` keeps the `string | null` contract (null = full-
|
|
328
|
+
// chain catch-up); a legit signer's `from` is a base64url revision
|
|
329
|
+
// id, so any non-string / over-long / wrong-alphabet value can't be
|
|
330
|
+
// one the signature was computed over, and the chain-lookup path
|
|
331
|
+
// (`typeof msg.from === 'string' ? msg.from : null`) still treats a
|
|
332
|
+
// null / absent `from` as the keyframe-fallback.
|
|
333
|
+
if (!validTagSigBase(msg.workspaceTag, MAX_FIELD_LEN) || !validTagSigBase(msg.signature, MAX_FIELD_LEN) || (msg.from != null && !validTagSigBase(msg.from, MAX_FIELD_LEN))) return
|
|
334
|
+
// The challenge nonce we issued on this socket is bound into the
|
|
335
|
+
// signed canonical, blocking cross-connection replay of a captured
|
|
336
|
+
// subscribe frame. A subscribe arriving before we sent the
|
|
337
|
+
// challenge (impossible from the legitimate client) has no nonce to
|
|
338
|
+
// verify against — drop. Audit round-9 H2.
|
|
339
|
+
const nonce = getNonce(socket)
|
|
340
|
+
if (typeof nonce !== 'string') return
|
|
341
|
+
const ok = await verifySubscribeSig(msg, nonce)
|
|
342
|
+
if (!ok) {
|
|
343
|
+
if (debug) console.warn('reject subscribe: bad signature', debugTag(msg.workspaceTag))
|
|
344
|
+
return
|
|
345
|
+
}
|
|
346
|
+
// Bail if the socket closed during the verify await. The close
|
|
347
|
+
// handler's `unsubscribeAll(socket)` already ran (when there was
|
|
348
|
+
// nothing to remove yet), and `subscribe()` below would add the
|
|
349
|
+
// dead socket to `subscribers[tag]` — a permanent leak: broadcasts
|
|
350
|
+
// no-op via `send`'s readyState gate, but the Set entry pins the
|
|
351
|
+
// socket reference past close, blocking GC. Audit round-12.
|
|
352
|
+
if (socket.readyState !== socket.OPEN) {
|
|
353
|
+
if (debug) console.warn('reject subscribe: socket closed mid-verify', debugTag(msg.workspaceTag))
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
const tag = msg.workspaceTag
|
|
357
|
+
const lookups = subscriptionLookups.get(socket) ?? new Map<string, SubscriptionLookup>()
|
|
358
|
+
subscriptionLookups.set(socket, lookups)
|
|
359
|
+
const previous = lookups.get(tag)
|
|
360
|
+
const resume = deps.pauseBroadcasts(socket)
|
|
361
|
+
const lookup = { resume }
|
|
362
|
+
lookups.set(tag, lookup)
|
|
363
|
+
// Acquire the replacement's pause before releasing its predecessor so
|
|
364
|
+
// buffered broadcasts cannot overtake the replacement snapshot. A hung
|
|
365
|
+
// obsolete read must not keep the whole socket paused. The hub's release
|
|
366
|
+
// is idempotent, so the old finally can still call it when the read ends.
|
|
367
|
+
previous?.resume()
|
|
368
|
+
try {
|
|
369
|
+
subscribe(socket, tag)
|
|
370
|
+
// Explicit ack — distinguishes "the server processed my subscribe
|
|
371
|
+
// and registered me as a peer" from "the WebSocket is open". A
|
|
372
|
+
// client that sent a malformed / bad-sig subscribe never gets this;
|
|
373
|
+
// a client that did gets one before the chain arrives. Lets the UI
|
|
374
|
+
// surface a `connecting → online` transition based on real handshake
|
|
375
|
+
// completion, not just socket state.
|
|
376
|
+
//
|
|
377
|
+
// The ack also carries the objstore inventory snapshot: the same
|
|
378
|
+
// subscribe that registers this socket for objstore-put / -deleted
|
|
379
|
+
// broadcasts seeds the client's initial inventory in one handshake.
|
|
380
|
+
// The client keeps it live thereafter from those broadcasts.
|
|
381
|
+
// Returns [] for a triage-only workspace. A lookup failure is NOT an
|
|
382
|
+
// empty inventory: clients interpret absent resources as deletions.
|
|
383
|
+
// Reconnect to retry the complete handshake on either transport.
|
|
384
|
+
const resources = await objstoreResources(tag)
|
|
385
|
+
if (socket.readyState !== socket.OPEN || lookups.get(tag) !== lookup) return
|
|
386
|
+
// `from` is the last revision id the client claims to have applied —
|
|
387
|
+
// now a base64url string, not an integer. We send only revisions
|
|
388
|
+
// after that. Client lying about `from` just means they get a
|
|
389
|
+
// smaller catch-up — their subsequent saves will reveal stale state
|
|
390
|
+
// on the usual base-mismatch path. Null / missing → full chain.
|
|
391
|
+
const fromId = typeof msg.from === 'string' ? msg.from : null
|
|
392
|
+
const revisions = chainForWire(await chainFrom(handle, tag, fromId))
|
|
393
|
+
if (socket.readyState !== socket.OPEN || lookups.get(tag) !== lookup) return
|
|
394
|
+
if (debug) console.log(`subscribe ${debugTag(tag)} from=${fromId?.slice(0, 8) ?? 'null'} → chain ${revisions.length}`)
|
|
395
|
+
send(socket, { type: 'workspace-subscribed', workspaceTag: tag, resources })
|
|
396
|
+
send(socket, { type: 'workspace-state', workspaceTag: tag, revisions })
|
|
397
|
+
} catch (err) {
|
|
398
|
+
if (lookups.get(tag) !== lookup) return
|
|
399
|
+
if (debug) console.warn('subscribe: snapshot lookup failed', debugTag(tag), err)
|
|
400
|
+
socket.close(1011, 'subscription unavailable')
|
|
401
|
+
} finally {
|
|
402
|
+
if (lookups.get(tag) === lookup) lookups.delete(tag)
|
|
403
|
+
resume()
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return { handleSave, handleSaveRest, handleSubscribe, sendSaveError }
|
|
408
|
+
}
|
|
@@ -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
|
|