Versioned /api/v1 surface and live WebSocket streams (#27) - #98
Conversation
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.
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.
Self-reviewThe origin check refused the panel's own page.
An operator would have had to allowlist their own address to use the feature Rate limiting answered Both are mine, from this PR. The 403 that the smoke was already asserting stayed All twelve gates green after the fix. |
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/v1Every 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 editto
v1.GET /api/v1answers without a credential: it is a description of thesoftware, identical on every install.
GET /api/v1/streamConsole lines, run-state changes, the stats sample and timeline events — per
server, gated on the same
viewscope the equivalent HTTP reads need. There isno 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')bypasseshandlePanelentirely. Every guard on theREST surface lives in that function, so each one is re-applied here:
web/rate.tsso both paths spend from thesame 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,
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-Protocolfor 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 backmsms.v1and never the element carrying the secret.The codec
shared/wsframe.ts— ~300 lines of pureUint8Array, rather than adding adependency 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:
UTF-8,
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
falsedoes not stop anything; the data queues in memory, so asubscriber 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_WEBdrives the parser through every caseabove, 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.