@terreno/feature-flags 0.19.0 → 0.20.0

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.
@@ -1,7 +1,9 @@
1
+ import {OpenFeature} from "@openfeature/server-sdk";
1
2
  import {
2
3
  APIError,
3
4
  asyncHandler,
4
5
  authenticateMiddleware,
6
+ logger,
5
7
  type ModelRouterOptions,
6
8
  modelRouter,
7
9
  type OpenApiMiddleware,
@@ -10,9 +12,31 @@ import {
10
12
  } from "@terreno/api";
11
13
  import type express from "express";
12
14
  import type {Model} from "mongoose";
15
+ import mongoose from "mongoose";
13
16
  import {evaluateAllFlags} from "./evaluate";
14
17
  import {FeatureFlag} from "./featureFlagModel";
15
- import type {FeatureFlagDocument, FeatureFlagsOptions, SegmentFunction} from "./types";
18
+ import {buildFlagDefinition} from "./flagConfiguration";
19
+ import {MongoFeatureFlagProvider} from "./openFeatureProvider";
20
+ import type {
21
+ FeatureFlagDocument,
22
+ FeatureFlagsLiveUpdatesOptions,
23
+ FeatureFlagsOptions,
24
+ FlagConfigurationResponse,
25
+ FlagDefinition,
26
+ SegmentFunction,
27
+ } from "./types";
28
+
29
+ let evaluateDeprecationWarned = false;
30
+
31
+ const resolveSocketServer = (
32
+ live: FeatureFlagsLiveUpdatesOptions
33
+ ): import("./types").FeatureFlagsSocketEmitter | null => {
34
+ const server = live.socketIoServer;
35
+ if (typeof server === "function") {
36
+ return server();
37
+ }
38
+ return server;
39
+ };
16
40
 
17
41
  /**
18
42
  * TerrenoPlugin that provides feature flags and A/B testing.
@@ -35,6 +59,7 @@ import type {FeatureFlagDocument, FeatureFlagsOptions, SegmentFunction} from "./
35
59
  export class FeatureFlagsApp implements TerrenoPlugin {
36
60
  private options: FeatureFlagsOptions;
37
61
  private segments: Record<string, SegmentFunction>;
62
+ private mongoProvider?: MongoFeatureFlagProvider;
38
63
 
39
64
  constructor(options?: FeatureFlagsOptions) {
40
65
  this.options = options ?? {};
@@ -43,6 +68,8 @@ export class FeatureFlagsApp implements TerrenoPlugin {
43
68
 
44
69
  register(app: express.Application, openApi?: unknown): void {
45
70
  const basePath = this.options.basePath ?? "/feature-flags";
71
+ const openFeatureDomain = this.options.openFeatureDomain ?? "feature-flags";
72
+
46
73
  const routerOptions: ModelRouterOptions<FeatureFlagDocument> = {
47
74
  ...(openApi ? {openApi: openApi as OpenApiMiddleware} : {}),
48
75
  permissions: this.options.permissions ?? {
@@ -55,17 +82,54 @@ export class FeatureFlagsApp implements TerrenoPlugin {
55
82
  sort: "-created",
56
83
  };
57
84
 
58
- // Admin CRUD routes for flags
85
+ this.mongoProvider = new MongoFeatureFlagProvider({
86
+ flagModel: FeatureFlag,
87
+ segments: this.segments,
88
+ });
89
+ OpenFeature.setProvider(openFeatureDomain, this.mongoProvider);
90
+
59
91
  app.use(
60
92
  `${basePath}/flags`,
61
93
  modelRouter(FeatureFlag as Model<FeatureFlagDocument>, routerOptions)
62
94
  );
63
95
 
64
- // GET /feature-flags/evaluate — evaluate all flags for current user
96
+ app.get(
97
+ `${basePath}/flagConfiguration`,
98
+ authenticateMiddleware(),
99
+ asyncHandler(async (req: express.Request, res: express.Response) => {
100
+ const user = req.user as {_id?: unknown; id?: string} | undefined;
101
+ if (!user) {
102
+ throw new APIError({status: 401, title: "Authentication required"});
103
+ }
104
+
105
+ const targetingKey = String(user._id ?? user.id);
106
+ const flags = await FeatureFlag.find({archived: {$ne: true}, enabled: true});
107
+ const config: Record<string, FlagDefinition> = {};
108
+
109
+ for (const flag of flags) {
110
+ config[flag.key] = buildFlagDefinition(flag, targetingKey, user, this.segments);
111
+ }
112
+
113
+ const body: FlagConfigurationResponse = {data: config};
114
+ return res.json(body);
115
+ })
116
+ );
117
+
65
118
  app.get(
66
119
  `${basePath}/evaluate`,
67
120
  authenticateMiddleware(),
68
121
  asyncHandler(async (req: express.Request, res: express.Response) => {
122
+ if (!evaluateDeprecationWarned) {
123
+ evaluateDeprecationWarned = true;
124
+ logger.warn(
125
+ "[feature-flags] GET /evaluate is deprecated; migrate clients to GET /flagConfiguration or use @terreno/rtk useFeatureFlags / useTerrenoFeatureFlags."
126
+ );
127
+ }
128
+
129
+ const sunset = new Date(Date.now() + 90 * 86400000).toUTCString();
130
+ res.setHeader("Deprecation", "true");
131
+ res.setHeader("Sunset", sunset);
132
+
69
133
  const user = req.user as {_id?: unknown; id?: string} | undefined;
70
134
  if (!user) {
71
135
  throw new APIError({status: 401, title: "Authentication required"});
@@ -78,7 +142,6 @@ export class FeatureFlagsApp implements TerrenoPlugin {
78
142
  })
79
143
  );
80
144
 
81
- // GET /feature-flags/segments — list registered segment names (admin only)
82
145
  app.get(
83
146
  `${basePath}/segments`,
84
147
  authenticateMiddleware(),
@@ -94,5 +157,62 @@ export class FeatureFlagsApp implements TerrenoPlugin {
94
157
  return res.json({data: Object.keys(this.segments)});
95
158
  })
96
159
  );
160
+
161
+ const live = this.options.liveUpdates;
162
+ if (live && this.mongoProvider) {
163
+ const eventName = live.eventName ?? "featureFlagsChanged";
164
+ let stream: {
165
+ close: () => Promise<void> | void;
166
+ on: (event: string, handler: (...args: unknown[]) => void) => unknown;
167
+ } | null = null;
168
+ let didRetry = false;
169
+
170
+ const cleanupStream = (): void => {
171
+ try {
172
+ stream?.close();
173
+ } catch {
174
+ /* ignore close errors */
175
+ }
176
+ stream = null;
177
+ };
178
+
179
+ const bindStream = (): void => {
180
+ try {
181
+ stream = FeatureFlag.watch([], {fullDocument: "updateLookup"});
182
+ } catch (err) {
183
+ logger.warn(
184
+ `[feature-flags] FeatureFlag.watch() failed — live updates require MongoDB as a replica set (even single-node). ${String(err)}`
185
+ );
186
+ return;
187
+ }
188
+
189
+ stream.on("change", (change) => {
190
+ const key = (change as {fullDocument?: {key?: string}}).fullDocument?.key;
191
+ const ioResolved = resolveSocketServer(live);
192
+ if (ioResolved) {
193
+ ioResolved.emit(eventName, {key});
194
+ }
195
+ this.mongoProvider?.emitConfigurationChanged();
196
+ });
197
+
198
+ stream.on("error", (err) => {
199
+ logger.warn(`[feature-flags] change stream error: ${String(err)}`);
200
+ cleanupStream();
201
+ if (!didRetry) {
202
+ didRetry = true;
203
+ bindStream();
204
+ return;
205
+ }
206
+ logger.warn("[feature-flags] change stream disabled after repeated errors");
207
+ });
208
+ };
209
+
210
+ bindStream();
211
+
212
+ const onConnectionClose = (): void => {
213
+ cleanupStream();
214
+ };
215
+ mongoose.connection.on("close", onConnectionClose);
216
+ }
97
217
  }
