Skip to content

Commit a080d22

Browse files
feat(analytics): add feature flags service and requireFeatureFlag middleware
Adds a high-level FeatureFlagsService wrapping PostHog's isFeatureEnabled and getFeatureFlag with structured options (personProperties, groups, groupProperties) for local evaluation. Also adds requireFeatureFlag Express middleware factory that gates routes by feature flag with fail-open semantics when analytics is not configured. Closes #3297
1 parent 7e60bbb commit a080d22

4 files changed

Lines changed: 367 additions & 0 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import FeatureFlagsService from '../services/analytics.featureFlags.service.js';
5+
6+
import responses from '../../../lib/helpers/responses.js';
7+
8+
/**
9+
* Returns Express middleware that gates access based on a PostHog feature flag.
10+
*
11+
* The middleware evaluates the flag for the authenticated user, passing
12+
* organisation context when available. Behaviour by scenario:
13+
*
14+
* - Flag enabled -> next()
15+
* - Flag disabled -> 403
16+
* - Analytics not configured (no PostHog key) -> next() (fail-open so
17+
* projects not using PostHog are never blocked)
18+
*
19+
* @param {string} flagName - PostHog feature flag key
20+
* @returns {Function} Express middleware function
21+
*/
22+
function requireFeatureFlag(flagName) {
23+
return async function requireFeatureFlagMiddleware(req, res, next) {
24+
const distinctId = req.user?._id ? String(req.user._id) : undefined;
25+
if (!distinctId) {
26+
return responses.error(res, 401, 'Unauthorized', 'Authentication required to evaluate feature flag')();
27+
}
28+
29+
try {
30+
const options = {};
31+
if (req.organization?._id) {
32+
options.groups = { company: String(req.organization._id) };
33+
}
34+
35+
const enabled = await FeatureFlagsService.isEnabled(flagName, distinctId, options);
36+
37+
// isEnabled returns false both when the flag is off AND when analytics
38+
// is not configured. Distinguish by checking getVariant: undefined
39+
// means not configured (fail-open), while false/string means configured.
40+
if (!enabled) {
41+
const variant = await FeatureFlagsService.getVariant(flagName, distinctId, options);
42+
// undefined -> analytics not configured -> fail-open
43+
if (variant === undefined) return next();
44+
45+
return responses.error(res, 403, 'Forbidden', 'Feature not available on your current plan')({
46+
type: 'FEATURE_FLAG_DISABLED',
47+
flag: flagName,
48+
});
49+
}
50+
51+
return next();
52+
} catch (err) {
53+
return next(err);
54+
}
55+
};
56+
}
57+
58+
export default requireFeatureFlag;
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/**
2+
* Module dependencies
3+
*/
4+
import AnalyticsService from './analytics.service.js';
5+
6+
/**
7+
* Check whether a feature flag is enabled for a given user.
8+
* Builds the PostHog options object from the provided context so callers
9+
* don't need to know the PostHog SDK shape.
10+
*
11+
* Returns `false` when analytics is not configured (safe default) so that
12+
* downstream projects without PostHog are never blocked.
13+
*
14+
* @param {string} flag - Feature flag key
15+
* @param {string} distinctId - User identifier
16+
* @param {Object} [options] - Evaluation context
17+
* @param {Object} [options.personProperties] - Properties for local evaluation
18+
* @param {Object} [options.groups] - Group identifiers (e.g. { company: orgId })
19+
* @param {Object} [options.groupProperties] - Properties per group type
20+
* @returns {Promise<boolean>} true when the flag is enabled, false otherwise
21+
*/
22+
const isEnabled = async (flag, distinctId, options = {}) => {
23+
const { personProperties, groups, groupProperties } = options;
24+
const phOptions = {};
25+
if (personProperties) phOptions.personProperties = personProperties;
26+
if (groups) phOptions.groups = groups;
27+
if (groupProperties) phOptions.groupProperties = groupProperties;
28+
29+
const result = await AnalyticsService.isFeatureEnabled(flag, distinctId, phOptions);
30+
// Normalise undefined (not configured) to false for a safe default
31+
return result === true;
32+
};
33+
34+
/**
35+
* Get the variant value of a feature flag for a given user.
36+
* Returns the variant key string, a boolean, or `undefined` when analytics
37+
* is not configured.
38+
*
39+
* @param {string} flag - Feature flag key
40+
* @param {string} distinctId - User identifier
41+
* @param {Object} [options] - Evaluation context
42+
* @param {Object} [options.personProperties] - Properties for local evaluation
43+
* @param {Object} [options.groups] - Group identifiers (e.g. { company: orgId })
44+
* @param {Object} [options.groupProperties] - Properties per group type
45+
* @returns {Promise<string|boolean|undefined>} Variant value
46+
*/
47+
const getVariant = async (flag, distinctId, options = {}) => {
48+
const { personProperties, groups, groupProperties } = options;
49+
const phOptions = {};
50+
if (personProperties) phOptions.personProperties = personProperties;
51+
if (groups) phOptions.groups = groups;
52+
if (groupProperties) phOptions.groupProperties = groupProperties;
53+
54+
return AnalyticsService.getFeatureFlag(flag, distinctId, phOptions);
55+
};
56+
57+
export default {
58+
isEnabled,
59+
getVariant,
60+
};
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for analytics feature flags service
8+
*/
9+
describe('Analytics feature flags service unit tests:', () => {
10+
let FeatureFlagsService;
11+
let mockAnalyticsService;
12+
13+
beforeEach(async () => {
14+
jest.resetModules();
15+
16+
mockAnalyticsService = {
17+
isFeatureEnabled: jest.fn(),
18+
getFeatureFlag: jest.fn(),
19+
};
20+
21+
jest.unstable_mockModule('../services/analytics.service.js', () => ({
22+
default: mockAnalyticsService,
23+
}));
24+
25+
const mod = await import('../services/analytics.featureFlags.service.js');
26+
FeatureFlagsService = mod.default;
27+
});
28+
29+
afterEach(() => {
30+
jest.restoreAllMocks();
31+
});
32+
33+
describe('isEnabled', () => {
34+
test('should return true when flag is enabled', async () => {
35+
mockAnalyticsService.isFeatureEnabled.mockResolvedValue(true);
36+
37+
const result = await FeatureFlagsService.isEnabled('my-flag', 'user-1');
38+
39+
expect(result).toBe(true);
40+
expect(mockAnalyticsService.isFeatureEnabled).toHaveBeenCalledWith('my-flag', 'user-1', {});
41+
});
42+
43+
test('should return false when flag is disabled', async () => {
44+
mockAnalyticsService.isFeatureEnabled.mockResolvedValue(false);
45+
46+
const result = await FeatureFlagsService.isEnabled('my-flag', 'user-1');
47+
48+
expect(result).toBe(false);
49+
});
50+
51+
test('should return false when analytics is not configured (undefined)', async () => {
52+
mockAnalyticsService.isFeatureEnabled.mockResolvedValue(undefined);
53+
54+
const result = await FeatureFlagsService.isEnabled('my-flag', 'user-1');
55+
56+
expect(result).toBe(false);
57+
});
58+
59+
test('should pass personProperties and groups to analytics service', async () => {
60+
mockAnalyticsService.isFeatureEnabled.mockResolvedValue(true);
61+
62+
await FeatureFlagsService.isEnabled('my-flag', 'user-1', {
63+
personProperties: { plan: 'pro' },
64+
groups: { company: 'org-1' },
65+
groupProperties: { company: { plan: 'enterprise' } },
66+
});
67+
68+
expect(mockAnalyticsService.isFeatureEnabled).toHaveBeenCalledWith('my-flag', 'user-1', {
69+
personProperties: { plan: 'pro' },
70+
groups: { company: 'org-1' },
71+
groupProperties: { company: { plan: 'enterprise' } },
72+
});
73+
});
74+
75+
test('should omit undefined option keys from PostHog options', async () => {
76+
mockAnalyticsService.isFeatureEnabled.mockResolvedValue(false);
77+
78+
await FeatureFlagsService.isEnabled('my-flag', 'user-1', {});
79+
80+
expect(mockAnalyticsService.isFeatureEnabled).toHaveBeenCalledWith('my-flag', 'user-1', {});
81+
});
82+
});
83+
84+
describe('getVariant', () => {
85+
test('should return variant string when flag has multivariate value', async () => {
86+
mockAnalyticsService.getFeatureFlag.mockResolvedValue('variant-a');
87+
88+
const result = await FeatureFlagsService.getVariant('my-flag', 'user-1');
89+
90+
expect(result).toBe('variant-a');
91+
expect(mockAnalyticsService.getFeatureFlag).toHaveBeenCalledWith('my-flag', 'user-1', {});
92+
});
93+
94+
test('should return boolean when flag is a simple toggle', async () => {
95+
mockAnalyticsService.getFeatureFlag.mockResolvedValue(false);
96+
97+
const result = await FeatureFlagsService.getVariant('my-flag', 'user-1');
98+
99+
expect(result).toBe(false);
100+
});
101+
102+
test('should return undefined when analytics is not configured', async () => {
103+
mockAnalyticsService.getFeatureFlag.mockResolvedValue(undefined);
104+
105+
const result = await FeatureFlagsService.getVariant('my-flag', 'user-1');
106+
107+
expect(result).toBeUndefined();
108+
});
109+
110+
test('should pass personProperties, groups, and groupProperties', async () => {
111+
mockAnalyticsService.getFeatureFlag.mockResolvedValue('variant-b');
112+
113+
await FeatureFlagsService.getVariant('my-flag', 'user-1', {
114+
personProperties: { email: 'a@b.com' },
115+
groups: { company: 'org-1' },
116+
groupProperties: { company: { name: 'Acme' } },
117+
});
118+
119+
expect(mockAnalyticsService.getFeatureFlag).toHaveBeenCalledWith('my-flag', 'user-1', {
120+
personProperties: { email: 'a@b.com' },
121+
groups: { company: 'org-1' },
122+
groupProperties: { company: { name: 'Acme' } },
123+
});
124+
});
125+
});
126+
});
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/**
2+
* Module dependencies.
3+
*/
4+
import { jest, beforeEach, afterEach, describe, test, expect } from '@jest/globals';
5+
6+
/**
7+
* Unit tests for requireFeatureFlag middleware
8+
*/
9+
describe('requireFeatureFlag middleware unit tests:', () => {
10+
let requireFeatureFlag;
11+
let mockFeatureFlagsService;
12+
let req;
13+
let res;
14+
let next;
15+
16+
beforeEach(async () => {
17+
jest.resetModules();
18+
19+
mockFeatureFlagsService = {
20+
isEnabled: jest.fn(),
21+
getVariant: jest.fn(),
22+
};
23+
24+
jest.unstable_mockModule('../services/analytics.featureFlags.service.js', () => ({
25+
default: mockFeatureFlagsService,
26+
}));
27+
28+
const mod = await import('../middlewares/analytics.requireFeatureFlag.js');
29+
requireFeatureFlag = mod.default;
30+
31+
req = {
32+
user: { _id: 'user-123' },
33+
organization: { _id: 'org-456' },
34+
};
35+
36+
res = {
37+
status: jest.fn().mockReturnThis(),
38+
json: jest.fn().mockReturnThis(),
39+
};
40+
41+
next = jest.fn();
42+
});
43+
44+
afterEach(() => {
45+
jest.restoreAllMocks();
46+
});
47+
48+
test('should call next when flag is enabled', async () => {
49+
mockFeatureFlagsService.isEnabled.mockResolvedValue(true);
50+
51+
const middleware = requireFeatureFlag('beta-feature');
52+
await middleware(req, res, next);
53+
54+
expect(next).toHaveBeenCalledWith();
55+
expect(mockFeatureFlagsService.isEnabled).toHaveBeenCalledWith(
56+
'beta-feature',
57+
'user-123',
58+
{ groups: { company: 'org-456' } },
59+
);
60+
});
61+
62+
test('should return 403 when flag is disabled', async () => {
63+
mockFeatureFlagsService.isEnabled.mockResolvedValue(false);
64+
mockFeatureFlagsService.getVariant.mockResolvedValue(false);
65+
66+
const middleware = requireFeatureFlag('beta-feature');
67+
await middleware(req, res, next);
68+
69+
expect(next).not.toHaveBeenCalled();
70+
expect(res.status).toHaveBeenCalledWith(403);
71+
expect(res.json).toHaveBeenCalledWith(
72+
expect.objectContaining({
73+
type: 'error',
74+
message: 'Forbidden',
75+
}),
76+
);
77+
});
78+
79+
test('should call next when analytics is not configured (fail-open)', async () => {
80+
mockFeatureFlagsService.isEnabled.mockResolvedValue(false);
81+
mockFeatureFlagsService.getVariant.mockResolvedValue(undefined);
82+
83+
const middleware = requireFeatureFlag('beta-feature');
84+
await middleware(req, res, next);
85+
86+
expect(next).toHaveBeenCalledWith();
87+
});
88+
89+
test('should return 401 when user is not authenticated', async () => {
90+
req.user = undefined;
91+
92+
const middleware = requireFeatureFlag('beta-feature');
93+
await middleware(req, res, next);
94+
95+
expect(next).not.toHaveBeenCalled();
96+
expect(res.status).toHaveBeenCalledWith(401);
97+
});
98+
99+
test('should not include groups when organization is absent', async () => {
100+
req.organization = undefined;
101+
mockFeatureFlagsService.isEnabled.mockResolvedValue(true);
102+
103+
const middleware = requireFeatureFlag('beta-feature');
104+
await middleware(req, res, next);
105+
106+
expect(mockFeatureFlagsService.isEnabled).toHaveBeenCalledWith(
107+
'beta-feature',
108+
'user-123',
109+
{},
110+
);
111+
expect(next).toHaveBeenCalledWith();
112+
});
113+
114+
test('should call next(err) on unexpected errors', async () => {
115+
const error = new Error('PostHog timeout');
116+
mockFeatureFlagsService.isEnabled.mockRejectedValue(error);
117+
118+
const middleware = requireFeatureFlag('beta-feature');
119+
await middleware(req, res, next);
120+
121+
expect(next).toHaveBeenCalledWith(error);
122+
});
123+
});

0 commit comments

Comments
 (0)