diff --git a/services/platform/app/features/chat/components/chat-input.tsx b/services/platform/app/features/chat/components/chat-input.tsx index ea4637e41b..3ec30f0ce8 100644 --- a/services/platform/app/features/chat/components/chat-input.tsx +++ b/services/platform/app/features/chat/components/chat-input.tsx @@ -43,6 +43,7 @@ interface ChatInputProps extends Omit< uploadFiles: (files: File[]) => Promise; removeAttachment: (fileId: Id<'_storage'>) => void; clearAttachments: () => FileAttachment[]; + fileUploadDisabled?: boolean; isIndexing?: boolean; indexingStatuses?: Map< Id<'_storage'>, @@ -65,6 +66,7 @@ export function ChatInput({ uploadFiles, removeAttachment, clearAttachments, + fileUploadDisabled = false, isIndexing = false, indexingStatuses, ...restProps @@ -124,7 +126,7 @@ export function ChatInput({ }; const handlePaste = (e: React.ClipboardEvent) => { - if (inputDisabled) return; + if (inputDisabled || fileUploadDisabled) return; const items = e.clipboardData?.items; if (!items) return; @@ -165,7 +167,7 @@ export function ChatInput({ className="relative flex h-full min-h-0 flex-1 flex-col" onFilesSelected={uploadFiles} clickable={false} - disabled={inputDisabled} + disabled={inputDisabled || fileUploadDisabled} > - - - + {!fileUploadDisabled && ( + + + + )} {isArenaMode ? ( diff --git a/services/platform/app/features/chat/components/chat-interface.tsx b/services/platform/app/features/chat/components/chat-interface.tsx index e99f96e1f5..522c98e84e 100644 --- a/services/platform/app/features/chat/components/chat-interface.tsx +++ b/services/platform/app/features/chat/components/chat-interface.tsx @@ -17,6 +17,7 @@ import { api } from '@/convex/_generated/api'; import { useT } from '@/lib/i18n/client'; import { cn } from '@/lib/utils/cn'; +import { useMyFeatureFlags } from '../../settings/governance/hooks/queries'; import { useBranchContext } from '../context/branch-context'; import { useChatLayout } from '../context/chat-layout-context'; import { useEditAndBranch, useForkOwnThread } from '../hooks/mutations'; @@ -144,6 +145,9 @@ export function ChatInterface({ const { isIndexing, statusMap: indexingStatuses } = useFileIndexingStatus(attachments); + const { data: featureFlags } = useMyFeatureFlags(organizationId); + const fileUploadDisabled = featureFlags?.fileUpload === false; + usePersistedAttachments({ userId: user?.userId, threadId, @@ -695,6 +699,7 @@ export function ChatInterface({ uploadFiles={uploadFiles} removeAttachment={removeAttachment} clearAttachments={clearAttachments} + fileUploadDisabled={fileUploadDisabled} isIndexing={isIndexing} indexingStatuses={indexingStatuses} /> diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.stories.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.stories.tsx new file mode 100644 index 0000000000..c1b7cdee0d --- /dev/null +++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.stories.tsx @@ -0,0 +1,26 @@ +import type { Meta, StoryObj } from '@storybook/react'; + +import { FeatureFlagsEditor } from './feature-flags-editor'; + +const meta: Meta = { + title: 'Settings/Governance/FeatureFlagsEditor', + component: FeatureFlagsEditor, + tags: ['autodocs'], + parameters: { + layout: 'padded', + }, + args: { + organizationId: 'org_test', + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const WithOrganization: Story = { + args: { + organizationId: 'org_demo', + }, +}; diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx new file mode 100644 index 0000000000..bd343a250d --- /dev/null +++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.test.tsx @@ -0,0 +1,158 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import { checkAccessibility } from '@/test/utils/a11y'; +import { render, screen } from '@/test/utils/render'; + +vi.mock('@/app/hooks/use-organization-id', () => ({ + useOrganizationId: () => 'org_test', +})); + +vi.mock('@/app/hooks/use-ability', () => ({ + useAbility: () => ({ + can: () => true, + cannot: () => false, + }), +})); + +vi.mock('@/app/hooks/use-toast', () => ({ + useToast: () => ({ + toast: vi.fn(), + }), +})); + +vi.mock('../hooks/queries', () => ({ + useGovernancePolicy: vi.fn().mockReturnValue({ + data: null, + isLoading: false, + }), +})); + +vi.mock('../hooks/mutations', () => ({ + useUpsertGovernancePolicy: () => ({ + mutateAsync: vi.fn(), + isPending: false, + }), +})); + +vi.mock('@/app/features/settings/organization/hooks/queries', () => ({ + useMembers: () => ({ + members: [ + { userId: 'user_1', displayName: 'Alice', email: 'alice@test.com' }, + { userId: 'user_2', displayName: 'Bob', email: 'bob@test.com' }, + ], + }), +})); + +vi.mock('@/app/features/settings/teams/hooks/queries', () => ({ + useOrgTeams: () => ({ + teams: [ + { id: 'team_1', name: 'Engineering' }, + { id: 'team_2', name: 'Marketing' }, + ], + }), +})); + +const { useGovernancePolicy } = await import('../hooks/queries'); +const mockedUseGovernancePolicy = vi.mocked(useGovernancePolicy); + +const { FeatureFlagsEditor } = await import('./feature-flags-editor'); + +describe('FeatureFlagsEditor', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockedUseGovernancePolicy.mockReturnValue({ + data: null, + isLoading: false, + } as never); + }); + + it('renders empty state when no rules exist', () => { + render(); + + expect( + screen.getByText(/no feature control rules configured/i), + ).toBeInTheDocument(); + }); + + it('renders rules table when rules exist', () => { + mockedUseGovernancePolicy.mockReturnValue({ + data: { + config: { + enabled: true, + rules: [ + { + scope: 'default', + webSearch: true, + codeExecution: false, + fileUpload: true, + }, + ], + }, + }, + isLoading: false, + } as never); + + render(); + + expect(screen.getByText('default')).toBeInTheDocument(); + expect(screen.getByText('\u2718')).toBeInTheDocument(); + }); + + it('renders add rule button', () => { + render(); + + expect( + screen.getByRole('button', { name: /add rule/i }), + ).toBeInTheDocument(); + }); + + it('renders enabled toggle', () => { + render(); + + expect(screen.getByRole('switch')).toBeInTheDocument(); + }); + + it('renders loading skeleton while loading', () => { + mockedUseGovernancePolicy.mockReturnValue({ + data: null, + isLoading: true, + } as never); + + const { container } = render(); + + expect(container.querySelector('[aria-busy="true"]')).toBeInTheDocument(); + }); + + describe('accessibility', () => { + it('passes axe audit with empty state', async () => { + const { container } = render( + , + ); + await checkAccessibility(container); + }); + + it('passes axe audit with rules table', async () => { + mockedUseGovernancePolicy.mockReturnValue({ + data: { + config: { + enabled: true, + rules: [ + { + scope: 'default', + webSearch: true, + codeExecution: true, + fileUpload: true, + }, + ], + }, + }, + isLoading: false, + } as never); + + const { container } = render( + , + ); + await checkAccessibility(container); + }); + }); +}); diff --git a/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx b/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx new file mode 100644 index 0000000000..2411537659 --- /dev/null +++ b/services/platform/app/features/settings/governance/components/feature-flags-editor.tsx @@ -0,0 +1,591 @@ +'use client'; + +import { Pencil, Plus, Trash2 } from 'lucide-react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; + +import { FormDialog } from '@/app/components/ui/dialog/form-dialog'; +import { Skeleton } from '@/app/components/ui/feedback/skeleton'; +import { Input } from '@/app/components/ui/forms/input'; +import { SearchableSelect } from '@/app/components/ui/forms/searchable-select'; +import { Select } from '@/app/components/ui/forms/select'; +import { Switch } from '@/app/components/ui/forms/switch'; +import { HStack, Stack } from '@/app/components/ui/layout/layout'; +import { PageSection } from '@/app/components/ui/layout/page-section'; +import { Button } from '@/app/components/ui/primitives/button'; +import { Text } from '@/app/components/ui/typography/text'; +import { useMembers } from '@/app/features/settings/organization/hooks/queries'; +import { useOrgTeams } from '@/app/features/settings/teams/hooks/queries'; +import { useAbility } from '@/app/hooks/use-ability'; +import { useToast } from '@/app/hooks/use-toast'; +import { useT } from '@/lib/i18n/client'; +import { + featureFlagsConfigSchema, + type FeatureFlagsConfig, + type FeatureFlagRule, +} from '@/lib/shared/schemas/governance'; +import { formatNumber } from '@/lib/utils/format/number'; +import { isRecord } from '@/lib/utils/type-guards'; + +import { useUpsertGovernancePolicy } from '../hooks/mutations'; +import { useGovernancePolicy } from '../hooks/queries'; + +interface FeatureFlagsEditorProps { + organizationId: string; +} + +const SCOPE_VALUES = ['default', 'user', 'team', 'role'] as const; + +function isScopeValue(v: string): v is FeatureFlagRule['scope'] { + return (SCOPE_VALUES as readonly string[]).includes(v); +} + +const ROLE_VALUES = ['admin', 'developer', 'editor', 'member'] as const; + +function emptyRule(): FeatureFlagRule { + return { + scope: 'default', + webSearch: true, + codeExecution: true, + fileUpload: true, + }; +} + +function parseFeatureFlagsConfig(policy: unknown): FeatureFlagsConfig { + const config = isRecord(policy) ? policy : {}; + const result = featureFlagsConfigSchema.safeParse(config); + if (result.success) { + return result.data; + } + return { enabled: false, rules: [] }; +} + +interface RuleDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + rule: FeatureFlagRule; + onSave: (rule: FeatureFlagRule) => void; + title: string; + cannotManage: boolean; + memberOptions: { value: string; label: string; description?: string }[]; + teamOptions: { value: string; label: string }[]; +} + +function RuleDialog({ + open, + onOpenChange, + rule: initialRule, + onSave, + title, + cannotManage, + memberOptions, + teamOptions, +}: RuleDialogProps) { + const { t } = useT('governance'); + const { t: tCommon } = useT('common'); + const [draft, setDraft] = useState(initialRule); + + const scopeOptions = useMemo( + () => + SCOPE_VALUES.map((v) => ({ + value: v, + label: t(`featureFlags.scopeLabels.${v}`), + })), + [t], + ); + + const roleOptions = useMemo( + () => + ROLE_VALUES.map((v) => ({ + value: v, + label: t(`featureFlags.roleLabels.${v}`), + })), + [t], + ); + + useEffect(() => { + if (open) { + setDraft(initialRule); + } + }, [open, initialRule]); + + const isDirty = useMemo(() => { + return JSON.stringify(draft) !== JSON.stringify(initialRule); + }, [draft, initialRule]); + + const updateDraft = useCallback((patch: Partial) => { + setDraft((prev) => { + const updated = { ...prev, ...patch }; + if (patch.scope === 'default') { + delete updated.scopeId; + } + return updated; + }); + }, []); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + onSave(draft); + onOpenChange(false); + }, + [draft, onSave, onOpenChange], + ); + + return ( + + + +
+ updateDraft({ scopeId: value })} + disabled={cannotManage} + size="sm" + /> +
+ )} + + {draft.scope === 'user' && ( +
+ + {t('featureFlags.scopeLabels.user')} + + updateDraft({ scopeId: value })} + options={memberOptions} + searchPlaceholder={t('featureFlags.searchUsers')} + emptyText={t('featureFlags.noUsersFound')} + aria-label={t('featureFlags.selectUser')} + trigger={ + + } + /> +
+ )} + + {draft.scope === 'team' && ( +
+ + {t('featureFlags.scopeLabels.team')} + + updateDraft({ scopeId: value })} + options={teamOptions} + searchPlaceholder={t('featureFlags.searchTeams')} + emptyText={t('featureFlags.noTeamsFound')} + aria-label={t('featureFlags.selectTeam')} + trigger={ + + } + /> +
+ )} +
+ + + updateDraft({ webSearch: checked })} + disabled={cannotManage} + /> + + updateDraft({ codeExecution: checked }) + } + disabled={cannotManage} + /> + updateDraft({ fileUpload: checked })} + disabled={cannotManage} + /> + +
+ + updateDraft({ + maxContextTokens: e.target.value + ? Number(e.target.value) + : undefined, + }) + } + disabled={cannotManage} + size="sm" + placeholder="e.g. 50000" + min={0} + /> + + {t('featureFlags.maxContextTokensHint')} + +
+
+
+
+ ); +} + +export function FeatureFlagsEditor({ + organizationId, +}: FeatureFlagsEditorProps) { + const { t } = useT('governance'); + const { toast } = useToast(); + const ability = useAbility(); + + const { data: policy, isLoading } = useGovernancePolicy( + organizationId, + 'feature_flags', + ); + const upsertMutation = useUpsertGovernancePolicy(); + const { members } = useMembers(organizationId); + const { teams } = useOrgTeams(); + + const memberOptions = useMemo( + () => + (members ?? []).map((m) => ({ + value: m.userId, + label: m.displayName || m.email || m.userId, + description: m.email && m.displayName ? m.email : undefined, + })), + [members], + ); + + const teamOptions = useMemo( + () => + (teams ?? []).map((team) => ({ + value: team.id, + label: team.name || team.id, + })), + [teams], + ); + + const savedConfig = useMemo( + () => parseFeatureFlagsConfig(policy?.config), + [policy], + ); + + const [enabled, setEnabled] = useState(false); + const [rules, setRules] = useState([]); + + const [dialogOpen, setDialogOpen] = useState(false); + const [editingIndex, setEditingIndex] = useState(null); + const [dialogRule, setDialogRule] = useState(emptyRule()); + + useEffect(() => { + setEnabled(savedConfig.enabled); + setRules(savedConfig.rules); + }, [savedConfig]); + + const cannotManage = ability.cannot('write', 'orgSettings'); + + const saveConfig = useCallback( + async (configToSave: { enabled: boolean; rules: FeatureFlagRule[] }) => { + try { + await upsertMutation.mutateAsync({ + organizationId, + policyType: 'feature_flags', + config: configToSave, + }); + toast({ title: t('featureFlags.saved'), variant: 'success' }); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : t('featureFlags.saveFailed'); + toast({ title: message, variant: 'destructive' }); + } + }, + [organizationId, upsertMutation, toast, t], + ); + + const handleToggleEnabled = useCallback( + (checked: boolean) => { + setEnabled(checked); + void saveConfig({ enabled: checked, rules }); + }, + [saveConfig, rules], + ); + + const removeRule = useCallback( + (index: number) => { + const newRules = rules.filter((_, i) => i !== index); + setRules(newRules); + void saveConfig({ enabled, rules: newRules }); + }, + [rules, enabled, saveConfig], + ); + + const openAddDialog = useCallback(() => { + setEditingIndex(null); + setDialogRule(emptyRule()); + setDialogOpen(true); + }, []); + + const openEditDialog = useCallback( + (index: number) => { + setEditingIndex(index); + setDialogRule(rules[index]); + setDialogOpen(true); + }, + [rules], + ); + + const handleDialogSave = useCallback( + (rule: FeatureFlagRule) => { + let newRules: FeatureFlagRule[]; + if (editingIndex === null) { + newRules = [...rules, rule]; + } else { + newRules = rules.map((r, i) => (i === editingIndex ? rule : r)); + } + setRules(newRules); + void saveConfig({ enabled, rules: newRules }); + }, + [editingIndex, rules, enabled, saveConfig], + ); + + const resolveTarget = useCallback( + (rule: FeatureFlagRule): string => { + switch (rule.scope) { + case 'user': { + if (!rule.scopeId) return '\u2014'; + return ( + memberOptions.find((o) => o.value === rule.scopeId)?.label ?? + rule.scopeId + ); + } + case 'team': { + if (!rule.scopeId) return '\u2014'; + return ( + teamOptions.find((o) => o.value === rule.scopeId)?.label ?? + rule.scopeId + ); + } + case 'role': + return rule.scopeId ?? '\u2014'; + case 'default': + return t('featureFlags.allUsers'); + default: + return '\u2014'; + } + }, + [memberOptions, teamOptions, t], + ); + + if (isLoading) { + return ( +
+ + + +
+ ); + } + + return ( + + } + > + + + {rules.length > 0 ? ( +
+ + + + + + + + + + + + + + + {rules.map((rule, index) => ( + + + + + + + + + + ))} + +
{t('featureFlags.title')}
+ {t('featureFlags.scope')} + + {t('featureFlags.target')} + + {t('featureFlags.webSearch')} + + {t('featureFlags.codeExecution')} + + {t('featureFlags.fileUpload')} + + {t('featureFlags.maxContextTokens')} + + {t('featureFlags.actions')} +
{rule.scope}{resolveTarget(rule)} + {rule.webSearch === false ? '\u2718' : '\u2714'} + + {rule.codeExecution === false ? '\u2718' : '\u2714'} + + {rule.fileUpload === false ? '\u2718' : '\u2714'} + + {rule.maxContextTokens != null + ? formatNumber(rule.maxContextTokens) + : '\u2014'} + + + + + +
+
+ ) : ( + + {t('featureFlags.noRules')} + + )} + + +
+
+ + +
+ ); +} diff --git a/services/platform/app/features/settings/governance/hooks/queries.ts b/services/platform/app/features/settings/governance/hooks/queries.ts index 55ad84f646..187f2ed86c 100644 --- a/services/platform/app/features/settings/governance/hooks/queries.ts +++ b/services/platform/app/features/settings/governance/hooks/queries.ts @@ -20,3 +20,9 @@ export function useGovernancePolicy( policyType, }); } + +export function useMyFeatureFlags(organizationId: string) { + return useConvexQuery(api.governance.queries.getMyFeatureFlags, { + organizationId, + }); +} diff --git a/services/platform/app/routes/dashboard/$id/settings/governance.tsx b/services/platform/app/routes/dashboard/$id/settings/governance.tsx index a58f3a0a30..7cb91859d4 100644 --- a/services/platform/app/routes/dashboard/$id/settings/governance.tsx +++ b/services/platform/app/routes/dashboard/$id/settings/governance.tsx @@ -5,6 +5,7 @@ import { z } from 'zod'; import { AccessDenied } from '@/app/components/layout/access-denied'; import { Tabs } from '@/app/components/ui/navigation/tabs'; import { BudgetEditor } from '@/app/features/settings/governance/components/budget-editor'; +import { FeatureFlagsEditor } from '@/app/features/settings/governance/components/feature-flags-editor'; import { PiiConfig } from '@/app/features/settings/governance/components/pii-config'; import { RetentionEditor } from '@/app/features/settings/governance/components/retention-editor'; import { SystemPromptEditor } from '@/app/features/settings/governance/components/system-prompt-editor'; @@ -29,8 +30,8 @@ function GovernanceSettingsPage() { const { id: organizationId } = Route.useParams(); const search = Route.useSearch(); const navigate = useNavigate(); - const { t } = useT('accessDenied'); - const { t: tGov } = useT('governance'); + const { t: tAccessDenied } = useT('accessDenied'); + const { t } = useT('governance'); const ability = useAbility(); const abilityLoading = useAbilityLoading(); @@ -49,12 +50,12 @@ function GovernanceSettingsPage() { () => [ { value: 'system-prompt', - label: tGov('tabs.systemPrompt'), + label: t('tabs.systemPrompt'), content: , }, { value: 'budgets', - label: tGov('tabs.budgets'), + label: t('tabs.budgets'), content: , }, { @@ -62,18 +63,23 @@ function GovernanceSettingsPage() { label: 'Retention', content: , }, + { + value: 'feature-controls', + label: t('tabs.featureControls'), + content: , + }, { value: 'usage', - label: tGov('tabs.usage'), + label: t('tabs.usage'), content: , }, { value: 'pii', - label: tGov('tabs.pii'), + label: t('tabs.pii'), content: , }, ], - [organizationId, tGov], + [organizationId, t], ); if (abilityLoading) { @@ -81,7 +87,7 @@ function GovernanceSettingsPage() { } if (ability.cannot('read', 'orgSettings')) { - return ; + return ; } return ( diff --git a/services/platform/convex/governance/__tests__/feature_enforcement.test.ts b/services/platform/convex/governance/__tests__/feature_enforcement.test.ts new file mode 100644 index 0000000000..768e77605a --- /dev/null +++ b/services/platform/convex/governance/__tests__/feature_enforcement.test.ts @@ -0,0 +1,320 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +import type { + FeatureFlagsConfig, + FeatureFlagRule, +} from '../../../lib/shared/schemas/governance'; + +vi.mock('../helpers', () => ({ + readPolicyConfig: vi.fn(), +})); + +const { readPolicyConfig } = await import('../helpers'); +const mockedReadPolicyConfig = vi.mocked(readPolicyConfig); + +const { resolveFeatureFlags } = await import('../feature_enforcement'); + +function createMockCtx() { + return { + db: { + query: vi.fn(), + }, + auth: { + getUserIdentity: vi.fn(), + }, + } as never; +} + +describe('resolveFeatureFlags', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns all-enabled defaults when no policy exists', async () => { + mockedReadPolicyConfig.mockResolvedValue(null); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result).toEqual({ + webSearch: true, + codeExecution: true, + fileUpload: true, + }); + }); + + it('returns all-enabled defaults when policy is disabled', async () => { + const config: FeatureFlagsConfig = { + enabled: false, + rules: [{ scope: 'default', webSearch: false, codeExecution: false }], + }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result).toEqual({ + webSearch: true, + codeExecution: true, + fileUpload: true, + }); + }); + + it('returns all-enabled defaults when rules array is empty', async () => { + const config: FeatureFlagsConfig = { + enabled: true, + rules: [], + }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result).toEqual({ + webSearch: true, + codeExecution: true, + fileUpload: true, + }); + }); + + it('applies default-scope rule when no more specific rule matches', async () => { + const config: FeatureFlagsConfig = { + enabled: true, + rules: [ + { + scope: 'default', + webSearch: false, + codeExecution: true, + fileUpload: true, + }, + ], + }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result.webSearch).toBe(false); + expect(result.codeExecution).toBe(true); + expect(result.fileUpload).toBe(true); + }); + + it('user-scope rule overrides team-scope rule', async () => { + const rules: FeatureFlagRule[] = [ + { scope: 'team', scopeId: 'team_1', webSearch: false }, + { scope: 'user', scopeId: 'user_1', webSearch: true }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + ['team_1'], + 'member', + ); + + expect(result.webSearch).toBe(true); + }); + + it('team-scope rule overrides role-scope rule', async () => { + const rules: FeatureFlagRule[] = [ + { scope: 'role', scopeId: 'member', webSearch: true }, + { scope: 'team', scopeId: 'team_1', webSearch: false }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_2', + ['team_1'], + 'member', + ); + + expect(result.webSearch).toBe(false); + }); + + it('role-scope rule overrides default rule', async () => { + const rules: FeatureFlagRule[] = [ + { scope: 'default', webSearch: true }, + { scope: 'role', scopeId: 'member', webSearch: false }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result.webSearch).toBe(false); + }); + + it('partial rule preserves defaults for unset fields', async () => { + const rules: FeatureFlagRule[] = [ + { scope: 'user', scopeId: 'user_1', webSearch: false }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result.webSearch).toBe(false); + expect(result.codeExecution).toBe(true); + expect(result.fileUpload).toBe(true); + expect(result.maxContextTokens).toBeUndefined(); + }); + + it('passes through maxContextTokens from the matching rule', async () => { + const rules: FeatureFlagRule[] = [ + { scope: 'user', scopeId: 'user_1', maxContextTokens: 50000 }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result.maxContextTokens).toBe(50000); + }); + + it('returns defaults when no rules match the user', async () => { + const rules: FeatureFlagRule[] = [ + { scope: 'user', scopeId: 'user_other', webSearch: false }, + { scope: 'team', scopeId: 'team_other', codeExecution: false }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result).toEqual({ + webSearch: true, + codeExecution: true, + fileUpload: true, + }); + }); + + it('handles all features disabled for a user', async () => { + const rules: FeatureFlagRule[] = [ + { + scope: 'user', + scopeId: 'user_1', + webSearch: false, + codeExecution: false, + fileUpload: false, + maxContextTokens: 10000, + }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + 'member', + ); + + expect(result).toEqual({ + webSearch: false, + codeExecution: false, + fileUpload: false, + maxContextTokens: 10000, + }); + }); + + it('matches team rule when user belongs to multiple teams', async () => { + const rules: FeatureFlagRule[] = [ + { scope: 'team', scopeId: 'team_2', webSearch: false }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + ['team_1', 'team_2', 'team_3'], + 'member', + ); + + expect(result.webSearch).toBe(false); + }); + + it('skips role rule when role is not provided', async () => { + const rules: FeatureFlagRule[] = [ + { scope: 'role', scopeId: 'admin', webSearch: false }, + { scope: 'default', webSearch: true }, + ]; + const config: FeatureFlagsConfig = { enabled: true, rules }; + mockedReadPolicyConfig.mockResolvedValue(config); + const ctx = createMockCtx(); + + const result = await resolveFeatureFlags( + ctx, + 'org_1', + 'user_1', + [], + undefined, + ); + + expect(result.webSearch).toBe(true); + }); +}); diff --git a/services/platform/convex/governance/mutations.ts b/services/platform/convex/governance/mutations.ts index ed8621926d..4b23577eba 100644 --- a/services/platform/convex/governance/mutations.ts +++ b/services/platform/convex/governance/mutations.ts @@ -2,6 +2,7 @@ import { v } from 'convex/values'; import { budgetConfigSchema, + featureFlagsConfigSchema, piiConfigSchema, } from '../../lib/shared/schemas/governance'; import { mutation } from '../_generated/server'; @@ -51,6 +52,15 @@ export const upsertPolicy = mutation({ } } + if (args.policyType === 'feature_flags') { + const parsed = featureFlagsConfigSchema.safeParse(args.config); + if (!parsed.success) { + throw new Error( + `Invalid feature flags configuration: ${parsed.error.message}`, + ); + } + } + const existing = await ctx.db .query('governancePolicies') .withIndex('by_org_policyType', (q) => diff --git a/services/platform/convex/governance/queries.ts b/services/platform/convex/governance/queries.ts index 43831ef392..1f0621e365 100644 --- a/services/platform/convex/governance/queries.ts +++ b/services/platform/convex/governance/queries.ts @@ -2,8 +2,10 @@ import { v } from 'convex/values'; import { query } from '../_generated/server'; import { authComponent } from '../auth'; +import { getUserTeamIds } from '../lib/get_user_teams'; import { getOrganizationMember } from '../lib/rls'; import { isAdmin } from '../lib/rls/helpers/role_helpers'; +import { resolveFeatureFlags } from './feature_enforcement'; import { GOVERNANCE_POLICY_TYPES } from './schema'; const policyTypeValidator = v.union( @@ -152,3 +154,31 @@ export const getUsageSummary = query({ }; }, }); + +export const getMyFeatureFlags = query({ + args: { + organizationId: v.string(), + }, + handler: async (ctx, args) => { + const authUser = await authComponent.getAuthUser(ctx); + if (!authUser) { + throw new Error('Unauthenticated'); + } + + const userId = String(authUser._id); + const member = await getOrganizationMember(ctx, args.organizationId, { + userId, + email: authUser.email, + name: authUser.name, + }); + + const teamIds = await getUserTeamIds(ctx, userId); + return resolveFeatureFlags( + ctx, + args.organizationId, + userId, + teamIds, + member.role, + ); + }, +}); diff --git a/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts b/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts index 878cf69577..49a661ef15 100644 --- a/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts +++ b/services/platform/convex/lib/agent_chat/__tests__/start_agent_chat.test.ts @@ -37,6 +37,22 @@ vi.mock('../../debug_log', () => ({ createDebugLog: () => () => {}, })); +vi.mock('../../../governance/budget_enforcement', () => ({ + checkBudget: vi.fn().mockResolvedValue({ allowed: true }), +})); + +vi.mock('../../../governance/feature_enforcement', () => ({ + resolveFeatureFlags: vi.fn().mockResolvedValue({ + webSearch: true, + codeExecution: true, + fileUpload: true, + }), +})); + +vi.mock('../../get_user_teams', () => ({ + getUserTeamIds: vi.fn().mockResolvedValue([]), +})); + vi.mock('../../message_deduplication', () => ({ computeDeduplicationState: () => ({ lastUserMessage: null, @@ -45,6 +61,10 @@ vi.mock('../../message_deduplication', () => ({ }), })); +const { resolveFeatureFlags } = + await import('../../../governance/feature_enforcement'); +const mockedResolveFeatureFlags = vi.mocked(resolveFeatureFlags); + const { startAgentChat } = await import('../start_agent_chat'); function createMockCtx( @@ -145,3 +165,133 @@ describe('startAgentChat — concurrent generation guard', () => { }); }); }); + +describe('startAgentChat — feature flag enforcement', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockListMessages.mockResolvedValue({ page: [] }); + mockSaveMessage.mockResolvedValue({ messageId: 'msg_1' }); + }); + + it('forwards maxContextTokens to scheduled generation when set', async () => { + mockedResolveFeatureFlags.mockResolvedValueOnce({ + webSearch: true, + codeExecution: true, + fileUpload: true, + maxContextTokens: 4096, + }); + + const ctx = createMockCtx({ + _id: 'meta_1', + generationStatus: 'idle', + }); + + await startAgentChat(createDefaultArgs(ctx)); + + expect(ctx.scheduler.runAfter).toHaveBeenCalledWith( + expect.any(Number), + 'mock-runAgentGeneration', + expect.objectContaining({ maxContextTokens: 4096 }), + ); + }); + + it('removes web tool and sets webSearchMode off when webSearch is disabled', async () => { + mockedResolveFeatureFlags.mockResolvedValueOnce({ + webSearch: false, + codeExecution: true, + fileUpload: true, + }); + + const ctx = createMockCtx({ + _id: 'meta_1', + generationStatus: 'idle', + }); + + const args = { + ...createDefaultArgs(ctx), + agentConfig: { + name: 'test-agent', + instructions: 'test', + maxSteps: 5, + webSearchMode: 'tool' as const, + convexToolNames: ['web', 'rag_search'] as never[], + }, + }; + + await startAgentChat(args); + + expect(ctx.scheduler.runAfter).toHaveBeenCalledWith( + expect.any(Number), + 'mock-runAgentGeneration', + expect.objectContaining({ + agentConfig: expect.objectContaining({ + webSearchMode: 'off', + convexToolNames: ['rag_search'], + }), + }), + ); + }); + + it('blocks file upload with assistant message when fileUpload is disabled', async () => { + mockedResolveFeatureFlags.mockResolvedValueOnce({ + webSearch: true, + codeExecution: true, + fileUpload: false, + }); + + const ctx = createMockCtx({ + _id: 'meta_1', + generationStatus: 'idle', + }); + + const args = { + ...createDefaultArgs(ctx), + attachments: [ + { + fileId: 'file_1' as never, + fileName: 'test.pdf', + fileType: 'application/pdf', + fileSize: 1024, + }, + ], + }; + + const result = await startAgentChat(args); + + expect(result.streamId).toBe('new-stream-id'); + expect(mockSaveMessage).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + message: expect.objectContaining({ + role: 'assistant', + content: expect.stringContaining('File uploads are disabled'), + }), + }), + ); + expect(ctx.db.patch).toHaveBeenCalledWith( + 'meta_1', + expect.objectContaining({ + generationStatus: 'idle', + }), + ); + expect(ctx.scheduler.runAfter).not.toHaveBeenCalled(); + }); + + it('allows request without attachments when fileUpload is disabled', async () => { + mockedResolveFeatureFlags.mockResolvedValueOnce({ + webSearch: true, + codeExecution: true, + fileUpload: false, + }); + + const ctx = createMockCtx({ + _id: 'meta_1', + generationStatus: 'idle', + }); + + await startAgentChat(createDefaultArgs(ctx)); + + expect(ctx.scheduler.runAfter).toHaveBeenCalled(); + }); +}); diff --git a/services/platform/convex/lib/agent_chat/internal_actions.ts b/services/platform/convex/lib/agent_chat/internal_actions.ts index 0c4289da9c..734e557f43 100644 --- a/services/platform/convex/lib/agent_chat/internal_actions.ts +++ b/services/platform/convex/lib/agent_chat/internal_actions.ts @@ -141,6 +141,7 @@ export const runAgentGeneration = internalAction({ maxSteps: v.optional(v.number()), deadlineMs: v.optional(v.number()), generationParams: v.optional(v.any()), + maxContextTokens: v.optional(v.number()), }, handler: async (ctx, args) => { const actionStartTime = Date.now(); @@ -171,6 +172,7 @@ export const runAgentGeneration = internalAction({ maxSteps, deadlineMs, generationParams, + maxContextTokens, } = args; const agentType = narrowStringUnion( @@ -412,6 +414,7 @@ export const runAgentGeneration = internalAction({ noCacheToolNames: agentConfig.noCacheToolNames, instructions: finalInstructions, toolsSummary, + maxContextTokens, }, { ctx, diff --git a/services/platform/convex/lib/agent_chat/start_agent_chat.ts b/services/platform/convex/lib/agent_chat/start_agent_chat.ts index 4d102c337d..fadc98993e 100644 --- a/services/platform/convex/lib/agent_chat/start_agent_chat.ts +++ b/services/platform/convex/lib/agent_chat/start_agent_chat.ts @@ -18,11 +18,13 @@ import { components, internal } from '../../_generated/api'; import type { Id } from '../../_generated/dataModel'; import type { MutationCtx } from '../../_generated/server'; import { checkBudget } from '../../governance/budget_enforcement'; +import { resolveFeatureFlags } from '../../governance/feature_enforcement'; import { persistentStreaming } from '../../streaming/helpers'; import type { FileAttachment } from '../attachments'; import type { AgentType } from '../context_management/constants'; import { AGENT_CONTEXT_CONFIGS } from '../context_management/constants'; import { createDebugLog } from '../debug_log'; +import { getUserTeamIds } from '../get_user_teams'; import { computeDeduplicationState, type AgentListMessagesResult, @@ -241,6 +243,52 @@ export async function startAgentChat( } } + // Feature flag enforcement — resolve per-user flags and override agent config + let enforcedConfig = agentConfig; + let governanceMaxContextTokens: number | undefined; + + if (userId) { + const userTeamIds = await getUserTeamIds(ctx, userId); + const featureFlags = await resolveFeatureFlags( + ctx, + organizationId, + userId, + userTeamIds, + ); + + if (!featureFlags.webSearch) { + enforcedConfig = { + ...agentConfig, + webSearchMode: 'off', + convexToolNames: (agentConfig.convexToolNames ?? []).filter( + (t) => t !== 'web', + ), + }; + } + + if (!featureFlags.fileUpload && attachments && attachments.length > 0) { + await saveMessage(ctx, components.agent, { + threadId, + message: { + role: 'assistant', + content: + 'File uploads are disabled for your account by organization policy. Please contact your administrator.', + }, + }); + if (threadMeta) { + await ctx.db.patch(threadMeta._id, { + generationStatus: 'idle' as const, + updatedAt: Date.now(), + }); + } + return { messageAlreadyExists, streamId }; + } + + if (featureFlags.maxContextTokens != null) { + governanceMaxContextTokens = featureFlags.maxContextTokens; + } + } + // Schedule the generic agent action with full configuration debugLog('SCHEDULE_ACTION', { threadId, @@ -252,7 +300,7 @@ export async function startAgentChat( internal.lib.agent_chat.internal_actions.runAgentGeneration, { agentType, - agentConfig, + agentConfig: enforcedConfig, model, provider, debugTag, @@ -271,6 +319,7 @@ export async function startAgentChat( userContext, deadlineMs, generationParams: args.generationParams, + maxContextTokens: governanceMaxContextTokens, }, ); diff --git a/services/platform/convex/lib/agent_chat/types.ts b/services/platform/convex/lib/agent_chat/types.ts index b24fb872c2..c9e90a0c05 100644 --- a/services/platform/convex/lib/agent_chat/types.ts +++ b/services/platform/convex/lib/agent_chat/types.ts @@ -139,6 +139,8 @@ export interface RunAgentGenerationArgs { maxSteps?: number; /** Optional per-request generation parameters from OpenAI compat endpoint */ generationParams?: GenerationParams; + /** Governance-enforced max context tokens (overrides agent config) */ + maxContextTokens?: number; } /** diff --git a/services/platform/convex/lib/agent_response/generate_response.ts b/services/platform/convex/lib/agent_response/generate_response.ts index f8ad11ef6d..38a33846c9 100644 --- a/services/platform/convex/lib/agent_response/generate_response.ts +++ b/services/platform/convex/lib/agent_response/generate_response.ts @@ -182,6 +182,7 @@ export async function generateAgentResponse( noCacheToolNames, instructions, toolsSummary, + maxContextTokens, } = config; const { ctx, @@ -507,12 +508,18 @@ export async function generateAgentResponse( // Build structured context (history, RAG, web) // Note: promptMessage is NOT included - it's passed via `prompt` parameter const agentConfig = AGENT_CONTEXT_CONFIGS[agentType]; + const effectiveMaxHistoryTokens = + maxContextTokens != null && + Number.isFinite(maxContextTokens) && + maxContextTokens > 0 + ? Math.floor(maxContextTokens) + : agentConfig.maxHistoryTokens; structuredThreadContext = await buildStructuredContext({ ctx, threadId, additionalContext, parentThreadId, - maxHistoryTokens: agentConfig.maxHistoryTokens, + maxHistoryTokens: effectiveMaxHistoryTokens, ragContext: knowledgeContextResult ?? hookData?.ragContext, webContext: webContextResult, }); @@ -940,7 +947,7 @@ export async function generateAgentResponse( threadId, additionalContext, parentThreadId, - maxHistoryTokens: agentConfig.maxHistoryTokens, + maxHistoryTokens: effectiveMaxHistoryTokens, ragContext: hookData?.ragContext, }); @@ -1169,7 +1176,7 @@ export async function generateAgentResponse( threadId, additionalContext, parentThreadId, - maxHistoryTokens: agentConfig.maxHistoryTokens, + maxHistoryTokens: effectiveMaxHistoryTokens, ragContext: hookData?.ragContext, }); diff --git a/services/platform/convex/lib/agent_response/types.ts b/services/platform/convex/lib/agent_response/types.ts index bb8b900770..dc523c7883 100644 --- a/services/platform/convex/lib/agent_response/types.ts +++ b/services/platform/convex/lib/agent_response/types.ts @@ -47,6 +47,8 @@ export interface GenerateResponseConfig { responseCacheTtlMs?: number; /** Tool names whose invocation should prevent caching the response */ noCacheToolNames?: string[]; + /** Governance-enforced max context tokens (overrides agent config maxHistoryTokens) */ + maxContextTokens?: number; } /** diff --git a/services/platform/messages/de.json b/services/platform/messages/de.json index b684b30bbf..8b3d5d40df 100644 --- a/services/platform/messages/de.json +++ b/services/platform/messages/de.json @@ -3640,12 +3640,23 @@ "saved": "Aufbewahrungsrichtlinie gespeichert" }, "featureFlags": { - "title": "Funktionssteuerung", + "title": "Feature-Steuerung", "description": "Funktionen pro Benutzer, Team oder Rolle aktivieren oder deaktivieren.", "webSearch": "Websuche", "codeExecution": "Code-Ausführung", - "maxContextTokens": "Maximale Kontext-Token", - "saved": "Funktionssteuerung gespeichert" + "fileUpload": "Datei-Upload", + "maxContextTokens": "Max. Kontext-Tokens", + "maxContextTokensHint": "Maximale Kontext-Tokens für KI-Antworten. Leer lassen für unbegrenzt.", + "enabled": "Feature-Steuerung aktivieren", + "scope": "Geltungsbereich", + "target": "Ziel", + "allUsers": "Alle Benutzer", + "addRule": "Regel hinzufügen", + "editRule": "Regel bearbeiten", + "deleteRule": "Regel löschen", + "noRules": "Keine Feature-Regeln konfiguriert. Füge eine Regel hinzu, um Funktionen pro Benutzer, Team oder Rolle zu steuern.", + "saved": "Feature-Steuerung gespeichert", + "saveFailed": "Feature-Steuerung konnte nicht gespeichert werden" }, "pii": { "title": "PII-Schutz", @@ -3693,7 +3704,8 @@ "systemPrompt": "System-Prompt", "budgets": "Budgets", "usage": "Nutzung", - "pii": "PII-Schutz" + "pii": "PII-Schutz", + "featureControls": "Feature-Steuerung" } } } diff --git a/services/platform/messages/en.json b/services/platform/messages/en.json index c42726c86e..309a1df70e 100644 --- a/services/platform/messages/en.json +++ b/services/platform/messages/en.json @@ -3694,12 +3694,43 @@ "saved": "Retention policy saved" }, "featureFlags": { - "title": "Feature Controls", + "title": "Feature controls", "description": "Enable or disable features per user, team, or role.", "webSearch": "Web search", "codeExecution": "Code execution", + "fileUpload": "File upload", "maxContextTokens": "Max context tokens", - "saved": "Feature controls saved" + "maxContextTokensHint": "Maximum number of context tokens for AI responses. Leave empty for no limit.", + "enabled": "Enable feature controls", + "scope": "Scope", + "target": "Target", + "allUsers": "All users", + "addRule": "Add rule", + "editRule": "Edit rule", + "deleteRule": "Delete rule", + "noRules": "No feature control rules configured. Add a rule to manage features per user, team, or role.", + "saved": "Feature controls saved", + "saveFailed": "Failed to save feature controls", + "actions": "Actions", + "role": "Role", + "scopeLabels": { + "default": "Default", + "user": "User", + "team": "Team", + "role": "Role" + }, + "roleLabels": { + "admin": "Admin", + "developer": "Developer", + "editor": "Editor", + "member": "Member" + }, + "searchUsers": "Search users...", + "noUsersFound": "No users found", + "selectUser": "Select user...", + "searchTeams": "Search teams...", + "noTeamsFound": "No teams found", + "selectTeam": "Select team..." }, "pii": { "title": "PII protection", @@ -3747,7 +3778,8 @@ "systemPrompt": "System prompt", "budgets": "Budgets", "usage": "Usage", - "pii": "PII protection" + "pii": "PII protection", + "featureControls": "Feature controls" } }, "prompts": {