Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 28 additions & 8 deletions forge/comms/aclManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@
*/
module.exports = function (app) {
const expertRbacToolCheck = async (teamMembership, application, toolName) => {
const applicationHash = typeof application === 'object' ? application.hashid : application
const applicationHash = (application && typeof application === 'object') ? application.hashid : application
if (toolName === 'expert:status-message') {
return true
}
if (toolName.startsWith('platform:')) {
return app.hasPermission(teamMembership, 'expert:insights:mcp:tool:allow')
}
// TODO: Understand all automations and which permissions they should require.
// For now, basic starter automations are added here, any not matching this list will require project:flows:edit permission
const toolAccessPermission = {
Expand Down Expand Up @@ -230,14 +233,15 @@ module.exports = function (app) {
throw ValidationError('user is not a member of the team that owns this project')
}

// check expert assistant feature is enabled for the team (support agent uses MQTT)
// check expert feature is enabled for the team (support agent uses MQTT)
if (acl.isClient) {
const team = await app.db.models.Team.byId(teamId)
if (team) {
await team.ensureTeamTypeExists()
const isAiEnabled = !!(app.config.features.enabled('ai') && team.getFeatureProperty('ai', true))
const isExpertAssistantEnabled = !!(app.config.features.enabled('expertAssistant') && team.getFeatureProperty('expertAssistant', true))
if (!isAiEnabled || !isExpertAssistantEnabled) {
const isExpertPlatformAutomationEnabled = !!(app.config.features.enabled('expertPlatformAutomation') && team.getFeatureProperty('expertPlatformAutomation', true))
if (!isAiEnabled || (!isExpertAssistantEnabled && !isExpertPlatformAutomationEnabled)) {
throw ValidationError('expert assistant feature is not enabled for this team')
}
}
Expand Down Expand Up @@ -285,10 +289,15 @@ module.exports = function (app) {
{ topic: /^ff\/v1\/[^/]+\/d\/[^/]+\/logs\/heartbeat$/ },
// ff/v1/<team>/d/<device>/resources/heartbeat
{ topic: /^ff\/v1\/[^/]+\/d\/[^/]+\/resources\/heartbeat$/ },
// ff/v1/<team>/p/<project>/editor/heartbeat
{ topic: /^ff\/v1\/[^/]+\/p\/[^/]+\/editor\/heartbeat$/ },
// ff/v1/platform/sync
{ topic: /^ff\/v1\/platform\/sync$/ },
// ff/v1/platform/leader
{ topic: /^ff\/v1\/platform\/leader$/ }
{ topic: /^ff\/v1\/platform\/leader$/ },
// Receive MCP dispatch inflight responses from the user's browser
// - ff/v1/mcp/<userid>/<sessionid>/<entityType>/<entityId>/support/inflight/<inflightType>/response
{ topic: /^ff\/v1\/mcp\/[^/]+\/[^/]+\/[^/]+\/[^/]+\/support\/inflight\/[^/]+\/response$/ }
],
pub: [
// Send commands to project launchers
Expand All @@ -306,7 +315,10 @@ module.exports = function (app) {
// ff/v1/platform/sync
{ topic: /^ff\/v1\/platform\/sync$/ },
// ff/v1/platform/leader
{ topic: /^ff\/v1\/platform\/leader$/ }
{ topic: /^ff\/v1\/platform\/leader$/ },
// Send MCP dispatch inflight requests to the user's browser
// - ff/v1/mcp/<userid>/<sessionid>/<entityType>/<entityId>/support/inflight/<inflightType>/request
{ topic: /^ff\/v1\/mcp\/[^/]+\/[^/]+\/[^/]+\/[^/]+\/support\/inflight\/[^/]+\/request$/ }
]
},
project: {
Expand Down Expand Up @@ -357,7 +369,9 @@ module.exports = function (app) {
// - ff/v1/<team>/d/<device/logs/heartbeat
{ topic: /^ff\/v1\/([^/]+)\/d\/([^/]+)\/logs\/heartbeat$/, verify: 'checkDeviceIsAssigned' },
// - ff/v1/<team>/d/<device/resources/heartbeat
{ topic: /^ff\/v1\/([^/]+)\/d\/([^/]+)\/resources\/heartbeat$/, verify: 'checkDeviceIsAssigned' }
{ topic: /^ff\/v1\/([^/]+)\/d\/([^/]+)\/resources\/heartbeat$/, verify: 'checkDeviceIsAssigned' },
// - ff/v1/<team>/p/<project>/editor/heartbeat
{ topic: /^ff\/v1\/([^/]+)\/p\/([^/]+)\/editor\/heartbeat$/, verify: 'checkTeamId' }
]
},
// frontend client (user)
Expand All @@ -367,14 +381,20 @@ module.exports = function (app) {
// topic captures, 0 = full topic, 1 = userid, 2 = sessionid, 3 = entity type (a|p|d|t), 4 = entity id, 5 = inflight type (only for inflight topics)
// example topic: ff/v1/expert/user123/session123/p/abc-123-456-789/support/chat/response
{ topic: /^ff\/v1\/expert\/([^/]+)\/([^/]+)\/([^/]+)\/([^/]+)\/support\/chat\/response$/, verify: 'checkExpertTopic', channel: 'chat', allowWildcard: { entity: true }, isClient: true, isSub: true },
{ topic: /^ff\/v1\/expert\/([^/]+)\/([^/]+)\/([^/]+)\/([^/]+)\/support\/inflight\/([^/]+)\/request$/, verify: 'checkExpertTopic', channel: 'inflight', allowWildcard: { entity: true, inflightType: true }, isClient: true, isSub: true }
{ topic: /^ff\/v1\/expert\/([^/]+)\/([^/]+)\/([^/]+)\/([^/]+)\/support\/inflight\/([^/]+)\/request$/, verify: 'checkExpertTopic', channel: 'inflight', allowWildcard: { entity: true, inflightType: true }, isClient: true, isSub: true },
// MCP dispatch inflight requests (from forge platform to user's browser)
{ topic: /^ff\/v1\/mcp\/([^/]+)\/([^/]+)\/([^/]+)\/([^/]+)\/support\/inflight\/([^/]+)\/request$/, verify: 'checkExpertTopic', channel: 'inflight', allowWildcard: { entity: true, inflightType: true }, isClient: true, isSub: true }
],
pub: [
// topic: ff/v1/expert/<userid>/<sessionid>/<a|p|d|t>/<appid|projid|devid|teamid>/support/chat/request
// topic captures, 0 = full topic, 1 = userid, 2 = sessionid, 3 = entity type (a|p|d|t), 4 = entity id, 5 = inflight type (only for inflight topics)
// example topic: ff/v1/expert/user123/session123/p/abc-111-222-333/support/chat/request
{ topic: /^ff\/v1\/expert\/([^/]+)\/([^/]+)\/([tapd])\/([^/]+)\/support\/chat\/request$/, verify: 'checkExpertTopic', channel: 'chat', isClient: true, isPub: true },
{ topic: /^ff\/v1\/expert\/([^/]+)\/([^/]+)\/([tapd])\/([^/]+)\/support\/inflight\/([^/]+)\/response$/, verify: 'checkExpertTopic', channel: 'inflight', isClient: true, isPub: true }
{ topic: /^ff\/v1\/expert\/([^/]+)\/([^/]+)\/([tapd])\/([^/]+)\/support\/inflight\/([^/]+)\/response$/, verify: 'checkExpertTopic', channel: 'inflight', isClient: true, isPub: true },
// MCP dispatch inflight responses (from user's browser back to forge platform)
{ topic: /^ff\/v1\/mcp\/([^/]+)\/([^/]+)\/([tapd])\/([^/]+)\/support\/inflight\/([^/]+)\/response$/, verify: 'checkExpertTopic', channel: 'inflight', isClient: true, isPub: true },
// - ff/v1/<team>/p/<project>/editor/heartbeat
{ topic: /^ff\/v1\/[^/]+\/p\/[^/]+\/editor\/heartbeat$/ }
]
},
// backend client (agent)
Expand Down
33 changes: 29 additions & 4 deletions forge/comms/commsClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,33 @@ class CommsClient extends EventEmitter {
const ownerId = topicParts[4]
const messageType = topicParts[5]
if (ownerType === 'p') {
this.emit('status/project', {
id: ownerId,
status: message.toString()
})
if (messageType === 'editor' && topicParts[6] === 'heartbeat') {
try {
const payload = JSON.parse(message.toString())
const teamId = topicParts[2]
if (payload.action === 'alive') {
this.emit('editor/heartbeat', {
projectId: ownerId,
teamId,
sessionId: payload.sessionId,
userId: payload.userId,
timestamp: Date.now()
})
} else if (payload.action === 'leaving') {
this.emit('editor/leaving', {
projectId: ownerId,
teamId
})
}
} catch (_) {
// ignore malformed payloads
}
} else {
this.emit('status/project', {
id: ownerId,
status: message.toString()
})
}
} else if (ownerType === 'd') {
if (messageType === 'status') {
this.emit('status/device', {
Expand Down Expand Up @@ -123,6 +146,8 @@ class CommsClient extends EventEmitter {
'ff/v1/+/d/+/logs/heartbeat',
// Device response heartbeat
'ff/v1/+/d/+/resources/heartbeat',
// Editor session heartbeat
'ff/v1/+/p/+/editor/heartbeat',
// Platform sync messages
'ff/v1/platform/sync'
])
Expand Down
77 changes: 77 additions & 0 deletions forge/comms/editorSessions.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/**
* This module tracks which browser sessions have the NR editor open
* for which instances. It follows the same heartbeat pattern as device
* log heartbeats in devices.js.
*
* Heartbeats arrive via MQTT on: ff/v1/<teamId>/p/<projectId>/editor/heartbeat
* Payload: JSON { sessionId, userId, action: 'alive'|'leaving' }
*/

class EditorSessionHandler {
// Browser background tabs throttle timers to ~60s; allow 2 missed
// intervals before considering a session stale.
static STALE_THRESHOLD_MS = 120_000

/**
* @param {import('../forge').ForgeApplication} app Fastify app
* @param {import('./commsClient').CommsClient} client Comms Client
*/
constructor (app, client) {
this.app = app
this.client = client

// editorSessions[projectId] = { sessionId, userId, teamId, lastHeartbeat }
this.editorSessions = {}
this.sweepInterval = -1

// Listen for editor heartbeat events from the comms client
client.on('editor/heartbeat', (beat) => {
this.editorSessions[beat.projectId] = {
sessionId: beat.sessionId,
userId: beat.userId,
teamId: beat.teamId,
lastHeartbeat: beat.timestamp
}
})

client.on('editor/leaving', (beat) => {
delete this.editorSessions[beat.projectId]
})

// Sweep stale entries every 30 seconds
this.sweepInterval = setInterval(() => {
const now = Date.now()
for (const [projectId, session] of Object.entries(this.editorSessions)) {
if (now - session.lastHeartbeat > EditorSessionHandler.STALE_THRESHOLD_MS) {
delete this.editorSessions[projectId]
}
}
}, 30_000)
}

/**
* Get the active editor session for a project, if any.
* @param {string} projectId - The project/instance ID
* @returns {object|null} Session info { sessionId, userId, teamId, lastHeartbeat } or null
*/
getActiveSession (projectId) {
const session = this.editorSessions[projectId]
if (!session) return null
if (Date.now() - session.lastHeartbeat > EditorSessionHandler.STALE_THRESHOLD_MS) {
delete this.editorSessions[projectId]
return null
}
return session
}

/**
* Stop the sweep interval (for clean shutdown)
*/
stop () {
clearInterval(this.sweepInterval)
}
}

module.exports = {
EditorSessionHandler: (app, client) => new EditorSessionHandler(app, client)
}
6 changes: 6 additions & 0 deletions forge/comms/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ const fp = require('fastify-plugin')
const ACLManager = require('./aclManager')
const { CommsClient } = require('./commsClient')
const { DeviceCommsHandler } = require('./devices')
const { EditorSessionHandler } = require('./editorSessions')

/**
* This module represents the real-time comms component of the platform.
Expand Down Expand Up @@ -31,6 +32,9 @@ module.exports = fp(async function (app, _opts) {
// Create the handler for any device-related messages
const deviceCommsHandler = DeviceCommsHandler(app, client)

// Create the handler for editor session heartbeats
const editorSessionHandler = EditorSessionHandler(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`
// client.on('status/project', (status) => {
Expand All @@ -40,6 +44,7 @@ module.exports = fp(async function (app, _opts) {
// Setup the platform API for the comms component
app.decorate('comms', {
devices: deviceCommsHandler,
editorSessions: editorSessionHandler,
aclManager: ACLManager(app),
platform: {
settings: {
Expand Down Expand Up @@ -73,6 +78,7 @@ module.exports = fp(async function (app, _opts) {
app.addHook('onClose', async (_) => {
app.log.info('Comms shutdown')
await deviceCommsHandler.stopLogWatcher()
editorSessionHandler.stop()
client.publish('ff/v1/platform/leader', JSON.stringify({ id: client.platformId, vote: -1 }))
await client.disconnect()
})
Expand Down
31 changes: 31 additions & 0 deletions forge/db/controllers/AccessToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,37 @@ module.exports = {
result.token = token
return result
},
createMCPToken: async function (app, user, scope, expiresAt, name) {
const userId = typeof user === 'number' ? user : user.id
const token = generateToken(32, 'ffmcp')
const tok = await app.db.models.AccessToken.create({
name,
token,
scope,
expiresAt,
ownerId: '' + userId,
ownerType: 'user'
})
const result = app.db.views.AccessToken.mcpTokenSummary(tok)
result.token = token
return result
},
updateMCPToken: async function (app, user, tokenId, scope, expiresAt) {
const userId = typeof user === 'number' ? user : user.id
const token = await app.db.models.AccessToken.byId(tokenId, 'user', userId)
if (token) {
token.scope = scope
if (expiresAt === undefined) {
token.expiresAt = null
} else {
token.expiresAt = expiresAt
}
await token.save()
} else {
throw new Error('Not Found')
}
return token
},
updatePersonalAccessToken: async function (app, user, tokenId, scope, expiresAt) {
const userId = typeof user === 'number' ? user : user.id
const token = await app.db.models.AccessToken.byId(tokenId, 'user', userId)
Expand Down
12 changes: 12 additions & 0 deletions forge/db/models/AccessToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,18 @@ module.exports = {
})
return tokens
},
getMCPTokens: async (user) => {
const tokens = this.findAll({
where: {
ownerType: 'user',
ownerId: '' + user.id,
scope: { [Op.like]: '%mcp:platform%' }
},
order: [['id', 'ASC']],
attributes: ['id', 'name', 'scope', 'expiresAt']
})
return tokens
},
getProjectHTTPTokens: async (project) => {
const tokens = this.findAll({
where: {
Expand Down
42 changes: 42 additions & 0 deletions forge/db/views/AccessToken.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,46 @@ module.exports = function (app) {
return tokenArray.map(token => personalAccessTokenSummary(token))
}

app.addSchema({
$id: 'MCPTokenSummary',
type: 'object',
// Composed via `allOf` elsewhere — keep open.
properties: {
id: { type: 'string' },
name: { type: 'string' },
expiresAt: { type: 'string', nullable: true }
},
required: ['id', 'name', 'expiresAt']
})
app.addSchema({
$id: 'MCPToken',
type: 'object',
allOf: [{ $ref: 'MCPTokenSummary' }],
properties: {
token: { type: 'string' }
},
required: ['token']
})

function mcpTokenSummary (token) {
const tokenSummary = {
id: token.hashid,
name: token.name,
expiresAt: token.expiresAt ?? null
}
return tokenSummary
}
app.addSchema({
$id: 'MCPTokenSummaryList',
type: 'array',
items: {
$ref: 'MCPTokenSummary'
}
})
function mcpTokenSummaryList (tokenArray) {
return tokenArray.map(token => mcpTokenSummary(token))
}

app.addSchema({
$id: 'InstanceHTTPTokenSummary',
type: 'object',
Expand Down Expand Up @@ -141,6 +181,8 @@ module.exports = function (app) {
provisioningTokenSummary,
personalAccessTokenSummary,
personalAccessTokenSummaryList,
mcpTokenSummary,
mcpTokenSummaryList,
instanceHTTPTokenSummary,
instanceHTTPTokenSummaryList
}
Expand Down
3 changes: 3 additions & 0 deletions forge/ee/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ module.exports = fp(async function (app, opts) {
// Set the expert assistant Feature Flag
app.config.features.register('expertAssistant', isAiEnabled && (app.config?.expert?.enabled ?? false), true)

// Set the expert platform automation Feature Flag (platform tools like create-instance, list-teams, etc.)
app.config.features.register('expertPlatformAutomation', isAiEnabled && (app.config?.expert?.enabled ?? false), true)

// temporary until FF Expert Insights can be enabled on Self Hosted EE instance
const isInsightsEnabled = isAiEnabled && app.config?.expert?.enabled && app.config?.expert?.insights?.enabled
app.config.features.register('expertInsights', isInsightsEnabled ?? false, false)
Expand Down
Loading
Loading