From cfb2b68e486caa8bcdc3e19af4224a3dfd254083 Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 20 May 2026 17:54:52 +0300 Subject: [PATCH 01/12] Add AI feature flag configuration --- etc/flowforge.yml | 10 +++++++++- forge/ee/lib/index.js | 3 +++ forge/lib/features.js | 2 ++ frontend/src/stores/account-settings.js | 1 + 4 files changed, 15 insertions(+), 1 deletion(-) diff --git a/etc/flowforge.yml b/etc/flowforge.yml index 6bf59658d9..42059a3fd7 100644 --- a/etc/flowforge.yml +++ b/etc/flowforge.yml @@ -67,6 +67,14 @@ driver: # public_url: ws://localhost:4881 +################################################# +# AI Configuration # +################################################# + +# ai: +# enabled: false + + ################################################# # Assistant Configuration # ################################################# @@ -127,4 +135,4 @@ rate_limits: # Create Default Admin # ################################################# # create_admin: false -# create_admin_access_token: false \ No newline at end of file +# create_admin_access_token: false diff --git a/forge/ee/lib/index.js b/forge/ee/lib/index.js index 75c472a704..83a5e97c79 100644 --- a/forge/ee/lib/index.js +++ b/forge/ee/lib/index.js @@ -38,6 +38,9 @@ module.exports = fp(async function (app, opts) { // Expert await app.register(require('./expert')) + // Set the AI Features Flag (global gate for all AI features) + app.config.features.register('ai', app.config?.ai?.enabled ?? false, true) + // Set the Generate Snapshot Description Feature Flag app.config.features.register('generatedSnapshotDescription', true, true) diff --git a/forge/lib/features.js b/forge/lib/features.js index 17bef62554..7be6973bd0 100644 --- a/forge/lib/features.js +++ b/forge/lib/features.js @@ -20,6 +20,7 @@ const featureList = [ 'instanceResources', 'tables', 'certifiedNodes', + 'ai', 'assistantInlineCompletions', 'generatedSnapshotDescription', 'ffNodes', @@ -47,6 +48,7 @@ const featureNames = { instanceResources: 'Instance Resources', tables: 'Tables', certifiedNodes: 'Certified Nodes', + ai: 'AI Features', assistantInlineCompletions: 'Assistant Inline Code Completions', generatedSnapshotDescription: 'Generate Snapshot Descriptions', ffNodes: 'FlowFuse Exclusive Nodes', diff --git a/frontend/src/stores/account-settings.js b/frontend/src/stores/account-settings.js index a2c1f5ed13..e47c1f0049 100644 --- a/frontend/src/stores/account-settings.js +++ b/frontend/src/stores/account-settings.js @@ -22,6 +22,7 @@ const FEATURE_CONFIGS = [ { output: 'isGitIntegrationFeatureEnabled', platformKey: 'gitIntegration', teamKey: 'gitIntegration' }, { output: 'isInstanceResourcesFeatureEnabled', platformKey: 'instanceResources', teamKey: 'instanceResources' }, { output: 'isTablesFeatureEnabled', platformKey: 'tables', teamKey: 'tables' }, + { output: 'isAiFeatureEnabled', platformKey: 'ai', teamKey: 'ai' }, { output: 'isGeneratedSnapshotDescriptionFeatureEnabled', platformKey: 'generatedSnapshotDescription', teamKey: 'generatedSnapshotDescription' }, { output: 'isApplicationsRBACFeatureEnabled', platformKey: 'rbacApplication', teamKey: 'rbacApplication' }, From 465ae8faca24237f5d023162d71171943e01487e Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 20 May 2026 18:41:28 +0300 Subject: [PATCH 02/12] Conditionally enable `generatedSnapshotDescription` and `assistantInlineCompletions` feature flags based on assistant configuration --- forge/ee/lib/index.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/forge/ee/lib/index.js b/forge/ee/lib/index.js index 83a5e97c79..c4a470b58d 100644 --- a/forge/ee/lib/index.js +++ b/forge/ee/lib/index.js @@ -42,10 +42,11 @@ module.exports = fp(async function (app, opts) { app.config.features.register('ai', app.config?.ai?.enabled ?? false, true) // Set the Generate Snapshot Description Feature Flag - app.config.features.register('generatedSnapshotDescription', true, true) + const isAssistantConfigured = app.config.assistant?.enabled === true && !!app.config.assistant?.service?.url + app.config.features.register('generatedSnapshotDescription', isAssistantConfigured, true) // Set the assistant inline completions Feature Flag - app.config.features.register('assistantInlineCompletions', true, true) + app.config.features.register('assistantInlineCompletions', isAssistantConfigured, true) // Set the expert assistant Feature Flag app.config.features.register('expertAssistant', app.config?.expert?.enabled ?? false, true) From 762c12d5a1cdcdcce012611037ba863a653c058c Mon Sep 17 00:00:00 2001 From: cstns Date: Wed, 20 May 2026 19:02:12 +0300 Subject: [PATCH 03/12] Enforce AI feature dependency checks for feature flags and assistant-related configurations --- forge/ee/lib/index.js | 9 +++++---- forge/routes/api/assistant.js | 3 ++- forge/routes/api/device.js | 3 ++- forge/routes/api/deviceLive.js | 5 +++-- forge/routes/api/project.js | 8 +++++--- frontend/src/stores/account-settings.js | 10 +++++++--- 6 files changed, 24 insertions(+), 14 deletions(-) diff --git a/forge/ee/lib/index.js b/forge/ee/lib/index.js index c4a470b58d..b96bd9e86b 100644 --- a/forge/ee/lib/index.js +++ b/forge/ee/lib/index.js @@ -39,20 +39,21 @@ module.exports = fp(async function (app, opts) { await app.register(require('./expert')) // Set the AI Features Flag (global gate for all AI features) - app.config.features.register('ai', app.config?.ai?.enabled ?? false, true) + const isAiEnabled = app.config?.ai?.enabled ?? false + app.config.features.register('ai', isAiEnabled, true) // Set the Generate Snapshot Description Feature Flag - const isAssistantConfigured = app.config.assistant?.enabled === true && !!app.config.assistant?.service?.url + const isAssistantConfigured = isAiEnabled && app.config.assistant?.enabled === true && !!app.config.assistant?.service?.url app.config.features.register('generatedSnapshotDescription', isAssistantConfigured, true) // Set the assistant inline completions Feature Flag app.config.features.register('assistantInlineCompletions', isAssistantConfigured, true) // Set the expert assistant Feature Flag - app.config.features.register('expertAssistant', app.config?.expert?.enabled ?? false, true) + app.config.features.register('expertAssistant', isAiEnabled && (app.config?.expert?.enabled ?? false), true) // temporary until FF Expert Insights can be enabled on Self Hosted EE instance - const isInsightsEnabled = app.config?.expert?.enabled && app.config?.expert?.insights?.enabled + const isInsightsEnabled = isAiEnabled && app.config?.expert?.enabled && app.config?.expert?.insights?.enabled app.config.features.register('expertInsights', isInsightsEnabled ?? false, false) } diff --git a/forge/routes/api/assistant.js b/forge/routes/api/assistant.js index d955ae592b..d916b74abe 100644 --- a/forge/routes/api/assistant.js +++ b/forge/routes/api/assistant.js @@ -178,9 +178,10 @@ module.exports = async function (app) { async (request, reply) => { const inlineDisabled = app.config.assistant?.completions?.inlineEnabled === false const featureEnabled = app.config.features.enabled('assistantInlineCompletions') + const isAiEnabledForTeam = request.team?.getFeatureProperty('ai', false) const featureEnabledForTeam = request.team?.getFeatureProperty('assistantInlineCompletions', false) const isStandaloneSessionUser = request.session.ownerType === 'user' - if (inlineDisabled || !featureEnabled || !(isStandaloneSessionUser || featureEnabledForTeam)) { + if (inlineDisabled || !featureEnabled || !(isStandaloneSessionUser || (isAiEnabledForTeam && featureEnabledForTeam))) { reply.code(404).send({ code: 'not_found', error: 'Not Found - feature not enabled for team' }) return } diff --git a/forge/routes/api/device.js b/forge/routes/api/device.js index a9167e422d..58f2612ac1 100644 --- a/forge/routes/api/device.js +++ b/forge/routes/api/device.js @@ -1190,9 +1190,10 @@ module.exports = async function (app) { await request.device.Team.ensureTeamTypeExists() const tier = app.license.get('tier') const isEnterprise = tier === 'enterprise' + const isAiEnabled = request.device.Team.getFeatureProperty('ai', false) const hasFeature = request.device.Team.getFeatureProperty('generatedSnapshotDescription', false) - if (!isEnterprise || !hasFeature) { + if (!isEnterprise || !isAiEnabled || !hasFeature) { return reply.code(404).send({ code: 'not_found' }) } } diff --git a/forge/routes/api/deviceLive.js b/forge/routes/api/deviceLive.js index 9262b9992f..5717f2005e 100644 --- a/forge/routes/api/deviceLive.js +++ b/forge/routes/api/deviceLive.js @@ -294,9 +294,10 @@ module.exports = async function (app) { tables: !!(app.config.features.enabled('tables') && team.getFeatureProperty('tables', true)) } - const assistantInlineCompletionsFeatureEnabled = !!(app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false)) + const isAiEnabledForTeam = team.getFeatureProperty('ai', false) + const assistantInlineCompletionsFeatureEnabled = !!(isAiEnabledForTeam && app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false)) response.assistant = { - enabled: app.config.assistant?.enabled || false, + enabled: isAiEnabledForTeam && (app.config.assistant?.enabled || false), requestTimeout: app.config.assistant?.requestTimeout || 60000, mcp: { enabled: true }, // default to enabled completions: { diff --git a/forge/routes/api/project.js b/forge/routes/api/project.js index 6af261ce62..f40663041d 100644 --- a/forge/routes/api/project.js +++ b/forge/routes/api/project.js @@ -849,9 +849,10 @@ module.exports = async function (app) { await request.project.Team.ensureTeamTypeExists() const team = request.project.Team - const assistantInlineCompletionsFeatureEnabled = !!(app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false)) + const isAiEnabledForTeam = team.getFeatureProperty('ai', false) + const assistantInlineCompletionsFeatureEnabled = !!(isAiEnabledForTeam && app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false)) settings.assistant = { - enabled: app.config.assistant?.enabled || false, + enabled: isAiEnabledForTeam && (app.config.assistant?.enabled || false), requestTimeout: app.config.assistant?.requestTimeout || 60000, mcp: { enabled: true }, // default to enabled completions: { @@ -1463,9 +1464,10 @@ module.exports = async function (app) { await request.project.Team.ensureTeamTypeExists() const tier = app.license.get('tier') const isEnterprise = tier === 'enterprise' + const isAiEnabled = request.project.Team.getFeatureProperty('ai', false) const hasFeature = request.project.Team.getFeatureProperty('generatedSnapshotDescription', false) - if (!isEnterprise || !hasFeature) { + if (!isEnterprise || !isAiEnabled || !hasFeature) { return reply.code(404).send({ code: 'not_found' }) } } diff --git a/frontend/src/stores/account-settings.js b/frontend/src/stores/account-settings.js index e47c1f0049..ea4f98cbcf 100644 --- a/frontend/src/stores/account-settings.js +++ b/frontend/src/stores/account-settings.js @@ -23,7 +23,7 @@ const FEATURE_CONFIGS = [ { output: 'isInstanceResourcesFeatureEnabled', platformKey: 'instanceResources', teamKey: 'instanceResources' }, { output: 'isTablesFeatureEnabled', platformKey: 'tables', teamKey: 'tables' }, { output: 'isAiFeatureEnabled', platformKey: 'ai', teamKey: 'ai' }, - { output: 'isGeneratedSnapshotDescriptionFeatureEnabled', platformKey: 'generatedSnapshotDescription', teamKey: 'generatedSnapshotDescription' }, + { output: 'isGeneratedSnapshotDescriptionFeatureEnabled', platformKey: 'generatedSnapshotDescription', teamKey: 'generatedSnapshotDescription', dependsOn: 'isAiFeatureEnabled' }, { output: 'isApplicationsRBACFeatureEnabled', platformKey: 'rbacApplication', teamKey: 'rbacApplication' }, // Team-only @@ -32,7 +32,7 @@ const FEATURE_CONFIGS = [ // Platform-only { output: 'isCertifiedNodesFeatureEnabled', platformKey: 'certifiedNodes' }, { output: 'isFlowFuseNodesFeatureEnabled', platformKey: 'ffNodes' }, - { output: 'isExpertAssistantFeatureEnabled', platformKey: 'expertAssistant' }, + { output: 'isExpertAssistantFeatureEnabled', platformKey: 'expertAssistant', dependsOn: 'isAiFeatureEnabled' }, { output: 'isInstanceAutoStackUpdateFeatureEnabled', platformKey: 'autoStackUpdate' }, { output: 'isDevOpsPipelinesFeatureEnabled', platformKey: 'devops-pipelines' }, { output: 'isExternalMqttBrokerFeatureEnabled', platformKey: 'externalBroker' } @@ -41,7 +41,7 @@ const FEATURE_CONFIGS = [ function buildFeatureChecks (state, team) { const checks = {} - for (const { output, platformKey, teamKey, optOut, platformSource } of FEATURE_CONFIGS) { + for (const { output, platformKey, teamKey, optOut, platformSource, dependsOn } of FEATURE_CONFIGS) { const platformCheckKey = `${output}ForPlatform` const teamCheckKey = `${output}ForTeam` @@ -66,6 +66,10 @@ function buildFeatureChecks (state, team) { } else if (teamKey) { checks[output] = checks[teamCheckKey] } + + if (dependsOn && !checks[dependsOn]) { + checks[output] = false + } } return checks From 93900f4db5ac622ea04bc2a75c63a514436f709a Mon Sep 17 00:00:00 2001 From: cstns Date: Thu, 21 May 2026 13:53:56 +0300 Subject: [PATCH 04/12] Enable AI features by default in configuration --- etc/flowforge.yml | 2 +- forge/ee/lib/index.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/etc/flowforge.yml b/etc/flowforge.yml index 42059a3fd7..949b8bdbd1 100644 --- a/etc/flowforge.yml +++ b/etc/flowforge.yml @@ -72,7 +72,7 @@ driver: ################################################# # ai: -# enabled: false +# enabled: true ################################################# diff --git a/forge/ee/lib/index.js b/forge/ee/lib/index.js index 83a5e97c79..b9d72cd14e 100644 --- a/forge/ee/lib/index.js +++ b/forge/ee/lib/index.js @@ -39,7 +39,7 @@ module.exports = fp(async function (app, opts) { await app.register(require('./expert')) // Set the AI Features Flag (global gate for all AI features) - app.config.features.register('ai', app.config?.ai?.enabled ?? false, true) + app.config.features.register('ai', app.config?.ai?.enabled ?? true, true) // Set the Generate Snapshot Description Feature Flag app.config.features.register('generatedSnapshotDescription', true, true) From 9cc3e9bc190db8a33cd3c47e162c0a1e45ae5b56 Mon Sep 17 00:00:00 2001 From: cstns Date: Thu, 21 May 2026 16:10:50 +0300 Subject: [PATCH 05/12] Refactor `TeamTypeEditDialog` to filter features dynamically based on platform-loaded settings --- .../TeamTypes/dialogs/TeamTypeEditDialog.vue | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue b/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue index 6842974da5..985aaf6eb9 100644 --- a/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue +++ b/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue @@ -172,9 +172,9 @@
Persistent File storage limit (Mb) Persistent Context storage limit (Mb) @@ -419,6 +419,14 @@ export default { }, computed: { ...mapState(useAccountSettingsStore, ['features']), + teamTypeFeatureList () { + return this.featureList.filter(feature => { + if (!Object.prototype.hasOwnProperty.call(this.features, feature)) { + return true + } + return this.features[feature] !== false + }) + }, formValid () { return (this.input.name) }, @@ -436,7 +444,8 @@ export default { return !!this.features.billing }, teamBrokerEnabled () { - return !!this.input.properties.features.teamBroker + const disabledOnPlatform = Object.prototype.hasOwnProperty.call(this.features, 'teamBroker') && this.features.teamBroker === false + return !disabledOnPlatform && !!this.input.properties.features.teamBroker }, autoStackUpdateEnforced () { return !!this.input.properties.autoStackUpdate?.enabled From da44fa589feddc082eb7ffa2c0be614bdd200266 Mon Sep 17 00:00:00 2001 From: cstns Date: Thu, 21 May 2026 17:17:58 +0300 Subject: [PATCH 06/12] Enable AI features for teams by default --- forge/routes/api/assistant.js | 2 +- forge/routes/api/device.js | 2 +- forge/routes/api/deviceLive.js | 2 +- forge/routes/api/project.js | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/forge/routes/api/assistant.js b/forge/routes/api/assistant.js index d916b74abe..e7fdf82bc9 100644 --- a/forge/routes/api/assistant.js +++ b/forge/routes/api/assistant.js @@ -178,7 +178,7 @@ module.exports = async function (app) { async (request, reply) => { const inlineDisabled = app.config.assistant?.completions?.inlineEnabled === false const featureEnabled = app.config.features.enabled('assistantInlineCompletions') - const isAiEnabledForTeam = request.team?.getFeatureProperty('ai', false) + const isAiEnabledForTeam = request.team?.getFeatureProperty('ai', true) const featureEnabledForTeam = request.team?.getFeatureProperty('assistantInlineCompletions', false) const isStandaloneSessionUser = request.session.ownerType === 'user' if (inlineDisabled || !featureEnabled || !(isStandaloneSessionUser || (isAiEnabledForTeam && featureEnabledForTeam))) { diff --git a/forge/routes/api/device.js b/forge/routes/api/device.js index 58f2612ac1..9912e192a1 100644 --- a/forge/routes/api/device.js +++ b/forge/routes/api/device.js @@ -1190,7 +1190,7 @@ module.exports = async function (app) { await request.device.Team.ensureTeamTypeExists() const tier = app.license.get('tier') const isEnterprise = tier === 'enterprise' - const isAiEnabled = request.device.Team.getFeatureProperty('ai', false) + const isAiEnabled = request.device.Team.getFeatureProperty('ai', true) const hasFeature = request.device.Team.getFeatureProperty('generatedSnapshotDescription', false) if (!isEnterprise || !isAiEnabled || !hasFeature) { diff --git a/forge/routes/api/deviceLive.js b/forge/routes/api/deviceLive.js index 5717f2005e..a69fc41f2b 100644 --- a/forge/routes/api/deviceLive.js +++ b/forge/routes/api/deviceLive.js @@ -294,7 +294,7 @@ module.exports = async function (app) { tables: !!(app.config.features.enabled('tables') && team.getFeatureProperty('tables', true)) } - const isAiEnabledForTeam = team.getFeatureProperty('ai', false) + const isAiEnabledForTeam = team.getFeatureProperty('ai', true) const assistantInlineCompletionsFeatureEnabled = !!(isAiEnabledForTeam && app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false)) response.assistant = { enabled: isAiEnabledForTeam && (app.config.assistant?.enabled || false), diff --git a/forge/routes/api/project.js b/forge/routes/api/project.js index f40663041d..059605729b 100644 --- a/forge/routes/api/project.js +++ b/forge/routes/api/project.js @@ -849,7 +849,7 @@ module.exports = async function (app) { await request.project.Team.ensureTeamTypeExists() const team = request.project.Team - const isAiEnabledForTeam = team.getFeatureProperty('ai', false) + const isAiEnabledForTeam = team.getFeatureProperty('ai', true) const assistantInlineCompletionsFeatureEnabled = !!(isAiEnabledForTeam && app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false)) settings.assistant = { enabled: isAiEnabledForTeam && (app.config.assistant?.enabled || false), @@ -1464,7 +1464,7 @@ module.exports = async function (app) { await request.project.Team.ensureTeamTypeExists() const tier = app.license.get('tier') const isEnterprise = tier === 'enterprise' - const isAiEnabled = request.project.Team.getFeatureProperty('ai', false) + const isAiEnabled = request.project.Team.getFeatureProperty('ai', true) const hasFeature = request.project.Team.getFeatureProperty('generatedSnapshotDescription', false) if (!isEnterprise || !isAiEnabled || !hasFeature) { From fd527a85e7419d13ff76c13bde10e57be3b94a16 Mon Sep 17 00:00:00 2001 From: cstns Date: Thu, 21 May 2026 17:46:31 +0300 Subject: [PATCH 07/12] Extract feature flag logic to a reusable composable and update AI feature dependency checks --- forge/routes/api/assistant.js | 4 +- forge/routes/api/device.js | 2 +- forge/routes/api/deviceLive.js | 6 +- forge/routes/api/project.js | 8 +- frontend/src/composables/FeatureChecks.js | 158 ++++++++++++++++++ frontend/src/stores/account-settings.js | 67 +------- .../frontend/stores/account-settings.spec.js | 41 +++++ 7 files changed, 210 insertions(+), 76 deletions(-) create mode 100644 frontend/src/composables/FeatureChecks.js diff --git a/forge/routes/api/assistant.js b/forge/routes/api/assistant.js index e7fdf82bc9..4902c5e4d0 100644 --- a/forge/routes/api/assistant.js +++ b/forge/routes/api/assistant.js @@ -178,10 +178,10 @@ module.exports = async function (app) { async (request, reply) => { const inlineDisabled = app.config.assistant?.completions?.inlineEnabled === false const featureEnabled = app.config.features.enabled('assistantInlineCompletions') - const isAiEnabledForTeam = request.team?.getFeatureProperty('ai', true) + const isAiEnabled = app.config.features.enabled('ai') && request.team?.getFeatureProperty('ai', true) const featureEnabledForTeam = request.team?.getFeatureProperty('assistantInlineCompletions', false) const isStandaloneSessionUser = request.session.ownerType === 'user' - if (inlineDisabled || !featureEnabled || !(isStandaloneSessionUser || (isAiEnabledForTeam && featureEnabledForTeam))) { + if (inlineDisabled || !featureEnabled || !(isStandaloneSessionUser || (isAiEnabled && featureEnabledForTeam))) { reply.code(404).send({ code: 'not_found', error: 'Not Found - feature not enabled for team' }) return } diff --git a/forge/routes/api/device.js b/forge/routes/api/device.js index 9912e192a1..d8b2d19546 100644 --- a/forge/routes/api/device.js +++ b/forge/routes/api/device.js @@ -1190,7 +1190,7 @@ module.exports = async function (app) { await request.device.Team.ensureTeamTypeExists() const tier = app.license.get('tier') const isEnterprise = tier === 'enterprise' - const isAiEnabled = request.device.Team.getFeatureProperty('ai', true) + const isAiEnabled = app.config.features.enabled('ai') && request.device.Team.getFeatureProperty('ai', true) const hasFeature = request.device.Team.getFeatureProperty('generatedSnapshotDescription', false) if (!isEnterprise || !isAiEnabled || !hasFeature) { diff --git a/forge/routes/api/deviceLive.js b/forge/routes/api/deviceLive.js index a69fc41f2b..5e0a77de63 100644 --- a/forge/routes/api/deviceLive.js +++ b/forge/routes/api/deviceLive.js @@ -294,10 +294,10 @@ module.exports = async function (app) { tables: !!(app.config.features.enabled('tables') && team.getFeatureProperty('tables', true)) } - const isAiEnabledForTeam = team.getFeatureProperty('ai', true) - const assistantInlineCompletionsFeatureEnabled = !!(isAiEnabledForTeam && app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false)) + const isAiEnabled = app.config.features.enabled('ai') && team.getFeatureProperty('ai', true) + const assistantInlineCompletionsFeatureEnabled = !!(isAiEnabled && app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false)) response.assistant = { - enabled: isAiEnabledForTeam && (app.config.assistant?.enabled || false), + enabled: isAiEnabled && (app.config.assistant?.enabled || false), requestTimeout: app.config.assistant?.requestTimeout || 60000, mcp: { enabled: true }, // default to enabled completions: { diff --git a/forge/routes/api/project.js b/forge/routes/api/project.js index 059605729b..29cd505a93 100644 --- a/forge/routes/api/project.js +++ b/forge/routes/api/project.js @@ -849,10 +849,10 @@ module.exports = async function (app) { await request.project.Team.ensureTeamTypeExists() const team = request.project.Team - const isAiEnabledForTeam = team.getFeatureProperty('ai', true) - const assistantInlineCompletionsFeatureEnabled = !!(isAiEnabledForTeam && app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false)) + const isAiEnabled = app.config.features.enabled('ai') && team.getFeatureProperty('ai', true) + const assistantInlineCompletionsFeatureEnabled = !!(isAiEnabled && app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false)) settings.assistant = { - enabled: isAiEnabledForTeam && (app.config.assistant?.enabled || false), + enabled: isAiEnabled && (app.config.assistant?.enabled || false), requestTimeout: app.config.assistant?.requestTimeout || 60000, mcp: { enabled: true }, // default to enabled completions: { @@ -1464,7 +1464,7 @@ module.exports = async function (app) { await request.project.Team.ensureTeamTypeExists() const tier = app.license.get('tier') const isEnterprise = tier === 'enterprise' - const isAiEnabled = request.project.Team.getFeatureProperty('ai', true) + const isAiEnabled = app.config.features.enabled('ai') && request.project.Team.getFeatureProperty('ai', true) const hasFeature = request.project.Team.getFeatureProperty('generatedSnapshotDescription', false) if (!isEnterprise || !isAiEnabled || !hasFeature) { diff --git a/frontend/src/composables/FeatureChecks.js b/frontend/src/composables/FeatureChecks.js new file mode 100644 index 0000000000..eff4702c55 --- /dev/null +++ b/frontend/src/composables/FeatureChecks.js @@ -0,0 +1,158 @@ +/** + * Feature flag configuration array. Each entry defines how a feature's availability + * is computed from platform settings and team type properties. + * + * For each entry, `buildFeatureChecks` produces up to three computed properties: + * - `{output}ForPlatform` - platform-level check result (only if `platformKey` is set) + * - `{output}ForTeam` - team-level check result (only if `teamKey` is set) + * - `{output}` - combined result (see combination rules below) + * + * @typedef {Object} FeatureConfig + * + * @property {string} output - Name of the computed property for the combined result. + * Always required. Convention: `is{Feature}FeatureEnabled`. + * + * @property {string} [platformKey] - Key to look up in the platform features object + * (state.features). If truthy, the platform check passes. + * + * @property {string} [teamKey] - Key to look up in team type properties + * (team.type.properties.features). If truthy or if the team type has + * `enableAllFeatures: true`, the team check passes. + * + * @property {boolean} [optOut=false] - Controls the default behavior of the team check. + * - `false` (default, opt-in): feature is disabled unless the team type explicitly + * enables it or has `enableAllFeatures: true`. + * - `true` (opt-out): feature is enabled by default. Only disabled if the team type + * explicitly sets it to `false`. If the property is `undefined`, it is treated as enabled. + * Only meaningful when `teamKey` is set. Ignored otherwise. + * + * @property {string} [platformSource] - Override where the platform value is read from. + * - `undefined` (default): reads from `state.features` (the platform feature flags) + * - `'settings'`: reads from `state.settings.features` instead (for features exposed + * through the settings object rather than the feature flags system) + * Only meaningful when `platformKey` is set. Ignored otherwise. + * + * @property {string} [dependsOn] - The `output` name of another feature that must be + * enabled (combined check) for this feature to be enabled. If the referenced feature's + * combined result is `false`, this feature is forced to `false`. + * The dependency must appear earlier in the array to ensure it is computed first. + * + * @property {string} [dependsOnPlatform] - A platform feature key that must be enabled + * for this feature to be enabled. Checked directly against the platform features object, + * independent of whether the dependency has its own entry in FEATURE_CONFIGS. + * + * @property {string} [dependsOnPlatformSource] - Override where the `dependsOnPlatform` + * value is read from, same semantics as `platformSource`. + * Only meaningful when `dependsOnPlatform` is set. Ignored otherwise. + * + * @property {string} [dependsOnTeam] - A team feature key that must be enabled for this + * feature to be enabled. Checked directly against the team type properties. + * + * @property {boolean} [dependsOnTeamOptOut] - Controls the default behavior of the + * `dependsOnTeam` check, same semantics as `optOut`. + * Only meaningful when `dependsOnTeam` is set. Ignored otherwise. + * + * Combination rules for the `{output}` property: + * - If both `platformKey` and `teamKey` are set: `platform AND team` + * - If only `platformKey` is set: `platform` only (platform-only feature) + * - If only `teamKey` is set: `team` only (team-only feature) + * - After the base combination, all `dependsOn*` gates are applied. If any + * dependency check fails, the combined result is forced to `false`. + * + * At least one of `platformKey` or `teamKey` must be provided. + * `dependsOn`, `dependsOnPlatform`, and `dependsOnTeam` can be used together. + */ +export const FEATURE_CONFIGS = [ + { output: 'isSharedLibraryFeatureEnabled', platformKey: 'shared-library', teamKey: 'shared-library', optOut: true }, + { output: 'isBlueprintsFeatureEnabled', platformKey: 'flowBlueprints', teamKey: 'flowBlueprints', optOut: true }, + { output: 'isCustomCatalogsFeatureEnabled', platformKey: 'customCatalogs', teamKey: 'customCatalogs', optOut: true }, + { output: 'isPrivateRegistryFeatureEnabled', platformKey: 'npm', teamKey: 'npm' }, + { output: 'isStaticAssetsFeatureEnabled', platformKey: 'staticAssets', teamKey: 'staticAssets' }, + { output: 'isHTTPBearerTokensFeatureEnabled', platformKey: 'httpBearerTokens', teamKey: 'teamHttpSecurity', platformSource: 'settings' }, + { output: 'isBOMFeatureEnabled', platformKey: 'bom', teamKey: 'bom' }, + { output: 'isTimelineFeatureEnabled', platformKey: 'projectHistory', teamKey: 'projectHistory' }, + { output: 'isMqttBrokerFeatureEnabled', platformKey: 'teamBroker', teamKey: 'teamBroker' }, + { output: 'isGitIntegrationFeatureEnabled', platformKey: 'gitIntegration', teamKey: 'gitIntegration' }, + { output: 'isInstanceResourcesFeatureEnabled', platformKey: 'instanceResources', teamKey: 'instanceResources' }, + { output: 'isTablesFeatureEnabled', platformKey: 'tables', teamKey: 'tables' }, + { output: 'isAiFeatureEnabled', platformKey: 'ai', teamKey: 'ai' }, + { + output: 'isGeneratedSnapshotDescriptionFeatureEnabled', + platformKey: 'generatedSnapshotDescription', + teamKey: 'generatedSnapshotDescription', + dependsOnPlatform: 'ai' + }, + { output: 'isApplicationsRBACFeatureEnabled', platformKey: 'rbacApplication', teamKey: 'rbacApplication' }, + + // Team-only + { output: 'isDeviceGroupsFeatureEnabled', teamKey: 'deviceGroups' }, + + // Platform-only + { output: 'isCertifiedNodesFeatureEnabled', platformKey: 'certifiedNodes' }, + { output: 'isFlowFuseNodesFeatureEnabled', platformKey: 'ffNodes' }, + { output: 'isExpertAssistantFeatureEnabled', platformKey: 'expertAssistant', dependsOnPlatform: 'ai' }, + { output: 'isInstanceAutoStackUpdateFeatureEnabled', platformKey: 'autoStackUpdate' }, + { output: 'isDevOpsPipelinesFeatureEnabled', platformKey: 'devops-pipelines' }, + { output: 'isExternalMqttBrokerFeatureEnabled', platformKey: 'externalBroker' } +] + +function isPlatformFeatureEnabled (state, platformKey, platformSource) { + const source = platformSource === 'settings' ? state.settings?.features : state.features + return !!source?.[platformKey] +} + +function isTeamFeatureEnabled (team, teamKey, optOut) { + if (optOut) { + const flag = team?.type?.properties?.features?.[teamKey] + return (flag === undefined || !!flag) || !!team?.type?.properties?.enableAllFeatures + } + return !!team?.type?.properties?.features?.[teamKey] || !!team?.type?.properties?.enableAllFeatures +} + +function applyDependencyGates (checks, output, { dependsOn, dependsOnPlatform, dependsOnTeam, dependsOnPlatformSource, dependsOnTeamOptOut }, state, team) { + if (dependsOn && !checks[dependsOn]) { + checks[output] = false + } + if (dependsOnPlatform && !isPlatformFeatureEnabled(state, dependsOnPlatform, dependsOnPlatformSource)) { + checks[output] = false + } + if (dependsOnTeam && !isTeamFeatureEnabled(team, dependsOnTeam, dependsOnTeamOptOut)) { + checks[output] = false + } +} + +export function buildFeatureChecks (state, team) { + const checks = {} + + for (const config of FEATURE_CONFIGS) { + const { + output, + platformKey, + teamKey, + optOut, + platformSource + } = config + const platformCheckKey = `${output}ForPlatform` + const teamCheckKey = `${output}ForTeam` + + if (platformKey) { + checks[platformCheckKey] = isPlatformFeatureEnabled(state, platformKey, platformSource) + } + + if (teamKey) { + checks[teamCheckKey] = isTeamFeatureEnabled(team, teamKey, optOut) + } + + if (platformKey && teamKey) { + checks[output] = checks[platformCheckKey] && checks[teamCheckKey] + } else if (platformKey) { + checks[output] = checks[platformCheckKey] + } else if (teamKey) { + checks[output] = checks[teamCheckKey] + } + + applyDependencyGates(checks, output, config, state, team) + } + + return checks +} diff --git a/frontend/src/stores/account-settings.js b/frontend/src/stores/account-settings.js index ea4f98cbcf..c6fbd133c9 100644 --- a/frontend/src/stores/account-settings.js +++ b/frontend/src/stores/account-settings.js @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import settingsApi from '@/api/settings.js' +import { buildFeatureChecks } from '@/composables/FeatureChecks.js' import { useAccountAuthStore } from '@/stores/account-auth.js' import { useContextStore } from '@/stores/context.js' @@ -9,72 +10,6 @@ export const POSTHOG_FLAGS = { EXPERT_COMMS_BETA_ENABLED: 'EXPERT_COMMS_BETA_ENABLED' } -const FEATURE_CONFIGS = [ - { output: 'isSharedLibraryFeatureEnabled', platformKey: 'shared-library', teamKey: 'shared-library', optOut: true }, - { output: 'isBlueprintsFeatureEnabled', platformKey: 'flowBlueprints', teamKey: 'flowBlueprints', optOut: true }, - { output: 'isCustomCatalogsFeatureEnabled', platformKey: 'customCatalogs', teamKey: 'customCatalogs', optOut: true }, - { output: 'isPrivateRegistryFeatureEnabled', platformKey: 'npm', teamKey: 'npm' }, - { output: 'isStaticAssetsFeatureEnabled', platformKey: 'staticAssets', teamKey: 'staticAssets' }, - { output: 'isHTTPBearerTokensFeatureEnabled', platformKey: 'httpBearerTokens', teamKey: 'teamHttpSecurity', platformSource: 'settings' }, - { output: 'isBOMFeatureEnabled', platformKey: 'bom', teamKey: 'bom' }, - { output: 'isTimelineFeatureEnabled', platformKey: 'projectHistory', teamKey: 'projectHistory' }, - { output: 'isMqttBrokerFeatureEnabled', platformKey: 'teamBroker', teamKey: 'teamBroker' }, - { output: 'isGitIntegrationFeatureEnabled', platformKey: 'gitIntegration', teamKey: 'gitIntegration' }, - { output: 'isInstanceResourcesFeatureEnabled', platformKey: 'instanceResources', teamKey: 'instanceResources' }, - { output: 'isTablesFeatureEnabled', platformKey: 'tables', teamKey: 'tables' }, - { output: 'isAiFeatureEnabled', platformKey: 'ai', teamKey: 'ai' }, - { output: 'isGeneratedSnapshotDescriptionFeatureEnabled', platformKey: 'generatedSnapshotDescription', teamKey: 'generatedSnapshotDescription', dependsOn: 'isAiFeatureEnabled' }, - { output: 'isApplicationsRBACFeatureEnabled', platformKey: 'rbacApplication', teamKey: 'rbacApplication' }, - - // Team-only - { output: 'isDeviceGroupsFeatureEnabled', teamKey: 'deviceGroups' }, - - // Platform-only - { output: 'isCertifiedNodesFeatureEnabled', platformKey: 'certifiedNodes' }, - { output: 'isFlowFuseNodesFeatureEnabled', platformKey: 'ffNodes' }, - { output: 'isExpertAssistantFeatureEnabled', platformKey: 'expertAssistant', dependsOn: 'isAiFeatureEnabled' }, - { output: 'isInstanceAutoStackUpdateFeatureEnabled', platformKey: 'autoStackUpdate' }, - { output: 'isDevOpsPipelinesFeatureEnabled', platformKey: 'devops-pipelines' }, - { output: 'isExternalMqttBrokerFeatureEnabled', platformKey: 'externalBroker' } -] - -function buildFeatureChecks (state, team) { - const checks = {} - - for (const { output, platformKey, teamKey, optOut, platformSource, dependsOn } of FEATURE_CONFIGS) { - const platformCheckKey = `${output}ForPlatform` - const teamCheckKey = `${output}ForTeam` - - if (platformKey) { - const source = platformSource === 'settings' ? state.settings?.features : state.features - checks[platformCheckKey] = !!source?.[platformKey] - } - - if (teamKey) { - if (optOut) { - const flag = team?.type?.properties?.features?.[teamKey] - checks[teamCheckKey] = (flag === undefined || !!flag) || !!team?.type?.properties?.enableAllFeatures - } else { - checks[teamCheckKey] = !!team?.type?.properties?.features?.[teamKey] || !!team?.type?.properties?.enableAllFeatures - } - } - - if (platformKey && teamKey) { - checks[output] = checks[platformCheckKey] && checks[teamCheckKey] - } else if (platformKey) { - checks[output] = checks[platformCheckKey] - } else if (teamKey) { - checks[output] = checks[teamCheckKey] - } - - if (dependsOn && !checks[dependsOn]) { - checks[output] = false - } - } - - return checks -} - export const useAccountSettingsStore = defineStore('account-settings', { state: () => ({ settings: null, diff --git a/test/unit/frontend/stores/account-settings.spec.js b/test/unit/frontend/stores/account-settings.spec.js index a5409da9b7..36a8dd7507 100644 --- a/test/unit/frontend/stores/account-settings.spec.js +++ b/test/unit/frontend/stores/account-settings.spec.js @@ -216,6 +216,47 @@ describe('account-settings store', () => { // flag undefined on team type → defaults to enabled expect(store.featuresCheck.isSharedLibraryFeatureEnabledForTeam).toBe(true) }) + + it('isGeneratedSnapshotDescriptionFeatureEnabled requires platform and team ai', () => { + mockTeam({ team: { id: 'team-1', billing: {}, type: { properties: { features: { generatedSnapshotDescription: true, ai: true }, billing: {}, instances: {} } } } }) + const store = useAccountSettingsStore() + store.setSettings({ features: { generatedSnapshotDescription: true, ai: true } }) + expect(store.featuresCheck.isGeneratedSnapshotDescriptionFeatureEnabled).toBe(true) + }) + + it('isGeneratedSnapshotDescriptionFeatureEnabled is false when platform ai is disabled', () => { + mockTeam({ team: { id: 'team-1', billing: {}, type: { properties: { features: { generatedSnapshotDescription: true, ai: true }, billing: {}, instances: {} } } } }) + const store = useAccountSettingsStore() + store.setSettings({ features: { generatedSnapshotDescription: true, ai: false } }) + expect(store.featuresCheck.isGeneratedSnapshotDescriptionFeatureEnabled).toBe(false) + }) + + it('isGeneratedSnapshotDescriptionFeatureEnabled is false when team ai is disabled', () => { + mockTeam({ team: { id: 'team-1', billing: {}, type: { properties: { features: { generatedSnapshotDescription: true, ai: false }, billing: {}, instances: {} } } } }) + const store = useAccountSettingsStore() + store.setSettings({ features: { generatedSnapshotDescription: true, ai: true } }) + expect(store.featuresCheck.isGeneratedSnapshotDescriptionFeatureEnabled).toBe(false) + }) + + it('isExpertAssistantFeatureEnabled requires platform ai only', () => { + const store = useAccountSettingsStore() + store.setSettings({ features: { expertAssistant: true, ai: true } }) + expect(store.featuresCheck.isExpertAssistantFeatureEnabled).toBe(true) + }) + + it('isExpertAssistantFeatureEnabled is true when platform ai is on even if team ai is off', () => { + mockTeam({ team: { id: 'team-1', billing: {}, type: { properties: { features: { ai: false }, billing: {}, instances: {} } } } }) + const store = useAccountSettingsStore() + store.setSettings({ features: { expertAssistant: true, ai: true } }) + expect(store.featuresCheck.isAiFeatureEnabled).toBe(false) + expect(store.featuresCheck.isExpertAssistantFeatureEnabled).toBe(true) + }) + + it('isExpertAssistantFeatureEnabled is false when platform ai is disabled', () => { + const store = useAccountSettingsStore() + store.setSettings({ features: { expertAssistant: true, ai: false } }) + expect(store.featuresCheck.isExpertAssistantFeatureEnabled).toBe(false) + }) }) }) }) From d052383757026e32c3bdab6653a1db47f5087b39 Mon Sep 17 00:00:00 2001 From: cstns Date: Thu, 21 May 2026 17:52:42 +0300 Subject: [PATCH 08/12] Add team-level opt-out for `generatedSnapshotDescription` feature flag --- frontend/src/composables/FeatureChecks.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/frontend/src/composables/FeatureChecks.js b/frontend/src/composables/FeatureChecks.js index eff4702c55..c69a3d3564 100644 --- a/frontend/src/composables/FeatureChecks.js +++ b/frontend/src/composables/FeatureChecks.js @@ -80,7 +80,9 @@ export const FEATURE_CONFIGS = [ output: 'isGeneratedSnapshotDescriptionFeatureEnabled', platformKey: 'generatedSnapshotDescription', teamKey: 'generatedSnapshotDescription', - dependsOnPlatform: 'ai' + dependsOnPlatform: 'ai', + dependsOnTeam: 'ai', + dependsOnTeamOptOut: true }, { output: 'isApplicationsRBACFeatureEnabled', platformKey: 'rbacApplication', teamKey: 'rbacApplication' }, From b3d66f667e8b5c913184354241be761f0fad0d53 Mon Sep 17 00:00:00 2001 From: cstns Date: Thu, 21 May 2026 18:29:14 +0300 Subject: [PATCH 09/12] Default AI features to true in `TeamTypeEditDialog` if undefined --- .../src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue | 3 +++ 1 file changed, 3 insertions(+) diff --git a/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue b/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue index 985aaf6eb9..0f912f08d3 100644 --- a/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue +++ b/frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue @@ -309,6 +309,9 @@ export default { if (this.input.properties.features.instanceResources === undefined) { this.input.properties.features.instanceResources = false } + if (this.input.properties.features.ai === undefined) { + this.input.properties.features.ai = true + } if (!this.input.autoStack) { this.input.autoStack = {} } From 838abc91ba7734f154633e7a5b63b1921d725c45 Mon Sep 17 00:00:00 2001 From: cstns Date: Thu, 21 May 2026 18:56:56 +0300 Subject: [PATCH 10/12] Ensure `isAiEnabled` is explicitly cast to boolean across feature flag checks --- forge/routes/api/assistant.js | 2 +- forge/routes/api/device.js | 2 +- forge/routes/api/deviceLive.js | 2 +- forge/routes/api/project.js | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/forge/routes/api/assistant.js b/forge/routes/api/assistant.js index 4902c5e4d0..7c4a4c4614 100644 --- a/forge/routes/api/assistant.js +++ b/forge/routes/api/assistant.js @@ -178,7 +178,7 @@ module.exports = async function (app) { async (request, reply) => { const inlineDisabled = app.config.assistant?.completions?.inlineEnabled === false const featureEnabled = app.config.features.enabled('assistantInlineCompletions') - const isAiEnabled = app.config.features.enabled('ai') && request.team?.getFeatureProperty('ai', true) + const isAiEnabled = !!(app.config.features.enabled('ai') && request.team?.getFeatureProperty('ai', true)) const featureEnabledForTeam = request.team?.getFeatureProperty('assistantInlineCompletions', false) const isStandaloneSessionUser = request.session.ownerType === 'user' if (inlineDisabled || !featureEnabled || !(isStandaloneSessionUser || (isAiEnabled && featureEnabledForTeam))) { diff --git a/forge/routes/api/device.js b/forge/routes/api/device.js index d8b2d19546..d74e8438da 100644 --- a/forge/routes/api/device.js +++ b/forge/routes/api/device.js @@ -1190,7 +1190,7 @@ module.exports = async function (app) { await request.device.Team.ensureTeamTypeExists() const tier = app.license.get('tier') const isEnterprise = tier === 'enterprise' - const isAiEnabled = app.config.features.enabled('ai') && request.device.Team.getFeatureProperty('ai', true) + const isAiEnabled = !!(app.config.features.enabled('ai') && request.device.Team.getFeatureProperty('ai', true)) const hasFeature = request.device.Team.getFeatureProperty('generatedSnapshotDescription', false) if (!isEnterprise || !isAiEnabled || !hasFeature) { diff --git a/forge/routes/api/deviceLive.js b/forge/routes/api/deviceLive.js index 5e0a77de63..3557f5698a 100644 --- a/forge/routes/api/deviceLive.js +++ b/forge/routes/api/deviceLive.js @@ -294,7 +294,7 @@ module.exports = async function (app) { tables: !!(app.config.features.enabled('tables') && team.getFeatureProperty('tables', true)) } - const isAiEnabled = app.config.features.enabled('ai') && team.getFeatureProperty('ai', true) + const isAiEnabled = !!(app.config.features.enabled('ai') && team.getFeatureProperty('ai', true)) const assistantInlineCompletionsFeatureEnabled = !!(isAiEnabled && app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false)) response.assistant = { enabled: isAiEnabled && (app.config.assistant?.enabled || false), diff --git a/forge/routes/api/project.js b/forge/routes/api/project.js index 29cd505a93..b3dfbac252 100644 --- a/forge/routes/api/project.js +++ b/forge/routes/api/project.js @@ -849,7 +849,7 @@ module.exports = async function (app) { await request.project.Team.ensureTeamTypeExists() const team = request.project.Team - const isAiEnabled = app.config.features.enabled('ai') && team.getFeatureProperty('ai', true) + const isAiEnabled = !!(app.config.features.enabled('ai') && team.getFeatureProperty('ai', true)) const assistantInlineCompletionsFeatureEnabled = !!(isAiEnabled && app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false)) settings.assistant = { enabled: isAiEnabled && (app.config.assistant?.enabled || false), @@ -1464,7 +1464,7 @@ module.exports = async function (app) { await request.project.Team.ensureTeamTypeExists() const tier = app.license.get('tier') const isEnterprise = tier === 'enterprise' - const isAiEnabled = app.config.features.enabled('ai') && request.project.Team.getFeatureProperty('ai', true) + const isAiEnabled = !!(app.config.features.enabled('ai') && request.project.Team.getFeatureProperty('ai', true)) const hasFeature = request.project.Team.getFeatureProperty('generatedSnapshotDescription', false) if (!isEnterprise || !isAiEnabled || !hasFeature) { From f1278c1711ccf3def29c191cea4c9a693e3eb41d Mon Sep 17 00:00:00 2001 From: cstns Date: Thu, 21 May 2026 18:58:11 +0300 Subject: [PATCH 11/12] Add tests for AI feature flag handling at platform and team levels --- test/unit/forge/routes/api/assistant_spec.js | 26 ++++++- test/unit/forge/routes/api/device_spec.js | 69 +++++++++++++++++ test/unit/forge/routes/api/project_spec.js | 75 +++++++++++++++++++ .../frontend/stores/account-settings.spec.js | 34 +++++++++ 4 files changed, 203 insertions(+), 1 deletion(-) diff --git a/test/unit/forge/routes/api/assistant_spec.js b/test/unit/forge/routes/api/assistant_spec.js index a936978480..aeffe9b831 100644 --- a/test/unit/forge/routes/api/assistant_spec.js +++ b/test/unit/forge/routes/api/assistant_spec.js @@ -31,12 +31,14 @@ describe('Assistant API', async function () { const mergedConfig = Object.assign({}, defaultConfig, config) const _app = await setup(mergedConfig) _app.comms = null // skip all the broker stuff + _app.config.features.register('ai', true, true) _app.config.features.register('tables', tablesFeatureEnabled, tablesFeatureEnabled) _app.config.features.register('assistantInlineCompletions', assistantInlineCompletionsFeatureEnabled, assistantInlineCompletionsFeatureEnabled) - // Enable tables feature for default team type + // Enable tables and ai features for default team type const defaultTeamType = await _app.db.models.TeamType.findOne({ where: { name: 'starter' } }) const defaultTeamTypeProperties = defaultTeamType.properties defaultTeamTypeProperties.features.tables = true + defaultTeamTypeProperties.features.ai = true defaultTeamType.properties = defaultTeamTypeProperties await defaultTeamType.save() return _app @@ -447,6 +449,28 @@ describe('Assistant API', async function () { }) response.statusCode.should.equal(404) }) + it('returns 404 when ai platform flag is disabled', async function () { + app.config.features.register('ai', false, true) + const response = await app.inject({ + method: 'POST', + url: `/api/v1/assistant/${serviceName}`, + headers: { authorization: 'Bearer ' + TestObjects.tokens.device }, + payload: { prompt: 'select all rows from test', transactionId: '555' } + }) + response.statusCode.should.equal(404) + app.config.features.register('ai', true, true) + }) + it('returns 404 when ai team flag is disabled', async function () { + await enableTeamTypeFeatureFlag(app, false, 'ai') + const response = await app.inject({ + method: 'POST', + url: `/api/v1/assistant/${serviceName}`, + headers: { authorization: 'Bearer ' + TestObjects.tokens.device }, + payload: { prompt: 'select all rows from test', transactionId: '555' } + }) + response.statusCode.should.equal(404) + await enableTeamTypeFeatureFlag(app, true, 'ai') + }) it('does not allow other contrib nodes', async function () { const serviceName = 'fim/' + encodeURIComponent('@third-party/contrib-node') + '/node-type' const response = await app.inject({ diff --git a/test/unit/forge/routes/api/device_spec.js b/test/unit/forge/routes/api/device_spec.js index 093ddf20b8..8844ef1787 100644 --- a/test/unit/forge/routes/api/device_spec.js +++ b/test/unit/forge/routes/api/device_spec.js @@ -122,6 +122,14 @@ describe('Device API', async function () { await TestObjects.CTeam.addUser(TestObjects.chris, { through: { role: Roles.Owner } }) TestObjects.defaultTeamType = app.defaultTeamType + // Enable ai feature flag at platform and team type level + app.config.features.register('ai', true, true) + const defaultTeamTypeProps = TestObjects.defaultTeamType.properties || {} + defaultTeamTypeProps.features = defaultTeamTypeProps.features || {} + defaultTeamTypeProps.features.ai = true + TestObjects.defaultTeamType.properties = defaultTeamTypeProps + await TestObjects.defaultTeamType.save() + TestObjects.Project1 = app.project TestObjects.Application1 = app.application TestObjects.provisioningTokens = { @@ -2491,6 +2499,27 @@ describe('Device API', async function () { body.assistant.completions.should.have.property('enabled', true) // defaults to enabled body.assistant.completions.should.have.property('inlineEnabled', true) // enabled due to tier/licensed }) + it('device downloads settings with assistant.enabled false when ai platform flag is disabled', async function () { + app = await setup({ + license: 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGbG93Rm9yZ2UgSW5jLiIsInN1YiI6IkZsb3dGb3JnZSBJbmMuIERldmVsb3BtZW50IiwibmJmIjoxNjYyNTk1MjAwLCJleHAiOjc5ODcwNzUxOTksIm5vdGUiOiJEZXZlbG9wbWVudC1tb2RlIE9ubHkuIE5vdCBmb3IgcHJvZHVjdGlvbiIsInVzZXJzIjoxNTAsInRlYW1zIjo1MCwicHJvamVjdHMiOjUwLCJkZXZpY2VzIjoyLCJkZXYiOnRydWUsImlhdCI6MTY2MjY1MzkyMX0.Tj4fnuDuxi_o5JYltmVi1Xj-BRn0aEjwRPa_fL2MYa9MzSwnvJEd-8bsRM38BQpChjLt-wN-2J21U7oSq2Fp5A', + assistant: { + enabled: true, + requestTimeout: 12345 + } + }) + app.config.features.register('ai', false, true) + await login('alice', 'aaPassword') + const device = await createDevice({ name: 'AppDeviceAiOff', type: 'AppDeviceAiOff_type', team: app.team.hashid, as: TestObjects.tokens.alice }) + const dbDevice = await app.db.models.Device.byId(device.id) + dbDevice.setApplication(app.application) + await dbDevice.save() + + const body = await getLiveSettings(device) + body.should.have.property('assistant').and.be.an.Object() + body.assistant.should.have.property('enabled', false) + body.assistant.should.have.property('completions').and.be.an.Object() + body.assistant.completions.should.have.property('inlineEnabled', false) + }) }) describe('Device state', function () { @@ -2654,6 +2683,46 @@ describe('Device API', async function () { sinon.restore() }) + it('returns 404 when ai platform flag is disabled', async function () { + app.config.features.register('ai', false, true) + const device = await app.db.models.Device.create({ name: 'ai-plat-off', type: 'x', credentialSecret: '' }) + await device.setTeam(TestObjects.ATeam) + + const response = await app.inject({ + method: 'POST', + url: `/api/v1/devices/${device.hashid}/generate/snapshot-description`, + cookies: { sid: TestObjects.tokens.alice }, + body: { target: 'latest' } + }) + + response.statusCode.should.equal(404) + app.config.features.register('ai', true, true) + }) + + it('returns 404 when ai team flag is disabled', async function () { + const props = TestObjects.defaultTeamType.properties || {} + props.features = props.features || {} + props.features.ai = false + TestObjects.defaultTeamType.properties = props + await TestObjects.defaultTeamType.save() + + const device = await app.db.models.Device.create({ name: 'ai-team-off', type: 'x', credentialSecret: '' }) + await device.setTeam(TestObjects.ATeam) + + const response = await app.inject({ + method: 'POST', + url: `/api/v1/devices/${device.hashid}/generate/snapshot-description`, + cookies: { sid: TestObjects.tokens.alice }, + body: { target: 'latest' } + }) + + response.statusCode.should.equal(404) + // Restore + props.features.ai = true + TestObjects.defaultTeamType.properties = props + await TestObjects.defaultTeamType.save() + }) + it('returns 404 when feature is disabled for the team type', async function () { // Disable feature flag const props = TestObjects.defaultTeamType.properties || {} diff --git a/test/unit/forge/routes/api/project_spec.js b/test/unit/forge/routes/api/project_spec.js index 7e24cc8cda..932e63b3e3 100644 --- a/test/unit/forge/routes/api/project_spec.js +++ b/test/unit/forge/routes/api/project_spec.js @@ -69,6 +69,15 @@ describe('Project API', function () { // TestObjects.tokens.alice = (await app.db.controllers.AccessToken.createTokenForPasswordReset(TestObjects.alice)).token TestObjects.tokens.project = (await app.project.refreshAuthTokens()).token + // Enable ai feature flag at platform and team type level + app.config.features.register('ai', true, true) + const defaultTeamType = await app.db.models.TeamType.findOne({ where: { name: 'starter' } }) + const defaultTeamTypeProps = defaultTeamType.properties || {} + defaultTeamTypeProps.features = defaultTeamTypeProps.features || {} + defaultTeamTypeProps.features.ai = true + defaultTeamType.properties = defaultTeamTypeProps + await defaultTeamType.save() + TestObjects.projectType1 = app.projectType TestObjects.template1 = app.template TestObjects.stack1 = app.stack @@ -2460,6 +2469,25 @@ describe('Project API', function () { body.assistant.completions.should.have.property('enabled', true) // defaults to enabled body.assistant.completions.should.have.property('inlineEnabled', true) // enabled due to tier/licensed }) + it('instance settings with assistant.enabled false when ai platform flag is disabled', async function () { + app = await setup({ + license: 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJGbG93Rm9yZ2UgSW5jLiIsInN1YiI6IkZsb3dGb3JnZSBJbmMuIERldmVsb3BtZW50IiwibmJmIjoxNjYyNTk1MjAwLCJleHAiOjc5ODcwNzUxOTksIm5vdGUiOiJEZXZlbG9wbWVudC1tb2RlIE9ubHkuIE5vdCBmb3IgcHJvZHVjdGlvbiIsInVzZXJzIjoxNTAsInRlYW1zIjo1MCwicHJvamVjdHMiOjUwLCJkZXZpY2VzIjoyLCJkZXYiOnRydWUsImlhdCI6MTY2MjY1MzkyMX0.Tj4fnuDuxi_o5JYltmVi1Xj-BRn0aEjwRPa_fL2MYa9MzSwnvJEd-8bsRM38BQpChjLt-wN-2J21U7oSq2Fp5A', + assistant: { + enabled: true, + requestTimeout: 12345 + } + }) + app.config.features.register('ai', false, true) + + await login('alice', 'aaPassword') + TestObjects.tokens.project = (await app.project.refreshAuthTokens()).token + + const body = await getSettings(app.project) + body.should.have.property('assistant').and.be.an.Object() + body.assistant.should.have.property('enabled', false) + body.assistant.should.have.property('completions').and.be.an.Object() + body.assistant.completions.should.have.property('inlineEnabled', false) + }) }) }) @@ -3054,6 +3082,53 @@ describe('Project API', function () { }) describe('Generate snapshot change description', function () { + it('returns 404 when ai platform flag is disabled', async function () { + app.config.features.register('ai', false, true) + const originalLicense = app.license + app.license = { get: (k) => (k === 'tier' ? 'enterprise' : undefined) } + + const response = await app.inject({ + method: 'POST', + url: `/api/v1/projects/${TestObjects.project1.id}/generate/snapshot-description`, + payload: { target: 'latest' }, + cookies: { sid: TestObjects.tokens.alice } + }) + + response.statusCode.should.equal(404) + app.license = originalLicense + app.config.features.register('ai', true, true) + }) + + it('returns 404 when ai team flag is disabled', async function () { + const originalLicense = app.license + app.license = { get: (k) => (k === 'tier' ? 'enterprise' : undefined) } + const originalProjectById = app.db.models.Project.byId + const byIdStub = sinon.stub(app.db.models.Project, 'byId').callsFake(async function (id, opts) { + const project = await originalProjectById.call(this, id, opts) + if (project && project.Team) { + project.Team.getTeamType = async () => ({ + getFeatureProperty: (name, def) => { + if (name === 'ai') return false + if (name === 'generatedSnapshotDescription') return true + return def + } + }) + } + return project + }) + + const response = await app.inject({ + method: 'POST', + url: `/api/v1/projects/${TestObjects.project1.id}/generate/snapshot-description`, + payload: { target: 'latest' }, + cookies: { sid: TestObjects.tokens.alice } + }) + + response.statusCode.should.equal(404) + byIdStub.restore() + app.license = originalLicense + }) + it('returns 200 and forwards LLM response', async function () { // Stub buildSnapshot to avoid heavy export logic and signature mismatches const buildSnapshotStub = sinon.stub(app.db.controllers.ProjectSnapshot, 'buildProjectSnapshot').resolves({ diff --git a/test/unit/frontend/stores/account-settings.spec.js b/test/unit/frontend/stores/account-settings.spec.js index 36a8dd7507..f506675293 100644 --- a/test/unit/frontend/stores/account-settings.spec.js +++ b/test/unit/frontend/stores/account-settings.spec.js @@ -217,6 +217,40 @@ describe('account-settings store', () => { expect(store.featuresCheck.isSharedLibraryFeatureEnabledForTeam).toBe(true) }) + it('isAiFeatureEnabled is true when both platform and team have ai enabled', () => { + mockTeam({ team: { id: 'team-1', billing: {}, type: { properties: { features: { ai: true }, billing: {}, instances: {} } } } }) + const store = useAccountSettingsStore() + store.setSettings({ features: { ai: true } }) + expect(store.featuresCheck.isAiFeatureEnabledForPlatform).toBe(true) + expect(store.featuresCheck.isAiFeatureEnabledForTeam).toBe(true) + expect(store.featuresCheck.isAiFeatureEnabled).toBe(true) + }) + + it('isAiFeatureEnabled is false when platform ai is disabled', () => { + mockTeam({ team: { id: 'team-1', billing: {}, type: { properties: { features: { ai: true }, billing: {}, instances: {} } } } }) + const store = useAccountSettingsStore() + store.setSettings({ features: { ai: false } }) + expect(store.featuresCheck.isAiFeatureEnabledForPlatform).toBe(false) + expect(store.featuresCheck.isAiFeatureEnabled).toBe(false) + }) + + it('isAiFeatureEnabled is false when team ai is explicitly disabled', () => { + mockTeam({ team: { id: 'team-1', billing: {}, type: { properties: { features: { ai: false }, billing: {}, instances: {} } } } }) + const store = useAccountSettingsStore() + store.setSettings({ features: { ai: true } }) + expect(store.featuresCheck.isAiFeatureEnabledForPlatform).toBe(true) + expect(store.featuresCheck.isAiFeatureEnabledForTeam).toBe(false) + expect(store.featuresCheck.isAiFeatureEnabled).toBe(false) + }) + + it('isAiFeatureEnabled is true when team ai is undefined and enableAllFeatures is true', () => { + mockTeam({ team: { id: 'team-1', billing: {}, type: { properties: { enableAllFeatures: true, billing: {}, instances: {} } } } }) + const store = useAccountSettingsStore() + store.setSettings({ features: { ai: true } }) + expect(store.featuresCheck.isAiFeatureEnabledForTeam).toBe(true) + expect(store.featuresCheck.isAiFeatureEnabled).toBe(true) + }) + it('isGeneratedSnapshotDescriptionFeatureEnabled requires platform and team ai', () => { mockTeam({ team: { id: 'team-1', billing: {}, type: { properties: { features: { generatedSnapshotDescription: true, ai: true }, billing: {}, instances: {} } } } }) const store = useAccountSettingsStore() From 62d5e70f03736f7cd7d33814a3a0e025f76ae082 Mon Sep 17 00:00:00 2001 From: cstns Date: Thu, 21 May 2026 19:09:43 +0300 Subject: [PATCH 12/12] Migrate `FeatureChecks.js` to TypeScript as `FeatureChecks.ts` for improved type safety and maintainability --- .../{FeatureChecks.js => FeatureChecks.ts} | 68 ++++++++++++++----- frontend/src/stores/account-settings.js | 2 +- 2 files changed, 51 insertions(+), 19 deletions(-) rename frontend/src/composables/{FeatureChecks.js => FeatureChecks.ts} (81%) diff --git a/frontend/src/composables/FeatureChecks.js b/frontend/src/composables/FeatureChecks.ts similarity index 81% rename from frontend/src/composables/FeatureChecks.js rename to frontend/src/composables/FeatureChecks.ts index c69a3d3564..5ba1e8af15 100644 --- a/frontend/src/composables/FeatureChecks.js +++ b/frontend/src/composables/FeatureChecks.ts @@ -7,8 +7,6 @@ * - `{output}ForTeam` - team-level check result (only if `teamKey` is set) * - `{output}` - combined result (see combination rules below) * - * @typedef {Object} FeatureConfig - * * @property {string} output - Name of the computed property for the combined result. * Always required. Convention: `is{Feature}FeatureEnabled`. * @@ -62,7 +60,41 @@ * At least one of `platformKey` or `teamKey` must be provided. * `dependsOn`, `dependsOnPlatform`, and `dependsOnTeam` can be used together. */ -export const FEATURE_CONFIGS = [ + +interface FeatureConfig { + output: string + platformKey?: string + teamKey?: string + optOut?: boolean + platformSource?: 'settings' + dependsOn?: string + dependsOnPlatform?: string + dependsOnPlatformSource?: 'settings' + dependsOnTeam?: string + dependsOnTeamOptOut?: boolean +} + +interface PlatformState { + features?: Record + settings?: { + features?: Record + } +} + +interface TeamTypeProperties { + features?: Record + enableAllFeatures?: boolean +} + +interface Team { + type?: { + properties?: TeamTypeProperties + } +} + +type FeatureChecks = Record + +export const FEATURE_CONFIGS: FeatureConfig[] = [ { output: 'isSharedLibraryFeatureEnabled', platformKey: 'shared-library', teamKey: 'shared-library', optOut: true }, { output: 'isBlueprintsFeatureEnabled', platformKey: 'flowBlueprints', teamKey: 'flowBlueprints', optOut: true }, { output: 'isCustomCatalogsFeatureEnabled', platformKey: 'customCatalogs', teamKey: 'customCatalogs', optOut: true }, @@ -98,12 +130,12 @@ export const FEATURE_CONFIGS = [ { output: 'isExternalMqttBrokerFeatureEnabled', platformKey: 'externalBroker' } ] -function isPlatformFeatureEnabled (state, platformKey, platformSource) { +function isPlatformFeatureEnabled (state: PlatformState, platformKey: string, platformSource?: 'settings'): boolean { const source = platformSource === 'settings' ? state.settings?.features : state.features return !!source?.[platformKey] } -function isTeamFeatureEnabled (team, teamKey, optOut) { +function isTeamFeatureEnabled (team: Team | null | undefined, teamKey: string, optOut?: boolean): boolean { if (optOut) { const flag = team?.type?.properties?.features?.[teamKey] return (flag === undefined || !!flag) || !!team?.type?.properties?.enableAllFeatures @@ -111,29 +143,29 @@ function isTeamFeatureEnabled (team, teamKey, optOut) { return !!team?.type?.properties?.features?.[teamKey] || !!team?.type?.properties?.enableAllFeatures } -function applyDependencyGates (checks, output, { dependsOn, dependsOnPlatform, dependsOnTeam, dependsOnPlatformSource, dependsOnTeamOptOut }, state, team) { - if (dependsOn && !checks[dependsOn]) { +function applyDependencyGates ( + checks: FeatureChecks, + output: string, + config: FeatureConfig, + state: PlatformState, + team: Team | null | undefined +): void { + if (config.dependsOn && !checks[config.dependsOn]) { checks[output] = false } - if (dependsOnPlatform && !isPlatformFeatureEnabled(state, dependsOnPlatform, dependsOnPlatformSource)) { + if (config.dependsOnPlatform && !isPlatformFeatureEnabled(state, config.dependsOnPlatform, config.dependsOnPlatformSource)) { checks[output] = false } - if (dependsOnTeam && !isTeamFeatureEnabled(team, dependsOnTeam, dependsOnTeamOptOut)) { + if (config.dependsOnTeam && !isTeamFeatureEnabled(team, config.dependsOnTeam, config.dependsOnTeamOptOut)) { checks[output] = false } } -export function buildFeatureChecks (state, team) { - const checks = {} +export function buildFeatureChecks (state: PlatformState, team: Team | null | undefined): FeatureChecks { + const checks: FeatureChecks = {} for (const config of FEATURE_CONFIGS) { - const { - output, - platformKey, - teamKey, - optOut, - platformSource - } = config + const { output, platformKey, teamKey, optOut, platformSource } = config const platformCheckKey = `${output}ForPlatform` const teamCheckKey = `${output}ForTeam` diff --git a/frontend/src/stores/account-settings.js b/frontend/src/stores/account-settings.js index c6fbd133c9..933883cbbd 100644 --- a/frontend/src/stores/account-settings.js +++ b/frontend/src/stores/account-settings.js @@ -1,7 +1,7 @@ import { defineStore } from 'pinia' import settingsApi from '@/api/settings.js' -import { buildFeatureChecks } from '@/composables/FeatureChecks.js' +import { buildFeatureChecks } from '@/composables/FeatureChecks' import { useAccountAuthStore } from '@/stores/account-auth.js' import { useContextStore } from '@/stores/context.js'