Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

256 changes: 256 additions & 0 deletions packages/mixpanel/lib/flags/custom_operators.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
const jsonLogic = require("json-logic-js");

// Strict RFC3339 guard for datetime strings. The date and hour fields are captured so the calendar
// can be validated separately; the pattern only constrains their shape.
const RFC3339_PATTERN =
/^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/;

// SemVer 2.0.0 requires major.minor.patch; partial versions are zero-padded to this.
const SEMVER_PARTS = 3;

// Longest operand the semver regex is allowed to see. A real version never approaches this; the
// bound matches MAX_LENGTH in node-semver, and keeps an arbitrarily long property value off the
// regex regardless of how the engine schedules backtracking.
const MAX_SEMVER_LENGTH = 256;

// Epoch milliseconds are compared as int64 elsewhere, so anything at or beyond this is out of range.
const MAX_EPOCH_MS = 9223372036854775808;

// Using the official semantic versioning 2.0.0 regular expression to handle cross-platform validation
// differences on other SDK's. For example, some platforms allow leading zeros even though it is not valid
// as part of the Semver 2.0.0 spec. See https://semver.org/
const SEMVER_PATTERN =
/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;

jsonLogic.add_operation("semver_compare", semverCompare);
jsonLogic.add_operation("datetime_compare", datetimeCompare);

// Implements a custom operation for semantic versioning comparison that conforms to the semver 2.0.0 standard.
// Prior to comparison, any leading version prefix is stripped.
function semverCompare(actual, symbol, target) {
if (arguments.length !== 3) {
return false;
}
if (typeof actual !== "string" || typeof target !== "string") {
return false;
}
if (actual.length > MAX_SEMVER_LENGTH || target.length > MAX_SEMVER_LENGTH) {
return false;
}
const actualVersion = normalizeSemver(actual);
const targetVersion = normalizeSemver(target);
if (
!SEMVER_PATTERN.test(actualVersion) ||
!SEMVER_PATTERN.test(targetVersion)
) {
return false;
}
const cmp = compareSemver(actualVersion, targetVersion);
return comparatorMatches(cmp, symbol);
}

// Strip optional build metadata and separate the core version from pre-release identifiers
function splitSemver(version) {
const plus = version.indexOf("+");
if (plus !== -1) {
version = version.slice(0, plus);
}
const dash = version.indexOf("-");
if (dash === -1) {
return { core: version.split("."), prerelease: [] };
}
return {
core: version.slice(0, dash).split("."),
prerelease: version.slice(dash + 1).split("."),
};
}

function isNumericIdentifier(identifier) {
return /^[0-9]+$/.test(identifier);
}

// Numeric identifiers carry no leading zeros, so the longer run of digits is the larger number.
// Comparing them as digits rather than as numbers keeps versions past Number.MAX_SAFE_INTEGER
// ordered correctly.
function compareNumeric(a, b) {
if (a.length !== b.length) {
return a.length < b.length ? -1 : 1;
}
return a < b ? -1 : a > b ? 1 : 0;
}

// SemVer 2.0.0 section 11.4: digits compare numerically, a numeric identifier ranks below an
// alphanumeric one, and anything else compares by ASCII order.
function comparePrereleaseIdentifier(a, b) {
const aNumeric = isNumericIdentifier(a);
const bNumeric = isNumericIdentifier(b);
if (aNumeric && bNumeric) {
return compareNumeric(a, b);
}
if (aNumeric) {
return -1;
}
if (bNumeric) {
return 1;
}
return a < b ? -1 : a > b ? 1 : 0;
}

// Ordering per SemVer 2.0.0 section 11. Both operands have already been normalized and matched
// against the official regex, so the core holds exactly three numeric identifiers and every
// prerelease field is well-formed; the split needs no error path.
function compareSemver(actualVersion, targetVersion) {
const actual = splitSemver(actualVersion);
const target = splitSemver(targetVersion);

for (let i = 0; i < actual.core.length; i++) {
const result = compareNumeric(actual.core[i], target.core[i]);
if (result !== 0) {
return result;
}
}

// A prerelease ranks below the release it belongs to (section 11.3).
if (!actual.prerelease.length && !target.prerelease.length) {
return 0;
}
if (!actual.prerelease.length) {
return 1;
}
if (!target.prerelease.length) {
return -1;
}

const shared = Math.min(actual.prerelease.length, target.prerelease.length);
for (let i = 0; i < shared; i++) {
const result = comparePrereleaseIdentifier(
actual.prerelease[i],
target.prerelease[i],
);
if (result !== 0) {
return result;
}
}
// Every field so far is equal, so the longer list wins (section 11.4.4).
if (actual.prerelease.length !== target.prerelease.length) {
return actual.prerelease.length < target.prerelease.length ? -1 : 1;
}
return 0;
}

// Implements a custom operation for datetime comparison.
// The target value stored on the feature flag is the millisecond epoch, whereas the actual value provided at evaluation time must be RFC-3339 formatted.
function datetimeCompare(actual, symbol, target) {
if (arguments.length !== 3) {
return false;
}
const actualSec = convertRfc3339ToUnixSeconds(actual);
const targetSec = convertUnixMillisecondsToSeconds(target);
if (actualSec === null || targetSec === null) {
return false;
}
const cmp = actualSec - targetSec;
return comparatorMatches(cmp, symbol);
}

function comparatorMatches(cmp, symbol) {
switch (symbol) {
case "===":
return cmp === 0;
case "!==":
return cmp !== 0;
case "<":
return cmp < 0;
case "<=":
return cmp <= 0;
case ">":
return cmp > 0;
case ">=":
return cmp >= 0;
default:
return false;
}
}

function normalizeSemver(version) {
const stripped = version.trim().replace(/^[vV]/, "");

let suffixStart = stripped.length;
for (const separator of ["-", "+"]) {
const index = stripped.indexOf(separator);
if (index !== -1 && index < suffixStart) {
suffixStart = index;
}
}

const core = stripped.slice(0, suffixStart);
const suffix = stripped.slice(suffixStart);

const parts = core.split(".");
while (parts.length < SEMVER_PARTS) {
parts.push("0");
}
return parts.join(".") + suffix;
}

// The pattern constrains each field to two digits, which still admits a date that cannot exist, such
// as 2026-02-30 or 29 February in a common year. Writing the fields into a Date and reading them back
// settles it: out-of-range fields are normalized into a real instant, so a date that does not exist
// comes back carrying different fields than it went in with. The three-argument setUTCFullYear sets
// all three at once, which judges 29 February against the year given rather than a placeholder, and
// leaves years 0 through 99 alone where Date.UTC would map them into the 1900s. The hour is checked
// separately because it is not part of the round trip; RFC 3339 section 5.6 allows hours 00 through 23.
function isRealCalendarDate(year, month, day, hour) {
if (hour > 23) {
return false;
}
const dt = new Date();
dt.setUTCFullYear(year, month - 1, day);
return (
dt.getUTCFullYear() === year &&
dt.getUTCMonth() === month - 1 &&
dt.getUTCDate() === day
);
}

function convertRfc3339ToUnixSeconds(value) {
if (typeof value !== "string") {
return null;
}
const normalized = value.trim().toUpperCase();
const fields = RFC3339_PATTERN.exec(normalized);
if (!fields) {
return null;
}
const [, year, month, day, hour] = fields;
if (
!isRealCalendarDate(Number(year), Number(month), Number(day), Number(hour))
) {
return null;
}
const ms = Date.parse(normalized);
if (Number.isNaN(ms)) {
return null;
}
return Math.floor(ms / 1000);
}

function convertUnixMillisecondsToSeconds(value) {
if (typeof value !== "number" || !Number.isFinite(value)) {
return null;
}
// A value int64 cannot represent is not a real timestamp; treating one as a bound would let a
// nonsense target define a rollout window.
if (value >= MAX_EPOCH_MS || value <= -MAX_EPOCH_MS) {
return null;
}
return Math.trunc(value / 1000);
}

module.exports = {
comparatorMatches,
semverCompare,
convertRfc3339ToUnixSeconds,
convertUnixMillisecondsToSeconds,
datetimeCompare,
};
1 change: 1 addition & 0 deletions packages/mixpanel/lib/flags/local_flags.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const {
asFallback,
} = require("./variant_source");
const { apply } = require("json-logic-js");
require("./custom_operators");

class LocalFeatureFlagsProvider extends FeatureFlagsProvider {
/**
Expand Down
74 changes: 74 additions & 0 deletions packages/mixpanel/test/flags/custom_operators.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
const fs = require("fs");
const path = require("path");

const { apply } = require("json-logic-js");
// Requiring the module registers semver_compare and datetime_compare on the shared
// json-logic-js instance used by apply().
require("../../lib/flags/custom_operators");

// The golden vectors are the cross-SDK contract for the custom operators; the canonical copy and
// its README live in the analytics monorepo. Cases run through apply() so that operator
// registration is covered alongside the comparison itself.
const TEST_DATA = path.join(__dirname, "test-data");

// The property key the vectors are evaluated against. It is plumbing the test supplies, so any name
// works as long as the rule and the data agree on it.
const VECTOR_KEY = "value";

// Build the event the rule reads from, omitting the key entirely for an unset property.
function dataFor(subject) {
return subject === null ? {} : { [VECTOR_KEY]: subject };
}

// Read a golden-vector file. String entries are headings, array entries are cases.
function loadVectors(operator) {
const entries = JSON.parse(
fs.readFileSync(
path.join(TEST_DATA, `${operator}_compare_tests.json`),
"utf8",
),
);

let section = "";
const cases = [];
entries.forEach((entry, index) => {
if (typeof entry === "string") {
section = entry;
return;
}
const [subject, symbol, target, want] = entry;
const rule = {
[`${operator}_compare`]: [{ var: VECTOR_KEY }, symbol, target],
};
const name = `${index} ${section}: ${JSON.stringify(subject)} ${symbol} ${JSON.stringify(target)}`;
cases.push([name, rule, dataFor(subject), want]);
});
return cases;
}

describe("semver_compare operator", () => {
it.each(loadVectors("semver"))("%s", (_name, rule, data, want) => {
expect(apply(rule, data)).toBe(want);
});
});

describe("datetime_compare operator", () => {
it.each(loadVectors("datetime"))("%s", (_name, rule, data, want) => {
expect(apply(rule, data)).toBe(want);
});
});

// The cases below are not golden vectors. They pin behaviour the shared files cannot express: a
// rule shape the engine would never produce, and the difference between an absent property and one
// holding a null, which both fail closed.
describe("fail-closed guards", () => {
it("refuses a rule that is missing an operand", () => {
const rule = { datetime_compare: [{ var: "signup" }, "==="] };
expect(apply(rule, { signup: "2026-07-16T00:00:00Z" })).toBe(false);
});

it("omits the property for an unset subject", () => {
expect(dataFor(null)).toEqual({});
expect(dataFor("1.2.3")).toEqual({ [VECTOR_KEY]: "1.2.3" });
});
});
Loading
Loading