Skip to content

Commit f906f4e

Browse files
committed
Add user-scoped feature flags
Allow named feature previews to be enabled for selected WCA users while global production flags remain off. Use the gate for solve history so user 8184 can access the preview without exposing it to other users.
1 parent 27c3c7a commit f906f4e

7 files changed

Lines changed: 97 additions & 10 deletions

File tree

.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ ROOM_RECONNECT_GRACE_MS=60000
88
GRAND_PRIX_ENABLED=false
99
# Friend System routes remain disabled through the #188 launch gate.
1010
SOCIAL_FEATURES_ENABLED=false
11+
# Comma-separated WCA user IDs approved for the solve-history preview.
12+
FEATURE_SOLVE_HISTORY_USER_IDS=
1113
# Manual Compose commands use this tag. scripts/deploy.sh overrides it with
1214
# the full deployed commit SHA.
1315
APP_IMAGE_TAG=local

compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ x-app-environment: &app-environment
77
ROOM_RECONNECT_GRACE_MS: ${ROOM_RECONNECT_GRACE_MS:-60000}
88
GRAND_PRIX_ENABLED: ${GRAND_PRIX_ENABLED:-false}
99
SOCIAL_FEATURES_ENABLED: ${SOCIAL_FEATURES_ENABLED:-false}
10+
FEATURE_SOLVE_HISTORY_USER_IDS: ${FEATURE_SOLVE_HISTORY_USER_IDS:-}
1011
METRICS_ENABLED: ${METRICS_ENABLED:-true}
1112
METRICS_RETENTION_DAYS: ${METRICS_RETENTION_DAYS:-90}
1213
METRICS_HASH_SECRET: ${METRICS_HASH_SECRET:-}

docs/development.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,11 @@ environment variables. Important groups are documented in `.env.example`:
8181
- CORS origins and metric retention; and
8282
- production TLS and backup paths.
8383

84+
An otherwise disabled feature that opts into user targeting can be scoped to selected WCA users with
85+
`FEATURE_<FLAG>_USER_IDS`, a comma-separated allowlist of numeric user IDs.
86+
For example, `FEATURE_SOLVE_HISTORY_USER_IDS=8184` enables the solve-history
87+
preview only for that signed-in user while its global production flag remains off.
88+
8489
`.env.example` is a template, not a file automatically sourced by host Node
8590
processes. Compose only passes variables referenced in its service environment;
8691
export any additional server override explicitly or add it to the relevant

server/api/solveHistory.js

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
const express = require('express');
22

3-
const { isFeatureEnabled } = require('../features');
3+
const { isFeatureEnabledForUser } = require('../features');
44
const { recordSolveHistoryRequest } = require('../metrics');
55
const { query } = require('../postgres');
66
const { stableId } = require('../postgres/dualWrite');
@@ -111,7 +111,7 @@ const solveResponse = (row) => ({
111111
});
112112

