From 73fd89f0b95704cb2a97eaa5e8c2189385ead43d Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 14 Jun 2026 19:46:56 +0200 Subject: [PATCH] feat(membershipd): open JetStream for the embedded node + wire it into the server The control plane previously opened a privileged JetStream client only when clustered or running --store kv (needJS). It now also opens one for a standalone single-node embedded deployment (openJS = needJS || embedded), because the embedded NATS always ships JetStream and the server needs it to own persisted rooms' durable streams (ensure on create + serve GET /rooms/{id}/history). An external NATS without a cluster/KV feature is unchanged (no JetStream; history degrades to empty). The internal service identity is generated under the same broadened condition so the in-process JetStream connection authenticates under enforce. After NewServer the js context is wired via SetJetStream with the control-plane KV replication factor, so a persisted room's history is as available as its metadata. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/membershipd/main.go | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/cmd/membershipd/main.go b/cmd/membershipd/main.go index 5670677f..31bbf5be 100644 --- a/cmd/membershipd/main.go +++ b/cmd/membershipd/main.go @@ -150,6 +150,16 @@ func main() { decentralized := *storeBackend == "kv" needJS := clustered || decentralized enforce := authMode == membership.AuthEnforce + embedded := *natsURL == "" + // The control plane also needs a privileged JetStream client to OWN the durable + // per-room streams of persisted rooms (ensure the stream on room creation so the + // subject is captured from the first message — even from a JetStream-less browser + // client — and read it back for GET /rooms/{id}/history). The embedded NATS + // always ships JetStream, so open the client whenever we run embedded, even for a + // standalone SQLite node. For an EXTERNAL NATS we only reach for JetStream when a + // cluster/KV feature explicitly requires it (unchanged), so an operator-managed + // external deployment without those features behaves exactly as before. + openJS := needJS || embedded // Internal service identity (issue 0006a): when the embedded data plane enforces // auth, membershipd must still connect to its OWN server to manage JetStream. @@ -159,7 +169,7 @@ func main() { // the server is embedded), so a standalone or non-enforce node is unchanged. var internalID cs.Identity var internalPubHex string - if needJS && enforce && *natsURL == "" { + if openJS && enforce && embedded { if *internalIDFile != "" { // Persisted identity: load it, generating + writing it (0600) on first // start. A stable internal key is what `user add --store kv` presents to @@ -316,9 +326,9 @@ func main() { // only client that can connect in this window (the holder still denies everyone // else; the internal identity bypasses the store). var js jetstream.JetStream - if needJS { + if openJS { var internalNC *nats.Conn - if *natsURL == "" { + if embedded { internalNC, js, err = connectInternalJS(ns, internalID, enforce) } else { internalNC, js, err = connectExternalJS(natsClientURL, *caFile) @@ -340,6 +350,14 @@ func main() { } srv := membership.NewServer(store, blobs, authMode) + // Wire the privileged JetStream context so the control plane owns persisted + // rooms' durable streams (ensure on create + serve GET /rooms/{id}/history). The + // stream replication factor matches the control-plane KV replication so a room's + // history is as available as its metadata. js is nil only for an external NATS + // without a cluster/KV feature, where history degrades to empty (see openJS). + if js != nil { + srv.SetJetStream(js, *kvReplicas) + } // On a public (non-loopback) bind, disable cleartext rooms: the embedded NATS // has no per-subject ACL, so cleartext content would be readable by any // registered peer. Forcing E2E keeps message content confidential regardless