From bc151d0eebcf6aa6c24e01149264266e4d90c141 Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 11 Aug 2026 07:30:39 +0300 Subject: [PATCH 1/5] Add tab presence support to track user activity across sessions --- forge/comms/aclManager.js | 29 ++- forge/comms/browserSessionPresence.js | 56 +++++ forge/comms/commsClient.js | 20 +- forge/comms/index.js | 3 + test/unit/forge/comms/authRoutesV2_spec.js | 32 +++ .../comms/browserSessionPresence_spec.js | 214 ++++++++++++++++++ 6 files changed, 351 insertions(+), 3 deletions(-) create mode 100644 forge/comms/browserSessionPresence.js create mode 100644 test/unit/forge/comms/browserSessionPresence_spec.js diff --git a/forge/comms/aclManager.js b/forge/comms/aclManager.js index 6e23f60eca..0ebf3c38a0 100644 --- a/forge/comms/aclManager.js +++ b/forge/comms/aclManager.js @@ -171,6 +171,25 @@ module.exports = function (app) { return false } }, + checkPresenceTopic: async function (requestParts, usernameParts) { + // requestParts = [ fullTopic, , , ] + // usernameParts = [ 'fe-team', , , ] + const topicUserId = requestParts[1] + const usernameUserHash = usernameParts[1] + if (topicUserId !== usernameUserHash) { + return false + } + try { + const user = await app.db.models.User.byId(usernameUserHash) + if (!user || user.suspended) { + return false + } + return true + } catch (error) { + app.log.error('Unexpected error during presence topic ACL check', { requestParts, usernameParts, error }) + return false + } + }, checkExpertPlatformTopic: async function (topicParts, usernameParts, acl) { // topicParts = [ fullTopic , , , ] // usernameParts = [ 'forge_platform' | 'expert-agent', [, ] ] @@ -467,7 +486,10 @@ module.exports = function (app) { // ff/v1/platform/leader { topic: /^ff\/v1\/platform\/leader$/ }, // platform can listen for Expert Agent requests - { topic: /^ff\/v1\/expert\/([^/]+)\/([^/]+)\/platform\/([^/]+)\/request$/, verify: 'checkExpertPlatformTopic', allowWildcard: { user: true, session: true, command: true }, isPlatform: true, isSub: true, agent: 'platform' } + { topic: /^ff\/v1\/expert\/([^/]+)\/([^/]+)\/platform\/([^/]+)\/request$/, verify: 'checkExpertPlatformTopic', allowWildcard: { user: true, session: true, command: true }, isPlatform: true, isSub: true, agent: 'platform' }, + // platform can listen for browser tab presence (shared subscription) + // - ff/v1/tab-presence/// + { topic: /^ff\/v1\/tab-presence\/[^/]+\/[^/]+\/(heartbeat|context)$/, shared: true } ], pub: [ // Send commands to project launchers @@ -566,7 +588,10 @@ module.exports = function (app) { // - ff/v1//a/+/created|updated|deleted { topic: /^ff\/v1\/([^/]+)\/a\/([^/]+)\/(created|updated|deleted)$/, verify: 'checkTeamStateSub' } ], - pub: [] + pub: [ + // ff/v1/tab-presence/// + { topic: /^ff\/v1\/tab-presence\/([^/]+)\/([^/]+)\/(heartbeat|context)$/, verify: 'checkPresenceTopic' } + ] }, // frontend client (user) expertClient: { diff --git a/forge/comms/browserSessionPresence.js b/forge/comms/browserSessionPresence.js new file mode 100644 index 0000000000..52fb86e881 --- /dev/null +++ b/forge/comms/browserSessionPresence.js @@ -0,0 +1,56 @@ +const CACHE_NAME = 'browserSessions' +const CACHE_TTL = 135_000 // ~3x the 45s heartbeat interval +const CACHE_MAX = 10_000 + +class BrowserSessionPresenceHandler { + constructor (app, client) { + this.app = app + this.client = client + this.cache = app.caches.createCache(CACHE_NAME, { max: CACHE_MAX, ttl: CACHE_TTL }) + this.setupEventHandlers() + } + + setupEventHandlers () { + this.client.on('tab-presence', (msg) => this.handlePresence(msg)) + } + + async handlePresence ({ userId, sessionId, messageType, payload }) { + const cacheKey = `${userId}:${sessionId}` + + if (messageType === 'heartbeat') { + const existing = await this.cache.get(cacheKey) || {} + await this.cache.set(cacheKey, { + ...existing, + userId, + sessionId, + lastSeen: Date.now(), + visibility: payload.visibility || 'visible' + }) + } else if (messageType === 'context') { + const existing = await this.cache.get(cacheKey) || {} + await this.cache.set(cacheKey, { + ...existing, + userId, + sessionId, + lastSeen: Date.now(), + context: payload + }) + } + } + + async getSessionsByUser (userId) { + const allEntries = await this.cache.all() + const prefix = `${userId}:` + const sessions = [] + for (const [key, value] of Object.entries(allEntries)) { + if (key.startsWith(prefix)) { + sessions.push(value) + } + } + return sessions + } +} + +module.exports = { + BrowserSessionPresenceHandler: (app, client) => new BrowserSessionPresenceHandler(app, client) +} diff --git a/forge/comms/commsClient.js b/forge/comms/commsClient.js index 70849e9509..134813cd70 100644 --- a/forge/comms/commsClient.js +++ b/forge/comms/commsClient.js @@ -52,6 +52,22 @@ class CommsClient extends EventEmitter { const ownerId = topicParts[4] const messageType = topicParts[5] + if (topicParts[2] === 'tab-presence') { + // ff/v1/tab-presence/// + const userId = topicParts[3] + const sessionId = topicParts[4] + const messageType = topicParts[5] + let payload + try { + payload = JSON.parse(message.toString()) + } catch (err) { + this.app.log.warn(`Ignoring malformed tab-presence payload on ${topic}: ${err.message}`) + return + } + this.emit('tab-presence', { userId, sessionId, messageType, payload }) + return + } + if (topicParts[2] === 'expert') { const userId = topicParts[3] const sessionId = topicParts[4] @@ -249,7 +265,9 @@ class CommsClient extends EventEmitter { // of consumers that share the workload, so keeping Expert separate from the // "platform" group prevents unrelated features from sharing a consumer pool and // allows them to scale independently. - '$share/expert/ff/v1/expert/+/+/platform/+/request' + '$share/expert/ff/v1/expert/+/+/platform/+/request', + // Browser tab presence - shared subscription + '$share/platform/ff/v1/tab-presence/+/+/+' ]) } } diff --git a/forge/comms/index.js b/forge/comms/index.js index fed0e78bf2..feedbb187d 100644 --- a/forge/comms/index.js +++ b/forge/comms/index.js @@ -1,6 +1,7 @@ const fp = require('fastify-plugin') const ACLManager = require('./aclManager') +const { BrowserSessionPresenceHandler } = require('./browserSessionPresence') const { CommsClient } = require('./commsClient') const { DeviceCommsHandler } = require('./devices') const { ExpertCommsHandler } = require('./expert') @@ -36,6 +37,7 @@ module.exports = fp(async function (app, _opts) { const instanceCommsHandler = InstanceCommsHandler(app, client) const platformAutomationHandler = PlatformAutomationHandler(app, client) const expertCommsHandler = new ExpertCommsHandler(app, client) + const browserSessionPresenceHandler = BrowserSessionPresenceHandler(app, client) // Not in the current release, but when we handle Launcher status // via MQTT, it will arrive here. Compare to the status/device handler in `devices.js` @@ -50,6 +52,7 @@ module.exports = fp(async function (app, _opts) { aclManager: ACLManager(app), platformAutomation: platformAutomationHandler, expert: expertCommsHandler, + browserSessions: browserSessionPresenceHandler, platform: { settings: { sync: function (key) { diff --git a/test/unit/forge/comms/authRoutesV2_spec.js b/test/unit/forge/comms/authRoutesV2_spec.js index 9f9b8e2d39..c50e3acc95 100644 --- a/test/unit/forge/comms/authRoutesV2_spec.js +++ b/test/unit/forge/comms/authRoutesV2_spec.js @@ -1575,6 +1575,38 @@ describe('Broker Auth v2 API', async function () { teamLookupStub.restore() } }) + + // Browser session presence topics + it('allows fe-team to publish heartbeat to own presence topic', async function () { + await allowWrite({ + username: teamFrontendUsername, + topic: `ff/v1/tab-presence/${TestObjects.alice.hashid}/session-abc12345/heartbeat` + }) + }) + it('allows fe-team to publish context to own presence topic', async function () { + await allowWrite({ + username: teamFrontendUsername, + topic: `ff/v1/tab-presence/${TestObjects.alice.hashid}/session-abc12345/context` + }) + }) + it('denies fe-team from publishing to another user\'s presence topic', async function () { + await denyWrite({ + username: teamFrontendUsername, + topic: `ff/v1/tab-presence/${bob.hashid}/session-abc12345/heartbeat` + }) + }) + it('denies fe-team from publishing to an invalid presence message type', async function () { + await denyWrite({ + username: teamFrontendUsername, + topic: `ff/v1/tab-presence/${TestObjects.alice.hashid}/session-abc12345/invalid` + }) + }) + it('allows forge_platform to subscribe to presence topics via shared subscription', async function () { + await allowRead({ + username: 'forge_platform', + topic: `$share/platform/ff/v1/tab-presence/${TestObjects.alice.hashid}/session-abc12345/heartbeat` + }) + }) }) }) }) diff --git a/test/unit/forge/comms/browserSessionPresence_spec.js b/test/unit/forge/comms/browserSessionPresence_spec.js new file mode 100644 index 0000000000..7099441135 --- /dev/null +++ b/test/unit/forge/comms/browserSessionPresence_spec.js @@ -0,0 +1,214 @@ +const should = require('should') // eslint-disable-line + +const setup = require('../routes/setup') + +const FF_UTIL = require('flowforge-test-utils') +const { BrowserSessionPresenceHandler } = FF_UTIL.require('forge/comms/browserSessionPresence') + +describe('BrowserSessionPresenceHandler', function () { + function mockClient () { + const handlers = {} + return { + on: (event, callback) => { + handlers[event] = callback + }, + emit: function () { + const evt = arguments[0] + const args = Array.prototype.slice.call(arguments, 1) + if (handlers[evt]) { + handlers[evt].apply(null, args) + } + } + } + } + + let app + let client + let handler + + before(async function () { + app = await setup() + }) + + after(async function () { + await app.close() + }) + + beforeEach(function () { + client = mockClient() + handler = BrowserSessionPresenceHandler(app, client) + }) + + describe('event handler registration', function () { + it('registers a tab-presence listener on the client', function () { + const testClient = mockClient() + const listeners = [] + testClient.on = (event) => { listeners.push(event) } + BrowserSessionPresenceHandler(app, testClient) + listeners.should.containEql('tab-presence') + }) + }) + + describe('heartbeat handling', function () { + it('creates a cache entry with lastSeen and visibility', async function () { + client.emit('tab-presence', { + userId: 'user1', + sessionId: 'session1', + messageType: 'heartbeat', + payload: { visibility: 'visible' } + }) + + // Allow async handler to complete + await new Promise(resolve => setImmediate(resolve)) + + const sessions = await handler.getSessionsByUser('user1') + sessions.should.have.length(1) + sessions[0].should.have.property('userId', 'user1') + sessions[0].should.have.property('sessionId', 'session1') + sessions[0].should.have.property('visibility', 'visible') + sessions[0].should.have.property('lastSeen').which.is.a.Number() + }) + + it('defaults visibility to visible when not provided', async function () { + client.emit('tab-presence', { + userId: 'user1', + sessionId: 'session2', + messageType: 'heartbeat', + payload: {} + }) + + await new Promise(resolve => setImmediate(resolve)) + + const sessions = await handler.getSessionsByUser('user1') + const session = sessions.find(s => s.sessionId === 'session2') + session.should.have.property('visibility', 'visible') + }) + + it('preserves existing context when updating with heartbeat', async function () { + // First set context + client.emit('tab-presence', { + userId: 'user2', + sessionId: 'session1', + messageType: 'context', + payload: { teamId: 'team1', pageName: 'instances' } + }) + await new Promise(resolve => setImmediate(resolve)) + + // Then send heartbeat + client.emit('tab-presence', { + userId: 'user2', + sessionId: 'session1', + messageType: 'heartbeat', + payload: { visibility: 'hidden' } + }) + await new Promise(resolve => setImmediate(resolve)) + + const sessions = await handler.getSessionsByUser('user2') + sessions.should.have.length(1) + sessions[0].should.have.property('visibility', 'hidden') + sessions[0].should.have.property('context').which.deepEqual({ teamId: 'team1', pageName: 'instances' }) + }) + }) + + describe('context handling', function () { + it('creates a cache entry with context payload', async function () { + client.emit('tab-presence', { + userId: 'user3', + sessionId: 'session1', + messageType: 'context', + payload: { teamId: 'team1', instanceId: 'inst1', pageName: 'editor' } + }) + + await new Promise(resolve => setImmediate(resolve)) + + const sessions = await handler.getSessionsByUser('user3') + sessions.should.have.length(1) + sessions[0].should.have.property('context').which.deepEqual({ + teamId: 'team1', + instanceId: 'inst1', + pageName: 'editor' + }) + sessions[0].should.have.property('lastSeen').which.is.a.Number() + }) + + it('updates lastSeen on context update', async function () { + client.emit('tab-presence', { + userId: 'user4', + sessionId: 'session1', + messageType: 'heartbeat', + payload: { visibility: 'visible' } + }) + await new Promise(resolve => setImmediate(resolve)) + + const before = (await handler.getSessionsByUser('user4'))[0].lastSeen + + // Small delay to ensure different timestamp + await new Promise(resolve => setTimeout(resolve, 10)) + + client.emit('tab-presence', { + userId: 'user4', + sessionId: 'session1', + messageType: 'context', + payload: { teamId: 'team2' } + }) + await new Promise(resolve => setImmediate(resolve)) + + const after = (await handler.getSessionsByUser('user4'))[0].lastSeen + after.should.be.greaterThanOrEqual(before) + }) + }) + + describe('unknown messageType', function () { + it('ignores unknown message types', async function () { + client.emit('tab-presence', { + userId: 'user5', + sessionId: 'session1', + messageType: 'invalid', + payload: { some: 'data' } + }) + + await new Promise(resolve => setImmediate(resolve)) + + const sessions = await handler.getSessionsByUser('user5') + sessions.should.have.length(0) + }) + }) + + describe('getSessionsByUser', function () { + it('returns only sessions for the requested user', async function () { + client.emit('tab-presence', { + userId: 'userA', + sessionId: 'sessionA1', + messageType: 'heartbeat', + payload: { visibility: 'visible' } + }) + client.emit('tab-presence', { + userId: 'userA', + sessionId: 'sessionA2', + messageType: 'heartbeat', + payload: { visibility: 'hidden' } + }) + client.emit('tab-presence', { + userId: 'userB', + sessionId: 'sessionB1', + messageType: 'heartbeat', + payload: { visibility: 'visible' } + }) + + await new Promise(resolve => setImmediate(resolve)) + + const sessionsA = await handler.getSessionsByUser('userA') + sessionsA.should.have.length(2) + sessionsA.every(s => s.userId === 'userA').should.be.true() + + const sessionsB = await handler.getSessionsByUser('userB') + sessionsB.should.have.length(1) + sessionsB[0].should.have.property('userId', 'userB') + }) + + it('returns empty array for unknown user', async function () { + const sessions = await handler.getSessionsByUser('nonexistent') + sessions.should.have.length(0) + }) + }) +}) From faf91fd1454cb563736e2cd2b8c1cf7347ffbc59 Mon Sep 17 00:00:00 2001 From: cstns Date: Tue, 11 Aug 2026 18:39:58 +0300 Subject: [PATCH 2/5] Expand tab presence subscription topic to accept any message type --- forge/comms/aclManager.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/forge/comms/aclManager.js b/forge/comms/aclManager.js index 0ebf3c38a0..e3413f1914 100644 --- a/forge/comms/aclManager.js +++ b/forge/comms/aclManager.js @@ -489,7 +489,10 @@ module.exports = function (app) { { topic: /^ff\/v1\/expert\/([^/]+)\/([^/]+)\/platform\/([^/]+)\/request$/, verify: 'checkExpertPlatformTopic', allowWildcard: { user: true, session: true, command: true }, isPlatform: true, isSub: true, agent: 'platform' }, // platform can listen for browser tab presence (shared subscription) // - ff/v1/tab-presence/// - { topic: /^ff\/v1\/tab-presence\/[^/]+\/[^/]+\/(heartbeat|context)$/, shared: true } + // Uses [^/]+ for the message-type segment because the subscription wildcard (+) + // is matched as a literal character. The publish-side ACL on teamFrontend + // already restricts to heartbeat|context. + { topic: /^ff\/v1\/tab-presence\/[^/]+\/[^/]+\/[^/]+$/, shared: true } ], pub: [ // Send commands to project launchers From 93e0f0dbc9fdd6bd491b095a7a5d7c4cae0ab83d Mon Sep 17 00:00:00 2001 From: cstns Date: Thu, 13 Aug 2026 16:01:12 +0300 Subject: [PATCH 3/5] Update tab presence topics to use `browser` namespace --- forge/comms/aclManager.js | 10 +++++----- forge/comms/commsClient.js | 14 +++++++------- test/unit/forge/comms/authRoutesV2_spec.js | 10 +++++----- 3 files changed, 17 insertions(+), 17 deletions(-) diff --git a/forge/comms/aclManager.js b/forge/comms/aclManager.js index e3413f1914..7b094e7dac 100644 --- a/forge/comms/aclManager.js +++ b/forge/comms/aclManager.js @@ -488,11 +488,11 @@ module.exports = function (app) { // platform can listen for Expert Agent requests { topic: /^ff\/v1\/expert\/([^/]+)\/([^/]+)\/platform\/([^/]+)\/request$/, verify: 'checkExpertPlatformTopic', allowWildcard: { user: true, session: true, command: true }, isPlatform: true, isSub: true, agent: 'platform' }, // platform can listen for browser tab presence (shared subscription) - // - ff/v1/tab-presence/// + // - ff/v1/browser/tab-presence/// // Uses [^/]+ for the message-type segment because the subscription wildcard (+) // is matched as a literal character. The publish-side ACL on teamFrontend // already restricts to heartbeat|context. - { topic: /^ff\/v1\/tab-presence\/[^/]+\/[^/]+\/[^/]+$/, shared: true } + { topic: /^ff\/v1\/browser\/tab-presence\/[^/]+\/[^/]+\/[^/]+$/, shared: true } ], pub: [ // Send commands to project launchers @@ -592,8 +592,8 @@ module.exports = function (app) { { topic: /^ff\/v1\/([^/]+)\/a\/([^/]+)\/(created|updated|deleted)$/, verify: 'checkTeamStateSub' } ], pub: [ - // ff/v1/tab-presence/// - { topic: /^ff\/v1\/tab-presence\/([^/]+)\/([^/]+)\/(heartbeat|context)$/, verify: 'checkPresenceTopic' } + // ff/v1/browser/tab-presence/// + { topic: /^ff\/v1\/browser\/tab-presence\/([^/]+)\/([^/]+)\/(heartbeat|context)$/, verify: 'checkPresenceTopic' } ] }, // frontend client (user) @@ -675,7 +675,7 @@ module.exports = function (app) { isSharedSub = true // This is a shared sub - validate the share group name const shareGroup = sharedSubParts[1] - if (shareGroup !== 'platform' && shareGroup !== usernameParts[2]) { + if (shareGroup !== 'platform' && shareGroup !== 'browser' && shareGroup !== usernameParts[2]) { return false } topic = sharedSubParts[2] diff --git a/forge/comms/commsClient.js b/forge/comms/commsClient.js index 134813cd70..9c43ccd784 100644 --- a/forge/comms/commsClient.js +++ b/forge/comms/commsClient.js @@ -52,16 +52,16 @@ class CommsClient extends EventEmitter { const ownerId = topicParts[4] const messageType = topicParts[5] - if (topicParts[2] === 'tab-presence') { - // ff/v1/tab-presence/// - const userId = topicParts[3] - const sessionId = topicParts[4] - const messageType = topicParts[5] + if (topicParts[2] === 'browser') { + // ff/v1/browser/tab-presence/// + const userId = topicParts[4] + const sessionId = topicParts[5] + const messageType = topicParts[6] let payload try { payload = JSON.parse(message.toString()) } catch (err) { - this.app.log.warn(`Ignoring malformed tab-presence payload on ${topic}: ${err.message}`) + this.app.log.warn(`Ignoring malformed browser payload on ${topic}: ${err.message}`) return } this.emit('tab-presence', { userId, sessionId, messageType, payload }) @@ -267,7 +267,7 @@ class CommsClient extends EventEmitter { // allows them to scale independently. '$share/expert/ff/v1/expert/+/+/platform/+/request', // Browser tab presence - shared subscription - '$share/platform/ff/v1/tab-presence/+/+/+' + '$share/browser/ff/v1/browser/tab-presence/+/+/+' ]) } } diff --git a/test/unit/forge/comms/authRoutesV2_spec.js b/test/unit/forge/comms/authRoutesV2_spec.js index c50e3acc95..a218f4010e 100644 --- a/test/unit/forge/comms/authRoutesV2_spec.js +++ b/test/unit/forge/comms/authRoutesV2_spec.js @@ -1580,31 +1580,31 @@ describe('Broker Auth v2 API', async function () { it('allows fe-team to publish heartbeat to own presence topic', async function () { await allowWrite({ username: teamFrontendUsername, - topic: `ff/v1/tab-presence/${TestObjects.alice.hashid}/session-abc12345/heartbeat` + topic: `ff/v1/browser/tab-presence/${TestObjects.alice.hashid}/session-abc12345/heartbeat` }) }) it('allows fe-team to publish context to own presence topic', async function () { await allowWrite({ username: teamFrontendUsername, - topic: `ff/v1/tab-presence/${TestObjects.alice.hashid}/session-abc12345/context` + topic: `ff/v1/browser/tab-presence/${TestObjects.alice.hashid}/session-abc12345/context` }) }) it('denies fe-team from publishing to another user\'s presence topic', async function () { await denyWrite({ username: teamFrontendUsername, - topic: `ff/v1/tab-presence/${bob.hashid}/session-abc12345/heartbeat` + topic: `ff/v1/browser/tab-presence/${bob.hashid}/session-abc12345/heartbeat` }) }) it('denies fe-team from publishing to an invalid presence message type', async function () { await denyWrite({ username: teamFrontendUsername, - topic: `ff/v1/tab-presence/${TestObjects.alice.hashid}/session-abc12345/invalid` + topic: `ff/v1/browser/tab-presence/${TestObjects.alice.hashid}/session-abc12345/invalid` }) }) it('allows forge_platform to subscribe to presence topics via shared subscription', async function () { await allowRead({ username: 'forge_platform', - topic: `$share/platform/ff/v1/tab-presence/${TestObjects.alice.hashid}/session-abc12345/heartbeat` + topic: `$share/browser/ff/v1/browser/tab-presence/${TestObjects.alice.hashid}/session-abc12345/heartbeat` }) }) }) From 8e043bfa1c68c07d22aebe03847c6cffe0e8bed1 Mon Sep 17 00:00:00 2001 From: Stephen McLaughlin <44235289+Steve-Mcl@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:32:42 +0100 Subject: [PATCH 4/5] Update forge/comms/aclManager.js --- forge/comms/aclManager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge/comms/aclManager.js b/forge/comms/aclManager.js index 7b094e7dac..cb168eae07 100644 --- a/forge/comms/aclManager.js +++ b/forge/comms/aclManager.js @@ -492,7 +492,7 @@ module.exports = function (app) { // Uses [^/]+ for the message-type segment because the subscription wildcard (+) // is matched as a literal character. The publish-side ACL on teamFrontend // already restricts to heartbeat|context. - { topic: /^ff\/v1\/browser\/tab-presence\/[^/]+\/[^/]+\/[^/]+$/, shared: true } + { topic: /^ff\/v1\/browser\/tab-presence\/[^/]+\/[^/]+\/[^/]+$/ } ], pub: [ // Send commands to project launchers From 4f2fc4c21b8676ae949900b0d29c83db5a88a30f Mon Sep 17 00:00:00 2001 From: cstns Date: Thu, 13 Aug 2026 21:10:41 +0300 Subject: [PATCH 5/5] Enable shared subscriptions for tab presence topics --- forge/comms/aclManager.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/forge/comms/aclManager.js b/forge/comms/aclManager.js index cb168eae07..7b094e7dac 100644 --- a/forge/comms/aclManager.js +++ b/forge/comms/aclManager.js @@ -492,7 +492,7 @@ module.exports = function (app) { // Uses [^/]+ for the message-type segment because the subscription wildcard (+) // is matched as a literal character. The publish-side ACL on teamFrontend // already restricts to heartbeat|context. - { topic: /^ff\/v1\/browser\/tab-presence\/[^/]+\/[^/]+\/[^/]+$/ } + { topic: /^ff\/v1\/browser\/tab-presence\/[^/]+\/[^/]+\/[^/]+$/, shared: true } ], pub: [ // Send commands to project launchers