This is an automated email from the ASF dual-hosted git repository.

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git


The following commit(s) were added to refs/heads/master by this push:
     new 50ee6b1178 [fix] require an account to subscribe to the alert and 
manager streams (#4272)
50ee6b1178 is described below

commit 50ee6b11780ad936bb01b4b1ee02344f5e34da3b
Author: Duansg <[email protected]>
AuthorDate: Mon Aug 17 14:33:55 2026 +0800

    [fix] require an account to subscribe to the alert and manager streams 
(#4272)
    
    Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
    Co-authored-by: aias00 <[email protected]>
---
 .../hertzbeat/alert/config/AlertSseManager.java    |  66 ++--
 .../alert/config/AlertSseManagerTest.java          |  90 +++--
 .../common/support/SseEmitterRegistry.java         | 198 ++++++++++
 .../common/support/SseEmitterRegistryTest.java     | 242 +++++++++++++
 .../manager/config/ManagerSseManager.java          |  80 ++---
 .../manager/config/ManagerSseManagerTest.java      | 101 ++++++
 hertzbeat-startup/src/main/resources/sureness.yml  |   6 +-
 .../startup/security/SurenessSseRuleTest.java      | 103 ++++++
 .../hertzbeat-mysql-iotdb/conf/sureness.yml        |   6 +-
 .../hertzbeat-mysql-tdengine/conf/sureness.yml     |   6 +-
 .../conf/sureness.yml                              |   6 +-
 .../conf/sureness.yml                              |   6 +-
 .../conf/sureness.yml                              |   6 +-
 script/sureness.yml                                |   6 +-
 .../core/interceptor/default.interceptor.spec.ts   | 124 +++++++
 .../app/core/interceptor/default.interceptor.ts    |   6 +-
 .../app/layout/basic/widgets/notify.component.ts   | 142 ++++----
 .../alert/alert-center/alert-center.component.ts   |  38 +-
 web-app/src/app/service/auth.service.spec.ts       |  12 +
 web-app/src/app/service/auth.service.ts            |   8 +-
 .../src/app/service/authorized-sse.service.spec.ts | 400 +++++++++++++++++++++
 web-app/src/app/service/authorized-sse.service.ts  | 343 ++++++++++++++++++
 22 files changed, 1772 insertions(+), 223 deletions(-)

diff --git 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/config/AlertSseManager.java
 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/config/AlertSseManager.java
index e3d245621e..a48e277e69 100644
--- 
a/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/config/AlertSseManager.java
+++ 
b/hertzbeat-alerter/src/main/java/org/apache/hertzbeat/alert/config/AlertSseManager.java
@@ -19,62 +19,50 @@
 
 package org.apache.hertzbeat.alert.config;
 
-import lombok.extern.slf4j.Slf4j;
+import org.apache.hertzbeat.common.support.SseEmitterRegistry;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Component;
-import 
org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
-import java.io.IOException;
-import java.util.Map;
-import java.util.Optional;
-import java.util.concurrent.ConcurrentHashMap;
-
 /**
- * SSE manager for alert
+ * SSE manager for alert.
+ *
+ * <p>Note: the lifecycle of a subscription - its timeout, the ceiling on how 
many may be held
+ * and the cleanup of the ones that went away - belongs to {@link 
SseEmitterRegistry}; what is
+ * alert specific is only the event these subscribers are waiting for.
  */
-@Slf4j
 @Component
 public class AlertSseManager {
-    private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
 
+    private static final String ALERT_EVENT = "ALERT_EVENT";
+
+    private final SseEmitterRegistry registry = new 
SseEmitterRegistry("alert");
+
+    /**
+     * Registers a subscription for the given client.
+     *
+     * @param clientId Identifier of the subscriber, unique per subscription
+     * @return The emitter the controller returns to spring
+     */
     public SseEmitter createEmitter(Long clientId) {
-        SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
-        emitter.onCompletion(() -> removeEmitter(clientId));
-        emitter.onTimeout(() -> removeEmitter(clientId));
-        emitter.onError((ex) -> removeEmitter(clientId));
-        emitters.put(clientId, emitter);
-        return emitter;
+        return registry.createEmitter(clientId);
     }
 
+    /**
+     * Delivers one alert to every live subscriber.
+     *
+     * @param data Serialised alert payload
+     */
     @Async
     public void broadcast(String data) {
-        emitters.forEach((clientId, emitter) -> {
-            try {
-                emitter.send(SseEmitter.event()
-                        .id(String.valueOf(System.currentTimeMillis()))
-                        .name("ALERT_EVENT")
-                        .data(data));
-            } catch (IOException | IllegalStateException e) {
-                tryCompleteAndClean(clientId, emitter);
-            } catch (Exception exception) {
-                log.error("Failed to broadcast alert data to client: {}", 
exception.getMessage());
-                tryCompleteAndClean(clientId, emitter);
-            }
-        });
+        registry.broadcast(ALERT_EVENT, data);
     }
 
-    private void tryCompleteAndClean(Long clientId, SseEmitter emitter) {
-        try {
-            
Optional.ofNullable(emitter).ifPresent(ResponseBodyEmitter::complete);
-        } catch (Throwable e) {
-            log.debug("Failed to complete emitter for client {}: {}", 
clientId, e.getMessage());
-        }
-        // execute clear
-        removeEmitter(clientId);
+    void setMaxEmitters(int maxEmitters) {
+        registry.setMaxEmitters(maxEmitters);
     }
 
-    private void removeEmitter(Long clientId) {
-        emitters.remove(clientId);
+    int subscriptionCount() {
+        return registry.subscriptionCount();
     }
 }
diff --git 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/config/AlertSseManagerTest.java
 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/config/AlertSseManagerTest.java
index 40e88ddef9..ecd308042c 100644
--- 
a/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/config/AlertSseManagerTest.java
+++ 
b/hertzbeat-alerter/src/test/java/org/apache/hertzbeat/alert/config/AlertSseManagerTest.java
@@ -17,25 +17,30 @@
 
 package org.apache.hertzbeat.alert.config;
 
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
-
-import java.lang.reflect.Field;
-import java.util.HashMap;
-import java.util.Map;
-
-import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
-import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.doThrow;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import java.lang.reflect.Field;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.hertzbeat.common.support.SseEmitterRegistry;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
 /**
- * alert sse manager test
+ * Test case for {@link AlertSseManager}.
+ *
+ * <p>Note: how a subscription is bounded and cleaned up is covered by
+ * {@code SseEmitterRegistryTest}; what is left here is what makes this stream 
the alert one.
  */
-public class AlertSseManagerTest {
+class AlertSseManagerTest {
 
     private AlertSseManager alertSseManager;
 
@@ -44,26 +49,51 @@ public class AlertSseManagerTest {
         alertSseManager = new AlertSseManager();
     }
 
+    /**
+     * The ui subscribes by event name, so an alert delivered under any other 
name reaches
+     * nobody even though the connection is up.
+     */
     @Test
-    void testCompleteThrowsException() throws Exception {
-        SseEmitter emitter = alertSseManager.createEmitter(1L);
-        assertNotNull(emitter);
+    void testAlertsAreDeliveredUnderTheAlertEventName() throws Exception {
+        alertSseManager.createEmitter(1L);
+        final SseEmitter subscriber = mock(SseEmitter.class);
+        emitters().put(1L, subscriber);
 
-        Map<Long, SseEmitter> emitters = new HashMap<>();
-        SseEmitter spyEmitter = mock(SseEmitter.class);
-        
-        doThrow(new IllegalStateException("Simulated output stream 
error")).when(spyEmitter).send(any(SseEmitter.SseEventBuilder.class));
-        doThrow(new RuntimeException("Complete 
failed")).when(spyEmitter).complete();
-        
-        emitters.put(1L, spyEmitter);
+        alertSseManager.broadcast("{\"id\":1}");
 
-        Field emittersField = 
AlertSseManager.class.getDeclaredField("emitters");
-        emittersField.setAccessible(true);
-        emittersField.set(alertSseManager, emitters);
+        final ArgumentCaptor<SseEmitter.SseEventBuilder> event =
+                ArgumentCaptor.forClass(SseEmitter.SseEventBuilder.class);
+        verify(subscriber).send(event.capture());
+        final String rendered = event.getValue().build().stream()
+                .map(part -> String.valueOf(part.getData()))
+                .collect(Collectors.joining());
+        assertTrue(rendered.contains("event:ALERT_EVENT"), "alerts must be 
delivered as ALERT_EVENT, was " + rendered);
+        assertTrue(rendered.contains("{\"id\":1}"), "the alert payload must be 
delivered as is, was " + rendered);
+    }
 
-        assertThrows(RuntimeException.class, () -> 
alertSseManager.broadcast("{\"id\":1,\"content\":\"Test alert\"}"));
-        Map<Long, SseEmitter> currentEmitters = (Map<Long, SseEmitter>) 
emittersField.get(alertSseManager);
-        assertFalse(currentEmitters.containsKey(1L), "Emitter should still 
exist because complete() threw exception");
+    /**
+     * The manager has to hand its subscriptions to a registry rather than 
hold them itself,
+     * otherwise none of the bounds that registry enforces apply to this 
stream.
+     */
+    @Test
+    void testSubscriptionsAreBoundedByTheRegistry() {
+        alertSseManager.setMaxEmitters(1);
+
+        assertNotNull(alertSseManager.createEmitter(1L));
+        final ResponseStatusException thrown =
+                assertThrows(ResponseStatusException.class, () -> 
alertSseManager.createEmitter(2L));
+
+        assertEquals(HttpStatus.SERVICE_UNAVAILABLE, thrown.getStatusCode());
+        assertEquals(1, alertSseManager.subscriptionCount());
     }
 
-}
\ No newline at end of file
+    @SuppressWarnings("unchecked")
+    private Map<Long, SseEmitter> emitters() throws Exception {
+        final Field registryField = 
AlertSseManager.class.getDeclaredField("registry");
+        registryField.setAccessible(true);
+        final Object registry = registryField.get(alertSseManager);
+        final Field emittersField = 
SseEmitterRegistry.class.getDeclaredField("emitters");
+        emittersField.setAccessible(true);
+        return (Map<Long, SseEmitter>) emittersField.get(registry);
+    }
+}
diff --git 
a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/support/SseEmitterRegistry.java
 
b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/support/SseEmitterRegistry.java
new file mode 100644
index 0000000000..9fe636e687
--- /dev/null
+++ 
b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/support/SseEmitterRegistry.java
@@ -0,0 +1,198 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hertzbeat.common.support;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.atomic.AtomicInteger;
+import lombok.Setter;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.server.ResponseStatusException;
+import 
org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+/**
+ * Holds the live server sent event subscriptions of one stream and owns their 
lifecycle.
+ *
+ * <p>What: hands an emitter to each subscriber, keeps the live ones, 
broadcasts an event to
+ * all of them and drops the ones that have gone away.
+ *
+ * <p>How: hold one instance per stream and name it after that stream, so 
refusals and
+ * broadcast failures stay tellable apart in the log. The instance is not a 
bean: a stream
+ * owns its registry the way it owns its subscribers.
+ *
+ * <p>Note: an open subscription occupies a request thread for as long as it 
lives, so both
+ * how long one may live and how many may exist at once are bounded here. Each 
stream used to
+ * carry its own copy of this lifecycle and the copies drifted - one released 
the slot of a
+ * subscription that died on an error and the other did not, so on that stream 
a failed
+ * subscriber held its slot until the process restarted.
+ */
+@Slf4j
+public class SseEmitterRegistry {
+
+    /**
+     * How long a subscription may stay open before the client has to 
reconnect.
+     *
+     * <p>An unbounded timeout means a subscription never expires on its own, 
so a client that
+     * goes away without closing cleanly holds its request thread until the 
container notices.
+     * A finite timeout bounds that. It is only safe because the ui reconnects 
when the stream
+     * ends: it reads through `fetch` rather than `EventSource`, so it has to 
reconnect itself,
+     * and it does.
+     */
+    private static final long EMITTER_TIMEOUT_MILLIS = 30 * 60 * 1000L;
+
+    /** Name of the stream these subscriptions belong to, used to make log 
lines tellable apart. */
+    private final String streamName;
+
+    /**
+     * Cap on concurrently held subscriptions. Each one occupies a request 
thread, so without
+     * a ceiling enough parallel subscriptions exhaust the container's thread 
pool and take
+     * the whole application down with them.
+     */
+    @Setter
+    private int maxEmitters = 1000;
+
+    private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
+
+    /**
+     * Slots taken by held subscriptions, reserved before the subscription is 
registered.
+     *
+     * <p>Reading `emitters.size()` and then putting into it would let every 
request of a
+     * parallel burst pass the same check and register anyway, which is 
exactly the burst the
+     * ceiling exists to survive. Claiming a slot by compare-and-set makes the 
ceiling hold no
+     * matter how many requests arrive at once. The counter can briefly run 
ahead of the map,
+     * between the claim and the put, which only ever refuses one subscription 
too early.
+     */
+    private final AtomicInteger heldSlots = new AtomicInteger();
+
+    /**
+     * @param streamName Name of the stream, used in log lines only
+     */
+    public SseEmitterRegistry(String streamName) {
+        this.streamName = streamName;
+    }
+
+    /**
+     * Registers a subscription for the given client.
+     *
+     * <p>When: from the request thread serving a subscribe request. The 
returned emitter is
+     * what the controller hands back to spring, which keeps the request open 
around it.
+     *
+     * @param clientId Identifier of the subscriber, unique per subscription, 
not null
+     * @return The emitter the caller returns from its controller method
+     * @throws ResponseStatusException With {@code 503} when the stream 
already holds as many
+     *         subscriptions as it may
+     * @throws NullPointerException When {@code clientId} is null, which is 
checked before a
+     *         slot is taken so that a caller that gets this wrong cannot 
spend the ceiling
+     */
+    public SseEmitter createEmitter(Long clientId) {
+        Objects.requireNonNull(clientId, "clientId of an sse subscription must 
not be null");
+        claimSlot();
+        boolean registered = false;
+        try {
+            final SseEmitter emitter = new SseEmitter(EMITTER_TIMEOUT_MILLIS);
+            emitter.onCompletion(() -> removeEmitter(clientId));
+            emitter.onTimeout(() -> removeEmitter(clientId));
+            // Without this a subscription that dies on an error keeps its 
slot: a stream that
+            // broadcasts rarely notices nothing, and the slot is held until 
the process restarts
+            emitter.onError(ex -> removeEmitter(clientId));
+            // The id is a fresh snowflake per subscription, so this never 
replaces a live emitter
+            emitters.put(clientId, emitter);
+            registered = true;
+            return emitter;
+        } finally {
+            if (!registered) {
+                // The slot is only ever given back by removing the 
subscription from the map,
+                // so one that never got in there would hold its slot until 
the process restarts
+                heldSlots.decrementAndGet();
+            }
+        }
+    }
+
+    /**
+     * Sends one event to every live subscription and drops the ones the send 
fails for.
+     *
+     * <p>Note: a failing send is how a client that went away is noticed, so 
the failure is
+     * expected rather than exceptional and only unforeseen ones are logged at 
error level.
+     *
+     * @param eventName Name of the sse event, which is what the ui subscribes 
by
+     * @param data Payload to deliver, already serialised
+     */
+    public void broadcast(String eventName, String data) {
+        emitters.forEach((clientId, emitter) -> {
+            try {
+                emitter.send(SseEmitter.event()
+                        .id(String.valueOf(System.currentTimeMillis()))
+                        .name(eventName)
+                        .data(data));
+            } catch (IOException | IllegalStateException e) {
+                tryCompleteAndClean(clientId, emitter);
+            } catch (Exception e) {
+                log.error("Failed to broadcast {} data to client: {}", 
streamName, e.getMessage());
+                tryCompleteAndClean(clientId, emitter);
+            }
+        });
+    }
+
+    /**
+     * @return How many subscriptions this stream currently holds
+     */
+    public int subscriptionCount() {
+        return heldSlots.get();
+    }
+
+    private void claimSlot() {
+        while (true) {
+            final int held = heldSlots.get();
+            if (held >= maxEmitters) {
+                log.warn("Refused {} subscription, already holding {} of at 
most {}", streamName, held, maxEmitters);
+                throw new 
ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
+                        "Too many " + streamName + " subscriptions");
+            }
+            if (heldSlots.compareAndSet(held, held + 1)) {
+                return;
+            }
+        }
+    }
+
+    private void tryCompleteAndClean(Long clientId, SseEmitter emitter) {
+        try {
+            
Optional.ofNullable(emitter).ifPresent(ResponseBodyEmitter::complete);
+        } catch (Throwable e) {
+            log.debug("Failed to complete emitter for client {}: {}", 
clientId, e.getMessage());
+        }
+        // Execute clear
+        removeEmitter(clientId);
+    }
+
+    /**
+     * A subscription can be dropped more than once for the same client: a 
send failure cleans
+     * it up and completing it fires the completion callback on top of that. 
The slot is only
+     * released for the removal that actually took the emitter out of the map, 
so the count
+     * cannot drift below what is held and quietly reopen the ceiling.
+     */
+    private void removeEmitter(Long clientId) {
+        if (emitters.remove(clientId) != null) {
+            heldSlots.decrementAndGet();
+        }
+    }
+}
diff --git 
a/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/support/SseEmitterRegistryTest.java
 
b/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/support/SseEmitterRegistryTest.java
new file mode 100644
index 0000000000..5702f9f0d4
--- /dev/null
+++ 
b/hertzbeat-common-spring/src/test/java/org/apache/hertzbeat/common/support/SseEmitterRegistryTest.java
@@ -0,0 +1,242 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hertzbeat.common.support;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockConstruction;
+import static org.mockito.Mockito.verify;
+import java.lang.reflect.Field;
+import java.util.Map;
+import java.util.concurrent.CyclicBarrier;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Consumer;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.mockito.MockedConstruction;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+/**
+ * Test case for {@link SseEmitterRegistry}.
+ *
+ * <p>Every open subscription holds a request thread for as long as it lives, 
so both how long
+ * one may live and how many may exist at once have to be bounded, and every 
way a
+ * subscription can end has to give its slot back.
+ */
+class SseEmitterRegistryTest {
+
+    private SseEmitterRegistry registry;
+
+    @BeforeEach
+    void setUp() {
+        registry = new SseEmitterRegistry("test");
+    }
+
+    /**
+     * An unbounded emitter never expires on its own, so a client that goes 
away without
+     * closing cleanly keeps holding its request thread until the container 
notices.
+     */
+    @Test
+    void testSubscriptionsAreGivenFiniteTimeout() {
+        final SseEmitter emitter = registry.createEmitter(1L);
+
+        assertNotNull(emitter.getTimeout());
+        assertTrue(emitter.getTimeout() > 0 && emitter.getTimeout() < 
Long.MAX_VALUE,
+                "timeout must be finite, was " + emitter.getTimeout());
+    }
+
+    /**
+     * Each open subscription occupies a request thread, so enough of them in 
parallel
+     * exhaust the container's pool and take the whole application down.
+     */
+    @Test
+    void testSubscriptionsBeyondLimitAreRefused() {
+        registry.setMaxEmitters(2);
+
+        registry.createEmitter(1L);
+        registry.createEmitter(2L);
+        final ResponseStatusException thrown =
+                assertThrows(ResponseStatusException.class, () -> 
registry.createEmitter(3L));
+
+        assertEquals(HttpStatus.SERVICE_UNAVAILABLE, thrown.getStatusCode());
+        assertEquals(2, registry.subscriptionCount());
+    }
+
+    /**
+     * The cap must not become a permanent lockout: once a dead subscription 
is cleaned up,
+     * its slot has to be available again.
+     */
+    @Test
+    void testDroppedSubscriptionFreesItsSlot() throws Exception {
+        registry.setMaxEmitters(1);
+        registry.createEmitter(1L);
+        assertThrows(ResponseStatusException.class, () -> 
registry.createEmitter(2L));
+
+        // A client that went away makes the next send fail, which is how the 
registry notices
+        final SseEmitter deadEmitter = mock(SseEmitter.class);
+        doThrow(new IllegalStateException("client 
gone")).when(deadEmitter).send(any(SseEmitter.SseEventBuilder.class));
+        replaceEmitter(1L, deadEmitter);
+
+        registry.broadcast("TEST_EVENT", "{\"id\":1}");
+
+        assertEquals(0, registry.subscriptionCount());
+        assertNotNull(registry.createEmitter(2L));
+    }
+
+    /**
+     * Nothing else frees the slot of a subscription that dies on an error: a 
stream that only
+     * broadcasts now and then would let a failed client hold its slot 
indefinitely and the
+     * ceiling would drift into a permanent lockout.
+     */
+    @Test
+    @SuppressWarnings("unchecked")
+    void testErroredSubscriptionFreesItsSlot() {
+        registry.setMaxEmitters(1);
+
+        try (MockedConstruction<SseEmitter> construction = 
mockConstruction(SseEmitter.class)) {
+            registry.createEmitter(1L);
+            final SseEmitter created = construction.constructed().get(0);
+            final ArgumentCaptor<Consumer<Throwable>> onError = 
ArgumentCaptor.forClass(Consumer.class);
+            verify(created).onError(onError.capture());
+
+            onError.getValue().accept(new IllegalStateException("client 
gone"));
+
+            assertEquals(0, registry.subscriptionCount());
+        }
+    }
+
+    /**
+     * Completing a dropped subscription is a courtesy, so a container that 
refuses it must
+     * not keep the subscription registered and its slot taken.
+     */
+    @Test
+    void testSubscriptionIsDroppedEvenWhenCompletingItFails() throws Exception 
{
+        registry.createEmitter(1L);
+        final SseEmitter unusableEmitter = mock(SseEmitter.class);
+        doThrow(new IllegalStateException("client gone"))
+                
.when(unusableEmitter).send(any(SseEmitter.SseEventBuilder.class));
+        doThrow(new RuntimeException("complete 
failed")).when(unusableEmitter).complete();
+        replaceEmitter(1L, unusableEmitter);
+
+        assertDoesNotThrow(() -> registry.broadcast("TEST_EVENT", 
"{\"id\":1}"));
+
+        assertFalse(emitters().containsKey(1L), "a subscription that cannot be 
completed still has to be dropped");
+        assertEquals(0, registry.subscriptionCount());
+    }
+
+    /**
+     * A slot is taken before the subscription exists, so anything that goes 
wrong between the
+     * two has to give it back: nothing ends up in the map to be removed 
later, and the slot
+     * would be held until the process restarts.
+     */
+    @Test
+    void testSlotIsGivenBackWhenRegisteringFails() {
+        registry.setMaxEmitters(1);
+
+        try (MockedConstruction<SseEmitter> failing = 
mockConstruction(SseEmitter.class, (mock, context) -> {
+            throw new IllegalStateException("cannot open a subscription");
+        })) {
+            // The failure is raised through mockito, so what reaches the 
caller is whatever it
+            // wraps the initializer's exception in; only that it fails 
matters here
+            assertThrows(RuntimeException.class, () -> 
registry.createEmitter(1L));
+        }
+
+        assertEquals(0, registry.subscriptionCount());
+        assertNotNull(registry.createEmitter(2L), "a failed registration must 
not spend the ceiling");
+    }
+
+    /**
+     * A caller that has no id for its subscriber cannot be registered at all, 
so it must be
+     * turned away before it takes a slot rather than after.
+     */
+    @Test
+    void testSubscriptionWithoutClientIdTakesNoSlot() {
+        assertThrows(NullPointerException.class, () -> 
registry.createEmitter(null));
+
+        assertEquals(0, registry.subscriptionCount());
+    }
+
+    /**
+     * The ceiling has to hold for subscriptions that arrive together, which 
is the only case
+     * that matters: a burst is what exhausts the thread pool, and a burst is 
exactly what a
+     * check of the current count followed by a separate registration lets 
straight through.
+     */
+    @Test
+    void testCeilingHoldsWhenSubscriptionsArriveTogether() throws Exception {
+        final int maxEmitters = 10;
+        final int racers = 200;
+        registry.setMaxEmitters(maxEmitters);
+        final CyclicBarrier allReady = new CyclicBarrier(racers);
+        final AtomicInteger accepted = new AtomicInteger();
+        final ExecutorService pool = Executors.newFixedThreadPool(racers);
+
+        try {
+            for (int i = 0; i < racers; i++) {
+                final long clientId = i;
+                pool.submit(() -> {
+                    try {
+                        // Every thread is released at the same instant, so 
they all reach the
+                        // ceiling check together
+                        allReady.await(10, TimeUnit.SECONDS);
+                        registry.createEmitter(clientId);
+                        accepted.incrementAndGet();
+                    } catch (ResponseStatusException refused) {
+                        // Expected for everyone who arrives after the ceiling 
is reached
+                    } catch (Exception e) {
+                        Thread.currentThread().interrupt();
+                    }
+                });
+            }
+            pool.shutdown();
+            assertTrue(pool.awaitTermination(30, TimeUnit.SECONDS), 
"subscriptions did not settle");
+        } finally {
+            pool.shutdownNow();
+        }
+
+        assertEquals(maxEmitters, accepted.get(), "more subscriptions were 
accepted than the ceiling allows");
+        assertEquals(maxEmitters, registry.subscriptionCount());
+    }
+
+    /**
+     * Stands in for a client that went away: the registry only learns about 
it when a send
+     * fails, which needs an emitter that fails on demand.
+     */
+    private void replaceEmitter(Long clientId, SseEmitter emitter) throws 
Exception {
+        emitters().put(clientId, emitter);
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<Long, SseEmitter> emitters() throws Exception {
+        final Field emittersField = 
SseEmitterRegistry.class.getDeclaredField("emitters");
+        emittersField.setAccessible(true);
+        return (Map<Long, SseEmitter>) emittersField.get(registry);
+    }
+}
diff --git 
a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ManagerSseManager.java
 
b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ManagerSseManager.java
index 1fd681566c..7434f28f72 100644
--- 
a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ManagerSseManager.java
+++ 
b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ManagerSseManager.java
@@ -19,80 +19,68 @@
 
 package org.apache.hertzbeat.manager.config;
 
-import lombok.extern.slf4j.Slf4j;
 import org.apache.hertzbeat.common.constants.ManagerEventTypeEnum;
 import org.apache.hertzbeat.common.entity.dto.ImportTaskMessage;
 import org.apache.hertzbeat.common.entity.dto.ManagerMessage;
+import org.apache.hertzbeat.common.support.SseEmitterRegistry;
 import org.apache.hertzbeat.common.util.JsonUtil;
 import org.springframework.scheduling.annotation.Async;
 import org.springframework.stereotype.Component;
-import 
org.springframework.web.servlet.mvc.method.annotation.ResponseBodyEmitter;
 import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
-import java.io.IOException;
-import java.util.Map;
-import java.util.Optional;
-import java.util.concurrent.ConcurrentHashMap;
-
 /**
- * Manager SSE
+ * Manager SSE.
+ *
+ * <p>Note: the lifecycle of a subscription - its timeout, the ceiling on how 
many may be held
+ * and the cleanup of the ones that went away - belongs to {@link 
SseEmitterRegistry}; what is
+ * manager specific is only the events these subscribers are waiting for.
  */
-@Slf4j
 @Component
 public class ManagerSseManager {
-    private final Map<Long, SseEmitter> emitters = new ConcurrentHashMap<>();
 
+    private final SseEmitterRegistry registry = new 
SseEmitterRegistry("manager");
+
+    /**
+     * Registers a subscription for the given client.
+     *
+     * @param clientId Identifier of the subscriber, unique per subscription
+     * @return The emitter the controller returns to spring
+     */
     public SseEmitter createEmitter(Long clientId) {
-        SseEmitter emitter = new SseEmitter(Long.MAX_VALUE);
-        emitter.onCompletion(() -> removeEmitter(clientId));
-        emitter.onTimeout(() -> removeEmitter(clientId));
-        emitters.put(clientId, emitter);
-        return emitter;
+        return registry.createEmitter(clientId);
     }
 
+    /**
+     * Delivers one manager event to every live subscriber.
+     *
+     * @param eventName Name of the sse event, which is what the ui subscribes 
by
+     * @param data Serialised payload
+     */
     @Async
     public void broadcast(String eventName, String data) {
-        emitters.forEach((clientId, emitter) -> {
-            try {
-                emitter.send(SseEmitter.event()
-                        .id(String.valueOf(System.currentTimeMillis()))
-                        .name(eventName)
-                        .data(data));
-            } catch (IOException | IllegalStateException e) {
-                tryCompleteAndClean(clientId, emitter);
-            } catch (Exception exception) {
-                log.error("Failed to broadcast manager message data to client: 
{}", exception.getMessage());
-                tryCompleteAndClean(clientId, emitter);
-            }
-        });
+        registry.broadcast(eventName, data);
     }
 
-    private void tryCompleteAndClean(Long clientId, SseEmitter emitter) {
-        try {
-            
Optional.ofNullable(emitter).ifPresent(ResponseBodyEmitter::complete);
-        } catch (Throwable e) {
-            log.debug("Failed to complete emitter for client {}: {}", 
clientId, e.getMessage());
-        }
-        // execute clear
-        removeEmitter(clientId);
+    public void broadcastImportTaskInProgress(String taskName, Integer 
progress) {
+        final ManagerMessage managerMessage = 
ImportTaskMessage.createInProgressMessage(taskName, progress);
+        broadcast(ManagerEventTypeEnum.IMPORT_TASK_EVENT.getValue(), 
JsonUtil.toJson(managerMessage));
     }
 
-    public void broadcastImportTaskInProgress(String taskName, Integer 
progress){
-        ManagerMessage managerMessage = 
ImportTaskMessage.createInProgressMessage(taskName, progress);
+    public void broadcastImportTaskSuccess(String taskName) {
+        final ManagerMessage managerMessage = 
ImportTaskMessage.createCompletedMessage(taskName);
         broadcast(ManagerEventTypeEnum.IMPORT_TASK_EVENT.getValue(), 
JsonUtil.toJson(managerMessage));
     }
 
-    public void broadcastImportTaskSuccess(String taskName){
-        ManagerMessage managerMessage = 
ImportTaskMessage.createCompletedMessage(taskName);
+    public void broadcastImportTaskFail(String taskName, String errMsg) {
+        final ManagerMessage managerMessage = 
ImportTaskMessage.createFailedMessage(taskName, errMsg);
         broadcast(ManagerEventTypeEnum.IMPORT_TASK_EVENT.getValue(), 
JsonUtil.toJson(managerMessage));
     }
 
-    public void broadcastImportTaskFail(String taskName, String errMsg){
-        ManagerMessage managerMessage = 
ImportTaskMessage.createFailedMessage(taskName, errMsg);
-        broadcast(ManagerEventTypeEnum.IMPORT_TASK_EVENT.getValue(), 
JsonUtil.toJson(managerMessage));
+    void setMaxEmitters(int maxEmitters) {
+        registry.setMaxEmitters(maxEmitters);
     }
 
-    private void removeEmitter(Long clientId) {
-        emitters.remove(clientId);
+    int subscriptionCount() {
+        return registry.subscriptionCount();
     }
-}
\ No newline at end of file
+}
diff --git 
a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ManagerSseManagerTest.java
 
b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ManagerSseManagerTest.java
new file mode 100644
index 0000000000..498e4739f2
--- /dev/null
+++ 
b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ManagerSseManagerTest.java
@@ -0,0 +1,101 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hertzbeat.manager.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import java.lang.reflect.Field;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.apache.hertzbeat.common.constants.ManagerEventTypeEnum;
+import org.apache.hertzbeat.common.support.SseEmitterRegistry;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.http.HttpStatus;
+import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+/**
+ * Test case for {@link ManagerSseManager}.
+ *
+ * <p>Note: how a subscription is bounded and cleaned up is covered by
+ * {@code SseEmitterRegistryTest}; what is left here is what makes this stream 
the manager one.
+ */
+class ManagerSseManagerTest {
+
+    private ManagerSseManager managerSseManager;
+
+    @BeforeEach
+    void setUp() {
+        managerSseManager = new ManagerSseManager();
+    }
+
+    /**
+     * The ui subscribes by event name, so import progress delivered under any 
other name
+     * reaches nobody even though the connection is up.
+     */
+    @Test
+    void testImportProgressIsDeliveredUnderTheImportTaskEventName() throws 
Exception {
+        managerSseManager.createEmitter(1L);
+        final SseEmitter subscriber = mock(SseEmitter.class);
+        emitters().put(1L, subscriber);
+
+        managerSseManager.broadcastImportTaskSuccess("my-task");
+
+        final ArgumentCaptor<SseEmitter.SseEventBuilder> event =
+                ArgumentCaptor.forClass(SseEmitter.SseEventBuilder.class);
+        verify(subscriber).send(event.capture());
+        final String rendered = event.getValue().build().stream()
+                .map(part -> String.valueOf(part.getData()))
+                .collect(Collectors.joining());
+        assertTrue(rendered.contains("event:" + 
ManagerEventTypeEnum.IMPORT_TASK_EVENT.getValue()),
+                "import progress must be delivered as IMPORT_TASK_EVENT, was " 
+ rendered);
+        assertTrue(rendered.contains("my-task"), "the task name must reach the 
subscriber, was " + rendered);
+    }
+
+    /**
+     * The manager has to hand its subscriptions to a registry rather than 
hold them itself,
+     * otherwise none of the bounds that registry enforces apply to this 
stream.
+     */
+    @Test
+    void testSubscriptionsAreBoundedByTheRegistry() {
+        managerSseManager.setMaxEmitters(1);
+
+        assertNotNull(managerSseManager.createEmitter(1L));
+        final ResponseStatusException thrown =
+                assertThrows(ResponseStatusException.class, () -> 
managerSseManager.createEmitter(2L));
+
+        assertEquals(HttpStatus.SERVICE_UNAVAILABLE, thrown.getStatusCode());
+        assertEquals(1, managerSseManager.subscriptionCount());
+    }
+
+    @SuppressWarnings("unchecked")
+    private Map<Long, SseEmitter> emitters() throws Exception {
+        final Field registryField = 
ManagerSseManager.class.getDeclaredField("registry");
+        registryField.setAccessible(true);
+        final Object registry = registryField.get(managerSseManager);
+        final Field emittersField = 
SseEmitterRegistry.class.getDeclaredField("emitters");
+        emittersField.setAccessible(true);
+        return (Map<Long, SseEmitter>) emittersField.get(registry);
+    }
+}
diff --git a/hertzbeat-startup/src/main/resources/sureness.yml 
b/hertzbeat-startup/src/main/resources/sureness.yml
index 5aa2f8a26e..9485a76b54 100644
--- a/hertzbeat-startup/src/main/resources/sureness.yml
+++ b/hertzbeat-startup/src/main/resources/sureness.yml
@@ -129,19 +129,21 @@ resourceRole:
   - /swagger-resources/**===get===[admin]
   # spring boot actuator exposes jvm, http and datasource internals for 
scraping
   - /actuator/**===get===[admin]
+  # the alert stream carries full alert payloads and the manager stream 
carries import
+  # progress; both are scoped like the log stream above rather than left 
anonymous
+  - /api/alert/sse/**===get===[admin,user,guest]
+  - /api/manager/sse/**===get===[admin,user,guest]
 
 # config the resource restful api that need bypass auth protection
 # rule: api===method
 # eg: /api/v1/source3===get means /api/v1/source3===get can be access by 
anyone, no need auth.
 excludedResource:
-  - /api/alert/sse/**===*
   - /api/account/auth/**===*
   - /api/i18n/**===get
   - /api/apps/hierarchy===get
   - /api/observability/capability===get
   - /api/push/**===*
   - /api/status/page/public/**===*
-  - /api/manager/sse/**===*
   # web ui resource
   - /===get
   - /assets/**===get
diff --git 
a/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/security/SurenessSseRuleTest.java
 
b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/security/SurenessSseRuleTest.java
new file mode 100644
index 0000000000..f8dba6e5e1
--- /dev/null
+++ 
b/hertzbeat-startup/src/test/java/org/apache/hertzbeat/startup/security/SurenessSseRuleTest.java
@@ -0,0 +1,103 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hertzbeat.startup.security;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import com.usthe.sureness.matcher.util.TirePathTree;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+import org.yaml.snakeyaml.Yaml;
+
+/**
+ * Guards the rbac rules covering the server sent event streams.
+ *
+ * <p>`/api/alert/sse/**` and `/api/manager/sse/**` used to sit in 
`excludedResource`.
+ * Sureness evaluates the exclusion tree before any credential check, so an 
anonymous
+ * `curl -N` stayed subscribed and received every alert the deployment raised 
- internal
+ * hostnames, addresses, metric values and alert content - because
+ * `AlertNoticeDispatch` broadcasts each alert to every subscriber with no per 
subscriber
+ * filtering. `/api/logs/sse/**` was already scoped this way; these now match 
it.
+ */
+class SurenessSseRuleTest {
+
+    private static final String SEPARATOR = "===";
+
+    private static final String EXCLUDE_ROLE = SEPARATOR + "[exclude]";
+
+    private static TirePathTree roleTree;
+
+    private static TirePathTree excludeTree;
+
+    @BeforeAll
+    @SuppressWarnings("unchecked")
+    static void loadSurenessConfig() throws IOException {
+        List<String> resourceRole;
+        List<String> excludedResource;
+        try (InputStream in = 
SurenessSseRuleTest.class.getResourceAsStream("/sureness.yml")) {
+            assertNotNull(in, "sureness.yml must be on the classpath");
+            Map<String, Object> document = new Yaml().load(in);
+            resourceRole = (List<String>) document.get("resourceRole");
+            excludedResource = (List<String>) document.get("excludedResource");
+        }
+        assertNotNull(resourceRole, "resourceRole must be present");
+        assertNotNull(excludedResource, "excludedResource must be present");
+        roleTree = new TirePathTree();
+        roleTree.buildTree(new LinkedHashSet<>(resourceRole));
+        excludeTree = new TirePathTree();
+        excludeTree.buildTree(excludedResource.stream()
+                .map(rule -> rule + EXCLUDE_ROLE)
+                .collect(Collectors.toCollection(LinkedHashSet::new)));
+    }
+
+    @Test
+    void subscribingToAlertsRequiresAnAccount() {
+        assertEquals("[admin,user,guest]",
+                roleTree.searchPathFilterRoles("/api/alert/sse/subscribe" + 
SEPARATOR + "get"));
+    }
+
+    @Test
+    void subscribingToManagerEventsRequiresAnAccount() {
+        assertEquals("[admin,user,guest]",
+                roleTree.searchPathFilterRoles("/api/manager/sse/subscribe" + 
SEPARATOR + "get"));
+    }
+
+    /**
+     * The rule above only takes effect if the path stops matching an 
exclusion: sureness
+     * returns from `checkIn` as soon as `isExcludedResource` matches, before 
any credential
+     * is looked at.
+     */
+    @Test
+    void theStreamsAreNoLongerAnonymous() {
+        
assertNull(excludeTree.searchPathFilterRoles("/api/alert/sse/subscribe" + 
SEPARATOR + "get"));
+        
assertNull(excludeTree.searchPathFilterRoles("/api/manager/sse/subscribe" + 
SEPARATOR + "get"));
+    }
+
+    @Test
+    void theLogStreamScopingIsUnchanged() {
+        assertEquals("[admin,user,guest]",
+                roleTree.searchPathFilterRoles("/api/logs/sse/subscribe" + 
SEPARATOR + "get"));
+    }
+}
diff --git a/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml 
b/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml
index 1815529be7..4fb6e2f1f5 100644
--- a/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml
+++ b/script/docker-compose/hertzbeat-mysql-iotdb/conf/sureness.yml
@@ -118,19 +118,21 @@ resourceRole:
   - /swagger-resources/**===get===[admin]
   # spring boot actuator exposes jvm, http and datasource internals for 
scraping
   - /actuator/**===get===[admin]
+  # the alert stream carries full alert payloads and the manager stream 
carries import
+  # progress; both are scoped like the log stream above rather than left 
anonymous
+  - /api/alert/sse/**===get===[admin,user,guest]
+  - /api/manager/sse/**===get===[admin,user,guest]
 
 # config the resource restful api that need bypass auth protection
 # rule: api===method
 # eg: /api/v1/source3===get means /api/v1/source3===get can be access by 
anyone, no need auth.
 excludedResource:
-  - /api/alert/sse/**===*
   - /api/account/auth/**===*
   - /api/i18n/**===get
   - /api/apps/hierarchy===get
   - /api/observability/capability===get
   - /api/push/**===*
   - /api/status/page/public/**===*
-  - /api/manager/sse/**===*
   # web ui resource
   - /===get
   - /assets/**===get
diff --git a/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml 
b/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml
index 1815529be7..4fb6e2f1f5 100644
--- a/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml
+++ b/script/docker-compose/hertzbeat-mysql-tdengine/conf/sureness.yml
@@ -118,19 +118,21 @@ resourceRole:
   - /swagger-resources/**===get===[admin]
   # spring boot actuator exposes jvm, http and datasource internals for 
scraping
   - /actuator/**===get===[admin]
+  # the alert stream carries full alert payloads and the manager stream 
carries import
+  # progress; both are scoped like the log stream above rather than left 
anonymous
+  - /api/alert/sse/**===get===[admin,user,guest]
+  - /api/manager/sse/**===get===[admin,user,guest]
 
 # config the resource restful api that need bypass auth protection
 # rule: api===method
 # eg: /api/v1/source3===get means /api/v1/source3===get can be access by 
anyone, no need auth.
 excludedResource:
-  - /api/alert/sse/**===*
   - /api/account/auth/**===*
   - /api/i18n/**===get
   - /api/apps/hierarchy===get
   - /api/observability/capability===get
   - /api/push/**===*
   - /api/status/page/public/**===*
-  - /api/manager/sse/**===*
   # web ui resource
   - /===get
   - /assets/**===get
diff --git 
a/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml 
b/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml
index 1815529be7..4fb6e2f1f5 100644
--- a/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml
+++ b/script/docker-compose/hertzbeat-mysql-victoria-metrics/conf/sureness.yml
@@ -118,19 +118,21 @@ resourceRole:
   - /swagger-resources/**===get===[admin]
   # spring boot actuator exposes jvm, http and datasource internals for 
scraping
   - /actuator/**===get===[admin]
+  # the alert stream carries full alert payloads and the manager stream 
carries import
+  # progress; both are scoped like the log stream above rather than left 
anonymous
+  - /api/alert/sse/**===get===[admin,user,guest]
+  - /api/manager/sse/**===get===[admin,user,guest]
 
 # config the resource restful api that need bypass auth protection
 # rule: api===method
 # eg: /api/v1/source3===get means /api/v1/source3===get can be access by 
anyone, no need auth.
 excludedResource:
-  - /api/alert/sse/**===*
   - /api/account/auth/**===*
   - /api/i18n/**===get
   - /api/apps/hierarchy===get
   - /api/observability/capability===get
   - /api/push/**===*
   - /api/status/page/public/**===*
-  - /api/manager/sse/**===*
   # web ui resource
   - /===get
   - /assets/**===get
diff --git 
a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml 
b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml
index 6281d0927d..5f170b6469 100644
--- a/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml
+++ b/script/docker-compose/hertzbeat-postgresql-greptimedb/conf/sureness.yml
@@ -122,19 +122,21 @@ resourceRole:
   - /swagger-resources/**===get===[admin]
   # spring boot actuator exposes jvm, http and datasource internals for 
scraping
   - /actuator/**===get===[admin]
+  # the alert stream carries full alert payloads and the manager stream 
carries import
+  # progress; both are scoped like the log stream above rather than left 
anonymous
+  - /api/alert/sse/**===get===[admin,user,guest]
+  - /api/manager/sse/**===get===[admin,user,guest]
 
 # config the resource restful api that need bypass auth protection
 # rule: api===method
 # eg: /api/v1/source3===get means /api/v1/source3===get can be access by 
anyone, no need auth.
 excludedResource:
-  - /api/alert/sse/**===*
   - /api/account/auth/**===*
   - /api/i18n/**===get
   - /api/apps/hierarchy===get
   - /api/observability/capability===get
   - /api/push/**===*
   - /api/status/page/public/**===*
-  - /api/manager/sse/**===*
   # web ui resource
   - /===get
   - /assets/**===get
diff --git 
a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml 
b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml
index 1815529be7..4fb6e2f1f5 100644
--- 
a/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml
+++ 
b/script/docker-compose/hertzbeat-postgresql-victoria-metrics/conf/sureness.yml
@@ -118,19 +118,21 @@ resourceRole:
   - /swagger-resources/**===get===[admin]
   # spring boot actuator exposes jvm, http and datasource internals for 
scraping
   - /actuator/**===get===[admin]
+  # the alert stream carries full alert payloads and the manager stream 
carries import
+  # progress; both are scoped like the log stream above rather than left 
anonymous
+  - /api/alert/sse/**===get===[admin,user,guest]
+  - /api/manager/sse/**===get===[admin,user,guest]
 
 # config the resource restful api that need bypass auth protection
 # rule: api===method
 # eg: /api/v1/source3===get means /api/v1/source3===get can be access by 
anyone, no need auth.
 excludedResource:
-  - /api/alert/sse/**===*
   - /api/account/auth/**===*
   - /api/i18n/**===get
   - /api/apps/hierarchy===get
   - /api/observability/capability===get
   - /api/push/**===*
   - /api/status/page/public/**===*
-  - /api/manager/sse/**===*
   # web ui resource
   - /===get
   - /assets/**===get
diff --git a/script/sureness.yml b/script/sureness.yml
index 6281d0927d..5f170b6469 100644
--- a/script/sureness.yml
+++ b/script/sureness.yml
@@ -122,19 +122,21 @@ resourceRole:
   - /swagger-resources/**===get===[admin]
   # spring boot actuator exposes jvm, http and datasource internals for 
scraping
   - /actuator/**===get===[admin]
+  # the alert stream carries full alert payloads and the manager stream 
carries import
+  # progress; both are scoped like the log stream above rather than left 
anonymous
+  - /api/alert/sse/**===get===[admin,user,guest]
+  - /api/manager/sse/**===get===[admin,user,guest]
 
 # config the resource restful api that need bypass auth protection
 # rule: api===method
 # eg: /api/v1/source3===get means /api/v1/source3===get can be access by 
anyone, no need auth.
 excludedResource:
-  - /api/alert/sse/**===*
   - /api/account/auth/**===*
   - /api/i18n/**===get
   - /api/apps/hierarchy===get
   - /api/observability/capability===get
   - /api/push/**===*
   - /api/status/page/public/**===*
-  - /api/manager/sse/**===*
   # web ui resource
   - /===get
   - /assets/**===get
diff --git a/web-app/src/app/core/interceptor/default.interceptor.spec.ts 
b/web-app/src/app/core/interceptor/default.interceptor.spec.ts
new file mode 100644
index 0000000000..cc422222fe
--- /dev/null
+++ b/web-app/src/app/core/interceptor/default.interceptor.spec.ts
@@ -0,0 +1,124 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { HttpContext, HttpErrorResponse, HttpHandler, HttpRequest, 
HttpResponse } from '@angular/common/http';
+import { Injector } from '@angular/core';
+import { Router } from '@angular/router';
+import { ALAIN_I18N_TOKEN } from '@delon/theme';
+import { of, throwError } from 'rxjs';
+
+import { AuthService } from '../../service/auth.service';
+import { LocalStorageService } from '../../service/local-storage.service';
+import { DefaultInterceptor } from './default.interceptor';
+import { SILENT_HTTP_ERROR } from './http-context';
+
+describe('DefaultInterceptor', () => {
+  it('does not redirect when a silent refresh request is refused', done => {
+    const router = jasmine.createSpyObj<Router>('Router', ['navigateByUrl']);
+    const injector = {
+      get: (token: unknown) => {
+        if (token === ALAIN_I18N_TOKEN) {
+          return { currentLang: 'en-US' };
+        }
+        if (token === Router) {
+          return router;
+        }
+        throw new Error(`Unexpected dependency: ${String(token)}`);
+      }
+    } as Injector;
+    const authService = jasmine.createSpyObj<AuthService>('AuthService', 
['refreshToken']);
+    const localStorageService = 
jasmine.createSpyObj<LocalStorageService>('LocalStorageService', [
+      'getAuthorizationToken',
+      'getRefreshToken'
+    ]);
+    localStorageService.getAuthorizationToken.and.returnValue(null as any);
+    const interceptor = new DefaultInterceptor(injector, authService, 
localStorageService);
+    const request = new HttpRequest(
+      'POST',
+      '/account/auth/refresh',
+      {},
+      {
+        context: new HttpContext().set(SILENT_HTTP_ERROR, true)
+      }
+    );
+    const next = {
+      handle: () => throwError(() => new HttpErrorResponse({ status: 401, url: 
request.url }))
+    } as HttpHandler;
+
+    interceptor.intercept(request, next).subscribe({
+      error: error => {
+        expect(error.status).toBe(401);
+        setTimeout(() => {
+          expect(router.navigateByUrl).not.toHaveBeenCalled();
+          done();
+        });
+      }
+    });
+  });
+
+  it('still refreshes and replays an ordinary silent request after a 401', () 
=> {
+    const injector = {
+      get: (token: unknown) => {
+        if (token === ALAIN_I18N_TOKEN) {
+          return { currentLang: 'en-US' };
+        }
+        throw new Error(`Unexpected dependency: ${String(token)}`);
+      }
+    } as Injector;
+    const authService = jasmine.createSpyObj<AuthService>('AuthService', 
['refreshToken']);
+    authService.refreshToken.and.returnValue(
+      of({ code: 0, msg: '', data: { token: 'fresh-token', refreshToken: 
'next-refresh-token' } } as any)
+    );
+    const localStorageService = 
jasmine.createSpyObj<LocalStorageService>('LocalStorageService', [
+      'getAuthorizationToken',
+      'getRefreshToken',
+      'storageAuthorizationToken',
+      'storageRefreshToken'
+    ]);
+    localStorageService.getAuthorizationToken.and.returnValue('expired-token');
+    localStorageService.getRefreshToken.and.returnValue('a-refresh-token');
+    const interceptor = new DefaultInterceptor(injector, authService, 
localStorageService);
+    const request = new HttpRequest(
+      'POST',
+      '/api/observability/metrics',
+      {},
+      {
+        context: new HttpContext().set(SILENT_HTTP_ERROR, true)
+      }
+    );
+    const next = jasmine.createSpyObj<HttpHandler>('HttpHandler', ['handle']);
+    next.handle.and.returnValues(
+      throwError(() => new HttpErrorResponse({ status: 401, url: request.url 
})),
+      of(new HttpResponse({ status: 200 }))
+    );
+
+    let response: HttpResponse<unknown> | undefined;
+    interceptor.intercept(request, next).subscribe(event => {
+      if (event instanceof HttpResponse) {
+        response = event;
+      }
+    });
+
+    expect(response?.status).toBe(200);
+    expect(authService.refreshToken).toHaveBeenCalledWith('a-refresh-token');
+    
expect(localStorageService.storageAuthorizationToken).toHaveBeenCalledWith('fresh-token');
+    
expect(localStorageService.storageRefreshToken).toHaveBeenCalledWith('next-refresh-token');
+    expect(next.handle).toHaveBeenCalledTimes(2);
+  });
+});
diff --git a/web-app/src/app/core/interceptor/default.interceptor.ts 
b/web-app/src/app/core/interceptor/default.interceptor.ts
index aba4d01ad3..85074f38de 100644
--- a/web-app/src/app/core/interceptor/default.interceptor.ts
+++ b/web-app/src/app/core/interceptor/default.interceptor.ts
@@ -86,7 +86,9 @@ export class DefaultInterceptor implements HttpInterceptor {
   private tryRefreshToken(ev: HttpResponseBase, req: HttpRequest<any>, next: 
HttpHandler): Observable<any> {
     // 1, redirect to login page if this request is used for refreshing token
     if ([`/account/auth/refresh`].some(url => req.url.includes(url))) {
-      this.toLogin();
+      if (!req.context.get(SILENT_HTTP_ERROR)) {
+        this.toLogin();
+      }
       return throwError(ev);
     }
     // 2, if `refreshToking` is true, means that the refreshing token request 
is in progress
@@ -202,7 +204,7 @@ export class DefaultInterceptor implements HttpInterceptor {
         if (!silentError) {
           this.checkStatus(err);
         }
-        return throwError(err.error);
+        return throwError(silentError ? err : err.error);
       })
     );
   }
diff --git a/web-app/src/app/layout/basic/widgets/notify.component.ts 
b/web-app/src/app/layout/basic/widgets/notify.component.ts
index 022ff9a0e7..abb4b329b0 100644
--- a/web-app/src/app/layout/basic/widgets/notify.component.ts
+++ b/web-app/src/app/layout/basic/widgets/notify.component.ts
@@ -1,15 +1,17 @@
-import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, 
OnInit, OnDestroy } from '@angular/core';
+import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, 
NgZone, OnInit, OnDestroy } from '@angular/core';
 import { Router } from '@angular/router';
 import { I18NService } from '@core';
 import { ALAIN_I18N_TOKEN } from '@delon/theme';
 import { NzModalService } from 'ng-zorro-antd/modal';
 import { NzNotificationService } from 'ng-zorro-antd/notification';
+import { Subscription } from 'rxjs';
 import { finalize } from 'rxjs/operators';
 
 import { Mute } from '../../../pojo/Mute';
 import { SingleAlert } from '../../../pojo/SingleAlert';
 import { AlertSoundService } from '../../../service/alert-sound.service';
 import { AlertService } from '../../../service/alert.service';
+import { AuthorizedSseService } from '../../../service/authorized-sse.service';
 import { GeneralConfigService } from '../../../service/general-config.service';
 
 @Component({
@@ -125,7 +127,8 @@ export class HeaderNotifyComponent implements OnInit, 
OnDestroy {
   private previousCount = 0;
   // default to mute status
   mute: Mute = { mute: true };
-  private eventSource!: EventSource;
+  private alertStream$!: Subscription;
+  private managerStream$!: Subscription;
   constructor(
     private router: Router,
     @Inject(ALAIN_I18N_TOKEN) private i18nSvc: I18NService,
@@ -134,7 +137,9 @@ export class HeaderNotifyComponent implements OnInit, 
OnDestroy {
     private alertSvc: AlertService,
     private modal: NzModalService,
     private cdr: ChangeDetectorRef,
-    private alertSound: AlertSoundService
+    private alertSound: AlertSoundService,
+    private authorizedSseSvc: AuthorizedSseService,
+    private ngZone: NgZone
   ) {}
 
   ngOnInit(): void {
@@ -166,9 +171,10 @@ export class HeaderNotifyComponent implements OnInit, 
OnDestroy {
     if (this.refreshInterval) {
       clearInterval(this.refreshInterval);
     }
-    if (this.eventSource) {
-      this.eventSource.close();
-    }
+    // both streams are unsubscribed: they used to share one field, so the 
alert connection
+    // was never closed once the manager one overwrote it
+    this.alertStream$?.unsubscribe();
+    this.managerStream$?.unsubscribe();
   }
 
   onPopoverVisibleChange(visible: boolean): void {
@@ -285,74 +291,70 @@ export class HeaderNotifyComponent implements OnInit, 
OnDestroy {
   }
 
   private initAlertSSEConnection(): void {
-    const sseUrl = '/api/alert/sse/subscribe';
-
-    this.eventSource = new EventSource(sseUrl);
-
-    this.eventSource.addEventListener('ALERT_EVENT', (evt: MessageEvent) => {
-      let list: any[] = [];
-      let alert: SingleAlert = JSON.parse(evt.data);
-      let item = {
-        id: alert.id,
-        avatar: '/assets/img/notification.svg',
-        // title: 
`${alert.tags?.monitorName}--${this.i18nSvc.fanyi(`alert.severity.${alert.severity}`)}`,
-        title: alert.content,
-        datetime: new Date(alert.activeAt).toLocaleString(),
-        color: 'blue',
-        status: alert.status,
-        type: this.i18nSvc.fanyi('dashboard.alerts.title-no')
-      };
-      console.log('alert:', alert);
-      list.push(item);
-      this.data = this.updateNoticeData(list);
-      if (!this.mute.mute && !this.notifiedAlert.includes(alert.id)) {
-        this.notifiedAlert.push(alert.id);
-        this.alertSound.playAlertSound(this.i18nSvc.currentLang);
-        const notification = new 
Notification(this.i18nSvc.fanyi('alert.notify.title'), {
-          body: this.i18nSvc.fanyi('alert.notify.body'),
-          icon: 'assets/logo.svg'
-        });
-        notification.onclick = () => {
-          window.focus();
-          this.router.navigateByUrl(`/alert/center`);
-          notification.close();
-        };
-      }
-      this.cdr.detectChanges();
+    // read through AuthorizedSseService rather than EventSource: the stream 
now requires a
+    // credential, and EventSource cannot carry an Authorization header
+    this.alertStream$ = 
this.authorizedSseSvc.stream('/api/alert/sse/subscribe', 
'ALERT_EVENT').subscribe({
+      next: data =>
+        this.ngZone.run(() => {
+          let list: any[] = [];
+          let alert: SingleAlert = JSON.parse(data);
+          let item = {
+            id: alert.id,
+            avatar: '/assets/img/notification.svg',
+            // title: 
`${alert.tags?.monitorName}--${this.i18nSvc.fanyi(`alert.severity.${alert.severity}`)}`,
+            title: alert.content,
+            datetime: new Date(alert.activeAt).toLocaleString(),
+            color: 'blue',
+            status: alert.status,
+            type: this.i18nSvc.fanyi('dashboard.alerts.title-no')
+          };
+          console.log('alert:', alert);
+          list.push(item);
+          this.data = this.updateNoticeData(list);
+          if (!this.mute.mute && !this.notifiedAlert.includes(alert.id)) {
+            this.notifiedAlert.push(alert.id);
+            this.alertSound.playAlertSound(this.i18nSvc.currentLang);
+            const notification = new 
Notification(this.i18nSvc.fanyi('alert.notify.title'), {
+              body: this.i18nSvc.fanyi('alert.notify.body'),
+              icon: 'assets/logo.svg'
+            });
+            notification.onclick = () => {
+              window.focus();
+              this.router.navigateByUrl(`/alert/center`);
+              notification.close();
+            };
+          }
+          this.cdr.detectChanges();
+        }),
+      error: error => console.error('SSE connection error:', error)
     });
-    this.eventSource.onerror = error => {
-      console.error('SSE connection error:', error);
-      this.eventSource.close();
-    };
   }
 
   private initManagerSSEConnection(): void {
-    const sseUrl = '/api/manager/sse/subscribe';
-    this.eventSource = new EventSource(sseUrl);
-    this.eventSource.addEventListener('IMPORT_TASK_EVENT', (evt: MessageEvent) 
=> {
-      let msg = JSON.parse(evt.data);
-      if (msg.notifyLevel === 'SUCCESS') {
-        this.notifySvc.success(
-          this.i18nSvc.fanyi('common.notice'),
-          this.i18nSvc.fanyi('common.notify.import-success-detail', { 
taskName: msg.taskName })
-        );
-      } else if (msg.notifyLevel === 'ERROR') {
-        this.notifySvc.error(
-          this.i18nSvc.fanyi('common.notice'),
-          this.i18nSvc.fanyi('common.notify.import-fail-detail', { taskName: 
msg.taskName, errMsg: msg.errMsg })
-        );
-      } else if (msg.notifyLevel === 'INFO') {
-        this.notifySvc.info(
-          this.i18nSvc.fanyi('common.notice'),
-          this.i18nSvc.fanyi('common.notify.import-progress', { taskName: 
msg.taskName, progress: msg.progress })
-        );
-      } else {
-        console.error('Parse message error, msg:', evt.data);
-      }
+    this.managerStream$ = 
this.authorizedSseSvc.stream('/api/manager/sse/subscribe', 
'IMPORT_TASK_EVENT').subscribe({
+      next: data =>
+        this.ngZone.run(() => {
+          let msg = JSON.parse(data);
+          if (msg.notifyLevel === 'SUCCESS') {
+            this.notifySvc.success(
+              this.i18nSvc.fanyi('common.notice'),
+              this.i18nSvc.fanyi('common.notify.import-success-detail', { 
taskName: msg.taskName })
+            );
+          } else if (msg.notifyLevel === 'ERROR') {
+            this.notifySvc.error(
+              this.i18nSvc.fanyi('common.notice'),
+              this.i18nSvc.fanyi('common.notify.import-fail-detail', { 
taskName: msg.taskName, errMsg: msg.errMsg })
+            );
+          } else if (msg.notifyLevel === 'INFO') {
+            this.notifySvc.info(
+              this.i18nSvc.fanyi('common.notice'),
+              this.i18nSvc.fanyi('common.notify.import-progress', { taskName: 
msg.taskName, progress: msg.progress })
+            );
+          } else {
+            console.error('Parse message error, msg:', data);
+          }
+        }),
+      error: error => console.error('Manager SSE connection error:', error)
     });
-    this.eventSource.onerror = error => {
-      console.error('Manager SSE connection error:', error);
-      this.eventSource.close();
-    };
   }
 }
diff --git 
a/web-app/src/app/routes/alert/alert-center/alert-center.component.ts 
b/web-app/src/app/routes/alert/alert-center/alert-center.component.ts
index 2d734e89fb..6c94f3e184 100644
--- a/web-app/src/app/routes/alert/alert-center/alert-center.component.ts
+++ b/web-app/src/app/routes/alert/alert-center/alert-center.component.ts
@@ -17,14 +17,16 @@
  * under the License.
  */
 
-import { Component, Inject, OnDestroy, OnInit } from '@angular/core';
+import { Component, Inject, NgZone, OnDestroy, OnInit } from '@angular/core';
 import { I18NService } from '@core';
 import { ALAIN_I18N_TOKEN } from '@delon/theme';
 import { NzModalService } from 'ng-zorro-antd/modal';
 import { NzNotificationService } from 'ng-zorro-antd/notification';
+import { Subscription } from 'rxjs';
 
 import { GroupAlert } from '../../../pojo/GroupAlert';
 import { AlertService } from '../../../service/alert.service';
+import { AuthorizedSseService } from '../../../service/authorized-sse.service';
 
 interface ExtendedGroupAlert extends GroupAlert {
   isNew?: boolean;
@@ -39,6 +41,8 @@ export class AlertCenterComponent implements OnInit, 
OnDestroy {
     private notifySvc: NzNotificationService,
     private modal: NzModalService,
     private alertSvc: AlertService,
+    private authorizedSseSvc: AuthorizedSseService,
+    private ngZone: NgZone,
     @Inject(ALAIN_I18N_TOKEN) private i18nSvc: I18NService
   ) {}
 
@@ -50,7 +54,7 @@ export class AlertCenterComponent implements OnInit, 
OnDestroy {
   checkedAlertIds = new Set<number>();
   filterStatus!: string;
   filterContent: string | undefined;
-  private eventSource!: EventSource;
+  private alertStream$!: Subscription;
 
   ngOnInit(): void {
     this.loadAlertsTable();
@@ -58,28 +62,24 @@ export class AlertCenterComponent implements OnInit, 
OnDestroy {
   }
 
   ngOnDestroy(): void {
-    if (this.eventSource) {
-      this.eventSource.close();
-    }
+    this.alertStream$?.unsubscribe();
   }
 
   // Initialize SSE subscription for real-time alerts
   private initSSESubscription(): void {
-    this.eventSource = new EventSource('/api/alert/sse/subscribe');
-    this.eventSource.addEventListener('ALERT_EVENT', (evt: MessageEvent) => {
-      try {
-        const newAlert: GroupAlert = JSON.parse(evt.data);
-        this.updateAlertList(newAlert);
-      } catch (error) {
-        console.error('Error parsing SSE data:', error);
-      }
+    // read through AuthorizedSseService rather than EventSource: the stream 
now requires a
+    // credential, and EventSource cannot carry an Authorization header
+    this.alertStream$ = 
this.authorizedSseSvc.stream('/api/alert/sse/subscribe', 
'ALERT_EVENT').subscribe({
+      next: data => {
+        try {
+          const newAlert: GroupAlert = JSON.parse(data);
+          this.ngZone.run(() => this.updateAlertList(newAlert));
+        } catch (error) {
+          console.error('Error parsing SSE data:', error);
+        }
+      },
+      error: error => console.error('SSE connection error:', error)
     });
-
-    // Handle SSE errors
-    this.eventSource.onerror = error => {
-      console.error('SSE connection error:', error);
-      this.eventSource.close();
-    };
   }
 
   private updateAlertList(newAlert: GroupAlert): void {
diff --git a/web-app/src/app/service/auth.service.spec.ts 
b/web-app/src/app/service/auth.service.spec.ts
index 650102d387..85008b856f 100644
--- a/web-app/src/app/service/auth.service.spec.ts
+++ b/web-app/src/app/service/auth.service.spec.ts
@@ -17,9 +17,11 @@
  * under the License.
  */
 
+import { HttpTestingController } from '@angular/common/http/testing';
 import { TestBed } from '@angular/core/testing';
 import { configureHttpServiceTest } from '@testing';
 
+import { SILENT_HTTP_ERROR } from '../core/interceptor/http-context';
 import { AuthService } from './auth.service';
 
 describe('AuthService', () => {
@@ -33,4 +35,14 @@ describe('AuthService', () => {
   it('should be created', () => {
     expect(service).toBeTruthy();
   });
+
+  it('can make a refresh request without global HTTP error handling', () => {
+    const http = TestBed.inject(HttpTestingController);
+
+    service.refreshToken('a-refresh-token', true).subscribe();
+
+    const request = http.expectOne('/account/auth/refresh');
+    expect(request.request.context.get(SILENT_HTTP_ERROR)).toBeTrue();
+    request.flush({ code: 0, msg: '', data: {} });
+  });
 });
diff --git a/web-app/src/app/service/auth.service.ts 
b/web-app/src/app/service/auth.service.ts
index ccc8ab20ff..c2dbcfcc34 100644
--- a/web-app/src/app/service/auth.service.ts
+++ b/web-app/src/app/service/auth.service.ts
@@ -17,10 +17,11 @@
  * under the License.
  */
 
-import { HttpClient } from '@angular/common/http';
+import { HttpClient, HttpContext } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 import { Observable } from 'rxjs';
 
+import { SILENT_HTTP_ERROR } from '../core/interceptor/http-context';
 import { Message } from '../pojo/Message';
 
 const account_auth_refresh_uri = '/account/auth/refresh';
@@ -32,11 +33,12 @@ const account_token = '/account/token';
 export class AuthService {
   constructor(private http: HttpClient) {}
 
-  public refreshToken(refreshToken: string): Observable<Message<any>> {
+  public refreshToken(refreshToken: string, silentHttpError = false): 
Observable<Message<any>> {
     let body = {
       token: refreshToken
     };
-    return this.http.post<Message<any>>(`${account_auth_refresh_uri}`, body);
+    const context = new HttpContext().set(SILENT_HTTP_ERROR, silentHttpError);
+    return this.http.post<Message<any>>(`${account_auth_refresh_uri}`, body, { 
context });
   }
 
   public generateToken(name?: string, expireSeconds?: number): 
Observable<Message<any>> {
diff --git a/web-app/src/app/service/authorized-sse.service.spec.ts 
b/web-app/src/app/service/authorized-sse.service.spec.ts
new file mode 100644
index 0000000000..88f82ad2dc
--- /dev/null
+++ b/web-app/src/app/service/authorized-sse.service.spec.ts
@@ -0,0 +1,400 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { HttpErrorResponse } from '@angular/common/http';
+import { TestBed } from '@angular/core/testing';
+import { Subject, of, throwError } from 'rxjs';
+
+import { AuthService } from './auth.service';
+import { AuthorizedSseService } from './authorized-sse.service';
+import { LocalStorageService } from './local-storage.service';
+
+describe('AuthorizedSseService', () => {
+  let service: AuthorizedSseService;
+  let localStorageService: jasmine.SpyObj<LocalStorageService>;
+  let authService: jasmine.SpyObj<AuthService>;
+
+  /** Serves the given chunks as a readable body, the way a live sse response 
arrives. */
+  function respondWith(chunks: string[], ok = true, status = 200): void {
+    const encoder = new TextEncoder();
+    let index = 0;
+    const reader = {
+      read: () => (index < chunks.length ? Promise.resolve({ value: 
encoder.encode(chunks[index++]), done: false }) : new Promise(() => {})) // an 
open stream never completes on its own
+    };
+    spyOn(window, 'fetch').and.returnValue(Promise.resolve({ ok, status, body: 
{ getReader: () => reader } } as any));
+  }
+
+  /**
+   * Serves one response per connection attempt, so a test can let the first 
one end and
+   * assert on what the service does next. A `null` entry stands for a request 
that fails
+   * outright rather than returning a response.
+   */
+  function respondPerAttempt(attempts: Array<string[] | null>): void {
+    const encoder = new TextEncoder();
+    let attempt = 0;
+    spyOn(window, 'fetch').and.callFake(() => {
+      const chunks = attempts[Math.min(attempt++, attempts.length - 1)];
+      if (chunks === null) {
+        return Promise.reject(new Error('network down'));
+      }
+      let index = 0;
+      const reader = {
+        // once the chunks run out the server closes the stream, as an emitter 
timeout does
+        read: () =>
+          index < chunks.length
+            ? Promise.resolve({ value: encoder.encode(chunks[index++]), done: 
false })
+            : Promise.resolve({ value: undefined, done: true })
+      };
+      return Promise.resolve({ ok: true, status: 200, body: { getReader: () => 
reader } } as any);
+    });
+  }
+
+  /**
+   * Serves one outcome per connection attempt: a status code for a response 
the server
+   * refuses, or a list of chunks for a stream that stays open.
+   */
+  function respondPerOutcome(outcomes: Array<number | string[]>): void {
+    const encoder = new TextEncoder();
+    let attempt = 0;
+    spyOn(window, 'fetch').and.callFake(() => {
+      const outcome = outcomes[Math.min(attempt++, outcomes.length - 1)];
+      if (typeof outcome === 'number') {
+        return Promise.resolve({ ok: false, status: outcome, body: null } as 
any);
+      }
+      let index = 0;
+      const reader = {
+        read: () =>
+          index < outcome.length ? Promise.resolve({ value: 
encoder.encode(outcome[index++]), done: false }) : new Promise(() => {})
+      };
+      return Promise.resolve({ ok: true, status: 200, body: { getReader: () => 
reader } } as any);
+    });
+  }
+
+  beforeEach(() => {
+    localStorageService = jasmine.createSpyObj('LocalStorageService', [
+      'getAuthorizationToken',
+      'getRefreshToken',
+      'storageAuthorizationToken',
+      'storageRefreshToken'
+    ]);
+    authService = jasmine.createSpyObj('AuthService', ['refreshToken']);
+    TestBed.configureTestingModule({
+      providers: [
+        { provide: LocalStorageService, useValue: localStorageService },
+        { provide: AuthService, useValue: authService }
+      ]
+    });
+    service = TestBed.inject(AuthorizedSseService);
+  });
+
+  it('sends the stored token so the stream can require a credential', done => {
+    localStorageService.getAuthorizationToken.and.returnValue('a-token');
+    respondWith(['event:ALERT_EVENT\ndata:{"id":1}\n\n']);
+
+    const subscription = service.stream('/api/alert/sse/subscribe', 
'ALERT_EVENT').subscribe(() => {
+      const [, init] = (window.fetch as jasmine.Spy).calls.mostRecent().args;
+      expect((init.headers as Record<string, 
string>)['Authorization']).toBe('Bearer a-token');
+      subscription.unsubscribe();
+      done();
+    });
+  });
+
+  it('emits the data payload of a matching event', done => {
+    localStorageService.getAuthorizationToken.and.returnValue('a-token');
+    respondWith(['event:ALERT_EVENT\ndata:{"id":1}\n\n']);
+
+    const subscription = service.stream('/api/alert/sse/subscribe', 
'ALERT_EVENT').subscribe(data => {
+      expect(data).toBe('{"id":1}');
+      subscription.unsubscribe();
+      done();
+    });
+  });
+
+  it('ignores events of another name on the same stream', done => {
+    localStorageService.getAuthorizationToken.and.returnValue('a-token');
+    respondWith(['event:OTHER_EVENT\ndata:{"id":1}\n\n', 
'event:ALERT_EVENT\ndata:{"id":2}\n\n']);
+
+    const subscription = service.stream('/api/alert/sse/subscribe', 
'ALERT_EVENT').subscribe(data => {
+      expect(data).toBe('{"id":2}');
+      subscription.unsubscribe();
+      done();
+    });
+  });
+
+  it('reassembles an event split across chunks', done => {
+    localStorageService.getAuthorizationToken.and.returnValue('a-token');
+    respondWith(['event:ALERT_EVENT\ndata:{"id"', ':3}\n\n']);
+
+    const subscription = service.stream('/api/alert/sse/subscribe', 
'ALERT_EVENT').subscribe(data => {
+      expect(data).toBe('{"id":3}');
+      subscription.unsubscribe();
+      done();
+    });
+  });
+
+  it('surfaces a rejected subscription as an error', done => {
+    localStorageService.getAuthorizationToken.and.returnValue(null as any);
+    localStorageService.getRefreshToken.and.returnValue(null);
+    respondWith([], false, 401);
+
+    service.stream('/api/alert/sse/subscribe', 'ALERT_EVENT').subscribe({
+      error: error => {
+        expect(String(error)).toContain('401');
+        done();
+      }
+    });
+  });
+
+  /**
+   * The server closes a subscription once its emitter times out, so a stream 
that gave up
+   * at the first close would stop delivering alerts half an hour in. 
`EventSource` used to
+   * reconnect on its own; reading through `fetch` has to do it here instead.
+   */
+  it('reconnects after the server closes the stream', done => {
+    localStorageService.getAuthorizationToken.and.returnValue('a-token');
+    respondPerAttempt([['event:ALERT_EVENT\ndata:{"id":1}\n\n'], 
['event:ALERT_EVENT\ndata:{"id":2}\n\n']]);
+    service.initialRetryDelayMillis = 0;
+
+    const received: string[] = [];
+    const subscription = service.stream('/api/alert/sse/subscribe', 
'ALERT_EVENT').subscribe(data => {
+      received.push(data);
+      if (received.length === 2) {
+        expect(received).toEqual(['{"id":1}', '{"id":2}']);
+        expect((window.fetch as jasmine.Spy).calls.count()).toBe(2);
+        subscription.unsubscribe();
+        done();
+      }
+    });
+  });
+
+  it('reconnects after a failed connection attempt', done => {
+    localStorageService.getAuthorizationToken.and.returnValue('a-token');
+    respondPerAttempt([null, ['event:ALERT_EVENT\ndata:{"id":9}\n\n']]);
+    service.initialRetryDelayMillis = 0;
+
+    const subscription = service.stream('/api/alert/sse/subscribe', 
'ALERT_EVENT').subscribe(data => {
+      expect(data).toBe('{"id":9}');
+      expect((window.fetch as jasmine.Spy).calls.count()).toBe(2);
+      subscription.unsubscribe();
+      done();
+    });
+  });
+
+  it('re-reads the token on every attempt so a refreshed one is used', done => 
{
+    localStorageService.getAuthorizationToken.and.returnValues('stale-token', 
'fresh-token');
+    respondPerAttempt([['event:ALERT_EVENT\ndata:{"id":1}\n\n'], 
['event:ALERT_EVENT\ndata:{"id":2}\n\n']]);
+    service.initialRetryDelayMillis = 0;
+
+    let count = 0;
+    const subscription = service.stream('/api/alert/sse/subscribe', 
'ALERT_EVENT').subscribe(() => {
+      if (++count === 2) {
+        const [, init] = (window.fetch as jasmine.Spy).calls.mostRecent().args;
+        expect((init.headers as Record<string, 
string>)['Authorization']).toBe('Bearer fresh-token');
+        subscription.unsubscribe();
+        done();
+      }
+    });
+  });
+
+  it('stops reconnecting once the subscription is closed', done => {
+    localStorageService.getAuthorizationToken.and.returnValue('a-token');
+    respondPerAttempt([['event:ALERT_EVENT\ndata:{"id":1}\n\n']]);
+    service.initialRetryDelayMillis = 0;
+
+    const subscription = service.stream('/api/alert/sse/subscribe', 
'ALERT_EVENT').subscribe(() => {
+      subscription.unsubscribe();
+      const attemptsAtUnsubscribe = (window.fetch as 
jasmine.Spy).calls.count();
+      setTimeout(() => {
+        expect((window.fetch as 
jasmine.Spy).calls.count()).toBe(attemptsAtUnsubscribe);
+        done();
+      }, 20);
+    });
+  });
+
+  /**
+   * A credential the server refuses is the one failure reconnecting cannot 
fix, so it has to
+   * end the observable instead of turning into a retry loop against the 
endpoint.
+   */
+  it('does not retry a rejected credential', done => {
+    localStorageService.getAuthorizationToken.and.returnValue('a-token');
+    localStorageService.getRefreshToken.and.returnValue('a-refresh-token');
+    respondWith([], false, 403);
+    service.initialRetryDelayMillis = 0;
+
+    service.stream('/api/alert/sse/subscribe', 'ALERT_EVENT').subscribe({
+      error: () => {
+        setTimeout(() => {
+          expect((window.fetch as jasmine.Spy).calls.count()).toBe(1);
+          // a refused role is not an expired token, so there is nothing to 
refresh
+          expect(authService.refreshToken).not.toHaveBeenCalled();
+          done();
+        }, 20);
+      }
+    });
+  });
+
+  /**
+   * A subscription outlives its credential: the access token is good for an 
hour while the
+   * server closes a subscription every half hour, so a reconnect eventually 
carries an
+   * expired token. This request never reaches the interceptor that refreshes 
on 401, so
+   * without refreshing here the stream would stop delivering for the rest of 
the session.
+   */
+  it('refreshes an expired token and reconnects', done => {
+    
localStorageService.getAuthorizationToken.and.returnValues('expired-token', 
'fresh-token');
+    localStorageService.getRefreshToken.and.returnValue('a-refresh-token');
+    authService.refreshToken.and.returnValue(
+      of({ code: 0, msg: '', data: { token: 'fresh-token', refreshToken: 
'next-refresh-token' } } as any)
+    );
+    respondPerOutcome([401, ['event:ALERT_EVENT\ndata:{"id":7}\n\n']]);
+    service.initialRetryDelayMillis = 0;
+
+    const subscription = service.stream('/api/alert/sse/subscribe', 
'ALERT_EVENT').subscribe({
+      next: data => {
+        expect(data).toBe('{"id":7}');
+        
expect(authService.refreshToken).toHaveBeenCalledWith('a-refresh-token', true);
+        
expect(localStorageService.storageAuthorizationToken).toHaveBeenCalledWith('fresh-token');
+        
expect(localStorageService.storageRefreshToken).toHaveBeenCalledWith('next-refresh-token');
+        const [, init] = (window.fetch as jasmine.Spy).calls.mostRecent().args;
+        expect((init.headers as Record<string, 
string>)['Authorization']).toBe('Bearer fresh-token');
+        subscription.unsubscribe();
+        done();
+      },
+      error: error => done.fail(`stream ended instead of reconnecting: 
${error}`)
+    });
+  });
+
+  /**
+   * Logging out invalidates the session even when a refresh request is 
already in flight.
+   * A late response must not put credentials back after logout cleared local 
storage.
+   */
+  it('does not restore credentials after logout during a refresh', done => {
+    const refreshResponse = new Subject<any>();
+    let storedAccessToken: string | null = 'expired-token';
+    let storedRefreshToken: string | null = 'a-refresh-token';
+    localStorageService.getAuthorizationToken.and.callFake(() => 
storedAccessToken as any);
+    localStorageService.getRefreshToken.and.callFake(() => storedRefreshToken);
+    authService.refreshToken.and.returnValue(refreshResponse);
+    respondPerOutcome([401]);
+
+    const subscription = service.stream('/api/alert/sse/subscribe', 
'ALERT_EVENT').subscribe();
+    setTimeout(() => {
+      expect(authService.refreshToken).toHaveBeenCalledWith('a-refresh-token', 
true);
+      storedAccessToken = null;
+      storedRefreshToken = null;
+      subscription.unsubscribe();
+      refreshResponse.next({ code: 0, msg: '', data: { token: 'late-token', 
refreshToken: 'late-refresh-token' } });
+      refreshResponse.complete();
+
+      setTimeout(() => {
+        
expect(localStorageService.storageAuthorizationToken).not.toHaveBeenCalled();
+        expect(localStorageService.storageRefreshToken).not.toHaveBeenCalled();
+        done();
+      });
+    });
+  });
+
+  /**
+   * A session that is genuinely over has to end the observable rather than 
turn into a loop
+   * of refresh attempts against an endpoint that keeps refusing.
+   */
+  it('ends the stream when the refresh is refused', done => {
+    localStorageService.getAuthorizationToken.and.returnValue('expired-token');
+    localStorageService.getRefreshToken.and.returnValue('a-refresh-token');
+    authService.refreshToken.and.returnValue(throwError(() => new 
HttpErrorResponse({ status: 401 })));
+    respondPerOutcome([401]);
+    service.initialRetryDelayMillis = 0;
+
+    service.stream('/api/alert/sse/subscribe', 'ALERT_EVENT').subscribe({
+      error: error => {
+        expect(String(error)).toContain('401');
+        setTimeout(() => {
+          expect((window.fetch as jasmine.Spy).calls.count()).toBe(1);
+          expect(authService.refreshToken).toHaveBeenCalledTimes(1);
+          done();
+        }, 20);
+      }
+    });
+  });
+
+  it('retries when the refresh endpoint is temporarily unavailable', done => {
+    
localStorageService.getAuthorizationToken.and.returnValues('expired-token', 
'expired-token', 'fresh-token');
+    localStorageService.getRefreshToken.and.returnValue('a-refresh-token');
+    authService.refreshToken.and.returnValues(
+      throwError(() => new HttpErrorResponse({ status: 502 })),
+      of({ code: 0, msg: '', data: { token: 'fresh-token', refreshToken: 
'next-refresh-token' } } as any)
+    );
+    respondPerOutcome([401, 401, ['event:ALERT_EVENT\ndata:{"id":8}\n\n']]);
+    service.initialRetryDelayMillis = 0;
+
+    const subscription = service.stream('/api/alert/sse/subscribe', 
'ALERT_EVENT').subscribe({
+      next: data => {
+        expect(data).toBe('{"id":8}');
+        expect(authService.refreshToken).toHaveBeenCalledTimes(2);
+        expect((window.fetch as jasmine.Spy).calls.count()).toBe(3);
+        subscription.unsubscribe();
+        done();
+      },
+      error: error => done.fail(`temporary refresh failure ended the stream: 
${error}`)
+    });
+  });
+
+  it('allows another refresh after a non-401 reconnect response', done => {
+    
localStorageService.getAuthorizationToken.and.returnValues('expired-token', 
'fresh-token', 'fresh-token', 'newer-token');
+    localStorageService.getRefreshToken.and.returnValue('a-refresh-token');
+    authService.refreshToken.and.returnValues(
+      of({ code: 0, msg: '', data: { token: 'fresh-token' } } as any),
+      of({ code: 0, msg: '', data: { token: 'newer-token' } } as any)
+    );
+    respondPerOutcome([401, 503, 401, 
['event:ALERT_EVENT\ndata:{"id":10}\n\n']]);
+    service.initialRetryDelayMillis = 0;
+
+    const subscription = service.stream('/api/alert/sse/subscribe', 
'ALERT_EVENT').subscribe({
+      next: data => {
+        expect(data).toBe('{"id":10}');
+        expect(authService.refreshToken).toHaveBeenCalledTimes(2);
+        expect((window.fetch as jasmine.Spy).calls.count()).toBe(4);
+        subscription.unsubscribe();
+        done();
+      },
+      error: error => done.fail(`a non-401 response prevented a later refresh: 
${error}`)
+    });
+  });
+
+  /**
+   * A token that the server still refuses after a refresh is the end of the 
session: retrying
+   * the refresh on every attempt would loop indefinitely.
+   */
+  it('refreshes only once for a credential that stays refused', done => {
+    localStorageService.getAuthorizationToken.and.returnValue('expired-token');
+    localStorageService.getRefreshToken.and.returnValue('a-refresh-token');
+    authService.refreshToken.and.returnValue(of({ code: 0, msg: '', data: { 
token: 'fresh-token' } } as any));
+    respondPerOutcome([401]);
+    service.initialRetryDelayMillis = 0;
+
+    service.stream('/api/alert/sse/subscribe', 'ALERT_EVENT').subscribe({
+      error: () => {
+        setTimeout(() => {
+          expect(authService.refreshToken).toHaveBeenCalledTimes(1);
+          expect((window.fetch as jasmine.Spy).calls.count()).toBe(2);
+          done();
+        }, 20);
+      }
+    });
+  });
+});
diff --git a/web-app/src/app/service/authorized-sse.service.ts 
b/web-app/src/app/service/authorized-sse.service.ts
new file mode 100644
index 0000000000..7cf9474084
--- /dev/null
+++ b/web-app/src/app/service/authorized-sse.service.ts
@@ -0,0 +1,343 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { HttpErrorResponse } from '@angular/common/http';
+import { Injectable, NgZone } from '@angular/core';
+import { Observable, firstValueFrom } from 'rxjs';
+
+import { AuthService } from './auth.service';
+import { LocalStorageService } from './local-storage.service';
+
+type RefreshOutcome = 'refreshed' | 'refused' | 'unavailable';
+
+/** What a stream does about an attempt the server rejected. */
+type StreamRecovery = 'reconnect' | 'retry-later' | 'end';
+
+/**
+ * Spaces out the reconnect attempts of one stream, doubling the wait after 
each attempt that
+ * did not get the stream running and starting over once one did.
+ */
+class ReconnectBackoff {
+  private timer?: ReturnType<typeof setTimeout>;
+
+  private delayMillis: number;
+
+  constructor(private initialMillis: number, private maxMillis: number) {
+    this.delayMillis = initialMillis;
+  }
+
+  schedule(attempt: () => void): void {
+    const wait = this.delayMillis;
+    this.delayMillis = Math.min(this.delayMillis * 2, this.maxMillis);
+    this.timer = setTimeout(attempt, wait);
+  }
+
+  /** Called once a connection is up, so the next drop is retried without any 
accumulated wait. */
+  reset(): void {
+    this.delayMillis = this.initialMillis;
+  }
+
+  cancel(): void {
+    if (this.timer) {
+      clearTimeout(this.timer);
+    }
+  }
+}
+
+/**
+ * Reads a server sent event stream with the bearer token attached.
+ *
+ * The browser's own `EventSource` cannot carry an `Authorization` header, 
which is why the
+ * alert and manager streams used to be reachable without any credential at 
all. Reading the
+ * stream through `fetch` instead lets the token travel with the request, so 
the endpoints can
+ * be moved behind the same rbac rules as the rest of the api.
+ *
+ * The returned observable starts the request on subscribe and aborts it on 
unsubscribe.
+ * Events are emitted outside the angular zone; a caller that touches 
component state should
+ * re-enter the zone itself, as it would with any other stream.
+ *
+ * A dropped connection is re-established rather than surfaced as an error, 
because the
+ * server closes an idle subscription on purpose once its emitter times out. 
`EventSource`
+ * used to reconnect on its own, so reading through `fetch` has to carry that 
behaviour over
+ * or a stream would simply stop delivering.
+ *
+ * An expired token is refreshed and the connection retried, because a 
subscription outlives
+ * its credential: the access token is good for an hour while the server 
closes a subscription
+ * every half hour, so a reconnect eventually lands on an expired token. This 
request does not
+ * go through `HttpClient`, so the interceptor that refreshes on 401 never 
sees it and the
+ * refresh has to happen here. Only a credential that is still refused after a 
refresh ends the
+ * observable: reconnecting cannot change that answer, and retrying would 
hammer the endpoint.
+ */
+@Injectable({ providedIn: 'root' })
+export class AuthorizedSseService {
+  /**
+   * Delay before the first reconnect attempt; doubles on each consecutive 
failure.
+   * An instance field rather than a constant so a test can drive the 
reconnect without
+   * waiting a real second.
+   */
+  initialRetryDelayMillis = 1000;
+
+  /** Ceiling for the doubling above, so a server that stays down is polled at 
a fixed rate. */
+  maxRetryDelayMillis = 30000;
+
+  /**
+   * The refresh in flight, shared by every stream this service runs. Two 
streams are open at
+   * once and their tokens expire together. Sharing avoids duplicate requests 
and prevents
+   * two successful responses from racing to replace the stored token pair.
+   */
+  private refreshInFlight?: Promise<RefreshOutcome>;
+
+  constructor(private localStorageService: LocalStorageService, private 
authService: AuthService, private ngZone: NgZone) {}
+
+  /**
+   * @param url stream endpoint, relative to the origin
+   * @param eventName name of the sse event to emit; other events are ignored
+   * @returns the `data` payload of every matching event, as raw text
+   */
+  stream(url: string, eventName: string): Observable<string> {
+    return new Observable<string>(subscriber => {
+      let controller: AbortController | undefined;
+      let stopped = false;
+      // one refresh per rejection, so a server that refuses the fresh token 
too ends the
+      // stream instead of looping between refreshing and being refused
+      let refreshed = false;
+      const backoff = new ReconnectBackoff(this.initialRetryDelayMillis, 
this.maxRetryDelayMillis);
+
+      const scheduleReconnect = (): void => {
+        if (stopped) {
+          return;
+        }
+        backoff.schedule(connect);
+      };
+
+      const connect = (): void => {
+        if (stopped) {
+          return;
+        }
+        const current = new AbortController();
+        controller = current;
+
+        this.ngZone.runOutsideAngular(() => {
+          fetch(url, { method: 'GET', headers: this.authorizedHeaders(), 
signal: current.signal })
+            .then(async response => {
+              // A response from the stream endpoint proves that the preceding 
refresh was
+              // accepted. Even an unrelated server error must allow a later 
401 to refresh
+              // again instead of permanently ending this stream.
+              if (response.status !== 401) {
+                refreshed = false;
+              }
+              if (response.status === 401 || response.status === 403) {
+                const recovery = await 
this.recoverFromRejection(response.status, refreshed);
+                // abort first, so what happens next is the only thing that 
happens: the
+                // handler that follows this one schedules its own reconnect 
unless the
+                // attempt was aborted
+                current.abort();
+                if (recovery === 'reconnect') {
+                  refreshed = true;
+                  connect();
+                } else if (recovery === 'retry-later') {
+                  scheduleReconnect();
+                } else {
+                  stopped = true;
+                  subscriber.error(new Error(`SSE request to ${url} failed 
with status ${response.status}`));
+                }
+                return;
+              }
+              const reader = this.readerOf(response, url);
+              // the stream is up, so a later drop starts its backoff from the 
bottom again,
+              // and the token it is holding may be refreshed again when it in 
turn expires
+              backoff.reset();
+              refreshed = false;
+
+              await this.pumpEvents(reader, current.signal, eventName, data => 
subscriber.next(data));
+            })
+            .then(() => {
+              if (!stopped && !current.signal.aborted) {
+                scheduleReconnect();
+              }
+            })
+            .catch(error => {
+              if (stopped || current.signal.aborted) {
+                return;
+              }
+              console.error(`SSE connection to ${url} interrupted, 
reconnecting`, error);
+              scheduleReconnect();
+            });
+        });
+      };
+
+      connect();
+
+      return () => {
+        stopped = true;
+        backoff.cancel();
+        controller?.abort();
+      };
+    });
+  }
+
+  /**
+   * @returns The headers every attempt carries, with the stored token 
attached when there is
+   *          one; it is read per attempt because it may have been refreshed 
since the last
+   */
+  private authorizedHeaders(): Record<string, string> {
+    const headers: Record<string, string> = {
+      Accept: 'text/event-stream',
+      'Cache-Control': 'no-cache'
+    };
+    const token = this.localStorageService.getAuthorizationToken();
+    if (token) {
+      headers['Authorization'] = `Bearer ${token}`;
+    }
+    return headers;
+  }
+
+  /**
+   * Decides what a rejected attempt means for the stream.
+   *
+   * @param status the status the stream endpoint answered with, either 401 or 
403
+   * @param alreadyRefreshed whether this stream has refreshed its token since 
it last had a
+   *        working connection; a second rejection in a row cannot be fixed by 
refreshing again
+   * @returns `reconnect` to retry at once with the fresh token, `retry-later` 
to back off and
+   *          try again, `end` when the answer is settled and reconnecting 
cannot change it
+   */
+  private async recoverFromRejection(status: number, alreadyRefreshed: 
boolean): Promise<StreamRecovery> {
+    // a refused role is not an expired token, so there is nothing to refresh
+    if (status !== 401 || alreadyRefreshed) {
+      return 'end';
+    }
+    const outcome = await this.refreshAuthToken();
+    if (outcome === 'refreshed') {
+      return 'reconnect';
+    }
+    return outcome === 'unavailable' ? 'retry-later' : 'end';
+  }
+
+  /**
+   * @returns The reader over the response body
+   * @throws Error When the response cannot be read as a stream, which the 
caller turns into a
+   *         reconnect the same way it treats a connection that dropped
+   */
+  private readerOf(response: Response, url: string): 
ReadableStreamDefaultReader<Uint8Array> {
+    if (!response.ok) {
+      throw new Error(`SSE request to ${url} failed with status 
${response.status}`);
+    }
+    const reader = response.body?.getReader();
+    if (!reader) {
+      throw new Error(`SSE response from ${url} has no readable body`);
+    }
+    return reader;
+  }
+
+  /**
+   * Reads the response until the server closes it or the caller aborts, 
handing every
+   * matching event to `emit`.
+   *
+   * <p>An event can be split across chunks, so what a read returns is 
appended to a buffer
+   * and only whole frames are taken off it.
+   */
+  private async pumpEvents(
+    reader: ReadableStreamDefaultReader<Uint8Array>,
+    signal: AbortSignal,
+    eventName: string,
+    emit: (data: string) => void
+  ): Promise<void> {
+    const decoder = new TextDecoder();
+    let buffer = '';
+    while (!signal.aborted) {
+      const { value, done } = await reader.read();
+      if (done) {
+        // the server closed it, most likely an emitter timeout; the caller 
reconnects
+        return;
+      }
+
+      buffer += decoder.decode(value, { stream: true });
+      const frames = buffer.split(/\r?\n\r?\n/);
+      buffer = frames.pop() ?? '';
+
+      for (const frame of frames) {
+        const payload = this.payloadOf(frame, eventName);
+        if (payload !== undefined) {
+          emit(payload);
+        }
+      }
+    }
+  }
+
+  /**
+   * @returns The `data` of the frame when it carries the event this stream 
subscribes to, and
+   *          `undefined` for every other frame
+   */
+  private payloadOf(frame: string, eventName: string): string | undefined {
+    let frameEvent = '';
+    const dataLines: string[] = [];
+    for (const line of frame.split(/\r?\n/)) {
+      if (line.startsWith('event:')) {
+        frameEvent = line.substring(6).trim();
+      } else if (line.startsWith('data:')) {
+        dataLines.push(line.substring(5));
+      }
+    }
+    return frameEvent === eventName && dataLines.length > 0 ? 
dataLines.join('\n') : undefined;
+  }
+
+  /**
+   * Exchanges the refresh token for a new pair and stores it.
+   *
+   * @returns whether the token was refreshed, explicitly refused, or 
temporarily unavailable
+   */
+  private refreshAuthToken(): Promise<RefreshOutcome> {
+    if (!this.refreshInFlight) {
+      this.refreshInFlight = this.requestFreshToken().finally(() => 
(this.refreshInFlight = undefined));
+    }
+    return this.refreshInFlight;
+  }
+
+  private async requestFreshToken(): Promise<RefreshOutcome> {
+    const refreshToken = this.localStorageService.getRefreshToken();
+    if (!refreshToken) {
+      return 'refused';
+    }
+    try {
+      const message = await 
firstValueFrom(this.authService.refreshToken(refreshToken, true));
+      if (message.code !== 0 || !message.data?.token) {
+        console.warn('SSE token refresh was refused', message.msg);
+        return 'refused';
+      }
+      // The request may finish after logout, or after another refresh/login 
replaced the
+      // session. Never let that stale response restore or overwrite 
credentials. If another
+      // session is already present, its access token is the one the reconnect 
should use.
+      if (this.localStorageService.getRefreshToken() !== refreshToken) {
+        return this.localStorageService.getAuthorizationToken() === null ? 
'refused' : 'refreshed';
+      }
+      this.localStorageService.storageAuthorizationToken(message.data.token);
+      if (message.data.refreshToken) {
+        
this.localStorageService.storageRefreshToken(message.data.refreshToken);
+      }
+      return 'refreshed';
+    } catch (error) {
+      if (error instanceof HttpErrorResponse && (error.status === 401 || 
error.status === 403)) {
+        console.warn('SSE token refresh was refused', error.status);
+        return 'refused';
+      }
+      console.warn('SSE token refresh is temporarily unavailable', error);
+      return 'unavailable';
+    }
+  }
+}


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to