diff --git a/forge/ee/lib/index.js b/forge/ee/lib/index.js index fd7e980f5b..8b979786d9 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 ?? true, true) + const isAiEnabled = app.config?.ai?.enabled ?? true + 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..7c4a4c4614 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 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 || 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 a9167e422d..d74e8438da 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 = !!(app.config.features.enabled('ai') && request.device.Team.getFeatureProperty('ai', true)) 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..3557f5698a 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 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: 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 6af261ce62..b3dfbac252 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 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: 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: { @@ -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 = !!(app.config.features.enabled('ai') && request.project.Team.getFeatureProperty('ai', true)) 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/composables/FeatureChecks.ts b/frontend/src/composables/FeatureChecks.ts new file mode 100644 index 0000000000..5ba1e8af15 --- /dev/null +++ b/frontend/src/composables/FeatureChecks.ts @@ -0,0 +1,192 @@ +/** + * 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) + * + * @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. + */ + +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 }, + { 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', + dependsOnTeam: 'ai', + dependsOnTeamOptOut: true + }, + { 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: PlatformState, platformKey: string, platformSource?: 'settings'): boolean { + const source = platformSource === 'settings' ? state.settings?.features : state.features + return !!source?.[platformKey] +} + +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 + } + return !!team?.type?.properties?.features?.[teamKey] || !!team?.type?.properties?.enableAllFeatures +} + +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 (config.dependsOnPlatform && !isPlatformFeatureEnabled(state, config.dependsOnPlatform, config.dependsOnPlatformSource)) { + checks[output] = false + } + if (config.dependsOnTeam && !isTeamFeatureEnabled(team, config.dependsOnTeam, config.dependsOnTeamOptOut)) { + checks[output] = false + } +} + +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 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/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 = {} } diff --git a/frontend/src/stores/account-settings.js b/frontend/src/stores/account-settings.js index e47c1f0049..933883cbbd 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' import { useAccountAuthStore } from '@/stores/account-auth.js' import { useContextStore } from '@/stores/context.js' @@ -9,68 +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' }, - { 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' }, - { 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 } 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] - } - } - - return checks -} - export const useAccountSettingsStore = defineStore('account-settings', { state: () => ({ settings: null, 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 a5409da9b7..f506675293 100644 --- a/test/unit/frontend/stores/account-settings.spec.js +++ b/test/unit/frontend/stores/account-settings.spec.js @@ -216,6 +216,81 @@ describe('account-settings store', () => { // flag undefined on team type → defaults to enabled 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() + 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) + }) }) }) })