001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.console.bus;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.security.*;
022import java.time.*;
023import java.util.*;
024import java.util.concurrent.*;
025import java.util.concurrent.atomic.*;
026import java.util.function.*;
027import java.util.logging.*;
028import java.util.stream.*;
029
030import org.apache.juneau.marshall.collections.*;
031import org.apache.juneau.marshall.marshaller.*;
032import org.apache.juneau.rest.server.sse.*;
033
034import jakarta.servlet.http.*;
035
036/**
037 * The server half of the console message bus: a retained-value store plus the sessions that bridge it to pages.
038 *
039 * <p>
040 * Publish from anywhere in the application; every connected page whose bridge was granted the topic receives it
041 * (and every page that connects later receives the retained value).  Topics not allowed by the {@link BusPolicy}
042 * are refused loudly (E-56).
043 *
044 * <p>
045 * The methods under "transport SPI" are public for {@link BusEventsMixin} and {@code BusWebSockets}; application code
046 * does not call them.
047 *
048 * <h5 class='section'>Example:</h5>
049 * <p class='bjava'>
050 *   <jc>// One per application (or per resource), shared by the REST resource and any background work.</jc>
051 *   ServerBus <jv>bus</jv> = ServerBus.<jsm>create</jsm>(BusPolicy.<jsm>create</jsm>()
052 *     .downstream(<js>"ops.jobs"</js>, <jk>true</jk>)
053 *     .downstream(<js>"cmd:jobs"</js>, <jk>false</jk>)
054 *     .build());
055 *
056 *   <jv>bus</jv>.publish(<js>"ops.jobs"</js>, JsonMap.<jsm>of</jsm>(<js>"schemaVersion"</js>, 1, <js>"running"</js>, <jv>running</jv>));
057 *   <jv>bus</jv>.publish(<js>"cmd:jobs"</js>, JsonMap.<jsm>of</jsm>(<js>"schemaVersion"</js>, 1, <js>"op"</js>, <js>"reload"</js>));
058 *
059 *   <jc>// Only to one user's pages (never retained):</jc>
060 *   <jv>bus</jv>.publishTo(<jv>s</jv> -&gt; <js>"jb"</js>.equals(<jv>s</jv>.principalName()), <js>"ops.jobs"</js>, <jv>mine</jv>);
061 * </p>
062 *
063 * @since 10.0.0
064 */
065@SuppressWarnings({
066   "java:S1192", // Transport names and refusal reasons are protocol literals; named constants would not clarify them.
067   "java:S3776" // Branching is inherent to openSession() and receive().
068})
069public final class ServerBus implements AutoCloseable {
070
071   private static final Logger LOGGER = Logger.getLogger(ServerBus.class.getName());
072   private static final SecureRandom RANDOM = new SecureRandom();
073   private static final AtomicLong STREAM_KEYS = new AtomicLong();
074
075   /** Session id strength: 256 bits, written as 64 hex characters. */
076   static final int ID_BYTES = 32;
077
078   /** The transports a session POST may request. */
079   static final Set<String> TRANSPORTS = Set.of("sse", "websocket");
080
081   /** The longest denied topic a 403 body echoes back. */
082   static final int MAX_ECHO_LENGTH = 128;
083
084   /** The most topics one session POST may request in each direction. */
085   static final int MAX_TOPICS = 256;
086
087   private final BusPolicy policy;
088   private final Clock clock;
089   private final ScheduledExecutorService timer;
090   private final SseBroadcaster sseStreams;
091   private final Object lock = new Object();
092
093   // Guarded by lock.
094   private final SortedMap<String,String> retained = new TreeMap<>();
095   private final Map<String,SessionImpl> sessions = new LinkedHashMap<>();
096   private boolean closed;
097
098   private volatile String webSocketPrefix;
099
100   ServerBus(BusPolicy policy, Clock clock, ScheduledExecutorService timer) {
101      this.policy = Objects.requireNonNull(policy, "policy");
102      this.clock = Objects.requireNonNull(clock, "clock");
103      this.timer = timer;
104      // One spare slot per queue: SseFrameSink bounds itself at maxQueuedFrames, so drop-oldest never runs.
105      this.sseStreams = new SseBroadcaster(policy.maxQueuedFrames() + 1);
106   }
107
108   /**
109    * Creates a bus and starts its timer (one daemon thread: heartbeat pings and session expiry).
110    *
111    * @param policy The policy.  Must not be <jk>null</jk>.
112    * @return A new bus.  {@link #close()} it when the application stops.
113    */
114   public static ServerBus create(BusPolicy policy) {
115      Objects.requireNonNull(policy, "policy");
116      var timer = Executors.newSingleThreadScheduledExecutor(r -> {
117         var t = new Thread(r, "juneau-bus-timer");
118         t.setDaemon(true);
119         return t;
120      });
121      var bus = new ServerBus(policy, Clock.systemUTC(), timer);
122      var period = Math.min(1000L, policy.heartbeat().toMillis());
123      timer.scheduleAtFixedRate(bus::tickSafely, period, period, TimeUnit.MILLISECONDS);
124      return bus;
125   }
126
127   //-----------------------------------------------------------------------------------------------------------------
128   // Application API
129   //-----------------------------------------------------------------------------------------------------------------
130
131   /**
132    * Publishes a value to every connected session granted the topic, and stores it if the topic is retained.
133    *
134    * <p>
135    * For a retained topic, a value whose JSON equals the stored JSON is not republished (distinct-until-changed).
136    *
137    * @param topic A concrete topic the policy allows downstream (not a {@code :*} pattern).
138    * @param payload The payload, serialized with {@link Json#of(Object)}.  Must not be <jk>null</jk>; use
139    *    {@link #clear(String)} to remove a retained value.
140    * @return <jk>false</jk> when the topic is retained and the value is unchanged; otherwise <jk>true</jk>.
141    * @throws IllegalArgumentException E-56 when the policy denies the topic or the frame is larger than
142    *    {@link BusPolicy#maxFrameBytes()}.
143    */
144   public boolean publish(String topic, Object payload) {
145      var retain = allowed("publish", topic);
146      var json = encode("publish", topic, payload);
147      synchronized (lock) {
148         if (retain) {
149            if (json.equals(retained.get(topic)))
150               return false;
151            retained.put(topic, json);
152         }
153         for (var s : List.copyOf(sessions.values()))
154            if (s.grants(topic))
155               s.send(seq -> BusFrames.pub(topic, json, retain, seq));
156      }
157      return true;
158   }
159
160   /**
161    * Removes a retained value and sends {@code clear} to every connected session granted the topic.
162    *
163    * <p>
164    * Clearing a topic that holds no value does nothing.
165    *
166    * @param topic A retained topic.
167    * @throws IllegalArgumentException E-56 when the policy denies the topic or does not retain it.
168    */
169   public void clear(String topic) {
170      if (! allowed("clear", topic))
171         throw e56("ServerBus.clear('%s'): the policy does not retain it", topic);
172      synchronized (lock) {
173         if (retained.remove(topic) == null)
174            return;
175         for (var s : List.copyOf(sessions.values()))
176            if (s.grants(topic))
177               s.send(seq -> BusFrames.clear(topic, seq));
178      }
179   }
180
181   /**
182    * Publishes a value to the connected sessions that match an audience.  Never retained, even on a retained topic.
183    *
184    * @param audience Selects sessions.  Runs under the bus lock: keep it cheap and side-effect free.
185    * @param topic A concrete topic the policy allows downstream.
186    * @param payload The payload.  Must not be <jk>null</jk>.
187    * @return The number of sessions that accepted the frame.
188    * @throws IllegalArgumentException E-56, as for {@link #publish(String, Object)}.
189    */
190   public int publishTo(Predicate<BusSession> audience, String topic, Object payload) {
191      Objects.requireNonNull(audience, "audience");
192      allowed("publishTo", topic);
193      var json = encode("publishTo", topic, payload);
194      var reached = 0;
195      synchronized (lock) {
196         for (var s : List.copyOf(sessions.values()))
197            if (s.isConnected() && s.grants(topic) && audience.test(s) && s.send(seq -> BusFrames.pub(topic, json, false, seq)))
198               reached++;
199      }
200      return reached;
201   }
202
203   /**
204    * The stored JSON of a retained topic.
205    *
206    * @param topic The topic.
207    * @return The JSON, or empty when nothing is stored.
208    */
209   public Optional<String> retainedJson(String topic) {
210      synchronized (lock) {
211         return Optional.ofNullable(retained.get(topic));
212      }
213   }
214
215   /**
216    * The live sessions (connected, or disconnected within their grace period).
217    *
218    * @return An immutable snapshot.
219    */
220   public List<BusSession> sessions() {
221      synchronized (lock) {
222         sweep(clock.instant());
223         return List.copyOf(sessions.values());
224      }
225   }
226
227   /** @return The policy this bus enforces. */
228   public BusPolicy policy() {
229      return policy;
230   }
231
232   /**
233    * Closes every connection with {@code 1001} ({@code bus:closed}), forgets every session and stops the timer.
234    * Retained values are kept.  Idempotent.
235    */
236   @Override /* AutoCloseable */
237   public void close() {
238      synchronized (lock) {
239         if (closed)
240            return;
241         closed = true;
242         for (var s : sessions.values())
243            s.detach(1001, "bus:closed");
244         sessions.clear();
245      }
246      if (timer != null)
247         timer.shutdownNow();
248   }
249
250   //-----------------------------------------------------------------------------------------------------------------
251   // Transport SPI
252   //-----------------------------------------------------------------------------------------------------------------
253
254   /**
255    * Transport SPI: opens a session from a session POST body.
256    *
257    * <p>
258    * Body: {@code {"v":1, "bridge":"ops", "transport":"sse"|"websocket", "downstream":[...], "upstream":[...]}}.
259    * {@code v} defaults to {@code 1} and {@code transport} to {@code "sse"}; {@code bridge} is required.
260    *
261    * @param req The POST request: the authorizer's argument and the source of the principal and session attributes.
262    * @param body The parsed body.  <jk>null</jk> is a 400.
263    * @return The new session.
264    * @throws BusRefusal 400 {@code bus:bad-request}, 403 {@code bus:topic-denied}, 429 {@code bus:too-many-sessions},
265    *    501 {@code bus:transport-unavailable}, or 503 {@code bus:closed}.
266    */
267   public BusSession openSession(HttpServletRequest req, JsonMap body) throws BusRefusal {
268      Objects.requireNonNull(req, "req");
269      if (body == null)
270         throw BusRefusal.badRequest("the session request must be a JSON object");
271      var v = body.get("v");
272      if (v != null && ! (v instanceof Number n && n.doubleValue() == BusFrames.VERSION))
273         throw BusRefusal.badRequest("unsupported bus version");
274      if (! (body.get("bridge") instanceof String bridge) || ! BusPolicy.BRIDGE_ID.matcher(bridge).matches())
275         throw BusRefusal.badRequest("bridge must be a valid id");
276      var t = body.get("transport");
277      if (t != null && ! (t instanceof String x && TRANSPORTS.contains(x)))
278         throw BusRefusal.badRequest("transport must be 'sse' or 'websocket'");
279      var transport = t == null ? "sse" : (String) t;
280      var downstream = topics(body, "downstream");
281      var upstream = topics(body, "upstream");
282      if (downstream.isEmpty() && upstream.isEmpty())
283         throw BusRefusal.badRequest("the session requests no topics");
284      if (! upstream.isEmpty() && ! "websocket".equals(transport))
285         throw BusRefusal.badRequest("upstream topics require the websocket transport");
286      if ("websocket".equals(transport) && webSocketPrefix == null)
287         throw BusRefusal.transportUnavailable();
288
289      var authorizer = policy.authorizer();
290      var denied = Stream.concat(
291         downstream.stream().filter(x -> policy.downstreamRetain(x).isEmpty() || ! authorizer.test(req, x)),
292         upstream.stream().filter(x -> ! policy.allowsUpstream(x) || ! authorizer.test(req, x))
293      ).toList();
294      if (! denied.isEmpty())
295         throw BusRefusal.topicDenied(echoable(denied));
296
297      var principal = req.getUserPrincipal();
298      var attributes = policy.sessionAttributes().apply(req);
299      synchronized (lock) {
300         if (closed)
301            throw new BusRefusal(503, "bus:closed", "the bus is closed", null, 0);
302         var now = clock.instant();
303         sweep(now);
304         if (sessions.size() >= policy.maxSessions())
305            throw BusRefusal.tooManySessions();
306         var s = new SessionImpl(newId(), bridge, transport, principal == null ? null : principal.getName(),
307            attributes, downstream, upstream, now);
308         sessions.put(s.id, s);
309         return s;
310      }
311   }
312
313   /**
314    * Transport SPI: finds a live session by its capability id, for the principal that opened it.
315    *
316    * @param id The session id from the request path.  Can be <jk>null</jk>.
317    * @param principal The request principal.  Can be <jk>null</jk> (anonymous).
318    * @return The session, or empty when the id is unknown or expired, or the principal differs.  The three cases are
319    *    deliberately indistinguishable.
320    */
321   public Optional<BusSession> session(String id, Principal principal) {
322      if (id == null)
323         return Optional.empty();
324      synchronized (lock) {
325         var s = sessions.get(id);
326         if (s == null)
327            return Optional.empty();
328         if (s.expired(clock.instant())) {
329            expire(s);
330            return Optional.empty();
331         }
332         if (neq(s.principalName, principal == null ? null : principal.getName()))
333            return Optional.empty();
334         return Optional.of(s);
335      }
336   }
337
338   /**
339    * Transport SPI: attaches a connection to a session, then sends the resync.
340    *
341    * <p>
342    * Any live sink is replaced and closed with {@code 4409} ({@code bus:replaced}).  The resync is
343    * {@code resync-begin}, one retained {@code pub} for every stored value the session's grant covers, in topic order,
344    * then {@code resync-end}.  When the bus is closed or the session has expired, the sink is closed at once
345    * ({@code 1001} / {@code 4401}).
346    *
347    * @param session A session of this bus.
348    * @param sink The connection's outbound side.
349    */
350   public void connect(BusSession session, FrameSink sink) {
351      var s = impl(session);
352      Objects.requireNonNull(sink, "sink");
353      synchronized (lock) {
354         if (closed) {
355            sink.close(1001, "bus:closed");
356            return;
357         }
358         if (sessions.get(s.id) != s) {
359            sink.close(4401, "bus:unknown-session");
360            return;
361         }
362         if (s.expired(clock.instant())) {
363            expire(s);
364            sink.close(4401, "bus:unknown-session");
365            return;
366         }
367         var old = s.sink.getAndSet(sink);
368         s.lastSent = clock.instant();
369         if (old != null)
370            old.close(4409, "bus:replaced");
371         if (! s.send(BusFrames::resyncBegin))
372            return;
373         for (var e : retained.entrySet())
374            if (s.grants(e.getKey()) && ! s.send(seq -> BusFrames.pub(e.getKey(), e.getValue(), true, seq)))
375               return;
376         s.send(BusFrames::resyncEnd);
377      }
378   }
379
380   /**
381    * Transport SPI: the connection behind a sink has closed.  Starts the session's grace period.
382    *
383    * <p>
384    * A sink that is no longer the session's live sink (it was replaced, or detached as a slow consumer) is ignored.
385    *
386    * @param session A session of this bus.
387    * @param sink The sink passed to {@link #connect(BusSession, FrameSink)}.
388    */
389   public void disconnect(BusSession session, FrameSink sink) {
390      var s = impl(session);
391      synchronized (lock) {
392         if (sink != null && s.sink.get() == sink) {
393            s.sink.set(null);
394            s.disconnectedAt = clock.instant();
395         }
396      }
397   }
398
399   /**
400    * Transport SPI: one upstream frame from a WebSocket connection.
401    *
402    * <p>
403    * Refusals are answered with an {@code error} frame and the frame is dropped; the connection stays open.
404    *
405    * @param session A session of this bus.
406    * @param frameText The frame text.
407    */
408   public void receive(BusSession session, String frameText) {
409      var s = impl(session);
410      synchronized (lock) {
411         if (sessions.get(s.id) != s)
412            return;
413         if (s.expired(clock.instant())) {
414            expire(s);
415            return;
416         }
417         if (! s.isConnected())
418            return;
419         if (! s.admitUpstream(clock.instant(), policy.maxUpstreamPerSecond())) {
420            s.offer(BusFrames.error("bus:rate-limited", "too many upstream frames", null));
421            return;
422         }
423      }
424      JsonMap frame;
425      try {
426         if (frameText != null && BusFrames.utf8Length(frameText) > policy.maxFrameBytes())
427            throw BusRefusal.badFrame("frame too large");
428         frame = BusFrames.decode(frameText);
429         if (! "pub".equals(frame.get("type")))
430            throw BusRefusal.badFrame("only pub frames may be sent upstream");
431         if (! (frame.get("payload") instanceof Map<?,?>))
432            throw BusRefusal.badFrame("an upstream payload must be a JSON object");
433      } catch (BusRefusal e) {
434         reply(s, BusFrames.error(e.code(), e.getMessage(), null));
435         return;
436      }
437      var topic = (String) frame.get("topic");
438      if (! s.upstream.contains(topic) || ! policy.allowsUpstream(topic)) {
439         var echo = BusPolicy.UPSTREAM_TOPIC.matcher(topic).matches() ? topic : null;
440         reply(s, BusFrames.error("bus:upstream-denied", "the session is not granted this upstream topic", echo));
441         return;
442      }
443      var p = (Map<?,?>) frame.get("payload");
444      var payload = p instanceof JsonMap j ? j : JsonMap.of(p);
445      try {
446         policy.upstreamHandler(topic).handle(s, topic, payload);
447      } catch (Exception e) {
448         if (e instanceof InterruptedException)
449            Thread.currentThread().interrupt();
450         LOGGER.log(Level.WARNING, e, () -> f("bus upstream handler for topic '%s' failed", topic));
451         reply(s, BusFrames.error("bus:upstream-failed", "handler failed", topic));
452      }
453   }
454
455   /**
456    * Transport SPI: records that the WebSocket transport is registered, so {@code transport:"websocket"} sessions may
457    * open and the session POST response carries {@code paths.websocket}.
458    *
459    * @param contextRelativePathPrefix The endpoint path without its {@code /{sessionId}} segment, for example
460    *    {@code /juneau-bus/ws}.  Must start with {@code /} and not end with {@code /}.
461    */
462   public void enableWebSocket(String contextRelativePathPrefix) {
463      if (contextRelativePathPrefix == null || ! contextRelativePathPrefix.startsWith("/") || contextRelativePathPrefix.endsWith("/"))
464         throw new IllegalArgumentException(f(
465            "ServerBus.enableWebSocket: '%s' must start with '/' and must not end with '/'", contextRelativePathPrefix));
466      webSocketPrefix = contextRelativePathPrefix;
467   }
468
469   //-----------------------------------------------------------------------------------------------------------------
470   // Package-private: BusEventsMixin and tests
471   //-----------------------------------------------------------------------------------------------------------------
472
473   /** The WebSocket path prefix passed to {@link #enableWebSocket(String)}, or <jk>null</jk>. */
474   String webSocketPrefix() {
475      return webSocketPrefix;
476   }
477
478   /** The capability id of a session of any {@code ServerBus}.  Only the session POST response may carry it. */
479   static String idOf(BusSession session) {
480      return impl(session).id;
481   }
482
483   /**
484    * The SSE stream's subscription for a session (built on {@link SseBroadcaster}).
485    *
486    * <p>
487    * One per session, keyed by the session's non-secret stream key, never its capability id.  Subscribing again
488    * closes the previous subscription, which ends the earlier stream quietly; {@link #connect(BusSession, FrameSink)}
489    * then closes the earlier sink with {@code 4409}.  The queue holds {@link BusPolicy#maxQueuedFrames()} {@code + 1}
490    * events; see {@code SseFrameSink}.
491    *
492    * @param session A session of this bus.
493    * @return A new subscription.  The caller closes it when its stream ends.
494    */
495   SseSubscription subscribeSse(BusSession session) {
496      return sseStreams.subscribe(impl(session).streamKey);
497   }
498
499   /**
500    * The session POST's {@code 200} body.
501    *
502    * @param session A session of this bus.
503    * @param busBase The root-relative servlet path plus {@link BusEventsMixin#BUS_PREFIX}, e.g. {@code /rest/ops/juneau-bus}.
504    * @param contextPath The servlet context path ({@code ""} for the root context): WebSocket endpoints are
505    *    context-relative, not servlet-relative.
506    * @return The grant.  {@code paths.websocket} is present only after {@link #enableWebSocket(String)}.
507    */
508   JsonMap grant(BusSession session, String busBase, String contextPath) {
509      var s = impl(session);
510      var down = s.downstream.stream().map(t -> JsonMap.of("topic", t, "retain", policy.downstreamRetain(t).orElse(false))).toList();
511      var paths = JsonMap.of("sse", busBase + "/stream/" + s.id);
512      var ws = webSocketPrefix;
513      if (ws != null)
514         paths.put("websocket", (contextPath == null ? "" : contextPath) + ws + "/" + s.id);
515      return JsonMap.of("v", BusFrames.VERSION, "sessionId", s.id, "heartbeatMs", policy.heartbeat().toMillis(),
516         "graceMs", policy.sessionGrace().toMillis(), "downstream", down, "upstream", List.copyOf(s.upstream), "paths", paths);
517   }
518
519   /** Expires old sessions, closes connections past {@code maxSessionAge}, and pings idle connections. */
520   void tick() {
521      synchronized (lock) {
522         if (closed)
523            return;
524         var now = clock.instant();
525         sweep(now);
526         var idle = policy.heartbeat();
527         for (var s : List.copyOf(sessions.values()))
528            if (s.sink.get() != null && ! now.isBefore(s.lastSent.plus(idle)))
529               s.offer(BusFrames.ping());
530      }
531   }
532
533   //-----------------------------------------------------------------------------------------------------------------
534   // Internals
535   //-----------------------------------------------------------------------------------------------------------------
536
537   private void tickSafely() {
538      try {
539         tick();
540      } catch (RuntimeException e) {
541         // A scheduled task that throws is never run again; log and keep the timer alive.
542         LOGGER.log(Level.WARNING, "bus timer tick failed", e);
543      }
544   }
545
546   /** Returns the retain flag, or throws E-56.  A {@code :*} pattern is not a publishable topic. */
547   private boolean allowed(String method, String topic) {
548      if (topic == null || topic.endsWith(":*"))
549         throw e56("ServerBus.%s('%s'): the policy does not allow it downstream", method, topic);
550      return policy.downstreamRetain(topic)
551         .orElseThrow(() -> e56("ServerBus.%s('%s'): the policy does not allow it downstream", method, topic));
552   }
553
554   /** Serializes a payload and checks the worst-case frame size (a {@code false} flag and the longest {@code seq}). */
555   private String encode(String method, String topic, Object payload) {
556      if (payload == null)
557         throw new IllegalArgumentException(f("ServerBus.%s('%s'): payload must not be null; use clear(topic)", method, topic));
558      var json = Json.of(payload);
559      var size = BusFrames.utf8Length(BusFrames.pub(topic, json, false, Long.MAX_VALUE));
560      if (size > policy.maxFrameBytes())
561         throw e56("ServerBus.%s('%s'): frame too large (%s bytes, maxFrameBytes %s)", method, topic, size, policy.maxFrameBytes());
562      return json;
563   }
564
565   private void reply(SessionImpl s, String frame) {
566      synchronized (lock) {
567         s.offer(frame);
568      }
569   }
570
571   /** Guarded by lock. */
572   private void sweep(Instant now) {
573      for (var s : List.copyOf(sessions.values()))
574         if (s.expired(now))
575            expire(s);
576   }
577
578   /** Guarded by lock. */
579   private void expire(SessionImpl s) {
580      sessions.remove(s.id);
581      s.detach(4401, "bus:unknown-session");
582   }
583
584   /** Keeps only denied topics that are safe to echo in a 403 body: inside a topic grammar and short. */
585   private static List<String> echoable(List<String> denied) {
586      return denied.stream()
587         .filter(x -> x.length() <= MAX_ECHO_LENGTH && (BusPolicy.DOWNSTREAM_PATTERN.matcher(x).matches() || BusPolicy.UPSTREAM_TOPIC.matcher(x).matches()))
588         .toList();
589   }
590
591   private static List<String> topics(JsonMap body, String key) throws BusRefusal {
592      var o = body.get(key);
593      if (o == null)
594         return List.of();
595      if (! (o instanceof List<?> list) || list.size() > MAX_TOPICS)
596         throw BusRefusal.badRequest(f("%s must be a list of at most %s topics", key, MAX_TOPICS));
597      var out = new LinkedHashSet<String>();
598      for (var x : list) {
599         if (! (x instanceof String s) || s.isEmpty())
600            throw BusRefusal.badRequest(f("%s must be a list of at most %s topics", key, MAX_TOPICS));
601         out.add(s);
602      }
603      return List.copyOf(out);
604   }
605
606   private static String newId() {
607      var bytes = new byte[ID_BYTES];
608      RANDOM.nextBytes(bytes);
609      return HexFormat.of().formatHex(bytes);
610   }
611
612   private static SessionImpl impl(BusSession session) {
613      if (session instanceof SessionImpl s)
614         return s;
615      throw new IllegalArgumentException("ServerBus: not a session of a ServerBus");
616   }
617
618   private static IllegalArgumentException e56(String format, Object...args) {
619      return new IllegalArgumentException("E-56: " + f(format, args));
620   }
621
622   /** A session.  Every mutable field is guarded by the bus lock; {@code sink} is also atomic for {@link #isConnected()}. */
623   private final class SessionImpl implements BusSession {
624
625      final String id;
626      final String streamKey = "bus-" + STREAM_KEYS.incrementAndGet();
627      final String bridgeId;
628      final String transport;
629      final String principalName;
630      final Map<String,Object> attributes;
631      final Set<String> downstream;
632      final Set<String> upstream;
633      final Instant createdAt;
634
635      final AtomicReference<FrameSink> sink = new AtomicReference<>();
636      Instant disconnectedAt;
637      Instant lastSent;
638      long seq;
639      Instant windowStart = Instant.EPOCH;
640      int windowCount;
641
642      @SuppressWarnings({
643         "java:S107" // internal constructor; the parameters are the bus configuration
644      })
645      SessionImpl(String id, String bridgeId, String transport, String principalName, Map<String,Object> attributes,
646            List<String> downstream, List<String> upstream, Instant now) {
647         this.id = id;
648         this.bridgeId = bridgeId;
649         this.transport = transport;
650         this.principalName = principalName;
651         this.attributes = attributes == null ? Map.of() : Collections.unmodifiableMap(new LinkedHashMap<>(attributes));
652         this.downstream = Collections.unmodifiableSet(new LinkedHashSet<>(downstream));
653         this.upstream = Collections.unmodifiableSet(new LinkedHashSet<>(upstream));
654         this.createdAt = now;
655         this.disconnectedAt = now;
656         this.lastSent = now;
657      }
658
659      @Override public String bridgeId() { return bridgeId; }
660      @Override public String transport() { return transport; }
661      @Override public String principalName() { return principalName; }
662      @Override public Object attribute(String name) { return attributes.get(name); }
663      @Override public Set<String> downstream() { return downstream; }
664      @Override public Set<String> upstream() { return upstream; }
665      @Override public boolean isConnected() { return sink.get() != null; }
666
667      /** Whether the grant covers a concrete topic: an exact entry, or {@code family:*} for {@code family:key}. */
668      boolean grants(String topic) {
669         if (downstream.contains(topic))
670            return true;
671         var i = topic.indexOf(':');
672         return i > 0 && downstream.contains(topic.substring(0, i) + ":*");
673      }
674
675      /** Sends a frame that carries the next {@code seq}.  Not connected: nothing happens and seq is not consumed. */
676      boolean send(LongFunction<String> frame) {
677         if (sink.get() == null)
678            return false;
679         return offer(frame.apply(++seq));
680      }
681
682      /** Offers a frame; on a full queue detaches the sink and closes it with 4429. */
683      boolean offer(String frame) {
684         var k = sink.get();
685         if (k == null)
686            return false;
687         if (k.offer(frame)) {
688            lastSent = clock.instant();
689            return true;
690         }
691         detach(4429, "bus:slow-consumer");
692         return false;
693      }
694
695      void detach(int code, String reason) {
696         var k = sink.get();
697         if (k == null)
698            return;
699         sink.set(null);
700         disconnectedAt = clock.instant();
701         k.close(code, reason);
702      }
703
704      boolean expired(Instant now) {
705         if (! now.isBefore(createdAt.plus(policy.maxSessionAge())))
706            return true;
707         return sink.get() == null && ! now.isBefore(disconnectedAt.plus(policy.sessionGrace()));
708      }
709
710      boolean admitUpstream(Instant now, int max) {
711         if (! now.isBefore(windowStart.plusSeconds(1))) {
712            windowStart = now;
713            windowCount = 0;
714         }
715         return ++windowCount <= max;
716      }
717
718      @Override /* Object */
719      public String toString() {
720         return f("BusSession[bridge=%s, transport=%s, principal=%s, connected=%s, id=<redacted>]",
721            bridgeId, transport, principalName, isConnected());
722      }
723   }
724}