diff --git a/web/.env.example b/web/.env.example index 2684667cd4..05e3ce4faa 100644 --- a/web/.env.example +++ b/web/.env.example @@ -92,3 +92,18 @@ NEXT_PUBLIC_AMPLITUDE_API_KEY= # number of concurrency NEXT_PUBLIC_BATCH_CONCURRENCY=5 + +# Cloud system-features frontend defaults. +# These values are only used when NEXT_PUBLIC_EDITION=CLOUD (IS_CLOUD_EDITION). +NEXT_PUBLIC_ENABLE_MARKETPLACE=true +NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN=true +NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN=false +NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN=true +NEXT_PUBLIC_ENABLE_COLLABORATION_MODE=false +NEXT_PUBLIC_ALLOW_REGISTER=true +NEXT_PUBLIC_ALLOW_CREATE_WORKSPACE=true +NEXT_PUBLIC_IS_EMAIL_SETUP=true +NEXT_PUBLIC_ENABLE_CHANGE_EMAIL=true +NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED=true +NEXT_PUBLIC_ENABLE_TRIAL_APP=true +NEXT_PUBLIC_ENABLE_EXPLORE_BANNER=true diff --git a/web/config/cloud-system-features.ts b/web/config/cloud-system-features.ts new file mode 100644 index 0000000000..8508789903 --- /dev/null +++ b/web/config/cloud-system-features.ts @@ -0,0 +1,52 @@ +import type { SystemFeatures } from '@/types/feature' +import { env } from '@/env' +import { defaultSystemFeatures, InstallationScope, LicenseStatus } from '@/types/feature' + +export const cloudSystemFeatures: SystemFeatures = { + ...defaultSystemFeatures, + sso_enforced_for_signin: false, + sso_enforced_for_signin_protocol: '', + + enable_marketplace: env.NEXT_PUBLIC_ENABLE_MARKETPLACE, + enable_email_code_login: env.NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN, + enable_email_password_login: env.NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN, + enable_social_oauth_login: env.NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN, + enable_collaboration_mode: env.NEXT_PUBLIC_ENABLE_COLLABORATION_MODE, + is_allow_register: env.NEXT_PUBLIC_ALLOW_REGISTER, + is_allow_create_workspace: env.NEXT_PUBLIC_ALLOW_CREATE_WORKSPACE, + is_email_setup: env.NEXT_PUBLIC_IS_EMAIL_SETUP, + enable_change_email: env.NEXT_PUBLIC_ENABLE_CHANGE_EMAIL, + + license: { + ...defaultSystemFeatures.license, + status: LicenseStatus.NONE, + expired_at: '', + }, + + branding: { + enabled: false, + application_title: '', + login_page_logo: '', + workspace_logo: '', + favicon: '', + }, + + webapp_auth: { + enabled: false, + allow_sso: false, + sso_config: { + protocol: '', + }, + allow_email_code_login: false, + allow_email_password_login: false, + }, + + plugin_installation_permission: { + plugin_installation_scope: InstallationScope.ALL, + restrict_to_marketplace_only: false, + }, + + enable_creators_platform: env.NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED, + enable_trial_app: env.NEXT_PUBLIC_ENABLE_TRIAL_APP, + enable_explore_banner: env.NEXT_PUBLIC_ENABLE_EXPLORE_BANNER, +} diff --git a/web/docker/entrypoint.sh b/web/docker/entrypoint.sh index a0b456dc9f..0fed7b033a 100755 --- a/web/docker/entrypoint.sh +++ b/web/docker/entrypoint.sh @@ -28,6 +28,21 @@ export NEXT_TELEMETRY_DISABLED=${NEXT_TELEMETRY_DISABLED} export NEXT_PUBLIC_AMPLITUDE_API_KEY=${AMPLITUDE_API_KEY} +# Cloud system-features frontend defaults. +# These values are only used when EDITION=CLOUD (IS_CLOUD_EDITION). +export NEXT_PUBLIC_ENABLE_MARKETPLACE=${NEXT_PUBLIC_ENABLE_MARKETPLACE:-${MARKETPLACE_ENABLED}} +export NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN=${NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN:-${ENABLE_EMAIL_CODE_LOGIN}} +export NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN=${NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN:-${ENABLE_EMAIL_PASSWORD_LOGIN}} +export NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN=${NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN:-${ENABLE_SOCIAL_OAUTH_LOGIN}} +export NEXT_PUBLIC_ENABLE_COLLABORATION_MODE=${NEXT_PUBLIC_ENABLE_COLLABORATION_MODE:-${ENABLE_COLLABORATION_MODE}} +export NEXT_PUBLIC_ALLOW_REGISTER=${NEXT_PUBLIC_ALLOW_REGISTER:-${ALLOW_REGISTER}} +export NEXT_PUBLIC_ALLOW_CREATE_WORKSPACE=${NEXT_PUBLIC_ALLOW_CREATE_WORKSPACE:-${ALLOW_CREATE_WORKSPACE}} +export NEXT_PUBLIC_IS_EMAIL_SETUP=${NEXT_PUBLIC_IS_EMAIL_SETUP:-${IS_EMAIL_SETUP}} +export NEXT_PUBLIC_ENABLE_CHANGE_EMAIL=${NEXT_PUBLIC_ENABLE_CHANGE_EMAIL:-${ENABLE_CHANGE_EMAIL}} +export NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED=${NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED:-${CREATORS_PLATFORM_FEATURES_ENABLED}} +export NEXT_PUBLIC_ENABLE_TRIAL_APP=${NEXT_PUBLIC_ENABLE_TRIAL_APP:-${ENABLE_TRIAL_APP}} +export NEXT_PUBLIC_ENABLE_EXPLORE_BANNER=${NEXT_PUBLIC_ENABLE_EXPLORE_BANNER:-${ENABLE_EXPLORE_BANNER}} + export NEXT_PUBLIC_TEXT_GENERATION_TIMEOUT_MS=${TEXT_GENERATION_TIMEOUT_MS} export NEXT_PUBLIC_CSP_WHITELIST=${CSP_WHITELIST} export NEXT_PUBLIC_ALLOW_EMBED=${ALLOW_EMBED} diff --git a/web/env.ts b/web/env.ts index 16a0ea2da1..bccee4fa53 100644 --- a/web/env.ts +++ b/web/env.ts @@ -13,7 +13,7 @@ const coercedBoolean = z.string() .transform(s => s === 'true' || s === '1') const coercedNumber = z.coerce.number().int().positive() -/// keep-sorted +/// Keep keys sorted except grouped feature-specific blocks. const clientSchema = { /** * Default is not allow to embed into iframe to prevent Clickjacking: https://owasp.org/www-community/attacks/Clickjacking @@ -63,6 +63,24 @@ const clientSchema = { * The deployment edition, SELF_HOSTED */ NEXT_PUBLIC_EDITION: z.enum(['SELF_HOSTED', 'CLOUD']).default('SELF_HOSTED'), + + /** + * Cloud-only system-features defaults. + * These values are only used when NEXT_PUBLIC_EDITION=CLOUD (IS_CLOUD_EDITION). + */ + NEXT_PUBLIC_ENABLE_MARKETPLACE: coercedBoolean.default(true), + NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN: coercedBoolean.default(true), + NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN: coercedBoolean.default(false), + NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN: coercedBoolean.default(true), + NEXT_PUBLIC_ENABLE_COLLABORATION_MODE: coercedBoolean.default(false), + NEXT_PUBLIC_ALLOW_REGISTER: coercedBoolean.default(true), + NEXT_PUBLIC_ALLOW_CREATE_WORKSPACE: coercedBoolean.default(true), + NEXT_PUBLIC_IS_EMAIL_SETUP: coercedBoolean.default(true), + NEXT_PUBLIC_ENABLE_CHANGE_EMAIL: coercedBoolean.default(true), + NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED: coercedBoolean.default(true), + NEXT_PUBLIC_ENABLE_TRIAL_APP: coercedBoolean.default(true), + NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: coercedBoolean.default(true), + /** * Enable inline LaTeX rendering with single dollar signs ($...$) * Default is false for security reasons to prevent conflicts with regular text @@ -171,6 +189,24 @@ export const env = createEnv({ NEXT_PUBLIC_DEPLOY_ENV: isServer ? process.env.NEXT_PUBLIC_DEPLOY_ENV : getRuntimeEnvFromBody('deployEnv'), NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON: isServer ? process.env.NEXT_PUBLIC_DISABLE_UPLOAD_IMAGE_AS_ICON : getRuntimeEnvFromBody('disableUploadImageAsIcon'), NEXT_PUBLIC_EDITION: isServer ? process.env.NEXT_PUBLIC_EDITION : getRuntimeEnvFromBody('edition'), + + /** + * Cloud-only system-features defaults. + * These values are only used when NEXT_PUBLIC_EDITION=CLOUD (IS_CLOUD_EDITION). + */ + NEXT_PUBLIC_ENABLE_MARKETPLACE: isServer ? process.env.NEXT_PUBLIC_ENABLE_MARKETPLACE : getRuntimeEnvFromBody('enableMarketplace'), + NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN: isServer ? process.env.NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN : getRuntimeEnvFromBody('enableEmailCodeLogin'), + NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN: isServer ? process.env.NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN : getRuntimeEnvFromBody('enableEmailPasswordLogin'), + NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN: isServer ? process.env.NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN : getRuntimeEnvFromBody('enableSocialOauthLogin'), + NEXT_PUBLIC_ENABLE_COLLABORATION_MODE: isServer ? process.env.NEXT_PUBLIC_ENABLE_COLLABORATION_MODE : getRuntimeEnvFromBody('enableCollaborationMode'), + NEXT_PUBLIC_ALLOW_REGISTER: isServer ? process.env.NEXT_PUBLIC_ALLOW_REGISTER : getRuntimeEnvFromBody('allowRegister'), + NEXT_PUBLIC_ALLOW_CREATE_WORKSPACE: isServer ? process.env.NEXT_PUBLIC_ALLOW_CREATE_WORKSPACE : getRuntimeEnvFromBody('allowCreateWorkspace'), + NEXT_PUBLIC_IS_EMAIL_SETUP: isServer ? process.env.NEXT_PUBLIC_IS_EMAIL_SETUP : getRuntimeEnvFromBody('isEmailSetup'), + NEXT_PUBLIC_ENABLE_CHANGE_EMAIL: isServer ? process.env.NEXT_PUBLIC_ENABLE_CHANGE_EMAIL : getRuntimeEnvFromBody('enableChangeEmail'), + NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED: isServer ? process.env.NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED : getRuntimeEnvFromBody('creatorsPlatformFeaturesEnabled'), + NEXT_PUBLIC_ENABLE_TRIAL_APP: isServer ? process.env.NEXT_PUBLIC_ENABLE_TRIAL_APP : getRuntimeEnvFromBody('enableTrialApp'), + NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: isServer ? process.env.NEXT_PUBLIC_ENABLE_EXPLORE_BANNER : getRuntimeEnvFromBody('enableExploreBanner'), + NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX: isServer ? process.env.NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX : getRuntimeEnvFromBody('enableSingleDollarLatex'), NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL: isServer ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL : getRuntimeEnvFromBody('enableWebsiteFirecrawl'), NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER: isServer ? process.env.NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER : getRuntimeEnvFromBody('enableWebsiteJinareader'), diff --git a/web/service/__tests__/system-features.spec.ts b/web/service/__tests__/system-features.spec.ts new file mode 100644 index 0000000000..87bf4b1ffa --- /dev/null +++ b/web/service/__tests__/system-features.spec.ts @@ -0,0 +1,261 @@ +import type { SystemFeatures } from '@/types/feature' +import { describe, expect, it, vi } from 'vitest' +import { defaultSystemFeatures } from '@/types/feature' + +type LoadOptions = { + cloudEnv?: Partial + isCloudEdition: boolean + systemFeaturesResult?: SystemFeatures + systemFeaturesError?: Error +} + +const defaultCloudEnv = { + NEXT_PUBLIC_ALLOW_CREATE_WORKSPACE: true, + NEXT_PUBLIC_ALLOW_REGISTER: true, + NEXT_PUBLIC_CREATORS_PLATFORM_FEATURES_ENABLED: true, + NEXT_PUBLIC_ENABLE_CHANGE_EMAIL: true, + NEXT_PUBLIC_ENABLE_COLLABORATION_MODE: false, + NEXT_PUBLIC_ENABLE_EMAIL_CODE_LOGIN: true, + NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN: false, + NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: true, + NEXT_PUBLIC_ENABLE_MARKETPLACE: true, + NEXT_PUBLIC_ENABLE_SOCIAL_OAUTH_LOGIN: true, + NEXT_PUBLIC_ENABLE_TRIAL_APP: true, + NEXT_PUBLIC_IS_EMAIL_SETUP: true, +} + +const queryKey = ['console', 'systemFeatures'] as const +const queryContext = { + queryKey, + signal: new AbortController().signal, + meta: undefined, +} as never + +const loadSystemFeaturesModule = async ({ + cloudEnv, + isCloudEdition, + systemFeaturesResult = defaultSystemFeatures, + systemFeaturesError, +}: LoadOptions) => { + vi.resetModules() + + const systemFeatures = systemFeaturesError + ? vi.fn().mockRejectedValue(systemFeaturesError) + : vi.fn().mockResolvedValue(systemFeaturesResult) + + vi.doMock('@/config', () => ({ + IS_CLOUD_EDITION: isCloudEdition, + })) + vi.doMock('@/env', () => ({ + env: { + ...defaultCloudEnv, + ...cloudEnv, + }, + })) + vi.doMock('../client', () => ({ + consoleClient: { + systemFeatures, + }, + consoleQuery: { + systemFeatures: { + queryKey: () => queryKey, + }, + }, + })) + + const module = await import('../system-features') + + return { + module, + systemFeatures, + } +} + +const loadServerSystemFeaturesModule = async ({ + cloudEnv, + isCloudEdition, + systemFeaturesResult = defaultSystemFeatures, + systemFeaturesError, +}: LoadOptions) => { + vi.resetModules() + + const getServerConsoleClientContext = vi.fn().mockResolvedValue({ cookie: 'session=1' }) + const systemFeatures = systemFeaturesError + ? vi.fn().mockRejectedValue(systemFeaturesError) + : vi.fn().mockResolvedValue(systemFeaturesResult) + + vi.doMock('@/config', () => ({ + IS_CLOUD_EDITION: isCloudEdition, + })) + vi.doMock('@/env', () => ({ + env: { + ...defaultCloudEnv, + ...cloudEnv, + }, + })) + vi.doMock('../server', () => ({ + getServerConsoleClientContext, + serverConsoleClient: { + systemFeatures, + }, + serverConsoleQuery: { + systemFeatures: { + queryKey: () => queryKey, + }, + }, + })) + + const module = await import('../server-system-features') + + return { + getServerConsoleClientContext, + module, + systemFeatures, + } +} + +describe('systemFeaturesQueryOptions', () => { + it('should return Cloud defaults without calling system-features when Cloud edition is enabled', async () => { + const { module, systemFeatures } = await loadSystemFeaturesModule({ + isCloudEdition: true, + }) + + const options = module.systemFeaturesQueryOptions() + const data = await options.queryFn?.(queryContext) + + expect(systemFeatures).not.toHaveBeenCalled() + expect(options.staleTime).toBe(Infinity) + expect(data).toMatchObject({ + enable_marketplace: true, + enable_email_code_login: true, + enable_email_password_login: false, + enable_social_oauth_login: true, + enable_trial_app: true, + }) + }) + + it('should use Cloud environment flags with local defaults for fixed fields', async () => { + const { module } = await loadSystemFeaturesModule({ + isCloudEdition: true, + cloudEnv: { + NEXT_PUBLIC_ENABLE_MARKETPLACE: false, + NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN: true, + NEXT_PUBLIC_ENABLE_COLLABORATION_MODE: true, + NEXT_PUBLIC_ALLOW_REGISTER: false, + NEXT_PUBLIC_ENABLE_EXPLORE_BANNER: false, + }, + }) + + const options = module.systemFeaturesQueryOptions() + const data = await options.queryFn?.(queryContext) + + expect(data).toMatchObject({ + enable_marketplace: false, + enable_email_password_login: true, + enable_collaboration_mode: true, + is_allow_register: false, + enable_explore_banner: false, + branding: { + enabled: false, + application_title: '', + favicon: '', + }, + license: { + status: 'none', + }, + }) + }) + + it('should fetch system-features when Cloud edition is disabled', async () => { + const systemFeaturesResult = { + ...defaultSystemFeatures, + enable_marketplace: true, + } + const { module, systemFeatures } = await loadSystemFeaturesModule({ + isCloudEdition: false, + systemFeaturesResult, + }) + + const options = module.systemFeaturesQueryOptions() + const data = await options.queryFn?.(queryContext) + + expect(systemFeatures).toHaveBeenCalledTimes(1) + expect(data).toBe(systemFeaturesResult) + }) + + it('should fall back to defaults when the non-Cloud request fails', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { module, systemFeatures } = await loadSystemFeaturesModule({ + isCloudEdition: false, + systemFeaturesError: new Error('network failed'), + }) + + const options = module.systemFeaturesQueryOptions() + const data = await options.queryFn?.(queryContext) + + expect(systemFeatures).toHaveBeenCalledTimes(1) + expect(data).toEqual(defaultSystemFeatures) + + errorSpy.mockRestore() + }) +}) + +describe('serverSystemFeaturesQueryOptions', () => { + it('should prefetch Cloud defaults without calling server system-features when Cloud edition is enabled', async () => { + const { getServerConsoleClientContext, module, systemFeatures } = await loadServerSystemFeaturesModule({ + isCloudEdition: true, + cloudEnv: { + NEXT_PUBLIC_ENABLE_MARKETPLACE: false, + NEXT_PUBLIC_ENABLE_EMAIL_PASSWORD_LOGIN: true, + }, + }) + + const options = module.serverSystemFeaturesQueryOptions() + const data = await options.queryFn?.(queryContext) + + expect(getServerConsoleClientContext).not.toHaveBeenCalled() + expect(systemFeatures).not.toHaveBeenCalled() + expect(options.staleTime).toBe(Infinity) + expect(data).toMatchObject({ + enable_marketplace: false, + enable_email_password_login: true, + }) + }) + + it('should fetch server system-features when Cloud edition is disabled', async () => { + const systemFeaturesResult = { + ...defaultSystemFeatures, + enable_marketplace: true, + } + const { getServerConsoleClientContext, module, systemFeatures } = await loadServerSystemFeaturesModule({ + isCloudEdition: false, + systemFeaturesResult, + }) + + const options = module.serverSystemFeaturesQueryOptions() + const data = await options.queryFn?.(queryContext) + + expect(getServerConsoleClientContext).toHaveBeenCalledTimes(1) + expect(systemFeatures).toHaveBeenCalledTimes(1) + expect(systemFeatures).toHaveBeenCalledWith(undefined, { + context: { cookie: 'session=1' }, + }) + expect(data).toBe(systemFeaturesResult) + }) + + it('should fall back to defaults when the non-Cloud server request fails', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const { module, systemFeatures } = await loadServerSystemFeaturesModule({ + isCloudEdition: false, + systemFeaturesError: new Error('server failed'), + }) + + const options = module.serverSystemFeaturesQueryOptions() + const data = await options.queryFn?.(queryContext) + + expect(systemFeatures).toHaveBeenCalledTimes(1) + expect(data).toEqual(defaultSystemFeatures) + + errorSpy.mockRestore() + }) +}) diff --git a/web/service/server-system-features.ts b/web/service/server-system-features.ts index f67f6ee2d2..62a6e2e2f2 100644 --- a/web/service/server-system-features.ts +++ b/web/service/server-system-features.ts @@ -1,5 +1,7 @@ import type { SystemFeatures } from '@/types/feature' import { queryOptions } from '@tanstack/react-query' +import { IS_CLOUD_EDITION } from '@/config' +import { cloudSystemFeatures } from '@/config/cloud-system-features' import { defaultSystemFeatures } from '@/types/feature' import { getServerConsoleClientContext, @@ -7,9 +9,19 @@ import { serverConsoleQuery, } from './server' -export const serverSystemFeaturesQueryOptions = () => - queryOptions({ - queryKey: serverConsoleQuery.systemFeatures.queryKey(), +export const serverSystemFeaturesQueryOptions = () => { + const queryKey = serverConsoleQuery.systemFeatures.queryKey() + + if (IS_CLOUD_EDITION) { + return queryOptions({ + queryKey, + queryFn: async () => cloudSystemFeatures, + staleTime: Infinity, + }) + } + + return queryOptions({ + queryKey, queryFn: async () => { try { return await serverConsoleClient.systemFeatures(undefined, { @@ -22,3 +34,4 @@ export const serverSystemFeaturesQueryOptions = () => } }, }) +} diff --git a/web/service/system-features.ts b/web/service/system-features.ts index 4bf79a16fd..97bf4f97a5 100644 --- a/web/service/system-features.ts +++ b/web/service/system-features.ts @@ -1,5 +1,7 @@ import type { SystemFeatures } from '@/types/feature' import { queryOptions } from '@tanstack/react-query' +import { IS_CLOUD_EDITION } from '@/config' +import { cloudSystemFeatures } from '@/config/cloud-system-features' import { defaultSystemFeatures } from '@/types/feature' import { consoleClient, consoleQuery } from './client' @@ -12,15 +14,24 @@ import { consoleClient, consoleQuery } from './client' * availability via defaults; the trade-off is acceptable because /system-features * is a small, dependency-free endpoint in the community edition. * - * No `staleTime` override either: inherit the 5-minute default from - * query-client-server.ts. Combined with `refetchOnWindowFocus`, this lets us - * recover from a transient startup failure (which got cached as "successful - * defaults") within ~5 minutes or on tab focus. `staleTime: Infinity` would - * pin the whole tab to defaults until reload — strictly worse than main. + * For Cloud, this query is intentionally local-only and uses `staleTime: + * Infinity`: the payload comes from frontend config/defaults, so refetching + * would only re-run the same local merge. For non-Cloud, do not override + * `staleTime`: inherit the 5-minute default from query-client-server.ts. */ -export const systemFeaturesQueryOptions = () => - queryOptions({ - queryKey: consoleQuery.systemFeatures.queryKey(), +export const systemFeaturesQueryOptions = () => { + const queryKey = consoleQuery.systemFeatures.queryKey() + + if (IS_CLOUD_EDITION) { + return queryOptions({ + queryKey, + queryFn: async () => cloudSystemFeatures, + staleTime: Infinity, + }) + } + + return queryOptions({ + queryKey, queryFn: async () => { try { return await consoleClient.systemFeatures() @@ -31,3 +42,4 @@ export const systemFeaturesQueryOptions = () => } }, }) +}