@terreno/feature-flags 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,391 @@
1
+ "use strict";
2
+ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
3
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
4
+ return new (P || (P = Promise))(function (resolve, reject) {
5
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
6
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
7
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
8
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
9
+ });
10
+ };
11
+ var __generator = (this && this.__generator) || function (thisArg, body) {
12
+ var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
13
+ return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
14
+ function verb(n) { return function (v) { return step([n, v]); }; }
15
+ function step(op) {
16
+ if (f) throw new TypeError("Generator is already executing.");
17
+ while (g && (g = 0, op[0] && (_ = 0)), _) try {
18
+ if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
19
+ if (y = 0, t) op = [op[0] & 2, t.value];
20
+ switch (op[0]) {
21
+ case 0: case 1: t = op; break;
22
+ case 4: _.label++; return { value: op[1], done: false };
23
+ case 5: _.label++; y = op[1]; op = [0]; continue;
24
+ case 7: op = _.ops.pop(); _.trys.pop(); continue;
25
+ default:
26
+ if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
27
+ if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
28
+ if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
29
+ if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
30
+ if (t[2]) _.ops.pop();
31
+ _.trys.pop(); continue;
32
+ }
33
+ op = body.call(thisArg, _);
34
+ } catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
35
+ if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
36
+ }
37
+ };
38
+ var __importDefault = (this && this.__importDefault) || function (mod) {
39
+ return (mod && mod.__esModule) ? mod : { "default": mod };
40
+ };
41
+ Object.defineProperty(exports, "__esModule", { value: true });
42
+ var bun_test_1 = require("bun:test");
43
+ var api_1 = require("@terreno/api");
44
+ var tests_1 = require("@terreno/api/src/tests");
45
+ var supertest_1 = __importDefault(require("supertest"));
46
+ var featureFlagModel_1 = require("../featureFlagModel");
47
+ var featureFlagsApp_1 = require("../featureFlagsApp");
48
+ var buildApp = function (options) {
49
+ var app = (0, tests_1.getBaseServer)();
50
+ (0, api_1.setupAuth)(app, tests_1.UserModel);
51
+ (0, api_1.addAuthRoutes)(app, tests_1.UserModel);
52
+ var plugin = new featureFlagsApp_1.FeatureFlagsApp(options);
53
+ plugin.register(app);
54
+ app.use(api_1.apiUnauthorizedMiddleware);
55
+ app.use(api_1.apiErrorMiddleware);
56
+ return app;
57
+ };
58
+ (0, bun_test_1.describe)("FeatureFlagsApp", function () {
59
+ var app;
60
+ var adminAgent;
61
+ var notAdminAgent;
62
+ (0, bun_test_1.beforeEach)(function () { return __awaiter(void 0, void 0, void 0, function () {
63
+ return __generator(this, function (_a) {
64
+ switch (_a.label) {
65
+ case 0: return [4 /*yield*/, (0, tests_1.setupDb)()];
66
+ case 1:
67
+ _a.sent();
68
+ return [4 /*yield*/, featureFlagModel_1.FeatureFlag.deleteMany({})];
69
+ case 2:
70
+ _a.sent();
71
+ return [2 /*return*/];
72
+ }
73
+ });
74
+ }); });
75
+ (0, bun_test_1.afterEach)(function () { return __awaiter(void 0, void 0, void 0, function () {
76
+ return __generator(this, function (_a) {
77
+ switch (_a.label) {
78
+ case 0: return [4 /*yield*/, featureFlagModel_1.FeatureFlag.deleteMany({})];
79
+ case 1:
80
+ _a.sent();
81
+ return [2 /*return*/];
82
+ }
83
+ });
84
+ }); });
85
+ (0, bun_test_1.describe)("construction", function () {
86
+ (0, bun_test_1.it)("defaults basePath to /feature-flags and segments to an empty object", function () { return __awaiter(void 0, void 0, void 0, function () {
87
+ var res;
88
+ return __generator(this, function (_a) {
89
+ switch (_a.label) {
90
+ case 0:
91
+ app = buildApp();
92
+ return [4 /*yield*/, (0, tests_1.authAsUser)(app, "admin")];
93
+ case 1:
94
+ adminAgent = _a.sent();
95
+ return [4 /*yield*/, adminAgent.get("/feature-flags/segments").expect(200)];
96
+ case 2:
97
+ res = _a.sent();
98
+ (0, bun_test_1.expect)(res.body.data).toEqual([]);
99
+ return [2 /*return*/];
100
+ }
101
+ });
102
+ }); });
103
+ (0, bun_test_1.it)("respects a custom basePath", function () { return __awaiter(void 0, void 0, void 0, function () {
104
+ return __generator(this, function (_a) {
105
+ switch (_a.label) {
106
+ case 0:
107
+ app = buildApp({ basePath: "/flags" });
108
+ return [4 /*yield*/, (0, tests_1.authAsUser)(app, "admin")];
109
+ case 1:
110
+ adminAgent = _a.sent();
111
+ return [4 /*yield*/, adminAgent.get("/flags/segments").expect(200)];
112
+ case 2:
113
+ _a.sent();
114
+ return [4 /*yield*/, adminAgent.get("/feature-flags/segments").expect(404)];
115
+ case 3:
116
+ _a.sent();
117
+ return [2 /*return*/];
118
+ }
119
+ });
120
+ }); });
121
+ (0, bun_test_1.it)("accepts an empty options object", function () { return __awaiter(void 0, void 0, void 0, function () {
122
+ var res;
123
+ return __generator(this, function (_a) {
124
+ switch (_a.label) {
125
+ case 0:
126
+ app = buildApp({});
127
+ return [4 /*yield*/, (0, tests_1.authAsUser)(app, "admin")];
128
+ case 1:
129
+ adminAgent = _a.sent();
130
+ return [4 /*yield*/, adminAgent.get("/feature-flags/segments").expect(200)];
131
+ case 2:
132
+ res = _a.sent();
133
+ (0, bun_test_1.expect)(res.body.data).toEqual([]);
134
+ return [2 /*return*/];
135
+ }
136
+ });
137
+ }); });
138
+ });
139
+ (0, bun_test_1.describe)("admin CRUD routes", function () {
140
+ (0, bun_test_1.beforeEach)(function () { return __awaiter(void 0, void 0, void 0, function () {
141
+ return __generator(this, function (_a) {
142
+ switch (_a.label) {
143
+ case 0:
144
+ app = buildApp();
145
+ return [4 /*yield*/, (0, tests_1.authAsUser)(app, "admin")];
146
+ case 1:
147
+ adminAgent = _a.sent();
148
+ return [4 /*yield*/, (0, tests_1.authAsUser)(app, "notAdmin")];
149
+ case 2:
150
+ notAdminAgent = _a.sent();
151
+ return [2 /*return*/];
152
+ }
153
+ });
154
+ }); });
155
+ (0, bun_test_1.it)("lets admins create, list, read, update, and delete flags", function () { return __awaiter(void 0, void 0, void 0, function () {
156
+ var createRes, flagId, listRes, readRes, updateRes, finalList;
157
+ return __generator(this, function (_a) {
158
+ switch (_a.label) {
159
+ case 0: return [4 /*yield*/, adminAgent
160
+ .post("/feature-flags/flags")
161
+ .send({ enabled: true, key: "new-flag", name: "New Flag" })
162
+ .expect(201)];
163
+ case 1:
164
+ createRes = _a.sent();
165
+ flagId = createRes.body.data._id;
166
+ (0, bun_test_1.expect)(createRes.body.data.key).toBe("new-flag");
167
+ return [4 /*yield*/, adminAgent.get("/feature-flags/flags").expect(200)];
168
+ case 2:
169
+ listRes = _a.sent();
170
+ (0, bun_test_1.expect)(listRes.body.data.length).toBe(1);
171
+ return [4 /*yield*/, adminAgent.get("/feature-flags/flags/".concat(flagId)).expect(200)];
172
+ case 3:
173
+ readRes = _a.sent();
174
+ (0, bun_test_1.expect)(readRes.body.data.key).toBe("new-flag");
175
+ return [4 /*yield*/, adminAgent
176
+ .patch("/feature-flags/flags/".concat(flagId))
177
+ .send({ rolloutPercentage: 50 })
178
+ .expect(200)];
179
+ case 4:
180
+ updateRes = _a.sent();
181
+ (0, bun_test_1.expect)(updateRes.body.data.rolloutPercentage).toBe(50);
182
+ return [4 /*yield*/, adminAgent.delete("/feature-flags/flags/".concat(flagId)).expect(204)];
183
+ case 5:
184
+ _a.sent();
185
+ return [4 /*yield*/, adminAgent.get("/feature-flags/flags").expect(200)];
186
+ case 6:
187
+ finalList = _a.sent();
188
+ (0, bun_test_1.expect)(finalList.body.data.length).toBe(0);
189
+ return [2 /*return*/];
190
+ }
191
+ });
192
+ }); });
193
+ (0, bun_test_1.it)("rejects non-admins from the CRUD routes", function () { return __awaiter(void 0, void 0, void 0, function () {
194
+ return __generator(this, function (_a) {
195
+ switch (_a.label) {
196
+ case 0:
197
+ // modelRouter responds with 405 Method Not Allowed when permissions fail
198
+ return [4 /*yield*/, notAdminAgent.get("/feature-flags/flags").expect(405)];
199
+ case 1:
200
+ // modelRouter responds with 405 Method Not Allowed when permissions fail
201
+ _a.sent();
202
+ return [4 /*yield*/, notAdminAgent
203
+ .post("/feature-flags/flags")
204
+ .send({ key: "nope", name: "Nope" })
205
+ .expect(405)];
206
+ case 2:
207
+ _a.sent();
208
+ return [2 /*return*/];
209
+ }
210
+ });
211
+ }); });
212
+ (0, bun_test_1.it)("rejects unauthenticated requests to CRUD routes", function () { return __awaiter(void 0, void 0, void 0, function () {
213
+ var unauth;
214
+ return __generator(this, function (_a) {
215
+ switch (_a.label) {
216
+ case 0:
217
+ unauth = (0, supertest_1.default)(app);
218
+ return [4 /*yield*/, unauth.get("/feature-flags/flags").expect(401)];
219
+ case 1:
220
+ _a.sent();
221
+ return [2 /*return*/];
222
+ }
223
+ });
224
+ }); });
225
+ });
226
+ (0, bun_test_1.describe)("GET /feature-flags/evaluate", function () {
227
+ (0, bun_test_1.beforeEach)(function () { return __awaiter(void 0, void 0, void 0, function () {
228
+ var segments;
229
+ return __generator(this, function (_a) {
230
+ segments = {
231
+ admins: function (user) { return user.admin === true; },
232
+ };
233
+ app = buildApp({ segments: segments });
234
+ return [2 /*return*/];
235
+ });
236
+ }); });
237
+ (0, bun_test_1.it)("returns 401 for unauthenticated callers", function () { return __awaiter(void 0, void 0, void 0, function () {
238
+ var unauth;
239
+ return __generator(this, function (_a) {
240
+ switch (_a.label) {
241
+ case 0:
242
+ unauth = (0, supertest_1.default)(app);
243
+ return [4 /*yield*/, unauth.get("/feature-flags/evaluate").expect(401)];
244
+ case 1:
245
+ _a.sent();
246
+ return [2 /*return*/];
247
+ }
248
+ });
249
+ }); });
250
+ (0, bun_test_1.it)("returns all enabled, non-archived flag values for the current user", function () { return __awaiter(void 0, void 0, void 0, function () {
251
+ var res;
252
+ return __generator(this, function (_a) {
253
+ switch (_a.label) {
254
+ case 0: return [4 /*yield*/, featureFlagModel_1.FeatureFlag.create({
255
+ enabled: true,
256
+ key: "rolled-out",
257
+ name: "Rolled out",
258
+ rolloutPercentage: 100,
259
+ })];
260
+ case 1:
261
+ _a.sent();
262
+ return [4 /*yield*/, featureFlagModel_1.FeatureFlag.create({
263
+ enabled: false,
264
+ key: "disabled-flag",
265
+ name: "Disabled",
266
+ rolloutPercentage: 100,
267
+ })];
268
+ case 2:
269
+ _a.sent();
270
+ return [4 /*yield*/, featureFlagModel_1.FeatureFlag.create({
271
+ archived: true,
272
+ enabled: true,
273
+ key: "archived-flag",
274
+ name: "Archived",
275
+ rolloutPercentage: 100,
276
+ })];
277
+ case 3:
278
+ _a.sent();
279
+ return [4 /*yield*/, featureFlagModel_1.FeatureFlag.create({
280
+ enabled: true,
281
+ key: "admins-only",
282
+ name: "Admins",
283
+ rolloutPercentage: 0,
284
+ rules: [{ enabled: true, segment: "admins" }],
285
+ })];
286
+ case 4:
287
+ _a.sent();
288
+ return [4 /*yield*/, (0, tests_1.authAsUser)(app, "admin")];
289
+ case 5:
290
+ adminAgent = _a.sent();
291
+ return [4 /*yield*/, adminAgent.get("/feature-flags/evaluate").expect(200)];
292
+ case 6:
293
+ res = _a.sent();
294
+ (0, bun_test_1.expect)(res.body.data["rolled-out"]).toBe(true);
295
+ (0, bun_test_1.expect)(res.body.data["admins-only"]).toBe(true);
296
+ (0, bun_test_1.expect)(res.body.data["disabled-flag"]).toBeUndefined();
297
+ (0, bun_test_1.expect)(res.body.data["archived-flag"]).toBeUndefined();
298
+ return [2 /*return*/];
299
+ }
300
+ });
301
+ }); });
302
+ (0, bun_test_1.it)("includes an admin's own evaluated flags", function () { return __awaiter(void 0, void 0, void 0, function () {
303
+ var res, notAdminRes;
304
+ return __generator(this, function (_a) {
305
+ switch (_a.label) {
306
+ case 0: return [4 /*yield*/, featureFlagModel_1.FeatureFlag.create({
307
+ enabled: true,
308
+ key: "user-only",
309
+ name: "User only",
310
+ rolloutPercentage: 0,
311
+ rules: [{ enabled: true, field: "admin", operator: "eq", value: true }],
312
+ })];
313
+ case 1:
314
+ _a.sent();
315
+ return [4 /*yield*/, (0, tests_1.authAsUser)(app, "admin")];
316
+ case 2:
317
+ adminAgent = _a.sent();
318
+ return [4 /*yield*/, adminAgent.get("/feature-flags/evaluate").expect(200)];
319
+ case 3:
320
+ res = _a.sent();
321
+ (0, bun_test_1.expect)(res.body.data["user-only"]).toBe(true);
322
+ return [4 /*yield*/, (0, tests_1.authAsUser)(app, "notAdmin")];
323
+ case 4:
324
+ notAdminAgent = _a.sent();
325
+ return [4 /*yield*/, notAdminAgent.get("/feature-flags/evaluate").expect(200)];
326
+ case 5:
327
+ notAdminRes = _a.sent();
328
+ // notAdmin isn't admin, rolloutPercentage is 0 → should evaluate false
329
+ (0, bun_test_1.expect)(notAdminRes.body.data["user-only"]).toBe(false);
330
+ return [2 /*return*/];
331
+ }
332
+ });
333
+ }); });
334
+ });
335
+ (0, bun_test_1.describe)("GET /feature-flags/segments", function () {
336
+ (0, bun_test_1.beforeEach)(function () { return __awaiter(void 0, void 0, void 0, function () {
337
+ return __generator(this, function (_a) {
338
+ app = buildApp({
339
+ segments: {
340
+ admins: function () { return true; },
341
+ "beta-testers": function () { return false; },
342
+ },
343
+ });
344
+ return [2 /*return*/];
345
+ });
346
+ }); });
347
+ (0, bun_test_1.it)("returns the registered segment keys for admins", function () { return __awaiter(void 0, void 0, void 0, function () {
348
+ var res;
349
+ return __generator(this, function (_a) {
350
+ switch (_a.label) {
351
+ case 0: return [4 /*yield*/, (0, tests_1.authAsUser)(app, "admin")];
352
+ case 1:
353
+ adminAgent = _a.sent();
354
+ return [4 /*yield*/, adminAgent.get("/feature-flags/segments").expect(200)];
355
+ case 2:
356
+ res = _a.sent();
357
+ (0, bun_test_1.expect)(res.body.data.sort()).toEqual(["admins", "beta-testers"]);
358
+ return [2 /*return*/];
359
+ }
360
+ });
361
+ }); });
362
+ (0, bun_test_1.it)("rejects non-admin users", function () { return __awaiter(void 0, void 0, void 0, function () {
363
+ var res;
364
+ return __generator(this, function (_a) {
365
+ switch (_a.label) {
366
+ case 0: return [4 /*yield*/, (0, tests_1.authAsUser)(app, "notAdmin")];
367
+ case 1:
368
+ notAdminAgent = _a.sent();
369
+ return [4 /*yield*/, notAdminAgent.get("/feature-flags/segments").expect(403)];
370
+ case 2:
371
+ res = _a.sent();
372
+ (0, bun_test_1.expect)(res.body.title).toInclude("Only admins can view segments");
373
+ return [2 /*return*/];
374
+ }
375
+ });
376
+ }); });
377
+ (0, bun_test_1.it)("requires authentication", function () { return __awaiter(void 0, void 0, void 0, function () {
378
+ var unauth;
379
+ return __generator(this, function (_a) {
380
+ switch (_a.label) {
381
+ case 0:
382
+ unauth = (0, supertest_1.default)(app);
383
+ return [4 /*yield*/, unauth.get("/feature-flags/segments").expect(401)];
384
+ case 1:
385
+ _a.sent();
386
+ return [2 /*return*/];
387
+ }
388
+ });
389
+ }); });
390
+ });
391
+ });
@@ -0,0 +1,36 @@
1
+ import type { FindExactlyOnePlugin, FindOneOrNonePlugin } from "@terreno/api";
2
+ import type mongoose from "mongoose";
3
+ export interface FeatureFlagVariant {
4
+ key: string;
5
+ weight: number;
6
+ }
7
+ export interface FeatureFlagRule {
8
+ field?: string;
9
+ operator?: "eq" | "neq" | "in" | "nin" | "gt" | "lt" | "contains";
10
+ value?: unknown;
11
+ segment?: string;
12
+ enabled?: boolean;
13
+ variant?: string;
14
+ }
15
+ export type FeatureFlagType = "boolean" | "variant";
16
+ export type FeatureFlagDocument = mongoose.Document<mongoose.Types.ObjectId> & {
17
+ key: string;
18
+ name: string;
19
+ description: string;
20
+ enabled: boolean;
21
+ type: FeatureFlagType;
22
+ variants: FeatureFlagVariant[];
23
+ rules: FeatureFlagRule[];
24
+ rolloutPercentage: number;
25
+ archived: boolean;
26
+ created: Date;
27
+ updated: Date;
28
+ deleted: boolean;
29
+ };
30
+ export type FeatureFlagModel = mongoose.Model<FeatureFlagDocument> & FindExactlyOnePlugin<FeatureFlagDocument> & FindOneOrNonePlugin<FeatureFlagDocument>;
31
+ export type SegmentFunction = (user: unknown) => boolean;
32
+ export interface FeatureFlagsOptions {
33
+ basePath?: string;
34
+ segments?: Record<string, SegmentFunction>;
35
+ }
36
+ export type EvaluationResult = Record<string, boolean | string | null>;
package/dist/types.js ADDED
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
package/package.json ADDED
@@ -0,0 +1,55 @@
1
+ {
2
+ "author": "Josh Gachnang",
3
+ "bugs": {
4
+ "url": "https://github.com/FlourishHealth/terreno/issues"
5
+ },
6
+ "dependencies": {
7
+ "@terreno/api": "^0.12.0",
8
+ "express": "^5.2.1",
9
+ "lodash": "^4.17.23"
10
+ },
11
+ "description": "Feature flags and A/B testing plugin for @terreno/api",
12
+ "devDependencies": {
13
+ "@biomejs/biome": "^2.3.6",
14
+ "@types/bun": "^1.2.4",
15
+ "@types/express": "^5.0.6",
16
+ "@types/lodash": "^4.17.17",
17
+ "@types/supertest": "^6.0.2",
18
+ "passport-local-mongoose": "^9.0.1",
19
+ "supertest": "^7.0.0",
20
+ "typescript": "~5.9.2"
21
+ },
22
+ "homepage": "https://github.com/FlourishHealth/terreno#readme",
23
+ "keywords": [
24
+ "feature-flags",
25
+ "ab-testing",
26
+ "api",
27
+ "express",
28
+ "terreno"
29
+ ],
30
+ "license": "Apache-2.0",
31
+ "peerDependencies": {
32
+ "mongoose": ">=8.0.0"
33
+ },
34
+ "main": "dist/index.js",
35
+ "name": "@terreno/feature-flags",
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "repository": {
40
+ "type": "git",
41
+ "url": "git+ssh://git@github.com/FlourishHealth/terreno.git"
42
+ },
43
+ "scripts": {
44
+ "compile": "bun tsc",
45
+ "compile:watch": "bun tsc -w",
46
+ "dev": "bun tsc -w",
47
+ "lint": "biome check ./src",
48
+ "lint:fix": "biome check --write ./src",
49
+ "test": "bun test",
50
+ "test:ci": "bun test",
51
+ "test:coverage": "bun run ../scripts/check-coverage.ts"
52
+ },
53
+ "types": "dist/index.d.ts",
54
+ "version": "0.7.1"
55
+ }
@@ -0,0 +1,195 @@
1
+ import {logger} from "@terreno/api";
2
+ import get from "lodash/get";
3
+ import type {
4
+ EvaluationResult,
5
+ FeatureFlagDocument,
6
+ FeatureFlagRule,
7
+ SegmentFunction,
8
+ } from "./types";
9
+
10
+ const isDebugEnabled = (): boolean => process.env.FEATURE_FLAGS_DEBUG !== "false";
11
+
12
+ /**
13
+ * Deterministic hash of a string to a number between 0 and 99.
14
+ * Uses a simple but effective hash (djb2) to ensure the same user+flag
15
+ * combination always produces the same result.
16
+ */
17
+ export const deterministicHash = (input: string): number => {
18
+ let hash = 5381;
19
+ for (let i = 0; i < input.length; i++) {
20
+ hash = (hash * 33) ^ input.charCodeAt(i);
21
+ }
22
+ return Math.abs(hash) % 100;
23
+ };
24
+
25
+ const matchesFieldRule = (user: unknown, rule: FeatureFlagRule): boolean => {
26
+ if (!rule.field || !rule.operator) {
27
+ return false;
28
+ }
29
+
30
+ const fieldValue = get(user, rule.field);
31
+ const ruleValue = rule.value;
32
+ let result = false;
33
+
34
+ switch (rule.operator) {
35
+ case "eq":
36
+ result = ruleValue !== undefined && fieldValue === ruleValue;
37
+ break;
38
+ case "neq":
39
+ result = ruleValue !== undefined && fieldValue !== ruleValue;
40
+ break;
41
+ case "in":
42
+ result = Array.isArray(ruleValue) && ruleValue.includes(fieldValue);
43
+ break;
44
+ case "nin":
45
+ result = Array.isArray(ruleValue) && !ruleValue.includes(fieldValue);
46
+ break;
47
+ case "gt":
48
+ result =
49
+ typeof fieldValue === "number" && typeof ruleValue === "number" && fieldValue > ruleValue;
50
+ break;
51
+ case "lt":
52
+ result =
53
+ typeof fieldValue === "number" && typeof ruleValue === "number" && fieldValue < ruleValue;
54
+ break;
55
+ case "contains":
56
+ result =
57
+ typeof fieldValue === "string" &&
58
+ typeof ruleValue === "string" &&
59
+ fieldValue.includes(ruleValue);
60
+ break;
61
+ default:
62
+ break;
63
+ }
64
+
65
+ if (isDebugEnabled()) {
66
+ logger.info(
67
+ `[feature-flags] field rule: user.${rule.field}=${JSON.stringify(fieldValue)} ${rule.operator} ${JSON.stringify(ruleValue)} → ${result}`
68
+ );
69
+ }
70
+
71
+ return result;
72
+ };
73
+
74
+ const matchesRule = (
75
+ user: unknown,
76
+ rule: FeatureFlagRule,
77
+ segments: Record<string, SegmentFunction>
78
+ ): boolean => {
79
+ if (rule.segment) {
80
+ const segmentFn = segments[rule.segment];
81
+ if (!segmentFn) {
82
+ logger.warn(`Segment function not found: ${rule.segment}`);
83
+ return false;
84
+ }
85
+ try {
86
+ const result = segmentFn(user);
87
+ if (isDebugEnabled()) {
88
+ logger.info(`[feature-flags] segment rule: "${rule.segment}" → ${result}`);
89
+ }
90
+ return result;
91
+ } catch (err) {
92
+ logger.warn(`Segment function "${rule.segment}" threw: ${err}`);
93
+ return false;
94
+ }
95
+ }
96
+
97
+ return matchesFieldRule(user, rule);
98
+ };
99
+
100
+ /**
101
+ * Evaluate a single feature flag for a user.
102
+ * Returns boolean for boolean flags, string key for variant flags, or null for disabled variant flags.
103
+ */
104
+ export const evaluateFlag = (
105
+ flag: FeatureFlagDocument,
106
+ userId: string,
107
+ user: unknown,
108
+ segments: Record<string, SegmentFunction>
109
+ ): boolean | string | null => {
110
+ const debug = isDebugEnabled();
111
+
112
+ if (!flag.enabled) {
113
+ if (debug) {
114
+ logger.info(
115
+ `[feature-flags] "${flag.key}" is disabled → ${flag.type === "variant" ? "null" : "false"}`
116
+ );
117
+ }
118
+ return flag.type === "variant" ? null : false;
119
+ }
120
+
121
+ // Check rules in order — first match wins
122
+ for (let i = 0; i < flag.rules.length; i++) {
123
+ const rule = flag.rules[i];
124
+ const matched = matchesRule(user, rule, segments);
125
+ if (matched) {
126
+ const result = flag.type === "variant" ? (rule.variant ?? null) : (rule.enabled ?? false);
127
+ if (debug) {
128
+ logger.info(`[feature-flags] "${flag.key}" matched rule ${i} → ${JSON.stringify(result)}`);
129
+ }
130
+ return result;
131
+ }
132
+ }
133
+
134
+ // No rules matched — use deterministic hashing
135
+ const hash = deterministicHash(`${userId}:${flag.key}`);
136
+
137
+ if (flag.type === "boolean") {
138
+ const result = hash < flag.rolloutPercentage;
139
+ if (debug) {
140
+ logger.info(
141
+ `[feature-flags] "${flag.key}" no rules matched, hash=${hash} rollout=${flag.rolloutPercentage}% → ${result}`
142
+ );
143
+ }
144
+ return result;
145
+ }
146
+
147
+ // Variant assignment based on cumulative weights
148
+ let cumulativeWeight = 0;
149
+ for (const variant of flag.variants) {
150
+ cumulativeWeight += variant.weight;
151
+ if (hash < cumulativeWeight) {
152
+ if (debug) {
153
+ logger.info(
154
+ `[feature-flags] "${flag.key}" no rules matched, hash=${hash} → variant "${variant.key}"`
155
+ );
156
+ }
157
+ return variant.key;
158
+ }
159
+ }
160
+
161
+ // Fallback (shouldn't happen if weights sum to 100)
162
+ const fallback = flag.variants.length > 0 ? flag.variants[flag.variants.length - 1].key : null;
163
+ if (debug) {
164
+ logger.info(`[feature-flags] "${flag.key}" variant fallback → ${JSON.stringify(fallback)}`);
165
+ }
166
+ return fallback;
167
+ };
168
+
169
+ /**
170
+ * Evaluate all enabled, non-archived feature flags for a user.
171
+ * Returns a map of flag key to evaluation result.
172
+ */
173
+ export const evaluateAllFlags = async (
174
+ flagModel: typeof import("./featureFlagModel").FeatureFlag,
175
+ userId: string,
176
+ user: unknown,
177
+ segments: Record<string, SegmentFunction>
178
+ ): Promise<EvaluationResult> => {
179
+ const flags = await flagModel.find({archived: {$ne: true}, enabled: true});
180
+ const results: EvaluationResult = {};
181
+
182
+ if (isDebugEnabled()) {
183
+ logger.info(`[feature-flags] evaluating ${flags.length} flags for user ${userId}`);
184
+ }
185
+
186
+ for (const flag of flags) {
187
+ results[flag.key] = evaluateFlag(flag, userId, user, segments);
188
+ }
189
+
190
+ if (isDebugEnabled()) {
191
+ logger.info(`[feature-flags] results: ${JSON.stringify(results)}`);
192
+ }
193
+
194
+ return results;
195
+ };