@preventive/triage 1.0.0-alpha.7 → 1.0.0-alpha.9

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@preventive/triage",
3
- "version": "1.0.0-alpha.7",
3
+ "version": "1.0.0-alpha.9",
4
4
  "description": "Client & relay server for triaging of automated reports",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -8,9 +8,10 @@
8
8
  //
9
9
  // One route, POSTs only:
10
10
  //
11
- // POST /api/sync/sse[?id=<sid>]
11
+ // POST /api/sync/sse
12
12
  // Request body:
13
- // { password?: string, — cached client password
13
+ // { id?: string, — session continuation token
14
+ // password?: string, — cached client password
14
15
  // frames?: Array<protocol-frame> — WS-style JSON frames
15
16
  // }
16
17
  // Response:
@@ -25,14 +26,23 @@
25
26
  // carrying the WS protocol's JSON envelopes.
26
27
  //
27
28
  // Continuation: each POST replaces the previous POST's response as the
28
- // session's downstream channel. If the `?id=<sid>` in the URL matches
29
- // a session this replica knows, the session continues (new outbound
30
- // stream attached, old one end()ed). If the id is unknown (different
31
- // replica picked up the POST, or session expired), a fresh session
32
- // with a new id is created and announced via the first `session`
33
- // event; the client uses the new id on all subsequent POSTs and re-
34
- // sends its subscribe frames on the next POST (its `frames` carry the
35
- // signed subscribes — they always do, see client/sync/sse-transport.ts).
29
+ // session's downstream channel. If the body's `id` matches a session
30
+ // this replica knows, the session continues (new outbound stream
31
+ // attached, old one end()ed). If the id is unknown (different replica
32
+ // picked up the POST, or session expired), a fresh session with a new
33
+ // id is created and announced via the first `session` event; the
34
+ // client uses the new id on all subsequent POSTs and re-sends its
35
+ // subscribe frames on the next POST (its `frames` carry the signed
36
+ // subscribes — they always do, see client/sync/sse-transport.ts).
37
+ //
38
+ // The id rides the JSON body, NOT the URL: the sid is a live bearer
39
+ // capability (whoever presents it attaches to the session's downstream
40
+ // and inherits its operator-auth flag), and a query-string token leaks
41
+ // into proxy / LB access logs — the same reason the objstore bearer
42
+ // tokens ride the Authorization header, never the URL. A legacy
43
+ // `?id=<sid>` query form is still ACCEPTED (older client bundles sent
44
+ // it; rejecting would churn them through a fresh session per POST),
45
+ // but current clients never emit it.
36
46
  //
37
47
  // Why POSTs only: a long-lived GET pins the client to one replica via
38
48
  // TCP affinity, which would mean POSTs from the same client must be