98
218
  }
@@ -0,0 +1,57 @@
1
+ import {evaluateFlag} from "./evaluate";
2
+ import type {FeatureFlagDocument, FlagDefinition, SegmentFunction} from "./types";
3
+
4
+ /**
5
+ * Resolves the OpenFeature defaultVariant for a flag document, including legacy
6
+ * documents that omit the field.
7
+ */
8
+ export const effectiveDefaultVariantForFlag = (flag: FeatureFlagDocument): string => {
9
+ if (flag.defaultVariant) {
10
+ return flag.defaultVariant;
11
+ }
12
+ if (flag.type === "boolean") {
13
+ return "off";
14
+ }
15
+ return flag.variants[0]?.key ?? "off";
16
+ };
17
+
18
+ /**
19
+ * Builds a {@link FlagDefinition} for the `/flagConfiguration` wire shape from a
20
+ * persisted flag and the resolved evaluation for one user.
21
+ */
22
+ export const buildFlagDefinition = (
23
+ flag: FeatureFlagDocument,
24
+ targetingKey: string,
25
+ user: unknown,
26
+ segments: Record<string, SegmentFunction>
27
+ ): FlagDefinition => {
28
+ const evaluated = evaluateFlag(flag, targetingKey, user, segments);
29
+
30
+ if (flag.type === "boolean") {
31
+ const active = evaluated === true;
32
+ return {
33
+ defaultVariant: active ? "on" : "off",
34
+ disabled: false,
35
+ variants: {off: false, on: true},
36
+ };
37
+ }
38
+
39
+ const variantKeys = flag.variants.map((v) => v.key);
40
+ let resolved: string;
41
+ if (typeof evaluated === "string" && variantKeys.includes(evaluated)) {
42
+ resolved = evaluated;
43
+ } else {
44
+ resolved = effectiveDefaultVariantForFlag(flag);
45
+ }
46
+
47
+ const variants: Record<string, string> = {};
48
+ for (const k of variantKeys) {
49
+ variants[k] = k;
50
+ }
51
+
52
+ return {
53
+ defaultVariant: resolved,
54
+ disabled: false,
55
+ variants,
56
+ };
57
+ };
package/src/index.ts CHANGED
@@ -1,13 +1,19 @@
1
1
  export {deterministicHash, evaluateAllFlags, evaluateFlag} from "./evaluate";
