From af99d99a5c46ecc208c03b59636820f61dffc702 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Tue, 5 May 2026 15:56:24 +0100 Subject: [PATCH 1/7] fix: correct feature flag and experiment evaluation --- .../src/analytics/analytics.controller.ts | 83 ++++++++ .../cloud/src/analytics/analytics.module.ts | 2 + .../src/analytics/bot-detection.service.ts | 1 + .../src/experiment/experiment.controller.ts | 177 ++++++++++++------ .../apps/cloud/src/feature-flag/evaluation.ts | 9 +- .../feature-flag/feature-flag.controller.ts | 56 +++++- .../src/feature-flag/feature-flag.module.ts | 2 +- .../src/feature-flag/feature-flag.service.ts | 7 +- .../src/analytics/bot-detection.service.ts | 1 + .../community/src/feature-flag/evaluation.ts | 13 +- .../feature-flag/feature-flag.controller.ts | 47 ++++- 11 files changed, 310 insertions(+), 88 deletions(-) diff --git a/backend/apps/cloud/src/analytics/analytics.controller.ts b/backend/apps/cloud/src/analytics/analytics.controller.ts index 4f3063996..1c5795413 100644 --- a/backend/apps/cloud/src/analytics/analytics.controller.ts +++ b/backend/apps/cloud/src/analytics/analytics.controller.ts @@ -95,6 +95,12 @@ import { GetKeywordsDto } from './dto/get-keywords.dto' import { GetBotStatsDto } from './dto/get-bot-stats.dto' import { GSCService } from '../project/gsc.service' import { GetProfileIdDto, GetSessionIdDto } from './dto/get-id.dto' +import { ExperimentService } from '../experiment/experiment.service' +import { + ExperimentStatus, + ExposureTrigger, +} from '../experiment/entity/experiment.entity' +import { getExperimentVariant } from '../feature-flag/evaluation' dayjs.extend(utc) dayjs.extend(dayjsTimezone) @@ -205,6 +211,7 @@ export class AnalyticsController { private readonly analyticsService: AnalyticsService, private readonly logger: AppLoggerService, private readonly gscService: GSCService, + private readonly experimentService: ExperimentService, ) {} @ApiBearerAuth() @@ -1663,6 +1670,13 @@ export class AnalyticsController { values: [transformed], clickhouse_settings: { async_insert: 1 }, }) + + await this.trackCustomEventExperimentExposures( + eventsDTO.pid, + eventsDTO.ev, + profileId, + transformed.created, + ) } catch (reason) { this.logger.error(reason) throw new InternalServerErrorException( @@ -1673,6 +1687,75 @@ export class AnalyticsController { return {} } + private async trackCustomEventExperimentExposures( + pid: string, + eventName: string, + profileId: string, + created: string, + ) { + try { + const experiments = await this.experimentService.find({ + where: { + project: { id: pid }, + status: ExperimentStatus.RUNNING, + exposureTrigger: ExposureTrigger.CUSTOM_EVENT, + customEventName: eventName, + }, + relations: ['variants'], + }) + + if (_isEmpty(experiments)) { + return + } + + const exposures = [] + for (const experiment of experiments) { + if (!experiment.variants || experiment.variants.length === 0) { + continue + } + + const sortedVariants = [...experiment.variants].sort((a, b) => + a.key.localeCompare(b.key), + ) + const variantKey = getExperimentVariant( + experiment.id, + sortedVariants.map((variant) => ({ + key: variant.key, + rolloutPercentage: variant.rolloutPercentage, + })), + profileId, + ) + + if (!variantKey) { + continue + } + + exposures.push({ + pid, + experimentId: experiment.id, + variantKey, + profileId, + created, + }) + } + + if (_isEmpty(exposures)) { + return + } + + await clickhouse.insert({ + table: 'experiment_exposures', + values: exposures, + format: 'JSONEachRow', + }) + } catch (reason) { + this.logger.warn( + { reason }, + 'Failed to track custom event experiment exposures', + ) + } + } + @Post('hb') @Auth(true, true) async heartbeat( diff --git a/backend/apps/cloud/src/analytics/analytics.module.ts b/backend/apps/cloud/src/analytics/analytics.module.ts index 529dda282..3e348ce82 100644 --- a/backend/apps/cloud/src/analytics/analytics.module.ts +++ b/backend/apps/cloud/src/analytics/analytics.module.ts @@ -11,6 +11,7 @@ import { UserModule } from '../user/user.module' import { AppLoggerModule } from '../logger/logger.module' import { ProjectModule } from '../project/project.module' import { RevenueModule } from '../revenue/revenue.module' +import { ExperimentModule } from '../experiment/experiment.module' @Module({ imports: [ @@ -19,6 +20,7 @@ import { RevenueModule } from '../revenue/revenue.module' AppLoggerModule, ProjectModule, forwardRef(() => RevenueModule), + forwardRef(() => ExperimentModule), ], providers: [ AnalyticsService, diff --git a/backend/apps/cloud/src/analytics/bot-detection.service.ts b/backend/apps/cloud/src/analytics/bot-detection.service.ts index 79175a021..cab195c09 100644 --- a/backend/apps/cloud/src/analytics/bot-detection.service.ts +++ b/backend/apps/cloud/src/analytics/bot-detection.service.ts @@ -20,6 +20,7 @@ export type BotEndpoint = | 'pageview' | 'custom' | 'error' + | 'feature_flag' | 'heartbeat' | 'noscript' diff --git a/backend/apps/cloud/src/experiment/experiment.controller.ts b/backend/apps/cloud/src/experiment/experiment.controller.ts index 869176359..d4c4bf91a 100644 --- a/backend/apps/cloud/src/experiment/experiment.controller.ts +++ b/backend/apps/cloud/src/experiment/experiment.controller.ts @@ -74,6 +74,17 @@ import { Pagination } from '../common/pagination' const EXPERIMENTS_MAXIMUM = 20 // Maximum experiments per project const FEATURE_FLAG_KEY_REGEX = /^[a-zA-Z0-9_-]+$/ +const validateUniqueVariantKeys = (variants: Array<{ key: string }>): void => { + const seen = new Set() + + for (const variant of variants) { + if (seen.has(variant.key)) { + throw new BadRequestException('Variant keys must be unique') + } + seen.add(variant.key) + } +} + type GoalEventConditions = { eventType: 'pageview' | 'custom_event' matchColumn: 'pg' | 'event_name' @@ -253,6 +264,8 @@ export class ExperimentController { ) } + validateUniqueVariantKeys(experimentDto.variants) + const controlVariants = experimentDto.variants.filter((v) => v.isControl) if (controlVariants.length !== 1) { throw new BadRequestException( @@ -445,6 +458,8 @@ export class ExperimentController { ) } + validateUniqueVariantKeys(experimentDto.variants) + const controlVariants = experimentDto.variants.filter( (v) => v.isControl, ) @@ -484,31 +499,47 @@ export class ExperimentController { } let featureFlag = experiment.featureFlag - if ( - experimentDto.featureFlagMode !== undefined && - experimentDto.featureFlagMode !== experiment.featureFlagMode - ) { - if (experimentDto.featureFlagMode === FeatureFlagMode.LINK) { - if (!experimentDto.existingFeatureFlagId) { - throw new BadRequestException( - 'Feature flag ID is required when linking an existing flag', - ) - } - const existingFlag = await this.featureFlagService.findOne({ - where: { - id: experimentDto.existingFeatureFlagId, - project: { id: experiment.project.id }, - }, - }) - if (!existingFlag) { - throw new NotFoundException('Feature flag not found') - } - if (existingFlag.experimentId && existingFlag.experimentId !== id) { - throw new BadRequestException( - 'This feature flag is already linked to another experiment', + const targetFeatureFlagMode = + experimentDto.featureFlagMode ?? experiment.featureFlagMode + + if (targetFeatureFlagMode === FeatureFlagMode.LINK) { + const targetFlagId = + experimentDto.existingFeatureFlagId ?? + (experiment.featureFlagMode === FeatureFlagMode.LINK + ? experiment.featureFlag?.id + : undefined) + + if (!targetFlagId) { + throw new BadRequestException( + 'Feature flag ID is required when linking an existing flag', + ) + } + + const existingFlag = await this.featureFlagService.findOne({ + where: { + id: targetFlagId, + project: { id: experiment.project.id }, + }, + }) + if (!existingFlag) { + throw new NotFoundException('Feature flag not found') + } + if (existingFlag.experimentId && existingFlag.experimentId !== id) { + throw new BadRequestException( + 'This feature flag is already linked to another experiment', + ) + } + + if ( + experiment.featureFlag && + experiment.featureFlag.id !== existingFlag.id + ) { + if (experiment.featureFlagMode === FeatureFlagMode.CREATE) { + await this.featureFlagService.delete( + experiment.featureFlag.id, + transactionalEntityManager, ) - } - if (experiment.featureFlag) { + } else { await this.featureFlagService.update( experiment.featureFlag.id, { @@ -517,6 +548,9 @@ export class ExperimentController { transactionalEntityManager, ) } + } + + if (existingFlag.experimentId !== id) { await this.featureFlagService.update( existingFlag.id, { @@ -524,8 +558,22 @@ export class ExperimentController { }, transactionalEntityManager, ) - featureFlag = existingFlag } + + featureFlag = existingFlag + } else if ( + targetFeatureFlagMode === FeatureFlagMode.CREATE && + experiment.featureFlag && + experiment.featureFlagMode === FeatureFlagMode.LINK + ) { + await this.featureFlagService.update( + experiment.featureFlag.id, + { + experimentId: null, + }, + transactionalEntityManager, + ) + featureFlag = null } const updatePayload: Partial = { @@ -919,15 +967,16 @@ export class ExperimentController { diff, ) + const exposureAttributionSubquery = + this.getExposureAttributionSubquery(experiment) + const exposuresQuery = ` - SELECT + SELECT variantKey, - uniqExact(profileId) as exposures - FROM experiment_exposures - WHERE - pid = {pid:FixedString(12)} - AND experimentId = {experimentId:String} - AND created BETWEEN {groupFrom:String} AND {groupTo:String} + count() as exposures + FROM ( + ${exposureAttributionSubquery} + ) GROUP BY variantKey ` @@ -964,14 +1013,14 @@ export class ExperimentController { SELECT e.variantKey, uniqExact(e.profileId) as conversions - FROM experiment_exposures e + FROM ( + ${exposureAttributionSubquery} + ) e INNER JOIN events c ON e.pid = c.pid AND e.profileId = assumeNotNull(c.profileId) AND c.type = '${eventType}' WHERE e.pid = {pid:FixedString(12)} - AND e.experimentId = {experimentId:String} - AND e.created BETWEEN {groupFrom:String} AND {groupTo:String} AND c.created BETWEEN {groupFrom:String} AND {groupTo:String} - AND c.created >= e.created + AND c.created >= e.exposureCreated AND ${matchCondition} ${metaCondition} GROUP BY e.variantKey @@ -1140,6 +1189,33 @@ export class ExperimentController { } } + private getExposureAttributionSubquery(experiment: Experiment): string { + const variantSelector = + experiment.multipleVariantHandling === + MultipleVariantHandling.FIRST_EXPOSURE + ? 'argMin(variantKey, tuple(created, variantKey))' + : 'any(variantKey)' + const multiVariantFilter = + experiment.multipleVariantHandling === MultipleVariantHandling.EXCLUDE + ? 'HAVING uniqExact(variantKey) = 1' + : '' + + return ` + SELECT + pid, + profileId, + ${variantSelector} as variantKey, + min(created) as exposureCreated + FROM experiment_exposures + WHERE + pid = {pid:FixedString(12)} + AND experimentId = {experimentId:String} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + GROUP BY pid, profileId + ${multiVariantFilter} + ` + } + /** * Generate time-series chart data for experiment win probabilities */ @@ -1159,16 +1235,18 @@ export class ExperimentController { safeTimezone, ) - // Important: table results use overall uniqExact(profileId) counts per variant. + // Important: table results use one attributed variant per profile. // For the time-series we must avoid "summing per-bucket uniques", because a profile // can appear in multiple time buckets (re-exposed / repeated events). Instead we: - // - bucket exposures by the first exposure per (variantKey, profileId) - // - bucket conversions by the first conversion per (variantKey, profileId) + // - bucket exposures by each profile's attributed exposure timestamp + // - bucket conversions by the first conversion per attributed variant/profile // This guarantees that the last chart point uses the same data as the table. const dateColumnsGroupBy = this.getTimeBucketDateColumnsGroupBy(timeBucket) + const exposureAttributionSubquery = + this.getExposureAttributionSubquery(experiment) const exposuresDateColumnsSelect = this.getTimeBucketDateColumnsSelect( timeBucket, - 'firstCreated', + 'exposureCreated', ) const exposuresQuery = ` @@ -1177,16 +1255,7 @@ export class ExperimentController { variantKey, count() as exposures FROM ( - SELECT - variantKey, - profileId, - min(created) as firstCreated - FROM experiment_exposures - WHERE - pid = {pid:FixedString(12)} - AND experimentId = {experimentId:String} - AND created BETWEEN {groupFrom:String} AND {groupTo:String} - GROUP BY variantKey, profileId + ${exposureAttributionSubquery} ) GROUP BY ${dateColumnsGroupBy}, variantKey ORDER BY ${dateColumnsGroupBy} @@ -1234,14 +1303,14 @@ export class ExperimentController { e.variantKey as variantKey, e.profileId as profileId, min(c.created) as firstConversion - FROM experiment_exposures e + FROM ( + ${exposureAttributionSubquery} + ) e INNER JOIN events c ON e.pid = c.pid AND e.profileId = assumeNotNull(c.profileId) AND c.type = '${eventType}' WHERE e.pid = {pid:FixedString(12)} - AND e.experimentId = {experimentId:String} - AND e.created BETWEEN {groupFrom:String} AND {groupTo:String} AND c.created BETWEEN {groupFrom:String} AND {groupTo:String} - AND c.created >= e.created + AND c.created >= e.exposureCreated AND ${matchCondition} ${metaCondition} GROUP BY e.variantKey, e.profileId diff --git a/backend/apps/cloud/src/feature-flag/evaluation.ts b/backend/apps/cloud/src/feature-flag/evaluation.ts index 6665c1dba..00f3ef2b4 100644 --- a/backend/apps/cloud/src/feature-flag/evaluation.ts +++ b/backend/apps/cloud/src/feature-flag/evaluation.ts @@ -87,14 +87,13 @@ function matchesTargetingRules( rules: TargetingRule[], attributes?: Record, ): boolean { - if (!attributes) { - return true - } - for (const rule of rules) { - const attributeValue = attributes[rule.column] + const attributeValue = attributes?.[rule.column] if (attributeValue === undefined) { + if (!rule.isExclusive) { + return false + } continue } diff --git a/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts b/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts index 38f84dd2d..110b1590d 100644 --- a/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts +++ b/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts @@ -50,9 +50,12 @@ import { } from './dto/feature-flag.dto' import { FeatureFlagService } from './feature-flag.service' import { ExperimentService } from '../experiment/experiment.service' -import { ExperimentStatus } from '../experiment/entity/experiment.entity' +import { + ExperimentStatus, + ExposureTrigger, +} from '../experiment/entity/experiment.entity' import { clickhouse } from '../common/integrations/clickhouse' -import { getIPFromHeaders, getIPDetails } from '../common/utils' +import { checkRateLimit, getIPFromHeaders, getIPDetails } from '../common/utils' import { getExperimentVariant } from './evaluation' import { trackCustom } from '../common/analytics' @@ -256,7 +259,7 @@ export class FeatureFlagController { @ApiOperation({ summary: 'Evaluate feature flags for a visitor (public endpoint)', description: - 'Evaluates all enabled feature flags for a project based on visitor attributes derived from the request. Does not require authentication.', + 'Evaluates all feature flags for a project based on visitor attributes derived from the request. Does not require authentication.', }) async evaluateFlags( @Body() evaluateDto: EvaluateFeatureFlagsDto, @@ -266,10 +269,39 @@ export class FeatureFlagController { this.logger.log({ pid: evaluateDto.pid }, 'POST /feature-flag/evaluate') const ip = getIPFromHeaders(headers) || reqIP || '' + const userAgent = headers['user-agent'] || '' + const origin = headers.origin || '' - const project = await this.projectService.findOne({ - where: { id: evaluateDto.pid }, - }) + if (ip) { + await checkRateLimit(ip, 'feature-flag-evaluate-ip', 600, 60) + } + await checkRateLimit( + evaluateDto.pid, + 'feature-flag-evaluate-project', + 5000, + 60, + ) + + const botResult = await this.analyticsService.checkBot( + evaluateDto.pid, + userAgent, + headers, + ip, + headers.referer || headers.referrer, + null, + 'feature_flag', + ) + + if (botResult.isBot) { + return { flags: {} } + } + + let project + try { + project = await this.projectService.getRedisProject(evaluateDto.pid) + } catch { + return { flags: {} } + } // Return empty flags instead of revealing whether a project exists // This prevents project ID enumeration attacks @@ -277,13 +309,16 @@ export class FeatureFlagController { return { flags: {} } } - const flags = await this.featureFlagService.findEnabledByProject( - evaluateDto.pid, - ) + this.analyticsService.checkIpBlacklist(project, ip) + this.analyticsService.checkOrigin(project, origin) + this.analyticsService.checkIfAccountSuspended(project) // Derive attributes from request headers (like analytics does) - const userAgent = headers['user-agent'] || '' const { country, city, region } = getIPDetails(ip) + this.analyticsService.checkCountryBlacklist(project, country) + + const flags = await this.featureFlagService.findByProject(evaluateDto.pid) + const { deviceType, browserName, osName } = await this.analyticsService.getRequestInformation(headers) @@ -337,6 +372,7 @@ export class FeatureFlagController { where: flagsWithExperiments.map((f) => ({ id: f.experimentId, status: ExperimentStatus.RUNNING, + exposureTrigger: ExposureTrigger.FEATURE_FLAG, })), relations: ['variants'], }) diff --git a/backend/apps/cloud/src/feature-flag/feature-flag.module.ts b/backend/apps/cloud/src/feature-flag/feature-flag.module.ts index ebf6926f9..993b5b4f8 100644 --- a/backend/apps/cloud/src/feature-flag/feature-flag.module.ts +++ b/backend/apps/cloud/src/feature-flag/feature-flag.module.ts @@ -16,7 +16,7 @@ import { FeatureFlagController } from './feature-flag.controller' ProjectModule, AppLoggerModule, UserModule, - AnalyticsModule, + forwardRef(() => AnalyticsModule), forwardRef(() => ExperimentModule), ], providers: [FeatureFlagService], diff --git a/backend/apps/cloud/src/feature-flag/feature-flag.service.ts b/backend/apps/cloud/src/feature-flag/feature-flag.service.ts index cbfeedad5..6ceb4c47f 100644 --- a/backend/apps/cloud/src/feature-flag/feature-flag.service.ts +++ b/backend/apps/cloud/src/feature-flag/feature-flag.service.ts @@ -113,8 +113,11 @@ export class FeatureFlagService { return repository.update(id, flagData) } - async delete(id: string): Promise { - return this.featureFlagRepository.delete(id) + async delete(id: string, manager?: EntityManager): Promise { + const repository = manager + ? manager.getRepository(FeatureFlag) + : this.featureFlagRepository + return repository.delete(id) } /** diff --git a/backend/apps/community/src/analytics/bot-detection.service.ts b/backend/apps/community/src/analytics/bot-detection.service.ts index 69553ad49..8bf54ddc6 100644 --- a/backend/apps/community/src/analytics/bot-detection.service.ts +++ b/backend/apps/community/src/analytics/bot-detection.service.ts @@ -20,6 +20,7 @@ export type BotEndpoint = | 'pageview' | 'custom' | 'error' + | 'feature_flag' | 'heartbeat' | 'noscript' diff --git a/backend/apps/community/src/feature-flag/evaluation.ts b/backend/apps/community/src/feature-flag/evaluation.ts index eac9c8115..758a54147 100644 --- a/backend/apps/community/src/feature-flag/evaluation.ts +++ b/backend/apps/community/src/feature-flag/evaluation.ts @@ -91,18 +91,13 @@ function matchesTargetingRules( rules: TargetingRule[], attributes?: Record, ): boolean { - if (!attributes) { - // If no attributes provided, we can't match any rules - // Return true to be permissive (flag will be shown) - return true - } - for (const rule of rules) { - const attributeValue = attributes[rule.column] + const attributeValue = attributes?.[rule.column] - // Check if we have the attribute if (attributeValue === undefined) { - // If attribute not provided, skip this rule (be permissive) + if (!rule.isExclusive) { + return false + } continue } diff --git a/backend/apps/community/src/feature-flag/feature-flag.controller.ts b/backend/apps/community/src/feature-flag/feature-flag.controller.ts index 0f1245fd5..167399daa 100644 --- a/backend/apps/community/src/feature-flag/feature-flag.controller.ts +++ b/backend/apps/community/src/feature-flag/feature-flag.controller.ts @@ -44,7 +44,7 @@ import { } from './dto/feature-flag.dto' import { FeatureFlagService } from './feature-flag.service' import { clickhouse } from '../common/integrations/clickhouse' -import { getIPFromHeaders, getIPDetails } from '../common/utils' +import { checkRateLimit, getIPFromHeaders, getIPDetails } from '../common/utils' const FEATURE_FLAGS_MAXIMUM = 50 // Maximum feature flags per project const FEATURE_FLAGS_PAGINATION_MAX_TAKE = 100 @@ -203,7 +203,7 @@ export class FeatureFlagController { @ApiOperation({ summary: 'Evaluate feature flags for a visitor (public endpoint)', description: - 'Evaluates all enabled feature flags for a project based on visitor attributes derived from the request. Does not require authentication.', + 'Evaluates all feature flags for a project based on visitor attributes derived from the request. Does not require authentication.', }) async evaluateFlags( @Body() evaluateDto: EvaluateFeatureFlagsDto, @@ -213,8 +213,39 @@ export class FeatureFlagController { this.logger.log({ pid: evaluateDto.pid }, 'POST /feature-flag/evaluate') const ip = getIPFromHeaders(headers) || reqIP || '' + const userAgent = headers['user-agent'] || '' + const origin = headers.origin || '' - const project = await this.projectService.getRedisProject(evaluateDto.pid) + if (ip) { + await checkRateLimit(ip, 'feature-flag-evaluate-ip', 600, 60) + } + await checkRateLimit( + evaluateDto.pid, + 'feature-flag-evaluate-project', + 5000, + 60, + ) + + const botResult = await this.analyticsService.checkBot( + evaluateDto.pid, + userAgent, + headers, + ip, + headers.referer || headers.referrer, + null, + 'feature_flag', + ) + + if (botResult.isBot) { + return { flags: {} } + } + + let project + try { + project = await this.projectService.getRedisProject(evaluateDto.pid) + } catch { + return { flags: {} } + } // Return empty flags instead of revealing whether a project exists // This prevents project ID enumeration attacks @@ -222,13 +253,15 @@ export class FeatureFlagController { return { flags: {} } } - const flags = await this.featureFlagService.findEnabledByProject( - evaluateDto.pid, - ) + this.analyticsService.checkIpBlacklist(project, ip) + this.analyticsService.checkOrigin(project, origin) // Derive attributes from request headers (like analytics does) - const userAgent = headers['user-agent'] || '' const { country, city, region } = getIPDetails(ip) + this.analyticsService.checkCountryBlacklist(project, country) + + const flags = await this.featureFlagService.findByProject(evaluateDto.pid) + const { deviceType, browserName, osName } = await this.analyticsService.getRequestInformation(headers) From e695056ac3435ff267d4d6442874afd45ae8622c Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Tue, 5 May 2026 17:10:46 +0100 Subject: [PATCH 2/7] fix feature flag and experiment evaluation edge cases --- backend/apps/cloud/src/analytics/analytics.controller.ts | 2 +- backend/apps/cloud/src/experiment/experiment.controller.ts | 3 ++- backend/apps/cloud/src/feature-flag/evaluation.ts | 2 +- .../apps/cloud/src/feature-flag/feature-flag.controller.ts | 2 +- .../community/src/feature-flag/feature-flag.controller.ts | 4 +++- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/apps/cloud/src/analytics/analytics.controller.ts b/backend/apps/cloud/src/analytics/analytics.controller.ts index 1c5795413..fecb0f163 100644 --- a/backend/apps/cloud/src/analytics/analytics.controller.ts +++ b/backend/apps/cloud/src/analytics/analytics.controller.ts @@ -1671,7 +1671,7 @@ export class AnalyticsController { clickhouse_settings: { async_insert: 1 }, }) - await this.trackCustomEventExperimentExposures( + void this.trackCustomEventExperimentExposures( eventsDTO.pid, eventsDTO.ev, profileId, diff --git a/backend/apps/cloud/src/experiment/experiment.controller.ts b/backend/apps/cloud/src/experiment/experiment.controller.ts index d4c4bf91a..f12e29fe9 100644 --- a/backend/apps/cloud/src/experiment/experiment.controller.ts +++ b/backend/apps/cloud/src/experiment/experiment.controller.ts @@ -1192,7 +1192,8 @@ export class ExperimentController { private getExposureAttributionSubquery(experiment: Experiment): string { const variantSelector = experiment.multipleVariantHandling === - MultipleVariantHandling.FIRST_EXPOSURE + MultipleVariantHandling.FIRST_EXPOSURE || + experiment.multipleVariantHandling === MultipleVariantHandling.EXCLUDE ? 'argMin(variantKey, tuple(created, variantKey))' : 'any(variantKey)' const multiVariantFilter = diff --git a/backend/apps/cloud/src/feature-flag/evaluation.ts b/backend/apps/cloud/src/feature-flag/evaluation.ts index 00f3ef2b4..3c5095358 100644 --- a/backend/apps/cloud/src/feature-flag/evaluation.ts +++ b/backend/apps/cloud/src/feature-flag/evaluation.ts @@ -192,5 +192,5 @@ export function getExperimentVariant( } } - return variants[variants.length - 1].key + return null } diff --git a/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts b/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts index 110b1590d..1efe4ba28 100644 --- a/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts +++ b/backend/apps/cloud/src/feature-flag/feature-flag.controller.ts @@ -300,7 +300,7 @@ export class FeatureFlagController { try { project = await this.projectService.getRedisProject(evaluateDto.pid) } catch { - return { flags: {} } + project = await this.projectService.getFullProject(evaluateDto.pid) } // Return empty flags instead of revealing whether a project exists diff --git a/backend/apps/community/src/feature-flag/feature-flag.controller.ts b/backend/apps/community/src/feature-flag/feature-flag.controller.ts index 167399daa..b6d9f0c15 100644 --- a/backend/apps/community/src/feature-flag/feature-flag.controller.ts +++ b/backend/apps/community/src/feature-flag/feature-flag.controller.ts @@ -260,7 +260,9 @@ export class FeatureFlagController { const { country, city, region } = getIPDetails(ip) this.analyticsService.checkCountryBlacklist(project, country) - const flags = await this.featureFlagService.findByProject(evaluateDto.pid) + const flags = await this.featureFlagService.findEnabledByProject( + evaluateDto.pid, + ) const { deviceType, browserName, osName } = await this.analyticsService.getRequestInformation(headers) From 05c48e69218a00915459e8b61080a8ab87fe1ee5 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Tue, 5 May 2026 18:01:44 +0100 Subject: [PATCH 3/7] fix: address feature flag exposure review findings --- .../src/analytics/analytics.controller.ts | 18 +++++++++++++----- .../apps/cloud/src/feature-flag/evaluation.ts | 2 +- .../feature-flag/feature-flag.controller.ts | 6 +++++- 3 files changed, 19 insertions(+), 7 deletions(-) diff --git a/backend/apps/cloud/src/analytics/analytics.controller.ts b/backend/apps/cloud/src/analytics/analytics.controller.ts index fecb0f163..2818c83c2 100644 --- a/backend/apps/cloud/src/analytics/analytics.controller.ts +++ b/backend/apps/cloud/src/analytics/analytics.controller.ts @@ -1743,11 +1743,19 @@ export class AnalyticsController { return } - await clickhouse.insert({ - table: 'experiment_exposures', - values: exposures, - format: 'JSONEachRow', - }) + clickhouse + .insert({ + table: 'experiment_exposures', + values: exposures, + format: 'JSONEachRow', + clickhouse_settings: { async_insert: 1 }, + }) + .catch((reason) => { + this.logger.warn( + { reason }, + 'Failed to async insert custom event experiment exposures', + ) + }) } catch (reason) { this.logger.warn( { reason }, diff --git a/backend/apps/cloud/src/feature-flag/evaluation.ts b/backend/apps/cloud/src/feature-flag/evaluation.ts index 3c5095358..e627ac87c 100644 --- a/backend/apps/cloud/src/feature-flag/evaluation.ts +++ b/backend/apps/cloud/src/feature-flag/evaluation.ts @@ -182,7 +182,7 @@ export function getExperimentVariant( const hashValue = parseInt(hash.substring(0, 8), 16) - const normalizedValue = (hashValue / 0xffffffff) * 100 + const normalizedValue = (hashValue / 0x100000000) * 100 let cumulativePercentage = 0 for (const variant of variants) { diff --git a/backend/apps/community/src/feature-flag/feature-flag.controller.ts b/backend/apps/community/src/feature-flag/feature-flag.controller.ts index b6d9f0c15..44e296f54 100644 --- a/backend/apps/community/src/feature-flag/feature-flag.controller.ts +++ b/backend/apps/community/src/feature-flag/feature-flag.controller.ts @@ -243,7 +243,11 @@ export class FeatureFlagController { let project try { project = await this.projectService.getRedisProject(evaluateDto.pid) - } catch { + } catch (reason) { + this.logger.warn( + `Failed to get Redis project for pid ${evaluateDto.pid} (feature_flag): ${reason}`, + 'evaluate', + ) return { flags: {} } } From 42d4a2b272164eb3be6e0e139b7625269c05863b Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Tue, 5 May 2026 18:09:07 +0100 Subject: [PATCH 4/7] fix: align feature flag evaluation behaviour --- .../src/feature-flag/feature-flag.controller.ts | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/backend/apps/community/src/feature-flag/feature-flag.controller.ts b/backend/apps/community/src/feature-flag/feature-flag.controller.ts index 44e296f54..0e70b1c5c 100644 --- a/backend/apps/community/src/feature-flag/feature-flag.controller.ts +++ b/backend/apps/community/src/feature-flag/feature-flag.controller.ts @@ -203,7 +203,7 @@ export class FeatureFlagController { @ApiOperation({ summary: 'Evaluate feature flags for a visitor (public endpoint)', description: - 'Evaluates all feature flags for a project based on visitor attributes derived from the request. Does not require authentication.', + 'Evaluates enabled feature flags for a project based on visitor attributes derived from the request. Does not require authentication.', }) async evaluateFlags( @Body() evaluateDto: EvaluateFeatureFlagsDto, @@ -219,13 +219,6 @@ export class FeatureFlagController { if (ip) { await checkRateLimit(ip, 'feature-flag-evaluate-ip', 600, 60) } - await checkRateLimit( - evaluateDto.pid, - 'feature-flag-evaluate-project', - 5000, - 60, - ) - const botResult = await this.analyticsService.checkBot( evaluateDto.pid, userAgent, @@ -240,6 +233,13 @@ export class FeatureFlagController { return { flags: {} } } + await checkRateLimit( + evaluateDto.pid, + 'feature-flag-evaluate-project', + 5000, + 60, + ) + let project try { project = await this.projectService.getRedisProject(evaluateDto.pid) From 0b2f9797968087a489a8a33432b58a80ca0371b9 Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Tue, 5 May 2026 20:06:23 +0100 Subject: [PATCH 5/7] Swetrix CE experiments support --- README.md | 2 +- .../src/analytics/analytics.controller.ts | 83 + .../src/analytics/analytics.module.ts | 5 +- backend/apps/community/src/app.module.ts | 2 + .../apps/community/src/experiment/bayesian.ts | 145 ++ .../src/experiment/dto/experiment.dto.ts | 343 +++++ .../entity/experiment-variant.entity.ts | 19 + .../experiment/entity/experiment.entity.ts | 70 + .../src/experiment/experiment.controller.ts | 1344 +++++++++++++++++ .../src/experiment/experiment.module.ts | 22 + .../src/experiment/experiment.service.ts | 512 +++++++ .../src/feature-flag/dto/feature-flag.dto.ts | 3 + .../entity/feature-flag.entity.ts | 2 + .../community/src/feature-flag/evaluation.ts | 33 + .../feature-flag/feature-flag.controller.ts | 90 +- .../src/feature-flag/feature-flag.module.ts | 10 +- .../src/feature-flag/feature-flag.service.ts | 9 +- .../apps/community/src/goal/goal.module.ts | 4 +- .../clickhouse/initialise_selfhosted.js | 38 + .../selfhosted_2026_05_05_experiments.js | 57 + web/app/api/api.server.ts | 1 + web/app/pages/Project/View/ViewProject.tsx | 34 +- 22 files changed, 2802 insertions(+), 26 deletions(-) create mode 100644 backend/apps/community/src/experiment/bayesian.ts create mode 100644 backend/apps/community/src/experiment/dto/experiment.dto.ts create mode 100644 backend/apps/community/src/experiment/entity/experiment-variant.entity.ts create mode 100644 backend/apps/community/src/experiment/entity/experiment.entity.ts create mode 100644 backend/apps/community/src/experiment/experiment.controller.ts create mode 100644 backend/apps/community/src/experiment/experiment.module.ts create mode 100644 backend/apps/community/src/experiment/experiment.service.ts create mode 100644 backend/migrations/clickhouse/selfhosted_2026_05_05_experiments.js diff --git a/README.md b/README.md index 0a331bcfc..25de46710 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ We've been building Swetrix since 2021 with a goal to make web analytics simple - **Data portability**: export to CSV and access data via our [developer API](https://docs.swetrix.com/statistics-api). - **Alerts & notifications (Cloud)**: get notified on thresholds via Email, Slack, Telegram, Discord, generic outbound webhook or browser web push, with per-alert custom message templates. - **Feature flags**: manage feature rollouts and conduct safe releases. -- **Experiments (Cloud)**: run A/B tests and experiments to optimize your site. +- **Experiments**: run A/B tests and experiments to optimize your site. - **Revenue analytics (Cloud)**: track MRR, churn and other financial metrics. - **Ask AI (Cloud)**: chat with your data to uncover insights. - **Goals**: track specific conversion goals and objectives. diff --git a/backend/apps/community/src/analytics/analytics.controller.ts b/backend/apps/community/src/analytics/analytics.controller.ts index 1d5b37746..278038d98 100644 --- a/backend/apps/community/src/analytics/analytics.controller.ts +++ b/backend/apps/community/src/analytics/analytics.controller.ts @@ -82,6 +82,8 @@ import { LiveVisitorsDto } from './dto/live-visitors.dto' import { NoscriptDto } from './dto/noscript.dto' import { GetKeywordsDto } from './dto/get-keywords.dto' import { GSCService } from '../project/gsc.service' +import { ExperimentService } from '../experiment/experiment.service' +import { getExperimentVariant } from '../feature-flag/evaluation' dayjs.extend(utc) dayjs.extend(dayjsTimezone) @@ -192,6 +194,7 @@ export class AnalyticsController { private readonly analyticsService: AnalyticsService, private readonly logger: AppLoggerService, private readonly gscService: GSCService, + private readonly experimentService: ExperimentService, ) {} @Get() @@ -1168,6 +1171,13 @@ export class AnalyticsController { values: [transformed], clickhouse_settings: { async_insert: 1 }, }) + + void this.trackCustomEventExperimentExposures( + eventsDTO.pid, + eventsDTO.ev, + profileId, + transformed.created, + ) } catch (e) { this.logger.error(e) throw new InternalServerErrorException( @@ -1178,6 +1188,79 @@ export class AnalyticsController { return {} } + private async trackCustomEventExperimentExposures( + pid: string, + eventName: string, + profileId: string, + created: string, + ) { + try { + const experiments = + await this.experimentService.findRunningCustomEventExperiments( + pid, + eventName, + ) + + if (_isEmpty(experiments)) { + return + } + + const exposures = [] + for (const experiment of experiments) { + if (!experiment.variants || experiment.variants.length === 0) { + continue + } + + const sortedVariants = [...experiment.variants].sort((a, b) => + a.key.localeCompare(b.key), + ) + const variantKey = getExperimentVariant( + experiment.id, + sortedVariants.map((variant) => ({ + key: variant.key, + rolloutPercentage: variant.rolloutPercentage, + })), + profileId, + ) + + if (!variantKey) { + continue + } + + exposures.push({ + pid, + experimentId: experiment.id, + variantKey, + profileId, + created, + }) + } + + if (_isEmpty(exposures)) { + return + } + + clickhouse + .insert({ + table: 'experiment_exposures', + values: exposures, + format: 'JSONEachRow', + clickhouse_settings: { async_insert: 1 }, + }) + .catch((reason) => { + this.logger.warn( + { reason }, + 'Failed to async insert custom event experiment exposures', + ) + }) + } catch (reason) { + this.logger.warn( + { reason }, + 'Failed to track custom event experiment exposures', + ) + } + } + @Post('hb') @Auth(true, true) async heartbeat( diff --git a/backend/apps/community/src/analytics/analytics.module.ts b/backend/apps/community/src/analytics/analytics.module.ts index efe7c9564..29fab9058 100644 --- a/backend/apps/community/src/analytics/analytics.module.ts +++ b/backend/apps/community/src/analytics/analytics.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common' +import { Module, forwardRef } from '@nestjs/common' import { AnalyticsService } from './analytics.service' import { AnalyticsController } from './analytics.controller' @@ -7,9 +7,10 @@ import { HeartbeatGateway } from './heartbeat.gateway' import { SaltService } from './salt.service' import { AppLoggerModule } from '../logger/logger.module' import { ProjectModule } from '../project/project.module' +import { ExperimentModule } from '../experiment/experiment.module' @Module({ - imports: [AppLoggerModule, ProjectModule], + imports: [AppLoggerModule, ProjectModule, forwardRef(() => ExperimentModule)], providers: [ AnalyticsService, BotDetectionService, diff --git a/backend/apps/community/src/app.module.ts b/backend/apps/community/src/app.module.ts index 3df42a7b3..ffb83473a 100644 --- a/backend/apps/community/src/app.module.ts +++ b/backend/apps/community/src/app.module.ts @@ -11,6 +11,7 @@ import { TaskManagerModule } from './task-manager/task-manager.module' import { PingModule } from './ping/ping.module' import { GoalModule } from './goal/goal.module' import { FeatureFlagModule } from './feature-flag/feature-flag.module' +import { ExperimentModule } from './experiment/experiment.module' import { CaptchaModule } from './captcha/captcha.module' import { getI18nConfig } from './configs' import { AuthModule } from './auth/auth.module' @@ -77,6 +78,7 @@ const modules = [ AnalyticsModule, PingModule, GoalModule, + ExperimentModule, FeatureFlagModule, CaptchaModule, AuthModule, diff --git a/backend/apps/community/src/experiment/bayesian.ts b/backend/apps/community/src/experiment/bayesian.ts new file mode 100644 index 000000000..70e13ab8b --- /dev/null +++ b/backend/apps/community/src/experiment/bayesian.ts @@ -0,0 +1,145 @@ +interface VariantData { + key: string + exposures: number + conversions: number +} + +type RandomFn = () => number + +function mulberry32(seed: number): RandomFn { + let a = seed >>> 0 + return () => { + a |= 0 + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function fnv1a32(input: string): number { + let hash = 0x811c9dc5 + for (let i = 0; i < input.length; i++) { + hash ^= input.charCodeAt(i) + hash = Math.imul(hash, 0x01000193) + } + return hash >>> 0 +} + +function seedFromVariants( + variants: VariantData[], + simulations: number, +): number { + const normalized = [...variants] + .sort((a, b) => a.key.localeCompare(b.key)) + .map((v) => `${v.key}:${v.exposures}:${v.conversions}`) + .join('|') + return (fnv1a32(normalized) ^ (simulations >>> 0)) >>> 0 +} + +function sampleGamma(shape: number, random: RandomFn): number { + if (shape >= 1) { + const d = shape - 1 / 3 + const c = 1 / Math.sqrt(9 * d) + + while (true) { + let x: number + let v: number + + do { + const u1 = Math.max(random(), Number.EPSILON) + const u2 = random() + x = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2) + v = 1 + c * x + } while (v <= 0) + + v = v * v * v + const u = random() + + if (u < 1 - 0.0331 * x * x * x * x) { + return d * v + } + + if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) { + return d * v + } + } + } + + const sample = sampleGamma(shape + 1, random) + const u = random() + return sample * Math.pow(u, 1 / shape) +} + +function sampleBeta(alpha: number, beta: number, random: RandomFn): number { + const x = sampleGamma(alpha, random) + const y = sampleGamma(beta, random) + return x / (x + y) +} + +export function calculateBayesianProbabilities( + variants: VariantData[], + simulations: number = 10000, +): Map { + if (variants.length === 0) { + return new Map() + } + + simulations = Math.max(1, Math.floor(simulations)) + + const sanitizedVariants = variants.map((variant) => { + const exposures = Math.max(0, Math.floor(variant.exposures)) + const conversions = Math.min( + Math.max(0, Math.floor(variant.conversions)), + exposures, + ) + return { key: variant.key, exposures, conversions } + }) + + if (sanitizedVariants.length === 1) { + return new Map([[sanitizedVariants[0].key, 1]]) + } + + const totalExposures = sanitizedVariants.reduce( + (sum, variant) => sum + variant.exposures, + 0, + ) + if (totalExposures === 0) { + const probability = 1 / sanitizedVariants.length + return new Map( + sanitizedVariants.map((variant) => [variant.key, probability]), + ) + } + + const wins = new Map() + for (const variant of sanitizedVariants) { + wins.set(variant.key, 0) + } + + const random = mulberry32(seedFromVariants(sanitizedVariants, simulations)) + + for (let i = 0; i < simulations; i++) { + let bestRate = -1 + let bestKey = '' + + for (const variant of sanitizedVariants) { + const alpha = variant.conversions + 1 + const beta = Math.max(1, variant.exposures - variant.conversions + 1) + const rate = sampleBeta(alpha, beta, random) + + if (rate > bestRate) { + bestRate = rate + bestKey = variant.key + } + } + + wins.set(bestKey, (wins.get(bestKey) || 0) + 1) + } + + const probabilities = new Map() + for (const [key, winCount] of wins) { + probabilities.set(key, winCount / simulations) + } + + return probabilities +} diff --git a/backend/apps/community/src/experiment/dto/experiment.dto.ts b/backend/apps/community/src/experiment/dto/experiment.dto.ts new file mode 100644 index 000000000..4a8f7d422 --- /dev/null +++ b/backend/apps/community/src/experiment/dto/experiment.dto.ts @@ -0,0 +1,343 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger' +import { + IsString, + IsOptional, + IsEnum, + IsArray, + ValidateNested, + IsBoolean, + IsInt, + Min, + Max, + MaxLength, + IsUUID, + IsNotEmpty, + Matches, + ArrayMaxSize, +} from 'class-validator' +import { Type } from 'class-transformer' +import { + ExperimentStatus, + ExposureTrigger, + MultipleVariantHandling, + FeatureFlagMode, +} from '../entity/experiment.entity' +import { PID_REGEX } from '../../common/constants' + +const KEY_REGEX = /^[a-zA-Z0-9_-]+$/ + +class ExperimentVariantDto { + @ApiProperty() + @IsString() + @IsNotEmpty() + @MaxLength(100) + name: string + + @ApiProperty() + @IsString() + @IsNotEmpty() + @MaxLength(100) + @Matches(KEY_REGEX, { + message: + 'Variant key must contain only alphanumeric characters, underscores, and hyphens', + }) + key: string + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(300) + description?: string + + @ApiProperty() + @IsInt() + @Min(0) + @Max(100) + rolloutPercentage: number + + @ApiProperty() + @IsBoolean() + isControl: boolean +} + +export class CreateExperimentDto { + @ApiProperty() + @IsNotEmpty() + @Matches(PID_REGEX, { message: 'The provided Project ID (pid) is incorrect' }) + pid: string + + @ApiProperty() + @IsString() + @IsNotEmpty() + @MaxLength(100) + name: string + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(500) + description?: string + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(500) + hypothesis?: string + + @ApiPropertyOptional({ enum: ExposureTrigger }) + @IsOptional() + @IsEnum(ExposureTrigger) + exposureTrigger?: ExposureTrigger + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(200) + customEventName?: string + + @ApiPropertyOptional({ enum: MultipleVariantHandling }) + @IsOptional() + @IsEnum(MultipleVariantHandling) + multipleVariantHandling?: MultipleVariantHandling + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + filterInternalUsers?: boolean + + @ApiPropertyOptional({ enum: FeatureFlagMode }) + @IsOptional() + @IsEnum(FeatureFlagMode) + featureFlagMode?: FeatureFlagMode + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(100) + @Matches(KEY_REGEX, { + message: + 'Feature flag key must contain only alphanumeric characters, underscores, and hyphens', + }) + featureFlagKey?: string + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + existingFeatureFlagId?: string + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + goalId?: string + + @ApiProperty({ type: [ExperimentVariantDto] }) + @IsArray() + @ArrayMaxSize(20, { + message: 'An experiment cannot have more than 20 variants', + }) + @ValidateNested({ each: true }) + @Type(() => ExperimentVariantDto) + variants: ExperimentVariantDto[] +} + +export class UpdateExperimentDto { + @ApiPropertyOptional() + @IsOptional() + @IsString() + @IsNotEmpty() + @MaxLength(100) + name?: string + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(500) + description?: string + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(500) + hypothesis?: string + + @ApiPropertyOptional({ enum: ExposureTrigger }) + @IsOptional() + @IsEnum(ExposureTrigger) + exposureTrigger?: ExposureTrigger + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(200) + customEventName?: string + + @ApiPropertyOptional({ enum: MultipleVariantHandling }) + @IsOptional() + @IsEnum(MultipleVariantHandling) + multipleVariantHandling?: MultipleVariantHandling + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + filterInternalUsers?: boolean + + @ApiPropertyOptional({ enum: FeatureFlagMode }) + @IsOptional() + @IsEnum(FeatureFlagMode) + featureFlagMode?: FeatureFlagMode + + @ApiPropertyOptional() + @IsOptional() + @IsString() + @MaxLength(100) + @Matches(KEY_REGEX, { + message: + 'Feature flag key must contain only alphanumeric characters, underscores, and hyphens', + }) + featureFlagKey?: string + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + existingFeatureFlagId?: string + + @ApiPropertyOptional() + @IsOptional() + @IsUUID() + goalId?: string + + @ApiPropertyOptional({ type: [ExperimentVariantDto] }) + @IsOptional() + @IsArray() + @ArrayMaxSize(20, { + message: 'An experiment cannot have more than 20 variants', + }) + @ValidateNested({ each: true }) + @Type(() => ExperimentVariantDto) + variants?: ExperimentVariantDto[] +} + +export class ExperimentDto { + @ApiProperty() + id: string + + @ApiProperty() + name: string + + @ApiProperty() + description: string | null + + @ApiProperty() + hypothesis: string | null + + @ApiProperty({ enum: ExperimentStatus }) + status: ExperimentStatus + + @ApiProperty({ enum: ExposureTrigger }) + exposureTrigger: ExposureTrigger + + @ApiProperty() + customEventName: string | null + + @ApiProperty({ enum: MultipleVariantHandling }) + multipleVariantHandling: MultipleVariantHandling + + @ApiProperty() + filterInternalUsers: boolean + + @ApiProperty({ enum: FeatureFlagMode }) + featureFlagMode: FeatureFlagMode + + @ApiProperty() + featureFlagKey: string | null + + @ApiProperty() + startedAt: Date | null + + @ApiProperty() + endedAt: Date | null + + @ApiProperty() + pid: string + + @ApiProperty() + goalId: string | null + + @ApiProperty() + featureFlagId: string | null + + @ApiProperty({ type: [ExperimentVariantDto] }) + variants: ExperimentVariantDto[] + + @ApiProperty() + created: Date +} + +export class VariantResultDto { + @ApiProperty() + key: string + + @ApiProperty() + name: string + + @ApiProperty() + isControl: boolean + + @ApiProperty() + exposures: number + + @ApiProperty() + conversions: number + + @ApiProperty() + conversionRate: number + + @ApiProperty() + probabilityOfBeingBest: number + + @ApiProperty() + improvement: number +} + +class ExperimentChartDataDto { + @ApiProperty({ type: [String] }) + x: string[] + + @ApiProperty({ + type: 'object', + additionalProperties: { type: 'array', items: { type: 'number' } }, + }) + winProbability: Record +} + +export class ExperimentResultsDto { + @ApiProperty() + experimentId: string + + @ApiProperty() + status: ExperimentStatus + + @ApiProperty({ type: [VariantResultDto] }) + variants: VariantResultDto[] + + @ApiProperty() + totalExposures: number + + @ApiProperty() + totalConversions: number + + @ApiProperty() + hasWinner: boolean + + @ApiProperty() + winnerKey: string | null + + @ApiProperty() + confidenceLevel: number + + @ApiPropertyOptional({ type: ExperimentChartDataDto }) + chart?: ExperimentChartDataDto + + @ApiPropertyOptional({ type: [String] }) + timeBucket?: string[] +} diff --git a/backend/apps/community/src/experiment/entity/experiment-variant.entity.ts b/backend/apps/community/src/experiment/entity/experiment-variant.entity.ts new file mode 100644 index 000000000..639d5fec5 --- /dev/null +++ b/backend/apps/community/src/experiment/entity/experiment-variant.entity.ts @@ -0,0 +1,19 @@ +export interface ExperimentVariant { + id: string + experimentId: string + name: string + key: string + description: string | null + rolloutPercentage: number + isControl: boolean +} + +export interface ClickhouseExperimentVariant { + id: string + experimentId: string + name: string + key: string + description: string | null + rolloutPercentage: number + isControl: number +} diff --git a/backend/apps/community/src/experiment/entity/experiment.entity.ts b/backend/apps/community/src/experiment/entity/experiment.entity.ts new file mode 100644 index 000000000..0dbbad681 --- /dev/null +++ b/backend/apps/community/src/experiment/entity/experiment.entity.ts @@ -0,0 +1,70 @@ +import { Project } from '../../project/entity/project.entity' +import { FeatureFlag } from '../../feature-flag/entity/feature-flag.entity' +import { Goal } from '../../goal/entity/goal.entity' +import { ExperimentVariant } from './experiment-variant.entity' + +export enum ExperimentStatus { + DRAFT = 'draft', + RUNNING = 'running', + PAUSED = 'paused', + COMPLETED = 'completed', +} + +export enum ExposureTrigger { + FEATURE_FLAG = 'feature_flag', + CUSTOM_EVENT = 'custom_event', +} + +export enum MultipleVariantHandling { + EXCLUDE = 'exclude', + FIRST_EXPOSURE = 'first_exposure', +} + +export enum FeatureFlagMode { + CREATE = 'create', + LINK = 'link', +} + +export interface Experiment { + id: string + name: string + description: string | null + hypothesis: string | null + status: ExperimentStatus + exposureTrigger: ExposureTrigger + customEventName: string | null + multipleVariantHandling: MultipleVariantHandling + filterInternalUsers: boolean + featureFlagMode: FeatureFlagMode + featureFlagKey: string | null + startedAt: string | null + endedAt: string | null + projectId: string + goalId: string | null + featureFlagId: string | null + variants: ExperimentVariant[] + project?: Project + goal?: Goal | null + featureFlag?: FeatureFlag | null + created: string +} + +export interface ClickhouseExperiment { + id: string + name: string + description: string | null + hypothesis: string | null + status: string + exposureTrigger: string + customEventName: string | null + multipleVariantHandling: string + filterInternalUsers: number + featureFlagMode: string + featureFlagKey: string | null + startedAt: string | null + endedAt: string | null + projectId: string + goalId: string | null + featureFlagId: string | null + created: string +} diff --git a/backend/apps/community/src/experiment/experiment.controller.ts b/backend/apps/community/src/experiment/experiment.controller.ts new file mode 100644 index 000000000..60e8718b5 --- /dev/null +++ b/backend/apps/community/src/experiment/experiment.controller.ts @@ -0,0 +1,1344 @@ +import { + Controller, + Get, + Put, + Delete, + Query, + Param, + Body, + NotFoundException, + Post, + BadRequestException, + ParseIntPipe, + HttpCode, + Headers, + Ip, +} from '@nestjs/common' +import { + ApiTags, + ApiResponse, + ApiBearerAuth, + ApiOperation, +} from '@nestjs/swagger' +import _isEmpty from 'lodash/isEmpty' +import _map from 'lodash/map' +import _pick from 'lodash/pick' +import _round from 'lodash/round' +import _sum from 'lodash/sum' + +import { ProjectService } from '../project/project.service' +import { AppLoggerService } from '../logger/logger.service' +import { + AnalyticsService, + getLowestPossibleTimeBucket, +} from '../analytics/analytics.service' +import { TimeBucketType } from '../analytics/dto/getData.dto' +import { Auth } from '../auth/decorators' +import { CurrentUserId } from '../auth/decorators/current-user-id.decorator' +import { checkRateLimit, getIPFromHeaders } from '../common/utils' +import { + Experiment, + ExperimentStatus, + ExposureTrigger, + MultipleVariantHandling, + FeatureFlagMode, +} from './entity/experiment.entity' +import { ExperimentVariant } from './entity/experiment-variant.entity' +import { + CreateExperimentDto, + UpdateExperimentDto, + ExperimentDto, + ExperimentResultsDto, + VariantResultDto, +} from './dto/experiment.dto' +import { ExperimentService } from './experiment.service' +import { GoalService } from '../goal/goal.service' +import { Goal, GoalMatchType, GoalType } from '../goal/entity/goal.entity' +import { FeatureFlagService } from '../feature-flag/feature-flag.service' +import { FeatureFlagType } from '../feature-flag/entity/feature-flag.entity' +import { clickhouse } from '../common/integrations/clickhouse' +import { calculateBayesianProbabilities } from './bayesian' +import { Pagination } from '../common/pagination/pagination' + +const EXPERIMENTS_MAXIMUM = 20 +const FEATURE_FLAG_KEY_REGEX = /^[a-zA-Z0-9_-]+$/ + +const validateUniqueVariantKeys = (variants: Array<{ key: string }>): void => { + const seen = new Set() + + for (const variant of variants) { + if (seen.has(variant.key)) { + throw new BadRequestException('Variant keys must be unique') + } + seen.add(variant.key) + } +} + +type GoalEventConditions = { + eventType: 'pageview' | 'custom_event' + matchColumn: 'pg' | 'event_name' + matchCondition: string + metaCondition: string + metaParams: Record + goalValue: string +} + +@ApiTags('Experiment') +@Controller(['experiment', 'v1/experiment']) +export class ExperimentController { + constructor( + private readonly experimentService: ExperimentService, + private readonly projectService: ProjectService, + private readonly logger: AppLoggerService, + private readonly analyticsService: AnalyticsService, + private readonly goalService: GoalService, + private readonly featureFlagService: FeatureFlagService, + ) {} + + @ApiBearerAuth() + @Get('/project/:projectId') + @Auth(false, true) + @ApiResponse({ status: 200, type: [ExperimentDto] }) + @ApiOperation({ summary: 'Get all experiments for a project' }) + async getProjectExperiments( + @CurrentUserId() userId: string, + @Param('projectId') projectId: string, + @Query('take', new ParseIntPipe({ optional: true })) take?: number, + @Query('skip', new ParseIntPipe({ optional: true })) skip?: number, + @Query('search') search?: string, + ) { + this.logger.log( + { userId, projectId, take, skip, search }, + 'GET /experiment/project/:projectId', + ) + + const project = await this.projectService.getFullProject(projectId) + + if (_isEmpty(project)) { + throw new NotFoundException('Project not found') + } + + this.projectService.allowedToView(project, userId) + + const result = await this.experimentService.paginate( + { take, skip }, + projectId, + search, + ) + + return new Pagination({ + results: _map(result.results, (experiment) => this.toDto(experiment)), + total: result.total, + }) + } + + @ApiBearerAuth() + @Get('/:id') + @Auth(false, true) + @ApiResponse({ status: 200, type: ExperimentDto }) + @ApiOperation({ summary: 'Get an experiment by ID' }) + async getExperiment( + @CurrentUserId() userId: string, + @Param('id') id: string, + ) { + this.logger.log({ userId, id }, 'GET /experiment/:id') + + const experiment = await this.experimentService.findOneWithRelations(id) + + if (_isEmpty(experiment)) { + throw new NotFoundException('Experiment not found') + } + + const project = await this.projectService.getFullProject( + experiment.projectId, + ) + this.projectService.allowedToView(project, userId) + + return this.toDto(experiment) + } + + @ApiBearerAuth() + @Post('/') + @Auth() + @ApiResponse({ status: 201, type: ExperimentDto }) + @ApiOperation({ summary: 'Create a new experiment' }) + async createExperiment( + @Body() experimentDto: CreateExperimentDto, + @CurrentUserId() uid: string, + @Headers() headers: Record, + @Ip() reqIP: string, + ) { + this.logger.log({ uid, pid: experimentDto.pid }, 'POST /experiment') + + const ip = getIPFromHeaders(headers) || reqIP + await checkRateLimit(ip, 'experiment-create', 20, 3600) + await checkRateLimit(uid, 'experiment-create', 20, 3600) + + const project = await this.projectService.getFullProject(experimentDto.pid) + + if (_isEmpty(project)) { + throw new NotFoundException('Project not found') + } + + this.projectService.allowedToManage( + project, + uid, + 'You are not allowed to add experiments to this project', + ) + + const experimentsCount = await this.experimentService.count( + experimentDto.pid, + ) + + if (experimentsCount >= EXPERIMENTS_MAXIMUM) { + throw new BadRequestException( + `You cannot create more than ${EXPERIMENTS_MAXIMUM} experiments per project.`, + ) + } + + this.validateVariants(experimentDto.variants) + + const goalId = await this.validateGoal( + experimentDto.goalId, + experimentDto.pid, + ) + this.validateCustomEventTrigger( + experimentDto.exposureTrigger, + experimentDto.customEventName, + ) + + let featureFlagId: string | null = null + if (experimentDto.featureFlagMode === FeatureFlagMode.LINK) { + const featureFlag = await this.validateLinkedFeatureFlag( + experimentDto.existingFeatureFlagId, + experimentDto.pid, + ) + featureFlagId = featureFlag.id + } + + try { + const newExperiment = await this.experimentService.create({ + name: experimentDto.name, + description: experimentDto.description || null, + hypothesis: experimentDto.hypothesis || null, + status: ExperimentStatus.DRAFT, + projectId: experimentDto.pid, + goalId, + exposureTrigger: + experimentDto.exposureTrigger || ExposureTrigger.FEATURE_FLAG, + customEventName: experimentDto.customEventName || null, + multipleVariantHandling: + experimentDto.multipleVariantHandling || + MultipleVariantHandling.EXCLUDE, + filterInternalUsers: experimentDto.filterInternalUsers !== false, + featureFlagMode: + experimentDto.featureFlagMode || FeatureFlagMode.CREATE, + featureFlagKey: experimentDto.featureFlagKey || null, + featureFlagId, + variants: experimentDto.variants as ExperimentVariant[], + }) + + if (featureFlagId) { + await this.featureFlagService.update(featureFlagId, { + experimentId: newExperiment.id, + }) + } + + return this.toDto(newExperiment) + } catch (reason) { + this.logger.error({ reason }, 'Error while creating experiment') + throw new BadRequestException('Error occurred while creating experiment') + } + } + + @ApiBearerAuth() + @Put('/:id') + @Auth() + @ApiResponse({ status: 200, type: ExperimentDto }) + @ApiOperation({ summary: 'Update an experiment' }) + async updateExperiment( + @Param('id') id: string, + @Body() experimentDto: UpdateExperimentDto, + @CurrentUserId() uid: string, + @Headers() headers: Record, + @Ip() reqIP: string, + ) { + this.logger.log({ id, uid }, 'PUT /experiment/:id') + + const ip = getIPFromHeaders(headers) || reqIP + await checkRateLimit(ip, 'experiment-update', 30, 3600) + await checkRateLimit(uid, 'experiment-update', 30, 3600) + + const experiment = await this.experimentService.findOneWithRelations(id) + + if (_isEmpty(experiment)) { + throw new NotFoundException() + } + + const project = await this.projectService.getFullProject( + experiment.projectId, + ) + this.projectService.allowedToManage( + project, + uid, + 'You are not allowed to manage this experiment', + ) + + if (experiment.status === ExperimentStatus.RUNNING) { + throw new BadRequestException( + 'Cannot update a running experiment. Pause it first.', + ) + } + + if (experiment.status === ExperimentStatus.COMPLETED) { + throw new BadRequestException('Cannot update a completed experiment') + } + + const updatePayload: Partial = { + ..._pick(experimentDto, [ + 'name', + 'description', + 'hypothesis', + 'exposureTrigger', + 'customEventName', + 'multipleVariantHandling', + 'filterInternalUsers', + 'featureFlagMode', + 'featureFlagKey', + ]), + } + + if (experimentDto.goalId !== undefined) { + updatePayload.goalId = await this.validateGoal( + experimentDto.goalId, + experiment.projectId, + ) + } + + const exposureTrigger = + experimentDto.exposureTrigger ?? experiment.exposureTrigger + const customEventName = + experimentDto.customEventName ?? experiment.customEventName + this.validateCustomEventTrigger(exposureTrigger, customEventName) + + if (experimentDto.variants) { + this.validateVariants(experimentDto.variants) + } + + updatePayload.featureFlagId = await this.resolveFeatureFlagForUpdate( + experiment, + experimentDto, + ) + + const updatedExperiment = await this.experimentService.update( + id, + updatePayload, + ) + + if (!updatedExperiment) { + throw new NotFoundException('Experiment not found after update') + } + + if (experimentDto.variants) { + updatedExperiment.variants = + await this.experimentService.recreateVariants( + id, + experimentDto.variants as ExperimentVariant[], + ) + } + + return this.toDto(updatedExperiment) + } + + @ApiBearerAuth() + @Delete('/:id') + @Auth() + @ApiResponse({ status: 204, description: 'Empty body' }) + @ApiOperation({ summary: 'Delete an experiment' }) + @HttpCode(204) + async deleteExperiment( + @Param('id') id: string, + @CurrentUserId() uid: string, + @Headers() headers: Record, + @Ip() reqIP: string, + ) { + this.logger.log({ id, uid }, 'DELETE /experiment/:id') + + const ip = getIPFromHeaders(headers) || reqIP + await checkRateLimit(ip, 'experiment-delete', 20, 3600) + await checkRateLimit(uid, 'experiment-delete', 20, 3600) + + const experiment = await this.experimentService.findOneWithRelations(id) + + if (_isEmpty(experiment)) { + throw new NotFoundException() + } + + const project = await this.projectService.getFullProject( + experiment.projectId, + ) + this.projectService.allowedToManage( + project, + uid, + 'You are not allowed to manage this experiment', + ) + + if (experiment.featureFlagId) { + if (experiment.featureFlagMode === FeatureFlagMode.CREATE) { + await this.featureFlagService.delete(experiment.featureFlagId) + } else { + await this.featureFlagService.update(experiment.featureFlagId, { + experimentId: null, + }) + } + } + + await this.experimentService.delete(id) + } + + @ApiBearerAuth() + @Post('/:id/start') + @Auth() + @ApiResponse({ status: 200, type: ExperimentDto }) + @ApiOperation({ summary: 'Start an experiment' }) + async startExperiment( + @Param('id') id: string, + @CurrentUserId() uid: string, + @Headers() headers: Record, + @Ip() reqIP: string, + ) { + this.logger.log({ id, uid }, 'POST /experiment/:id/start') + + const ip = getIPFromHeaders(headers) || reqIP + await checkRateLimit(ip, 'experiment-lifecycle', 30, 3600) + await checkRateLimit(uid, 'experiment-lifecycle', 30, 3600) + + const experiment = await this.getManageableExperiment(id, uid) + + if (experiment.status === ExperimentStatus.RUNNING) { + throw new BadRequestException('Experiment is already running') + } + + if (experiment.status === ExperimentStatus.COMPLETED) { + throw new BadRequestException('Cannot restart a completed experiment') + } + + if (!experiment.goalId) { + throw new BadRequestException( + 'Experiment must have a goal before starting', + ) + } + + const goal = await this.goalService.findOne(experiment.goalId) + if (!goal || goal.projectId !== experiment.projectId) { + throw new BadRequestException( + 'Experiment must have a goal before starting', + ) + } + + let featureFlagId = experiment.featureFlagId + if (!featureFlagId) { + if (experiment.featureFlagMode !== FeatureFlagMode.CREATE) { + throw new BadRequestException( + 'No feature flag linked to this experiment. Please link a feature flag first.', + ) + } + + const flagKey = + experiment.featureFlagKey?.trim() || + `experiment_${experiment.id.replace(/-/g, '_').substring(0, 20)}` + + if (!FEATURE_FLAG_KEY_REGEX.test(flagKey)) { + throw new BadRequestException( + 'Feature flag key must contain only alphanumeric characters, underscores, and hyphens', + ) + } + + const existingFlag = await this.featureFlagService.findByKey( + experiment.projectId, + flagKey, + ) + if (existingFlag) { + throw new BadRequestException( + `Feature flag with key "${flagKey}" already exists`, + ) + } + + const featureFlag = await this.featureFlagService.create({ + key: flagKey, + description: `Feature flag for experiment: ${experiment.name}`, + flagType: FeatureFlagType.ROLLOUT, + rolloutPercentage: 100, + enabled: true, + experimentId: experiment.id, + projectId: experiment.projectId, + }) + featureFlagId = featureFlag.id + } else { + await this.featureFlagService.update(featureFlagId, { + enabled: true, + experimentId: experiment.id, + }) + } + + const updatedExperiment = await this.experimentService.update(id, { + status: ExperimentStatus.RUNNING, + startedAt: dayString(), + featureFlagId, + }) + + return this.toDto(updatedExperiment) + } + + @ApiBearerAuth() + @Post('/:id/pause') + @Auth() + @ApiResponse({ status: 200, type: ExperimentDto }) + @ApiOperation({ summary: 'Pause an experiment' }) + async pauseExperiment( + @Param('id') id: string, + @CurrentUserId() uid: string, + @Headers() headers: Record, + @Ip() reqIP: string, + ) { + this.logger.log({ id, uid }, 'POST /experiment/:id/pause') + + const ip = getIPFromHeaders(headers) || reqIP + await checkRateLimit(ip, 'experiment-lifecycle', 30, 3600) + await checkRateLimit(uid, 'experiment-lifecycle', 30, 3600) + + const experiment = await this.getManageableExperiment(id, uid) + + if (experiment.status !== ExperimentStatus.RUNNING) { + throw new BadRequestException('Can only pause a running experiment') + } + + if (experiment.featureFlagId) { + await this.featureFlagService.update(experiment.featureFlagId, { + enabled: false, + }) + } + + const updatedExperiment = await this.experimentService.update(id, { + status: ExperimentStatus.PAUSED, + }) + + return this.toDto(updatedExperiment) + } + + @ApiBearerAuth() + @Post('/:id/complete') + @Auth() + @ApiResponse({ status: 200, type: ExperimentDto }) + @ApiOperation({ summary: 'Complete an experiment' }) + async completeExperiment( + @Param('id') id: string, + @CurrentUserId() uid: string, + @Headers() headers: Record, + @Ip() reqIP: string, + ) { + this.logger.log({ id, uid }, 'POST /experiment/:id/complete') + + const ip = getIPFromHeaders(headers) || reqIP + await checkRateLimit(ip, 'experiment-lifecycle', 30, 3600) + await checkRateLimit(uid, 'experiment-lifecycle', 30, 3600) + + const experiment = await this.getManageableExperiment(id, uid) + + if ( + experiment.status !== ExperimentStatus.RUNNING && + experiment.status !== ExperimentStatus.PAUSED + ) { + throw new BadRequestException( + 'Can only complete a running or paused experiment', + ) + } + + if (experiment.featureFlagId) { + await this.featureFlagService.update(experiment.featureFlagId, { + enabled: false, + }) + } + + const updatedExperiment = await this.experimentService.update(id, { + status: ExperimentStatus.COMPLETED, + endedAt: dayString(), + }) + + return this.toDto(updatedExperiment) + } + + @ApiBearerAuth() + @Get('/:id/results') + @Auth(false, true) + @ApiResponse({ status: 200, type: ExperimentResultsDto }) + @ApiOperation({ summary: 'Get experiment results with Bayesian statistics' }) + async getExperimentResults( + @CurrentUserId() userId: string, + @Param('id') id: string, + @Query('period') period: string, + @Query('timeBucket') timeBucketParam?: TimeBucketType, + @Query('from') from?: string, + @Query('to') to?: string, + @Query('timezone') timezone?: string, + ) { + this.logger.log( + { userId, id, period, timeBucket: timeBucketParam, from, to }, + 'GET /experiment/:id/results', + ) + + let experiment = await this.experimentService.findOneWithRelations(id) + + if (_isEmpty(experiment)) { + throw new NotFoundException('Experiment not found') + } + + const project = await this.projectService.getFullProject( + experiment.projectId, + ) + this.projectService.allowedToView(project, userId) + + experiment = await this.withGoal(experiment) + + const safeTimezone = this.analyticsService.getSafeTimezone(timezone) + + let timeBucket = + timeBucketParam || getLowestPossibleTimeBucket(period, from, to) + let allowedTimeBucketForPeriodAll: TimeBucketType[] | undefined + let diff: number | undefined + const goalConditions = experiment.goal + ? this.buildGoalEventConditions(experiment.goal) + : null + + if (period === 'all') { + const res = await this.analyticsService.calculateTimeBucketForAllTime( + experiment.projectId, + goalConditions?.eventType || 'pageview', + ) + + diff = res.diff + timeBucket = res.timeBucket.includes(timeBucket) + ? timeBucket + : res.timeBucket[0] + allowedTimeBucketForPeriodAll = res.timeBucket + } + + const { groupFrom, groupTo, groupFromUTC, groupToUTC } = + this.analyticsService.getGroupFromTo( + from, + to, + timeBucket, + period, + safeTimezone, + diff, + ) + + const exposureAttributionSubquery = + this.getExposureAttributionSubquery(experiment) + + const exposuresQuery = ` + SELECT + variantKey, + count() as exposures + FROM ( + ${exposureAttributionSubquery} + ) + GROUP BY variantKey + ` + + const exposuresParams = { + pid: experiment.projectId, + experimentId: experiment.id, + groupFrom: groupFromUTC, + groupTo: groupToUTC, + } + + let exposuresData: { variantKey: string; exposures: number }[] = [] + try { + const result = await clickhouse + .query({ query: exposuresQuery, query_params: exposuresParams }) + .then((resultSet) => + resultSet.json<{ variantKey: string; exposures: number }>(), + ) + exposuresData = result.data + } catch (err) { + this.logger.warn({ err }, 'Failed to get experiment exposures') + } + + let conversionsData: { variantKey: string; conversions: number }[] = [] + if (goalConditions) { + const { + eventType, + matchCondition, + metaCondition, + metaParams, + goalValue, + } = goalConditions + + const conversionsQuery = ` + SELECT + e.variantKey, + uniqExact(e.profileId) as conversions + FROM ( + ${exposureAttributionSubquery} + ) e + INNER JOIN events c ON e.pid = c.pid AND e.profileId = assumeNotNull(c.profileId) AND c.type = '${eventType}' + WHERE + e.pid = {pid:FixedString(12)} + AND c.created BETWEEN {groupFrom:String} AND {groupTo:String} + AND c.created >= e.exposureCreated + AND ${matchCondition} + ${metaCondition} + GROUP BY e.variantKey + ` + + const conversionsParams = { + pid: experiment.projectId, + experimentId: experiment.id, + groupFrom: groupFromUTC, + groupTo: groupToUTC, + goalValue, + ...metaParams, + } + + try { + const result = await clickhouse + .query({ query: conversionsQuery, query_params: conversionsParams }) + .then((resultSet) => + resultSet.json<{ variantKey: string; conversions: number }>(), + ) + conversionsData = result.data + } catch (err) { + this.logger.warn({ err }, 'Failed to get experiment conversions') + } + } + + const exposuresMap = new Map( + exposuresData.map((exposure) => [ + exposure.variantKey, + Number(exposure.exposures), + ]), + ) + const conversionsMap = new Map( + conversionsData.map((conversion) => [ + conversion.variantKey, + Number(conversion.conversions), + ]), + ) + + const controlVariant = experiment.variants.find( + (variant) => variant.isControl, + ) + const controlExposures = controlVariant + ? exposuresMap.get(controlVariant.key) || 0 + : 0 + const controlConversions = controlVariant + ? conversionsMap.get(controlVariant.key) || 0 + : 0 + const controlRate = + controlExposures > 0 ? controlConversions / controlExposures : 0 + + const variantData = experiment.variants.map((variant) => ({ + key: variant.key, + exposures: exposuresMap.get(variant.key) || 0, + conversions: conversionsMap.get(variant.key) || 0, + })) + + const probabilities = calculateBayesianProbabilities(variantData) + + const variantResults: VariantResultDto[] = experiment.variants.map( + (variant) => { + const exposures = exposuresMap.get(variant.key) || 0 + const conversions = conversionsMap.get(variant.key) || 0 + const conversionRate = exposures > 0 ? conversions / exposures : 0 + const improvement = variant.isControl + ? 0 + : controlRate > 0 + ? ((conversionRate - controlRate) / controlRate) * 100 + : conversionRate > 0 + ? 100 + : 0 + + return { + key: variant.key, + name: variant.name, + isControl: variant.isControl, + exposures, + conversions, + conversionRate: _round(conversionRate * 100, 2), + probabilityOfBeingBest: _round( + (probabilities.get(variant.key) || 0) * 100, + 2, + ), + improvement: _round(improvement, 2), + } + }, + ) + + const totalExposures = _sum( + variantResults.map((variant) => variant.exposures), + ) + const totalConversions = _sum( + variantResults.map((variant) => variant.conversions), + ) + + let highestProbVariant: VariantResultDto | null = null + let hasWinner = false + + if (variantResults.length > 0) { + highestProbVariant = variantResults.reduce((a, b) => + a.probabilityOfBeingBest > b.probabilityOfBeingBest ? a : b, + ) + hasWinner = highestProbVariant.probabilityOfBeingBest >= 95 + } + + let chart: + | { x: string[]; winProbability: Record } + | undefined + + try { + chart = await this.generateExperimentChart( + experiment, + timeBucket, + groupFrom, + groupTo, + groupFromUTC, + groupToUTC, + safeTimezone, + ) + } catch (err) { + this.logger.warn({ err }, 'Failed to generate experiment chart data') + } + + return { + experimentId: experiment.id, + status: experiment.status, + variants: variantResults, + totalExposures, + totalConversions, + hasWinner, + winnerKey: + hasWinner && highestProbVariant ? highestProbVariant.key : null, + confidenceLevel: 95, + chart, + timeBucket: allowedTimeBucketForPeriodAll, + } + } + + private toDto(experiment: Experiment): ExperimentDto { + const dto = { ...experiment } as Record + delete dto.projectId + delete dto.project + delete dto.goal + delete dto.featureFlag + + return { + ...dto, + pid: experiment.projectId, + goalId: experiment.goalId || null, + featureFlagId: experiment.featureFlagId || null, + } as unknown as ExperimentDto + } + + private validateVariants( + variants: Array<{ + key: string + isControl: boolean + rolloutPercentage: number + }>, + ) { + if (!variants || variants.length < 2) { + throw new BadRequestException( + 'An experiment must have at least 2 variants', + ) + } + + validateUniqueVariantKeys(variants) + + const controlVariants = variants.filter((variant) => variant.isControl) + if (controlVariants.length !== 1) { + throw new BadRequestException( + 'An experiment must have exactly one control variant', + ) + } + + const totalPercentage = _sum( + variants.map((variant) => variant.rolloutPercentage), + ) + if (totalPercentage !== 100) { + throw new BadRequestException( + 'Variant rollout percentages must sum to 100', + ) + } + } + + private async validateGoal( + goalId: string | undefined, + projectId: string, + ): Promise { + if (!goalId) { + return null + } + + const goal = await this.goalService.findOne(goalId) + if (!goal || goal.projectId !== projectId) { + throw new NotFoundException('Goal not found') + } + + return goal.id + } + + private validateCustomEventTrigger( + exposureTrigger: ExposureTrigger | undefined, + customEventName: string | null | undefined, + ) { + if ( + exposureTrigger === ExposureTrigger.CUSTOM_EVENT && + !customEventName?.trim() + ) { + throw new BadRequestException( + 'Custom event name is required when using custom event exposure trigger', + ) + } + } + + private async validateLinkedFeatureFlag( + featureFlagId: string | undefined, + projectId: string, + experimentId?: string, + ) { + if (!featureFlagId) { + throw new BadRequestException( + 'Feature flag ID is required when linking an existing flag', + ) + } + + const featureFlag = await this.featureFlagService.findOne(featureFlagId) + if (!featureFlag || featureFlag.projectId !== projectId) { + throw new NotFoundException('Feature flag not found') + } + + if (featureFlag.experimentId && featureFlag.experimentId !== experimentId) { + throw new BadRequestException( + 'This feature flag is already linked to another experiment', + ) + } + + return featureFlag + } + + private async resolveFeatureFlagForUpdate( + experiment: Experiment, + experimentDto: UpdateExperimentDto, + ): Promise { + const targetFeatureFlagMode = + experimentDto.featureFlagMode ?? experiment.featureFlagMode + const currentFeatureFlag = experiment.featureFlagId + ? await this.featureFlagService.findOne(experiment.featureFlagId) + : null + + if (targetFeatureFlagMode === FeatureFlagMode.LINK) { + const targetFlagId = + experimentDto.existingFeatureFlagId ?? + (experiment.featureFlagMode === FeatureFlagMode.LINK + ? experiment.featureFlagId + : undefined) + const existingFlag = await this.validateLinkedFeatureFlag( + targetFlagId, + experiment.projectId, + experiment.id, + ) + + if (currentFeatureFlag && currentFeatureFlag.id !== existingFlag.id) { + if (experiment.featureFlagMode === FeatureFlagMode.CREATE) { + await this.featureFlagService.delete(currentFeatureFlag.id) + } else { + await this.featureFlagService.update(currentFeatureFlag.id, { + experimentId: null, + }) + } + } + + if (existingFlag.experimentId !== experiment.id) { + await this.featureFlagService.update(existingFlag.id, { + experimentId: experiment.id, + }) + } + + return existingFlag.id + } + + if ( + targetFeatureFlagMode === FeatureFlagMode.CREATE && + currentFeatureFlag && + experiment.featureFlagMode === FeatureFlagMode.LINK + ) { + await this.featureFlagService.update(currentFeatureFlag.id, { + experimentId: null, + }) + return null + } + + return experiment.featureFlagId + } + + private async getManageableExperiment( + id: string, + uid: string, + ): Promise { + const experiment = await this.experimentService.findOneWithRelations(id) + + if (_isEmpty(experiment)) { + throw new NotFoundException() + } + + const project = await this.projectService.getFullProject( + experiment.projectId, + ) + this.projectService.allowedToManage( + project, + uid, + 'You are not allowed to manage this experiment', + ) + + return experiment + } + + private async withGoal(experiment: Experiment): Promise { + if (!experiment.goalId) { + return { ...experiment, goal: null } + } + + const goal = await this.goalService.findOne(experiment.goalId) + return { + ...experiment, + goal: goal && goal.projectId === experiment.projectId ? goal : null, + } + } + + private buildGoalEventConditions(goal: Goal): GoalEventConditions { + const eventType = + goal.type === GoalType.CUSTOM_EVENT ? 'custom_event' : 'pageview' + const matchColumn = + goal.type === GoalType.CUSTOM_EVENT ? 'event_name' : 'pg' + const goalValue = goal.value || '' + const metaParams: Record = {} + + let matchCondition = '' + if (goal.matchType === GoalMatchType.EXACT) { + matchCondition = `c.${matchColumn} = {goalValue:String}` + } else if (goalValue.trim() === '') { + matchCondition = '1=0' + } else { + matchCondition = `c.${matchColumn} ILIKE concat('%', {goalValue:String}, '%')` + } + + let metaCondition = '' + if (goal.metadataFilters && goal.metadataFilters.length > 0) { + const conditions: string[] = [] + + goal.metadataFilters.forEach((filter, index) => { + const keyParam = `metaKey${index}` + const valueParam = `metaValue${index}` + metaParams[keyParam] = filter.key + metaParams[valueParam] = filter.value + conditions.push( + `has(c.meta.key, {${keyParam}:String}) AND c.meta.value[indexOf(c.meta.key, {${keyParam}:String})] = {${valueParam}:String}`, + ) + }) + + metaCondition = `AND (${conditions.join(' AND ')})` + } + + return { + eventType, + matchColumn, + matchCondition, + metaCondition, + metaParams, + goalValue, + } + } + + private getExposureAttributionSubquery(experiment: Experiment): string { + const variantSelector = + experiment.multipleVariantHandling === + MultipleVariantHandling.FIRST_EXPOSURE || + experiment.multipleVariantHandling === MultipleVariantHandling.EXCLUDE + ? 'argMin(variantKey, tuple(created, variantKey))' + : 'any(variantKey)' + const multiVariantFilter = + experiment.multipleVariantHandling === MultipleVariantHandling.EXCLUDE + ? 'HAVING uniqExact(variantKey) = 1' + : '' + + return ` + SELECT + pid, + profileId, + ${variantSelector} as variantKey, + min(created) as exposureCreated + FROM experiment_exposures + WHERE + pid = {pid:FixedString(12)} + AND experimentId = {experimentId:String} + AND created BETWEEN {groupFrom:String} AND {groupTo:String} + GROUP BY pid, profileId + ${multiVariantFilter} + ` + } + + private async generateExperimentChart( + experiment: Experiment, + timeBucket: TimeBucketType, + groupFrom: string, + groupTo: string, + groupFromUTC: string, + groupToUTC: string, + safeTimezone: string, + ): Promise<{ x: string[]; winProbability: Record }> { + const { xShifted } = this.analyticsService.generateXAxis( + timeBucket, + groupFrom, + groupTo, + safeTimezone, + ) + + const dateColumnsGroupBy = this.getTimeBucketDateColumnsGroupBy(timeBucket) + const exposureAttributionSubquery = + this.getExposureAttributionSubquery(experiment) + const exposuresDateColumnsSelect = this.getTimeBucketDateColumnsSelect( + timeBucket, + 'exposureCreated', + ) + + const exposuresQuery = ` + SELECT + ${exposuresDateColumnsSelect}, + variantKey, + count() as exposures + FROM ( + ${exposureAttributionSubquery} + ) + GROUP BY ${dateColumnsGroupBy}, variantKey + ORDER BY ${dateColumnsGroupBy} + ` + + const queryParams = { + pid: experiment.projectId, + experimentId: experiment.id, + groupFrom: groupFromUTC, + groupTo: groupToUTC, + timezone: safeTimezone, + } + + let exposuresData: any[] = [] + try { + const result = await clickhouse + .query({ query: exposuresQuery, query_params: queryParams }) + .then((resultSet) => resultSet.json()) + exposuresData = result.data as any[] + } catch (err) { + this.logger.warn({ err }, 'Failed to get time-bucketed exposures') + } + + let conversionsData: any[] = [] + if (experiment.goal) { + const { + eventType, + matchCondition, + metaCondition, + metaParams, + goalValue, + } = this.buildGoalEventConditions(experiment.goal) + const conversionsDateColumnsSelect = this.getTimeBucketDateColumnsSelect( + timeBucket, + 'firstConversion', + ) + + const conversionsQuery = ` + SELECT + ${conversionsDateColumnsSelect}, + variantKey, + count() as conversions + FROM ( + SELECT + e.variantKey as variantKey, + e.profileId as profileId, + min(c.created) as firstConversion + FROM ( + ${exposureAttributionSubquery} + ) e + INNER JOIN events c ON e.pid = c.pid AND e.profileId = assumeNotNull(c.profileId) AND c.type = '${eventType}' + WHERE + e.pid = {pid:FixedString(12)} + AND c.created BETWEEN {groupFrom:String} AND {groupTo:String} + AND c.created >= e.exposureCreated + AND ${matchCondition} + ${metaCondition} + GROUP BY e.variantKey, e.profileId + ) + GROUP BY ${dateColumnsGroupBy}, variantKey + ORDER BY ${dateColumnsGroupBy} + ` + + try { + const result = await clickhouse + .query({ + query: conversionsQuery, + query_params: { ...queryParams, goalValue, ...metaParams }, + }) + .then((resultSet) => resultSet.json()) + conversionsData = result.data as any[] + } catch (err) { + this.logger.warn({ err }, 'Failed to get time-bucketed conversions') + } + } + + const variantKeys = experiment.variants.map((variant) => variant.key) + const winProbability: Record = {} + + for (const key of variantKeys) { + winProbability[key] = Array(xShifted.length).fill(0) + } + + const cumulativeExposures: Record = {} + const cumulativeConversions: Record = {} + + for (const key of variantKeys) { + cumulativeExposures[key] = 0 + cumulativeConversions[key] = 0 + } + + const exposuresByBucket: Record> = {} + for (const row of exposuresData) { + const rowDate = this.generateDateStringFromRow(row, timeBucket) + const key = row.variantKey + if (!rowDate || !key) continue + exposuresByBucket[rowDate] ||= {} + exposuresByBucket[rowDate][key] = Number(row.exposures) || 0 + } + + const conversionsByBucket: Record> = {} + for (const row of conversionsData) { + const rowDate = this.generateDateStringFromRow(row, timeBucket) + const key = row.variantKey + if (!rowDate || !key) continue + conversionsByBucket[rowDate] ||= {} + conversionsByBucket[rowDate][key] = Number(row.conversions) || 0 + } + + for (let i = 0; i < xShifted.length; i++) { + const bucketDate = xShifted[i] + + const exposuresDelta = exposuresByBucket[bucketDate] + if (exposuresDelta) { + for (const [key, value] of Object.entries(exposuresDelta)) { + if (typeof cumulativeExposures[key] === 'number') { + cumulativeExposures[key] += value + } + } + } + + const conversionsDelta = conversionsByBucket[bucketDate] + if (conversionsDelta) { + for (const [key, value] of Object.entries(conversionsDelta)) { + if (typeof cumulativeConversions[key] === 'number') { + cumulativeConversions[key] += value + } + } + } + + const variantData = variantKeys.map((key) => ({ + key, + exposures: cumulativeExposures[key], + conversions: cumulativeConversions[key], + })) + + const probabilities = calculateBayesianProbabilities(variantData) + + for (const key of variantKeys) { + winProbability[key][i] = _round((probabilities.get(key) || 0) * 100, 2) + } + } + + return { + x: xShifted, + winProbability, + } + } + + private getTimeBucketDateColumnsSelect( + timeBucket: string, + dateExpr: string, + ): string { + switch (timeBucket) { + case 'minute': + return `toYear(${dateExpr}, {timezone:String}) as year, toMonth(${dateExpr}, {timezone:String}) as month, toDayOfMonth(${dateExpr}, {timezone:String}) as day, toHour(${dateExpr}, {timezone:String}) as hour, toMinute(${dateExpr}, {timezone:String}) as minute` + case 'hour': + return `toYear(${dateExpr}, {timezone:String}) as year, toMonth(${dateExpr}, {timezone:String}) as month, toDayOfMonth(${dateExpr}, {timezone:String}) as day, toHour(${dateExpr}, {timezone:String}) as hour` + case 'day': + return `toYear(${dateExpr}, {timezone:String}) as year, toMonth(${dateExpr}, {timezone:String}) as month, toDayOfMonth(${dateExpr}, {timezone:String}) as day` + case 'month': + return `toYear(${dateExpr}, {timezone:String}) as year, toMonth(${dateExpr}, {timezone:String}) as month` + case 'year': + return `toYear(${dateExpr}, {timezone:String}) as year` + default: + return `toYear(${dateExpr}, {timezone:String}) as year, toMonth(${dateExpr}, {timezone:String}) as month, toDayOfMonth(${dateExpr}, {timezone:String}) as day` + } + } + + private getTimeBucketDateColumnsGroupBy(timeBucket: string): string { + switch (timeBucket) { + case 'minute': + return 'year, month, day, hour, minute' + case 'hour': + return 'year, month, day, hour' + case 'day': + return 'year, month, day' + case 'month': + return 'year, month' + case 'year': + return 'year' + default: + return 'year, month, day' + } + } + + private generateDateStringFromRow(row: any, timeBucket: string): string { + const { year, month, day, hour, minute } = row + + let dateString = `${year}` + + if (typeof month === 'number') { + dateString += month < 10 ? `-0${month}` : `-${month}` + } + + if ( + typeof day === 'number' && + timeBucket !== 'month' && + timeBucket !== 'year' + ) { + dateString += day < 10 ? `-0${day}` : `-${day}` + } + + if ( + typeof hour === 'number' && + (timeBucket === 'hour' || timeBucket === 'minute') + ) { + const strMinute = + typeof minute === 'number' + ? minute < 10 + ? `0${minute}` + : `${minute}` + : '00' + + dateString += + hour < 10 ? ` 0${hour}:${strMinute}:00` : ` ${hour}:${strMinute}:00` + } + + return dateString + } +} + +const dayString = () => new Date().toISOString().slice(0, 19).replace('T', ' ') diff --git a/backend/apps/community/src/experiment/experiment.module.ts b/backend/apps/community/src/experiment/experiment.module.ts new file mode 100644 index 000000000..33850478e --- /dev/null +++ b/backend/apps/community/src/experiment/experiment.module.ts @@ -0,0 +1,22 @@ +import { Module, forwardRef } from '@nestjs/common' +import { ExperimentController } from './experiment.controller' +import { ExperimentService } from './experiment.service' +import { ProjectModule } from '../project/project.module' +import { AppLoggerModule } from '../logger/logger.module' +import { AnalyticsModule } from '../analytics/analytics.module' +import { GoalModule } from '../goal/goal.module' +import { FeatureFlagModule } from '../feature-flag/feature-flag.module' + +@Module({ + imports: [ + forwardRef(() => ProjectModule), + AppLoggerModule, + forwardRef(() => AnalyticsModule), + forwardRef(() => GoalModule), + forwardRef(() => FeatureFlagModule), + ], + controllers: [ExperimentController], + providers: [ExperimentService], + exports: [ExperimentService], +}) +export class ExperimentModule {} diff --git a/backend/apps/community/src/experiment/experiment.service.ts b/backend/apps/community/src/experiment/experiment.service.ts new file mode 100644 index 000000000..e88c86cdc --- /dev/null +++ b/backend/apps/community/src/experiment/experiment.service.ts @@ -0,0 +1,512 @@ +import { Injectable } from '@nestjs/common' +import { randomUUID } from 'crypto' +import dayjs from 'dayjs' +import utc from 'dayjs/plugin/utc' +import _filter from 'lodash/filter' +import _head from 'lodash/head' +import _isEmpty from 'lodash/isEmpty' +import _keys from 'lodash/keys' +import _map from 'lodash/map' +import _reduce from 'lodash/reduce' + +import { clickhouse } from '../common/integrations/clickhouse' +import { Pagination } from '../common/pagination/pagination' +import { PaginationOptionsInterface } from '../common/pagination/pagination.results.interface' +import { + ClickhouseExperiment, + Experiment, + ExperimentStatus, + ExposureTrigger, + MultipleVariantHandling, + FeatureFlagMode, +} from './entity/experiment.entity' +import { + ClickhouseExperimentVariant, + ExperimentVariant, +} from './entity/experiment-variant.entity' + +dayjs.extend(utc) + +const ALLOWED_EXPERIMENT_KEYS = [ + 'name', + 'description', + 'hypothesis', + 'status', + 'exposureTrigger', + 'customEventName', + 'multipleVariantHandling', + 'filterInternalUsers', + 'featureFlagMode', + 'featureFlagKey', + 'startedAt', + 'endedAt', + 'goalId', + 'featureFlagId', +] + +const NULLABLE_EXPERIMENT_KEYS = [ + 'description', + 'hypothesis', + 'customEventName', + 'featureFlagKey', + 'startedAt', + 'endedAt', + 'goalId', + 'featureFlagId', +] + +@Injectable() +export class ExperimentService { + formatVariantFromClickhouse( + variant: ClickhouseExperimentVariant, + ): ExperimentVariant | null { + if (!variant) { + return null + } + + return { + id: variant.id, + experimentId: variant.experimentId, + name: variant.name, + key: variant.key, + description: variant.description || null, + rolloutPercentage: Number(variant.rolloutPercentage), + isControl: Boolean(variant.isControl), + } + } + + formatExperimentFromClickhouse( + experiment: ClickhouseExperiment, + variants: ExperimentVariant[] = [], + ): Experiment | null { + if (!experiment) { + return null + } + + return { + id: experiment.id, + name: experiment.name, + description: experiment.description || null, + hypothesis: experiment.hypothesis || null, + status: experiment.status as ExperimentStatus, + exposureTrigger: experiment.exposureTrigger as ExposureTrigger, + customEventName: experiment.customEventName || null, + multipleVariantHandling: + experiment.multipleVariantHandling as MultipleVariantHandling, + filterInternalUsers: Boolean(experiment.filterInternalUsers), + featureFlagMode: experiment.featureFlagMode as FeatureFlagMode, + featureFlagKey: experiment.featureFlagKey || null, + startedAt: experiment.startedAt || null, + endedAt: experiment.endedAt || null, + projectId: experiment.projectId, + goalId: experiment.goalId || null, + featureFlagId: experiment.featureFlagId || null, + variants, + created: experiment.created, + } + } + + private formatExperimentToClickhouse( + experiment: Partial, + ): Partial { + const result: Partial = {} + const writableResult = result as Record + + for (const key of ALLOWED_EXPERIMENT_KEYS) { + if (experiment[key as keyof Experiment] !== undefined) { + writableResult[key] = experiment[key as keyof Experiment] + } + } + + if (experiment.id !== undefined) { + result.id = experiment.id + } + + if (experiment.projectId !== undefined) { + result.projectId = experiment.projectId + } + + if (experiment.created !== undefined) { + result.created = experiment.created + } + + if (experiment.filterInternalUsers !== undefined) { + result.filterInternalUsers = experiment.filterInternalUsers ? 1 : 0 + } + + return result + } + + private async getVariantsForExperiments( + experimentIds: string[], + ): Promise> { + const variantsByExperiment = new Map() + + if (_isEmpty(experimentIds)) { + return variantsByExperiment + } + + const { data } = await clickhouse + .query({ + query: ` + SELECT * + FROM experiment_variant + WHERE experimentId IN {experimentIds:Array(String)} + ORDER BY key ASC + `, + query_params: { experimentIds }, + }) + .then((resultSet) => resultSet.json()) + + for (const row of data) { + const variant = this.formatVariantFromClickhouse(row) + if (!variant) { + continue + } + + const list = variantsByExperiment.get(variant.experimentId) || [] + list.push(variant) + variantsByExperiment.set(variant.experimentId, list) + } + + return variantsByExperiment + } + + private async hydrateExperiments( + rows: ClickhouseExperiment[], + ): Promise { + const variantsByExperiment = await this.getVariantsForExperiments( + rows.map((row) => row.id), + ) + + return _map(rows, (row) => + this.formatExperimentFromClickhouse( + row, + variantsByExperiment.get(row.id) || [], + ), + ).filter((experiment): experiment is Experiment => experiment !== null) + } + + async paginate( + options: PaginationOptionsInterface, + projectId: string, + search?: string, + ): Promise> { + const take = + typeof options.take === 'number' && Number.isFinite(options.take) + ? Math.min(Math.max(options.take, 1), 200) + : 100 + const skip = + typeof options.skip === 'number' && Number.isFinite(options.skip) + ? Math.max(options.skip, 0) + : 0 + + const searchCondition = search?.trim() + ? `AND (name ILIKE concat('%', {search:String}, '%') OR description ILIKE concat('%', {search:String}, '%'))` + : '' + const queryParams: Record = { projectId, take, skip } + + if (search?.trim()) { + queryParams.search = search.trim() + } + + const [countResult, dataResult] = await Promise.all([ + clickhouse + .query({ + query: ` + SELECT count() as total + FROM experiment + WHERE projectId = {projectId:FixedString(12)} + ${searchCondition} + `, + query_params: queryParams, + }) + .then((resultSet) => resultSet.json<{ total: number }>()), + clickhouse + .query({ + query: ` + SELECT * + FROM experiment + WHERE projectId = {projectId:FixedString(12)} + ${searchCondition} + ORDER BY created DESC + LIMIT {take:UInt32} OFFSET {skip:UInt32} + `, + query_params: queryParams, + }) + .then((resultSet) => resultSet.json()), + ]) + + return new Pagination({ + results: await this.hydrateExperiments(dataResult.data), + total: Number(countResult.data[0]?.total || 0), + }) + } + + async count(projectId: string): Promise { + const { data } = await clickhouse + .query({ + query: ` + SELECT count() as total + FROM experiment + WHERE projectId = {projectId:FixedString(12)} + `, + query_params: { projectId }, + }) + .then((resultSet) => resultSet.json<{ total: number }>()) + + return Number(data[0]?.total || 0) + } + + async findOne(id: string): Promise { + const { data } = await clickhouse + .query({ + query: ` + SELECT * + FROM experiment + WHERE id = {id:String} + LIMIT 1 + `, + query_params: { id }, + }) + .then((resultSet) => resultSet.json()) + + if (_isEmpty(data)) { + return null + } + + return _head(await this.hydrateExperiments([_head(data)])) || null + } + + findOneWithRelations(id: string): Promise { + return this.findOne(id) + } + + async findByProject(projectId: string): Promise { + const { data } = await clickhouse + .query({ + query: ` + SELECT * + FROM experiment + WHERE projectId = {projectId:FixedString(12)} + ORDER BY created DESC + `, + query_params: { projectId }, + }) + .then((resultSet) => resultSet.json()) + + return this.hydrateExperiments(data) + } + + async findRunningByIds( + experimentIds: Array, + exposureTrigger: ExposureTrigger, + ): Promise { + const ids = experimentIds.filter((id): id is string => Boolean(id)) + + if (_isEmpty(ids)) { + return [] + } + + const { data } = await clickhouse + .query({ + query: ` + SELECT * + FROM experiment + WHERE id IN {ids:Array(String)} + AND status = {status:String} + AND exposureTrigger = {exposureTrigger:String} + `, + query_params: { + ids, + status: ExperimentStatus.RUNNING, + exposureTrigger, + }, + }) + .then((resultSet) => resultSet.json()) + + return this.hydrateExperiments(data) + } + + async findRunningCustomEventExperiments( + projectId: string, + customEventName: string, + ): Promise { + const { data } = await clickhouse + .query({ + query: ` + SELECT * + FROM experiment + WHERE projectId = {projectId:FixedString(12)} + AND status = {status:String} + AND exposureTrigger = {exposureTrigger:String} + AND customEventName = {customEventName:String} + `, + query_params: { + projectId, + status: ExperimentStatus.RUNNING, + exposureTrigger: ExposureTrigger.CUSTOM_EVENT, + customEventName, + }, + }) + .then((resultSet) => resultSet.json()) + + return this.hydrateExperiments(data) + } + + async create(experimentData: Partial): Promise { + const id = randomUUID() + const created = dayjs.utc().format('YYYY-MM-DD HH:mm:ss') + const variants = experimentData.variants || [] + + const experiment: Experiment = { + id, + name: experimentData.name, + description: experimentData.description || null, + hypothesis: experimentData.hypothesis || null, + status: experimentData.status || ExperimentStatus.DRAFT, + exposureTrigger: + experimentData.exposureTrigger || ExposureTrigger.FEATURE_FLAG, + customEventName: experimentData.customEventName || null, + multipleVariantHandling: + experimentData.multipleVariantHandling || + MultipleVariantHandling.EXCLUDE, + filterInternalUsers: experimentData.filterInternalUsers !== false, + featureFlagMode: experimentData.featureFlagMode || FeatureFlagMode.CREATE, + featureFlagKey: experimentData.featureFlagKey || null, + startedAt: experimentData.startedAt || null, + endedAt: experimentData.endedAt || null, + projectId: experimentData.projectId, + goalId: experimentData.goalId || null, + featureFlagId: experimentData.featureFlagId || null, + variants: [], + created, + } + + await clickhouse.insert({ + table: 'experiment', + format: 'JSONEachRow', + values: [ + { + ...this.formatExperimentToClickhouse(experiment), + filterInternalUsers: experiment.filterInternalUsers ? 1 : 0, + }, + ], + }) + + experiment.variants = await this.insertVariants(id, variants) + + return experiment + } + + async update( + id: string, + experimentData: Partial, + ): Promise { + const existing = await this.findOne(id) + + if (!existing) { + return null + } + + const filtered = _reduce( + _filter( + _keys(experimentData), + (key) => + ALLOWED_EXPERIMENT_KEYS.includes(key) && + experimentData[key as keyof Experiment] !== undefined, + ), + (obj, key) => { + obj[key] = experimentData[key as keyof Experiment] + return obj + }, + {} as Record, + ) + + const columns = _keys(filtered) + + if (!_isEmpty(columns)) { + const formattedData = this.formatExperimentToClickhouse(filtered) + const params: Record = { id } + + const assignments = _map(columns, (col) => { + const value = formattedData[col as keyof ClickhouseExperiment] + params[col] = value + + if (col === 'filterInternalUsers') { + return `${col}={${col}:Int8}` + } + + if (value === null && NULLABLE_EXPERIMENT_KEYS.includes(col)) { + return `${col}=NULL` + } + + return `${col}={${col}:String}` + }).join(', ') + + await clickhouse.command({ + query: `ALTER TABLE experiment UPDATE ${assignments} WHERE id={id:String}`, + query_params: params, + }) + } + + return { + ...existing, + ...(filtered as Partial), + } + } + + async delete(id: string): Promise { + await clickhouse.command({ + query: `ALTER TABLE experiment_variant DELETE WHERE experimentId = {id:String}`, + query_params: { id }, + }) + + await clickhouse.command({ + query: `ALTER TABLE experiment DELETE WHERE id = {id:String}`, + query_params: { id }, + }) + } + + async recreateVariants( + experimentId: string, + variantsData: Partial[], + ): Promise { + await clickhouse.command({ + query: `ALTER TABLE experiment_variant DELETE WHERE experimentId = {experimentId:String}`, + query_params: { experimentId }, + }) + + return this.insertVariants(experimentId, variantsData) + } + + private async insertVariants( + experimentId: string, + variantsData: Partial[], + ): Promise { + if (_isEmpty(variantsData)) { + return [] + } + + const variants: ExperimentVariant[] = variantsData.map((variant) => ({ + id: randomUUID(), + experimentId, + name: variant.name, + key: variant.key, + description: variant.description || null, + rolloutPercentage: variant.rolloutPercentage, + isControl: variant.isControl, + })) + + await clickhouse.insert({ + table: 'experiment_variant', + format: 'JSONEachRow', + values: variants.map((variant) => ({ + ...variant, + isControl: variant.isControl ? 1 : 0, + })), + }) + + return variants + } +} diff --git a/backend/apps/community/src/feature-flag/dto/feature-flag.dto.ts b/backend/apps/community/src/feature-flag/dto/feature-flag.dto.ts index 83e49d533..8b4eae6cf 100644 --- a/backend/apps/community/src/feature-flag/dto/feature-flag.dto.ts +++ b/backend/apps/community/src/feature-flag/dto/feature-flag.dto.ts @@ -208,6 +208,9 @@ export class FeatureFlagDto { @ApiProperty() enabled: boolean + @ApiProperty() + experimentId: string | null + @ApiProperty() pid: string diff --git a/backend/apps/community/src/feature-flag/entity/feature-flag.entity.ts b/backend/apps/community/src/feature-flag/entity/feature-flag.entity.ts index 01267849a..326442bd1 100644 --- a/backend/apps/community/src/feature-flag/entity/feature-flag.entity.ts +++ b/backend/apps/community/src/feature-flag/entity/feature-flag.entity.ts @@ -10,6 +10,7 @@ export { FeatureFlagType, TargetingRule } export interface FeatureFlag extends EvaluatableFeatureFlag { id: string description: string | null + experimentId: string | null projectId: string created: string } @@ -22,6 +23,7 @@ export interface ClickhouseFeatureFlag { rolloutPercentage: number targetingRules: string | null // JSON string in ClickHouse enabled: number // Int8 in ClickHouse + experimentId: string | null projectId: string created: string } diff --git a/backend/apps/community/src/feature-flag/evaluation.ts b/backend/apps/community/src/feature-flag/evaluation.ts index 758a54147..692b79280 100644 --- a/backend/apps/community/src/feature-flag/evaluation.ts +++ b/backend/apps/community/src/feature-flag/evaluation.ts @@ -29,6 +29,11 @@ export interface EvaluatableFeatureFlag { targetingRules: TargetingRule[] | null } +interface ExperimentVariant { + key: string + rolloutPercentage: number +} + /** * Evaluates all feature flags for a project given visitor attributes */ @@ -161,3 +166,31 @@ function isInRolloutPercentage( return normalizedValue < percentage } + +export function getExperimentVariant( + experimentId: string, + variants: ExperimentVariant[], + profileId: string, +): string | null { + if (variants.length === 0) { + return null + } + + const hash = crypto + .createHash('sha256') + .update(`experiment:${experimentId}:${profileId}`) + .digest('hex') + + const hashValue = parseInt(hash.substring(0, 8), 16) + const normalizedValue = (hashValue / 0x100000000) * 100 + + let cumulativePercentage = 0 + for (const variant of variants) { + cumulativePercentage += variant.rolloutPercentage + if (normalizedValue < cumulativePercentage) { + return variant.key + } + } + + return null +} diff --git a/backend/apps/community/src/feature-flag/feature-flag.controller.ts b/backend/apps/community/src/feature-flag/feature-flag.controller.ts index 0e70b1c5c..c58291992 100644 --- a/backend/apps/community/src/feature-flag/feature-flag.controller.ts +++ b/backend/apps/community/src/feature-flag/feature-flag.controller.ts @@ -45,6 +45,9 @@ import { import { FeatureFlagService } from './feature-flag.service' import { clickhouse } from '../common/integrations/clickhouse' import { checkRateLimit, getIPFromHeaders, getIPDetails } from '../common/utils' +import { ExperimentService } from '../experiment/experiment.service' +import { ExposureTrigger } from '../experiment/entity/experiment.entity' +import { getExperimentVariant } from './evaluation' const FEATURE_FLAGS_MAXIMUM = 50 // Maximum feature flags per project const FEATURE_FLAGS_PAGINATION_MAX_TAKE = 100 @@ -57,6 +60,7 @@ export class FeatureFlagController { private readonly projectService: ProjectService, private readonly logger: AppLoggerService, private readonly analyticsService: AnalyticsService, + private readonly experimentService: ExperimentService, ) {} @ApiBearerAuth() @@ -309,17 +313,76 @@ export class FeatureFlagController { derivedAttributes, ) - // Track evaluations in ClickHouse (async, don't wait) + const experimentVariants = new Map() + const flagsWithExperiments = flags.filter( + (flag) => flag.experimentId && evaluatedFlags[flag.key], + ) + + if (flagsWithExperiments.length > 0) { + const experiments = await this.experimentService.findRunningByIds( + flagsWithExperiments.map((flag) => flag.experimentId), + ExposureTrigger.FEATURE_FLAG, + ) + + for (const experiment of experiments) { + if (!experiment.variants || experiment.variants.length === 0) { + continue + } + + const sortedVariants = [...experiment.variants].sort((a, b) => + a.key.localeCompare(b.key), + ) + const variantKey = getExperimentVariant( + experiment.id, + sortedVariants.map((variant) => ({ + key: variant.key, + rolloutPercentage: variant.rolloutPercentage, + })), + profileId, + ) + + if (variantKey) { + experimentVariants.set(experiment.id, variantKey) + } + } + } + this.trackEvaluations( evaluateDto.pid, flags, evaluatedFlags, profileId, + experimentVariants, ).catch((err) => { this.logger.error({ err }, 'Failed to track flag evaluations') }) - return { flags: evaluatedFlags } + const response: { + flags: Record + experiments?: Record + } = { + flags: evaluatedFlags, + } + + if (experimentVariants.size > 0) { + const experimentsByIdOrFlagKey: Record = {} + + for (const [experimentId, variantKey] of experimentVariants.entries()) { + experimentsByIdOrFlagKey[experimentId] = variantKey + + const linkedFlags = flagsWithExperiments.filter( + (flag) => flag.experimentId === experimentId, + ) + + for (const linkedFlag of linkedFlags) { + experimentsByIdOrFlagKey[linkedFlag.key] = variantKey + } + } + + response.experiments = experimentsByIdOrFlagKey + } + + return response } private async trackEvaluations( @@ -327,6 +390,7 @@ export class FeatureFlagController { flags: FeatureFlag[], evaluatedFlags: Record, profileId: string, + experimentVariants?: Map, ) { if (flags.length === 0) return @@ -351,6 +415,28 @@ export class FeatureFlagController { // Log error but don't fail the request this.logger.error({ err }, 'Failed to insert flag evaluations') } + + if (experimentVariants && experimentVariants.size > 0) { + const experimentExposures = Array.from(experimentVariants.entries()).map( + ([experimentId, variantKey]) => ({ + pid, + experimentId, + variantKey, + profileId, + created: now, + }), + ) + + try { + await clickhouse.insert({ + table: 'experiment_exposures', + values: experimentExposures, + format: 'JSONEachRow', + }) + } catch (err) { + this.logger.error({ err }, 'Failed to insert experiment exposures') + } + } } @ApiBearerAuth() diff --git a/backend/apps/community/src/feature-flag/feature-flag.module.ts b/backend/apps/community/src/feature-flag/feature-flag.module.ts index c4e56db40..91df74677 100644 --- a/backend/apps/community/src/feature-flag/feature-flag.module.ts +++ b/backend/apps/community/src/feature-flag/feature-flag.module.ts @@ -1,13 +1,19 @@ -import { Module } from '@nestjs/common' +import { Module, forwardRef } from '@nestjs/common' import { ProjectModule } from '../project/project.module' import { AppLoggerModule } from '../logger/logger.module' import { AnalyticsModule } from '../analytics/analytics.module' +import { ExperimentModule } from '../experiment/experiment.module' import { FeatureFlagService } from './feature-flag.service' import { FeatureFlagController } from './feature-flag.controller' @Module({ - imports: [ProjectModule, AppLoggerModule, AnalyticsModule], + imports: [ + ProjectModule, + AppLoggerModule, + forwardRef(() => AnalyticsModule), + forwardRef(() => ExperimentModule), + ], providers: [FeatureFlagService], exports: [FeatureFlagService], controllers: [FeatureFlagController], diff --git a/backend/apps/community/src/feature-flag/feature-flag.service.ts b/backend/apps/community/src/feature-flag/feature-flag.service.ts index f68c05a8a..2d376575b 100644 --- a/backend/apps/community/src/feature-flag/feature-flag.service.ts +++ b/backend/apps/community/src/feature-flag/feature-flag.service.ts @@ -32,6 +32,7 @@ const ALLOWED_FLAG_KEYS = [ 'rolloutPercentage', 'targetingRules', 'enabled', + 'experimentId', ] @Injectable() @@ -58,6 +59,7 @@ export class FeatureFlagService { rolloutPercentage: flag.rolloutPercentage, targetingRules, enabled: Boolean(flag.enabled), + experimentId: flag.experimentId || null, projectId: flag.projectId, created: flag.created, } @@ -259,6 +261,7 @@ export class FeatureFlagService { const rolloutPercentage = flagData.rolloutPercentage ?? 100 const description = flagData.description || null const targetingRules = flagData.targetingRules || null + const experimentId = flagData.experimentId || null const formattedFlag = this.formatFlagToClickhouse({ ...flagData, @@ -277,6 +280,7 @@ export class FeatureFlagService { rolloutPercentage: formattedFlag.rolloutPercentage ?? 100, targetingRules: formattedFlag.targetingRules || null, enabled: formattedFlag.enabled ?? 1, + experimentId: formattedFlag.experimentId || null, projectId: flagData.projectId, created, }, @@ -294,6 +298,7 @@ export class FeatureFlagService { rolloutPercentage, targetingRules, enabled, + experimentId, projectId: flagData.projectId, created, } @@ -352,7 +357,9 @@ export class FeatureFlagService { // Handle nullable string columns if ( value === null && - (col === 'description' || col === 'targetingRules') + (col === 'description' || + col === 'targetingRules' || + col === 'experimentId') ) { return `${col}=NULL` } diff --git a/backend/apps/community/src/goal/goal.module.ts b/backend/apps/community/src/goal/goal.module.ts index 6a0f3492d..5d9d94feb 100644 --- a/backend/apps/community/src/goal/goal.module.ts +++ b/backend/apps/community/src/goal/goal.module.ts @@ -1,4 +1,4 @@ -import { Module } from '@nestjs/common' +import { Module, forwardRef } from '@nestjs/common' import { ProjectModule } from '../project/project.module' import { AppLoggerModule } from '../logger/logger.module' @@ -7,7 +7,7 @@ import { GoalService } from './goal.service' import { GoalController } from './goal.controller' @Module({ - imports: [ProjectModule, AppLoggerModule, AnalyticsModule], + imports: [ProjectModule, AppLoggerModule, forwardRef(() => AnalyticsModule)], providers: [GoalService], exports: [GoalService], controllers: [GoalController], diff --git a/backend/migrations/clickhouse/initialise_selfhosted.js b/backend/migrations/clickhouse/initialise_selfhosted.js index f463fe67f..2cbc4a48b 100644 --- a/backend/migrations/clickhouse/initialise_selfhosted.js +++ b/backend/migrations/clickhouse/initialise_selfhosted.js @@ -86,6 +86,7 @@ const CLICKHOUSE_INIT_QUERIES = [ rolloutPercentage UInt8 DEFAULT 100, targetingRules Nullable(String), enabled Int8 DEFAULT 1, + experimentId Nullable(String), projectId FixedString(12), created DateTime('UTC') ) @@ -93,6 +94,43 @@ const CLICKHOUSE_INIT_QUERIES = [ ORDER BY (projectId, id) PARTITION BY toYYYYMM(created);`, + `CREATE TABLE IF NOT EXISTS ${dbName}.experiment + ( + id String, + name String, + description Nullable(String), + hypothesis Nullable(String), + status Enum8('draft' = 1, 'running' = 2, 'paused' = 3, 'completed' = 4) DEFAULT 'draft', + exposureTrigger Enum8('feature_flag' = 1, 'custom_event' = 2) DEFAULT 'feature_flag', + customEventName Nullable(String), + multipleVariantHandling Enum8('exclude' = 1, 'first_exposure' = 2) DEFAULT 'exclude', + filterInternalUsers Int8 DEFAULT 1, + featureFlagMode Enum8('create' = 1, 'link' = 2) DEFAULT 'create', + featureFlagKey Nullable(String), + startedAt Nullable(DateTime('UTC')), + endedAt Nullable(DateTime('UTC')), + projectId FixedString(12), + goalId Nullable(String), + featureFlagId Nullable(String), + created DateTime('UTC') + ) + ENGINE = MergeTree() + ORDER BY (projectId, created, id) + PARTITION BY toYYYYMM(created);`, + + `CREATE TABLE IF NOT EXISTS ${dbName}.experiment_variant + ( + id String, + experimentId String, + name String, + key String, + description Nullable(String), + rolloutPercentage UInt8 DEFAULT 50, + isControl Int8 DEFAULT 0 + ) + ENGINE = MergeTree() + ORDER BY (experimentId, key, id);`, + `CREATE TABLE IF NOT EXISTS ${dbName}.feature_flag_evaluations ( pid FixedString(12), diff --git a/backend/migrations/clickhouse/selfhosted_2026_05_05_experiments.js b/backend/migrations/clickhouse/selfhosted_2026_05_05_experiments.js new file mode 100644 index 000000000..c1a3c547a --- /dev/null +++ b/backend/migrations/clickhouse/selfhosted_2026_05_05_experiments.js @@ -0,0 +1,57 @@ +const { queriesRunner, dbName } = require('./setup') + +const queries = [ + `ALTER TABLE ${dbName}.feature_flag ADD COLUMN IF NOT EXISTS experimentId Nullable(String) AFTER enabled;`, + + `CREATE TABLE IF NOT EXISTS ${dbName}.experiment + ( + id String, + name String, + description Nullable(String), + hypothesis Nullable(String), + status Enum8('draft' = 1, 'running' = 2, 'paused' = 3, 'completed' = 4) DEFAULT 'draft', + exposureTrigger Enum8('feature_flag' = 1, 'custom_event' = 2) DEFAULT 'feature_flag', + customEventName Nullable(String), + multipleVariantHandling Enum8('exclude' = 1, 'first_exposure' = 2) DEFAULT 'exclude', + filterInternalUsers Int8 DEFAULT 1, + featureFlagMode Enum8('create' = 1, 'link' = 2) DEFAULT 'create', + featureFlagKey Nullable(String), + startedAt Nullable(DateTime('UTC')), + endedAt Nullable(DateTime('UTC')), + projectId FixedString(12), + goalId Nullable(String), + featureFlagId Nullable(String), + created DateTime('UTC') + ) + ENGINE = MergeTree() + ORDER BY (projectId, created, id) + PARTITION BY toYYYYMM(created);`, + + `CREATE TABLE IF NOT EXISTS ${dbName}.experiment_variant + ( + id String, + experimentId String, + name String, + key String, + description Nullable(String), + rolloutPercentage UInt8 DEFAULT 50, + isControl Int8 DEFAULT 0 + ) + ENGINE = MergeTree() + ORDER BY (experimentId, key, id);`, + + `CREATE TABLE IF NOT EXISTS ${dbName}.experiment_exposures + ( + pid FixedString(12), + experimentId String, + variantKey String, + profileId String, + created DateTime('UTC') + ) + ENGINE = MergeTree() + PARTITION BY toYYYYMM(created) + ORDER BY (pid, experimentId, created) + TTL created + INTERVAL 1 YEAR;`, +] + +queriesRunner(queries) diff --git a/web/app/api/api.server.ts b/web/app/api/api.server.ts index 052d09680..7c5623adf 100644 --- a/web/app/api/api.server.ts +++ b/web/app/api/api.server.ts @@ -1274,6 +1274,7 @@ export interface ProjectFeatureFlag { rolloutPercentage: number targetingRules: TargetingRule[] | null enabled: boolean + experimentId: string | null pid: string created: string } diff --git a/web/app/pages/Project/View/ViewProject.tsx b/web/app/pages/Project/View/ViewProject.tsx index 72cc6243e..076bab67e 100644 --- a/web/app/pages/Project/View/ViewProject.tsx +++ b/web/app/pages/Project/View/ViewProject.tsx @@ -775,24 +775,26 @@ const ViewProjectContent = () => { ] : [] - if (isSelfhosted) { - return [...baseTabs, ...adminTabs] + const experimentsTab = { + id: PROJECT_TABS.experiments, + label: t('dashboard.experiments'), + icon: FlaskIcon, } - const newTabs = [ - ...baseTabs, - { - id: PROJECT_TABS.ai, - label: t('dashboard.askAi'), - icon: SparkleIcon, - }, - { - id: PROJECT_TABS.experiments, - label: t('dashboard.experiments'), - icon: FlaskIcon, - }, - ...adminTabs, - ].filter((x) => !!x) + const newTabs = ( + isSelfhosted + ? [...baseTabs, experimentsTab, ...adminTabs] + : [ + ...baseTabs, + { + id: PROJECT_TABS.ai, + label: t('dashboard.askAi'), + icon: SparkleIcon, + }, + experimentsTab, + ...adminTabs, + ] + ).filter((x) => !!x) if (projectQueryTabs && projectQueryTabs.length) { return _filter(newTabs, (tab) => _includes(projectQueryTabs, tab.id)) From dabe823522c4fd0bdcf1b1ae4ac95983cc6f572d Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 6 May 2026 20:01:33 +0100 Subject: [PATCH 6/7] Update dependencies --- docs/package-lock.json | 1785 ++++++++++------------------------------ docs/package.json | 28 +- 2 files changed, 463 insertions(+), 1350 deletions(-) diff --git a/docs/package-lock.json b/docs/package-lock.json index 2b443586a..1db56deaf 100644 --- a/docs/package-lock.json +++ b/docs/package-lock.json @@ -12,25 +12,25 @@ "@radix-ui/react-popover": "^1.1.15", "@types/mdx": "^2.0.13", "class-variance-authority": "^0.7.1", - "fumadocs-core": "^16.7.11", - "fumadocs-mdx": "^14.2.11", - "fumadocs-ui": "^16.7.11", - "next": "^16.2.2", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "simple-icons": "^16.15.0", - "swetrix": "^4.1.0", + "fumadocs-core": "^16.8.7", + "fumadocs-mdx": "^14.3.2", + "fumadocs-ui": "^16.8.7", + "next": "^16.2.5", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "simple-icons": "^16.18.1", + "swetrix": "^4.2.0", "tailwind-merge": "^3.5.0" }, "devDependencies": { - "@tailwindcss/postcss": "^4.2.2", - "@types/node": "^25.5.2", + "@tailwindcss/postcss": "^4.2.4", + "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "autoprefixer": "^10.4.27", - "oxfmt": "^0.44.0", - "postcss": "^8.5.9", - "tailwindcss": "^4.2.2", + "autoprefixer": "^10.5.0", + "oxfmt": "^0.48.0", + "postcss": "^8.5.14", + "tailwindcss": "^4.2.4", "typescript": "^5.9.3" } }, @@ -47,17 +47,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/@emnapi/core": { - "version": "1.9.2", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.2.tgz", - "integrity": "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==", - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/runtime": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", @@ -68,20 +57,10 @@ "tslib": "^2.4.0" } }, - "node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", - "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", - "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz", + "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==", "cpu": [ "ppc64" ], @@ -95,9 +74,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", - "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.0.tgz", + "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==", "cpu": [ "arm" ], @@ -111,9 +90,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", - "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz", + "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==", "cpu": [ "arm64" ], @@ -127,9 +106,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", - "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.0.tgz", + "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==", "cpu": [ "x64" ], @@ -143,9 +122,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", - "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz", + "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==", "cpu": [ "arm64" ], @@ -159,9 +138,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", - "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz", + "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==", "cpu": [ "x64" ], @@ -175,9 +154,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", - "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz", + "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==", "cpu": [ "arm64" ], @@ -191,9 +170,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", - "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz", + "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==", "cpu": [ "x64" ], @@ -207,9 +186,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", - "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz", + "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==", "cpu": [ "arm" ], @@ -223,9 +202,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", - "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz", + "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==", "cpu": [ "arm64" ], @@ -239,9 +218,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", - "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz", + "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==", "cpu": [ "ia32" ], @@ -255,9 +234,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", - "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz", + "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==", "cpu": [ "loong64" ], @@ -271,9 +250,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", - "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz", + "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==", "cpu": [ "mips64el" ], @@ -287,9 +266,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", - "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz", + "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==", "cpu": [ "ppc64" ], @@ -303,9 +282,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", - "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz", + "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==", "cpu": [ "riscv64" ], @@ -319,9 +298,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", - "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz", + "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==", "cpu": [ "s390x" ], @@ -335,9 +314,9 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", - "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz", + "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==", "cpu": [ "x64" ], @@ -351,9 +330,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", - "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz", + "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==", "cpu": [ "arm64" ], @@ -367,9 +346,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", - "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz", + "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==", "cpu": [ "x64" ], @@ -383,9 +362,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", - "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz", + "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==", "cpu": [ "arm64" ], @@ -399,9 +378,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", - "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz", + "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==", "cpu": [ "x64" ], @@ -415,9 +394,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", - "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz", + "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==", "cpu": [ "arm64" ], @@ -431,9 +410,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", - "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz", + "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==", "cpu": [ "x64" ], @@ -447,9 +426,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", - "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz", + "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==", "cpu": [ "arm64" ], @@ -463,9 +442,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", - "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz", + "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==", "cpu": [ "ia32" ], @@ -479,9 +458,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", - "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz", + "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==", "cpu": [ "x64" ], @@ -532,33 +511,19 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, - "node_modules/@formatjs/fast-memoize": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@formatjs/fast-memoize/-/fast-memoize-3.1.1.tgz", - "integrity": "sha512-CbNbf+tlJn1baRnPkNePnBqTLxGliG6DDgNa/UtV66abwIjwsliPMOt0172tzxABYzSuxZBZfcp//qI8AvBWPg==", - "license": "MIT" - }, - "node_modules/@formatjs/intl-localematcher": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/@formatjs/intl-localematcher/-/intl-localematcher-0.8.2.tgz", - "integrity": "sha512-q05KMYGJLyqFNFtIb8NhWLF5X3aK/k0wYt7dnRFuy6aLQL+vUwQ1cg5cO4qawEiINybeCPXAWlprY2mSBjSXAQ==", - "license": "MIT", - "dependencies": { - "@formatjs/fast-memoize": "3.1.1" - } - }, "node_modules/@fumadocs/tailwind": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/@fumadocs/tailwind/-/tailwind-0.0.3.tgz", - "integrity": "sha512-/FWcggMz9BhoX+13xBoZLX+XX9mYvJ50dkTqy3IfocJqua65ExcsKfxwKH8hgTO3vA5KnWv4+4jU7LaW2AjAmQ==", + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/@fumadocs/tailwind/-/tailwind-0.0.5.tgz", + "integrity": "sha512-ENKPWUDRmriccsrUDE4bDBq3FNr/ms3BP2rWlsAEMV1yP23pcCaan+ceGfeBUsAQjw7sj9Q3R4Kl3g/TCStPzQ==", "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^7.1.1" - }, "peerDependencies": { + "@tailwindcss/oxide": "^4.0.0", "tailwindcss": "^4.0.0" }, "peerDependenciesMeta": { + "@tailwindcss/oxide": { + "optional": true + }, "tailwindcss": { "optional": true } @@ -1066,6 +1031,7 @@ "version": "1.5.5", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { @@ -1116,604 +1082,48 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.3.tgz", - "integrity": "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ==", - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, "node_modules/@next/env": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.2.tgz", - "integrity": "sha512-LqSGz5+xGk9EL/iBDr2yo/CgNQV6cFsNhRR2xhSXYh7B/hb4nePCxlmDvGEKG30NMHDFf0raqSyOZiQrO7BkHQ==", - "license": "MIT" - }, - "node_modules/@next/swc-darwin-arm64": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.2.tgz", - "integrity": "sha512-B92G3ulrwmkDSEJEp9+XzGLex5wC1knrmCSIylyVeiAtCIfvEJYiN3v5kXPlYt5R4RFlsfO/v++aKV63Acrugg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-darwin-x64": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.2.tgz", - "integrity": "sha512-7ZwSgNKJNQiwW0CKhNm9B1WS2L1Olc4B2XY0hPYCAL3epFnugMhuw5TMWzMilQ3QCZcCHoYm9NGWTHbr5REFxw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.2.tgz", - "integrity": "sha512-c3m8kBHMziMgo2fICOP/cd/5YlrxDU5YYjAJeQLyFsCqVF8xjOTH/QYG4a2u48CvvZZSj1eHQfBCbyh7kBr30Q==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.2.tgz", - "integrity": "sha512-VKLuscm0P/mIfzt+SDdn2+8TNNJ7f0qfEkA+az7OqQbjzKdBxAHs0UvuiVoCtbwX+dqMEL9U54b5wQ/aN3dHeg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.2.tgz", - "integrity": "sha512-kU3OPHJq6sBUjOk7wc5zJ7/lipn8yGldMoAv4z67j6ov6Xo/JvzA7L7LCsyzzsXmgLEhk3Qkpwqaq/1+XpNR3g==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-linux-x64-musl": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.2.tgz", - "integrity": "sha512-CKXRILyErMtUftp+coGcZ38ZwE/Aqq45VMCcRLr2I4OXKrgxIBDXHnBgeX/UMil0S09i2JXaDL3Q+TN8D/cKmg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.2.tgz", - "integrity": "sha512-sS/jSk5VUoShUqINJFvNjVT7JfR5ORYj/+/ZpOYbbIohv/lQfduWnGAycq2wlknbOql2xOR0DoV0s6Xfcy49+g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.2.tgz", - "integrity": "sha512-aHaKceJgdySReT7qeck5oShucxWRiiEuwCGK8HHALe6yZga8uyFpLkPgaRw3kkF04U7ROogL/suYCNt/+CuXGA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@orama/orama": { - "version": "3.1.18", - "resolved": "https://registry.npmjs.org/@orama/orama/-/orama-3.1.18.tgz", - "integrity": "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==", - "license": "Apache-2.0", - "engines": { - "node": ">= 20.0.0" - } - }, - "node_modules/@oxc-parser/binding-android-arm-eabi": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.124.0.tgz", - "integrity": "sha512-+R9zCafSL8ovjokdPtorUp3sXrh8zQ2AC2L0ivXNvlLR0WS+5WdPkNVrnENq5UvzagM4Xgl0NPsJKz3Hv9+y8g==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-android-arm64": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.124.0.tgz", - "integrity": "sha512-ULHC/gVZ+nP4pd3kNNQTYaQ/e066BW/KuY5qUsvwkVWwOUQGDg+WpfyVOmQ4xfxoue6cMlkKkJ+ntdzfDXpNlg==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-darwin-arm64": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.124.0.tgz", - "integrity": "sha512-fGJ2hw7bnbUYn6UvTjp0m4WJ9zXz3cohgcwcgeo7gUZehpPNpvcVEVeIVHNmHnAuAw/ysf4YJR8DA1E+xCA4Lw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-darwin-x64": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.124.0.tgz", - "integrity": "sha512-j0+re9pgps5BH2Tk3fm59Hi3QuLP3C4KhqXi6A+wRHHHJWDFR8mc/KI9mBrfk2JRT+15doGo+zv1eN75/9DuOw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-freebsd-x64": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.124.0.tgz", - "integrity": "sha512-0k5mS0npnrhKy72UfF51lpOZ2ESoPWn6gdFw+RdeRWcokraDW1O2kSx3laQ+yk7cCEavQdJSpWCYS/GvBbUCXQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.124.0.tgz", - "integrity": "sha512-P/i4eguRWvAUfGdfhQYg1jpwYkyUV6D3gefIH7HhmRl1Ph6P4IqTIEVcyJr1i/3vr1V5OHU4wonH6/ue/Qzvrw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.124.0.tgz", - "integrity": "sha512-/ameqFQH5fFP+66Atr8Ynv/2rYe4utcU7L4MoWS5JtrFLVO78g4qDLavyIlJxa6caSwYOvG/eO3c/DXqY5/6Rw==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm64-gnu": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.124.0.tgz", - "integrity": "sha512-gNeyEcXTtfrRCbj2EfxWU85Fs0wIX3p44Y3twnvuMfkWlLrb9M1Z25AYNSKjJM+fdAjeeQCjw0on47zFuBYwQw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-arm64-musl": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.124.0.tgz", - "integrity": "sha512-uvG7v4Tz9S8/PVqY0SP0DLHxo4hZGe+Pv2tGVnwcsjKCCUPjplbrFVvDzXq+kOaEoUkiCY0Kt1hlZ6FDJ1LKNQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-ppc64-gnu": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.124.0.tgz", - "integrity": "sha512-t7KZaaUhfp2au0MRpoENEFqwLKYDdptEry6V7pTAVdPEcFG4P6ii8yeGU9m6p5vb+b8WEKmdpGMNXBEYy7iJdw==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.124.0.tgz", - "integrity": "sha512-eurGGaxHZiIQ+fBSageS8TAkRqZgdOiBeqNrWAqAPup9hXBTmQ0WcBjwsLElf+3jvDL9NhnX0dOgOqPfsjSjdg==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-riscv64-musl": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.124.0.tgz", - "integrity": "sha512-d1V7/ll1i/LhqE/gZy6Wbz6evlk0egh2XKkwMI3epiojtbtUwQSLIER0Y3yDBBocPuWOjJdvmjtEmPTTLXje/w==", - "cpu": [ - "riscv64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-s390x-gnu": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.124.0.tgz", - "integrity": "sha512-w1+cBvriUteOpox6ATqCFVkpGL47PFdcfCPGmgUZbd78Fw44U0gQkc+kVGvAOTvGrptMYgwomD1c6OTVvkrpGg==", - "cpu": [ - "s390x" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-x64-gnu": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.124.0.tgz", - "integrity": "sha512-RRB1evQiXRtMCsQQiAh9U0H3HzguLpE0ytfStuhRgmOj7tqUCOVxkHsvM9geZjAax6NqVRj7VXx32qjjkZPsBw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-linux-x64-musl": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.124.0.tgz", - "integrity": "sha512-asVYN0qmSHlCU8H9Q47SmeJ/Z5EG4IWCC+QGxkfFboI5qh15aLlJnHmnrV61MwQRPXGnVC/sC3qKhrUyqGxUqw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-openharmony-arm64": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.124.0.tgz", - "integrity": "sha512-nhwuxm6B8pn9lzAzMUfa571L5hCXYwQo8C8cx5aGOuHWCzruR8gPJnRRXGBci+uGaIIQEZDyU/U6HDgrSp/JlQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-wasm32-wasi": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.124.0.tgz", - "integrity": "sha512-LWuq4Dl9tff7n+HjJcqoBjDlVCtruc0shgtdtGM+rTUIE9aFxHA/P+wCYR+aWMjN8m9vNaRME/sKXErmhmeKrA==", - "cpu": [ - "wasm32" - ], - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.2" - }, - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@oxc-parser/binding-win32-arm64-msvc": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.124.0.tgz", - "integrity": "sha512-aOh3Lf3AeH0dgzT4yBXcArFZ8VhqNXwZ/xlN0GqBtgVaGoHOOqL2YHlcVIgT+ghsXPVR2PTtYgBiQ1CNK7jp5A==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-ia32-msvc": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.124.0.tgz", - "integrity": "sha512-sib5xC0nz/+SCpaETBuHBz4SXS02KuG5HtyOcHsO/SK5ZvLRGhOZx0elDKawjb6adFkD7dQCqpXUS25wY6ELKQ==", - "cpu": [ - "ia32" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-parser/binding-win32-x64-msvc": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.124.0.tgz", - "integrity": "sha512-UgojtjGUgZgAZQYt7SC6VO65OVdxEkRe2q+2vbHJO//18qw3Hrk6UvHGQKldsQKgbVcIBT/YBrt85YberiYIPQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@oxc-project/types": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", - "integrity": "sha512-VBFWMTBvHxS11Z5Lvlr3IWgrwhMTXV+Md+EQF0Xf60+wAdsGFTBx7X7K/hP4pi8N7dcm1RvcHwDxZ16Qx8keUg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, - "node_modules/@oxc-resolver/binding-android-arm-eabi": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.19.1.tgz", - "integrity": "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg==", - "cpu": [ - "arm" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@oxc-resolver/binding-android-arm64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.19.1.tgz", - "integrity": "sha512-oolbkRX+m7Pq2LNjr/kKgYeC7bRDMVTWPgxBGMjSpZi/+UskVo4jsMU3MLheZV55jL6c3rNelPl4oD60ggYmqA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@oxc-resolver/binding-darwin-arm64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.19.1.tgz", - "integrity": "sha512-nUC6d2i3R5B12sUW4O646qD5cnMXf2oBGPLIIeaRfU9doJRORAbE2SGv4eW6rMqhD+G7nf2Y8TTJTLiiO3Q/dQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.5.tgz", + "integrity": "sha512-Lb9ElHD2klcyeVD25vW+siPFqz9QMzDUSgvFZNO+dZEKoMHex4viJhVuzBhrXKqb+UKnih7mVYbt50/7KLsSCA==", + "license": "MIT" }, - "node_modules/@oxc-resolver/binding-darwin-x64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.19.1.tgz", - "integrity": "sha512-cV50vE5+uAgNcFa3QY1JOeKDSkM/9ReIcc/9wn4TavhW/itkDGrXhw9jaKnkQnGbjJ198Yh5nbX/Gr2mr4Z5jQ==", + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.5.tgz", + "integrity": "sha512-BW+8PGVmsruomXHsitD8JG6gny9lEdobctjBwvtPF8AKtxGDR7nR35FOl/oK9UAPXBOBm+vx0k8qtpeHOXQMGQ==", "cpu": [ - "x64" + "arm64" ], "license": "MIT", "optional": true, "os": [ "darwin" - ] - }, - "node_modules/@oxc-resolver/binding-freebsd-x64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.19.1.tgz", - "integrity": "sha512-xZOQiYGFxtk48PBKff+Zwoym7ScPAIVp4c14lfLxizO2LTTTJe5sx9vQNGrBymrf/vatSPNMD4FgsaaRigPkqw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.19.1.tgz", - "integrity": "sha512-lXZYWAC6kaGe/ky2su94e9jN9t6M0/6c+GrSlCqL//XO1cxi5lpAhnJYdyrKfm0ZEr/c7RNyAx3P7FSBcBd5+A==", - "cpu": [ - "arm" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">= 10" + } }, - "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.19.1.tgz", - "integrity": "sha512-veG1kKsuK5+t2IsO9q0DErYVSw2azvCVvWHnfTOS73WE0STdLLB7Q1bB9WR+yHPQM76ASkFyRbogWo1GR1+WbQ==", + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.5.tgz", + "integrity": "sha512-ZoCGnCl9LlQJWmqXrZAUlNxvuNmclvE+7zUif+nDydkkehl9FKxHJ+wxSQMj+C37BYFerKiEdX9s9o02ir975Q==", "cpu": [ - "arm" + "x64" ], "license": "MIT", "optional": true, "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-arm64-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.19.1.tgz", - "integrity": "sha512-heV2+jmXyYnUrpUXSPugqWDRpnsQcDm2AX4wzTuvgdlZfoNYO0O3W2AVpJYaDn9AG4JdM6Kxom8+foE7/BcSig==", - "cpu": [ - "arm64" + "darwin" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">= 10" + } }, - "node_modules/@oxc-resolver/binding-linux-arm64-musl": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.19.1.tgz", - "integrity": "sha512-jvo2Pjs1c9KPxMuMPIeQsgu0mOJF9rEb3y3TdpsrqwxRM+AN6/nDDwv45n5ZrUnQMsdBy5gIabioMKnQfWo9ew==", + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.5.tgz", + "integrity": "sha512-AwcZzMChaWkOTZt3vu+2ZMIj8g4dYQY+B8VUVhlFSQ2JtvyZpefyYHTe00D6b6L7BysYw7vl3zsvs9jix8tl5Q==", "cpu": [ "arm64" ], @@ -1721,64 +1131,31 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.19.1.tgz", - "integrity": "sha512-vLmdNxWCdN7Uo5suays6A/+ywBby2PWBBPXctWPg5V0+eVuzsJxgAn6MMB4mPlshskYbppjpN2Zg83ArHze9gQ==", - "cpu": [ - "ppc64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.19.1.tgz", - "integrity": "sha512-/b+WgR+VTSBxzgOhDO7TlMXC1ufPIMR6Vj1zN+/x+MnyXGW7prTLzU9eW85Aj7Th7CCEG9ArCbTeqxCzFWdg2w==", - "cpu": [ - "riscv64" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">= 10" + } }, - "node_modules/@oxc-resolver/binding-linux-riscv64-musl": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.19.1.tgz", - "integrity": "sha512-YlRdeWb9j42p29ROh+h4eg/OQ3dTJlpHSa+84pUM9+p6i3djtPz1q55yLJhgW9XfDch7FN1pQ/Vd6YP+xfRIuw==", + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.5.tgz", + "integrity": "sha512-QqMgqWbCBFsfiQ7BF3dUlW8HJy1LWhpcqbTpoHMWA9IV+TnWwDKozQJA5NdIAHjQ00yX2Q7AUkLr/XK4n77q8A==", "cpu": [ - "riscv64" + "arm64" ], "license": "MIT", "optional": true, "os": [ "linux" - ] - }, - "node_modules/@oxc-resolver/binding-linux-s390x-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.19.1.tgz", - "integrity": "sha512-EDpafVOQWF8/MJynsjOGFThcqhRHy417sRyLfQmeiamJ8qVhSKAn2Dn2VVKUGCjVB9C46VGjhNo7nOPUi1x6uA==", - "cpu": [ - "s390x" ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] + "engines": { + "node": ">= 10" + } }, - "node_modules/@oxc-resolver/binding-linux-x64-gnu": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.19.1.tgz", - "integrity": "sha512-NxjZe+rqWhr+RT8/Ik+5ptA3oz7tUw361Wa5RWQXKnfqwSSHdHyrw6IdcTfYuml9dM856AlKWZIUXDmA9kkiBQ==", + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.5.tgz", + "integrity": "sha512-3hzeiFGZtyATVx9pCeuzTshXmh50vHZitqaeZiyJZaUmjQyrfjsVUgS8apOj1vEJCIpKJM/55F45yPAV2kpjsA==", "cpu": [ "x64" ], @@ -1786,12 +1163,15 @@ "optional": true, "os": [ "linux" - ] + ], + "engines": { + "node": ">= 10" + } }, - "node_modules/@oxc-resolver/binding-linux-x64-musl": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.19.1.tgz", - "integrity": "sha512-cM/hQwsO3ReJg5kR+SpI69DMfvNCp+A/eVR4b4YClE5bVZwz8rh2Nh05InhwI5HR/9cArbEkzMjcKgTHS6UaNw==", + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.5.tgz", + "integrity": "sha512-0mzZV/mAt7Qj2tYNdTB6AqrS8dwng/AQLSYC5Z1YLpZdi2wxqKDPK7RY2RvjB1fXyJfOfdA3l/yTF5yLi+WfuQ==", "cpu": [ "x64" ], @@ -1799,41 +1179,15 @@ "optional": true, "os": [ "linux" - ] - }, - "node_modules/@oxc-resolver/binding-openharmony-arm64": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.19.1.tgz", - "integrity": "sha512-QF080IowFB0+9Rh6RcD19bdgh49BpQHUW5TajG1qvWHvmrQznTZZjYlgE2ltLXyKY+qs4F/v5xuX1XS7Is+3qA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@oxc-resolver/binding-wasm32-wasi": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.19.1.tgz", - "integrity": "sha512-w8UCKhX826cP/ZLokXDS6+milN8y4X7zidsAttEdWlVoamTNf6lhBJldaWr3ukTDiye7s4HRcuPEPOXNC432Vg==", - "cpu": [ - "wasm32" ], - "license": "MIT", - "optional": true, - "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" - }, "engines": { - "node": ">=14.0.0" + "node": ">= 10" } }, - "node_modules/@oxc-resolver/binding-win32-arm64-msvc": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.19.1.tgz", - "integrity": "sha512-nJ4AsUVZrVKwnU/QRdzPCCrO0TrabBqgJ8pJhXITdZGYOV28TIYystV1VFLbQ7DtAcaBHpocT5/ZJnF78YJPtQ==", + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.5.tgz", + "integrity": "sha512-f/H4nZ2zJBvA8/+HpsB9mNonF9zfQoAU6D0WxJrfzhJDvJLfngVN85oqxUyrDVK99DIFfFYhLpGa5K+c5uotSw==", "cpu": [ "arm64" ], @@ -1841,25 +1195,15 @@ "optional": true, "os": [ "win32" - ] - }, - "node_modules/@oxc-resolver/binding-win32-ia32-msvc": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-11.19.1.tgz", - "integrity": "sha512-EW+ND5q2Tl+a3pH81l1QbfgbF3HmqgwLfDfVithRFheac8OTcnbXt/JxqD2GbDkb7xYEqy1zNaVFRr3oeG8npA==", - "cpu": [ - "ia32" ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "engines": { + "node": ">= 10" + } }, - "node_modules/@oxc-resolver/binding-win32-x64-msvc": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.19.1.tgz", - "integrity": "sha512-6hIU3RQu45B+VNTY4Ru8ppFwjVS/S5qwYyGhBotmjxfEKk41I2DlGtRfGJndZ5+6lneE2pwloqunlOyZuX/XAw==", + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.5.tgz", + "integrity": "sha512-nuP7DHs4koAojsIxVPkihNgKiRUKtCU65j5X6DAbSy8VBrfT/o90bCLLHPf51JEdOZwZMFzM6e0NiGWfIWjVAg==", "cpu": [ "x64" ], @@ -1867,12 +1211,24 @@ "optional": true, "os": [ "win32" - ] + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@orama/orama": { + "version": "3.1.18", + "resolved": "https://registry.npmjs.org/@orama/orama/-/orama-3.1.18.tgz", + "integrity": "sha512-a61ljmRVVyG5MC/698C8/FfFDw5a8LOIvyOLW5fztgUXqUpc1jOfQzOitSCbge657OgXXThmY3Tk8fpiDb4UcA==", + "license": "Apache-2.0", + "engines": { + "node": ">= 20.0.0" + } }, "node_modules/@oxfmt/binding-android-arm-eabi": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.44.0.tgz", - "integrity": "sha512-5UvghMd9SA/yvKTWCAxMAPXS1d2i054UeOf4iFjZjfayTwCINcC3oaSXjtbZfCaEpxgJod7XiOjTtby5yEv/BQ==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.48.0.tgz", + "integrity": "sha512-uwqk+/KhQvBIpULD8SMM/zAafMRC/+DV/xsEQjkkIsJ/kLmEI/2bxonVowcYTiXqqZ/a0FEW8DPkZY3VvwELDA==", "cpu": [ "arm" ], @@ -1887,9 +1243,9 @@ } }, "node_modules/@oxfmt/binding-android-arm64": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.44.0.tgz", - "integrity": "sha512-IVudM1BWfvrYO++Khtzr8q9n5Rxu7msUvoFMqzGJVdX7HfUXUDHwaH2zHZNB58svx2J56pmCUzophyaPFkcG/A==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.48.0.tgz", + "integrity": "sha512-VUCiKuXK5+McVssgHEJdrcGK7hRJzrRb36zm9/jwzMholyYt4BgXhw5Nm1V1DX6Ce717Zi/1jk432b/tgmQgtQ==", "cpu": [ "arm64" ], @@ -1904,9 +1260,9 @@ } }, "node_modules/@oxfmt/binding-darwin-arm64": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.44.0.tgz", - "integrity": "sha512-eWCLAIKAHfx88EqEP1Ga2yz7qVcqDU5lemn4xck+07bH182hDdprOHjbogyk0In1Djys3T0/pO2JepFnRJ41Mg==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.48.0.tgz", + "integrity": "sha512-IkKp8rnIyQLW6Jt+6jragCbUVYSayk55lapiprLjIVvt4NczLyO/nwX2GgefLQ5iaBdfS8UEAFgCs/pLO6Cl0w==", "cpu": [ "arm64" ], @@ -1921,9 +1277,9 @@ } }, "node_modules/@oxfmt/binding-darwin-x64": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.44.0.tgz", - "integrity": "sha512-eHTBznHLM49++dwz07MblQ2cOXyIgeedmE3Wgy4ptUESj38/qYZyRi1MPwC9olQJWssMeY6WI3UZ7YmU5ggvyQ==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.48.0.tgz", + "integrity": "sha512-+aFuhsGIuvnoOjXyKVHMhPKJZR1kQkAl8QyrKoMlA7yJsSTC3N0Asl53La8TChSHhW8epToQ/Q0nvLmEmfNmLg==", "cpu": [ "x64" ], @@ -1938,9 +1294,9 @@ } }, "node_modules/@oxfmt/binding-freebsd-x64": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.44.0.tgz", - "integrity": "sha512-jLMmbj0u0Ft43QpkUVr/0v1ZfQCGWAvU+WznEHcN3wZC/q6ox7XeSJtk9P36CCpiDSUf3sGnzbIuG1KdEMEDJQ==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.48.0.tgz", + "integrity": "sha512-fbqzQL8FjI9gGnktI7RIo0dksDziTAYBy7xlI7jU7eID5fxLF/25fS4Xj6GydD8Y5oWHL83U4NK160QaOAxtyg==", "cpu": [ "x64" ], @@ -1955,9 +1311,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-gnueabihf": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.44.0.tgz", - "integrity": "sha512-n+A/u/ByK1qV8FVGOwyaSpw5NPNl0qlZfgTBqHeGIqr8Qzq1tyWZ4lAaxPoe5mZqE3w88vn3+jZtMxriHPE7tg==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.48.0.tgz", + "integrity": "sha512-hn4i0zhAyTiB3ZHjQfYUZkDvrbVkohw1S7pySWxWUoZ87HnkDoTFThj7QTxk40hNPOTUP0vHbPRNamFIv1HBJQ==", "cpu": [ "arm" ], @@ -1972,9 +1328,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm-musleabihf": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.44.0.tgz", - "integrity": "sha512-5eax+FkxyCqAi3Rw0mrZFr7+KTt/XweFsbALR+B5ljWBLBl8nHe4ADrUnb1gLEfQCJLl+Ca5FIVD4xEt95AwIw==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.48.0.tgz", + "integrity": "sha512-R4WBD9qF3QM9hqgdAa+fBGXmquTvDUujrPQ36t2Sjk8RPOSKGHDeN7l/khr10hqbQaOq9KCgPHG9ubNET/X/RQ==", "cpu": [ "arm" ], @@ -1989,9 +1345,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-gnu": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.44.0.tgz", - "integrity": "sha512-58l8JaHxSGOmOMOG2CIrNsnkRJAj0YcHQCmvNACniOa/vd1iRHhlPajczegzS5jwMENlqgreyiTR9iNlke8qCw==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.48.0.tgz", + "integrity": "sha512-5bVdwSwlm1M8wbYCorLOxWxUBw/8tBvHYyQNIfwWVPwOJaj5vg1APSGJQVpwJfV5VNE9PSrR91UKEpoNwHhqUA==", "cpu": [ "arm64" ], @@ -2006,9 +1362,9 @@ } }, "node_modules/@oxfmt/binding-linux-arm64-musl": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.44.0.tgz", - "integrity": "sha512-AlObQIXyVRZ96LbtVljtFq0JqH5B92NU+BQeDFrXWBUWlCKAM0wF5GLfIhCLT5kQ3Sl+U0YjRJ7Alqj5hGQaCg==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.48.0.tgz", + "integrity": "sha512-vCS3Fk7gFslTqE1lUE2IlroyVV7u/9SmMA/uBqDoshuck2psGWcjW0ePyPZI3rM3+qtf2pDaMVIKMHozraifuw==", "cpu": [ "arm64" ], @@ -2023,9 +1379,9 @@ } }, "node_modules/@oxfmt/binding-linux-ppc64-gnu": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.44.0.tgz", - "integrity": "sha512-YcFE8/q/BbrCiIiM5piwbkA6GwJc5QqhMQp2yDrqQ2fuVkZ7CInb1aIijZ/k8EXc72qXMSwKpVlBv1w/MsGO/A==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.48.0.tgz", + "integrity": "sha512-gKtfFfueUClXDumyoHUbymqRf7prHejOOyzJK0eIJn93GF9JBdFHdo60TM1ZBHxkEwZvjuOgHmKtneKbEOc/Eg==", "cpu": [ "ppc64" ], @@ -2040,9 +1396,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-gnu": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.44.0.tgz", - "integrity": "sha512-eOdzs6RqkRzuqNHUX5C8ISN5xfGh4xDww8OEd9YAmc3OWN8oAe5bmlIqQ+rrHLpv58/0BuU48bxkhnIGjA/ATQ==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.48.0.tgz", + "integrity": "sha512-SYt0UhOvZD/UwZz9sXq6J2uAw8o24f5VZpLB2DH01f6MevshmlgakQlZe2lwek2sZJkd07eLu7mZa0g7yeiw7Q==", "cpu": [ "riscv64" ], @@ -2057,9 +1413,9 @@ } }, "node_modules/@oxfmt/binding-linux-riscv64-musl": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.44.0.tgz", - "integrity": "sha512-YBgNTxntD/QvlFUfgvh8bEdwOhXiquX8gaofZJAwYa/Xp1S1DQrFVZEeck7GFktr24DztsSp8N8WtWCBwxs0Hw==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.48.0.tgz", + "integrity": "sha512-JLbrwck2AopG4ud/XklZO5N+qxGC7cS7ROvXZVNfx0MCLDDL2kGOLvzuWORkVjnjAM0CMAfIMU2zNBtQbM+4dw==", "cpu": [ "riscv64" ], @@ -2074,9 +1430,9 @@ } }, "node_modules/@oxfmt/binding-linux-s390x-gnu": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.44.0.tgz", - "integrity": "sha512-GLIh1R6WHWshl/i4QQDNgj0WtT25aRO4HNUWEoitxiywyRdhTFmFEYT2rXlcl9U6/26vhmOqG5cRlMLG3ocaIA==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.48.0.tgz", + "integrity": "sha512-mdxt5L8OQLxkQH+JVpdC/lknZNe0lX4hlO3d8+xvw2wToo+iDrid9tiGOd5bmHfUVd5wVhrUry0qlu5vq66NkQ==", "cpu": [ "s390x" ], @@ -2091,9 +1447,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-gnu": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.44.0.tgz", - "integrity": "sha512-gZOpgTlOsLcLfAF9qgpTr7FIIFSKnQN3hDf/0JvQ4CIwMY7h+eilNjxq/CorqvYcEOu+LRt1W4ZS7KccEHLOdA==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.48.0.tgz", + "integrity": "sha512-oEz1BQwMrV7OMEFx/3VPDU3n9TM0AnxpktDYXjEg5i6nTX87wo18wSfBvkl4tzAICdKtoAQAdBIl7Y7hsPlx5w==", "cpu": [ "x64" ], @@ -2108,9 +1464,9 @@ } }, "node_modules/@oxfmt/binding-linux-x64-musl": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.44.0.tgz", - "integrity": "sha512-1CyS9JTB+pCUFYFI6pkQGGZaT/AY5gnhHVrQQLhFba6idP9AzVYm1xbdWfywoldTYvjxQJV6x4SuduCIfP3W+A==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.48.0.tgz", + "integrity": "sha512-g2SKTTurP5mWjd8Ecait0erYqmltL4IqW1EwttM25BxM6NiTt4ubobJYMR1uox1V2QgG4UfHH10CGRvWlUixjw==", "cpu": [ "x64" ], @@ -2125,9 +1481,9 @@ } }, "node_modules/@oxfmt/binding-openharmony-arm64": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.44.0.tgz", - "integrity": "sha512-bmEv70Ak6jLr1xotCbF5TxIKjsmQaiX+jFRtnGtfA03tJPf6VG3cKh96S21boAt3JZc+Vjx8PYcDuLj39vM2Pw==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.48.0.tgz", + "integrity": "sha512-CIg24VgheEpvolHL2gQuax5qcQ602bRMHrJ9g8XsQr3iVj9aSPgopigBKuMqrXsupwkrU+RQCn5cG8PgFntR6w==", "cpu": [ "arm64" ], @@ -2142,9 +1498,9 @@ } }, "node_modules/@oxfmt/binding-win32-arm64-msvc": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.44.0.tgz", - "integrity": "sha512-yWzB+oCpSnP/dmw85eFLAT5o35Ve5pkGS2uF/UCISpIwDqf1xa7OpmtomiqY/Vzg8VyvMbuf6vroF2khF/+1Vg==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.48.0.tgz", + "integrity": "sha512-zeaWkcxcEULwkGF3I/HgEvcDPN8buYDrxibBUa/IFh5Vmwyge+KpLO+hEwSovW349H0O/C0Z2kaFmEzEDm00/Q==", "cpu": [ "arm64" ], @@ -2159,9 +1515,9 @@ } }, "node_modules/@oxfmt/binding-win32-ia32-msvc": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.44.0.tgz", - "integrity": "sha512-TcWpo18xEIE3AmIG2kpr3kz5IEhQgnx0lazl2+8L+3eTopOAUevQcmlr4nhguImNWz0OMeOZrYZOhJNCf16nlQ==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.48.0.tgz", + "integrity": "sha512-yiEKnIAGvx5CyZQOlMaNlZkAbwT7/Quk0j3WLt+PR5hK+qYjPTRRJYDfD77wCBPLvEYAG41v4KG3iL0H+uxoxg==", "cpu": [ "ia32" ], @@ -2176,9 +1532,9 @@ } }, "node_modules/@oxfmt/binding-win32-x64-msvc": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.44.0.tgz", - "integrity": "sha512-oj8aLkPJZppIM4CMQNsyir9ybM1Xw/CfGPTSsTnzpVGyljgfbdP0EVUlURiGM0BDrmw5psQ6ArmGCcUY/yABaQ==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.48.0.tgz", + "integrity": "sha512-GSD2+7t2UoVMV2NgxXypa4bKewflPMAjYnF0Xw9/ht82ZfafAHhb8STwrEd7wlH2PFogt5zw3WVCxYJaHUdbeQ==", "cpu": [ "x64" ], @@ -3085,23 +2441,6 @@ "node": ">=20" } }, - "node_modules/@shikijs/rehype": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/rehype/-/rehype-4.0.2.tgz", - "integrity": "sha512-cmPlKLD8JeojasNFoY64162ScpEdEdQUMuVodPCrv1nx1z3bjmGwoKWDruQWa/ejSznImlaeB0Ty6Q3zPaVQAA==", - "license": "MIT", - "dependencies": { - "@shikijs/types": "4.0.2", - "@types/hast": "^3.0.4", - "hast-util-to-string": "^3.0.1", - "shiki": "4.0.2", - "unified": "^11.0.5", - "unist-util-visit": "^5.1.0" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/@shikijs/themes": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.0.2.tgz", @@ -3114,19 +2453,6 @@ "node": ">=20" } }, - "node_modules/@shikijs/transformers": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@shikijs/transformers/-/transformers-4.0.2.tgz", - "integrity": "sha512-1+L0gf9v+SdDXs08vjaLb3mBFa8U7u37cwcBQIv/HCocLwX69Tt6LpUCjtB+UUTvQxI7BnjZKhN/wMjhHBcJGg==", - "license": "MIT", - "dependencies": { - "@shikijs/core": "4.0.2", - "@shikijs/types": "4.0.2" - }, - "engines": { - "node": ">=20" - } - }, "node_modules/@shikijs/types": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.0.2.tgz", @@ -3162,9 +2488,9 @@ } }, "node_modules/@tailwindcss/node": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz", - "integrity": "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.4.tgz", + "integrity": "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA==", "dev": true, "license": "MIT", "dependencies": { @@ -3174,37 +2500,37 @@ "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", - "tailwindcss": "4.2.2" + "tailwindcss": "4.2.4" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.2.tgz", - "integrity": "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg==", - "dev": true, + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.2.4.tgz", + "integrity": "sha512-9El/iI069DKDSXwTvB9J4BwdO5JhRrOweGaK25taBAvBXyXqJAX+Jqdvs8r8gKpsI/1m0LeJLyQYTf/WLrBT1Q==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 20" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-arm64": "4.2.2", - "@tailwindcss/oxide-darwin-x64": "4.2.2", - "@tailwindcss/oxide-freebsd-x64": "4.2.2", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", - "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", - "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", - "@tailwindcss/oxide-linux-x64-musl": "4.2.2", - "@tailwindcss/oxide-wasm32-wasi": "4.2.2", - "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", - "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" + "@tailwindcss/oxide-android-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-arm64": "4.2.4", + "@tailwindcss/oxide-darwin-x64": "4.2.4", + "@tailwindcss/oxide-freebsd-x64": "4.2.4", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.4", + "@tailwindcss/oxide-linux-arm64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-arm64-musl": "4.2.4", + "@tailwindcss/oxide-linux-x64-gnu": "4.2.4", + "@tailwindcss/oxide-linux-x64-musl": "4.2.4", + "@tailwindcss/oxide-wasm32-wasi": "4.2.4", + "@tailwindcss/oxide-win32-arm64-msvc": "4.2.4", + "@tailwindcss/oxide-win32-x64-msvc": "4.2.4" } }, "node_modules/@tailwindcss/oxide-android-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.2.tgz", - "integrity": "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.2.4.tgz", + "integrity": "sha512-e7MOr1SAn9U8KlZzPi1ZXGZHeC5anY36qjNwmZv9pOJ8E4Q6jmD1vyEHkQFmNOIN7twGPEMXRHmitN4zCMN03g==", "cpu": [ "arm64" ], @@ -3219,9 +2545,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.2.tgz", - "integrity": "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.2.4.tgz", + "integrity": "sha512-tSC/Kbqpz/5/o/C2sG7QvOxAKqyd10bq+ypZNf+9Fi2TvbVbv1zNpcEptcsU7DPROaSbVgUXmrzKhurFvo5eDg==", "cpu": [ "arm64" ], @@ -3236,9 +2562,9 @@ } }, "node_modules/@tailwindcss/oxide-darwin-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.2.tgz", - "integrity": "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.2.4.tgz", + "integrity": "sha512-yPyUXn3yO/ufR6+Kzv0t4fCg2qNr90jxXc5QqBpjlPNd0NqyDXcmQb/6weunH/MEDXW5dhyEi+agTDiqa3WsGg==", "cpu": [ "x64" ], @@ -3253,9 +2579,9 @@ } }, "node_modules/@tailwindcss/oxide-freebsd-x64": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.2.tgz", - "integrity": "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.2.4.tgz", + "integrity": "sha512-BoMIB4vMQtZsXdGLVc2z+P9DbETkiopogfWZKbWwM8b/1Vinbs4YcUwo+kM/KeLkX3Ygrf4/PsRndKaYhS8Eiw==", "cpu": [ "x64" ], @@ -3270,9 +2596,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.2.tgz", - "integrity": "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.2.4.tgz", + "integrity": "sha512-7pIHBLTHYRAlS7V22JNuTh33yLH4VElwKtB3bwchK/UaKUPpQ0lPQiOWcbm4V3WP2I6fNIJ23vABIvoy2izdwA==", "cpu": [ "arm" ], @@ -3287,9 +2613,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.2.tgz", - "integrity": "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.2.4.tgz", + "integrity": "sha512-+E4wxJ0ZGOzSH325reXTWB48l42i93kQqMvDyz5gqfRzRZ7faNhnmvlV4EPGJU3QJM/3Ab5jhJ5pCRUsKn6OQw==", "cpu": [ "arm64" ], @@ -3304,9 +2630,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-arm64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.2.tgz", - "integrity": "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.2.4.tgz", + "integrity": "sha512-bBADEGAbo4ASnppIziaQJelekCxdMaxisrk+fB7Thit72IBnALp9K6ffA2G4ruj90G9XRS2VQ6q2bCKbfFV82g==", "cpu": [ "arm64" ], @@ -3321,9 +2647,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-gnu": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.2.tgz", - "integrity": "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.2.4.tgz", + "integrity": "sha512-7Mx25E4WTfnht0TVRTyC00j3i0M+EeFe7wguMDTlX4mRxafznw0CA8WJkFjWYH5BlgELd1kSjuU2JiPnNZbJDA==", "cpu": [ "x64" ], @@ -3338,9 +2664,9 @@ } }, "node_modules/@tailwindcss/oxide-linux-x64-musl": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.2.tgz", - "integrity": "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.2.4.tgz", + "integrity": "sha512-2wwJRF7nyhOR0hhHoChc04xngV3iS+akccHTGtz965FwF0up4b2lOdo6kI1EbDaEXKgvcrFBYcYQQ/rrnWFVfA==", "cpu": [ "x64" ], @@ -3355,9 +2681,9 @@ } }, "node_modules/@tailwindcss/oxide-wasm32-wasi": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.2.tgz", - "integrity": "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.2.4.tgz", + "integrity": "sha512-FQsqApeor8Fo6gUEklzmaa9994orJZZDBAlQpK2Mq+DslRKFJeD6AjHpBQ0kZFQohVr8o85PPh8eOy86VlSCmw==", "bundleDependencies": [ "@napi-rs/wasm-runtime", "@emnapi/core", @@ -3385,9 +2711,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", - "integrity": "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.4.tgz", + "integrity": "sha512-L9BXqxC4ToVgwMFqj3pmZRqyHEztulpUJzCxUtLjobMCzTPsGt1Fa9enKbOpY2iIyVtaHNeNvAK8ERP/64sqGQ==", "cpu": [ "arm64" ], @@ -3402,9 +2728,9 @@ } }, "node_modules/@tailwindcss/oxide-win32-x64-msvc": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.2.tgz", - "integrity": "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.2.4.tgz", + "integrity": "sha512-ESlKG0EpVJQwRjXDDa9rLvhEAh0mhP1sF7sap9dNZT0yyl9SAG6T7gdP09EH0vIv0UNTlo6jPWyujD6559fZvw==", "cpu": [ "x64" ], @@ -3419,27 +2745,17 @@ } }, "node_modules/@tailwindcss/postcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.2.tgz", - "integrity": "sha512-n4goKQbW8RVXIbNKRB/45LzyUqN451deQK0nzIeauVEqjlI49slUlgKYJM2QyUzap/PcpnS7kzSUmPb1sCRvYQ==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.2.4.tgz", + "integrity": "sha512-wgAVj6nUWAolAu8YFvzT2cTBIElWHkjZwFYovF+xsqKsW2ADxM/X2opxj5NsF/qVccAOjRNe8X2IdPzMsWyHTg==", "dev": true, "license": "MIT", "dependencies": { "@alloc/quick-lru": "^5.2.0", - "@tailwindcss/node": "4.2.2", - "@tailwindcss/oxide": "4.2.2", + "@tailwindcss/node": "4.2.4", + "@tailwindcss/oxide": "4.2.4", "postcss": "^8.5.6", - "tailwindcss": "4.2.2" - } - }, - "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" + "tailwindcss": "4.2.4" } }, "node_modules/@types/debug": { @@ -3497,13 +2813,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.5.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.2.tgz", - "integrity": "sha512-tO4ZIRKNC+MDWV4qKVZe3Ql/woTnmHDr5JD8UI5hn2pwBrHEwOEMZK7WlNb5RKB6EoJ02gwmQS9OrjuFnZYdpg==", + "version": "25.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz", + "integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.19.0" } }, "node_modules/@types/react": { @@ -3587,9 +2903,9 @@ } }, "node_modules/autoprefixer": { - "version": "10.4.27", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.27.tgz", - "integrity": "sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", "dev": true, "funding": [ { @@ -3607,8 +2923,8 @@ ], "license": "MIT", "dependencies": { - "browserslist": "^4.28.1", - "caniuse-lite": "^1.0.30001774", + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", "fraction.js": "^5.3.4", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" @@ -3634,18 +2950,21 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.9.19", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", - "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "version": "2.10.27", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz", + "integrity": "sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==", "license": "Apache-2.0", "bin": { - "baseline-browser-mapping": "dist/cli.js" + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", "dev": true, "funding": [ { @@ -3663,11 +2982,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" @@ -3814,18 +3133,6 @@ "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", "license": "MIT" }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -3902,21 +3209,21 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.286", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", - "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "version": "1.5.351", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.351.tgz", + "integrity": "sha512-9D7Iqx8RImSvCnOsj86rCH6eQjZFQoM04Jn6HnZVM0Nu/G58/gmKYQ1d12MZTbjQbQSTGI8nwEy07ErsA2slLA==", "dev": true, "license": "ISC" }, "node_modules/enhanced-resolve": { - "version": "5.20.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", - "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.0.tgz", + "integrity": "sha512-otxSQPw4lkOZWkHpB3zaEQs6gWYEsmX4xQF68ElXC/TWvGxGMSGOvoNbaLXm6/cS/fSfHtsEdw90y20PCd+sCA==", "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", - "tapable": "^2.3.0" + "tapable": "^2.3.3" }, "engines": { "node": ">=10.13.0" @@ -3967,9 +3274,9 @@ } }, "node_modules/esbuild": { - "version": "0.27.3", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", - "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "version": "0.28.0", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.0.tgz", + "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -3979,32 +3286,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.3", - "@esbuild/android-arm": "0.27.3", - "@esbuild/android-arm64": "0.27.3", - "@esbuild/android-x64": "0.27.3", - "@esbuild/darwin-arm64": "0.27.3", - "@esbuild/darwin-x64": "0.27.3", - "@esbuild/freebsd-arm64": "0.27.3", - "@esbuild/freebsd-x64": "0.27.3", - "@esbuild/linux-arm": "0.27.3", - "@esbuild/linux-arm64": "0.27.3", - "@esbuild/linux-ia32": "0.27.3", - "@esbuild/linux-loong64": "0.27.3", - "@esbuild/linux-mips64el": "0.27.3", - "@esbuild/linux-ppc64": "0.27.3", - "@esbuild/linux-riscv64": "0.27.3", - "@esbuild/linux-s390x": "0.27.3", - "@esbuild/linux-x64": "0.27.3", - "@esbuild/netbsd-arm64": "0.27.3", - "@esbuild/netbsd-x64": "0.27.3", - "@esbuild/openbsd-arm64": "0.27.3", - "@esbuild/openbsd-x64": "0.27.3", - "@esbuild/openharmony-arm64": "0.27.3", - "@esbuild/sunos-x64": "0.27.3", - "@esbuild/win32-arm64": "0.27.3", - "@esbuild/win32-ia32": "0.27.3", - "@esbuild/win32-x64": "0.27.3" + "@esbuild/aix-ppc64": "0.28.0", + "@esbuild/android-arm": "0.28.0", + "@esbuild/android-arm64": "0.28.0", + "@esbuild/android-x64": "0.28.0", + "@esbuild/darwin-arm64": "0.28.0", + "@esbuild/darwin-x64": "0.28.0", + "@esbuild/freebsd-arm64": "0.28.0", + "@esbuild/freebsd-x64": "0.28.0", + "@esbuild/linux-arm": "0.28.0", + "@esbuild/linux-arm64": "0.28.0", + "@esbuild/linux-ia32": "0.28.0", + "@esbuild/linux-loong64": "0.28.0", + "@esbuild/linux-mips64el": "0.28.0", + "@esbuild/linux-ppc64": "0.28.0", + "@esbuild/linux-riscv64": "0.28.0", + "@esbuild/linux-s390x": "0.28.0", + "@esbuild/linux-x64": "0.28.0", + "@esbuild/netbsd-arm64": "0.28.0", + "@esbuild/netbsd-x64": "0.28.0", + "@esbuild/openbsd-arm64": "0.28.0", + "@esbuild/openbsd-x64": "0.28.0", + "@esbuild/openharmony-arm64": "0.28.0", + "@esbuild/sunos-x64": "0.28.0", + "@esbuild/win32-arm64": "0.28.0", + "@esbuild/win32-ia32": "0.28.0", + "@esbuild/win32-x64": "0.28.0" } }, "node_modules/escalade": { @@ -4196,41 +3503,20 @@ } } }, - "node_modules/fuma-cli": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/fuma-cli/-/fuma-cli-0.0.3.tgz", - "integrity": "sha512-DefY1l9+PdairAjOeMfHMCHwkP9kRo7pqqpqb2qFpjb3Vxa8XIpPVVrFGR+9eMfE7Bp6rxsg8NAE3XRPLMWiDA==", - "license": "MIT", - "dependencies": { - "magic-string": "^0.30.21", - "oxc-parser": "^0.124.0", - "oxc-resolver": "^11.19.1", - "package-manager-detector": "^1.6.0", - "picocolors": "^1.1.1", - "tinyexec": "^1.0.4", - "zod": "^4.3.6" - } - }, "node_modules/fumadocs-core": { - "version": "16.7.11", - "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.7.11.tgz", - "integrity": "sha512-v09UEizAi7NfqwYEq2hvDimicj6ZX7xBYcOjp3vGwXZr9Vn/S2bI76HM6YWr38DmIcj1ed8zFTf1lWJsJRYN+w==", + "version": "16.8.7", + "resolved": "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.8.7.tgz", + "integrity": "sha512-y7awKyp4PwA2fGCsa/9z7a6/SlhAIABV9B/kmUAy9NlM+eo1Lm3P0A+Bcs7R36YsVsJd9tqiVuExbpLQ7n01Jw==", "license": "MIT", "dependencies": { - "@formatjs/intl-localematcher": "^0.8.2", "@orama/orama": "^3.1.18", - "@shikijs/rehype": "^4.0.2", - "@shikijs/transformers": "^4.0.2", "estree-util-value-to-estree": "^3.5.0", "github-slugger": "^2.0.0", "hast-util-to-estree": "^3.1.3", "hast-util-to-jsx-runtime": "^2.3.6", - "image-size": "^2.0.2", + "js-yaml": "^4.1.1", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", - "negotiator": "^1.0.0", - "npm-to-yarn": "^3.0.1", - "path-to-regexp": "^8.4.2", "remark": "^15.0.1", "remark-gfm": "^4.0.1", "remark-rehype": "^11.1.2", @@ -4243,7 +3529,7 @@ }, "peerDependencies": { "@mdx-js/mdx": "*", - "@mixedbread/sdk": "^0.46.0", + "@mixedbread/sdk": "0.x.x", "@orama/core": "1.x.x", "@oramacloud/client": "2.x.x", "@tanstack/react-router": "1.x.x", @@ -4319,23 +3605,23 @@ } }, "node_modules/fumadocs-mdx": { - "version": "14.2.11", - "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-14.2.11.tgz", - "integrity": "sha512-j0gHKs45c62ARteE8/yBM2Nu2I8AE2Cs37ktPEdc/8EX7TL66XP74un5OpHp6itLyWTu8Jur0imOiiIDq8+rDg==", + "version": "14.3.2", + "resolved": "https://registry.npmjs.org/fumadocs-mdx/-/fumadocs-mdx-14.3.2.tgz", + "integrity": "sha512-73SoZkbUuqnD91G/0zBcaQdM1TMnYw5JJzKgkGvQTiZbtLQFuWTt8/uRqnzFMuNIUu/WY9Lo9d1iZ8G+jOVieA==", "license": "MIT", "dependencies": { "@mdx-js/mdx": "^3.1.1", "@standard-schema/spec": "^1.1.0", "chokidar": "^5.0.0", - "esbuild": "^0.27.3", + "esbuild": "^0.28.0", "estree-util-value-to-estree": "^3.5.0", "js-yaml": "^4.1.1", "mdast-util-mdx": "^3.0.0", "mdast-util-to-markdown": "^2.1.2", "picocolors": "^1.1.1", - "picomatch": "^4.0.3", - "tinyexec": "^1.0.4", - "tinyglobby": "^0.2.15", + "picomatch": "^4.0.4", + "tinyexec": "^1.1.1", + "tinyglobby": "^0.2.16", "unified": "^11.0.5", "unist-util-remove-position": "^5.0.0", "unist-util-visit": "^5.1.0", @@ -4346,20 +3632,16 @@ "fumadocs-mdx": "dist/bin.js" }, "peerDependencies": { - "@fumadocs/mdx-remote": "^1.4.0", "@types/mdast": "*", "@types/mdx": "*", "@types/react": "*", "fumadocs-core": "^15.0.0 || ^16.0.0", "mdast-util-directive": "*", "next": "^15.3.0 || ^16.0.0", - "react": "*", + "react": "^19.2.0", "vite": "6.x.x || 7.x.x || 8.x.x" }, "peerDependenciesMeta": { - "@fumadocs/mdx-remote": { - "optional": true - }, "@types/mdast": { "optional": true }, @@ -4384,12 +3666,12 @@ } }, "node_modules/fumadocs-ui": { - "version": "16.7.11", - "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.7.11.tgz", - "integrity": "sha512-vEo4bGuWhhM3BBX/vRYDSpF666WJ+EbPke50LgdAdPlQUstRsvkOjWkta/GA9Vggph+aPKSk0AK8isJiMu4t8Q==", + "version": "16.8.7", + "resolved": "https://registry.npmjs.org/fumadocs-ui/-/fumadocs-ui-16.8.7.tgz", + "integrity": "sha512-ICTA2y+R++f8UKF3RL6mT3jCk6l7F5wpfuPV5dc7VRWNGxeFeiEwHdqo6Zb51HTjt1jDc7PQ1/T8I+gwPniU8g==", "license": "MIT", "dependencies": { - "@fumadocs/tailwind": "0.0.3", + "@fumadocs/tailwind": "0.0.5", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -4401,14 +3683,13 @@ "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tabs": "^1.1.13", "class-variance-authority": "^0.7.1", - "fuma-cli": "^0.0.3", - "lucide-react": "^1.7.0", + "lucide-react": "^1.14.0", "motion": "^12.38.0", "next-themes": "^0.4.6", - "react-medium-image-zoom": "^5.4.3", "react-remove-scroll": "^2.7.2", "rehype-raw": "^7.0.0", "scroll-into-view-if-needed": "^3.1.0", + "shiki": "^4.0.2", "tailwind-merge": "^3.5.0", "unist-util-visit": "^5.1.0" }, @@ -4416,11 +3697,10 @@ "@takumi-rs/image-response": "*", "@types/mdx": "*", "@types/react": "*", - "fumadocs-core": "16.7.11", + "fumadocs-core": "16.8.7", "next": "16.x.x", "react": "^19.2.0", - "react-dom": "^19.2.0", - "shiki": "*" + "react-dom": "^19.2.0" }, "peerDependenciesMeta": { "@takumi-rs/image-response": { @@ -4434,21 +3714,9 @@ }, "next": { "optional": true - }, - "shiki": { - "optional": true } } }, - "node_modules/fumadocs-ui/node_modules/lucide-react": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.7.0.tgz", - "integrity": "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg==", - "license": "ISC", - "peerDependencies": { - "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, "node_modules/get-nonce": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", @@ -4626,19 +3894,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-to-string": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/hast-util-to-string/-/hast-util-to-string-3.0.1.tgz", - "integrity": "sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -4679,18 +3934,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/image-size": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz", - "integrity": "sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w==", - "license": "MIT", - "bin": { - "image-size": "bin/image-size.js" - }, - "engines": { - "node": ">=16.x" - } - }, "node_modules/inline-style-parser": { "version": "0.2.7", "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", @@ -4754,9 +3997,9 @@ } }, "node_modules/jiti": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", - "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { @@ -5046,10 +4289,20 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/lucide-react": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz", + "integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -6146,22 +5399,13 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, - "node_modules/negotiator": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", - "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/next": { - "version": "16.2.2", - "resolved": "https://registry.npmjs.org/next/-/next-16.2.2.tgz", - "integrity": "sha512-i6AJdyVa4oQjyvX/6GeER8dpY/xlIV+4NMv/svykcLtURJSy/WzDnnUk/TM4d0uewFHK7xSQz4TbIwPgjky+3A==", + "version": "16.2.5", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.5.tgz", + "integrity": "sha512-TkVTm9F2WEulkgGljm4wPwNgvCCWCVw6StUHsZb8WZpHFRjepoUWg3d7L4IMg7IyjcJ4Co9eVhpro8e8O+KarQ==", "license": "MIT", "dependencies": { - "@next/env": "16.2.2", + "@next/env": "16.2.5", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", @@ -6175,14 +5419,14 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.2.2", - "@next/swc-darwin-x64": "16.2.2", - "@next/swc-linux-arm64-gnu": "16.2.2", - "@next/swc-linux-arm64-musl": "16.2.2", - "@next/swc-linux-x64-gnu": "16.2.2", - "@next/swc-linux-x64-musl": "16.2.2", - "@next/swc-win32-arm64-msvc": "16.2.2", - "@next/swc-win32-x64-msvc": "16.2.2", + "@next/swc-darwin-arm64": "16.2.5", + "@next/swc-darwin-x64": "16.2.5", + "@next/swc-linux-arm64-gnu": "16.2.5", + "@next/swc-linux-arm64-musl": "16.2.5", + "@next/swc-linux-x64-gnu": "16.2.5", + "@next/swc-linux-x64-musl": "16.2.5", + "@next/swc-win32-arm64-msvc": "16.2.5", + "@next/swc-win32-x64-msvc": "16.2.5", "sharp": "^0.34.5" }, "peerDependencies": { @@ -6247,24 +5491,12 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", "dev": true, "license": "MIT" }, - "node_modules/npm-to-yarn": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/npm-to-yarn/-/npm-to-yarn-3.0.1.tgz", - "integrity": "sha512-tt6PvKu4WyzPwWUzy/hvPFqn+uwXO0K1ZHka8az3NnrhWJDmSqI8ncWq0fkL0k/lmmi5tAC11FXwXuh0rFbt1A==", - "license": "MIT", - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/nebrelbug/npm-to-yarn?sponsor=1" - } - }, "node_modules/oniguruma-parser": { "version": "0.12.1", "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.1.tgz", @@ -6282,78 +5514,10 @@ "regex-recursion": "^6.0.2" } }, - "node_modules/oxc-parser": { - "version": "0.124.0", - "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.124.0.tgz", - "integrity": "sha512-h07SFj/tp2U3cf3+LFX6MmOguQiM9ahwpGs0ZK5CGhgL8p4kk24etrJKsEzhXAvo7mfvoKTZooZ5MLKAPRmJ1g==", - "license": "MIT", - "dependencies": { - "@oxc-project/types": "^0.124.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/sponsors/Boshen" - }, - "optionalDependencies": { - "@oxc-parser/binding-android-arm-eabi": "0.124.0", - "@oxc-parser/binding-android-arm64": "0.124.0", - "@oxc-parser/binding-darwin-arm64": "0.124.0", - "@oxc-parser/binding-darwin-x64": "0.124.0", - "@oxc-parser/binding-freebsd-x64": "0.124.0", - "@oxc-parser/binding-linux-arm-gnueabihf": "0.124.0", - "@oxc-parser/binding-linux-arm-musleabihf": "0.124.0", - "@oxc-parser/binding-linux-arm64-gnu": "0.124.0", - "@oxc-parser/binding-linux-arm64-musl": "0.124.0", - "@oxc-parser/binding-linux-ppc64-gnu": "0.124.0", - "@oxc-parser/binding-linux-riscv64-gnu": "0.124.0", - "@oxc-parser/binding-linux-riscv64-musl": "0.124.0", - "@oxc-parser/binding-linux-s390x-gnu": "0.124.0", - "@oxc-parser/binding-linux-x64-gnu": "0.124.0", - "@oxc-parser/binding-linux-x64-musl": "0.124.0", - "@oxc-parser/binding-openharmony-arm64": "0.124.0", - "@oxc-parser/binding-wasm32-wasi": "0.124.0", - "@oxc-parser/binding-win32-arm64-msvc": "0.124.0", - "@oxc-parser/binding-win32-ia32-msvc": "0.124.0", - "@oxc-parser/binding-win32-x64-msvc": "0.124.0" - } - }, - "node_modules/oxc-resolver": { - "version": "11.19.1", - "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.19.1.tgz", - "integrity": "sha512-qE/CIg/spwrTBFt5aKmwe3ifeDdLfA2NESN30E42X/lII5ClF8V7Wt6WIJhcGZjp0/Q+nQ+9vgxGk//xZNX2hg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - }, - "optionalDependencies": { - "@oxc-resolver/binding-android-arm-eabi": "11.19.1", - "@oxc-resolver/binding-android-arm64": "11.19.1", - "@oxc-resolver/binding-darwin-arm64": "11.19.1", - "@oxc-resolver/binding-darwin-x64": "11.19.1", - "@oxc-resolver/binding-freebsd-x64": "11.19.1", - "@oxc-resolver/binding-linux-arm-gnueabihf": "11.19.1", - "@oxc-resolver/binding-linux-arm-musleabihf": "11.19.1", - "@oxc-resolver/binding-linux-arm64-gnu": "11.19.1", - "@oxc-resolver/binding-linux-arm64-musl": "11.19.1", - "@oxc-resolver/binding-linux-ppc64-gnu": "11.19.1", - "@oxc-resolver/binding-linux-riscv64-gnu": "11.19.1", - "@oxc-resolver/binding-linux-riscv64-musl": "11.19.1", - "@oxc-resolver/binding-linux-s390x-gnu": "11.19.1", - "@oxc-resolver/binding-linux-x64-gnu": "11.19.1", - "@oxc-resolver/binding-linux-x64-musl": "11.19.1", - "@oxc-resolver/binding-openharmony-arm64": "11.19.1", - "@oxc-resolver/binding-wasm32-wasi": "11.19.1", - "@oxc-resolver/binding-win32-arm64-msvc": "11.19.1", - "@oxc-resolver/binding-win32-ia32-msvc": "11.19.1", - "@oxc-resolver/binding-win32-x64-msvc": "11.19.1" - } - }, "node_modules/oxfmt": { - "version": "0.44.0", - "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.44.0.tgz", - "integrity": "sha512-lnncqvHewyRvaqdrnntVIrZV2tEddz8lbvPsQzG/zlkfvgZkwy0HP1p/2u1aCDToeg1jb9zBpbJdfkV73Itw+w==", + "version": "0.48.0", + "resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.48.0.tgz", + "integrity": "sha512-AVaLh+7XeGx+R1zfFV+f6VV61nT2MWVJXVUDhbTm5LBWGyNt64xAyh3NYYyjeY2WykNt9AvqSQLPHcbWquYF9g==", "dev": true, "license": "MIT", "dependencies": { @@ -6369,32 +5533,26 @@ "url": "https://github.com/sponsors/Boshen" }, "optionalDependencies": { - "@oxfmt/binding-android-arm-eabi": "0.44.0", - "@oxfmt/binding-android-arm64": "0.44.0", - "@oxfmt/binding-darwin-arm64": "0.44.0", - "@oxfmt/binding-darwin-x64": "0.44.0", - "@oxfmt/binding-freebsd-x64": "0.44.0", - "@oxfmt/binding-linux-arm-gnueabihf": "0.44.0", - "@oxfmt/binding-linux-arm-musleabihf": "0.44.0", - "@oxfmt/binding-linux-arm64-gnu": "0.44.0", - "@oxfmt/binding-linux-arm64-musl": "0.44.0", - "@oxfmt/binding-linux-ppc64-gnu": "0.44.0", - "@oxfmt/binding-linux-riscv64-gnu": "0.44.0", - "@oxfmt/binding-linux-riscv64-musl": "0.44.0", - "@oxfmt/binding-linux-s390x-gnu": "0.44.0", - "@oxfmt/binding-linux-x64-gnu": "0.44.0", - "@oxfmt/binding-linux-x64-musl": "0.44.0", - "@oxfmt/binding-openharmony-arm64": "0.44.0", - "@oxfmt/binding-win32-arm64-msvc": "0.44.0", - "@oxfmt/binding-win32-ia32-msvc": "0.44.0", - "@oxfmt/binding-win32-x64-msvc": "0.44.0" - } - }, - "node_modules/package-manager-detector": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz", - "integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA==", - "license": "MIT" + "@oxfmt/binding-android-arm-eabi": "0.48.0", + "@oxfmt/binding-android-arm64": "0.48.0", + "@oxfmt/binding-darwin-arm64": "0.48.0", + "@oxfmt/binding-darwin-x64": "0.48.0", + "@oxfmt/binding-freebsd-x64": "0.48.0", + "@oxfmt/binding-linux-arm-gnueabihf": "0.48.0", + "@oxfmt/binding-linux-arm-musleabihf": "0.48.0", + "@oxfmt/binding-linux-arm64-gnu": "0.48.0", + "@oxfmt/binding-linux-arm64-musl": "0.48.0", + "@oxfmt/binding-linux-ppc64-gnu": "0.48.0", + "@oxfmt/binding-linux-riscv64-gnu": "0.48.0", + "@oxfmt/binding-linux-riscv64-musl": "0.48.0", + "@oxfmt/binding-linux-s390x-gnu": "0.48.0", + "@oxfmt/binding-linux-x64-gnu": "0.48.0", + "@oxfmt/binding-linux-x64-musl": "0.48.0", + "@oxfmt/binding-openharmony-arm64": "0.48.0", + "@oxfmt/binding-win32-arm64-msvc": "0.48.0", + "@oxfmt/binding-win32-ia32-msvc": "0.48.0", + "@oxfmt/binding-win32-x64-msvc": "0.48.0" + } }, "node_modules/parse-entities": { "version": "4.0.2", @@ -6433,16 +5591,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/path-to-regexp": { - "version": "8.4.2", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", - "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -6462,9 +5610,9 @@ } }, "node_modules/postcss": { - "version": "8.5.9", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz", - "integrity": "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw==", + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", "dev": true, "funding": [ { @@ -6490,19 +5638,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-selector-parser": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz", - "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -6521,40 +5656,24 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz", + "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.6", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz", + "integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" - } - }, - "node_modules/react-medium-image-zoom": { - "version": "5.4.3", - "resolved": "https://registry.npmjs.org/react-medium-image-zoom/-/react-medium-image-zoom-5.4.3.tgz", - "integrity": "sha512-cDIwdn35fRUPsGnnj/cG6Pacll+z+Mfv6EWU2wDO5ngbZjg5uLRb2ZhEnh92ufbXCJDFvXHekb8G3+oKqUcv5g==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/rpearce" - } - ], - "license": "BSD-3-Clause", - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^19.2.6" } }, "node_modules/react-remove-scroll": { @@ -6949,9 +6068,9 @@ } }, "node_modules/simple-icons": { - "version": "16.15.0", - "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.15.0.tgz", - "integrity": "sha512-hOyY4Cdvh1D/FJa1Qx4nTvypCT2BoI3jpc4xjxVgwVh1Hmd9mnqBqBTziDytCj2f5UOAXCfdnwODiNv710aqkQ==", + "version": "16.18.1", + "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.18.1.tgz", + "integrity": "sha512-+AS16pmdVHFdKrzYuTGfNGW6RRJ7eubpRhh2wipqPD5nglXKKIbAoEFhdxuweR1AV63+TuLXVJ+NEqlJpXXa2A==", "funding": [ { "type": "opencollective", @@ -7051,9 +6170,9 @@ } }, "node_modules/swetrix": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/swetrix/-/swetrix-4.1.0.tgz", - "integrity": "sha512-TPOr14oYBt3LquPfWqBjRP6IhbQ+WOiz8ticORRvg4gECb3loFbHB455OUWcpXPCTaFi/E9s5TVgxgqIX+viHQ==", + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/swetrix/-/swetrix-4.2.0.tgz", + "integrity": "sha512-cM6XvE1SE60pZsPCoFWd0hLYHpJTGd1r6z1nnnnFnIewA1xEx31h3DzEMP9uJYHbqbZ6xZOFUrxlUSj+m+scFA==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/Swetrix" @@ -7070,16 +6189,16 @@ } }, "node_modules/tailwindcss": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz", - "integrity": "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q==", + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.4.tgz", + "integrity": "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA==", "devOptional": true, "license": "MIT" }, "node_modules/tapable": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.2.tgz", - "integrity": "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==", + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", "engines": { @@ -7166,9 +6285,9 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "version": "7.19.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz", + "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "dev": true, "license": "MIT" }, @@ -7360,12 +6479,6 @@ } } }, - "node_modules/util-deprecate": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", - "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", - "license": "MIT" - }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", diff --git a/docs/package.json b/docs/package.json index e5e3e7585..f328fb82c 100644 --- a/docs/package.json +++ b/docs/package.json @@ -14,25 +14,25 @@ "@radix-ui/react-popover": "^1.1.15", "@types/mdx": "^2.0.13", "class-variance-authority": "^0.7.1", - "fumadocs-core": "^16.7.11", - "fumadocs-mdx": "^14.2.11", - "fumadocs-ui": "^16.7.11", - "next": "^16.2.2", - "react": "^19.2.4", - "react-dom": "^19.2.4", - "simple-icons": "^16.15.0", - "swetrix": "^4.1.0", + "fumadocs-core": "^16.8.7", + "fumadocs-mdx": "^14.3.2", + "fumadocs-ui": "^16.8.7", + "next": "^16.2.5", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "simple-icons": "^16.18.1", + "swetrix": "^4.2.0", "tailwind-merge": "^3.5.0" }, "devDependencies": { - "@tailwindcss/postcss": "^4.2.2", - "@types/node": "^25.5.2", + "@tailwindcss/postcss": "^4.2.4", + "@types/node": "^25.6.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", - "autoprefixer": "^10.4.27", - "oxfmt": "^0.44.0", - "postcss": "^8.5.9", - "tailwindcss": "^4.2.2", + "autoprefixer": "^10.5.0", + "oxfmt": "^0.48.0", + "postcss": "^8.5.14", + "tailwindcss": "^4.2.4", "typescript": "^5.9.3" } } From 3252e9390ba7a947518030296710df3a468a72be Mon Sep 17 00:00:00 2001 From: Blue Mouse Date: Wed, 6 May 2026 20:12:06 +0100 Subject: [PATCH 7/7] fix: harden experiment validation and exposure tracking --- README.md | 4 +- .../apps/community/src/experiment/bayesian.ts | 54 +++++++++---------- .../src/experiment/dto/experiment.dto.ts | 7 +++ .../src/experiment/experiment.controller.ts | 22 ++++---- .../src/experiment/experiment.service.ts | 3 ++ .../src/feature-flag/dto/feature-flag.dto.ts | 12 +++++ .../feature-flag/feature-flag.controller.ts | 19 ++++--- web/app/pages/Project/View/ViewProject.tsx | 12 +++-- 8 files changed, 80 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 25de46710..17eb85623 100644 --- a/README.md +++ b/README.md @@ -57,7 +57,7 @@ We've been building Swetrix since 2021 with a goal to make web analytics simple - **Data portability**: export to CSV and access data via our [developer API](https://docs.swetrix.com/statistics-api). - **Alerts & notifications (Cloud)**: get notified on thresholds via Email, Slack, Telegram, Discord, generic outbound webhook or browser web push, with per-alert custom message templates. - **Feature flags**: manage feature rollouts and conduct safe releases. -- **Experiments**: run A/B tests and experiments to optimize your site. +- **Experiments (Cloud)**: run A/B tests and experiments to optimize your site. - **Revenue analytics (Cloud)**: track MRR, churn and other financial metrics. - **Ask AI (Cloud)**: chat with your data to uncover insights. - **Goals**: track specific conversion goals and objectives. @@ -85,7 +85,7 @@ Cloud vs Community Edition | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Infrastructure management** | ✅ It's easy to get started with Swetrix Cloud - you can set up tracking in a matter of minutes. We manage server maintenance, upgrades, security for you. | ⚠️ You are responsible for managing servers, installs, upgrades, scaling and backups. | | **Core analytics (traffic, events, sessions, funnels, performance, errors)** | ✅ Included | ✅ Included | -| **Advanced features (Revenue, Experiments, AI)** | ✅ Included | ⚠️ Not included | +| **Advanced features (Revenue, Experiments, AI)** | ✅ Included | ⚠️ Cloud only; not included in CE | | **Teams & sharing** | ✅ Organisations to manage multiple projects and users with permissions setup; invite people to your projects directly, or share a public or password protected link with people. | ⚠️ Only direct project invites, password protected links and public projects are supported. | | **Alerts & notifications** | ✅ Yes (Email, Slack, Telegram, Discord, webhook, web push) | ⚠️ Not included | | **Email reports** | ✅ Yes (weekly/monthly/quarterly) | ⚠️ Not included | diff --git a/backend/apps/community/src/experiment/bayesian.ts b/backend/apps/community/src/experiment/bayesian.ts index 70e13ab8b..cadb5c63b 100644 --- a/backend/apps/community/src/experiment/bayesian.ts +++ b/backend/apps/community/src/experiment/bayesian.ts @@ -38,37 +38,35 @@ function seedFromVariants( } function sampleGamma(shape: number, random: RandomFn): number { - if (shape >= 1) { - const d = shape - 1 / 3 - const c = 1 / Math.sqrt(9 * d) - - while (true) { - let x: number - let v: number - - do { - const u1 = Math.max(random(), Number.EPSILON) - const u2 = random() - x = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2) - v = 1 + c * x - } while (v <= 0) - - v = v * v * v - const u = random() - - if (u < 1 - 0.0331 * x * x * x * x) { - return d * v - } + if (shape < 1) { + throw new RangeError('sampleGamma expects shape >= 1') + } - if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) { - return d * v - } + const d = shape - 1 / 3 + const c = 1 / Math.sqrt(9 * d) + + while (true) { + let x: number + let v: number + + do { + const u1 = Math.max(random(), Number.EPSILON) + const u2 = random() + x = Math.sqrt(-2 * Math.log(u1)) * Math.cos(2 * Math.PI * u2) + v = 1 + c * x + } while (v <= 0) + + v = v * v * v + const u = random() + + if (u < 1 - 0.0331 * x * x * x * x) { + return d * v } - } - const sample = sampleGamma(shape + 1, random) - const u = random() - return sample * Math.pow(u, 1 / shape) + if (Math.log(u) < 0.5 * x * x + d * (1 - v + Math.log(v))) { + return d * v + } + } } function sampleBeta(alpha: number, beta: number, random: RandomFn): number { diff --git a/backend/apps/community/src/experiment/dto/experiment.dto.ts b/backend/apps/community/src/experiment/dto/experiment.dto.ts index 4a8f7d422..26765117f 100644 --- a/backend/apps/community/src/experiment/dto/experiment.dto.ts +++ b/backend/apps/community/src/experiment/dto/experiment.dto.ts @@ -14,6 +14,7 @@ import { IsNotEmpty, Matches, ArrayMaxSize, + ArrayMinSize, } from 'class-validator' import { Type } from 'class-transformer' import { @@ -132,6 +133,9 @@ export class CreateExperimentDto { @ApiProperty({ type: [ExperimentVariantDto] }) @IsArray() + @ArrayMinSize(2, { + message: 'An experiment must have at least 2 variants', + }) @ArrayMaxSize(20, { message: 'An experiment cannot have more than 20 variants', }) @@ -209,6 +213,9 @@ export class UpdateExperimentDto { @ApiPropertyOptional({ type: [ExperimentVariantDto] }) @IsOptional() @IsArray() + @ArrayMinSize(2, { + message: 'An experiment must have at least 2 variants', + }) @ArrayMaxSize(20, { message: 'An experiment cannot have more than 20 variants', }) diff --git a/backend/apps/community/src/experiment/experiment.controller.ts b/backend/apps/community/src/experiment/experiment.controller.ts index 60e8718b5..c4599ab52 100644 --- a/backend/apps/community/src/experiment/experiment.controller.ts +++ b/backend/apps/community/src/experiment/experiment.controller.ts @@ -62,6 +62,7 @@ import { Pagination } from '../common/pagination/pagination' const EXPERIMENTS_MAXIMUM = 20 const FEATURE_FLAG_KEY_REGEX = /^[a-zA-Z0-9_-]+$/ +const ROLLOUT_PERCENTAGE_EPSILON = 1e-6 const validateUniqueVariantKeys = (variants: Array<{ key: string }>): void => { const seen = new Set() @@ -483,7 +484,7 @@ export class ExperimentController { const updatedExperiment = await this.experimentService.update(id, { status: ExperimentStatus.RUNNING, - startedAt: dayString(), + startedAt: experiment.startedAt ?? dayString(), featureFlagId, }) @@ -682,7 +683,10 @@ export class ExperimentController { FROM ( ${exposureAttributionSubquery} ) e - INNER JOIN events c ON e.pid = c.pid AND e.profileId = assumeNotNull(c.profileId) AND c.type = '${eventType}' + INNER JOIN events c ON e.pid = c.pid + AND c.profileId IS NOT NULL + AND e.profileId = c.profileId + AND c.type = '${eventType}' WHERE e.pid = {pid:FixedString(12)} AND c.created BETWEEN {groupFrom:String} AND {groupTo:String} @@ -865,7 +869,7 @@ export class ExperimentController { const totalPercentage = _sum( variants.map((variant) => variant.rolloutPercentage), ) - if (totalPercentage !== 100) { + if (Math.abs(totalPercentage - 100) > ROLLOUT_PERCENTAGE_EPSILON) { throw new BadRequestException( 'Variant rollout percentages must sum to 100', ) @@ -1061,12 +1065,7 @@ export class ExperimentController { } private getExposureAttributionSubquery(experiment: Experiment): string { - const variantSelector = - experiment.multipleVariantHandling === - MultipleVariantHandling.FIRST_EXPOSURE || - experiment.multipleVariantHandling === MultipleVariantHandling.EXCLUDE - ? 'argMin(variantKey, tuple(created, variantKey))' - : 'any(variantKey)' + const variantSelector = 'argMin(variantKey, tuple(created, variantKey))' const multiVariantFilter = experiment.multipleVariantHandling === MultipleVariantHandling.EXCLUDE ? 'HAVING uniqExact(variantKey) = 1' @@ -1169,7 +1168,10 @@ export class ExperimentController { FROM ( ${exposureAttributionSubquery} ) e - INNER JOIN events c ON e.pid = c.pid AND e.profileId = assumeNotNull(c.profileId) AND c.type = '${eventType}' + INNER JOIN events c ON e.pid = c.pid + AND c.profileId IS NOT NULL + AND e.profileId = c.profileId + AND c.type = '${eventType}' WHERE e.pid = {pid:FixedString(12)} AND c.created BETWEEN {groupFrom:String} AND {groupTo:String} diff --git a/backend/apps/community/src/experiment/experiment.service.ts b/backend/apps/community/src/experiment/experiment.service.ts index e88c86cdc..71dcef504 100644 --- a/backend/apps/community/src/experiment/experiment.service.ts +++ b/backend/apps/community/src/experiment/experiment.service.ts @@ -299,6 +299,7 @@ export class ExperimentService { } async findRunningByIds( + projectId: string, experimentIds: Array, exposureTrigger: ExposureTrigger, ): Promise { @@ -314,10 +315,12 @@ export class ExperimentService { SELECT * FROM experiment WHERE id IN {ids:Array(String)} + AND projectId = {projectId:FixedString(12)} AND status = {status:String} AND exposureTrigger = {exposureTrigger:String} `, query_params: { + projectId, ids, status: ExperimentStatus.RUNNING, exposureTrigger, diff --git a/backend/apps/community/src/feature-flag/dto/feature-flag.dto.ts b/backend/apps/community/src/feature-flag/dto/feature-flag.dto.ts index 8b4eae6cf..6237e8737 100644 --- a/backend/apps/community/src/feature-flag/dto/feature-flag.dto.ts +++ b/backend/apps/community/src/feature-flag/dto/feature-flag.dto.ts @@ -241,6 +241,18 @@ export class EvaluatedFlagsResponseDto { example: { 'new-checkout': true, 'dark-mode': false }, }) flags: Record + + @ApiPropertyOptional({ + description: 'Map of experiment IDs to assigned variant keys', + example: { '7df32d55-cabd-4e7d-b1ac-2cb0e85b5742': 'variant-a' }, + }) + experiments?: Record + + @ApiPropertyOptional({ + description: 'Map of feature flag keys to assigned experiment variant keys', + example: { 'new-checkout': 'variant-a' }, + }) + experimentsByFlag?: Record } class FeatureFlagProfileDto { diff --git a/backend/apps/community/src/feature-flag/feature-flag.controller.ts b/backend/apps/community/src/feature-flag/feature-flag.controller.ts index c58291992..36596ae30 100644 --- a/backend/apps/community/src/feature-flag/feature-flag.controller.ts +++ b/backend/apps/community/src/feature-flag/feature-flag.controller.ts @@ -320,6 +320,7 @@ export class FeatureFlagController { if (flagsWithExperiments.length > 0) { const experiments = await this.experimentService.findRunningByIds( + evaluateDto.pid, flagsWithExperiments.map((flag) => flag.experimentId), ExposureTrigger.FEATURE_FLAG, ) @@ -329,12 +330,9 @@ export class FeatureFlagController { continue } - const sortedVariants = [...experiment.variants].sort((a, b) => - a.key.localeCompare(b.key), - ) const variantKey = getExperimentVariant( experiment.id, - sortedVariants.map((variant) => ({ + experiment.variants.map((variant) => ({ key: variant.key, rolloutPercentage: variant.rolloutPercentage, })), @@ -360,26 +358,29 @@ export class FeatureFlagController { const response: { flags: Record experiments?: Record + experimentsByFlag?: Record } = { flags: evaluatedFlags, } if (experimentVariants.size > 0) { - const experimentsByIdOrFlagKey: Record = {} + const experiments: Record = {} + const experimentsByFlag: Record = {} for (const [experimentId, variantKey] of experimentVariants.entries()) { - experimentsByIdOrFlagKey[experimentId] = variantKey + experiments[experimentId] = variantKey const linkedFlags = flagsWithExperiments.filter( (flag) => flag.experimentId === experimentId, ) for (const linkedFlag of linkedFlags) { - experimentsByIdOrFlagKey[linkedFlag.key] = variantKey + experimentsByFlag[linkedFlag.key] = variantKey } } - response.experiments = experimentsByIdOrFlagKey + response.experiments = experiments + response.experimentsByFlag = experimentsByFlag } return response @@ -410,6 +411,7 @@ export class FeatureFlagController { table: 'feature_flag_evaluations', values, format: 'JSONEachRow', + clickhouse_settings: { async_insert: 1 }, }) } catch (err) { // Log error but don't fail the request @@ -432,6 +434,7 @@ export class FeatureFlagController { table: 'experiment_exposures', values: experimentExposures, format: 'JSONEachRow', + clickhouse_settings: { async_insert: 1 }, }) } catch (err) { this.logger.error({ err }, 'Failed to insert experiment exposures') diff --git a/web/app/pages/Project/View/ViewProject.tsx b/web/app/pages/Project/View/ViewProject.tsx index 076bab67e..70eae0f1a 100644 --- a/web/app/pages/Project/View/ViewProject.tsx +++ b/web/app/pages/Project/View/ViewProject.tsx @@ -775,11 +775,13 @@ const ViewProjectContent = () => { ] : [] - const experimentsTab = { - id: PROJECT_TABS.experiments, - label: t('dashboard.experiments'), - icon: FlaskIcon, - } + const experimentsTab = PROJECT_TABS.experiments + ? { + id: PROJECT_TABS.experiments, + label: t('dashboard.experiments'), + icon: FlaskIcon, + } + : null const newTabs = ( isSelfhosted