@preventive/triage 1.0.0-alpha.13 → 1.0.0-alpha.15
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/brotli-fallback.js +3 -3
- package/out/client-admin.js +2 -2
- package/out/client-sync.js +14 -14
- package/out/graph.js +30 -5
- package/out/index.html +47 -7
- package/out/prism.js +2 -2
- package/out/stasis.svg +45 -0
- package/out/terminal.js +255 -43
- package/out/view.css +1 -1
- package/out/view.js +147 -110
- package/package.json +27 -4
- package/report/index.js +253 -0
- package/report/src/finding-id.js +80 -0
- package/report/src/finding.js +300 -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 +51 -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 +253 -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/utf8.js +21 -0
- package/report/src/write-md-finding.js +273 -0
- package/report/src/write-md.js +291 -0
- package/server-e2e/bus-receiver.ts +1 -0
- package/server-e2e/hub.ts +37 -6
- package/server-e2e/index.ts +3 -2
- package/server-e2e/objstore/handlers.ts +6 -5
- package/server-e2e/objstore/init.ts +1 -1
- package/server-e2e/objstore/rest-mint.ts +2 -2
- package/server-e2e/objstore/rest.ts +2 -2
- package/server-e2e/pubsub.ts +8 -5
- package/server-e2e/sync-handlers.ts +54 -28
|
@@ -48,9 +48,9 @@ export type ObjstoreDeps = {
|
|
|
48
48
|
// Cross-instance pub/sub for objstore-deleted. Fired alongside the
|
|
49
49
|
// local `broadcast` after a successful delete so peers on OTHER
|
|
50
50
|
// server instances see the version drop in real time. Carries the
|
|
51
|
-
// full (tag, resourceTag, version) tuple inline — the workspace_object
|
|
51
|
+
// full (tag, resourceTag, version, incarnation) tuple inline — the workspace_object
|
|
52
52
|
// row is gone post-delete, so the bus payload IS the wire data.
|
|
53
|
-
publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
|
|
53
|
+
publishObjDeleted: (tag: string, resourceTag: string, version: number, incarnation: string) => void
|
|
54
54
|
getNonce: (socket: WebSocket) => string | undefined
|
|
55
55
|
debug: boolean
|
|
56
56
|
// Auth gate for the FIRST put-begin against a never-before-seen
|
|
@@ -191,13 +191,14 @@ async function handleDelete(deps: ObjstoreDeps, socket: WebSocket, msg: Objstore
|
|
|
191
191
|
// observes its own PUTs as echo events. The same symmetry on
|
|
192
192
|
// `onDeleted` lets `session.onDeleted` fire for the session's
|
|
193
193
|
// own deletes — pinned by `tests/objstore-client-races.test.js`.
|
|
194
|
-
|
|
194
|
+
// A successful nonempty delete matched this authenticated incarnation.
|
|
195
|
+
deps.broadcast(tag, { type: 'objstore-deleted', workspaceTag: tag, resourceTag, version: result.deletedVersion, incarnation: prevIncarnation! }, null)
|
|
195
196
|
// Cross-instance fan-out (Neon mode). The workspace_object row is
|
|
196
197
|
// gone post-delete so the bus payload carries (tag, resourceTag,
|
|
197
|
-
// version) inline; the receiver builds its `objstore-deleted`
|
|
198
|
+
// version, incarnation) inline; the receiver builds its `objstore-deleted`
|
|
198
199
|
// broadcast directly from the bus envelope. SQLite mode publishes
|
|
199
200
|
// to a no-op.
|
|
200
|
-
deps.publishObjDeleted(tag, resourceTag, result.deletedVersion)
|
|
201
|
+
deps.publishObjDeleted(tag, resourceTag, result.deletedVersion, prevIncarnation!)
|
|
201
202
|
if (deps.debug) console.log(`objstore delete → ${debugTag(tag)}/${resourceTag.slice(0, 8)}…`)
|
|
202
203
|
}
|
|
203
204
|
|
|
@@ -30,7 +30,7 @@ export type ObjstoreInitDeps = {
|
|
|
30
30
|
// mode passes Postgres LISTEN/NOTIFY-backed implementations. See
|
|
31
31
|
// server-e2e/pubsub.ts for the bus design.
|
|
32
32
|
publishObjPut: (tag: string, resourceTag: string) => void
|
|
33
|
-
publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
|
|
33
|
+
publishObjDeleted: (tag: string, resourceTag: string, version: number, incarnation: string) => void
|
|
34
34
|
getNonce: (socket: WebSocket) => string | undefined
|
|
35
35
|
debug: boolean
|
|
36
36
|
// Auth gate for the FIRST objstore-put-begin against a workspace
|
|
@@ -219,6 +219,6 @@ async function handleRestDelete(
|
|
|
219
219
|
// fans out to subscribers (including the originator, matching the WS path's
|
|
220
220
|
// `except: null`) so peers' `onDeleted` fire.
|
|
221
221
|
if (result.deletedVersion === 0) return
|
|
222
|
-
deps.broadcast(route.tag, { type: 'objstore-deleted', workspaceTag: route.tag, resourceTag: route.resourceTag, version: result.deletedVersion }, null)
|
|
223
|
-
deps.publishObjDeleted(route.tag, route.resourceTag, result.deletedVersion)
|
|
222
|
+
deps.broadcast(route.tag, { type: 'objstore-deleted', workspaceTag: route.tag, resourceTag: route.resourceTag, version: result.deletedVersion, incarnation: prevIncarnation! }, null)
|
|
223
|
+
deps.publishObjDeleted(route.tag, route.resourceTag, result.deletedVersion, prevIncarnation!)
|
|
224
224
|
}
|
|
@@ -71,8 +71,8 @@ export type ObjstoreRestDeps = {
|
|
|
71
71
|
// Cross-instance pub/sub for objstore-deleted — fired alongside the local
|
|
72
72
|
// `broadcast` after a successful REST delete mint, so peers on OTHER
|
|
73
73
|
// instances drop the resource in real time. Carries (tag, resourceTag,
|
|
74
|
-
// version) inline (the row is gone post-delete). SQLite mode passes a no-op.
|
|
75
|
-
publishObjDeleted: (tag: string, resourceTag: string, version: number) => void
|
|
74
|
+
// version, incarnation) inline (the row is gone post-delete). SQLite mode passes a no-op.
|
|
75
|
+
publishObjDeleted: (tag: string, resourceTag: string, version: number, incarnation: string) => void
|
|
76
76
|
// New-workspace operator gate for the REST put-begin mint — the
|
|
77
77
|
// connection-independent analog of the WS path's `authGate`. Returns
|
|
78
78
|
// `true` to DENY (a password is configured AND the workspace is
|
package/server-e2e/pubsub.ts
CHANGED
|
@@ -57,13 +57,13 @@ function newSenderId(): string {
|
|
|
57
57
|
// - 'objput': objstore-put broadcast. `res` is the resource tag; the
|
|
58
58
|
// receiver SELECTs the live row by (tag, res) for the rest of the
|
|
59
59
|
// metadata fields.
|
|
60
|
-
// - 'objdel': objstore-deleted broadcast. `ver`
|
|
61
|
-
//
|
|
62
|
-
//
|
|
60
|
+
// - 'objdel': objstore-deleted broadcast. `ver` and `incarnation` identify
|
|
61
|
+
// the deleted row, which is gone from workspace_object after commit.
|
|
62
|
+
// Incarnation is optional only for messages from older relay instances.
|
|
63
63
|
export type BusMessage =
|
|
64
64
|
| { kind: 'rev'; tag: string; id: string }
|
|
65
65
|
| { kind: 'objput'; tag: string; res: string }
|
|
66
|
-
| { kind: 'objdel'; tag: string; res: string; ver: number }
|
|
66
|
+
| { kind: 'objdel'; tag: string; res: string; ver: number; incarnation?: string }
|
|
67
67
|
|
|
68
68
|
// Receiver wired up by the hub layer (see server-e2e/index.ts). Each handler
|
|
69
69
|
// runs once per remote message; failures are logged but don't crash the
|
|
@@ -388,7 +388,10 @@ function parseBusMessage(raw: Record<string, unknown>): BusMessage | null {
|
|
|
388
388
|
const ver = raw['ver']
|
|
389
389
|
if (typeof res !== 'string') return null
|
|
390
390
|
if (typeof ver !== 'number' || !Number.isSafeInteger(ver)) return null
|
|
391
|
-
|
|
391
|
+
const incarnation = raw['incarnation']
|
|
392
|
+
if (incarnation === undefined) return { kind: 'objdel', tag, res, ver }
|
|
393
|
+
if (typeof incarnation !== 'string' || incarnation.length === 0) return null
|
|
394
|
+
return { kind: 'objdel', tag, res, ver, incarnation }
|
|
392
395
|
}
|
|
393
396
|
return null
|
|
394
397
|
}
|
|
@@ -26,6 +26,8 @@ type WireRevision = {
|
|
|
26
26
|
signature: string
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
type SubscriptionLookup = { resume: () => void }
|
|
30
|
+
|
|
29
31
|
// Structured result of the shared save pipeline (`commitSave`), rendered
|
|
30
32
|
// per transport: WS → protocol frames; REST → JSON + HTTP status.
|
|
31
33
|
type SaveOutcome =
|
|
@@ -79,6 +81,7 @@ export type SyncHandlersDeps = {
|
|
|
79
81
|
// payload budget. Optional: a SQLite deployment passes a no-op.
|
|
80
82
|
publishRevision: (tag: string, revisionId: string) => void
|
|
81
83
|
subscribe: (socket: WebSocket, tag: string) => void
|
|
84
|
+
pauseBroadcasts: (socket: WebSocket) => () => void
|
|
82
85
|
getNonce: (socket: WebSocket) => string | undefined
|
|
83
86
|
requiresAuth: (socket: WebSocket) => boolean
|
|
84
87
|
// Whether an operator password is configured. The REST save plane's
|
|
@@ -112,6 +115,9 @@ export type SyncHandlers = {
|
|
|
112
115
|
|
|
113
116
|
export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
114
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>>()
|
|
115
121
|
|
|
116
122
|
// Typed wrapper for the three `workspace-save-error` emit sites
|
|
117
123
|
// (too-large at handleSave, stale-base after the catch-up, busy at
|
|
@@ -348,34 +354,54 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
|
|
|
348
354
|
return
|
|
349
355
|
}
|
|
350
356
|
const tag = msg.workspaceTag
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
//
|
|
358
|
-
//
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
+
}
|
|
379
405
|
}
|
|
380
406
|
|
|
381
407
|
return { handleSave, handleSaveRest, handleSubscribe, sendSaveError }
|