Skip to content

Versioned /api/v1 surface and live WebSocket streams (#27) - #98

Merged
CaYatur merged 3 commits into
mainfrom
feat/api-v1-stream
Jul 28, 2026
Merged

Versioned /api/v1 surface and live WebSocket streams (#27)#98
CaYatur merged 3 commits into
mainfrom
feat/api-v1-stream

Conversation

@CaYatur

@CaYatur CaYatur commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Two things an integration cannot work without: a prefix that promises not to
change under it, and a way to hear about a console line without asking every
second. Part of epic #52.

/api/v1

Every existing route is now reachable under /api/v1/… as well as /api/…,
via one rewrite at the top of the handler rather than a second route table.
Two tables drift, and the one that drifts is always the documented one — an
integration would be reading a spec for a router the app no longer runs. What
the prefix adds is the promise that a breaking change becomes v2, not an edit
to v1. GET /api/v1 answers without a credential: it is a description of the
software, identical on every install.

GET /api/v1/stream

Console lines, run-state changes, the stats sample and timeline events — per
server, gated on the same view scope the equivalent HTTP reads need. There is
no operation on the socket: a subscriber has nothing to send but subscriptions,
by construction, so this cannot become a way to start a server.

Scope is re-checked at send time, not only at subscribe time. Permissions
change while a socket is open, and a stream that keeps flowing after the scope
was revoked is a revocation that did not happen.

The part worth reviewing

server.on('upgrade') bypasses handlePanel entirely. Every guard on the
REST surface lives in that function, so each one is re-applied here:

  • key resolution and session resolution,
  • the per-key token bucket — moved to web/rate.ts so both paths spend from the
    same buckets. A limiter that only counts requests is one an integration
    walks around by opening streams, and the upgrade is the more expensive of the
    two because it leaves a socket behind,
  • the origin allowlist. Browsers do not apply CORS to WebSocket: a page on
    any origin can open one to a panel running on the visitor's machine, and this
    check is the only thing standing there.

Credentials arrive in a header, or in Sec-WebSocket-Protocol for browsers,
which cannot set headers. Not in the query string: a URL is the one part of a
request that lands in access logs, history and Referer. The server echoes back
msms.v1 and never the element carrying the secret.

The codec

shared/wsframe.ts — ~300 lines of pure Uint8Array, rather than adding a
dependency to a portable build for a protocol whose framing fits on one page.

Pure is also what makes it testable, and the failures that matter are the ones a
live loopback test never produces by accident:

  • a frame split across two reads (asserted byte-by-byte),
  • three frames in one read,
  • fragments assembled past the size cap,
  • an unmasked client frame, a fragmented control frame, a reserved bit, invalid
    UTF-8,
  • a client that sends nothing but empty continuation frames — capped by count as
    well as by bytes, because zero-length fragments cost nothing and the size cap
    alone never fires. The parser also loops rather than recursing, or ten thousand
    of them in one 64 KB read would blow the stack.

Masking is a property of the direction, not of the parser. The first version
hard-coded "always masked" and then could not read the frames this same file
encodes — the smoke caught it on the first run, which is why both halves are now
asserted.

Backpressure closes the connection at 1 MB of unwritten data. socket.write()
returning false does not stop anything; the data queues in memory, so a
subscriber to a busy console that stops reading is an unbounded allocation any
authenticated client can start. Closing beats trimming: a console feed with
invisible holes is worse than one that ends and says why.

Verify

All twelve gates exit 0. MSMS_SMOKE_WEB drives the parser through every case
above, then opens a real socket — checking the handshake accept value, both
auth paths, the 401 with no credential, the 403 for a disallowed origin, a scoped
subscribe, a live console line that must arrive, another server's line that must
not, an unsubscribe that stops delivery, and a deliberate unmasked frame that
must close the connection.

Protocol documented in docs/api-websocket.md.

CaYatur added 2 commits July 28, 2026 15:14
Two things an integration cannot work without: a prefix that promises not to
change under it, and a way to hear about a console line without asking every
second.

## /api/v1

A rewrite at the top of the handler, not a second route table. Two tables
drift, and the one that drifts is always the documented one — an integration
would be reading a spec for a router the app no longer runs. The unversioned
form stays because the panel bundle calls it; what the prefix adds is the
promise that a breaking change becomes a v2 prefix rather than an edit here.

## The stream

`GET /api/v1/stream` carries console lines, run-state changes, the stats
sample and timeline events, per server, gated on the same `view` scope the
equivalent HTTP reads need. There is no operation on the socket: a subscriber
has nothing to send but subscriptions, by construction.

`server.on('upgrade')` bypasses `handlePanel` entirely, so every guard is
re-applied here rather than assumed: key resolution, the per-key token bucket
(the same buckets, moved to web/rate.ts — a limiter that only counts requests
is one an integration walks around by opening streams), and the origin
allowlist. That last one matters more here than on the REST side, because
browsers do not apply CORS to WebSocket: a page on any origin can open one to
localhost, and the server-side check is the only thing standing there.

Credentials ride in a header, or in Sec-WebSocket-Protocol for browsers, which
cannot set headers. Not in the query string — a URL is the one part of a
request that lands in access logs, history and Referer headers.

## The codec

shared/wsframe.ts, ~300 lines of pure Uint8Array, rather than a dependency in
a portable build for a protocol whose framing fits on one page. Pure is also
what makes it testable: a frame split across two reads, three frames in one
read, fragments assembled past the size cap, an unmasked client frame — the
failures a live loopback test never produces by accident. All of them asserted,
then a real socket for the handshake, both auth paths, the origin refusal, a
live console line and a deliberate protocol violation.

Masking is a property of the direction, not of the parser. The first version
hard-coded "always masked" and could not read the frames this same file
encodes; the smoke found it immediately, which is why both halves are now
asserted.

Backpressure closes the connection at 1 MB of unwritten data rather than
dropping messages: socket.write() returning false queues in memory, so a
subscriber that stops reading is an unbounded allocation, and a console feed
with invisible holes is worse than one that ends and says why.

Documented in docs/api-websocket.md.
Copilot AI review requested due to automatic review settings July 28, 2026 12:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

isOriginAllowed is default-deny against apiOrigins, and I applied it to the
upgrade on its own. That is not the rule the REST surface runs: a same-origin
XHR never consults CORS at all, so an empty allowlist costs the panel's page
nothing there. An upgrade has no such exemption - this check is the whole
check - so the allowlist alone refused Origin: http://127.0.0.1:8799, the page
this listener had just served and the most likely browser client for a live
console. Same-origin is now accepted on its own terms, compared against the
request's Host so another port on the same machine is still cross-origin.

The 403 assertion stayed green through this, because a test that only checks
a bad origin is refused cannot notice that a good one is refused too. Both
directions are asserted now.

Also: authenticate() folded 'over its burst limit' into the same null as 'no
usable credential', so a client that had merely been too quick was told its
key was invalid - the message that makes an integration rotate a key that was
never the problem. It answers 429 now.
@CaYatur

CaYatur commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Self-review

The origin check refused the panel's own page.

isOriginAllowed is default-deny against apiOrigins, and I applied it to the
upgrade on its own. That is not the same rule the REST surface runs, and the
difference matters:

  • On REST, a same-origin XHR never consults CORS at all. applyCors only
    adds headers for cross-origin callers, so an empty apiOrigins costs the
    panel's own page nothing.
  • On an upgrade there is no such exemption. The check here is the whole
    check, so the allowlist alone refuses Origin: http://127.0.0.1:8799 — the
    page this very listener just served, and the single most likely browser client
    for a live console.

An operator would have had to allowlist their own address to use the feature
from the panel, which reads as broken rather than as a security control. Now
same-origin is accepted on its own terms and the allowlist governs
cross-origin, as it was written to. Compared against the request's Host,
which carries the port, so :8798 on the same machine is still another origin —
asserted both ways.

Rate limiting answered 401. authenticate() folded "your key is over its
burst limit" into the same null as "no usable credential", so a client that had
merely been too quick was told its credential was invalid. That is the message
that makes an integration rotate a key that was never the problem. It is now
429 Too Many Requests, distinct from the 401.

Both are mine, from this PR. The 403 that the smoke was already asserting stayed
green through the first bug, because a test that only checks that a bad origin is
refused cannot notice that a good one is refused too — the same shape of gap as
the world-name assertion in #91.

All twelve gates green after the fix.

@CaYatur
CaYatur merged commit d9c39a7 into main Jul 28, 2026
1 check passed
@CaYatur
CaYatur deleted the feat/api-v1-stream branch July 28, 2026 12:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants