Skip to content

Commit b974fab

Browse files
cstnshardillb
andauthored
Enforce AI feature dependency checks for feature flags and assistant-… (#7319)
Co-authored-by: Ben Hardill <ben@flowforge.com>
1 parent b361efc commit b974fab

12 files changed

Lines changed: 457 additions & 74 deletions

File tree

forge/ee/lib/index.js

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,20 +39,21 @@ module.exports = fp(async function (app, opts) {
3939
await app.register(require('./expert'))
4040

4141
// Set the AI Features Flag (global gate for all AI features)
42-
app.config.features.register('ai', app.config?.ai?.enabled ?? true, true)
42+
const isAiEnabled = app.config?.ai?.enabled ?? true
43+
app.config.features.register('ai', isAiEnabled, true)
4344

4445
// Set the Generate Snapshot Description Feature Flag
45-
const isAssistantConfigured = app.config.assistant?.enabled === true && !!app.config.assistant?.service?.url
46+
const isAssistantConfigured = isAiEnabled && app.config.assistant?.enabled === true && !!app.config.assistant?.service?.url
4647
app.config.features.register('generatedSnapshotDescription', isAssistantConfigured, true)
4748

4849
// Set the assistant inline completions Feature Flag
4950
app.config.features.register('assistantInlineCompletions', isAssistantConfigured, true)
5051

5152
// Set the expert assistant Feature Flag
52-
app.config.features.register('expertAssistant', app.config?.expert?.enabled ?? false, true)
53+
app.config.features.register('expertAssistant', isAiEnabled && (app.config?.expert?.enabled ?? false), true)
5354

5455
// temporary until FF Expert Insights can be enabled on Self Hosted EE instance
55-
const isInsightsEnabled = app.config?.expert?.enabled && app.config?.expert?.insights?.enabled
56+
const isInsightsEnabled = isAiEnabled && app.config?.expert?.enabled && app.config?.expert?.insights?.enabled
5657
app.config.features.register('expertInsights', isInsightsEnabled ?? false, false)
5758
}
5859

forge/routes/api/assistant.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -178,9 +178,10 @@ module.exports = async function (app) {
178178
async (request, reply) => {
179179
const inlineDisabled = app.config.assistant?.completions?.inlineEnabled === false
180180
const featureEnabled = app.config.features.enabled('assistantInlineCompletions')
181+
const isAiEnabled = !!(app.config.features.enabled('ai') && request.team?.getFeatureProperty('ai', true))
181182
const featureEnabledForTeam = request.team?.getFeatureProperty('assistantInlineCompletions', false)
182183
const isStandaloneSessionUser = request.session.ownerType === 'user'
183-
if (inlineDisabled || !featureEnabled || !(isStandaloneSessionUser || featureEnabledForTeam)) {
184+
if (inlineDisabled || !featureEnabled || !(isStandaloneSessionUser || (isAiEnabled && featureEnabledForTeam))) {
184185
reply.code(404).send({ code: 'not_found', error: 'Not Found - feature not enabled for team' })
185186
return
186187
}

forge/routes/api/device.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1190,9 +1190,10 @@ module.exports = async function (app) {
11901190
await request.device.Team.ensureTeamTypeExists()
11911191
const tier = app.license.get('tier')
11921192
const isEnterprise = tier === 'enterprise'
1193+
const isAiEnabled = !!(app.config.features.enabled('ai') && request.device.Team.getFeatureProperty('ai', true))
11931194
const hasFeature = request.device.Team.getFeatureProperty('generatedSnapshotDescription', false)
11941195

1195-
if (!isEnterprise || !hasFeature) {
1196+
if (!isEnterprise || !isAiEnabled || !hasFeature) {
11961197
return reply.code(404).send({ code: 'not_found' })
11971198
}
11981199
}

forge/routes/api/deviceLive.js

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,9 +294,10 @@ module.exports = async function (app) {
294294
tables: !!(app.config.features.enabled('tables') && team.getFeatureProperty('tables', true))
295295
}
296296

297-
const assistantInlineCompletionsFeatureEnabled = !!(app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false))
297+
const isAiEnabled = !!(app.config.features.enabled('ai') && team.getFeatureProperty('ai', true))
298+
const assistantInlineCompletionsFeatureEnabled = !!(isAiEnabled && app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false))
298299
response.assistant = {
299-
enabled: app.config.assistant?.enabled || false,
300+
enabled: isAiEnabled && (app.config.assistant?.enabled || false),
300301
requestTimeout: app.config.assistant?.requestTimeout || 60000,
301302
mcp: { enabled: true }, // default to enabled
302303
completions: {

forge/routes/api/project.js

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -849,9 +849,10 @@ module.exports = async function (app) {
849849

850850
await request.project.Team.ensureTeamTypeExists()
851851
const team = request.project.Team
852-
const assistantInlineCompletionsFeatureEnabled = !!(app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false))
852+
const isAiEnabled = !!(app.config.features.enabled('ai') && team.getFeatureProperty('ai', true))
853+
const assistantInlineCompletionsFeatureEnabled = !!(isAiEnabled && app.config.features.enabled('assistantInlineCompletions') && team.getFeatureProperty('assistantInlineCompletions', false))
853854
settings.assistant = {
854-
enabled: app.config.assistant?.enabled || false,
855+
enabled: isAiEnabled && (app.config.assistant?.enabled || false),
855856
requestTimeout: app.config.assistant?.requestTimeout || 60000,
856857
mcp: { enabled: true }, // default to enabled
857858
completions: {
@@ -1463,9 +1464,10 @@ module.exports = async function (app) {
14631464
await request.project.Team.ensureTeamTypeExists()
14641465
const tier = app.license.get('tier')
14651466
const isEnterprise = tier === 'enterprise'
1467+
const isAiEnabled = !!(app.config.features.enabled('ai') && request.project.Team.getFeatureProperty('ai', true))
14661468
const hasFeature = request.project.Team.getFeatureProperty('generatedSnapshotDescription', false)
14671469

1468-
if (!isEnterprise || !hasFeature) {
1470+
if (!isEnterprise || !isAiEnabled || !hasFeature) {
14691471
return reply.code(404).send({ code: 'not_found' })
14701472
}
14711473
}
Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
/**
2+
* Feature flag configuration array. Each entry defines how a feature's availability
3+
* is computed from platform settings and team type properties.
4+
*
5+
* For each entry, `buildFeatureChecks` produces up to three computed properties:
6+
* - `{output}ForPlatform` - platform-level check result (only if `platformKey` is set)
7+
* - `{output}ForTeam` - team-level check result (only if `teamKey` is set)
8+
* - `{output}` - combined result (see combination rules below)
9+
*
10+
* @property {string} output - Name of the computed property for the combined result.
11+
* Always required. Convention: `is{Feature}FeatureEnabled`.
12+
*
13+
* @property {string} [platformKey] - Key to look up in the platform features object
14+
* (state.features). If truthy, the platform check passes.
15+
*
16+
* @property {string} [teamKey] - Key to look up in team type properties
17+
* (team.type.properties.features). If truthy or if the team type has
18+
* `enableAllFeatures: true`, the team check passes.
19+
*
20+
* @property {boolean} [optOut=false] - Controls the default behavior of the team check.
21+
* - `false` (default, opt-in): feature is disabled unless the team type explicitly
22+
* enables it or has `enableAllFeatures: true`.
23+
* - `true` (opt-out): feature is enabled by default. Only disabled if the team type
24+
* explicitly sets it to `false`. If the property is `undefined`, it is treated as enabled.
25+
* Only meaningful when `teamKey` is set. Ignored otherwise.
26+
*
27+
* @property {string} [platformSource] - Override where the platform value is read from.
28+
* - `undefined` (default): reads from `state.features` (the platform feature flags)
29+
* - `'settings'`: reads from `state.settings.features` instead (for features exposed
30+
* through the settings object rather than the feature flags system)
31+
* Only meaningful when `platformKey` is set. Ignored otherwise.
32+
*
33+
* @property {string} [dependsOn] - The `output` name of another feature that must be
34+
* enabled (combined check) for this feature to be enabled. If the referenced feature's
35+
* combined result is `false`, this feature is forced to `false`.
36+
* The dependency must appear earlier in the array to ensure it is computed first.
37+
*
38+
* @property {string} [dependsOnPlatform] - A platform feature key that must be enabled
39+
* for this feature to be enabled. Checked directly against the platform features object,
40+
* independent of whether the dependency has its own entry in FEATURE_CONFIGS.
41+
*
42+
* @property {string} [dependsOnPlatformSource] - Override where the `dependsOnPlatform`
43+
* value is read from, same semantics as `platformSource`.
44+
* Only meaningful when `dependsOnPlatform` is set. Ignored otherwise.
45+
*
46+
* @property {string} [dependsOnTeam] - A team feature key that must be enabled for this
47+
* feature to be enabled. Checked directly against the team type properties.
48+
*
49+
* @property {boolean} [dependsOnTeamOptOut] - Controls the default behavior of the
50+
* `dependsOnTeam` check, same semantics as `optOut`.
51+
* Only meaningful when `dependsOnTeam` is set. Ignored otherwise.
52+
*
53+
* Combination rules for the `{output}` property:
54+
* - If both `platformKey` and `teamKey` are set: `platform AND team`
55+
* - If only `platformKey` is set: `platform` only (platform-only feature)
56+
* - If only `teamKey` is set: `team` only (team-only feature)
57+
* - After the base combination, all `dependsOn*` gates are applied. If any
58+
* dependency check fails, the combined result is forced to `false`.
59+
*
60+
* At least one of `platformKey` or `teamKey` must be provided.
61+
* `dependsOn`, `dependsOnPlatform`, and `dependsOnTeam` can be used together.
62+
*/
63+
64+
interface FeatureConfig {
65+
output: string
66+
platformKey?: string
67+
teamKey?: string
68+
optOut?: boolean
69+
platformSource?: 'settings'
70+
dependsOn?: string
71+
dependsOnPlatform?: string
72+
dependsOnPlatformSource?: 'settings'
73+
dependsOnTeam?: string
74+
dependsOnTeamOptOut?: boolean
75+
}
76+
77+
interface PlatformState {
78+
features?: Record<string, boolean>
79+
settings?: {
80+
features?: Record<string, boolean>
81+
}
82+
}
83+
84+
interface TeamTypeProperties {
85+
features?: Record<string, boolean>
86+
enableAllFeatures?: boolean
87+
}
88+
89+
interface Team {
90+
type?: {
91+
properties?: TeamTypeProperties
92+
}
93+
}
94+
95+
type FeatureChecks = Record<string, boolean>
96+
97+
export const FEATURE_CONFIGS: FeatureConfig[] = [
98+
{ output: 'isSharedLibraryFeatureEnabled', platformKey: 'shared-library', teamKey: 'shared-library', optOut: true },
99+
{ output: 'isBlueprintsFeatureEnabled', platformKey: 'flowBlueprints', teamKey: 'flowBlueprints', optOut: true },
100+
{ output: 'isCustomCatalogsFeatureEnabled', platformKey: 'customCatalogs', teamKey: 'customCatalogs', optOut: true },
101+
{ output: 'isPrivateRegistryFeatureEnabled', platformKey: 'npm', teamKey: 'npm' },
102+
{ output: 'isStaticAssetsFeatureEnabled', platformKey: 'staticAssets', teamKey: 'staticAssets' },
103+
{ output: 'isHTTPBearerTokensFeatureEnabled', platformKey: 'httpBearerTokens', teamKey: 'teamHttpSecurity', platformSource: 'settings' },
104+
{ output: 'isBOMFeatureEnabled', platformKey: 'bom', teamKey: 'bom' },
105+
{ output: 'isTimelineFeatureEnabled', platformKey: 'projectHistory', teamKey: 'projectHistory' },
106+
{ output: 'isMqttBrokerFeatureEnabled', platformKey: 'teamBroker', teamKey: 'teamBroker' },
107+
{ output: 'isGitIntegrationFeatureEnabled', platformKey: 'gitIntegration', teamKey: 'gitIntegration' },
108+
{ output: 'isInstanceResourcesFeatureEnabled', platformKey: 'instanceResources', teamKey: 'instanceResources' },
109+
{ output: 'isTablesFeatureEnabled', platformKey: 'tables', teamKey: 'tables' },
110+
{ output: 'isAiFeatureEnabled', platformKey: 'ai', teamKey: 'ai' },
111+
{
112+
output: 'isGeneratedSnapshotDescriptionFeatureEnabled',
113+
platformKey: 'generatedSnapshotDescription',
114+
teamKey: 'generatedSnapshotDescription',
115+
dependsOnPlatform: 'ai',
116+
dependsOnTeam: 'ai',
117+
dependsOnTeamOptOut: true
118+
},
119+
{ output: 'isApplicationsRBACFeatureEnabled', platformKey: 'rbacApplication', teamKey: 'rbacApplication' },
120+
121+
// Team-only
122+
{ output: 'isDeviceGroupsFeatureEnabled', teamKey: 'deviceGroups' },
123+
124+
// Platform-only
125+
{ output: 'isCertifiedNodesFeatureEnabled', platformKey: 'certifiedNodes' },
126+
{ output: 'isFlowFuseNodesFeatureEnabled', platformKey: 'ffNodes' },
127+
{ output: 'isExpertAssistantFeatureEnabled', platformKey: 'expertAssistant', dependsOnPlatform: 'ai' },
128+
{ output: 'isInstanceAutoStackUpdateFeatureEnabled', platformKey: 'autoStackUpdate' },
129+
{ output: 'isDevOpsPipelinesFeatureEnabled', platformKey: 'devops-pipelines' },
130+
{ output: 'isExternalMqttBrokerFeatureEnabled', platformKey: 'externalBroker' }
131+
]
132+
133+
function isPlatformFeatureEnabled (state: PlatformState, platformKey: string, platformSource?: 'settings'): boolean {
134+
const source = platformSource === 'settings' ? state.settings?.features : state.features
135+
return !!source?.[platformKey]
136+
}
137+
138+
function isTeamFeatureEnabled (team: Team | null | undefined, teamKey: string, optOut?: boolean): boolean {
139+
if (optOut) {
140+
const flag = team?.type?.properties?.features?.[teamKey]
141+
return (flag === undefined || !!flag) || !!team?.type?.properties?.enableAllFeatures
142+
}
143+
return !!team?.type?.properties?.features?.[teamKey] || !!team?.type?.properties?.enableAllFeatures
144+
}
145+
146+
function applyDependencyGates (
147+
checks: FeatureChecks,
148+
output: string,
149+
config: FeatureConfig,
150+
state: PlatformState,
151+
team: Team | null | undefined
152+
): void {
153+
if (config.dependsOn && !checks[config.dependsOn]) {
154+
checks[output] = false
155+
}
156+
if (config.dependsOnPlatform && !isPlatformFeatureEnabled(state, config.dependsOnPlatform, config.dependsOnPlatformSource)) {
157+
checks[output] = false
158+
}
159+
if (config.dependsOnTeam && !isTeamFeatureEnabled(team, config.dependsOnTeam, config.dependsOnTeamOptOut)) {
160+
checks[output] = false
161+
}
162+
}
163+
164+
export function buildFeatureChecks (state: PlatformState, team: Team | null | undefined): FeatureChecks {
165+
const checks: FeatureChecks = {}
166+
167+
for (const config of FEATURE_CONFIGS) {
168+
const { output, platformKey, teamKey, optOut, platformSource } = config
169+
const platformCheckKey = `${output}ForPlatform`
170+
const teamCheckKey = `${output}ForTeam`
171+
172+
if (platformKey) {
173+
checks[platformCheckKey] = isPlatformFeatureEnabled(state, platformKey, platformSource)
174+
}
175+
176+
if (teamKey) {
177+
checks[teamCheckKey] = isTeamFeatureEnabled(team, teamKey, optOut)
178+
}
179+
180+
if (platformKey && teamKey) {
181+
checks[output] = checks[platformCheckKey] && checks[teamCheckKey]
182+
} else if (platformKey) {
183+
checks[output] = checks[platformCheckKey]
184+
} else if (teamKey) {
185+
checks[output] = checks[teamCheckKey]
186+
}
187+
188+
applyDependencyGates(checks, output, config, state, team)
189+
}
190+
191+
return checks
192+
}

frontend/src/pages/admin/TeamTypes/dialogs/TeamTypeEditDialog.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,9 @@ export default {
309309
if (this.input.properties.features.instanceResources === undefined) {
310310
this.input.properties.features.instanceResources = false
311311
}
312+
if (this.input.properties.features.ai === undefined) {
313+
this.input.properties.features.ai = true
314+
}
312315
if (!this.input.autoStack) {
313316
this.input.autoStack = {}
314317
}

frontend/src/stores/account-settings.js

Lines changed: 1 addition & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { defineStore } from 'pinia'
22

33
import settingsApi from '@/api/settings.js'
4+
import { buildFeatureChecks } from '@/composables/FeatureChecks'
45
import { useAccountAuthStore } from '@/stores/account-auth.js'
56
import { useContextStore } from '@/stores/context.js'
67

@@ -9,68 +10,6 @@ export const POSTHOG_FLAGS = {
910
EXPERT_COMMS_BETA_ENABLED: 'EXPERT_COMMS_BETA_ENABLED'
1011
}
1112

12-
const FEATURE_CONFIGS = [
13-
{ output: 'isSharedLibraryFeatureEnabled', platformKey: 'shared-library', teamKey: 'shared-library', optOut: true },
14-
{ output: 'isBlueprintsFeatureEnabled', platformKey: 'flowBlueprints', teamKey: 'flowBlueprints', optOut: true },
15-
{ output: 'isCustomCatalogsFeatureEnabled', platformKey: 'customCatalogs', teamKey: 'customCatalogs', optOut: true },
16-
{ output: 'isPrivateRegistryFeatureEnabled', platformKey: 'npm', teamKey: 'npm' },
17-
{ output: 'isStaticAssetsFeatureEnabled', platformKey: 'staticAssets', teamKey: 'staticAssets' },
18-
{ output: 'isHTTPBearerTokensFeatureEnabled', platformKey: 'httpBearerTokens', teamKey: 'teamHttpSecurity', platformSource: 'settings' },
19-
{ output: 'isBOMFeatureEnabled', platformKey: 'bom', teamKey: 'bom' },
20-
{ output: 'isTimelineFeatureEnabled', platformKey: 'projectHistory', teamKey: 'projectHistory' },
21-
{ output: 'isMqttBrokerFeatureEnabled', platformKey: 'teamBroker', teamKey: 'teamBroker' },
22-
{ output: 'isGitIntegrationFeatureEnabled', platformKey: 'gitIntegration', teamKey: 'gitIntegration' },
23-
{ output: 'isInstanceResourcesFeatureEnabled', platformKey: 'instanceResources', teamKey: 'instanceResources' },
24-
{ output: 'isTablesFeatureEnabled', platformKey: 'tables', teamKey: 'tables' },
25-
{ output: 'isAiFeatureEnabled', platformKey: 'ai', teamKey: 'ai' },
26-
{ output: 'isGeneratedSnapshotDescriptionFeatureEnabled', platformKey: 'generatedSnapshotDescription', teamKey: 'generatedSnapshotDescription' },
27-
{ output: 'isApplicationsRBACFeatureEnabled', platformKey: 'rbacApplication', teamKey: 'rbacApplication' },
28-
29-
// Team-only
30-
{ output: 'isDeviceGroupsFeatureEnabled', teamKey: 'deviceGroups' },
31-
32-
// Platform-only
33-
{ output: 'isCertifiedNodesFeatureEnabled', platformKey: 'certifiedNodes' },
34-
{ output: 'isFlowFuseNodesFeatureEnabled', platformKey: 'ffNodes' },
35-
{ output: 'isExpertAssistantFeatureEnabled', platformKey: 'expertAssistant' },
36-
{ output: 'isInstanceAutoStackUpdateFeatureEnabled', platformKey: 'autoStackUpdate' },
37-
{ output: 'isDevOpsPipelinesFeatureEnabled', platformKey: 'devops-pipelines' },
38-
{ output: 'isExternalMqttBrokerFeatureEnabled', platformKey: 'externalBroker' }
39-
]
40-
41-
function buildFeatureChecks (state, team) {
42-
const checks = {}
43-
44-
for (const { output, platformKey, teamKey, optOut, platformSource } of FEATURE_CONFIGS) {
45-
const platformCheckKey = `${output}ForPlatform`
46-
const teamCheckKey = `${output}ForTeam`
47-
48-
if (platformKey) {
49-
const source = platformSource === 'settings' ? state.settings?.features : state.features
50-
checks[platformCheckKey] = !!source?.[platformKey]
51-
}
52-
53-
if (teamKey) {
54-
if (optOut) {
55-
const flag = team?.type?.properties?.features?.[teamKey]
56-
checks[teamCheckKey] = (flag === undefined || !!flag) || !!team?.type?.properties?.enableAllFeatures
57-
} else {
58-
checks[teamCheckKey] = !!team?.type?.properties?.features?.[teamKey] || !!team?.type?.properties?.enableAllFeatures
59-
}
60-
}
61-
62-
if (platformKey && teamKey) {
63-
checks[output] = checks[platformCheckKey] && checks[teamCheckKey]
64-
} else if (platformKey) {
65-
checks[output] = checks[platformCheckKey]
66-
} else if (teamKey) {
67-
checks[output] = checks[teamCheckKey]
68-
}
69-
}
70-
71-
return checks
72-
}
73-
7413
export const useAccountSettingsStore = defineStore('account-settings', {
7514
state: () => ({
7615
settings: null,

0 commit comments

Comments
 (0)