2
2
  export {FeatureFlag, featureFlagAdminConfig} from "./featureFlagModel";
3
3
  export {FeatureFlagsApp} from "./featureFlagsApp";
4
+ export {buildFlagDefinition, effectiveDefaultVariantForFlag} from "./flagConfiguration";
5
+ export {MongoFeatureFlagProvider} from "./openFeatureProvider";
4
6
  export type {
5
7
  EvaluationResult,
6
8
  FeatureFlagDocument,
7
9
  FeatureFlagModel,
8
10
  FeatureFlagRule,
11
+ FeatureFlagsLiveUpdatesOptions,
9
12
  FeatureFlagsOptions,
13
+ FeatureFlagsSocketEmitter,
10
14
  FeatureFlagType,
11
15
  FeatureFlagVariant,
16
+ FlagConfigurationResponse,
17
+ FlagDefinition,
12
18
  SegmentFunction,
13
19
  } from "./types";
@@ -0,0 +1,141 @@
1
+ import {
2
+ ErrorCode,
3
+ type EvaluationContext,
4
+ type JsonValue,
5
+ type Logger,
6
+ OpenFeatureEventEmitter,
7
+ type Provider,
8
+ type ResolutionDetails,
9
+ ServerProviderEvents,
10
+ StandardResolutionReasons,
11
+ } from "@openfeature/server-sdk";
12
+ import {evaluateFlag} from "./evaluate";
13
+ import type {FeatureFlagModel, SegmentFunction} from "./types";
14
+
15
+ export interface MongoFeatureFlagProviderOptions {
16
+ flagModel: FeatureFlagModel;
17
+ segments?: Record<string, SegmentFunction>;
18
+ }
19
+
20
+ export class MongoFeatureFlagProvider implements Provider {
21
+ readonly metadata = {name: "MongoFeatureFlagProvider"} as const;
22
+ readonly runsOn = "server" as const;
23
+ readonly events = new OpenFeatureEventEmitter();
24
+
25
+ private readonly flagModel: FeatureFlagModel;
26
+ private readonly segments: Record<string, SegmentFunction>;
27
+
28
+ constructor(options: MongoFeatureFlagProviderOptions) {
29
+ this.flagModel = options.flagModel;
30
+ this.segments = options.segments ?? {};
31
+ }
32
+
33
+ async resolveBooleanEvaluation(
34
+ flagKey: string,
35
+ defaultValue: boolean,
36
+ context: EvaluationContext,
37
+ _logger: Logger
38
+ ): Promise<ResolutionDetails<boolean>> {
39
+ return this.resolve<boolean>(flagKey, defaultValue, context, "boolean", _logger);
40
+ }
41
+
42
+ async resolveStringEvaluation(
43
+ flagKey: string,
44
+ defaultValue: string,
45
+ context: EvaluationContext,
46
+ _logger: Logger
47
+ ): Promise<ResolutionDetails<string>> {
48
+ return this.resolve<string>(flagKey, defaultValue, context, "variant", _logger);
49
+ }
50
+
51
+ async resolveNumberEvaluation(
52
+ _flagKey: string,
53
+ defaultValue: number,
54
+ _context: EvaluationContext,
55
+ _logger: Logger
56
+ ): Promise<ResolutionDetails<number>> {
57
+ return {
58
+ errorCode: ErrorCode.FLAG_NOT_FOUND,
59
+ reason: StandardResolutionReasons.ERROR,
60
+ value: defaultValue,
61
+ };
62
+ }
63
+
64
+ async resolveObjectEvaluation<T extends JsonValue>(
65
+ _flagKey: string,
66
+ defaultValue: T,
67
+ _context: EvaluationContext,
68
+ _logger: Logger
69
+ ): Promise<ResolutionDetails<T>> {
70
+ return {
71
+ errorCode: ErrorCode.FLAG_NOT_FOUND,
72
+ reason: StandardResolutionReasons.ERROR,
73
+ value: defaultValue,
74
+ };
75
+ }
76
+
77
+ emitConfigurationChanged(): void {
78
+ this.events.emit(ServerProviderEvents.ConfigurationChanged, {flagsChanged: []});
79
+ }
80
+
81
+ private async resolve<T extends boolean | string>(
82
+ flagKey: string,
83
+ defaultValue: T,
84
+ context: EvaluationContext,
85
+ expectedType: "boolean" | "variant",
86
+ _logger: Logger
87
+ ): Promise<ResolutionDetails<T>> {
88
+ const flag = await this.flagModel.findOneOrNone({archived: {$ne: true}, key: flagKey});
89
+ if (!flag) {
90
+ return {
91
+ errorCode: ErrorCode.FLAG_NOT_FOUND,
92
+ reason: StandardResolutionReasons.ERROR,
93
+ value: defaultValue,
94
+ };
95
+ }
96
+
97
+ if (flag.type !== expectedType) {
98
+ return {
99
+ errorCode: ErrorCode.TYPE_MISMATCH,
100
+ reason: StandardResolutionReasons.ERROR,
101
+ value: defaultValue,
102
+ };
103
+ }
104
+
105
+ if (!flag.enabled) {
106
+ return {
107
+ reason: StandardResolutionReasons.DISABLED,
108
+ value: defaultValue,
109
+ variant: flag.defaultVariant,
110
+ };
111
+ }
112
+
113
+ const targetingKey = context.targetingKey ?? "";
114
+ const userFromContext = (context as {user?: unknown}).user;
115
+ const user = userFromContext !== undefined ? userFromContext : context;
116
+ const result = evaluateFlag(flag, targetingKey, user, this.segments);
117
+
118
+ if (expectedType === "boolean") {
119
+ const value = result as boolean as T;
120
+ return {
121
+ reason: StandardResolutionReasons.TARGETING_MATCH,
122
+ value,
123
+ variant: value ? "on" : "off",
124
+ };
125
+ }
126
+
127
+ if (result === null) {
128
+ return {
129
+ reason: StandardResolutionReasons.DISABLED,
130
+ value: defaultValue,
131
+ variant: flag.defaultVariant,
132
+ };
133
+ }
134
+
135
+ return {
136
+ reason: StandardResolutionReasons.TARGETING_MATCH,
137
+ value: result as T,
138
+ variant: result as string,
139
+ };
140
+ }
141
+ }
@@ -202,6 +202,51 @@ describe("FeatureFlag model", () => {
202
202
  const maybe = await FeatureFlag.findOneOrNone({key: "missing-flag"});
203
203
  expect(maybe).toBeNull();
204
204
  });