@@ -106,8 +116,10 @@ export type SseServer = {
106
116
  // Inbound POST body. Every field optional — an empty-body POST is a
107
117
  // valid "wake the session" probe (the response stream rides on every
108
118
  // POST), and a body that carries only `password` or only `frames` is
109
- // a normal partial update.
119
+ // a normal partial update. `id` is the session continuation token
120
+ // (see the header's "Continuation" note for why it rides the body).
110
121
  type SseBody = {
122
+ id?: unknown
111
123
  password?: unknown
112
124
  frames?: unknown
113
125
  }
@@ -116,12 +128,13 @@ export function installSseServer(deps: SseServerDeps): SseServer {
116
128
  const { peerDeps, isShuttingDown, maxSessions, maxBodyBytes, debug } = deps
117
129
 
118
130
  // Active SSE sessions, keyed by the random session id `createSession`
119
- // mints on the first POST that lacks a `?id=` (or whose id this
120
- // replica doesn't recognise) and that subsequent POSTs echo back to
121
- // continue the session. Bounded by `maxSessions` — over the cap, new
122
- // POSTs get a 503. POSTs against an unknown id are NOT 404'd — they
123
- // mint a fresh session instead, so a multi-replica deployment doesn't
124
- // require sticky LB routing to recover.
131
+ // mints on the first POST that carries no continuation id (or whose
132
+ // id this replica doesn't recognise) and that subsequent POSTs echo
133
+ // back (body `id` field) to continue the session. Bounded by
134
+ // `maxSessions` — over the cap, new POSTs get a 503. POSTs against an
135
+ // unknown id are NOT 404'd — they mint a fresh session instead, so a
136
+ // multi-replica deployment doesn't require sticky LB routing to
137
+ // recover.
125
138
  const sessions = new Map<string, SseSession>()
126
139
 
127
140
  function dropSession(sid: string): void {
@@ -251,6 +264,12 @@ export function installSseServer(deps: SseServerDeps): SseServer {
251
264
  return
252
265
  }
253
266
  }
267
+ // Resolve the continuation token: the JSON body's `id` is the
268
+ // canonical carrier; the `?id=` query form is the legacy
269
+ // fallback for older client bundles (see the header note — a
270
+ // query sid leaks into access logs). Body wins when both are
271
+ // present (current clients only ever send one).
272
+ const sid = parseSid(body.id) ?? sidFromUrl
254
273
  // Look up the session by id (if the client sent one). If found
255
274
  // and alive, attach the new response and dispatch. If not (or
256
275
  // session is closed), mint a fresh one — the response stream
@@ -265,8 +284,8 @@ export function installSseServer(deps: SseServerDeps): SseServer {
265
284
  // through to createSession with `res` still header-virgin, so
266
285
  // the new session can writeSseHeaders without ERR_HTTP_HEADERS_SENT.
267
286
  let session: SseSession | null = null
268
- if (sidFromUrl) {
269
- const existing = sessions.get(sidFromUrl)
287
+ if (sid) {
288
+ const existing = sessions.get(sid)
270
289
  if (existing && existing.readyState === existing.OPEN && existing.attachResponse(res)) {
271
290
  writeSseHeaders(res)
272
291
  session = existing
@@ -333,25 +352,33 @@ export function installSseServer(deps: SseServerDeps): SseServer {
333
352
  return { handle, sessions: () => sessions.values(), keepaliveTimer }
334
353
  }
335
354
 
336
- // Bare-bones query parse for `id=<base64url>`. Avoids URLSearchParams
355
+ // Shape gate for a continuation token from either carrier (body `id`
356
+ // field or the legacy `?id=` query). The {1,64} bound is a
357
+ // deliberately lenient sanity gate: anything outside the base64url
358
+ // alphabet is rejected; an unrecognised sid just gets a fresh session
359
+ // from createSession (no failure mode), and the wide length window
360
+ // keeps a future randomId-length change from silently breaking old
361
+ // clients that round-trip a longer/shorter token. Returns null on
362
+ // missing / non-string / malformed.
363
+ function parseSid(v: unknown): string | null {
364
+ if (typeof v !== 'string') return null
365
+ if (!/^[A-Za-z0-9_-]{1,64}$/u.test(v)) return null
366
+ return v
367
+ }
368
+
369
+ // Bare-bones query parse for the LEGACY `id=<base64url>` carrier
370
+ // (older client bundles; current clients send the sid in the POST
371
+ // body — see the header's "Continuation" note). Avoids URLSearchParams
337
372
  // (which decodes percent-escapes) — `randomId()` mints a 22-char
338
373
  // base64url string echoed back unchanged, so no escapes are possible
339
- // on the legitimate path. The {1,64} bound is a deliberately lenient
340
- // sanity gate: anything outside the base64url alphabet is rejected; an
341
- // unrecognised sid just gets a fresh session from createSession (no
342
- // failure mode), and the wide length window keeps a future
343
- // randomId-length change from silently breaking old clients that
344
- // round-trip a longer/shorter token. Returns null on missing /
345
- // malformed.
374
+ // on the legitimate path.
346
375
  function parseSidQuery(query: string | undefined): string | null {
347
376
  if (typeof query !== 'string') return null
348
377
  for (const part of query.split('&')) {
349
378
  const eq = part.indexOf('=')
350
379
  if (eq <= 0) continue
351
380
  if (part.slice(0, eq) !== 'id') continue
352
- const v = part.slice(eq + 1)
353
- if (!/^[A-Za-z0-9_-]{1,64}$/u.test(v)) return null
354
- return v
381
+ return parseSid(part.slice(eq + 1))
355
382
  }
356
383
  return null
357
384
  }
@@ -238,6 +238,14 @@ export function createSyncHandlers(deps: SyncHandlersDeps): SyncHandlers {
238
238
  // chain in the 409 body. Either way the catch-up clears the client's
239
239
  // pending and the error is a no-op on the now-missing pending (a
240
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.
241
249
  const revisions = chainForWire(await chainFrom(handle, tag, baseNorm))
242
250
  if (debug) console.log(`save (stale base ${baseNorm} vs head ${commit.head}) → chain ${revisions.length}`)
243
251
  return { kind: 'stale-base', base: baseNorm, revisions }