Authorize the SSE stream with short-lived project-scoped tickets - #49
Merged
Conversation
Viewers can now open GET /data/projects/{projectId}/events as a
server-sent-events stream. A registry keeps the open streams per project
and fans incoming payloads out on an executor, evicting dead emitters on
send failure; a scheduled comment heartbeat keeps idle streams alive
through nginx's 60-second proxy read timeout, and the path rides the
existing /data/ route so no proxy changes are needed (response buffering
is disabled per-response with X-Accel-Buffering).
Authorization at stream-open mirrors the STOMP subscribe check: the
caller must hold ViewProject on the project, with identity resolution
isolated in one seam that #305 replaces with a short-lived redeemable
ticket. The Last-Event-ID header (and a lastEventId query fallback for
fresh loads, which never send the header) is plumbed to a catch-up seam
whose real replay implementation arrives with the sequence work; streams
also get a finite lifetime so every reconnect re-authorizes.
Nothing publishes into the registry yet — the broker bridge lands
separately once events carry real sequence numbers (#296).
Part of protegeproject/webprotege-gwt-ui#304
(epic protegeproject/webprotege-gwt-ui#303).
…tags Every pushed event window carried the hardcoded tags 0 to 1, so after the first window advanced a client's bookmark past 1, the client's stale- window guard dropped every subsequent push — the websocket went silent after one event and delivery quietly fell back to the 10-second poll. That is the direct cause of the ~9.5s median UI latency measured in the epic's baseline. The emitter now consumes the sequenced event that the event-history service publishes after archiving a change bundle, and pushes it with truthful tags: startTag is the previous ordinal, endTag is the bundle's ordinal. Because that event is emitted strictly after persistence, anything pushed is already fetchable through the pull path. The wire payload shape is unchanged, so the client deserializes it exactly as before. The error log now includes the exception so failures are diagnosable. The gateway half of protegeproject/webprotege-gwt-ui#296 (part of protegeproject/webprotege-gwt-ui#303).
The sequenced change events the gateway consumes now fan out to the open SSE streams as well as the STOMP topic, carrying the per-project sequence ordinal as the SSE event id and the same truthful tags in the payload. Both sinks send the identical payload object, so the bytes a client sees are the same regardless of transport, and the registry hands each emitter write to its dispatch executor so a slow stream can never stall the broker listener thread. STOMP delivery stays in place until the old transport is retired. Part of protegeproject/webprotege-gwt-ui#304 (epic protegeproject/webprotege-gwt-ui#303).
A stream that opens with a last-seen event id now gets exact catch-up: the gateway queries the durable event archive for everything after that id, sends the result as a single replay frame whose id is the archive's end position, and only then releases live delivery. Live events arriving during the replay are buffered per subscriber and flushed in order with anything at or below the replay position dropped, so nothing is missed and nothing is delivered twice regardless of how the broker and the query interleave. The buffer is bounded; on overflow or a failed history query the stream stays alive and falls back to live-only, where the client's gap detection recovers the difference. Fresh connections carry no id and start at now — project open downloads no history on this transport either. Part of protegeproject/webprotege-gwt-ui#304 (epic protegeproject/webprotege-gwt-ui#303).
The SSE stream cannot carry a bearer header, so authorization needs a credential that can safely ride a URL. An authenticated endpoint now issues an opaque, single-project stream ticket: identity comes from the validated bearer token — never from the request body — the caller's view permission is checked before issuance, and the ticket expires after a short configurable window. Tickets live in a store behind an interface so a multi-instance deployment can swap the in-memory implementation for a shared one. Ticket values are never written to logs. Part of protegeproject/webprotege-gwt-ui#305 (epic protegeproject/webprotege-gwt-ui#303).
…eam opens The stream endpoint's interim identity-from-query-params check is replaced by ticket redemption: the connection is refused without a live ticket for exactly the requested project, and the view-permission check re-runs on every connect with the identity recorded server-side at issuance — so each of the browser's automatic reconnects is a fresh authorization checkpoint, and revoked access takes effect at the next reconnect instead of never. Nothing the request itself carries is trusted for authorization. Part of protegeproject/webprotege-gwt-ui#305 (epic protegeproject/webprotege-gwt-ui#303).
Delivering a change bundle failed at the gateway with a Jackson error: the sequenced event and the history-query response typed their inner events against this service's own event model, and the event-history service's serialization of a hierarchy-changed event does not construct under it. The gateway never acts on the inner events — it only relays them to browsers — so the event list and the sequenced bundle now carry them as raw JSON. What reaches the client is byte-for-byte what the producer serialized, and the gateway stops breaking whenever its copy of the event model drifts from a producer's. Found running the end-to-end suite against the epic stack: viewers opened streams fine but never received a single event. Part of protegeproject/webprotege-gwt-ui#304 (epic protegeproject/webprotege-gwt-ui#303).
Keep-alives went out as SSE comment frames, which browsers never deliver to page JavaScript — so the client had no way to tell a quiet-but-healthy stream from a half-open zombie connection that a network drop killed without an error. The heartbeat is now a real named event with an empty data field (a data-less event would also never be dispatched), and the client uses it to detect and reopen silent streams. Part of protegeproject/webprotege-gwt-ui#304 (epic protegeproject/webprotege-gwt-ui#303).
A client cannot tell from a bare heartbeat whether it has actually seen every event — a frame dropped by the browser across a brief network loss leaves the connection healthy and the loss invisible until the next event happens to arrive. Heartbeats now carry the highest sequence this instance has published for the project, giving clients a bounded-time way to notice they are behind and pull the difference. Part of protegeproject/webprotege-gwt-ui#304 (epic protegeproject/webprotege-gwt-ui#303).
This was referenced Jul 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
protegeproject/webprotege-gwt-ui#305 (part of protegeproject/webprotege-gwt-ui#303). Stacked on #48.
EventSourcecannot send an Authorization header, so the stream is guarded by an opaque, single-project, short-TTL ticket instead:POST /data/events/ticket(bearer-authenticated) issues{"ticket","expiresIn"}after a ViewProject check; identity comes from the validated JWT, never the request body.Deliberately rejected (documented in the brief): cookie auth (wrong cookie domain/path, session state in the gateway, CSRF re-exposure) and the raw access token in the URL (5-minute Keycloak tokens in access logs). Residual: tickets do appear in nginx access logs, bounded by the 120s TTL + project scope + re-check on redemption.
Tests: 66/66 (+21 for issuance/store/redemption, JUnit 5, JDK 17). The client half (mint ticket → open stream → re-mint on fatal error) lands with #306.