205
+
206
+ it("auto-fills defaultVariant to off for boolean flags when omitted", async () => {
207
+ const flag = await FeatureFlag.create({
208
+ key: "default-variant-bool",
209
+ name: "Default variant bool",
210
+ });
211
+ expect(flag.defaultVariant).toBe("off");
212
+ });
213
+
214
+ it("auto-fills defaultVariant to the first variant key when omitted on variant flags", async () => {
215
+ const flag = await FeatureFlag.create({
216
+ key: "default-variant-var",
217
+ name: "Default variant var",
218
+ type: "variant",
219
+ variants: [
220
+ {key: "control", weight: 40},
221
+ {key: "treatment", weight: 60},
222
+ ],
223
+ });
224
+ expect(flag.defaultVariant).toBe("control");
225
+ });
226
+
227
+ it("accepts explicit defaultVariant on for boolean flags", async () => {
228
+ const flag = await FeatureFlag.create({
229
+ defaultVariant: "on",
230
+ enabled: true,
231
+ key: "explicit-on-bool",
232
+ name: "Explicit on",
233
+ rolloutPercentage: 100,
234
+ type: "boolean",
235
+ });
236
+ expect(flag.defaultVariant).toBe("on");
237
+ });
238
+
239
+ it("rejects boolean defaultVariant values other than on or off", async () => {
240
+ await expect(
241
+ FeatureFlag.create({
242
+ defaultVariant: "maybe",
243
+ enabled: true,
244
+ key: "bad-default-variant",
245
+ name: "Bad default",
246
+ type: "boolean",
247
+ })
248
+ ).rejects.toMatchObject({title: "Boolean flags must use defaultVariant 'on' or 'off'"});
249
+ });
205
250
  });
