diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 49e81c1e4..669fddf96 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -212,6 +212,25 @@ import { BoxliteRestModule } from './boxlite-rest/boxlite-rest.module' host: process.env.POSTHOG_HOST, }, apiKey: process.env.POSTHOG_API_KEY, + // Local-dev bootstrap. Only consulted when PostHog isn't configured + // (i.e. no POSTHOG_API_KEY) AND never in production — a prod deploy + // without a PostHog key must fail closed (gated routes stay hidden), + // not fail open. Gated here at the injection site; the provider also + // refuses bootstrap in production as defense-in-depth. + // + // All true → matches the "team-internal full-access" rollout state. + // Mirrors the dashboard-side defaults in PostHogProviderWrapper.tsx + // (LOCAL_DEV_FEATURE_FLAG_DEFAULTS). + ...(process.env.NODE_ENV !== 'production' && { + bootstrapFlags: { + organization_infrastructure: true, + organization_experiments: true, + dashboard_playground: true, + dashboard_webhooks: true, + 'dashboard_create-sandbox': true, + sandbox_spending: true, + }, + }), }), }), ], diff --git a/apps/api/src/common/providers/openfeature-posthog.provider.spec.ts b/apps/api/src/common/providers/openfeature-posthog.provider.spec.ts new file mode 100644 index 000000000..0b2a7fffc --- /dev/null +++ b/apps/api/src/common/providers/openfeature-posthog.provider.spec.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2025 Daytona Platforms Inc. + * Modified by BoxLite AI, 2025-2026 + * SPDX-License-Identifier: AGPL-3.0 + */ + +import type { EvaluationContext, Logger } from '@openfeature/server-sdk' +import { OpenFeaturePostHogProvider } from './openfeature-posthog.provider' + +/** + * Guards the production fail-closed gate: bootstrap flags are a local-dev + * convenience and must NEVER resolve `true` in production when PostHog is + * unconfigured — otherwise a prod deploy without POSTHOG_API_KEY would expose + * flag-gated routes (fail open). See app.module.ts injection-site gate. + */ +describe('OpenFeaturePostHogProvider — production bootstrap gate', () => { + const FLAG = 'dashboard_create-sandbox' + const originalNodeEnv = process.env.NODE_ENV + const logger: Logger = { debug: () => {}, info: () => {}, warn: () => {}, error: () => {} } + const context: EvaluationContext = {} + + afterEach(() => { + process.env.NODE_ENV = originalNodeEnv + }) + + it('honors bootstrap flags outside production when PostHog is unconfigured', async () => { + process.env.NODE_ENV = 'development' + const provider = new OpenFeaturePostHogProvider({ bootstrapFlags: { [FLAG]: true } }) + + const result = await provider.resolveBooleanEvaluation(FLAG, false, context, logger) + + expect(result.value).toBe(true) + }) + + it('ignores bootstrap flags in production — fails closed to the call-site default', async () => { + process.env.NODE_ENV = 'production' + const provider = new OpenFeaturePostHogProvider({ bootstrapFlags: { [FLAG]: true } }) + + const result = await provider.resolveBooleanEvaluation(FLAG, false, context, logger) + + expect(result.value).toBe(false) + }) +}) diff --git a/apps/api/src/common/providers/openfeature-posthog.provider.ts b/apps/api/src/common/providers/openfeature-posthog.provider.ts index 3cfbffbb1..3ecf482b6 100644 --- a/apps/api/src/common/providers/openfeature-posthog.provider.ts +++ b/apps/api/src/common/providers/openfeature-posthog.provider.ts @@ -16,6 +16,14 @@ export interface OpenFeaturePostHogProviderConfig { clientOptions?: PostHogOptions /** Whether to evaluate flags locally (default: false) */ evaluateLocally?: boolean + /** + * Local-dev bootstrap values used when PostHog isn't configured (no apiKey). + * Mirrors the dashboard's `PostHogProviderWrapper` bootstrap pattern. + * Without these, `@RequireFlagsEnabled([X])` decorators always fail because + * each call-site's `defaultValue` is `false` — so e.g. POST /regions and + * GET /runners return 404 ("hidden route") in local dev. Map key = flag name. + */ + bootstrapFlags?: Record } export class OpenFeaturePostHogProvider implements Provider { @@ -26,10 +34,18 @@ export class OpenFeaturePostHogProvider implements Provider { private readonly client?: PostHog private readonly evaluateLocally: boolean private readonly isConfigured: boolean + private readonly bootstrapFlags: Record constructor(config: OpenFeaturePostHogProviderConfig = {}) { this.evaluateLocally = config.evaluateLocally ?? false this.isConfigured = !!config.apiKey + // Bootstrap flags are a local-dev convenience only. Hard-refuse them in + // production so an unconfigured prod (no PostHog key) falls through to each + // call-site's `defaultValue` (fail closed) instead of resolving them true + // (fail open). Defense-in-depth: the injection site (app.module) also skips + // passing them in production. + this.bootstrapFlags = + process.env.NODE_ENV === 'production' ? {} : (config.bootstrapFlags ?? {}) if (config.apiKey) { try { @@ -92,8 +108,10 @@ export class OpenFeaturePostHogProvider implements Provider { context: EvaluationContext, logger: Logger, ): Promise> { - // If PostHog is not configured, return default value + // Bootstrap-aware (mirror evaluateFlag's behavior for object resolution). if (!this.isConfigured || !this.client) { + // Object flags are rare in our usage; not added to bootstrapFlags map + // (typed as boolean | string | number). Fall through to default. logger.debug(`PostHog not configured, returning default value for flag ${flagKey}`) return { value: defaultValue, @@ -147,8 +165,17 @@ export class OpenFeaturePostHogProvider implements Provider { context: EvaluationContext, logger: Logger, ): Promise> { - // If PostHog is not configured, return default value + // If PostHog is not configured, prefer bootstrap value if present, then + // fall back to call-site defaultValue. if (!this.isConfigured || !this.client) { + if (Object.prototype.hasOwnProperty.call(this.bootstrapFlags, flagKey)) { + const bootstrap = this.bootstrapFlags[flagKey] + logger.debug(`PostHog not configured, using bootstrap value for flag ${flagKey}: ${bootstrap}`) + return { + value: bootstrap, + reason: StandardResolutionReasons.STATIC, + } + } logger.debug(`PostHog not configured, returning default value for flag ${flagKey}`) return { value: defaultValue, diff --git a/apps/api/webpack.config.js b/apps/api/webpack.config.js index 62929cc1e..e4373d759 100644 --- a/apps/api/webpack.config.js +++ b/apps/api/webpack.config.js @@ -37,6 +37,22 @@ module.exports = composePlugins( } config.mode = process.env.NODE_ENV + // Local dev only (NODE_ENV=development, set by infra-local's stack-up via + // apps/.env). `nx serve api` triggers the base `api:build` target, which + // would otherwise (a) crash inside @nx/webpack's GeneratePackageJsonPlugin + // in this workspace and (b) fail type-checking on the pre-existing + // @opentelemetry/otlp-exporter-base version skew in the lockfile (a + // type-only mismatch in tracing.ts — the OTLP exporters work at runtime). + // Drop both plugins so the API builds and serves locally. Production + // (NODE_ENV=production) keeps both: the pruned deploy package.json and + // full type-checking. + if (process.env.NODE_ENV === 'development') { + const devSkipPlugins = new Set(['GeneratePackageJsonPlugin', 'ForkTsCheckerWebpackPlugin']) + config.plugins = (config.plugins || []).filter( + (plugin) => !devSkipPlugins.has(plugin?.constructor?.name), + ) + } + config.watchOptions = { ignored: /node_modules/, } diff --git a/apps/dashboard/src/components/PostHogProviderWrapper.tsx b/apps/dashboard/src/components/PostHogProviderWrapper.tsx index 9f75f1bed..78735a964 100644 --- a/apps/dashboard/src/components/PostHogProviderWrapper.tsx +++ b/apps/dashboard/src/components/PostHogProviderWrapper.tsx @@ -6,6 +6,7 @@ import { useConfig } from '@/hooks/useConfig' import { usePrivacyConsentStore } from '@/hooks/usePrivacyConsent' +import { FeatureFlags } from '@/enums/FeatureFlags' import { usePostHog } from 'posthog-js/react' import { PostHogProvider } from 'posthog-js/react' import { FC, ReactNode, useEffect } from 'react' @@ -14,6 +15,23 @@ interface PostHogProviderWrapperProps { children: ReactNode } +// Local-dev defaults for the feature flags that gate UI surfaces. When +// PostHog isn't configured (no API key in /api/config — typical local dev), +// we mount PostHogProvider in a no-network mode with these bootstrap values +// so flag-gated components (e.g. CreateSandboxSheet, which short-circuits to +// `return null` if `dashboard_create-sandbox` is falsy) still render. +// +// All true → matches the "team-internal full-access" rollout state. Flip an +// entry to false to simulate prod gating one off. +const LOCAL_DEV_FEATURE_FLAG_DEFAULTS: Record = { + [FeatureFlags.DASHBOARD_CREATE_SANDBOX]: true, + [FeatureFlags.DASHBOARD_PLAYGROUND]: true, + [FeatureFlags.DASHBOARD_WEBHOOKS]: true, + [FeatureFlags.ORGANIZATION_INFRASTRUCTURE]: true, + [FeatureFlags.ORGANIZATION_EXPERIMENTS]: true, + [FeatureFlags.SANDBOX_SPENDING]: true, +} + function PostHogConsentSync() { const posthog = usePostHog() const analytics = usePrivacyConsentStore((state) => state.preferences.analytics) @@ -35,20 +53,55 @@ function PostHogConsentSync() { export const PostHogProviderWrapper: FC = ({ children }) => { const config = useConfig() - if (!config.posthog) { - return children - } + const posthogConfigured = !!(config.posthog?.apiKey && config.posthog?.host) - if (!config.posthog?.apiKey || !config.posthog?.host) { + if (config.posthog && !posthogConfigured) { + // Server sent a posthog block but it's incomplete — likely operator typo. console.error('Invalid PostHog configuration') - return children + } + + if (!posthogConfigured) { + // Production must never bootstrap flags to `true` (fail-open). If PostHog + // isn't configured in a prod build, render without a provider so every + // flag resolves falsy (fail-closed) — matching the pre-bootstrap behavior. + // `import.meta.env.PROD` is Vite's production-build equivalent of + // NODE_ENV==='production'; infra-local runs `vite` dev (PROD=false), so it + // still gets the local-dev bootstrap below. + if (import.meta.env.PROD) { + return children + } + + // Local-dev / unconfigured path. Mount PostHogProvider with a stub key + + // bootstrap feature flags so `useFeatureFlagEnabled(...)` returns the + // defaults above. All network behavior disabled: no /decide call (we + // pre-load flags via bootstrap), no event capture, no autocapture. + return ( + posthog.opt_out_capturing(), + }} + > + {children} + + ) } return (