feat(membership): server owns persisted rooms' stream + GET /rooms/{id}/history

The durable JetStream stream of a persisted (ModeMatrix) room was created only
by the Go client's first publish/subscribe. A client that speaks only core NATS
(the browser client uniweb, which has no JetStream) therefore never created it,
so its messages were captured nowhere and lost on reload. Move stream ownership
to the control plane and expose the backlog over plain HTTP.

- handleCreateRoom ensures the room's stream (idempotent CreateOrUpdateStream)
  BEFORE writing the room row, so the subject is captured from the first message
  whoever publishes it. Done before the store write so a stream failure leaves no
  orphan room. Skipped when no JetStream is wired (room still works, no history).
- New member-only GET /rooms/{id}/history?limit=N (default 200, hard cap 1000):
  reads the stream server-side via the modern jetstream API (Stream.Info +
  GetMsg by sequence, no consumer) and returns the last N frames oldest->newest
  as {"messages":[<base64-std of the marshaled frame>]}. The server never
  decrypts — it relays the E2E ciphertext bytes the stream already holds.
  Existence is checked first (404), then membership (403); enforce rejects an
  unsigned request with 401 before the handler runs.
- Lazy backfill: the history endpoint ensures the stream of a pre-existing
  persisted room, so it starts capturing from now on. Messages sent before the
  stream existed were never captured and are unrecoverable.
- The stream config (streamConfigForRoom) mirrors pkg/client/persist.go
  byte-for-byte plus Replicas (matched to the control-plane KV replication). It
  is copied rather than imported because pkg/client imports pkg/membership and
  the reverse would be an import cycle; the source of truth is documented in a
  comment.
- Server gains SetJetStream(js, replicas) to wire the privileged JetStream
  context and the room-stream replication factor.

Tests (history_test.go): golden (3 frames round-trip in order, decodable),
core-NATS capture (the central fix), handleCreateRoom creates the stream, limit,
empty room ([] not null), 401 unsigned, 403 non-member, 404 missing room.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 19:46:45 +02:00
parent 3fdbb54353
commit e71063b16e
3 changed files with 648 additions and 0 deletions
+50
View File
@@ -109,6 +109,21 @@ type Server struct {
// the RemoteAddr-only behavior that predates the flag. Set by the command via
// SetTrustedProxies. See clientIP.
trustedProxies trustedProxyMatcher
// js is the privileged JetStream context the server uses to own the durable
// per-room streams of persisted rooms: it ensures a room's stream on creation
// so the room's subject is captured from the first message — even from a
// JetStream-less browser client (uniweb) that speaks only core NATS — and reads
// it back for GET /rooms/{id}/history. It is wired by the command via
// SetJetStream whenever a JetStream-capable data plane is available (always for
// the embedded server). nil leaves history empty and stream-ensure a no-op,
// preserving the pre-feature behavior for a deployment without JetStream.
js jetstream.JetStream
// streamReplicas is the replication factor for the room streams the server
// creates, matched to the cluster's control-plane (KV) replication — 1 for a
// standalone node, up to 3 in an HA cluster — so a persisted room's history is
// as available as its metadata. Used only when js != nil. See SetJetStream.
streamReplicas int
}
// Posture describes the security posture a membershipd node runs with. It is
@@ -143,6 +158,19 @@ func NewServer(store Store, blobs blobstore.Store, authMode AuthMode) *Server {
return s
}
// SetJetStream wires the privileged JetStream context (and the room-stream
// replication factor) the server uses to ensure and read the durable streams of
// persisted rooms. replicas below 1 is clamped to 1. It must be called once at
// startup, before the server begins serving; leaving it unset keeps history empty
// and stream-ensure a no-op, the behavior for a deployment without JetStream.
func (s *Server) SetJetStream(js jetstream.JetStream, replicas int) {
if replicas < 1 {
replicas = 1
}
s.js = js
s.streamReplicas = replicas
}
// UseReplicatedNonces switches the server's anti-replay store from the
// per-process in-memory cache to a JetStream KV bucket shared across the cluster
// (issue 0003e). It MUST be called on every node of a multi-node deployment:
@@ -403,6 +431,13 @@ func (s *Server) routes() {
s.mux.HandleFunc("POST /rooms/{id}/invite", s.handleInvite)
s.mux.HandleFunc("GET /rooms/{id}/key", s.handleGetKey)
s.mux.HandleFunc("GET /rooms/{id}/members", s.handleListMembers)
// Durable message history for a persisted room, read server-side from the room's
// JetStream stream so a client without JetStream (the browser client uniweb) can
// load the backlog over plain HTTP. Member-only, like /key and /members.
// Registered without the /api prefix like every other control-plane route: Caddy
// strips /api via handle_path /api/* before forwarding, so the SPA's
// GET /api/rooms/{id}/history arrives here as GET /rooms/{id}/history.
s.mux.HandleFunc("GET /rooms/{id}/history", s.handleRoomHistory)
s.mux.HandleFunc("GET /members/{endpoint}/rooms", s.handleListMemberRooms)
s.mux.HandleFunc("POST /rooms/{id}/rekey", s.handleRekey)
s.mux.HandleFunc("GET /rooms/{id}", s.handleGetRoom)
@@ -632,6 +667,21 @@ func (s *Server) handleCreateRoom(w http.ResponseWriter, r *http.Request) {
SignMsgs: req.Policy.SignMsgs,
OwnerEndpoint: req.Owner.Endpoint,
}
// Own the durable stream for a persisted room (issue room-history): ensure it
// BEFORE the room row is written so the subject is captured from the very first
// message whoever publishes it — a Go client OR a JetStream-less browser client.
// Done first so a stream failure aborts cleanly with no orphan room row (the
// rare orphan empty stream it can leave is harmless and idempotently reused).
// Skipped when no JetStream is wired: the room still works, just without history.
if info.Persist && s.js != nil {
ctx, cancel := context.WithTimeout(r.Context(), historyOpTimeout)
err := ensureRoomStream(ctx, s.js, roomID, info.Subject, s.streamReplicas)
cancel()
if err != nil {
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
return
}
}
if err := s.store.CreateRoom(info, req.Owner.SignPub, req.Owner.KexPub, req.SealedKeySelf); err != nil {
writeServerErr(w, r, http.StatusInternalServerError, "internal error", err)
return