206
251
 
207
252
  describe("featureFlagAdminConfig", () => {
@@ -215,6 +260,7 @@ describe("featureFlagAdminConfig", () => {
215
260
  "type",
216
261
  "enabled",
217
262
  "archived",
263
+ "defaultVariant",
218
264
  "created",
219
265
  ]);
220
266
  });
@@ -0,0 +1,114 @@
1
+ import {afterEach, beforeEach, describe, expect, it, mock, spyOn} from "bun:test";
2
+ import EventEmitter from "node:events";
3
+ import {OpenFeature} from "@openfeature/server-sdk";
4
+ import {
5
+ addAuthRoutes,
6
+ apiErrorMiddleware,
7
+ apiUnauthorizedMiddleware,
8
+ logger,
9
+ setupAuth,
10
+ type UserModel as UserModelType,
11
+ } from "@terreno/api";
12
+ import {getBaseServer, setupDb, UserModel} from "@terreno/api/src/tests";
13
+ import type express from "express";
14
+
15
+ import {FeatureFlag} from "../featureFlagModel";
16
+ import {FeatureFlagsApp} from "../featureFlagsApp";
17
+
18
+ class FakeChangeStream extends EventEmitter {
19
+ public close = mock((): Promise<void> => Promise.resolve());
20
+ }
21
+
22
+ const buildAppWithLive = (options: {
23
+ eventName?: string;
24
+ io: {emit: ReturnType<typeof mock>};
25
+ }): express.Application => {
26
+ const app = getBaseServer();
27
+ setupAuth(app, UserModel as unknown as UserModelType);
28
+ addAuthRoutes(app, UserModel as unknown as UserModelType);
29
+
30
+ const plugin = new FeatureFlagsApp({
31
+ liveUpdates: {
32
+ eventName: options.eventName,
33
+ socketIoServer: () => options.io,
34
+ },
35
+ });
36
+ plugin.register(app);
37
+
38
+ app.use(apiUnauthorizedMiddleware);
39
+ app.use(apiErrorMiddleware);
40
+ return app;
41
+ };
42
+
43
+ describe("FeatureFlagsApp live updates", () => {
44
+ let watchSpy: ReturnType<typeof spyOn>;
45
+
46
+ beforeEach(async () => {
47
+ await setupDb();
48
+ await FeatureFlag.deleteMany({});
49
+ });
50
+
51
+ afterEach(async () => {
52
+ await FeatureFlag.deleteMany({});
53
+ watchSpy?.mockRestore();
54
+ await OpenFeature.clearProviders();
55
+ });
56
+
57
+ it("emits the default socket event and payload on change stream events", async () => {
58
+ const stream = new FakeChangeStream();
59
+ watchSpy = spyOn(FeatureFlag, "watch").mockReturnValue(stream as never);
60
+
61
+ const io = {emit: mock(() => {})};
62
+ buildAppWithLive({io});
63
+
64
+ stream.emit("change", {fullDocument: {key: "todo-summary-card"}});
65
+
66
+ expect(io.emit).toHaveBeenCalledWith("featureFlagsChanged", {key: "todo-summary-card"});
67
+ });
68
+
69
+ it("respects a custom liveUpdates.eventName", async () => {
70
+ const stream = new FakeChangeStream();
71
+ watchSpy = spyOn(FeatureFlag, "watch").mockReturnValue(stream as never);
72
+
73
+ const io = {emit: mock(() => {})};
74
+ buildAppWithLive({eventName: "flagsUpdated", io});
75
+
76
+ stream.emit("change", {fullDocument: {key: "k"}});
77
+
78
+ expect(io.emit).toHaveBeenCalledWith("flagsUpdated", {key: "k"});
79
+ });
80
+
81
+ it("logs a warn on stream error, retries once, then disables after a second error", async () => {
82
+ const warnSpy = spyOn(logger, "warn").mockImplementation(() => {});
83
+
84
+ const stream1 = new FakeChangeStream();
85
+ const stream2 = new FakeChangeStream();
86
+ let call = 0;
87
+ watchSpy = spyOn(FeatureFlag, "watch").mockImplementation(() => {
88
+ call += 1;
89
+ if (call === 1) {
90
+ return stream1 as never;
91
+ }
92
+ return stream2 as never;
93
+ });
94
+
95
+ const io = {emit: mock(() => {})};
96
+ buildAppWithLive({io});
97
+
98
+ stream1.emit("error", new Error("first"));
99
+ await new Promise<void>((resolve) => {
100
+ setTimeout(resolve, 0);
101
+ });
102
+ stream2.emit("error", new Error("second"));
103
+ await new Promise<void>((resolve) => {
104
+ setTimeout(resolve, 0);
105
+ });
106
+
107
+ const disabledMsg = warnSpy.mock.calls.find(
108
+ (c) => typeof c[0] === "string" && c[0].includes("change stream disabled")
109
+ );
110
+ expect(disabledMsg).toBeDefined();
111
+
112
+ warnSpy.mockRestore();
113
+ });
114
+ });
@@ -1,4 +1,5 @@
1
1
  import {afterEach, beforeEach, describe, expect, it} from "bun:test";