113113
const createSolveHistoryRouter = ({
114-
enabled = isFeatureEnabled('solveHistory'),
114+
enabled = (user) => isFeatureEnabledForUser('solveHistory', user && user.id),
115115
postgresQuery = query,
116116
recordMetric = recordSolveHistoryRequest,
117117
now = () => Date.now(),
@@ -123,7 +123,8 @@ const createSolveHistoryRouter = ({
123123
let outcome = 'error';
124124
let count = 0;
125125
try {
126-
if (!enabled) {
126+
const featureEnabled = typeof enabled === 'function' ? enabled(req.user) : enabled;
127+
if (!featureEnabled) {
127128
outcome = 'feature_disabled';
128129
return res.status(404).json({
129130
code: 'feature_disabled',

server/api/solveHistory.test.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,21 @@ describe('solve history API', () => {
2727
expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ code: 'feature_disabled' }));
2828
});
2929

30+
it('allows only the configured user when the feature is user-scoped', async () => {
31+
const postgresQuery = jest.fn().mockResolvedValue({ rows: [] });
32+
const enabled = (user) => user && user.id === 8184;
33+
34+
const allowed = response();
35+
await handlerFor({ enabled, postgresQuery })({ query: {}, user: { id: 8184 } }, allowed);
36+
expect(postgresQuery).toHaveBeenCalledTimes(1);
37+
expect(allowed.json).toHaveBeenCalledWith({ solves: [], nextCursor: null });
38+
39+
const blocked = response();
40+
await handlerFor({ enabled, postgresQuery })({ query: {}, user: { id: 8185 } }, blocked);
41+
expect(blocked.status).toHaveBeenCalledWith(404);
42+
expect(postgresQuery).toHaveBeenCalledTimes(1);
43+
});
44+
3045
it('returns only session-linked authorized PostgreSQL history with stable cursors', async () => {
3146
const postgresQuery = jest.fn().mockResolvedValue({ rows: [{
3247
solve_id: '4d8ce8c4-8910-47e9-b39f-79e1280c1e3a',

server/features.js

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,33 @@ const FEATURES = Object.freeze({
55

66
const isFeatureEnabled = (feature) => FEATURES[feature] === true;
77

8+
const userFeatureEnvironmentKey = (feature) => `FEATURE_${feature
9+
.replace(/([a-z])([A-Z])/g, '$1_$2')
10+
.toUpperCase()}_USER_IDS`;
11+
12+
const parseUserIds = (value) => new Set((value || '')
13+
.split(',')
14+
.map((userId) => userId.trim())
15+
.filter((userId) => /^[1-9]\d*$/.test(userId)));
16+
17+
const USER_FEATURES = Object.freeze(Object.fromEntries(
18+
Object.keys(FEATURES).map((feature) => [
19+
feature,
20+
parseUserIds(process.env[userFeatureEnvironmentKey(feature)]),
21+
]),
22+
));
23+
24+
const isFeatureEnabledForUser = (feature, userId) => (
25+
isFeatureEnabled(feature)
26+
|| (typeof userId === 'number' && Number.isSafeInteger(userId) && userId > 0
27+
? USER_FEATURES[feature]?.has(String(userId)) === true
28+
: typeof userId === 'string' && /^[1-9]\d*$/.test(userId)
29+
? USER_FEATURES[feature]?.has(userId) === true
30+
: false)
31+
);
32+
833
module.exports = {
934
FEATURES,
1035
isFeatureEnabled,
36+
isFeatureEnabledForUser,
1137
};

server/features.test.js

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,68 @@
11
/* eslint-env jest */
22

33
const originalNodeEnv = process.env.NODE_ENV;
4+
const originalSolveHistoryUserIds = process.env.FEATURE_SOLVE_HISTORY_USER_IDS;
5+
const originalFriendsUserIds = process.env.FEATURE_FRIENDS_USER_IDS;
46

5-
const loadFeatures = (nodeEnv) => {
7+
const loadFeatures = ({ nodeEnv, solveHistoryUserIds, friendsUserIds }) => {
68
jest.resetModules();
79
process.env.NODE_ENV = nodeEnv;
10+
if (solveHistoryUserIds === undefined) {
11+
delete process.env.FEATURE_SOLVE_HISTORY_USER_IDS;
12+
} else {
13+
process.env.FEATURE_SOLVE_HISTORY_USER_IDS = solveHistoryUserIds;
14+
}
15+
if (friendsUserIds === undefined) {
16+
delete process.env.FEATURE_FRIENDS_USER_IDS;
17+
} else {
18+
process.env.FEATURE_FRIENDS_USER_IDS = friendsUserIds;
19+
}
820
// eslint-disable-next-line global-require
921
return require('./features');
1022
};
1123

1224
afterEach(() => {
1325
process.env.NODE_ENV = originalNodeEnv;
26+
if (originalSolveHistoryUserIds === undefined) {
27+
delete process.env.FEATURE_SOLVE_HISTORY_USER_IDS;
28+
} else {
29+
process.env.FEATURE_SOLVE_HISTORY_USER_IDS = originalSolveHistoryUserIds;
30+
}
31+
if (originalFriendsUserIds === undefined) {
32+
delete process.env.FEATURE_FRIENDS_USER_IDS;
33+
} else {
34+
process.env.FEATURE_FRIENDS_USER_IDS = originalFriendsUserIds;
35+
}
1436
});
1537

1638
describe('feature flags', () => {
1739
it('keeps friends out of production', () => {
18-
expect(loadFeatures('production').isFeatureEnabled('friends')).toBe(false);
40+
expect(loadFeatures({ nodeEnv: 'production' }).isFeatureEnabled('friends')).toBe(false);
1941
});
2042

2143
it('keeps friends available to local development', () => {
22-
expect(loadFeatures('development').isFeatureEnabled('friends')).toBe(true);
44+
expect(loadFeatures({ nodeEnv: 'development' }).isFeatureEnabled('friends')).toBe(true);
2345
});
2446

2547
it('keeps solve history development-only', () => {
26-
expect(loadFeatures('development').isFeatureEnabled('solveHistory')).toBe(true);
27-
expect(loadFeatures('dev').isFeatureEnabled('solveHistory')).toBe(true);
28-
expect(loadFeatures('production').isFeatureEnabled('solveHistory')).toBe(false);
29-
expect(loadFeatures('prod').isFeatureEnabled('solveHistory')).toBe(false);
48+
expect(loadFeatures({ nodeEnv: 'development' }).isFeatureEnabled('solveHistory')).toBe(true);
49+
expect(loadFeatures({ nodeEnv: 'dev' }).isFeatureEnabled('solveHistory')).toBe(true);
50+
expect(loadFeatures({ nodeEnv: 'production' }).isFeatureEnabled('solveHistory')).toBe(false);
51+
expect(loadFeatures({ nodeEnv: 'prod' }).isFeatureEnabled('solveHistory')).toBe(false);
52+
});
53+
54+
it('can enable a disabled feature for selected WCA users', () => {
55+
const features = loadFeatures({
56+
nodeEnv: 'prod',
57+
solveHistoryUserIds: '8184, 42, invalid, 0',
58+
friendsUserIds: '8184',
59+
});
60+
61+
expect(features.isFeatureEnabled('solveHistory')).toBe(false);
62+
expect(features.isFeatureEnabledForUser('solveHistory', 8184)).toBe(true);
63+
expect(features.isFeatureEnabledForUser('solveHistory', '42')).toBe(true);
64+
expect(features.isFeatureEnabledForUser('solveHistory', 8185)).toBe(false);
65+
expect(features.isFeatureEnabledForUser('friends', 8184)).toBe(true);
66+
expect(features.isFeatureEnabledForUser('unknown', 8184)).toBe(false);
3067
});
3168
});

0 commit comments

Comments
 (0)