From ffc4a316a486519b0500a2c10446312649d8f92e Mon Sep 17 00:00:00 2001 From: Yos Riady Date: Wed, 29 Jul 2026 15:29:01 +0700 Subject: [PATCH] fix: reject empty filter memberships --- src/commands/analytics.ts | 4 ++++ src/commands/profiles.ts | 4 ++++ src/commands/segments.ts | 4 ++++ src/lib/filters.ts | 5 +++++ test/commands/analytics.test.ts | 22 ++++++++++++++++++++++ test/commands/bodyBuilders.test.ts | 8 ++++++++ test/commands/segments.test.ts | 9 +++++++++ 7 files changed, 56 insertions(+) diff --git a/src/commands/analytics.ts b/src/commands/analytics.ts index 2209e22..8fa2164 100644 --- a/src/commands/analytics.ts +++ b/src/commands/analytics.ts @@ -4,6 +4,7 @@ import { hasTinybirdMembershipDelimiter, isCanonicalFilterOperator, isCanonicalFilterValue, + isEmptyMembershipArray, isValuelessFilterOperator, } from '../lib/filters' import { parseJsonObject } from '../lib/json' @@ -87,6 +88,9 @@ function validateAnalyticsFilter( if (!isCanonicalFilterOperator(record.op)) { throw new Error(`${path} requires a canonical "op"`) } + if (isEmptyMembershipArray(record.value)) { + throw new Error(`${path}: membership arrays cannot be empty`) + } if ( !isValuelessFilterOperator(record.op) && (record.value === undefined || diff --git a/src/commands/profiles.ts b/src/commands/profiles.ts index 7214e8b..81b014f 100644 --- a/src/commands/profiles.ts +++ b/src/commands/profiles.ts @@ -2,6 +2,7 @@ import { Cli, z } from 'incur' import { createClient, requireApiKey } from '../lib/client' import { isCanonicalFilterOperator, + isEmptyMembershipArray, isValuelessFilterOperator, } from '../lib/filters' import { @@ -194,6 +195,9 @@ export function parseSearchFilters(raw: string): unknown[] { '--filters: each entry must use a canonical "op" (eq, neq, gt, lt, gte, lte, in, nin, startsWith, endsWith, contains, notEmpty, or isEmpty)', ) } + if (isEmptyMembershipArray(record.value)) { + throw new Error('--filters: membership arrays cannot be empty') + } if ( !isValuelessFilterOperator(record.op) && (record.value === undefined || record.value === null) diff --git a/src/commands/segments.ts b/src/commands/segments.ts index 1b21d8e..9d832e7 100644 --- a/src/commands/segments.ts +++ b/src/commands/segments.ts @@ -4,6 +4,7 @@ import { hasTinybirdMembershipDelimiter, isCanonicalFilterValue, isCanonicalFilterOperator, + isEmptyMembershipArray, isValuelessFilterOperator, } from '../lib/filters' import { parseJsonArray } from '../lib/json' @@ -70,6 +71,9 @@ export function buildCreateSegmentBody(options: CreateSegmentOptions) { if (!isCanonicalFilterOperator(record.op)) { throw new Error('--filters: each entry requires a canonical "op"') } + if (isEmptyMembershipArray(record.value)) { + throw new Error('--filters: membership arrays cannot be empty') + } if ( !isValuelessFilterOperator(record.op) && (record.value === undefined || diff --git a/src/lib/filters.ts b/src/lib/filters.ts index 3949cb8..c2b8c04 100644 --- a/src/lib/filters.ts +++ b/src/lib/filters.ts @@ -32,12 +32,17 @@ export function isCanonicalFilterValue(value: unknown): boolean { typeof value === 'number' || typeof value === 'boolean' || (Array.isArray(value) && + value.length > 0 && value.every( (item) => typeof item === 'string' || typeof item === 'number', )) ) } +export function isEmptyMembershipArray(value: unknown): boolean { + return Array.isArray(value) && value.length === 0 +} + export function hasTinybirdMembershipDelimiter(value: unknown): boolean { return ( Array.isArray(value) && diff --git a/test/commands/analytics.test.ts b/test/commands/analytics.test.ts index 3947465..9bbedde 100644 --- a/test/commands/analytics.test.ts +++ b/test/commands/analytics.test.ts @@ -119,6 +119,28 @@ describe('commands/analytics', function () { ).to.throw(/cannot contain "\|"/); }); + it('rejects empty membership arrays, including nested filters', function () { + expect(() => + buildAnalyticsParams({ + filters: JSON.stringify([ + { field: 'browser', op: 'in', value: [] }, + ]), + }), + ).to.throw(/membership arrays cannot be empty/); + expect(() => + buildAnalyticsParams({ + filters: JSON.stringify([ + { + field: 'event', + op: 'eq', + value: 'purchase', + filters: [{ field: 'sku', op: 'nin', value: [] }], + }, + ]), + }), + ).to.throw(/membership arrays cannot be empty/); + }); + it('merges primitive params through unchanged', function () { const params = buildAnalyticsParams({ params: '{"limit":10,"group_by":"device"}', diff --git a/test/commands/bodyBuilders.test.ts b/test/commands/bodyBuilders.test.ts index b3b2bbb..3bc69ce 100644 --- a/test/commands/bodyBuilders.test.ts +++ b/test/commands/bodyBuilders.test.ts @@ -455,6 +455,14 @@ describe('commands / body builders', function () { parseSearchFilters('[{"field":"users.browser","op":"notEmpty"}]'), ).to.not.throw(); }); + + it('rejects empty membership arrays', function () { + expect(() => + parseSearchFilters( + '[{"field":"users.browser","op":"in","value":[]}]', + ), + ).to.throw(/membership arrays cannot be empty/); + }); }); // ── Input validation guards ── diff --git a/test/commands/segments.test.ts b/test/commands/segments.test.ts index 12ab658..767f5c4 100644 --- a/test/commands/segments.test.ts +++ b/test/commands/segments.test.ts @@ -25,5 +25,14 @@ describe('commands/segments', function () { expect(() => createSegmentRun({ title: 'x', filters: '5' })).to.throw(/filters/); expect(() => createSegmentRun({ title: 'x', filters: '"foo"' })).to.throw(/filters/); }); + + it('rejects empty membership arrays', function () { + expect(() => + createSegmentRun({ + title: 'x', + filters: '[{"field":"browser","op":"in","value":[]}]', + }), + ).to.throw(/membership arrays cannot be empty/); + }); }); });