2
+ import {OpenFeature} from "@openfeature/server-sdk";
2
3
  import {
3
4
  addAuthRoutes,
4
5
  apiErrorMiddleware,
@@ -43,6 +44,7 @@ describe("FeatureFlagsApp", () => {
43
44
 
44
45
  afterEach(async () => {
45
46
  await FeatureFlag.deleteMany({});
47
+ await OpenFeature.clearProviders();
46
48
  });
47
49
 
48
50
  describe("construction", () => {
@@ -187,6 +189,105 @@ describe("FeatureFlagsApp", () => {
187
189
  // notAdmin isn't admin, rolloutPercentage is 0 → should evaluate false
188
190
  expect(notAdminRes.body.data["user-only"]).toBe(false);
189
191
  });
192
+
193
+ it("includes Deprecation and Sunset headers on GET /evaluate", async () => {
194
+ await FeatureFlag.create({
195
+ enabled: true,
196
+ key: "hdr-flag",
197
+ name: "Hdr",
198
+ rolloutPercentage: 100,
199
+ });
200
+ adminAgent = await authAsUser(app, "admin");
201
+ const res = await adminAgent.get("/feature-flags/evaluate").expect(200);
202
+ expect(res.headers.deprecation).toBe("true");
203
+ expect(res.headers.sunset).toBeDefined();
204
+ expect(res.body.data["hdr-flag"]).toBe(true);
205
+ });
206
+ });
207
+
208
+ describe("GET /feature-flags/flagConfiguration", () => {
209
+ beforeEach(async () => {
210
+ const segments: Record<string, SegmentFunction> = {
211
+ admins: (user: unknown) => (user as {admin?: boolean}).admin === true,
212
+ };
213
+ app = buildApp({segments});
214
+ });
215
+
216
+ it("returns 401 when unauthenticated", async () => {
217
+ const unauth = supertest(app);
218
+ await unauth.get("/feature-flags/flagConfiguration").expect(401);
219
+ });
220
+
221
+ it("returns OpenFeature-shaped entries for enabled flags and omits disabled and archived", async () => {
222
+ await FeatureFlag.create({
223
+ enabled: true,
224
+ key: "bool-active",
225
+ name: "Bool",
226
+ rolloutPercentage: 100,
227
+ type: "boolean",
228
+ });
229
+ await FeatureFlag.create({
230
+ defaultVariant: "compact",
231
+ enabled: true,
232
+ key: "profile",
233
+ name: "Profile",
234
+ rules: [],
235
+ type: "variant",
236
+ variants: [
237
+ {key: "compact", weight: 50},
238
+ {key: "detailed", weight: 50},
239
+ ],
240
+ });
241
+ await FeatureFlag.create({
242
+ enabled: false,
243
+ key: "disabled-one",
244
+ name: "Off",
245
+ rolloutPercentage: 100,
246
+ type: "boolean",
247
+ });
248
+ await FeatureFlag.create({
249
+ archived: true,
250
+ enabled: true,
251
+ key: "archived-one",
252
+ name: "Archived",
253
+ rolloutPercentage: 100,
254
+ type: "boolean",
255
+ });
256
+
257
+ adminAgent = await authAsUser(app, "admin");
258
+ const res = await adminAgent.get("/feature-flags/flagConfiguration").expect(200);
259
+ const data = res.body.data as Record<
260
+ string,
261
+ {defaultVariant: string; disabled: boolean; variants: Record<string, boolean | string>}
262
+ >;
263
+
264
+ expect(data["bool-active"]).toEqual({
265
+ defaultVariant: "on",
266
+ disabled: false,
267
+ variants: {off: false, on: true},
268
+ });
269
+ expect(data.profile?.disabled).toBe(false);
270
+ expect(["compact", "detailed"].includes(data.profile?.defaultVariant ?? "")).toBe(true);
271
+ expect(data.profile?.variants.compact).toBe("compact");
272
+ expect(data["disabled-one"]).toBeUndefined();
273
+ expect(data["archived-one"]).toBeUndefined();
274
+ });
275
+
276
+ it("uses resolved boolean variant as defaultVariant (not the persisted schema default alone)", async () => {
277
+ await FeatureFlag.create({
278
+ defaultVariant: "off",
279
+ enabled: true,
280
+ key: "resolved-bool",
281
+ name: "Resolved",
282
+ rolloutPercentage: 100,
283
+ rules: [],
284
+ type: "boolean",
285
+ });
286
+ adminAgent = await authAsUser(app, "admin");
287
+ const res = await adminAgent.get("/feature-flags/flagConfiguration").expect(200);
288
+ expect(res.body.data["resolved-bool"].defaultVariant).toBe("on");
289
+ expect(res.body.data["resolved-bool"].variants.on).toBe(true);
290
+ });
190
291
  });
191
292
 
192
293
  describe("GET /feature-flags/segments", () => {